diff --git a/src/crafting.lua b/src/crafting.lua new file mode 100644 index 0000000..840fd67 --- /dev/null +++ b/src/crafting.lua @@ -0,0 +1,181 @@ +-- Deterministic gear crafting, layered on top of the loot-rolled affix +-- system (affixes.lua). Killing monsters earns essence; essence forges tier-1 +-- gems; two matching gems merge into one gem a tier higher; a gem sockets +-- into gear for a guaranteed on-hit rider alongside a flat power bump; +-- tempering spends essence directly for power at rising cost; disenchanting +-- strips a rolled affix back into essence. Nothing here rolls dice - a build +-- is entirely a function of the essence spent on it, distinct from the luck +-- of what affixes.lua hands out on drops. + +local Items = require("items") +local Status = require("status") +local Affixes = require("affixes") + +local Crafting = {} + +Crafting.FIRE = "fire" +Crafting.FROST = "frost" +Crafting.SHOCK = "shock" +Crafting.VENOM = "venom" + +Crafting.elements = { Crafting.FIRE, Crafting.FROST, Crafting.SHOCK, Crafting.VENOM } + +-- SHOCK's duration is 2, not 1: Status.tick decrements duration before +-- skipsTurn is ever checked (see status.lua), and a hit landed during the +-- player's own turn ticks the victim's status for the first time on that +-- SAME round, in the victim's own upcoming takeMonsterTurn - a duration-1 +-- stun would already read 0 and be gone before skipsTurn ever saw it, +-- silently never stunning anything. 2 survives that first tick with 1 left, +-- which is exactly enough to skip the one turn immediately after the hit. +Crafting.riders = { + [Crafting.FIRE] = { status = Status.BURNING, duration = 3, potency = 2 }, + [Crafting.FROST] = { status = Status.SLOW, duration = 2, potency = 1 }, + [Crafting.SHOCK] = { status = Status.STUN, duration = 2, potency = 1 }, + [Crafting.VENOM] = { status = Status.POISON, duration = 4, potency = 2 }, +} + +Crafting.tierNames = { "chip", "flawed", "perfect" } +Crafting.MAX_TIER = #Crafting.tierNames + +Crafting.FORGE_COST = 6 +Crafting.ENCHANT_COST = 10 +Crafting.TEMPER_MAX = 5 + +local function isElement(element) + return Crafting.riders[element] ~= nil +end + +function Crafting.gem(element, tier) + return { + kind = Items.GEM, + glyph = "*", + element = element, + tier = tier, + power = tier, + name = Crafting.tierNames[tier] .. " " .. element .. " gem", + } +end + +-- A kill's essence scales with the monster's own xp reward, so the crafting +-- economy tracks the game's existing difficulty curve instead of needing a +-- second balance table. +function Crafting.essenceFor(xp) + return math.max(1, xp // 3) +end + +-- Spends essence to forge a fresh tier-1 gem of the given element. +function Crafting.forge(entity, element) + if not isElement(element) then return false, "no such element" end + if entity.essence < Crafting.FORGE_COST then return false, "not enough essence" end + entity.essence = entity.essence - Crafting.FORGE_COST + return true, Crafting.gem(element, 1) +end + +-- Combines two gems of the same element and tier into one gem a tier higher. +-- Mismatched pairs are rejected untouched, so a misclick costs nothing. +function Crafting.mergeGems(gemA, gemB) + if gemA.element ~= gemB.element then return false, "elements do not match" end + if gemA.tier ~= gemB.tier then return false, "tiers do not match" end + if gemA.tier >= Crafting.MAX_TIER then return false, "already at maximum tier" end + return true, nil, Crafting.gem(gemA.element, gemA.tier + 1) +end + +local function socketable(item) + return item.kind == Items.WEAPON or item.kind == Items.ARMOR + or item.kind == Items.BOW or item.kind == Items.RING +end + +-- weapon/armor bake their equipped power straight into attack/defense +-- (see items.equipWeapon/equipArmor), so any crafting action that edits +-- item.power after the fact must apply the same delta to the wielder to +-- avoid a stat that only updates on the next re-equip. +local function applyPowerDelta(entity, item, delta) + if delta == 0 then return end + if entity.weapon == item then + entity.attack = entity.attack + delta + elseif entity.armor == item then + entity.defense = entity.defense + delta + end +end + +-- Attaches a gem's rider and power to a piece of gear, replacing whatever was +-- socketed before - the old gem is pried loose and breaks, it is not +-- returned. Rings and bows take the rider (for ranged/aura play) but only +-- weapon and armor power feeds attack/defense; see socketPowerKind below. +function Crafting.socket(entity, item, gem) + if not socketable(item) then return false, "cannot be socketed" end + if gem.kind ~= Items.GEM then return false, "not a gem" end + local previousPower = item.socket and item.socket.power or 0 + item.socket = { element = gem.element, tier = gem.tier, power = gem.power, onHit = Crafting.riders[gem.element] } + if item.kind == Items.WEAPON or item.kind == Items.ARMOR then + local delta = gem.power - previousPower + item.power = item.power + delta + applyPowerDelta(entity, item, delta) + end + return true +end + +function Crafting.temperCost(item) + return ((item.temper or 0) + 1) * 4 +end + +-- Flat, permanent power increase capped so tempering cannot out-scale simply +-- finding better gear; each step costs more essence than the last. +function Crafting.temper(entity, item) + if item.kind ~= Items.WEAPON and item.kind ~= Items.ARMOR then + return false, "cannot be tempered" + end + local level = item.temper or 0 + if level >= Crafting.TEMPER_MAX then return false, "fully tempered" end + local cost = Crafting.temperCost(item) + if entity.essence < cost then return false, "not enough essence" end + entity.essence = entity.essence - cost + item.temper = level + 1 + item.power = item.power + 1 + applyPowerDelta(entity, item, 1) + return true +end + +local function affixPool(item) + if item.kind == Items.WEAPON then return Affixes.weapon end + if item.kind == Items.ARMOR then return Affixes.armor end + return nil +end + +-- Grants a guaranteed, chosen affix to gear that has none - the deterministic +-- counterpart to affixes.rollWeapon/rollArmor rolling one by chance on drop. +function Crafting.enchant(entity, item, affixKey) + local pool = affixPool(item) + if not pool then return false, "cannot be enchanted" end + if item.affix then return false, "already bears an affix" end + local chosen + for _, entry in ipairs(pool) do + if entry.key == affixKey then chosen = entry end + end + if not chosen then return false, "unknown affix" end + if entity.essence < Crafting.ENCHANT_COST then return false, "not enough essence" end + entity.essence = entity.essence - Crafting.ENCHANT_COST + Affixes.decorate(item, chosen) + applyPowerDelta(entity, item, chosen.power) + return true +end + +-- Strips a rolled affix back into essence, worth more the stronger the affix +-- was, undoing affixes.decorate's power bump and name prefix exactly. +function Crafting.disenchant(entity, item) + if not item.affix then return false, "nothing to disenchant" end + local affix = item.affix + local refund = Crafting.FORGE_COST + affix.power * 3 + applyPowerDelta(entity, item, -affix.power) + item.power = item.power - affix.power + local prefix = affix.name .. " " + if item.name:sub(1, #prefix) == prefix then + item.name = item.name:sub(#prefix + 1) + end + item.affix = nil + item.damageType = nil + entity.essence = entity.essence + refund + return true, refund +end + +return Crafting