diff --git a/src/resistances.lua b/src/resistances.lua new file mode 100644 index 0000000..ce0301c --- /dev/null +++ b/src/resistances.lua @@ -0,0 +1,67 @@ +-- Elemental damage typing. Every hit that lands - melee, a bolt, a thrown +-- weapon, a hazard tick, a status effect's damage-over-time - is tagged with +-- one of the types below, and this module decides how much of it actually +-- gets through. A defender's tier for a type comes from up to two places: its +-- own base profile (bestiary data, e.g. a dragon's own hide) and whatever it +-- has equipped (weapon/armor/ring/bow, e.g. a ring of cold resistance). The +-- single best (most protective) tier across those sources wins - immune beats +-- resist beats weak beats the normal default - so a found ring can cancel out +-- an innate vulnerability, but nothing ever stacks two resists into a second +-- discount or two weaknesses into a bigger penalty. + +local Resistances = {} + +Resistances.PHYSICAL = "physical" +Resistances.FIRE = "fire" +Resistances.COLD = "cold" +Resistances.POISON = "poison" +Resistances.LIGHTNING = "lightning" +Resistances.ARCANE = "arcane" + +Resistances.TYPES = { + Resistances.PHYSICAL, Resistances.FIRE, Resistances.COLD, + Resistances.POISON, Resistances.LIGHTNING, Resistances.ARCANE, +} + +Resistances.IMMUNE = "immune" +Resistances.RESIST = "resist" +Resistances.WEAK = "weak" +Resistances.NORMAL = "normal" + +local RANK = { immune = 3, resist = 2, weak = 1, normal = 0 } + +-- Slots checked alongside an entity's own `resist` table, in a fixed order so +-- the result never depends on Lua's unspecified pairs() iteration order. +local EQUIP_SLOTS = { "weapon", "armor", "ring", "bow" } + +local function tierFrom(source, damageType) + return source and source.resist and source.resist[damageType] +end + +-- The single tier that governs `damageType` incoming against `entity`, folding +-- in its own base profile and every equipped slot that carries one. +function Resistances.tierOf(entity, damageType) + local best = tierFrom(entity, damageType) or Resistances.NORMAL + for _, slot in ipairs(EQUIP_SLOTS) do + local tier = tierFrom(entity[slot], damageType) + if tier and RANK[tier] > RANK[best] then + best = tier + end + end + return best +end + +-- Scales a already-computed `amount` of `damageType` damage by `entity`'s +-- tier for that type. Immune drops it to nothing outright rather than merely +-- halving it, so it can floor damage below the "always at least 1" guarantee +-- combat.lua's own minimum otherwise enforces. +function Resistances.apply(entity, amount, damageType) + if amount <= 0 then return amount end + local tier = Resistances.tierOf(entity, damageType or Resistances.PHYSICAL) + if tier == Resistances.IMMUNE then return 0 end + if tier == Resistances.RESIST then return math.floor(amount / 2) end + if tier == Resistances.WEAK then return math.floor(amount * 1.5) end + return amount +end + +return Resistances diff --git a/src/runewords.lua b/src/runewords.lua new file mode 100644 index 0000000..32e91bc --- /dev/null +++ b/src/runewords.lua @@ -0,0 +1,77 @@ +-- Rune stones drop as dungeon loot and slot into a weapon or armor's rune +-- chain in the order they are socketed. A chain that exactly matches a +-- cataloged sequence forms a runeword: a fixed, always-on stat bonus read +-- live by combat.lua (the same additive-bonus pattern skills.lua already +-- uses for berserk/mana shield), rather than baked permanently into the +-- item's own power the way affixes.lua or crafting.lua's gems are - so a +-- runeword needs no equip-time stat sync, and simply stops applying the +-- instant a further rune breaks its exact match. + +local Items = require("items") + +local Runewords = {} + +Runewords.MAX_RUNES = 3 + +Runewords.RUNES = { "sol", "ith", "tal", "ral", "eth", "amn" } + +-- Keyed by the chain's runes joined in slotted order - an exact match only, +-- so a chain longer or shorter than a given word never qualifies for it. +Runewords.WORDS = { + ["sol-ith"] = { name = "Zenith", attack = 3 }, + ["tal-ral"] = { name = "Bastion", defense = 3 }, + ["eth-amn-sol"] = { name = "Undertow", attack = 2, defense = 2 }, +} + +local function wordKey(runes) + return table.concat(runes, "-") +end + +function Runewords.rune(key) + return { kind = Items.RUNE, rune = key, name = key .. " rune", glyph = "1" } +end + +local function slottable(item) + return item.kind == Items.WEAPON or item.kind == Items.ARMOR +end + +-- Appends `rune`'s key to `item`'s chain. Fails once the item is full or is +-- not a weapon/armor; never fails on a rune that will not complete a known +-- word, since an unmatched chain simply confers no bonus rather than being +-- rejected outright - there is no wasted resource to protect against the way +-- crafting.lua guards a gem merge against mismatched pairs. +function Runewords.socket(item, rune) + if not slottable(item) then return false, "cannot hold runes" end + item.runes = item.runes or {} + if #item.runes >= Runewords.MAX_RUNES then return false, "the rune chain is full" end + item.runes[#item.runes + 1] = rune.rune + return true +end + +-- The cataloged word `item`'s current chain forms, or nil if it matches none +-- (including an empty chain, or one that no longer exactly matches any entry +-- once it has grown past a shorter word's own length). +function Runewords.activeWord(item) + if not item or not item.runes or #item.runes == 0 then return nil end + return Runewords.WORDS[wordKey(item.runes)] +end + +function Runewords.attackBonus(entity) + local total = 0 + local weaponWord = entity.weapon and Runewords.activeWord(entity.weapon) + if weaponWord then total = total + (weaponWord.attack or 0) end + local armorWord = entity.armor and Runewords.activeWord(entity.armor) + if armorWord then total = total + (armorWord.attack or 0) end + return total +end + +function Runewords.defenseBonus(entity) + local total = 0 + local weaponWord = entity.weapon and Runewords.activeWord(entity.weapon) + if weaponWord then total = total + (weaponWord.defense or 0) end + local armorWord = entity.armor and Runewords.activeWord(entity.armor) + if armorWord then total = total + (armorWord.defense or 0) end + return total +end + +return Runewords