From bdf3624b02f2ed6ffbd08c2da59d35e76b2c000f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 18:29:50 +0200 Subject: [PATCH] feat: add Curses.roll and Curses.afflict --- src/curses.lua | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/curses.lua diff --git a/src/curses.lua b/src/curses.lua new file mode 100644 index 0000000..f323ae1 --- /dev/null +++ b/src/curses.lua @@ -0,0 +1,161 @@ +-- Malevolent gear rolled as the dark mirror of affixes.lua's beneficial +-- prefixes. A cursed weapon, suit of armor, bow or ring looks completely +-- ordinary on the ground and in the pack - its stat penalty is already +-- folded into item.power/item.range at generation time, so nothing +-- downstream needs to know an item is cursed to use its numbers correctly. +-- Putting one on binds it in place: trying to swap it out for something +-- else fails and reveals the curse right there, the same "you find out by +-- trying" moment as a classic roguelike's stuck cursed ring. This is a +-- different curse from shrines.lua's: that one is a temporary status +-- inflicted the instant a prayer backfires, this one travels with a +-- specific item and needs a specific remedy - a scroll of remove curse, or +-- a prayer at a shrine of fortune - to lift. + +local Curses = {} + +Curses.WEAKNESS = "weakness" +Curses.BRITTLE = "brittle" +Curses.FAULTY = "faulty" +Curses.AFFLICTION = "affliction" + +-- The four wearable slots a curse can ever occupy, in a fixed order so +-- "the earliest cursed slot" always means the same thing across callers. +Curses.SLOTS = { "weapon", "armor", "ring", "bow" } + +Curses.pool = { + weapon = { { key = Curses.WEAKNESS, powerPenalty = 2 } }, + armor = { { key = Curses.BRITTLE, powerPenalty = 2 } }, + bow = { { key = Curses.FAULTY, powerPenalty = 1, rangePenalty = 2 } }, + ring = { { key = Curses.AFFLICTION, drain = 1 } }, +} + +Curses.CHANCE_NUMERATOR = 1 +Curses.CHANCE_DENOMINATOR = 8 + +-- Rolls a curse for a freshly generated `slot` item, or nil most of the +-- time. Weapons and armor should only ever be offered here when they did +-- not already land a beneficial affix (see builder.lua's rollItem), so a +-- single drop is never simultaneously a blessing and a curse. +function Curses.roll(rng, slot) + local pool = Curses.pool[slot] + if not pool then return nil end + if not rng:chance(Curses.CHANCE_NUMERATOR, Curses.CHANCE_DENOMINATOR) then return nil end + return pool[rng:range(1, #pool)] +end + +-- Binds `curse` onto `item`, silently folding its stat penalty into the +-- item's own numbers. The curse itself stays hidden (curseRevealed false) +-- until reveal() is called by a discovery path. +function Curses.afflict(item, curse) + item.cursed = true + item.curseKind = curse.key + item.curseRevealed = false + if curse.powerPenalty then + item.cursePenalty = curse.powerPenalty + item.power = math.max(0, item.power - curse.powerPenalty) + end + if curse.rangePenalty then + item.curseRangePenalty = curse.rangePenalty + item.range = math.max(1, item.range - curse.rangePenalty) + end + if curse.drain then + item.curseDrain = curse.drain + end + return item +end + +function Curses.isCursed(item) + return item ~= nil and item.cursed == true +end + +function Curses.isRevealed(item) + return item ~= nil and item.curseRevealed == true +end + +function Curses.reveal(item) + if item then item.curseRevealed = true end +end + +-- True once whatever `entity` currently has worn in `slot` is cursed. +function Curses.isBoundIn(entity, slot) + return Curses.isCursed(entity[slot]) +end + +-- Refuses to let go of a bound item: reveals the curse the instant the +-- wearer discovers it - the whole point of the mechanic is finding out by +-- trying - and reports false so the caller can leave the replacement +-- sitting in the pack instead of swapping it in. An empty or clean slot +-- always succeeds without side effects. +function Curses.tryRelease(entity, slot) + local item = entity[slot] + if not Curses.isCursed(item) then return true end + Curses.reveal(item) + return false +end + +-- Undoes `item`'s curse in place: restores whatever power/range it cost, +-- drops the drain, and clears every curse field so the piece swaps out +-- normally from here on. No-op (returns false) on clean or absent gear. +function Curses.cleanse(item) + if not Curses.isCursed(item) then return false end + if item.cursePenalty then + item.power = item.power + item.cursePenalty + item.cursePenalty = nil + end + if item.curseRangePenalty then + item.range = item.range + item.curseRangePenalty + item.curseRangePenalty = nil + end + item.curseDrain = nil + item.cursed = nil + item.curseKind = nil + item.curseRevealed = nil + return true +end + +-- Lifts every curse currently worn by `entity` across all four slots, +-- returning how many pieces were cleansed - 0 when none of its gear was +-- cursed to begin with, so callers can report "nothing to cleanse". +function Curses.cleanseWorn(entity) + local count = 0 + for _, slot in ipairs(Curses.SLOTS) do + if Curses.cleanse(entity[slot]) then count = count + 1 end + end + return count +end + +-- Reveals the first still-hidden curse among `entity`'s worn gear, in +-- Curses.SLOTS order, and returns the item it found - or nil once nothing +-- worn is hiding a curse anymore. +function Curses.revealFirstUnknown(entity) + for _, slot in ipairs(Curses.SLOTS) do + local item = entity[slot] + if Curses.isCursed(item) and not Curses.isRevealed(item) then + Curses.reveal(item) + return item + end + end + return nil +end + +-- The hit points a cursed ring bleeds from its wearer this round. Only a +-- ring's affliction drains on a timer - weapon, armor and bow curses are a +-- flat, one-time stat tax instead - so this is the only per-turn cost the +-- whole system has. +function Curses.drainAmount(entity) + local ring = entity.ring + if ring and ring.curseDrain then return ring.curseDrain end + return 0 +end + +-- Display name for `item`: an unrevealed curse hides behind the item's +-- ordinary name exactly like a clean piece, a revealed one is prefixed so +-- the player never again mistakes it for something safe to swap out. +function Curses.label(item, name) + if Curses.isCursed(item) and Curses.isRevealed(item) then + return "cursed " .. name + end + return name +end + +return Curses