From a8b7261a8470ab5813677ba3e46827d3af58aeeb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 18:39:20 +0200 Subject: [PATCH] feat: add rollLoot and Chests.new --- src/chests.lua | 182 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/chests.lua diff --git a/src/chests.lua b/src/chests.lua new file mode 100644 index 0000000..ee92378 --- /dev/null +++ b/src/chests.lua @@ -0,0 +1,182 @@ +-- Lockable loot containers scattered through the dungeon: an unlocked chest +-- yields its hoard the instant it is opened, a locked one demands either a +-- spent key (Items.KEY, the same key a locked door already consumes) or a +-- lockpick gamble (Items.LOCKPICK) that risks springing the chest's own trap +-- on a failed pick - a second, riskier route into the same treasure rather +-- than a strictly worse one, since a lockpick is commoner floor loot than a +-- key. Only a locked chest can be trapped at all: there is no lock to punish +-- fumbling with on one that was never going to resist in the first place. +-- A chest never moves and never decays; once opened it stays on the map as +-- an emptied husk exactly like a corpse past its last butchering, so a +-- second visit finds nothing left to take. Chests are plain data on +-- level.chests, exactly like level.corpses and level.nodes, so they +-- serialize with no special save-format handling. + +local Items = require("items") +local Affixes = require("affixes") +local Status = require("status") + +local Chests = {} + +Chests.LOCK_CHANCE_NUMERATOR = 1 +Chests.LOCK_CHANCE_DENOMINATOR = 2 + +Chests.TRAP_CHANCE_NUMERATOR = 1 +Chests.TRAP_CHANCE_DENOMINATOR = 3 + +Chests.PICK_BASE_NUMERATOR = 1 +Chests.PICK_BASE_DENOMINATOR = 2 +Chests.PICK_LEVEL_BONUS_DIVISOR = 5 + +Chests.TRAP_DART_MIN = 2 +Chests.TRAP_DART_MAX = 5 + +Chests.GOLD_MIN_BONUS = 0 +Chests.GOLD_MAX_BONUS = 10 +Chests.GOLD_BASE = 10 +Chests.GOLD_PER_DEPTH = 6 + +local chestWeapons = { + { name = "ornate rapier", power = 4 }, + { name = "gilded axe", power = 5 }, +} +local chestArmors = { + { name = "banded mail", power = 3 }, +} + +-- One chest's hoard: always some gold plus a coin flip between a lightly +-- affixed weapon or armor piece (see affixes.lua) and, half the time on top +-- of that, a spare healing potion. Richer on average than a single floor +-- drop (builder.lua's rollItem), since reaching it costs a key or a +-- lockpick gamble rather than a free step onto its tile. +local function rollLoot(rng, depth) + local loot = { Items.gold(Chests.GOLD_BASE + depth * Chests.GOLD_PER_DEPTH + + rng:range(Chests.GOLD_MIN_BONUS, Chests.GOLD_MAX_BONUS)) } + if rng:chance(1, 2) then + local base = chestWeapons[rng:range(1, #chestWeapons)] + loot[#loot + 1] = Affixes.decorate(Items.weapon(base.name, base.power + depth // 4), Affixes.rollWeapon(rng)) + else + local base = chestArmors[rng:range(1, #chestArmors)] + loot[#loot + 1] = Affixes.decorate(Items.armor(base.name, base.power + depth // 4), Affixes.rollArmor(rng)) + end + if rng:chance(1, 2) then + loot[#loot + 1] = Items.potionOf("heal") + end + return loot +end + +-- A freshly rolled chest at (unassigned) x/y, exactly like Nodes.new - +-- builder.lua stamps x/y directly onto the returned table once it finds a +-- free cell for it. Trapped is only ever rolled for a locked chest (see +-- above), so the rng stream is never touched by the trap roll at all on an +-- unlocked one. +function Chests.new(rng, depth) + local locked = rng:chance(Chests.LOCK_CHANCE_NUMERATOR, Chests.LOCK_CHANCE_DENOMINATOR) + local trapped = locked and rng:chance(Chests.TRAP_CHANCE_NUMERATOR, Chests.TRAP_CHANCE_DENOMINATOR) + return { + locked = locked, + trapped = trapped, + opened = false, + loot = rollLoot(rng, depth), + } +end + +-- A chest still worth forcing: locked and not already emptied. An opened +-- chest reports unlocked regardless of its own `locked` flag, since nothing +-- is left inside to gate. +function Chests.isLocked(chest) + return chest.locked and not chest.opened +end + +-- Odds of a lockpick attempt succeeding: a flat coin flip that improves +-- slightly with player level, capped a point shy of the denominator so a +-- pick attempt is never a sure thing no matter how experienced the player +-- gets - mirrors disarm.lua's Disarm.oddsFor. +function Chests.oddsFor(player) + local numerator = Chests.PICK_BASE_NUMERATOR + player.level // Chests.PICK_LEVEL_BONUS_DIVISOR + if numerator > Chests.PICK_BASE_DENOMINATOR - 1 then + numerator = Chests.PICK_BASE_DENOMINATOR - 1 + end + return numerator, Chests.PICK_BASE_DENOMINATOR +end + +-- Pours every item in `chest`'s hoard into `player`'s inventory and marks +-- the chest spent for good. +local function grant(player, chest) + for _, item in ipairs(chest.loot) do + player.inventory[#player.inventory + 1] = item + end + chest.opened = true + chest.loot = {} +end + +-- Opens an already-unlocked (or never-locked) chest outright. Refuses with +-- (false, line) and no state change against one still locked or already +-- emptied - the caller (Game:openChest) routes a locked chest through +-- Chests.unlockWithKey or Chests.pick instead. +function Chests.open(player, chest) + if chest.opened then return false, "this chest is already empty" end + if Chests.isLocked(chest) then return false, "the chest is locked" end + grant(player, chest) + return true, "you open the chest and take its hoard" +end + +-- Spends a key from `player`'s inventory to force the lock outright, no +-- roll involved - the same certain trade a locked door already makes for +-- one of Items.KEY. Returns (false, line) with nothing spent if the player +-- is not carrying one, or against a chest that is not actually locked. +function Chests.unlockWithKey(player, chest) + if not Chests.isLocked(chest) then return false, "this chest has no lock to open" end + for i, item in ipairs(player.inventory) do + if item.kind == Items.KEY then + table.remove(player.inventory, i) + chest.locked = false + grant(player, chest) + return true, "you unlock the chest with your key" + end + end + return false, "you have no key" +end + +-- A trapped chest's punishment for a failed pick: a dart to the ribs or a +-- dose of poison, the same two effects a floor trap can spring (see +-- game.lua's Game:springTrap) but resolved locally since a chest is not a +-- level.traps entry. Spends the trap the instant it springs - a chest only +-- ever bites back once. +function Chests.springTrap(game, chest) + chest.trapped = false + local player = game.player + if game.rng:chance(1, 2) then + local dealt = game.rng:range(Chests.TRAP_DART_MIN, Chests.TRAP_DART_MAX) + player.hp = player.hp - dealt + return string.format("a needle trap jabs you for %d", dealt) + end + Status.apply(player, Status.POISON, 5, 2) + return "a cloud of poison bursts from the lock" +end + +-- Gambles a lockpick against `chest`'s lock. Always spends the pick, win or +-- lose, once one is found. On success the chest opens exactly like +-- Chests.unlockWithKey; on failure a trapped chest springs on the player +-- (Chests.springTrap) while an untrapped one merely wastes the pick, the +-- lock still standing for a later key or another pick. +function Chests.pick(game, chest) + local player = game.player + if not Chests.isLocked(chest) then return false, "this chest has no lock to pick" end + local pickIndex + for i, item in ipairs(player.inventory) do + if item.kind == Items.LOCKPICK then pickIndex = i; break end + end + if not pickIndex then return false, "you have no lockpicks" end + table.remove(player.inventory, pickIndex) + local numerator, denominator = Chests.oddsFor(player) + if not game.rng:chance(numerator, denominator) then + if chest.trapped then return false, Chests.springTrap(game, chest) end + return false, "your lockpick slips and the lock holds" + end + chest.locked = false + grant(player, chest) + return true, "your lockpick clicks the lock open" +end + +return Chests