feat: add rollLoot and Chests.new

This commit is contained in:
dualfroz committed 2026-09-02 18:39:20 +02:00
1 parent 0e8655e871
commit a8b7261a84
1 file changed
+182
+182
View File
@@ -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