From 7af63ad1d7ffacd9bc4abe1c37b3661c0ea97259 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 12:30:20 +0200 Subject: [PATCH 001/172] feat: add Abilities.regenTick and Abilities.onDeath --- src/abilities.lua | 153 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/abilities.lua diff --git a/src/abilities.lua b/src/abilities.lua new file mode 100644 index 0000000..df46ff9 --- /dev/null +++ b/src/abilities.lua @@ -0,0 +1,153 @@ +-- Species-level passive and death-triggered abilities layered on top of the +-- generic AI behaviours in ai.lua. Declared per species in Monsters.species +-- so a bestiary entry fully describes what a creature does, not just how +-- hard it hits or how it moves. + +local Status = require("status") +local Items = require("items") +local Los = require("los") +local Combat = require("combat") +local Durability = require("durability") +local Momentum = require("momentum") + +local Abilities = {} + +-- Regeneration: heals `monster.regen` hp at the start of its turn, unless it +-- is actively burning - fire cauterizes the wound faster than it closes. +-- Runs after Status.tick so a fresh burn this same turn still suppresses it. +function Abilities.regenTick(monster) + local amount = monster.regen + if not amount or not monster:isAlive() then return nil end + if Status.has(monster, Status.BURNING) then return nil end + local before = monster.hp + monster.hp = math.min(monster.maxHp, monster.hp + amount) + if monster.hp == before then return nil end + return string.format("%s knits its wounds for %d", monster.name, monster.hp - before) +end + +-- On-death bursts: some species release a parting effect on whatever is +-- adjacent to their corpse - including the player - the instant they die. +-- Keyed by the `onDeath` tag on the species, run from Game:awardKill so the +-- burst fires no matter what finished the monster off (melee, spell, thrown +-- weapon, or a status effect ticking it down). +local bursts = { + spores = function(game, monster) + local hit = {} + local targets = { game.player } + for _, other in ipairs(game:currentEntities()) do targets[#targets + 1] = other end + for _, victim in ipairs(targets) do + if victim ~= monster and victim:isAlive() + and math.abs(victim.x - monster.x) <= 1 and math.abs(victim.y - monster.y) <= 1 then + Status.apply(victim, Status.POISON, 4, 2) + hit[#hit + 1] = victim.name + end + end + if #hit == 0 then return nil end + return string.format("a burst of spores poisons %s", table.concat(hit, ", ")) + end, +} + +function Abilities.onDeath(game, monster) + local burst = monster.onDeath and bursts[monster.onDeath] + if not burst then return nil end + return burst(game, monster) +end + +-- Turn-start special abilities, keyed by the `ability` tag on the species +-- (Monsters.species) and stamped onto the entity at creation. Abilities.tryAct +-- runs from Game:takeMonsterTurn BEFORE the plain ai.lua behaviour: when it +-- fires it consumes the whole turn and the caller must skip Ai.act; when it +-- returns false the monster falls through to its ordinary ai tag unchanged. +-- Range/cooldown constants live here, not on the species, mirroring how +-- `bursts` above centralises onDeath behaviour by tag rather than by species. + +local RANGE = { summon = 6, curse = 5, heal = 5, shriek = 5 } +local COOLDOWN = { summon = 6, curse = 4, heal = 5, rally = 8, shriek = 6 } +local MAX_SUMMONED = 2 + +local EIGHT_DIRS = { + { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }, +} + +-- The first free, unoccupied, walkable cell next to (x, y), scanning a fixed +-- direction order so results are deterministic for a given map state. +local function freeAdjacent(game, x, y) + for _, dir in ipairs(EIGHT_DIRS) do + local nx, ny = x + dir[1], y + dir[2] + if game.level.map:isWalkable(nx, ny) and not game.level:entityAt(nx, ny) + and not (nx == game.player.x and ny == game.player.y) then + return nx, ny + end + end + return nil +end + +function Abilities.tickCooldown(monster) + if monster.cooldown and monster.cooldown > 0 then + monster.cooldown = monster.cooldown - 1 + end +end + +-- Necromancer-style ability: raises a fresh minion of `summonSpecies` beside +-- itself, tagged `summonedBy` so the population cap only counts its own brood. +local function trySummon(game, monster) + if not game:canSeePlayer(monster, RANGE.summon) then return false end + local count = 0 + for _, other in ipairs(game.level.entities) do + if other:isAlive() and other.summonedBy == monster.id then count = count + 1 end + end + if count >= MAX_SUMMONED then return false end + local x, y = freeAdjacent(game, monster.x, monster.y) + if not x then return false end + local minion = game:spawnMinion(monster.summonSpecies, x, y) + minion.summonedBy = monster.id + monster.cooldown = COOLDOWN.summon + return true, string.format("%s calls forth a %s", monster.name, minion.name) +end + +-- Shaman-style ability: a ranged bolt that both hurts and weakens the player, +-- withheld at point-blank range so the caster's ai (flee) kites instead. The +-- hurt half runs through the exact same Combat.damage the melee/shoot paths +-- use - mitigated by the player's defense and resistances, breaking Momentum +-- (see momentum.lua's own "claw, breath or ambush" wording - a curse bolt is +-- none of those by name but is that in shape) and wearing armor exactly like +-- monsterShoot's breath already does - rather than a flat, unmitigated +-- subtraction none of that pipeline ever saw. +local function tryCurse(game, monster) + if game:playerAdjacent(monster) then return false end + if not game:canSeePlayer(monster, RANGE.curse) or not game:hasClearShot(monster) then + return false + end + local dealt = Combat.damage(monster, game.player, 1) + game.player.hp = game.player.hp - dealt + Momentum.onDamaged(game.player) + local broke = Durability.wearArmor(game.player) + if broke then game.log:add(broke) end + Status.apply(game.player, Status.WEAKEN, 6, 3) + monster.cooldown = COOLDOWN.curse + return true, string.format("%s curses you for %d", monster.name, dealt) +end + +-- Banshee-style ability: a ranged wail that blinds instead of hurting, +-- withheld point-blank exactly like tryCurse so the caster's own "flee" ai +-- keeps it at kiting distance rather than trading blows. Status.BLIND +-- (game.lua's Game:effectiveRadius) was previously only ever inflicted BY +-- the player - a thrown potion, the blind spell, or a shared trap effect - +-- never the other way around; this is the first hostile source of it, the +-- same gap tryShriek's sibling "curse" would leave for WEAKEN if a monster +-- could apply it but nothing here ever did. Duration outlasts the cooldown +-- by one turn on purpose (5 vs 6) so a banshee left free to keep casting +-- keeps you blind almost the whole encounter but not with zero window - the +-- one turn of clear sight between wails is a player's chance to reposition +-- or close the distance before the next howl lands. +local function tryShriek(game, monster) + if game:playerAdjacent(monster) then return false end + if not game:canSeePlayer(monster, RANGE.shriek) or not game:hasClearShot(monster) then + return false + end + Status.apply(game.player, Status.BLIND, 5, 1) + monster.cooldown = COOLDOWN.shriek + return true, string.format("%s's wail blinds you", monster.name) +end + From aa657fdc88271341e4b38f667b7f12bee0040cd5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 13:52:41 +0200 Subject: [PATCH 002/172] feat: extend abilities with tryHeal and tryRally --- src/abilities.lua | 110 ++++++++++++++++++++++++++++++++++++++++++++++ src/affixes.lua | 11 +++-- src/ai.lua | 67 +++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/src/abilities.lua b/src/abilities.lua index df46ff9..56df4db 100644 --- a/src/abilities.lua +++ b/src/abilities.lua @@ -151,3 +151,113 @@ local function tryShriek(game, monster) return true, string.format("%s's wail blinds you", monster.name) end +-- Cleric-style ability: mends the neediest hurt ally within range and line of +-- sight instead of attacking. Ties break on the lowest hp fraction. Filtered +-- to other.kind == "monster" - the same "hostile" tag every other module +-- (companions.lua/mercenaries.lua/reanimation.lua's own nearestHostile, +-- commands.lua's isVisibleHostile) already keys off of - so a dark cleric +-- mends a fellow monster, never the player's own wounded companion, hireling +-- or thrall sharing the same entities list. +local function tryHeal(game, monster) + local best, bestRatio = nil, 1 + for _, other in ipairs(game.level.entities) do + if other ~= monster and other.kind == "monster" and other:isAlive() and other.hp < other.maxHp then + local dist = math.max(math.abs(other.x - monster.x), math.abs(other.y - monster.y)) + if dist <= RANGE.heal and Los.clear(game.level.map, monster.x, monster.y, other.x, other.y) then + local ratio = other.hp / other.maxHp + if ratio < bestRatio then bestRatio, best = ratio, other end + end + end + end + if not best then return false end + local amount = 6 + local before = best.hp + best.hp = math.min(best.maxHp, best.hp + amount) + monster.cooldown = COOLDOWN.heal + return true, string.format("%s mends %s for %d", monster.name, best.name, best.hp - before) +end + +-- Chieftain-style ability: grants every adjacent, not-yet-buffed ally (and +-- itself) a temporary attack boost. No-op once the whole knot is already lit. +-- Filtered to other.kind == "monster" for the same reason tryHeal above is: +-- without it a chieftain standing next to the player's companion, hireling +-- or thrall would rally that ally of the PLAYER's instead of one of its own. +local function tryRally(game, monster) + local buffed = {} + for _, other in ipairs(game.level.entities) do + if other ~= monster and other.kind == "monster" and other:isAlive() and not Status.has(other, Status.STRENGTH) + and math.abs(other.x - monster.x) <= 1 and math.abs(other.y - monster.y) <= 1 then + Status.apply(other, Status.STRENGTH, 10, 2) + buffed[#buffed + 1] = other.name + end + end + if #buffed == 0 then return false end + Status.apply(monster, Status.STRENGTH, 10, 2) + monster.cooldown = COOLDOWN.rally + return true, string.format("%s rallies %s", monster.name, table.concat(buffed, ", ")) +end + +-- Footpad-style ability: not cooldown-gated. Once adjacent it lifts a single +-- non-essential item (never an equipped key or the amulet, since those never +-- sit unequipped in inventory) and flees for the rest of the encounter; a +-- fleeing footpad just keeps running rather than trying again. +local function trySteal(game, monster) + if monster.fleeing then + game:stepMonsterAway(monster, game.player.x, game.player.y) + return true + end + if not game:playerAdjacent(monster) then return false end + local idx = game:findInInventory(function(item) + return item.kind ~= Items.KEY and item.kind ~= Items.AMULET + end) + if not idx then + monster.fleeing = true + return true, string.format("%s finds nothing worth stealing and flees", monster.name) + end + local item = table.remove(game.player.inventory, idx) + monster.stolen = item + monster.fleeing = true + return true, string.format("%s steals your %s and flees", monster.name, item.name) +end + +local dispatch = { summon = trySummon, curse = tryCurse, heal = tryHeal, rally = tryRally, shriek = tryShriek } + +-- Called once per monster turn, after status ticks and regen but before +-- ai.lua. Returns true when the ability consumed the turn. +function Abilities.tryAct(game, monster) + if not monster.ability then return false end + if monster.ability == "steal" then + local acted, line = trySteal(game, monster) + if acted and line then game.log:add(line) end + return acted == true + end + local handler = dispatch[monster.ability] + if not handler or (monster.cooldown and monster.cooldown > 0) then return false end + local acted, line = handler(game, monster) + if not acted then return false end + if line then game.log:add(line) end + return true +end + +-- Slime-style reaction: fired from Game:playerAttack when a hit lands but +-- does not kill. Halves the victim's max hp and spawns a clone at the other +-- half, capped at two splits total (`splitGen`) so a slime cannot be farmed +-- into an unbounded swarm. +function Abilities.onHit(game, target) + if target.ability ~= "split" then return nil end + if not target:isAlive() or target.maxHp < 4 then return nil end + if (target.splitGen or 0) >= 2 then return nil end + local x, y = freeAdjacent(game, target.x, target.y) + if not x then return nil end + local newMax = target.maxHp // 2 + target.maxHp = newMax + target.hp = math.min(target.hp, newMax) + target.splitGen = (target.splitGen or 0) + 1 + local clone = game:spawnMinion(target.species, x, y) + clone.maxHp = newMax + clone.hp = newMax + clone.splitGen = target.splitGen + return string.format("the %s splits in two", target.name) +end + +return Abilities diff --git a/src/affixes.lua b/src/affixes.lua index 8009930..0a57fb3 100644 --- a/src/affixes.lua +++ b/src/affixes.lua @@ -1,14 +1,18 @@ -- Gear affixes. A weapon or armour piece may roll a prefix that bumps its power -- and, for some weapons, attaches an on-hit rider (e.g. flaming sets the target -- alight). Rolls draw from the shared rng so drops stay deterministic per seed. +-- An elemental rider also retags the weapon's own base swing (damageType, +-- read by combat.lua) to match - a flaming sword fights with fire, not steel, +-- so it hits harder against a cold-weak target and can do nothing at all +-- against something fire-immune. local Affixes = {} Affixes.weapon = { { key = "sharp", name = "sharp", power = 1 }, { key = "vicious", name = "vicious", power = 2 }, - { key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 } }, - { key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 } }, + { key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 }, damageType = "fire" }, + { key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 }, damageType = "poison" }, } Affixes.armor = { @@ -31,9 +35,10 @@ end function Affixes.decorate(item, affix) if not affix then return item end - item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit } + item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit, damageType = affix.damageType } item.power = item.power + affix.power item.name = affix.name .. " " .. item.name + item.damageType = affix.damageType return item end diff --git a/src/ai.lua b/src/ai.lua index bfa532d..4d55a53 100644 --- a/src/ai.lua +++ b/src/ai.lua @@ -2,15 +2,38 @@ -- resolves that monster's turn through game helpers, so all state mutation and -- rng use stays centralised and deterministic. +local Status = require("status") +local Infighting = require("infighting") + local Ai = {} -local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8 } +local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8, tunnel = 10 } local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } +-- A player-aligned ally standing adjacent to `monster` occupies its cell +-- exactly the way a wall would for Game:stepMonsterToward's own pathfinding +-- (blockedForMonster treats any occupied non-goal cell as impassable) - so a +-- one-wide corridor an ally is plugging leaves every pursuit behaviour below +-- with no route at all, and without this check they simply idle beside it +-- forever rather than fighting the living combatant in their way. Resolves +-- through Infighting.strike, the same generic monster-vs-any-creature combat +-- infighting.lua already uses for rivalries and discord. Returns true once +-- it has spent the monster's turn this way, so each caller can bail out at +-- the same priority Game:playerAdjacent already has over closing distance. +local function attackBlockingAlly(game, monster) + local ally = game:adjacentAlly(monster) + if not ally then return false end + Infighting.strike(game, monster, ally) + return true +end + local function chase(game, monster) if game:playerAdjacent(monster) then game:monsterMelee(monster) - elseif game:canSeePlayer(monster, AGGRO.chase) then + return + end + if attackBlockingAlly(game, monster) then return end + if game:canSeePlayer(monster, AGGRO.chase) then game:stepMonsterToward(monster, game.player.x, game.player.y) end end @@ -20,6 +43,7 @@ local function pack(game, monster) game:monsterMelee(monster) return end + if attackBlockingAlly(game, monster) then return end if game:canSeePlayer(monster, AGGRO.pack) then game:stepMonsterToward(monster, game.player.x, game.player.y) return @@ -39,6 +63,7 @@ local function ranged(game, monster) end return end + if attackBlockingAlly(game, monster) then return end if game:canSeePlayer(monster, AGGRO.ranged) then game:stepMonsterToward(monster, game.player.x, game.player.y) end @@ -50,6 +75,14 @@ local function flee(game, monster) end end +-- Shopkeepers idle unless game.lua has already flagged them hostile (a theft +-- from their stock), at which point they behave exactly like a chaser. +local function vend(game, monster) + if monster.hostile then + chase(game, monster) + end +end + local function erratic(game, monster) if game:playerAdjacent(monster) then game:monsterMelee(monster) @@ -63,15 +96,45 @@ local function erratic(game, monster) end end +-- A burrower: melee once adjacent, chase like anything else once it can see +-- the player through open floor, otherwise claw straight through whatever +-- rock stands between them (see Game:monsterDig, digging.lua) rather than +-- idling the way a plain chaser out of sight would. +local function tunnel(game, monster) + if game:playerAdjacent(monster) then + game:monsterMelee(monster) + return + end + if attackBlockingAlly(game, monster) then return end + if game:canSeePlayer(monster, AGGRO.tunnel) then + game:stepMonsterToward(monster, game.player.x, game.player.y) + return + end + game:monsterDig(monster, game.player.x, game.player.y) +end + Ai.behaviours = { chase = chase, pack = pack, ranged = ranged, flee = flee, erratic = erratic, + vend = vend, + tunnel = tunnel, } +-- A confused monster gets the exact same chance each turn to have its own +-- chosen direction overridden that Game:movePlayer rolls for the player - see +-- status.lua's Status.CONFUSE_NUMERATOR/DENOMINATOR, shared by both sides so +-- neither one confuses more reliably than the other. On a lucky roll (or once +-- confusion wears off) `behaviour` below runs completely untouched. function Ai.act(game, monster) + if Status.has(monster, Status.CONFUSE) + and game.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then + local dir = DIRS[game.rng:range(1, #DIRS)] + game:moveMonster(monster, dir[1], dir[2]) + return + end local behaviour = Ai.behaviours[monster.ai] or chase behaviour(game, monster) end From 6821564c6758e47e3fa62e3da4afbbd823da619c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 16:19:37 +0200 Subject: [PATCH 003/172] feat: add pairKey, Alchemy.herb and Analytics.winRate --- src/alchemy.lua | 102 ++++++++++++++++++++++++++++++++++++++++++++++ src/analytics.lua | 58 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/alchemy.lua create mode 100644 src/analytics.lua diff --git a/src/alchemy.lua b/src/alchemy.lua new file mode 100644 index 0000000..0ed9348 --- /dev/null +++ b/src/alchemy.lua @@ -0,0 +1,102 @@ +-- Herb-based potion brewing, layered on top of items.lua's existing potion +-- catalog rather than inventing a second one: a cauldron turns two foraged +-- herbs into the same potions rollItem already scatters as loot, so a +-- brewed potion drinks, throws (throwing.lua) and identifies exactly like a +-- found one. Recipes are fixed data, the same way crafting.lua's elemental +-- riders are fixed - the uncertainty here is not "what does this pair do" +-- but whether two ill-matched herbs curdle instead of brewing cleanly. See +-- Game:brewAt for how a curdle is resolved against the brewer. + +local Items = require("items") + +local Alchemy = {} + +Alchemy.SUNPETAL = "sunpetal" +Alchemy.BLOODROOT = "bloodroot" +Alchemy.EMBERLEAF = "emberleaf" +Alchemy.FROSTMOSS = "frostmoss" +Alchemy.NIGHTCAP = "nightcap" +Alchemy.MOONBELL = "moonbell" + +-- Fixed order so a given rng draw always names the same herb. New herbs +-- append at the end (see builder.lua's own comment on Alchemy.order) so an +-- existing seed's earlier picks never change which herb they name. +Alchemy.order = { + Alchemy.BLOODROOT, Alchemy.EMBERLEAF, Alchemy.FROSTMOSS, Alchemy.NIGHTCAP, Alchemy.SUNPETAL, + Alchemy.MOONBELL, +} + +Alchemy.names = { + [Alchemy.SUNPETAL] = "sunpetal", + [Alchemy.BLOODROOT] = "bloodroot", + [Alchemy.EMBERLEAF] = "emberleaf", + [Alchemy.FROSTMOSS] = "frostmoss", + [Alchemy.NIGHTCAP] = "nightcap", + [Alchemy.MOONBELL] = "moonbell", +} + +local CURDLE = { curdle = true } + +-- Canonical order-independent key for a herb pair: the two herb keys sorted +-- and joined, so brewing (A, B) and (B, A) always land on the same recipe. +local function pairKey(a, b) + if a > b then a, b = b, a end + return a .. "+" .. b +end +Alchemy.pairKey = pairKey + +-- Same-herb pairs concentrate that herb's own aspect; cross pairs blend two +-- aspects into a shared potion, except the pairings whose aspects outright +-- oppose each other (light versus shade, fire versus frost, day versus +-- dream), which curdle instead of brewing. +Alchemy.recipes = { + [pairKey(Alchemy.SUNPETAL, Alchemy.SUNPETAL)] = { effect = "heal", power = 18 }, + [pairKey(Alchemy.SUNPETAL, Alchemy.BLOODROOT)] = { effect = "heal", power = 10 }, + [pairKey(Alchemy.SUNPETAL, Alchemy.EMBERLEAF)] = { effect = "haste" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.NIGHTCAP)] = CURDLE, + [pairKey(Alchemy.BLOODROOT, Alchemy.BLOODROOT)] = { effect = "strength" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.EMBERLEAF)] = { effect = "strength" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.NIGHTCAP)] = { effect = "poison", power = 4 }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.EMBERLEAF)] = { effect = "haste" }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.FROSTMOSS)] = CURDLE, + [pairKey(Alchemy.EMBERLEAF, Alchemy.NIGHTCAP)] = { effect = "poison", power = 5 }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.NIGHTCAP)] = { effect = "poison", power = 5 }, + [pairKey(Alchemy.NIGHTCAP, Alchemy.NIGHTCAP)] = { effect = "poison", power = 7 }, + -- Moonbell's own aspect is dream-fog, so concentrating it addles the + -- brewer's own mind rather than anyone else's - the same "own aspect + -- turned inward" shape as every other same-herb pair above. Sunlight is + -- the one aspect it outright opposes (wakeful day versus dreaming night, + -- the same shape as sunpetal/nightcap's light-versus-shade curdle above), + -- so that pairing curdles instead of brewing. + [pairKey(Alchemy.MOONBELL, Alchemy.MOONBELL)] = { effect = "confuse" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.MOONBELL)] = CURDLE, + [pairKey(Alchemy.BLOODROOT, Alchemy.MOONBELL)] = { effect = "clarity" }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.MOONBELL)] = { effect = "blind" }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.MOONBELL)] = { effect = "levitate" }, + [pairKey(Alchemy.NIGHTCAP, Alchemy.MOONBELL)] = { effect = "poison", power = 6 }, +} + +function Alchemy.herb(key) + return { kind = Items.HERB, herb = key, name = Alchemy.names[key] .. " herb", glyph = ";" } +end + +-- The fixed outcome for brewing herbA with herbB together, order +-- independent. Returns nil only when either key names no known herb. +function Alchemy.lookup(herbA, herbB) + if not Alchemy.names[herbA] or not Alchemy.names[herbB] then return nil end + return Alchemy.recipes[pairKey(herbA, herbB)] +end + +-- Resolves a completed brew into a fresh potion item, or (nil, "curdle") for +-- an opposed pairing - the caller decides what a curdle does to the brewer. +function Alchemy.brew(herbA, herbB) + local recipe = Alchemy.lookup(herbA, herbB) + if not recipe then return nil, "unknown herb" end + if recipe.curdle then return nil, "curdle" end + return Items.potionOf(recipe.effect, recipe.power) +end + +return Alchemy diff --git a/src/analytics.lua b/src/analytics.lua new file mode 100644 index 0000000..cdaaff9 --- /dev/null +++ b/src/analytics.lua @@ -0,0 +1,58 @@ +-- Aggregate, read-only statistics over meta.lua's own cross-run history: +-- how often a body of runs actually won, which class shows up most among +-- them, and where a fresh score would land against that history - three +-- questions renown and achievements never answer on their own, since both +-- only ever look at the one run in front of them, never the shape of every +-- run that came before it. + +local Analytics = {} + +-- Fraction of recorded runs that ended in victory. 0 for a state that has +-- not recorded any runs yet rather than dividing by zero. +function Analytics.winRate(meta) + if meta.totalRuns == 0 then return 0 end + return meta.wins / meta.totalRuns +end + +-- Mean score across every leaderboard entry (see leaderboard.lua), 0 for an +-- empty board. +function Analytics.averageScore(board) + if #board == 0 then return 0 end + local total = 0 + for _, entry in ipairs(board) do total = total + entry.score end + return total / #board +end + +-- The className appearing most often on `board`, ties broken toward +-- whichever class was seen first on the board - so the answer only ever +-- depends on the board's own entry order, never on however pairs() happens +-- to walk a count table. nil for an empty board. +function Analytics.favoriteClass(board) + local counts, order = {}, {} + for _, entry in ipairs(board) do + if not counts[entry.className] then order[#order + 1] = entry.className end + counts[entry.className] = (counts[entry.className] or 0) + 1 + end + local best, bestCount = nil, 0 + for _, className in ipairs(order) do + if counts[className] > bestCount then + best, bestCount = className, counts[className] + end + end + return best +end + +-- How many of `board`'s entries `score` would actually outrank, as a +-- fraction from 0 (worse than or tied with everything) to 1 (better than +-- everything). An empty board always answers 1 - there is nothing left to +-- beat, so nothing holds the score back. +function Analytics.percentile(board, score) + if #board == 0 then return 1 end + local beaten = 0 + for _, entry in ipairs(board) do + if score > entry.score then beaten = beaten + 1 end + end + return beaten / #board +end + +return Analytics From 023fa35caf3d9aa8af9c890109c1b9e4185bbfb7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 19:47:24 +0200 Subject: [PATCH 004/172] feat: add Artifacts.create and Artifacts.lifestealAmount --- src/artifacts.lua | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/artifacts.lua diff --git a/src/artifacts.lua b/src/artifacts.lua new file mode 100644 index 0000000..6b24bd8 --- /dev/null +++ b/src/artifacts.lua @@ -0,0 +1,133 @@ +-- Unique named gear. Unlike a rolled affix (affixes.lua), which stacks a +-- small power bump onto an ordinary base item, an artifact is a fixed, +-- always-on mechanic attached to a fixed item - lifesteal, thorns, a passive +-- regen aura, an armour-ignoring shot. Artifacts are never masked by +-- identification: identify.isConcealable only conceals potions and scrolls, +-- so a found artifact shows its real name and effect immediately. +-- +-- Builder places at most one per floor from depth 5 on, its rarity roll +-- appended after every other placement (see builder.lua) so it can never +-- perturb an earlier system's rng sequence. Per-floor rng is independent +-- (dungeon.lua reseeds fresh per depth), so this is a per-floor rarity, not a +-- tracked one-per-run guarantee the way the amulet is. + +local Items = require("items") + +local Artifacts = {} + +Artifacts.LIFESTEAL = "lifesteal" +Artifacts.THORNS = "thorns" +Artifacts.REGEN = "regen" +Artifacts.PIERCING = "piercing" + +Artifacts.catalog = { + { + key = "bloodfang", slot = "weapon", name = "Bloodfang", power = 4, + unique = Artifacts.LIFESTEAL, value = 0.5, + }, + { + key = "aegis_of_embers", slot = "armor", name = "Aegis of Embers", power = 2, + unique = Artifacts.THORNS, value = 0.5, + }, + { + key = "ring_of_the_tide", slot = "ring", name = "Ring of the Tide", attack = 1, defense = 1, + unique = Artifacts.REGEN, value = 2, + }, + { + key = "gale_bow", slot = "bow", name = "Gale Bow", power = 3, range = 6, + unique = Artifacts.PIERCING, value = 3, + }, + { + key = "cinderplate", slot = "armor", name = "Cinderplate", power = 2, + resist = { fire = "resist" }, + }, + { + key = "frostband", slot = "ring", name = "Frostband", attack = 0, defense = 1, + resist = { cold = "resist" }, + }, + + -- Boss-exclusive drops (bosses.lua): stronger versions of an existing + -- unique mechanic, flagged bossOnly so the loop below keeps them out of + -- Artifacts.order and therefore out of builder.lua's ordinary per-floor + -- roll. The only way to obtain one is felling the boss that guarantees it. + { + key = "warlords_cleaver", slot = "weapon", name = "the Warlord's Cleaver", power = 6, + unique = Artifacts.LIFESTEAL, value = 0.75, bossOnly = true, + }, + { + key = "lich_phylactery", slot = "ring", name = "the Lich's Phylactery", attack = 1, defense = 2, + unique = Artifacts.REGEN, value = 4, bossOnly = true, + }, + { + key = "bone_queen_carapace", slot = "armor", name = "the Bone Queen's Carapace", power = 4, + unique = Artifacts.THORNS, value = 0.9, bossOnly = true, + }, +} + +Artifacts.byKey = {} +Artifacts.order = {} +for _, spec in ipairs(Artifacts.catalog) do + Artifacts.byKey[spec.key] = spec + if not spec.bossOnly then + Artifacts.order[#Artifacts.order + 1] = spec.key + end +end + +-- Builds the dropped item for `key` in the same shape items.lua's own +-- constructors return for that slot, tagged with the fields the equip +-- callers below key their special-case behaviour off (`unique` and `value`). +function Artifacts.create(key) + local spec = Artifacts.byKey[key] + if not spec then return nil end + local item + if spec.slot == "weapon" then + item = Items.weapon(spec.name, spec.power) + elseif spec.slot == "armor" then + item = Items.armor(spec.name, spec.power) + elseif spec.slot == "ring" then + item = Items.ring(spec.name, spec.attack, spec.defense) + elseif spec.slot == "bow" then + item = Items.bow(spec.name, spec.power, spec.range) + end + item.artifact = key + item.unique = spec.unique + item.value = spec.value + item.resist = spec.resist + return item +end + +-- Hp returned to `attacker` from a lifesteal weapon's hit, or 0 without one. +function Artifacts.lifestealAmount(attacker, damage) + local weapon = attacker.weapon + if weapon and weapon.unique == Artifacts.LIFESTEAL then + return math.max(1, math.floor(damage * weapon.value)) + end + return 0 +end + +-- Damage reflected back at whoever just landed a melee hit on `defender`, or +-- 0 without thorned armour worn. +function Artifacts.thornsAmount(defender, damage) + local armor = defender.armor + if armor and armor.unique == Artifacts.THORNS then + return math.max(1, math.floor(damage * armor.value)) + end + return 0 +end + +-- Hp `entity` regenerates this turn from a worn ring, or 0 without one. +function Artifacts.ringRegen(entity) + local ring = entity.ring + if ring and ring.unique == Artifacts.REGEN then return ring.value end + return 0 +end + +-- Flat defense `entity`'s readied bow ignores on its next shot, or 0 without +-- a piercing bow. +function Artifacts.bowPierce(entity) + local bow = entity.bow + if bow and bow.unique == Artifacts.PIERCING then return bow.value end + return 0 +end + +return Artifacts From fbfe8411acefe0d521dec9c065e0cba9657c6c0c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 20:01:27 +0200 Subject: [PATCH 005/172] feat: add Ascension.isUnlocked, Ascension.resolve --- src/ascension.lua | 136 +++++++++++++++++++++++++++++++++++++++++++++ src/atmosphere.lua | 49 ++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/ascension.lua create mode 100644 src/atmosphere.lua diff --git a/src/ascension.lua b/src/ascension.lua new file mode 100644 index 0000000..9c4d2b4 --- /dev/null +++ b/src/ascension.lua @@ -0,0 +1,136 @@ +-- Optional run-wide difficulty mutators a player opts into before a run +-- starts, in exchange for extra renown once it ends: risk taken on purpose, +-- unlike events.lua's mutators which fire and fade on their own mid-run +-- regardless of anyone's choice. A mutator unlocks once meta.lua's own +-- cross-run tallies (best depth, wins) cross its fixed requirement - +-- computed live off those tallies rather than persisted, since both only +-- ever climb, so nothing here needs its own entry in meta.lua's save shape. +-- +-- Active mutators for the run in progress live on game.ascension, a plain +-- array of keys in catalog order (see Ascension.resolve), exactly the shape +-- warren.lua's --mutators flag builds. Plain data, so it serializes with no +-- special save-format handling, and every combat/essence/sight hook that +-- reads it (Game:monsterDamageMultiplier, Game:essenceMultiplier, +-- Game:effectiveRadius) stays a pure function of that array alongside +-- whatever level-scoped event Events.lua itself is contributing. + +local Ascension = {} + +Ascension.MUTATORS = { + { key = "bloodbound", name = "Bloodbound", desc = "monsters hit 50% harder", + renownBonus = 15, requiresDepth = 3, requiresWins = 0, damageMultiplier = 1.5 }, + { key = "famine", name = "Famine", desc = "essence income halved", + renownBonus = 10, requiresDepth = 2, requiresWins = 0, essenceMultiplier = 0.5 }, + { key = "nightblind", name = "Nightblind", desc = "sight radius reduced by 2", + renownBonus = 10, requiresDepth = 2, requiresWins = 0, sightPenalty = 2 }, + { key = "frail", name = "Frail", desc = "defense halved", + renownBonus = 12, requiresDepth = 4, requiresWins = 0, + applyToPlayer = function(player) + player.baseDefense = player.baseDefense // 2 + player.defense = player.defense // 2 + end }, + { key = "glasscannon", name = "Glass Cannon", desc = "max hp halved, attack doubled", + renownBonus = 20, requiresDepth = 0, requiresWins = 1, + applyToPlayer = function(player) + player.maxHp = math.max(1, player.maxHp // 2) + player.hp = math.min(player.hp, player.maxHp) + player.baseAttack = player.baseAttack * 2 + player.attack = player.attack * 2 + end }, +} + +Ascension.byKey = {} +Ascension.order = {} +for _, def in ipairs(Ascension.MUTATORS) do + Ascension.byKey[def.key] = def + Ascension.order[#Ascension.order + 1] = def.key +end + +-- A meta-less caller (no --meta flag at all) can still activate whatever +-- costs nothing to unlock; every other mutator needs a persistent meta file +-- to have already banked the depth or win it requires. +function Ascension.isUnlocked(meta, key) + local def = Ascension.byKey[key] + if not def then return false end + if not meta then return def.requiresDepth == 0 and def.requiresWins == 0 end + return meta.bestDepth >= def.requiresDepth and meta.wins >= def.requiresWins +end + +-- Validates a requested list of mutator keys against both the catalog and +-- `meta`'s own unlock state, returned de-duplicated in catalog order rather +-- than whatever order the caller supplied - so two runs activating the same +-- set always see it identically regardless of how --mutators listed them. +-- An unknown or still-locked key is silently dropped rather than failing +-- the whole run over one bad flag. +function Ascension.resolve(meta, requested) + if not requested or #requested == 0 then return {} end + local wanted = {} + for _, key in ipairs(requested) do wanted[key] = true end + local active = {} + for _, key in ipairs(Ascension.order) do + if wanted[key] and Ascension.isUnlocked(meta, key) then + active[#active + 1] = key + end + end + return active +end + +function Ascension.isActive(active, key) + for _, k in ipairs(active or {}) do + if k == key then return true end + end + return false +end + +function Ascension.damageMultiplier(active) + local mult = 1 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.damageMultiplier then mult = mult * def.damageMultiplier end + end + return mult +end + +function Ascension.essenceMultiplier(active) + local mult = 1 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.essenceMultiplier then mult = mult * def.essenceMultiplier end + end + return mult +end + +function Ascension.sightPenalty(active) + local penalty = 0 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.sightPenalty then penalty = penalty + def.sightPenalty end + end + return penalty +end + +-- Renown bonus percentage this active set adds on top of Meta.renownFor's +-- own base score, summed rather than compounded - three 15% mutators add +-- 45%, not a runaway 1.15^3 - so stacking more of them is always a +-- straightforward trade of more risk for more reward. +function Ascension.renownBonusPercent(active) + local percent = 0 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def then percent = percent + def.renownBonus end + end + return percent +end + +-- Applies every active mutator's one-time player-stat delta, in catalog +-- order. Meant to run once in Game.new, after Meta.applyAll's perks and any +-- class delta, since a mutator is the final, voluntary layer of difficulty +-- stacked on top of whatever build the player already chose. +function Ascension.applyToPlayer(active, player) + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.applyToPlayer then def.applyToPlayer(player) end + end +end + +return Ascension diff --git a/src/atmosphere.lua b/src/atmosphere.lua new file mode 100644 index 0000000..462c238 --- /dev/null +++ b/src/atmosphere.lua @@ -0,0 +1,49 @@ +-- Floor-wide weather: a single condition drawn once at generation (like +-- `dark`) that colors the whole level rather than one cell of it. Unlike +-- terrain.lua's per-tile hazards (lava, chasm, ice), a floor's weather has no +-- position of its own - every numeric hook below is a flat, floor-lifetime +-- modifier the caller applies once per turn or once at generation, never +-- something a player can step in or out of. + +local Atmosphere = {} + +Atmosphere.FOGGY = "foggy" +Atmosphere.CONSECRATED = "consecrated" +Atmosphere.INFESTED = "infested" + +-- Fixed order so a given rng draw always names the same condition. +Atmosphere.order = { Atmosphere.FOGGY, Atmosphere.CONSECRATED, Atmosphere.INFESTED } + +-- A thick, clinging fog: everyone's sight radius shrinks, monsters and traps +-- included, before the darkness/torch radius in Game:effectiveRadius is +-- even considered. +function Atmosphere.sightPenalty(weather) + if weather == Atmosphere.FOGGY then return 2 end + return 0 +end + +-- Consecrated ground mends the living each turn they linger on it, exactly +-- like an artifact's fixed regen (see artifacts.ringRegen) but floor-wide +-- and free of any equipment. +function Atmosphere.regenBonus(weather) + if weather == Atmosphere.CONSECRATED then return 1 end + return 0 +end + +-- An infested floor's monsters hit harder from the moment they are placed - +-- a flat, permanent bump baked into their stats at generation time (see +-- builder.lua), the same way an elite affix or a boss's own catalog entry +-- already sets a monster's power once and for all. +function Atmosphere.monsterAttackBonus(weather) + if weather == Atmosphere.INFESTED then return 2 end + return 0 +end + +function Atmosphere.describe(weather) + if weather == Atmosphere.FOGGY then return "a thick fog blankets this floor" end + if weather == Atmosphere.CONSECRATED then return "this floor is consecrated ground" end + if weather == Atmosphere.INFESTED then return "this floor teems with infestation" end + return nil +end + +return Atmosphere From 9bcc8fe6e2a3b50845dc30325e8828fbc04ba486 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:03:17 +0200 Subject: [PATCH 006/172] feat: add Biomes.forDepth, Biomes.describe and Blocks.treasure --- src/biomes.lua | 62 +++++++++++++++++++++++++++ src/blocks.lua | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/biomes.lua create mode 100644 src/blocks.lua diff --git a/src/biomes.lua b/src/biomes.lua new file mode 100644 index 0000000..2a845c1 --- /dev/null +++ b/src/biomes.lua @@ -0,0 +1,62 @@ +-- Depth-band environmental theme layered on top of the systems that already +-- read raw depth alone: unlike atmosphere.lua's weather (a coin flip drawn +-- fresh on every floor from depth two on, see Atmosphere.order), a biome is +-- a fixed function of depth by itself, so floor three always belongs to the +-- same people and the same climate no matter which seed generated it - +-- weather is what a given run of that floor happens to be like today, a +-- biome is what the floor always is. Each entry names the faction that +-- actually lives there (see factions.lua) and the hazard kind its climate +-- tends toward (see hazards.lua), which the builder's biome pass - appended +-- dead last of all in Builder.build, after even the grave, so it can never +-- perturb any earlier system's rng sequence - may draw on to reinforce a +-- floor with one more native monster or bloom a small pocket of that +-- hazard, strictly on top of everything already placed there. + +local Factions = require("factions") +local Hazards = require("hazards") + +local Biomes = {} + +Biomes.CATALOG = { + { key = "warrens", name = "the mossy warrens", favoredFaction = Factions.VERMIN, hazardKind = Hazards.GAS, ambient = "damp stone and skittering claws" }, + { key = "burrows", name = "the goblin burrows", favoredFaction = Factions.GOBLINOID, hazardKind = Hazards.GAS, ambient = "crude tunnels reeking of tallow smoke" }, + { key = "crypts", name = "the sunken crypts", favoredFaction = Factions.UNDEAD, hazardKind = Hazards.GAS, ambient = "cold air thick with grave rot" }, + { key = "warcamp", name = "the orcish warcamp", favoredFaction = Factions.ORCISH, hazardKind = Hazards.FIRE, ambient = "smoke-stained banners and cookfires" }, + { key = "sanctum", name = "the cult sanctum", favoredFaction = Factions.CULT, hazardKind = Hazards.FIRE, ambient = "chanting echoes off blood-lit stone" }, + { key = "wyrmnest", name = "the wyrm's nest", favoredFaction = Factions.DRAKE, hazardKind = Hazards.FIRE, ambient = "scorched walls and the reek of sulfur" }, +} + +Biomes.byKey = {} +for _, biome in ipairs(Biomes.CATALOG) do + Biomes.byKey[biome.key] = biome +end + +-- Cyclic by depth, exactly the way chosenGenerator above already cycles +-- through Gen.order: a dungeon of any Builder.maxDepth wraps back through +-- the catalog rather than running off its end once depth outgrows it. +function Biomes.forDepth(depth) + return Biomes.CATALOG[((depth - 1) % #Biomes.CATALOG) + 1] +end + +function Biomes.describe(biome) + return string.format("%s: %s", biome.name, biome.ambient) +end + +-- The subset of an already depth-filtered species list (see +-- Monsters.forDepth) that actually belongs to biome's own faction - the +-- pool the builder's reinforcement pass draws a native from, empty whenever +-- nothing eligible this shallow yet belongs to the biome's own people. +-- Ranged specs are excluded outright: a second archer stacks free damage no +-- melee approach can answer, so reinforcement only ever sends backup a +-- player can actually close the distance on. +function Biomes.nativesAmong(biome, eligible) + local natives = {} + for _, spec in ipairs(eligible) do + if spec.faction == biome.favoredFaction and spec.ai ~= "ranged" then + natives[#natives + 1] = spec + end + end + return natives +end + +return Biomes diff --git a/src/blocks.lua b/src/blocks.lua new file mode 100644 index 0000000..4c0a459 --- /dev/null +++ b/src/blocks.lua @@ -0,0 +1,113 @@ +-- Pushable blocks: a block occupies a floor cell and blocks movement and +-- pathing exactly like a wall, but shoving it (see Game:movePlayer) relocates +-- it one cell in the push direction instead of just refusing the move, the +-- same "walk into it to interact" shape a closed door or a vault lever +-- already has. A block shoved into lava solidifies into rubble and one +-- shoved into a chasm fills the gap into plain floor - both permanently +-- consume the block, since it has nothing left to rest on once the hazard +-- it corrected is gone. Shoved onto a pressure plate instead, the block +-- survives and holds its linked gate open for as long as it stays put (see +-- Blocks.updateGates) - unlike vaults.lua's lever, a gate reseals the +-- instant the weight holding it open moves off the plate again. + +local Tiles = require("tiles") +local Items = require("items") +local Affixes = require("affixes") + +local Blocks = {} + +local treasureWeapons = { + { name = "gate-forged blade", power = 7 }, + { name = "gate-forged maul", power = 8 }, +} +local treasureArmors = { + { name = "gate-forged plate", power = 6 }, +} + +-- A guaranteed affix on a base already well above anything the floor's own +-- loot table can roll at this depth, mirroring vaults.lua's own Vaults. +-- treasure but with names that never claim to come from a vault. +function Blocks.treasure(rng, depth) + if rng:chance(1, 2) then + local base = treasureWeapons[rng:range(1, #treasureWeapons)] + local item = Items.weapon(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.weapon[rng:range(1, #Affixes.weapon)]) + end + local base = treasureArmors[rng:range(1, #treasureArmors)] + local item = Items.armor(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.armor[rng:range(1, #Affixes.armor)]) +end + +-- Whether a cell a block might be shoved into can actually receive it: in +-- bounds, not a wall, a door of any kind, or a gate, and not already host to +-- another block or a living entity. Lava and chasm cells count as +-- receiving even though Blocks.push special-cases what happens to them +-- next, rather than refusing the shove outright. +function Blocks.canReceive(level, x, y) + if not level.map:inBounds(x, y) then return false end + local tile = level.map:get(x, y) + if tile == Tiles.WALL or Tiles.isDoor(tile) or Tiles.isVaultDoor(tile) or Tiles.isGate(tile) then + return false + end + if level:blockAt(x, y) or level:entityAt(x, y) then return false end + return true +end + +local function remove(level, block) + for i, b in ipairs(level.blocks) do + if b == block then + table.remove(level.blocks, i) + return + end + end +end + +-- Shoves `block` by (dx, dy) if the far cell can receive it. Returns +-- (true, message) on a successful shove, resolving whatever lava or chasm +-- it lands in along the way (Blocks.remove), or (false) when the far cell +-- refuses it, exactly like bumping into a wall. +function Blocks.push(level, block, dx, dy) + local nx, ny = block.x + dx, block.y + dy + if not Blocks.canReceive(level, nx, ny) then return false end + local tile = level.map:get(nx, ny) + if tile == Tiles.LAVA then + level.map:set(nx, ny, Tiles.RUBBLE) + remove(level, block) + return true, "the block hisses into the lava and cools into rubble" + end + if tile == Tiles.CHASM then + level.map:set(nx, ny, Tiles.FLOOR) + remove(level, block) + return true, "the block tumbles into the chasm and fills it" + end + block.x, block.y = nx, ny + return true, "you shove the block" +end + +-- Whether any plate linked to `gate` currently carries a block's weight. +local function gateHeld(level, gate) + for _, plate in ipairs(level.plates) do + if plate.gateX == gate.x and plate.gateY == gate.y and level:blockAt(plate.x, plate.y) then + return true + end + end + return false +end + +-- Re-evaluates every gate on the level against its own plates' current +-- weight, flipping both its tile glyph and its `open` field the instant a +-- plate gains or loses a block. Called after any block shove (Game: +-- movePlayer) so a gate reacts immediately rather than waiting for a full +-- turn tick, the same way Vaults.pull's door flip is immediate on the pull +-- that completes it. +function Blocks.updateGates(level) + for _, gate in ipairs(level.gates) do + local held = gateHeld(level, gate) + if held ~= gate.open then + gate.open = held + level.map:set(gate.x, gate.y, held and Tiles.GATE_OPEN or Tiles.GATE_CLOSED) + end + end +end + +return Blocks From 4def2b8ddad2443d8260f2b35becd7edaa73f19b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:04:36 +0200 Subject: [PATCH 007/172] feat: add Bosses.forDepth and Bosses.create --- src/bosses.lua | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/bosses.lua diff --git a/src/bosses.lua b/src/bosses.lua new file mode 100644 index 0000000..4b52b46 --- /dev/null +++ b/src/bosses.lua @@ -0,0 +1,124 @@ +-- Named floor bosses: rare, sturdier variants of the bestiary that phase-shift +-- once bloodied ("enrage") and always drop a superior, boss-exclusive +-- artifact on death. A boss is otherwise a plain monster entity - it rides +-- the same scheduler, ai.lua behaviour and abilities.lua dispatch as every +-- other creature - so this module only adds what is genuinely new: the +-- catalog, the enrage phase check, and the guaranteed drop. +-- +-- A boss's second, harder-won phase - the brace-then-unleash cataclysm once +-- it is battered all the way down to desperateAt - lives in phases.lua +-- instead of here, since unlike enrage it spends whole turns and needs its +-- own two-state machine; this module only supplies the catalog numbers +-- (desperateAt, desperateDefenseBonus, novaDamage, novaRadius) it reads. +-- +-- Builder places at most one per floor from depth 6 on, its rarity roll +-- appended after every other placement (artifacts included) so it can never +-- perturb an earlier system's rng sequence, exactly like artifacts.lua does +-- for its own roll. Gating at depth 6+ also keeps bosses off every floor the +-- fixed-maxDepth win-condition tests generate (those use maxDepth <= 3). + +local Entity = require("entity") +local Artifacts = require("artifacts") + +local Bosses = {} + +Bosses.catalog = { + { + key = "warlord", glyph = "W", name = "the Orc Warlord", + hp = 34, attack = 7, defense = 3, speed = 100, ai = "pack", + minDepth = 6, xp = 40, ability = "rally", + enrageAt = 0.5, enrageAttack = 4, enrageSpeed = 25, enrageHeal = 6, + desperateAt = 0.2, desperateDefenseBonus = 5, novaDamage = 12, novaRadius = 2, + lootKey = "warlords_cleaver", + }, + { + key = "lich", glyph = "L", name = "the Lich", + hp = 28, attack = 5, defense = 2, speed = 100, ai = "flee", range = 6, + minDepth = 7, xp = 45, ability = "curse", + enrageAt = 0.5, enrageAttack = 3, enrageSpeed = 20, enrageHeal = 8, + desperateAt = 0.2, desperateDefenseBonus = 4, novaDamage = 10, novaRadius = 2, + lootKey = "lich_phylactery", + }, + { + key = "bonequeen", glyph = "Q", name = "the Bone Queen", + hp = 32, attack = 4, defense = 2, speed = 95, ai = "flee", + minDepth = 8, xp = 50, ability = "summon", summonSpecies = "zombie", + enrageAt = 0.5, enrageAttack = 3, enrageSpeed = 15, enrageHeal = 10, + desperateAt = 0.2, desperateDefenseBonus = 4, novaDamage = 11, novaRadius = 2, + lootKey = "bone_queen_carapace", + }, +} + +Bosses.byKey = {} +for _, spec in ipairs(Bosses.catalog) do + Bosses.byKey[spec.key] = spec +end + +-- Boss species eligible at a depth, in registry order, mirroring +-- Monsters.forDepth so builder.lua can roll among them the same way. +function Bosses.forDepth(depth) + local eligible = {} + for _, spec in ipairs(Bosses.catalog) do + if spec.minDepth <= depth then eligible[#eligible + 1] = spec end + end + return eligible +end + +function Bosses.create(spec, id, x, y) + local entity = Entity.new({ + id = id, x = x, y = y, + glyph = spec.glyph, name = spec.name, kind = "monster", + species = "boss_" .. spec.key, ai = spec.ai, + hp = spec.hp, maxHp = spec.hp, + attack = spec.attack, defense = spec.defense, + speed = spec.speed, range = spec.range or 0, + }) + entity.ability = spec.ability + entity.summonSpecies = spec.summonSpecies + entity.cooldown = 0 + entity.isBoss = true + entity.bossKey = spec.key + entity.enraged = false + -- Always alert: sneaking up on a named boss is not part of the design + -- (see stealth.lua's header for how this differs from a wild monster). + entity.awareness = "hunting" + return entity +end + +-- Xp reward for felling a boss, looked up by the bossKey stamped onto it at +-- creation. Falls back to a flat amount for a boss entity missing one, which +-- should not happen outside of a malformed save. +function Bosses.xpFor(entity) + local spec = Bosses.byKey[entity.bossKey] + return spec and spec.xp or 20 +end + +-- Checked at the start of a boss's own turn, after status ticks, hazards and +-- regen but before its ability or plain ai runs: once bloodied past its +-- enrageAt threshold it permanently gains attack and speed and takes a +-- one-time heal, exactly once for the rest of the fight. Mirrors the direct +-- stat-field mutation classes.lua uses for a permanent delta, rather than a +-- status.lua effect, since this should never expire or be cleansed. +function Bosses.tryEnrage(monster) + if not monster.isBoss or monster.enraged or not monster:isAlive() then return nil end + local spec = Bosses.byKey[monster.bossKey] + if not spec then return nil end + if monster.hp > monster.maxHp * spec.enrageAt then return nil end + monster.enraged = true + monster.attack = monster.attack + spec.enrageAttack + monster.baseAttack = monster.baseAttack + spec.enrageAttack + monster.speed = monster.speed + spec.enrageSpeed + monster.hp = math.min(monster.maxHp, monster.hp + spec.enrageHeal) + return string.format("%s flies into a bloodied rage", monster.name) +end + +-- The guaranteed superior artifact a boss drops on death, dropped straight +-- onto the floor at its corpse's feet by Game:awardKill rather than rolled +-- into the ordinary loot table builder.lua scatters at level generation. +function Bosses.lootFor(entity) + local spec = Bosses.byKey[entity.bossKey] + if not spec then return nil end + return Artifacts.create(spec.lootKey) +end + +return Bosses From 907f57db41508110c2707f38cd249022b902e73c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:41:04 +0200 Subject: [PATCH 008/172] feat: add Branches.seedFor and Branches.build --- src/branches.lua | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/branches.lua diff --git a/src/branches.lua b/src/branches.lua new file mode 100644 index 0000000..fb6a998 --- /dev/null +++ b/src/branches.lua @@ -0,0 +1,83 @@ +-- Rift crossings: a rare, shimmering tear in a floor from depth six on (see +-- builder.lua's placement, gated behind a carried rift shard) that a player +-- can step through into a short, self-contained pocket floor - generated +-- once from the same world seed as everything else (see Branches.build) at +-- a deliberately harsher effective depth than the floor the rift itself sits +-- on, so what waits behind it is always a genuine step up in danger and in +-- reward. Unlike horde.lua's arena (a one-way trial that replaces the run's +-- level outright), a rift crossing is round-trip: Game:enterRift remembers +-- exactly where the player stood before stepping through, and Game:exitRift +-- restores it to the byte, so nothing about the main dungeon left behind is +-- ever perturbed by the detour. + +local Rng = require("rng") +local Builder = require("builder") +local Level = require("level") + +local Branches = {} + +-- How much harder a rift's own pocket floor is generated than the main-track +-- depth its entrance sits on - fixed rather than randomised, so a rift found +-- at a given depth always promises the same order of danger and reward. +Branches.DEPTH_BONUS = 4 + +-- Distinguishes a rift's own private rng stream from both the main dungeon's +-- per-depth stream (Dungeon:levelSeed's own salt) and every other salted +-- stream in the codebase, so discovering a rift can never perturb - or be +-- perturbed by - any of them. +local SALT = 0xC2B2AE3D27D4EB4F + +-- The deterministic seed a rift found on `depth` always builds its pocket +-- floor from, a pure function of the world seed and the origin depth alone - +-- never the main dungeon's own per-depth rng stream - so the exact same rift +-- always opens onto the exact same pocket floor no matter how many times (or +-- in what order) the run's other floors happen to be visited first. +function Branches.seedFor(worldSeed, depth) + return math.tointeger(worldSeed ~ (depth * SALT)) or 0 +end + +-- Builds the pocket floor a rift found at `depth` opens onto: an ordinary +-- Builder.build floor, just generated Branches.DEPTH_BONUS deeper than the +-- rift's own origin so its monsters and loot are drawn from a rung the +-- player has not reached yet on the main track. `options` is the run's own +-- dungeon options (mutators, class, graves), copied rather than mutated, with +-- maxDepth pinned one short of the pocket's own depth - short, not equal or +-- past it, so neither of Builder.build's two maxDepth-gated passes ever fire +-- in a pocket floor: no chasm (there is deliberately no lower pocket floor to +-- fall into) and, far more importantly, no Amulet of Warren - a rift is a +-- richer fight, never a shortcut past the real dungeon's own win condition. +function Branches.build(worldSeed, depth, options) + local pocketDepth = depth + Branches.DEPTH_BONUS + local rng = Rng.new(Branches.seedFor(worldSeed, depth)) + local pocketOptions = {} + for k, v in pairs(options or {}) do pocketOptions[k] = v end + pocketOptions.maxDepth = pocketDepth - 1 + return Builder.build(pocketDepth, rng, pocketOptions) +end + +-- Every rift's own pocket floor, serialized exactly like Dungeon:toData +-- serializes the main track: a depth-sorted array of {depth, level} pairs, so +-- a save taken mid-crossing (or with a rift merely discovered and never +-- entered) restores every pocket floor already generated byte-for-byte, dead +-- monsters and all - and stores nothing extra for a rift a run never stepped +-- through at all. +function Branches.levelsToData(riftLevels) + local depths = {} + for depth in pairs(riftLevels or {}) do depths[#depths + 1] = depth end + table.sort(depths) + local stored = {} + for _, depth in ipairs(depths) do + stored[#stored + 1] = { depth = depth, level = riftLevels[depth]:toData() } + end + return stored +end + +function Branches.levelsFromData(stored) + local riftLevels = {} + for _, entry in ipairs(stored or {}) do + riftLevels[entry.depth] = Level.fromData(entry.level) + end + return riftLevels +end + +return Branches From 86b701721462fd2d085bbef02d0e03e6f17e7a7c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 22:53:08 +0200 Subject: [PATCH 009/172] feat: extend builder with Builder.maxDepth and reachSet --- src/builder.lua | 157 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 14 deletions(-) diff --git a/src/builder.lua b/src/builder.lua index edd58e6..b281514 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -9,12 +9,51 @@ local Gen = require("gen.init") local Monsters = require("monsters") local Items = require("items") local Affixes = require("affixes") +local Shop = require("shop") +local Shrines = require("shrines") +local Companions = require("companions") +local Artifacts = require("artifacts") +local Bosses = require("bosses") +local Quests = require("quests") +local Alchemy = require("alchemy") +local Elites = require("elites") +local Vaults = require("vaults") +local Runewords = require("runewords") +local Atmosphere = require("atmosphere") +local Stealth = require("stealth") +local Crafting = require("crafting") +local Nodes = require("nodes") +local Sets = require("sets") +local Mimics = require("mimics") +local Curses = require("curses") +local Graves = require("graves") +local Chests = require("chests") +local Biomes = require("biomes") +local Hazards = require("hazards") +local Blocks = require("blocks") +local Prefab = require("gen.prefab") local Level = require("level") local Builder = {} local WIDTH, HEIGHT = 60, 24 +-- The dungeon bottoms out here: the Amulet of Warren is guaranteed to be +-- somewhere on this floor, and there is no way to descend past it. Tests +-- override it via options.maxDepth to exercise a full fetch-and-return +-- playthrough without generating nine intervening floors. +Builder.MAX_DEPTH = 10 + +-- The effects a random floor trap, or a trap kit rolled as loot, can carry - +-- shared by the two draws below and exposed here so tests can assert against +-- the authoritative list instead of a copy that would go stale the next time +-- a new effect is appended. +Builder.TRAP_EFFECTS = { "dart", "poison", "teleport", "confuse", "blind", "stun" } + +function Builder.maxDepth(options) + return (options and options.maxDepth) or Builder.MAX_DEPTH +end + local weaponBases = { { name = "dagger", power = 2 }, { name = "mace", power = 3 }, { name = "sword", power = 4 }, { name = "battle axe", power = 5 }, @@ -23,13 +62,21 @@ local armorBases = { { name = "leather armor", power = 1 }, { name = "chain mail", power = 2 }, { name = "plate armor", power = 3 }, } +local bowBases = { + { name = "short bow", power = 2, range = 5 }, + { name = "long bow", power = 3, range = 7 }, + { name = "crossbow", power = 4, range = 6 }, +} local NEIGHBOURS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } -- Cells reachable from (sx, sy) through walkable terrain and closed doors, and - -- when allowLocked is true - locked doors too. Used to guarantee the descent is -- never sealed behind a locked door and that keys always sit on an openable side. -local function reachSet(map, sx, sy, allowLocked) +-- When allowVault is also true, a sealed vault door or a closed gate (see +-- below) counts as passable too, so the far side of an unopened vault or +-- gate puzzle can still be found for its treasure's placement. +local function reachSet(map, sx, sy, allowLocked, allowVault) local seen = { [map:index(sx, sy)] = true } local stack = { { sx, sy } } while #stack > 0 do @@ -37,11 +84,15 @@ local function reachSet(map, sx, sy, allowLocked) stack[#stack] = nil for _, step in ipairs(NEIGHBOURS) do local nx, ny = cell[1] + step[1], cell[2] + step[2] - if map:inBounds(nx, ny) and map:isPassable(nx, ny, allowLocked) then - local idx = map:index(nx, ny) - if not seen[idx] then - seen[idx] = true - stack[#stack + 1] = { nx, ny } + if map:inBounds(nx, ny) then + local passable = map:isPassable(nx, ny, allowLocked) + or (allowVault and (Tiles.isVaultDoor(map:get(nx, ny)) or Tiles.isGate(map:get(nx, ny)))) + if passable then + local idx = map:index(nx, ny) + if not seen[idx] then + seen[idx] = true + stack[#stack + 1] = { nx, ny } + end end end end @@ -81,25 +132,42 @@ end local function rollItem(rng, depth) local roll = rng:range(1, 100) - if roll <= 24 then - local effects = { "heal", "heal", "strength", "haste", "poison" } + if roll <= 22 then + local effects = { "heal", "heal", "strength", "haste", "poison", "clarity", "cure", "confuse", "blind", "levitate" } return Items.potionOf(effects[rng:range(1, #effects)]) - elseif roll <= 40 then + elseif roll <= 36 then local effects = { "teleport", "identify", "mapping", "fireball" } return Items.scroll(effects[rng:range(1, #effects)]) - elseif roll <= 60 then + elseif roll <= 54 then local base = weaponBases[rng:range(1, #weaponBases)] return Affixes.decorate(Items.weapon(base.name, base.power), Affixes.rollWeapon(rng)) - elseif roll <= 76 then + elseif roll <= 68 then local base = armorBases[rng:range(1, #armorBases)] return Affixes.decorate(Items.armor(base.name, base.power), Affixes.rollArmor(rng)) - elseif roll <= 86 then + elseif roll <= 76 then return Items.ring("ring of power", rng:range(0, 1), rng:range(0, 1)) + elseif roll <= 80 then + local learnable = { "heal", "shield", "blink", "shock", "discord", "polymorph", "confuse", "blind", "levitate", "weaken", "cleanse" } + return Items.tome(learnable[rng:range(1, #learnable)]) + elseif roll <= 84 then + local base = bowBases[rng:range(1, #bowBases)] + return Items.bow(base.name, base.power, base.range) + elseif roll <= 92 then + return Items.ammo("arrow", rng:range(4, 8)) else return Items.food("ration", 400) end end +-- Maps a floor item's kind to the wearable slot curses.lua knows how to +-- afflict, so the dead-last curse pass below can find every eligible piece +-- regardless of which earlier system placed it (a plain drop, a set piece, +-- vault treasure, ...). +local slotForCursableItem = { + [Items.WEAPON] = "weapon", [Items.ARMOR] = "armor", + [Items.RING] = "ring", [Items.BOW] = "bow", +} + local function chosenGenerator(depth, options) if options and options.generator and Gen.has(options.generator) then return options.generator @@ -175,7 +243,11 @@ function Builder.build(depth, rng, options) end) if x then local spec = eligible[rng:range(1, #eligible)] - entities[#entities + 1] = Monsters.create(spec, #entities + 1, x, y, depth) + local monster = Monsters.create(spec, #entities + 1, x, y, depth) + -- A wild monster starts asleep (see stealth.lua): a plain field set, + -- never an rng draw, so it cannot perturb any placement roll below. + monster.awareness = Stealth.DORMANT + entities[#entities + 1] = monster mark(x, y) end end @@ -189,7 +261,56 @@ function Builder.build(depth, rng, options) end end - local trapEffects = { "dart", "poison", "teleport" } + -- Loose gold piles and the shopkeeper (below) are both gated to depth 4+: + -- the shopkeeper joins Monsters.forDepth's eligible array from that depth + -- on, and any new rng draw here would otherwise reshuffle the trap/light/ + -- amulet placement the fixed-maxDepth win-condition tests rely on for the + -- shallow floors they actually exercise. + if depth >= 4 then + local goldCount = rng:range(1, 3) + for _ = 1, goldCount do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.gold(rng:range(8, 20) + depth * 4) } + mark(x, y) + end + end + end + + -- A shopkeeper occasionally sets up on a floor, guarding a handful of + -- priced wares. Gold to spend there comes from the loose piles above. + if depth >= 4 and rng:chance(1, 3) then + local sx, sy = pickFree(reachable, rng, freeFloor) + if sx then + local keeper = Monsters.create(Monsters.byKey.shopkeeper, #entities + 1, sx, sy, depth) + entities[#entities + 1] = keeper + mark(sx, sy) + for _, item in ipairs(Shop.stock(rng)) do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { + x = x, y = y, item = item, price = Shop.priceOf(item), shopId = keeper.id, + } + mark(x, y) + end + end + end + end + + -- Shrines join the shop above at depth 4+, for the same reason: an earlier + -- rng draw would reshuffle the trap/light/amulet placement the fixed- + -- maxDepth win-condition tests rely on for the shallow floors they exercise. + local shrines = {} + if depth >= 4 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local kind = Shrines.order[rng:range(1, #Shrines.order)] + shrines[#shrines + 1] = { x = x, y = y, kind = kind, uses = 0 } + mark(x, y) + end + end + + local trapEffects = Builder.TRAP_EFFECTS local trapCount = rng:range(1, 3) + depth // 3 for _ = 1, trapCount do local x, y = pickFree(reachable, rng, freeFloor) @@ -199,6 +320,14 @@ function Builder.build(depth, rng, options) end end + if depth == Builder.maxDepth(options) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.amulet() } + mark(x, y) + end + end + local dark = depth >= 3 if dark then for _ = 1, rng:range(1, 3) do From ef58d96008e2f5759dead2f7af45a30a1c89957c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 10:39:17 +0200 Subject: [PATCH 010/172] feat: extend builder --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index b281514..81a61c2 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -336,6 +336,179 @@ function Builder.build(depth, rng, options) end end + -- A companion's bonding charm is the rarest of the depth-gated placements + -- and, unlike the others, drawn last so it can never perturb the rng + -- sequence any earlier system (traps, shrines, the shop, the amulet) relies + -- on for its own placement. + if depth >= 3 and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Companions.order[rng:range(1, #Companions.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.charm(key) } + mark(x, y) + end + end + + -- Rarer still, and drawn last of all for the same rng-ordering reason as + -- the charm above: a named artifact with a fixed, always-on mechanic. + if depth >= 5 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Artifacts.order[rng:range(1, #Artifacts.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Artifacts.create(key) } + mark(x, y) + end + end + + -- A trap kit lets the player plant one of these same effects + -- themselves (see Game:useItem's KIT branch and Game:checkTrapFor, which + -- springs a planted trap on a monster exactly as a builder-placed one + -- springs on the player). Drawn last of all so it can never perturb any + -- earlier system's rng sequence, the charm and artifact included. + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.kit(trapEffects[rng:range(1, #trapEffects)]) } + mark(x, y) + end + end + + -- A named boss rarer still guards the deepest floors, drawn dead last of + -- all for the same rng-ordering reason as the kit above: it can never + -- perturb any earlier system's placement, artifacts included. + if depth >= 6 and rng:chance(1, 5) then + local eligible = Bosses.forDepth(depth) + if #eligible > 0 then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local spec = eligible[rng:range(1, #eligible)] + entities[#entities + 1] = Bosses.create(spec, #entities + 1, x, y) + mark(x, y) + end + end + end + + -- A bounty board rarer than a trap kit but commoner than an artifact, + -- offering a single fixed pact the moment the player steps onto it (see + -- Game:tryAcceptQuest). Drawn dead last of all for the same rng-ordering + -- reason as the boss above: it can never perturb any earlier system's + -- placement, the boss included. + local questBoards = {} + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + questBoards[#questBoards + 1] = { + x = x, y = y, claimed = false, + quest = Quests.generate(rng, depth, Builder.maxDepth(options)), + } + end + end + + -- Herbs dot every floor from the first, foraging material rather than a + -- depth-gated reward. Drawn dead last of all, after the quest board, so + -- they can never perturb any earlier system's rng sequence. + local herbCount = rng:range(2, 4) + for _ = 1, herbCount do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Alchemy.order[rng:range(1, #Alchemy.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Alchemy.herb(key) } + mark(x, y) + end + end + + -- A cauldron to brew those herbs at, rarer than the herbs themselves and + -- gated to depth 2+ so the entrance floor is foraging-only. Drawn last of + -- all for the same rng-ordering reason as the herbs above. + local cauldrons = {} + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + cauldrons[#cauldrons + 1] = { x = x, y = y } + mark(x, y) + end + end + + -- Volatile terrain rarer than a cauldron, gated to depth 4+ so the entry + -- floors are always safe: a lava pool of two to four adjoining cells that + -- burns anyone standing on it (see Game:checkTerrainFor), a single chasm + -- pit that drops the player to the floor below and swallows a monster + -- whole (capped to floors that still have one to fall onto), and a patch + -- of ice that sends a traveller sliding. Drawn dead last of all for the + -- same rng-ordering reason as the cauldron above. + if depth >= 4 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.LAVA) + mark(x, y) + for _ = 1, rng:range(1, 3) do + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local nx, ny = x + dir[1], y + dir[2] + if freeFloor(nx, ny) then + map:set(nx, ny, Tiles.LAVA) + mark(nx, ny) + end + end + end + end + + if depth >= 4 and depth < Builder.maxDepth(options) and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.CHASM) + mark(x, y) + end + end + + if depth >= 4 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.ICE) + mark(x, y) + for _ = 1, rng:range(1, 4) do + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local nx, ny = x + dir[1], y + dir[2] + if freeFloor(nx, ny) then + map:set(nx, ny, Tiles.ICE) + mark(nx, ny) + end + end + end + end + + -- Elite promotion rolls last of all, dead after the ice above: it only + -- edits stats already-placed entities carry, never a position, so it + -- cannot desync reachability - but rng-ordering discipline still applies, + -- since it draws from the same stream every earlier placement in this + -- floor already relied on. + for _, entity in ipairs(entities) do + Elites.maybePromote(rng, entity, depth) + end + + -- A vault seals a treasure behind a lever puzzle instead of a key: a door + -- set on an unused doorway pinch point that only swings open once every + -- lever scattered on the side already reachable without it has been + -- pulled (see vaults.lua). Rarer than a boss and drawn dead last of all so + -- it can never perturb any earlier system's rng sequence, the boss and + -- quest board included. Falls back to an ordinary closed door whenever the + -- floor's geometry cannot host a genuine puzzle (no free doorway, no room + -- for its levers, or no cell the door actually gates), so a vault never + -- seals content behind an unsolvable or pointless door. + local vaultLevers, vaultDoors = {}, {} + if depth >= 5 and rng:chance(1, 6) then + local doorCandidates = doorwayCandidates(map) + for i = #doorCandidates, 1, -1 do + if taken(doorCandidates[i].x, doorCandidates[i].y) then + table.remove(doorCandidates, i) + end + end + if #doorCandidates > 0 then + local cell = doorCandidates[rng:range(1, #doorCandidates)] + map:set(cell.x, cell.y, Tiles.VAULT_DOOR) + mark(cell.x, cell.y) + local vaultId = "vault" .. depth + local openSide = reachSet(map, upX, upY, false, false) + local leverCount = rng:range(2, 3) return Level.new({ depth = depth, generator = generator, From 6d0666042ceb567d26af3f0ce6c9ab21c6e9b942 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 14:15:58 +0200 Subject: [PATCH 011/172] feat: expand builder --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index 81a61c2..503d5d2 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -509,6 +509,179 @@ function Builder.build(depth, rng, options) local vaultId = "vault" .. depth local openSide = reachSet(map, upX, upY, false, false) local leverCount = rng:range(2, 3) + for _ = 1, leverCount do + local x, y = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and openSide[map:index(cx, cy)] == true + end) + if x then + map:set(x, y, Tiles.LEVER) + vaultLevers[#vaultLevers + 1] = { x = x, y = y, id = vaultId, pulled = false } + mark(x, y) + end + end + local farSide = reachSet(map, upX, upY, false, true) + local tx, ty + if #vaultLevers > 0 then + tx, ty = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and farSide[map:index(cx, cy)] == true + and openSide[map:index(cx, cy)] ~= true + end) + end + if tx then + vaultDoors[#vaultDoors + 1] = { x = cell.x, y = cell.y, id = vaultId, opened = false } + floorItems[#floorItems + 1] = { x = tx, y = ty, item = Vaults.treasure(rng, depth) } + mark(tx, ty) + else + -- No lever site or no cell the door actually gates: back out to a + -- plain closed door so the puzzle is never unsolvable or hollow, and + -- free the lever tiles this attempt already claimed. + map:set(cell.x, cell.y, Tiles.DOOR_CLOSED) + for _, lever in ipairs(vaultLevers) do + map:set(lever.x, lever.y, Tiles.FLOOR) + end + vaultLevers = {} + end + end + end + + -- Rune stones dot the dungeon from depth three on, rarer than a herb but + -- commoner than a trap kit: raw components for runewords.lua's + -- socket-and-match combo system, found rather than crafted so a build is + -- bounded by what the floor actually offers. Drawn dead last of all, + -- after the vault above, so it can never perturb any earlier system's + -- rng sequence. + if depth >= 3 and rng:chance(1, 2) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Runewords.RUNES[rng:range(1, #Runewords.RUNES)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Runewords.rune(key) } + mark(x, y) + end + end + + -- A floor-wide weather condition, drawn dead last of all so it can never + -- perturb any earlier system's rng sequence: an infested reading retroactively + -- bumps every monster already placed on this floor (exactly like Elites. + -- maybePromote's own retroactive stat edit above), since only a stat and + -- never a position is at stake. + local weather + if depth >= 2 and rng:chance(1, 3) then + weather = Atmosphere.order[rng:range(1, #Atmosphere.order)] + local attackBonus = Atmosphere.monsterAttackBonus(weather) + if attackBonus > 0 then + for _, entity in ipairs(entities) do + entity.attack = entity.attack + attackBonus + entity.baseAttack = entity.baseAttack + attackBonus + end + end + end + + -- Ore veins and gem seams dot the dungeon from the second floor on, dead + -- last of all so they can never perturb any earlier system's rng sequence: + -- unlike the herbs above, a single visit does not spend a node outright - + -- it holds a few charges and slowly reopens (see nodes.lua) - so a floor + -- already cleared of monsters still rewards a return trip. + local nodes = {} + if depth >= 2 then + local veinCount = rng:range(1, 2) + for _ = 1, veinCount do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local kind = rng:chance(1, 2) and Nodes.ORE or Nodes.GEM + local element = kind == Nodes.GEM and Crafting.elements[rng:range(1, #Crafting.elements)] or nil + local node = Nodes.new(kind, element) + node.x, node.y = x, y + nodes[#nodes + 1] = node + mark(x, y) + end + end + end + + -- A single item-set piece dots the dungeon from depth three on, rarer + -- than a rune but commoner than an artifact: unlike an artifact's fixed + -- always-on mechanic, a set piece is inert alone and only pays off once + -- its match turns up too (see sets.lua). Drawn dead last of all, after + -- the nodes above, so it can never perturb any earlier system's rng + -- sequence. + if depth >= 3 and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local entry = Sets.order[rng:range(1, #Sets.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Sets.create(entry.set, entry.slot) } + mark(x, y) + end + end + + -- A single mimic disguises itself as loot from depth two on, rarer than + -- a trap kit: unlike every item placed above, it is inert to look at and + -- only bites once the player actually reaches for it (see mimics.lua). + -- Drawn dead last of all, after the set piece above, so it can never + -- perturb any earlier system's rng sequence. + if depth >= 2 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Mimics.floorItem(rng) } + mark(x, y) + end + end + + -- A scroll of remove curse turns up rarer than a mimic, the one dedicated + -- remedy for the curse pass below that a run can actually find rather than + -- having to gamble on a shrine of fortune. Drawn dead last of all, after + -- the mimic above, so it can never perturb any earlier system's rng + -- sequence. + if depth >= 2 and rng:chance(1, 8) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.scroll("removecurse") } + mark(x, y) + end + end + + -- Curses.lua's dark mirror of affixes.lua rolls dead last of all, after + -- every item above has already been placed: it only edits the stats of + -- weapons, armor, rings and bows already sitting in floorItems, never a + -- position, so it cannot desync reachability - but rng-ordering discipline + -- still applies, since it draws from the same stream every earlier + -- placement in this floor already relied on. Skips anything that already + -- landed a beneficial affix or names its own set/artifact, so a single + -- drop is never simultaneously a blessing and a curse. + for _, ground in ipairs(floorItems) do + local slot = slotForCursableItem[ground.item.kind] + if slot and not ground.price and not ground.item.affix and not ground.item.set and not ground.item.artifact then + local curse = Curses.roll(rng, slot) + if curse then Curses.afflict(ground.item, curse) end + end + end + + -- A miner's pick turns up rarer than the remove-curse scroll above, the + -- one way a run can find its own way into a rock burrower's tunnel or dig + -- a shortcut through a dead-end wall (see digging.lua). Drawn dead last + -- of all, after the curse pass above, so it can never perturb any + -- earlier system's rng sequence. + if depth >= 2 and rng:chance(1, 10) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.pick() } + mark(x, y) + end + end + + -- A lockpick turns up commoner than the miner's pick above, drawn dead + -- last of all so it can never perturb any earlier system's rng sequence: + -- the one way a run can gamble past a locked chest's lock without ever + -- finding its key (see chests.lua's Chests.pick). + if rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.lockpick() } + mark(x, y) + end + end + + -- A single lootable chest dots the dungeon from the first floor on, rarer + -- than a lockpick, drawn dead last of all so it can never perturb any + -- earlier system's rng sequence: a locked one may also be trapped (see return Level.new({ depth = depth, generator = generator, From 364ea34db4cf8f7274bb6ff8e1ebeb6948514633 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 14:26:27 +0200 Subject: [PATCH 012/172] feat: extend builder with onOpenSide --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index 503d5d2..9618784 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -682,6 +682,179 @@ function Builder.build(depth, rng, options) -- A single lootable chest dots the dungeon from the first floor on, rarer -- than a lockpick, drawn dead last of all so it can never perturb any -- earlier system's rng sequence: a locked one may also be trapped (see + -- Chests.new), so a run either finds a key, gambles a lockpick, or leaves + -- it be. + local chests = {} + if rng:chance(1, 3) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local chest = Chests.new(rng, depth) + chest.x, chest.y = x, y + chests[#chests + 1] = chest + mark(x, y) + end + end + + -- A secret door hides a single alcove of bonus loot behind an otherwise + -- ordinary stretch of wall, found only by lingering beside it long enough + -- (see secrets.lua's passive sweep). Drawn dead last of all before the + -- grave below - which must stay the true last word, since it is the one + -- block whose own rng draw count depends on what the caller passed in + -- (see its own comment) - so this can never perturb any earlier system's + -- rng sequence either: picks an already-reachable floor cell and a + -- direction whose neighbour and whose cell two further along are both + -- still untouched solid rock, then carves that far cell into a one-tile + -- room holding a single roll of loot. The door itself stays a plain wall + -- tile until secrets.lua's sweep discovers it - this list only remembers + -- where to look. Skipped outright whenever 40 random reachable cells in a + -- row turn up no such spot, so a cramped floor's own geometry never + -- forces the roll to place nothing. + local secretDoors = {} + if depth >= 2 and rng:chance(1, 4) then + for _ = 1, 40 do + local cell = reachable[rng:range(1, #reachable)] + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local doorX, doorY = cell.x + dir[1], cell.y + dir[2] + local roomX, roomY = cell.x + dir[1] * 2, cell.y + dir[2] * 2 + if roomX >= 2 and roomY >= 2 and roomX <= map.width - 1 and roomY <= map.height - 1 + and map:get(doorX, doorY) == Tiles.WALL and map:get(roomX, roomY) == Tiles.WALL then + -- The room's only walkable neighbour may be the door itself: if any + -- of its other three sides already happens to border open floor + -- somewhere else in the layout, that side would let the player + -- wander straight into the alcove without ever finding the door, + -- so a candidate site with any such back way in is rejected outright. + local isolated = true + for _, side in ipairs(NEIGHBOURS) do + local ax, ay = roomX + side[1], roomY + side[2] + if not (ax == doorX and ay == doorY) and map:isWalkable(ax, ay) then + isolated = false + break + end + end + if isolated then + map:set(roomX, roomY, Tiles.FLOOR) + secretDoors[#secretDoors + 1] = { x = doorX, y = doorY, discovered = false } + floorItems[#floorItems + 1] = { x = roomX, y = roomY, item = rollItem(rng, depth) } + break + end + end + end + end + + -- A biome reinforcement and hazard bloom, drawn dead last of all before + -- the grave below - which must stay the true last word, since it is the + -- one block whose own rng draw count depends on what the caller passed in + -- (see its own comment) - so this can never perturb any earlier system's + -- rng sequence either: see biomes.lua for why a floor's biome is a fixed + -- function of depth rather than another dice roll like weather. The + -- reinforcement draws only from natives of the eligible roster already + -- computed above, so it can never plant a monster too deep for its own + -- minDepth, and starts dormant exactly like every other wild monster + -- placed above (see Stealth.DORMANT); the bloom seeds level.hazards the + -- same way a fire spell or a trap would mid-game, just already + -- smouldering the moment the floor is first entered. + local biome = Biomes.forDepth(depth) + local natives = Biomes.nativesAmong(biome, eligible) + if #natives > 0 and rng:chance(1, 3) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local spec = natives[rng:range(1, #natives)] + local monster = Monsters.create(spec, #entities + 1, x, y, depth) + monster.awareness = Stealth.DORMANT + entities[#entities + 1] = monster + mark(x, y) + end + end + + local hazards = {} + if rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + hazards[#hazards + 1] = { x = x, y = y, kind = biome.hazardKind, duration = 4, potency = 2 } + end + end + + -- A pressure gate seals a second stash behind a shove puzzle instead of a + -- lever pull: a gate set on an unused doorway pinch point that only stays + -- open while a block's weight rests on its linked plate (see blocks.lua), + -- rather than a lever's one-way, permanently-spent pull. The block always + -- sits exactly two cells from its plate along a single cardinal direction, + -- with the cell behind the block itself also free, so a single shove from + -- that approach cell always lands it on the plate - the puzzle is never a + -- multi-step arrangement a floor's own cramped geometry might make + -- unsolvable. Also verifies, after sealing the gate shut, that the stairs + -- down are still reachable without ever crossing it - exactly the same + -- guarantee the locked-door pass above gives a key - so a gate can only + -- ever seal optional treasure, never the one path through the floor. + -- Falls back to an ordinary closed door whenever the floor's geometry + -- cannot host a genuine puzzle (no free doorway, no straight three-cell + -- run for the plate/block/approach, or the gate would seal the only route + -- to the stairs), so a gate never seals content behind an unsolvable or + -- pointless door. Drawn dead last of all before the grave below - which + -- must stay the true last word (see its own comment) - so this can never + -- perturb any earlier system's rng sequence either. + local blocks, plates, gates = {}, {}, {} + if depth >= 3 and rng:chance(1, 5) then + local doorCandidates = doorwayCandidates(map) + for i = #doorCandidates, 1, -1 do + if taken(doorCandidates[i].x, doorCandidates[i].y) then + table.remove(doorCandidates, i) + end + end + if #doorCandidates > 0 then + local cell = doorCandidates[rng:range(1, #doorCandidates)] + map:set(cell.x, cell.y, Tiles.GATE_CLOSED) + mark(cell.x, cell.y) + local sealsTheOnlyRoute = not reachSet(map, upX, upY, false)[map:index(downX, downY)] + local openSide = reachSet(map, upX, upY, false, false) + local function onOpenSide(cx, cy) + return freeFloor(cx, cy) and openSide[map:index(cx, cy)] == true + end + local px, py = pickFree(reachable, rng, onOpenSide) + local siteDirs = {} + if px then + for _, dir in ipairs(NEIGHBOURS) do + local bx, by = px - dir[1], py - dir[2] + local ax, ay = bx - dir[1], by - dir[2] + if onOpenSide(bx, by) and onOpenSide(ax, ay) then + siteDirs[#siteDirs + 1] = { bx = bx, by = by } + end + end + end + local tx, ty + if not sealsTheOnlyRoute and #siteDirs > 0 then + local farSide = reachSet(map, upX, upY, false, true) + tx, ty = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and farSide[map:index(cx, cy)] == true + and openSide[map:index(cx, cy)] ~= true + end) + end + if tx then + local site = siteDirs[rng:range(1, #siteDirs)] + plates[#plates + 1] = { x = px, y = py, gateX = cell.x, gateY = cell.y } + blocks[#blocks + 1] = { x = site.bx, y = site.by } + gates[#gates + 1] = { x = cell.x, y = cell.y, open = false } + mark(px, py) + mark(site.bx, site.by) + floorItems[#floorItems + 1] = { x = tx, y = ty, item = Blocks.treasure(rng, depth) } + mark(tx, ty) + else + map:set(cell.x, cell.y, Tiles.DOOR_CLOSED) + end + end + end + + -- A hand-authored prefab vault dots the deep dungeon from depth three on, + -- sealed behind solid rock rather than gated by a key or a lever: the one + -- way in is digging.lua's pick (or a burrower's own claws), swung from + -- whichever reachable cell happens to border its outer wall. Anchored only + -- over untouched rock - every cell of its footprint still the generator's + -- own default fill - so it can never overwrite a corridor or room this + -- floor's own generator already carved, and only accepted once at least + -- one of its wall cells actually borders the reachable area, so a vault + -- can never end up sealed unreachably deep inside solid rock no digger + -- could ever find. Its monster and loot markers (see gen/prefab.lua) draw + -- from the same eligible roster and item tables every earlier pass in this return Level.new({ depth = depth, generator = generator, From 72e296914618686df5c0425e63f75541f0776d04 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 15:53:52 +0200 Subject: [PATCH 013/172] feat: build out builder --- src/builder.lua | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index 9618784..5d9cd32 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -855,14 +855,145 @@ function Builder.build(depth, rng, options) -- can never end up sealed unreachably deep inside solid rock no digger -- could ever find. Its monster and loot markers (see gen/prefab.lua) draw -- from the same eligible roster and item tables every earlier pass in this + -- floor already used. Drawn dead last of all before the grave below - + -- which must stay the true last word (see its own comment) - so this can + -- never perturb any earlier system's rng sequence either. + local prefabVaults = {} + if depth >= 3 and rng:chance(1, 5) then + local vault = Prefab.vaults[rng:range(1, #Prefab.vaults)] + local w, h = Prefab.size(vault) + local vaultReach = reachSet(map, upX, upY, false) + for _ = 1, 40 do + local ox = rng:range(2, map.width - w) + local oy = rng:range(2, map.height - h) + if Prefab.fits(map, vault, ox, oy) then + local untouched = true + for y = oy, oy + h - 1 do + for x = ox, ox + w - 1 do + if map:get(x, y) ~= Tiles.WALL then untouched = false end + end + end + local bordersReachable = false + for y = oy - 1, oy + h do + for x = ox - 1, ox + w do + local onPerimeter = x < ox or x > ox + w - 1 or y < oy or y > oy + h - 1 + if onPerimeter and map:inBounds(x, y) and vaultReach[map:index(x, y)] then + bordersReachable = true + end + end + end + if untouched and bordersReachable then + local stamp = Prefab.stamp(map, vault, ox, oy) + for _, spot in ipairs(stamp.treasure) do + floorItems[#floorItems + 1] = { x = spot.x, y = spot.y, item = Vaults.treasure(rng, depth) } + mark(spot.x, spot.y) + end + for _, spot in ipairs(stamp.loot) do + floorItems[#floorItems + 1] = { x = spot.x, y = spot.y, item = rollItem(rng, depth) } + mark(spot.x, spot.y) + end + for _, spot in ipairs(stamp.monsters) do + local spec = eligible[rng:range(1, #eligible)] + local monster = Monsters.create(spec, #entities + 1, spot.x, spot.y, depth) + monster.awareness = Stealth.DORMANT + entities[#entities + 1] = monster + mark(spot.x, spot.y) + end + prefabVaults[#prefabVaults + 1] = { name = stamp.name, x = ox, y = oy, w = w, h = h } + break + end + end + end + end + + -- A net turns up from depth five on, rarer than a miner's pick, drawn dead + -- last of all - after even the prefab vault pass above - so it can never + -- perturb any earlier system's rng sequence: the one way a run carries the + -- means to subdue a battered monster alive instead of finishing it off + -- (see captives.lua), rather than a strictly worse copy of an ordinary + -- weapon. Gated this deep so it can never turn up in the fixed shallow + -- worlds the win-condition tests build with a low --maxDepth. + if depth >= 5 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.net() } + mark(x, y) + end + end + + -- A rift shard turns up from depth four on, rarer than a net, drawn dead + -- last of all - after the net above - so it can never perturb any earlier + -- system's rng sequence: fuel for the one crossing a rift (below) actually + -- costs (see branches.lua), found a couple of floors ahead of where a rift + -- can itself first appear so a run always has a real chance to be carrying + -- one the day it matters. + if depth >= 4 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.riftstone() } + mark(x, y) + end + end + + -- A rift tears open rarer still, only from depth six on, drawn dead last + -- of all - after the shard above - so it can never perturb any earlier + -- system's rng sequence: a shimmering, unstable seam in the floor a + -- carried rift shard alone can stabilize into a crossing onto a short, + -- harsher pocket floor (see branches.lua and Game:enterRift). Plain + -- position-keyed level data exactly like a shrine or a node, never a tile + -- of its own, so it needs no new entry in tiles.lua. + local rifts = {} + if depth >= 6 and rng:chance(1, 8) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + rifts[#rifts + 1] = { x = x, y = y } + mark(x, y) + end + end + + -- A previous run's grave turns up rarer than a pick, dead last of all - + -- after the lockpick, chest, secret door, biome, gate and net pass above - + -- so it can never perturb any earlier system's rng sequence: cross-run + -- bones (see graves.lua), rolled only when the caller actually supplies + -- any - a run without --meta, or a fresh meta with no recorded deaths + -- yet, makes no extra rng draw here at all, so its floors stay + -- byte-identical to a build with no graves system in play. + if options and options.graves and #options.graves > 0 then + local eligibleGraves = Graves.eligibleFor(options.graves, depth) + if #eligibleGraves > 0 and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local grave = eligibleGraves[rng:range(1, #eligibleGraves)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Graves.floorItem(grave) } + mark(x, y) + end + end + end + return Level.new({ depth = depth, generator = generator, + weather = weather, + biome = biome.key, map = map, entities = entities, floorItems = floorItems, traps = traps, lights = lights, + hazards = hazards, + shrines = shrines, + questBoards = questBoards, + cauldrons = cauldrons, + nodes = nodes, + chests = chests, + vaultLevers = vaultLevers, + vaultDoors = vaultDoors, + secretDoors = secretDoors, + blocks = blocks, + plates = plates, + gates = gates, + prefabVaults = prefabVaults, + rifts = rifts, dark = dark, explored = {}, upX = upX, upY = upY, From ae5d1a414d895353ab17b7fcbc80d0a84b45ab60 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 16:29:04 +0200 Subject: [PATCH 014/172] feat: add Captives.eligible and Captives.canReach --- src/captives.lua | 160 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/captives.lua diff --git a/src/captives.lua b/src/captives.lua new file mode 100644 index 0000000..2d34de5 --- /dev/null +++ b/src/captives.lua @@ -0,0 +1,160 @@ +-- Net-and-ransom: throwing a spare net at a wild monster too battered to +-- keep fighting subdues it alive instead of finishing it off with a killing +-- blow. A captured monster never runs through Game:awardKill at all - no +-- xp, no essence, no quest or compendium credit, no faction kill penalty +-- (see factions.lua's registerKill) and no corpse (corpses.lua) - it is +-- simply dropped from level.entities the instant its hp hits zero here, and +-- reborn as a captive: plain data on level.captives, exactly like a chest +-- or a corpse, so it serializes with no special save-format handling. +-- +-- A held captive can then be spent exactly one of three ways, each removing +-- it from level.captives for good: ransomed back to its own kind for +-- nothing but goodwill (Captives.ransom), sold off for gold at that same +-- goodwill's expense (Captives.sell), or interrogated for whatever it knows +-- instead of what it is worth (Captives.interrogate, which cashes in +-- secrets.lua's own passive search early). None of the three is strictly +-- better than the others - ransoming is the one move that can pull a +-- tribal faction back from the brink of open hostility (see factions.lua's +-- isWary), selling is the only one that pays gold, and interrogating is the +-- only one that answers to the floor's own layout rather than to gold or +-- reputation at all. +-- +-- Capture is a gamble, not a guarantee: even a target already weak enough +-- to try for can still thrash free, and the net itself is spent from the +-- inventory either way, the same one-shot risk a lockpick's own gamble +-- takes against a locked chest. A net never works at all on anything +-- already spoken for by another escalation or special-case system - a +-- boss, a mimic, a grave shade, a nemesis, or the shopkeeper (ai "vend", +-- effectively unkillable loot rather than wild game to begin with). + +local Los = require("los") +local Items = require("items") +local Monsters = require("monsters") +local Elites = require("elites") +local Factions = require("factions") +local Secrets = require("secrets") + +local Captives = {} + +Captives.RANGE = 4 + +-- A target must be battered down to this fraction of its own max hp or +-- lower before a net is even worth trying - full-health prey just shrugs a +-- net off before Captives.resolve ever rolls the catch itself. +Captives.HP_FRACTION_NUMERATOR = 3 +Captives.HP_FRACTION_DENOMINATOR = 10 + +Captives.CATCH_NUMERATOR = 1 +Captives.CATCH_DENOMINATOR = 2 + +Captives.RANSOM_REP_GAIN = 12 +Captives.SELL_REP_LOSS = 8 +Captives.INTERROGATE_ESSENCE_DIVISOR = 2 + +-- Whether `target` is even worth throwing a net at: alive, an ordinary wild +-- hostile (not a boss, mimic, grave shade or nemesis, and not the vending +-- shopkeeper), and hurt down to HP_FRACTION_NUMERATOR/DENOMINATOR of its own +-- max hp or less. +function Captives.eligible(target) + return target ~= nil + and target:isAlive() + and target.kind == "monster" + and target.ai ~= "vend" + and not target.isBoss + and not target.mimic + and not target.grave + and not target.nemesis + and target.hp * Captives.HP_FRACTION_DENOMINATOR <= target.maxHp * Captives.HP_FRACTION_NUMERATOR +end + +-- Same shape as throwing.lua's Throwing.canReach: a net flies no further +-- than RANGE and needs an unbroken line of sight, same as any thrown weapon. +function Captives.canReach(map, x1, y1, x2, y2) + local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2)) + return dist <= Captives.RANGE and Los.clear(map, x1, y1, x2, y2) +end + +-- Gold/essence baseline shared by every resolution below: the same xp a +-- kill would have paid, elite-boosted the same way Game:awardKill's own xp +-- is (see elites.lua), so a netted champion is worth visibly more than a +-- netted rank-and-file of the same species. +function Captives.valueOf(target) + local xp = Monsters.xpFor(target) + return xp + Elites.xpBonus(target, xp) +end + +-- Resolves a net thrown from (thrower) at `target`. Returns (true, lines) if +-- the net left the thrower's hand - whether it actually held anything or +-- not - or (false, lines) if it never left the hand at all: the exact same +-- two-outcome shape Throwing.resolve takes for a thrown weapon or potion. +function Captives.resolve(game, thrower, item, target) + if not item or item.kind ~= Items.NET then + return false, { "that can't be thrown as a net" } + end + if not target or not target:isAlive() then + return false, { "no target" } + end + if not Captives.canReach(game.level.map, thrower.x, thrower.y, target.x, target.y) then + return false, { "you have no clear throw" } + end + if not Captives.eligible(target) then + return true, { target.name .. " is far too fierce to net while it can still fight like that" } + end + if not game.rng:chance(Captives.CATCH_NUMERATOR, Captives.CATCH_DENOMINATOR) then + return true, { target.name .. " thrashes free of the net" } + end + local captive = { + x = target.x, y = target.y, + species = target.species, faction = target.faction, name = target.name, + value = Captives.valueOf(target), + } + game.level.captives[#game.level.captives + 1] = captive + target.hp = 0 + return true, { string.format("the net drops true - %s is bound and struggling", target.name) } +end + +-- Drops `captive` off level.captives outright - shared by every way of +-- spending one below, so a captive can never be resolved twice over. +function Captives.remove(level, captive) + for i, candidate in ipairs(level.captives) do + if candidate == captive then + table.remove(level.captives, i) + return + end + end +end + +-- Frees a held captive back to its own kind for nothing but goodwill: no +-- gold changes hands, but its faction's standing warms by RANSOM_REP_GAIN - +-- the mirror image of Factions.registerKill's penalty for finishing the +-- same creature off outright, and the one resolution that can actually pull +-- a tribal faction back out of Factions.isWary's hostile cutoff. +function Captives.ransom(level, player, captive) + Captives.remove(level, captive) + return Factions.adjust(player, captive.faction, Captives.RANSOM_REP_GAIN) +end + +-- Sells a held captive off for its own value in gold: word gets back to its +-- kind either way, so standing sours by SELL_REP_LOSS even though - unlike +-- an outright kill - nothing actually died to earn it. +function Captives.sell(level, player, captive) + Captives.remove(level, captive) + player.gold = player.gold + captive.value + Factions.adjust(player, captive.faction, -Captives.SELL_REP_LOSS) + return captive.value +end + +-- Interrogates a held captive instead of ransoming or selling it: no gold, +-- no reputation swing either way, just whatever it gives up before its +-- usefulness runs out - every currently-hidden secret door on this floor, +-- found outright (see Secrets.revealAll), plus a trickle of essence for the +-- trouble. Returns the doors this actually revealed, same as +-- Secrets.revealAll itself. +function Captives.interrogate(level, player, captive) + local revealed = Secrets.revealAll(level) + Captives.remove(level, captive) + player.essence = player.essence + math.max(1, captive.value // Captives.INTERROGATE_ESSENCE_DIVISOR) + return revealed +end + +return Captives From 0e8655e87181d406c54b4a0c981e149fe6395ede Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 17:33:25 +0200 Subject: [PATCH 015/172] feat: add Challenges.seedFor and Challenges.forDay --- src/challenges.lua | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/challenges.lua diff --git a/src/challenges.lua b/src/challenges.lua new file mode 100644 index 0000000..9d99912 --- /dev/null +++ b/src/challenges.lua @@ -0,0 +1,69 @@ +-- Named challenge presets: a fixed class, a fixed mutator set and a +-- deterministic seed, bound together so "today's challenge" means the same +-- run for everyone who plays it - the daily-seed ritual familiar +-- roguelikes build around, reproduced here without this module ever +-- touching the wall clock itself (see Challenges.seedFor's own +-- caller-supplied day index), so a test can pin any "day" it likes and get +-- the exact same answer back every time. + +local Ascension = require("ascension") + +local Challenges = {} + +Challenges.CATALOG = { + { key = "iron_gauntlet", name = "Iron Gauntlet", class = "fighter", mutators = { "bloodbound" } }, + { key = "arcane_sprint", name = "Arcane Sprint", class = "mage", mutators = { "famine" } }, + { key = "shadow_creed", name = "Shadow Creed", class = "rogue", mutators = {} }, + { key = "wild_hunt", name = "Wild Hunt", class = "ranger", mutators = { "nightblind" } }, +} + +Challenges.byKey = {} +for _, def in ipairs(Challenges.CATALOG) do + Challenges.byKey[def.key] = def +end + +-- A Knuth multiplicative hash of `dayIndex`, folded into the same 16-bit +-- range warren.lua's own default seed (os.time() & 0xFFFF) already uses, so +-- a challenge seed is never distinguishable from an ordinary one downstream. +function Challenges.seedFor(dayIndex) + return (dayIndex * 2654435761) % 65536 +end + +-- The preset and seed "day `dayIndex`" always resolves to - a pure function +-- of dayIndex alone, so the same day always hands out the same challenge to +-- everyone who asks it, and a test can walk arbitrary days without waiting +-- on a clock. +function Challenges.forDay(dayIndex) + local preset = Challenges.CATALOG[(dayIndex % #Challenges.CATALOG) + 1] + return preset, Challenges.seedFor(dayIndex) +end + +-- True iff `game` was actually played under `preset`'s own terms: started +-- as its class, with every mutator it demands active (see +-- Ascension.isActive) - extra mutators beyond the preset's own are fine, +-- only a missing requirement fails it. Returns (false, reason) naming +-- exactly what fell short, so a caller never has to re-derive why. +function Challenges.satisfiedBy(preset, game) + if game.player.class ~= preset.class then + return false, "started as " .. tostring(game.player.class) .. " instead of " .. preset.class + end + for _, key in ipairs(preset.mutators) do + if not Ascension.isActive(game.ascension, key) then + return false, "missing mutator " .. key + end + end + return true, nil +end + +-- One human-readable line describing `preset`: its display name, the class +-- it demands, and every mutator it demands active - exactly the terms +-- Challenges.satisfiedBy itself checks, spelled out for a player deciding +-- whether to attempt it. +function Challenges.describe(preset) + if #preset.mutators == 0 then + return string.format("%s (%s)", preset.name, preset.class) + end + return string.format("%s (%s + %s)", preset.name, preset.class, table.concat(preset.mutators, ", ")) +end + +return Challenges From a8b7261a8470ab5813677ba3e46827d3af58aeeb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 18:39:20 +0200 Subject: [PATCH 016/172] 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 From 2cc87fc08c7c9824c33b4779d6c894eed819a1de Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 19:47:04 +0200 Subject: [PATCH 017/172] feat: extend classes with Classes.isValid and Classes.apply --- src/classes.lua | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ src/combat.lua | 45 +++++++++++++++++++++++++++------- 2 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/classes.lua diff --git a/src/classes.lua b/src/classes.lua new file mode 100644 index 0000000..7b8bf43 --- /dev/null +++ b/src/classes.lua @@ -0,0 +1,64 @@ +-- Player classes. A class is chosen once at character creation (Game.new +-- options.class) and never changes; it grants a one-time stat or kit delta +-- and, through skills.lua, gates which skills the player is allowed to spend +-- points on. Classless play (no options.class given) is unaffected - every +-- class delta is opt-in so existing unclassed games keep their exact stats. + +local Items = require("items") + +local Classes = {} + +Classes.order = { "fighter", "rogue", "mage", "ranger" } + +Classes.defs = { + fighter = { + name = "fighter", + apply = function(player) + player.maxHp = player.maxHp + 8 + player.hp = player.hp + 8 + player.attack = player.attack + 2 + player.baseAttack = player.baseAttack + 2 + end, + }, + rogue = { + name = "rogue", + apply = function(player) + player.gold = player.gold + 15 + player.attack = player.attack + 1 + player.baseAttack = player.baseAttack + 1 + end, + }, + mage = { + name = "mage", + apply = function(player) + player.maxMana = player.maxMana + 8 + player.mana = player.mana + 8 + player.attack = player.attack - 1 + player.baseAttack = player.baseAttack - 1 + end, + }, + ranger = { + name = "ranger", + apply = function(player) + Items.equipBow(Items.bow("shortbow", 3, 6), player) + player.inventory[#player.inventory + 1] = Items.ammo("arrow", 12) + end, + }, +} + +function Classes.isValid(key) + return Classes.defs[key] ~= nil +end + +-- Applies the one-time delta for `key` to a freshly created player. Must run +-- after the player's base stats, mana pool and starting inventory are all in +-- place, since every delta is additive on top of whatever is already there. +function Classes.apply(player, key) + local def = Classes.defs[key] + if not def then return false end + player.class = key + def.apply(player) + return true +end + +return Classes diff --git a/src/combat.lua b/src/combat.lua index beca537..b0cc827 100644 --- a/src/combat.lua +++ b/src/combat.lua @@ -1,24 +1,53 @@ -- Deterministic combat: damage is effective attack minus effective defense, --- floored at 1. Effective stats fold in temporary status modifiers. +-- floored at 1. Effective stats fold in temporary status modifiers, plus +-- conditional skill bonuses (berserk/mana shield - see skills.lua, a no-op +-- for any entity without a `skills` table, i.e. monsters) and any completed +-- runeword on the wielder's own weapon/armor (see runewords.lua, a no-op for +-- any item without a matching rune chain), plus any item-set threshold the +-- wielder's current gear happens to meet (see sets.lua, a no-op below its +-- lowest 2-piece threshold). +-- The attack-minus-defense minimum-1 floor happens before elemental +-- resistance (resistances.lua) gets a say, so a defender immune to the +-- attack's damage type can still shrug off a hit that combat itself +-- guaranteed would land. + +local Skills = require("skills") +local Resistances = require("resistances") +local Runewords = require("runewords") +local Sets = require("sets") local Combat = {} local function attackOf(entity) - return entity.effectiveAttack and entity:effectiveAttack() or entity.attack + local base = entity.effectiveAttack and entity:effectiveAttack() or entity.attack + return base + Skills.attackBonus(entity) + Runewords.attackBonus(entity) + Sets.attackBonus(entity) end local function defenseOf(entity) - return entity.effectiveDefense and entity:effectiveDefense() or entity.defense + local base = entity.effectiveDefense and entity:effectiveDefense() or entity.defense + return base + Skills.defenseBonus(entity) + Runewords.defenseBonus(entity) + Sets.defenseBonus(entity) end -function Combat.damage(attacker, defender) - local dealt = attackOf(attacker) - defenseOf(defender) +-- The type of an attacker's raw damage: a weapon retagged by an elemental +-- affix (flaming/venomed - see affixes.lua) fights with that element instead +-- of a bare fist or claw's plain physical. +local function damageTypeOf(attacker) + return (attacker.weapon and attacker.weapon.damageType) or Resistances.PHYSICAL +end + +-- `multiplier` scales the raw attack-minus-defense difference before the +-- floor-at-1 minimum is applied, so a backstab still guarantees at least 1 +-- rather than 2x-ing an already-clamped hit. `damageType` defaults to the +-- attacker's own weapon typing when omitted. +function Combat.damage(attacker, defender, multiplier, damageType) + local dealt = (attackOf(attacker) - defenseOf(defender)) * (multiplier or 1) if dealt < 1 then dealt = 1 end - return dealt + dealt = math.floor(dealt) + return Resistances.apply(defender, dealt, damageType or damageTypeOf(attacker)) end -function Combat.attack(attacker, defender) - local dealt = Combat.damage(attacker, defender) +function Combat.attack(attacker, defender, multiplier, damageType) + local dealt = Combat.damage(attacker, defender, multiplier, damageType) defender.hp = defender.hp - dealt return { damage = dealt, killed = defender.hp <= 0 } end From 63b15fb14613036e69226b0f5f121d760feabe06 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 20:30:30 +0200 Subject: [PATCH 018/172] feat: add Commands.parse and isVisibleHostile --- src/commands.lua | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/commands.lua diff --git a/src/commands.lua b/src/commands.lua new file mode 100644 index 0000000..e492369 --- /dev/null +++ b/src/commands.lua @@ -0,0 +1,161 @@ +-- Text command parser and dispatcher for a human player, layered entirely on +-- top of the granular Game: methods choosePlayerAction (ai.lua's own +-- autopilot) already drives - nothing here duplicates combat, targeting or +-- turn bookkeeping, it only translates a typed word into the same call the +-- autopilot would have made. Every other player-facing system in this engine +-- is a mechanic; this is the one module whose whole job is the interface +-- between a line of text and those mechanics, which is why it takes `game` +-- as a plain argument rather than requiring game.lua itself (that require +-- runs the other way, from game.lua's Game:manualStep). +-- +-- Commands.apply(game, text) always returns (consumesTurn, message): a +-- movement/action word that actually did something returns true and leaves +-- Game:manualStep to spend the turn, exactly like choosePlayerAction would +-- have; an unrecognized word, a bad argument, or an action with nothing to +-- act on returns false and spends nothing, so a typo never costs a turn the +-- way a wasted keystroke would in any other roguelike. Purely informational +-- commands (inventory, look, status, help) always return false for the same +-- reason - reading your own inventory is free. + +local Spells = require("spells") +local Identify = require("identify") +local Factions = require("factions") +local Examine = require("examine") +local Skills = require("skills") + +local Commands = {} + +local DIRS = { + north = { 0, -1 }, n = { 0, -1 }, + south = { 0, 1 }, s = { 0, 1 }, + east = { 1, 0 }, e = { 1, 0 }, + west = { -1, 0 }, w = { -1, 0 }, + northeast = { 1, -1 }, ne = { 1, -1 }, + northwest = { -1, -1 }, nw = { -1, -1 }, + southeast = { 1, 1 }, se = { 1, 1 }, + southwest = { -1, 1 }, sw = { -1, 1 }, +} + +-- Splits `text` on whitespace into a lowercase verb plus its remaining +-- tokens as args. Returns nil for anything with no verb at all (an empty or +-- all-whitespace line), which Commands.apply treats the same as an unknown +-- command - no turn spent either way. +function Commands.parse(text) + if type(text) ~= "string" then return nil end + local tokens = {} + for token in text:gmatch("%S+") do + tokens[#tokens + 1] = token:lower() + end + if #tokens == 0 then return nil end + local args = {} + for i = 2, #tokens do args[#args + 1] = tokens[i] end + return { verb = tokens[1], args = args } +end + +-- A monster the player can actually see right now: alive, kind "monster" +-- (excludes the player's own companions/hirelings/thralls, which never +-- carry that kind - see game.lua's isPlayerAlly), and standing on a tile +-- game.visible already marks lit for the player this turn. +local function isVisibleHostile(game, entity) + return entity.kind == "monster" and entity:isAlive() + and game.visible[game.level.map:index(entity.x, entity.y)] == true +end + +-- Up to `count` nearest visible hostiles, Chebyshev distance, ties broken by +-- ascending id - the same tie rule Game:nearestAlly and companions.lua's own +-- nearestHostile already use, so auto-targeting a ranged command picks +-- exactly the monster the autopilot would have picked in the same spot. +local function nearestHostiles(game, count) + local player = game.player + local results, excluded = {}, {} + for _ = 1, count do + local best, bestDist + for _, entity in ipairs(game.level.entities) do + if not excluded[entity] and isVisibleHostile(game, entity) then + local dist = math.max(math.abs(entity.x - player.x), math.abs(entity.y - player.y)) + if not best or dist < bestDist or (dist == bestDist and entity.id < best.id) then + best, bestDist = entity, dist + end + end + end + if not best then break end + results[#results + 1] = best + excluded[best] = true + end + return results +end + +local function nearestHostile(game) + return nearestHostiles(game, 1)[1] +end + +-- Every visible hostile in game.level.entities's own order, unlike +-- nearestHostiles above which sorts by distance - this is the order +-- Handlers.look numbers its list in, so "examine monster " always means +-- the same monster the player just saw look print at that number. +local function visibleHostilesInOrder(game) + local list = {} + for _, entity in ipairs(game.level.entities) do + if isVisibleHostile(game, entity) then list[#list + 1] = entity end + end + return list +end + +-- Resolves an optional trailing target number - the exact 1-based index +-- Handlers.look/examine already print next to each visible hostile - to an +-- entity, falling back to the nearest visible hostile (the old, only +-- behaviour) when the caller omitted it. Every targeted handler below stays +-- correct for a player who never bothers naming a target, while letting a +-- player who cares (a dangerous archer standing behind an expendable rat) +-- reach past the default without any change to game.lua's own range/line-of- +-- sight checks, which still reject an out-of-reach explicit target exactly +-- like they already reject a too-far auto-picked one. +-- Returns (target, explicit): explicit is true only when the caller actually +-- passed a token for the slot, so callers can tell "you named a target that +-- is not there" apart from "there is no target in sight at all" and keep +-- their own existing wording for the latter. +local function resolveTarget(game, indexText) + if indexText == nil then return nearestHostile(game), false end + local index = tonumber(indexText) + return index and visibleHostilesInOrder(game)[index], true +end + +-- Commands.parse lowercases every token before a handler ever sees it, which +-- is harmless for directions/spells/factions/slots (already all-lowercase +-- keys) but not for Skills.active/Skills.passive, whose keys are camelCase +-- (secondWind, poisonBlade, manaShield) - a player typing the skill's own +-- name would otherwise never match it. Maps a lowercased word back to its +-- real key by case-insensitive scan; returns the word unchanged when nothing +-- matches, so an actually unknown skill still reaches Skills.use/Skills.learn +-- and gets their own "no such skill" wording instead of being swallowed here. +local function resolveSkillKey(word) + if not word then return word end + for key in pairs(Skills.active) do + if key:lower() == word then return key end + end + for key in pairs(Skills.passive) do + if key:lower() == word then return key end + end + return word +end + +local Handlers = {} + +local function moveHandler(dx, dy) + return function(game) + if game:movePlayer(dx, dy) then return true, nil end + return false, "you cannot go that way" + end +end + +for name, dir in pairs(DIRS) do + Handlers[name] = moveHandler(dir[1], dir[2]) +end + +Handlers.attack = function(game, args) + local dir = DIRS[args[1]] + if not dir then return false, "attack which direction?" end + if game:movePlayer(dir[1], dir[2]) then return true, nil end + return false, "there is nothing to attack there" +end + From 1d5768acd1fbca09f7512f14df98bae0df1c7d66 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 20:44:24 +0200 Subject: [PATCH 019/172] feat: add indexArg and hereHandler to commands --- src/commands.lua | 163 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/commands.lua b/src/commands.lua index e492369..7a8b375 100644 --- a/src/commands.lua +++ b/src/commands.lua @@ -159,3 +159,166 @@ Handlers.attack = function(game, args) return false, "there is nothing to attack there" end +Handlers.wait = function() return true, "you wait" end +Handlers.z = Handlers.wait +Handlers["."] = Handlers.wait + +Handlers.pickup = function(game) + game:pickupHere() + return true, nil +end +Handlers.g = Handlers.pickup + +-- Game:autoExplore already returns the exact (consumesTurn, message) shape +-- every handler here does - it just may have spent several turns getting +-- there instead of one, stopping itself the moment a hostile comes into +-- sight or the player takes damage rather than walking on regardless. +Handlers.explore = function(game) + return game:autoExplore() +end + +-- Game:restUntilHealed shares the same shape and the same "may spend several +-- turns before returning" behaviour as Game:autoExplore just above. +Handlers.rest = function(game) + return game:restUntilHealed() +end + +-- Game:travelTo shares that same "may spend several turns" shape too. "down"/ +-- "up" are the level's own stairs (self.level.downX/Y, upX/Y) - always a +-- valid, already-walkable destination regardless of what the player has +-- explored yet, exactly like choosePlayerAction's own goal-seeking already +-- assumes; a raw x/y command is deliberately not offered since nothing in +-- this interface ever shows the player a raw coordinate to aim one at. +Handlers.travel = function(game, args) + if args[1] == "down" then return game:travelTo(game.level.downX, game.level.downY) end + if args[1] == "up" then return game:travelTo(game.level.upX, game.level.upY) end + return false, "travel where? down or up" +end + +-- Every handler below that takes an inventory index shares the same shape: +-- a missing or non-numeric first arg fails without spending a turn, exactly +-- like an unknown verb, rather than crashing into the underlying Game +-- method with a nil index. +local function indexArg(args) + return tonumber(args[1]) +end + +Handlers.use = function(game, args) + local index = indexArg(args) + if not index then return false, "use what? give an inventory number" end + if game:useItem(index) then return true, nil end + return false, "you cannot use that" +end +Handlers.u = Handlers.use + +Handlers.drop = function(game, args) + local index = indexArg(args) + if not index then return false, "drop what? give an inventory number" end + if game:dropItem(index) then return true, nil end + return false, "you have nothing there to drop" +end + +Handlers.sell = function(game, args) + local index = indexArg(args) + if not index then return false, "sell what? give an inventory number" end + if game:sellItem(index) then return true, nil end + return false, "that cannot be sold here" +end + +-- Trailing target number is always the argument slot right after whatever +-- an individual handler needed for itself (an inventory index, a spell/skill +-- key) - see resolveTarget above for exactly what "explicit" changes. +Handlers.throw = function(game, args) + local index = indexArg(args) + if not index then return false, "throw what? give an inventory number" end + local target, explicit = resolveTarget(game, args[2]) + if not target then + if explicit then return false, "no monster in sight at that number" end + return false, "there is nothing in sight to throw at" + end + if game:throwAt(index, target) then return true, nil end + return false, "you cannot throw that" +end + +Handlers.capture = function(game, args) + local index = indexArg(args) + if not index then return false, "capture with what? give an inventory number" end + local target, explicit = resolveTarget(game, args[2]) + if not target then + if explicit then return false, "no monster in sight at that number" end + return false, "there is nothing in sight to capture" + end + if game:captureAt(index, target) then return true, nil end + return false, "the capture attempt fails" +end + +Handlers.fire = function(game, args) + local target, explicit = resolveTarget(game, args[1]) + if not target then + if explicit then return false, "no monster in sight at that number" end + return false, "there is nothing in sight to fire at" + end + if game:fireAt(target) then return true, nil end + return false, "you cannot fire right now" +end + +Handlers.cast = function(game, args) + local key = args[1] + if not key then return false, "cast what spell?" end + local def = Spells.catalog[key] + if not def then return false, "you know no spell by that name" end + local target = nil + if def.targeted then + local explicit + target, explicit = resolveTarget(game, args[2]) + if not target then + if explicit then return false, "no monster in sight at that number" end + return false, "there is nothing in sight to cast that at" + end + end + if game:castSpell(key, target) then return true, nil end + return false, "the spell fails" +end + +-- multishot's two targets are named together or not at all: giving only one +-- explicit number is ambiguous about which of the (up to) two shots it picks +-- out, so that case falls back to the same auto-pick-both-nearest behaviour +-- as giving none, rather than guessing which slot the player meant. +Handlers.useskill = function(game, args) + local key = resolveSkillKey(args[1]) + if not key then return false, "use which skill?" end + local success + if key == "cleave" then + local target, explicit = resolveTarget(game, args[2]) + if not target and explicit then return false, "no monster in sight at that number" end + success = game:useSkill(key, target) + elseif key == "multishot" then + if args[2] and args[3] then + local list = visibleHostilesInOrder(game) + local t1, t2 = list[tonumber(args[2]) or -1], list[tonumber(args[3]) or -1] + if not t1 or not t2 then return false, "no monster in sight at that number" end + success = game:useSkill(key, t1, t2) + else + local targets = nearestHostiles(game, 2) + success = game:useSkill(key, targets[1], targets[2]) + end + else + success = game:useSkill(key) + end + if success then return true, nil end + return false, "that skill fails" +end + +-- Zero-argument actions that always resolve against whatever stands on the +-- player's own tile (a chest, a corpse, a captive, a node, a stairwell...), +-- the same way every one of these Game methods already looks itself up by +-- self.player.x/y rather than taking a target. `nothingMessage` only ever +-- surfaces when the underlying method itself failed silently (no log line +-- of its own) - most branches already explain themselves through game.log. +local function hereHandler(methodName, nothingMessage) + return function(game) + if game[methodName](game) then return true, nil end + return false, nothingMessage + end +end + From 4e4d22cf2edbcb2ea787f3adc8147269221aedf2 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 21:29:54 +0200 Subject: [PATCH 020/172] feat: add targetArg to commands --- src/commands.lua | 155 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/commands.lua b/src/commands.lua index 7a8b375..6975164 100644 --- a/src/commands.lua +++ b/src/commands.lua @@ -322,3 +322,158 @@ local function hereHandler(methodName, nothingMessage) end end +Handlers.open = hereHandler("openChest", "there is nothing here to open") +Handlers.butcher = hereHandler("butcherCorpse", "there is nothing here to butcher") +Handlers.raise = hereHandler("raiseCorpse", "there is nothing here to raise") +Handlers.scavenge = hereHandler("scavengeCorpse", "there is nothing here to scavenge") +Handlers.harvest = hereHandler("harvestNode", "there is nothing here to harvest") +Handlers.ransom = hereHandler("ransomCaptive", "there is nothing here to ransom") +Handlers.sellcaptive = hereHandler("sellCaptive", "there is nothing here to sell") +Handlers.interrogate = hereHandler("interrogateCaptive", "there is nothing here to interrogate") +Handlers.descend = hereHandler("descend", "there are no stairs down here") +Handlers[">"] = Handlers.descend +Handlers.ascend = hereHandler("ascend", "there are no stairs up here") +Handlers["<"] = Handlers.ascend +Handlers.enterrift = hereHandler("enterRift", "there is no rift here to cross") +Handlers.exitrift = hereHandler("exitRift", "you are not standing across a rift") + +-- Game:startHorde is a deliberate, one-way trial (see its own header: the +-- dungeon floor left behind is simply abandoned, there is no exit back) that +-- always succeeds and, unlike every hereHandler above, carries no location +-- requirement of its own - so this guards against the one real footgun a +-- typed command adds that the method itself does not: retyping "horde" +-- mid-run would otherwise silently discard the current wave and its +-- progress by building a brand new arena on top of it. +Handlers.horde = function(game) + if game.horde then return false, "you are already fighting in the arena" end + game:startHorde() + return true, nil +end + +Handlers.pray = function(game) + local shrine = game.level:shrineAt(game.player.x, game.player.y) + if not shrine then return false, "there is no shrine here" end + if game:prayAt(shrine) then return true, nil end + return false, "your prayer goes unanswered" +end + +Handlers.quest = function(game) + local board = game.level:questBoardAt(game.player.x, game.player.y) + if not board then return false, "there is no quest board here" end + if game:tryAcceptQuest(board) then return true, nil end + return false, "you cannot take up that bounty" +end + +-- Every faction key factions.lua defines, spelled out here (rather than +-- discovered from the module at runtime) purely so a bad argument's error +-- message can list them by name; the set itself is built from the same +-- constants Game:appeaseFaction already trusts, so a typo can never buy +-- standing with a faction that does not exist. +local FACTION_NAMES = { + Factions.MERCHANT, Factions.GOBLINOID, Factions.VERMIN, Factions.ORCISH, + Factions.UNDEAD, Factions.BEAST, Factions.CULT, Factions.DRAKE, Factions.BANDIT, +} +local FACTION_SET = {} +for _, name in ipairs(FACTION_NAMES) do FACTION_SET[name] = true end + +Handlers.appease = function(game, args) + local faction = args[1] + if not faction or not FACTION_SET[faction] then + return false, "appease which faction? " .. table.concat(FACTION_NAMES, ", ") + end + if game:appeaseFaction(faction) then return true, nil end + return false, nil +end + +Handlers.dig = function(game, args) + local dir = DIRS[args[1]] + if not dir then return false, "dig which direction?" end + if game:dig(dir[1], dir[2]) then return true, nil end + return false, "you cannot dig there" +end + +-- Disarms a detected-but-unsprung trap one step away in `dir` - the same +-- Chebyshev-1 reach Disarm.sweep already uses to notice it in the first +-- place (see Game:endPlayerTurn's "you notice a hidden trap nearby"). The +-- validity check below duplicates Game:disarmTrap's own guard on purpose: +-- unlike every other handler here, a disarm attempt against a real trap +-- must still spend the turn even when Disarm.attempt fails and springs it, +-- but Game:disarmTrap's own boolean return conflates that spring with "no +-- trap here" into the same false - so this handler tells the two apart +-- itself instead of trusting the method's return value for turn cost. +Handlers.disarm = function(game, args) + local dir = DIRS[args[1]] + if not dir then return false, "disarm which direction?" end + local trap = game.level:trapAt(game.player.x + dir[1], game.player.y + dir[2]) + if not trap or not trap.detected or trap.revealed then + return false, "there is nothing here you have noticed to disarm" + end + game:disarmTrap(trap) + return true, nil +end + +-- Crafting/economy verbs (game.lua's forgeGem/mergeGemsAt/socketItem/ +-- socketRune/temperItem/enchantItem/disenchantItem/repairEquipped/ +-- stealFromShop/dismissHireling/learnSkill/brewAt), the other half of the +-- API choosePlayerAction can already reach that a typed session could not +-- until now. Every one of these Game methods already logs its own success +-- or failure reason, so a handler only needs its own message for an +-- argument so malformed the underlying method can never even be called. + +-- Shared by socket/socketrune/temper/enchant/disenchant: their `target` +-- resolves through Game:resolveGear exactly like this, either the literal +-- word for currently equipped gear or an inventory slot number for +-- anything else (a spare ring, an unequipped bow). +local function targetArg(token) + if token == "weapon" or token == "armor" then return token end + return tonumber(token) +end + +Handlers.forge = function(game, args) + local element = args[1] + if not element then return false, "forge which element? fire, frost, shock or venom" end + if game:forgeGem(element) then return true, nil end + return false, nil +end + +Handlers.merge = function(game, args) + local a, b = tonumber(args[1]), tonumber(args[2]) + if not a or not b then return false, "merge which two gems? give two inventory numbers" end + if game:mergeGemsAt(a, b) then return true, nil end + return false, "those cannot be merged" +end + +Handlers.socket = function(game, args) + local target, gemIndex = targetArg(args[1]), tonumber(args[2]) + if not target or not gemIndex then + return false, "socket what into what? give a target (weapon/armor/item number) and a gem number" + end + if game:socketItem(target, gemIndex) then return true, nil end + return false, "that cannot be socketed" +end + +Handlers.socketrune = function(game, args) + local target, runeIndex = targetArg(args[1]), tonumber(args[2]) + if not target or not runeIndex then + return false, "socket which rune into what? give a target (weapon/armor/item number) and a rune number" + end + if game:socketRune(target, runeIndex) then return true, nil end + return false, "that cannot be socketed" +end + +Handlers.temper = function(game, args) + local target = targetArg(args[1]) + if not target then return false, "temper what? weapon, armor or an item number" end + if game:temperItem(target) then return true, nil end + return false, "that cannot be tempered" +end + +Handlers.enchant = function(game, args) + local target, affixKey = targetArg(args[1]), args[2] + if not target or not affixKey then + return false, "enchant what with what? give a target (weapon/armor/item number) and an affix name" + end + if game:enchantItem(target, affixKey) then return true, nil end + return false, "that cannot be enchanted" +end + From 6f9d811c8174dfbca31b9428db756b406dba4ece Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 22:31:27 +0200 Subject: [PATCH 021/172] feat: add groundIndexHere and Commands.apply to commands --- src/commands.lua | 146 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/commands.lua b/src/commands.lua index 6975164..a5b21d0 100644 --- a/src/commands.lua +++ b/src/commands.lua @@ -477,3 +477,149 @@ Handlers.enchant = function(game, args) return false, "that cannot be enchanted" end +Handlers.disenchant = function(game, args) + local target = targetArg(args[1]) + if not target then return false, "disenchant what? weapon, armor or an item number" end + if game:disenchantItem(target) then return true, nil end + return false, "that cannot be disenchanted" +end + +Handlers.repair = function(game, args) + local slot = args[1] + if slot ~= "weapon" and slot ~= "armor" and slot ~= "bow" then + return false, "repair what? weapon, armor or bow" + end + if game:repairEquipped(slot) then return true, nil end + return false, nil +end + +-- Steals whichever warded ware sits underfoot, the same tile Game:pickupHere +-- already resolves against - unlike Game:stealFromShop's own raw floorItems +-- index, a typed command can only ever mean "the ware I am standing on". +local function groundIndexHere(game) + for i, ground in ipairs(game.level.floorItems) do + if ground.x == game.player.x and ground.y == game.player.y then return i, ground end + end + return nil +end + +Handlers.steal = function(game) + local index = groundIndexHere(game) + if index and game:stealFromShop(index) then return true, nil end + return false, "there is nothing here worth stealing" +end + +Handlers.dismiss = function(game) + if game:dismissHireling() then return true, nil end + return false, nil +end + +Handlers.learn = function(game, args) + local key = resolveSkillKey(args[1]) + if not key then return false, "learn which skill?" end + game:learnSkill(key) + return false, nil +end + +-- Duplicates Game:brewAt's own precondition checks (cauldron underfoot, two +-- distinct herb items) before ever calling it, the same shape as disarm +-- above and for the same reason: past those preconditions, brewing always +-- spends both herbs, whether the pairing brews a potion or curdles into gas +-- (see Alchemy.brew), so this handler must not read a curdle's own `false` +-- as "nothing happened" and hand the player a free turn back. +Handlers.brew = function(game, args) + local a, b = tonumber(args[1]), tonumber(args[2]) + if not a or not b then return false, "brew which two herbs? give two inventory numbers" end + if not game.level:cauldronAt(game.player.x, game.player.y) then + return false, "there is no cauldron here" + end + if a == b then return false, "brew two different herbs, not the same one twice" end + local herbA, herbB = game.player.inventory[a], game.player.inventory[b] + if not herbA or not herbB or herbA.kind ~= "herb" or herbB.kind ~= "herb" then + return false, "you need two herbs to brew" + end + game:brewAt(a, b) + return true, nil +end + +-- Purely informational commands. Every one of these returns false (spends no +-- turn) and carries its whole answer in the message, which is the one place +-- a caller should print something the game log itself would never contain. +Handlers.inventory = function(game) + if #game.player.inventory == 0 then return false, "you are carrying nothing" end + local lines = {} + for i, item in ipairs(game.player.inventory) do + lines[#lines + 1] = string.format(" %d) %s", i, Identify.label(game.identify, item)) + end + return false, table.concat(lines, "\n") +end +Handlers.i = Handlers.inventory + +Handlers.status = function(game) + local p = game.player + return false, string.format( + "hp %d/%d mana %d/%d gold %d essence %d level %d xp %d/%d depth %d turn %d", + p.hp, p.maxHp, p.mana or 0, p.maxMana or 0, p.gold, p.essence, + p.level, p.xp, p.xpNext, game.depth, game.turn) +end + +Handlers.look = function(game) + local targets = visibleHostilesInOrder(game) + if #targets == 0 then return false, "you see no threats nearby" end + local names = {} + for i, entity in ipairs(targets) do + names[#names + 1] = string.format("%d) %s", i, entity.name) + end + return false, "in sight: " .. table.concat(names, ", ") +end +Handlers.l = Handlers.look + +-- Detail views (examine.lua): `examine ` inspects inventory item n, the +-- same index use/drop/sell already key off; `examine monster ` inspects +-- the nth entry of the very list Handlers.look just numbered. Both branches +-- are free, like every other informational command here. +Handlers.examine = function(game, args) + if args[1] == "monster" then + local index = tonumber(args[2]) + if not index then return false, "examine which monster? give the number look showed you" end + local target = visibleHostilesInOrder(game)[index] + if not target then return false, "no monster in sight at that number" end + return false, Examine.monster(game.player, target) + end + local index = indexArg(args) + if not index then return false, "examine what? give an inventory number, or 'monster '" end + local item = game.player.inventory[index] + if not item then return false, "you are not carrying anything at that number" end + return false, Examine.item(game.identify, item) +end +Handlers.x = Handlers.examine + +Handlers.help = function() + return false, "commands: north/south/east/west/ne/nw/se/sw (or n/s/e/w/...), attack , " .. + "wait, pickup, explore, rest, travel down/up, inventory, use , drop , sell , throw [target], " .. + "fire [target], capture [target], cast [target], useskill [target], " .. + "open, pray, quest, appease , butcher, raise, " .. + "scavenge, harvest, ransom, sellcaptive, interrogate, dig , disarm , " .. + "descend, ascend, enterrift, exitrift, horde, " .. + "steal, dismiss, learn , forge , merge , brew , " .. + "socket , socketrune , temper , " .. + "enchant , disenchant , repair , " .. + "examine , examine monster , look, status, help " .. + "([target] is the number look/examine gives a monster; omit it to auto-target the nearest one)" +end + +-- Parses `text` and dispatches it against `game`. Returns (consumesTurn, +-- message): Game:manualStep spends the player's turn iff the first value is +-- true, and either surfaces `message` itself (a failure reason, or an +-- informational answer) or leaves the game log to speak for a successful +-- action, exactly the way every Game: method already logs its own +-- outcome. +function Commands.apply(game, text) + local parsed = Commands.parse(text) + if not parsed then return false, "say something" end + local handler = Handlers[parsed.verb] + if not handler then return false, "unknown command: " .. parsed.verb end + return handler(game, parsed.args) +end + +return Commands From 972f6e00ecb4f4f3d942ce85a8a3590aa250b5de Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 22:51:39 +0200 Subject: [PATCH 022/172] feat: add Companions.summon and adjacent --- src/companions.lua | 188 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/companions.lua diff --git a/src/companions.lua b/src/companions.lua new file mode 100644 index 0000000..a847b61 --- /dev/null +++ b/src/companions.lua @@ -0,0 +1,188 @@ +-- Player-aligned allies bonded from a charm found on the floor. Once summoned +-- a companion joins the turn scheduler exactly like a monster (see game.lua's +-- takeMonsterTurn), but its own turn logic below hunts nearby hostile +-- monsters instead of the player and never turns hostile itself. Only one +-- companion may be bonded at a time - summoning a second retires the first. +-- +-- Static per-species numbers (sight radius, heal rate) are looked up from +-- Companions.byKey by species key rather than copied onto the entity, the +-- same convention monsters.lua uses for xp: one source of truth, and nothing +-- extra to add to entity.lua's serialization whitelist. A companion's own +-- battle-earned progress (companionXp/companionLevel/companionHealBonus +-- below) is the opposite: per-instance and mutable, so unlike the static +-- table above it does need its own prefixed trio in that whitelist, the same +-- way mercenaries.lua's wage/loyalty or reanimation.lua's decay already do. + +local Entity = require("entity") +local Combat = require("combat") +local Los = require("los") +local Monsters = require("monsters") +local Status = require("status") +local Noise = require("noise") + +local Companions = {} + +-- `evolutions` entries are climbed in order, each permanently once +-- companionXp first reaches its `xp` (a running total, not a delta - the +-- same shape titles.lua's own ladder uses): the companion is renamed and its +-- hp/attack/defense grow by that entry's own flat amount, on top of +-- whatever an earlier stage already added rather than replacing it. A +-- hearth sprite's `heal` entries stack the same way into companionHealBonus, +-- read by tendPlayer below alongside the species' own static heal rate. +Companions.species = { + { key = "hound", glyph = "d", name = "hunting hound", hp = 14, attack = 4, defense = 0, speed = 130, sight = 7, + evolutions = { + { level = 2, xp = 12, name = "warhound", hp = 6, attack = 2, defense = 1 }, + { level = 3, xp = 40, name = "dire hound", hp = 10, attack = 3, defense = 1 }, + } }, + { key = "golem", glyph = "G", name = "clay golem", hp = 26, attack = 5, defense = 3, speed = 70, sight = 5, + evolutions = { + { level = 2, xp = 15, name = "stone golem", hp = 10, attack = 2, defense = 2 }, + { level = 3, xp = 45, name = "iron golem", hp = 16, attack = 3, defense = 3 }, + } }, + { key = "sprite", glyph = "e", name = "hearth sprite", hp = 8, attack = 1, defense = 0, speed = 110, sight = 8, heal = 2, + evolutions = { + { level = 2, xp = 10, name = "kindled sprite", hp = 4, attack = 1, defense = 0, heal = 1 }, + { level = 3, xp = 35, name = "radiant sprite", hp = 6, attack = 1, defense = 1, heal = 2 }, + } }, +} + +Companions.byKey = {} +Companions.order = {} +for _, spec in ipairs(Companions.species) do + Companions.byKey[spec.key] = spec + Companions.order[#Companions.order + 1] = spec.key +end + +local ADJACENT = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } } + +-- Bonds a fresh companion of `key` onto a free cell next to the player, +-- retiring any earlier bond first. Returns the new companion, or nil if +-- every cell around the player is blocked. +function Companions.summon(game, key) + local spec = Companions.byKey[key] + if not spec then return nil end + for _, other in ipairs(game.level.entities) do + if other.kind == "companion" and other:isAlive() then other.hp = 0 end + end + game:cleanupDead() + local player = game.player + for _, dir in ipairs(ADJACENT) do + local x, y = player.x + dir[1], player.y + dir[2] + if game.level.map:isWalkable(x, y) and not game.level:entityAt(x, y) then + local companion = Entity.new({ + id = game:nextMonsterId(), x = x, y = y, + glyph = spec.glyph, name = spec.name, kind = "companion", + species = key, hp = spec.hp, attack = spec.attack, defense = spec.defense, speed = spec.speed, + }) + companion.companionXp = 0 + companion.companionLevel = 1 + game.level.entities[#game.level.entities + 1] = companion + return companion + end + end + return nil +end + +-- Chebyshev-1 (8-directional), matching mercenaries.lua's and reanimation.lua's +-- own "adjacent" helpers exactly - both of those modules explicitly describe +-- their combat branch as "the same three-branch shape as Companions.act", so +-- this was meant to be one shared rule, not three independently-typed copies. +-- This one alone used Manhattan-1 (4-directional) until now, so a companion +-- caught diagonally next to a hostile monster fell through to +-- stepMonsterToward instead of attacking - the exact same geometry a hireling +-- or thrall standing in already engages without moving. +local function adjacent(a, b) + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) == 1 +end + +-- Nearest living monster the companion can see within its species' sight +-- radius, ties broken by ascending id for determinism. A blinded companion +-- loses every longer-ranged sighting and falls back to melee range only, +-- the same clamp Game:canSeePlayer applies to a blinded hostile monster. +local function nearestHostile(game, companion) + local spec = Companions.byKey[companion.species] + local sight = spec.sight + if Status.has(companion, Status.BLIND) then sight = math.min(sight, 1) end + local best, bestDist = nil, math.huge + for _, entity in ipairs(game.level.entities) do + if entity.kind == "monster" and entity:isAlive() then + local dist = math.max(math.abs(entity.x - companion.x), math.abs(entity.y - companion.y)) + if dist <= sight and Los.clear(game.level.map, companion.x, companion.y, entity.x, entity.y) then + if dist < bestDist or (dist == bestDist and entity.id < best.id) then + best, bestDist = entity, dist + end + end + end + end + return best +end + +-- A hearth sprite mends its bonded player a little on any turn it does not +-- spend fighting - the only companion with a passive off-turn effect. An +-- evolved sprite's companionHealBonus (see Companions.gainXp) tops up its +-- species' own static rate rather than replacing it. +local function tendPlayer(game, companion) + local spec = Companions.byKey[companion.species] + if spec.heal and game.player.hp < game.player.maxHp then + local amount = spec.heal + (companion.companionHealBonus or 0) + game.player.hp = math.min(game.player.maxHp, game.player.hp + amount) + end +end + +-- Credits a companion's own kill tally with `amount` xp and, in the same +-- call, permanently applies every evolution stage (see Companions.species) +-- its running companionXp total first reaches - possibly more than one at +-- once, if a single grant vaults it clean past an intermediate threshold. +-- Entirely separate from the xp Game:awardKill still credits the player for +-- that identical kill (see Companions.act): the player's own level and this +-- companion's are two independent ladders climbed off the same battles. +function Companions.gainXp(game, companion, amount) + if not amount or amount <= 0 then return end + companion.companionXp = (companion.companionXp or 0) + amount + companion.companionLevel = companion.companionLevel or 1 + local spec = Companions.byKey[companion.species] + if not spec or not spec.evolutions then return end + for _, stage in ipairs(spec.evolutions) do + if companion.companionLevel < stage.level and companion.companionXp >= stage.xp then + companion.companionLevel = stage.level + companion.name = stage.name + companion.maxHp = companion.maxHp + stage.hp + companion.hp = companion.hp + stage.hp + companion.attack = companion.attack + stage.attack + companion.defense = companion.defense + stage.defense + if stage.heal then + companion.companionHealBonus = (companion.companionHealBonus or 0) + stage.heal + end + game.log:add("your " .. companion.name .. " evolves, growing stronger") + end + end +end + +-- The companion's full turn: engage the nearest visible monster if one is in +-- reach, close the distance to one it can see but not yet reach, otherwise +-- stay within a step of the player. +function Companions.act(game, companion) + if game:allyActsConfused(companion) then return end + local target = nearestHostile(game, companion) + if target and adjacent(companion, target) then + local outcome = Combat.attack(companion, target) + game.log:add(string.format("your %s hits %s for %d", companion.name, target.name, outcome.damage)) + game:emitNoise(companion.x, companion.y, Noise.MELEE) + if outcome.killed then + game:awardKill(target) + Companions.gainXp(game, companion, Monsters.xpFor(target)) + end + return + end + if target then + game:stepMonsterToward(companion, target.x, target.y) + return + end + tendPlayer(game, companion) + if not adjacent(companion, game.player) and not (companion.x == game.player.x and companion.y == game.player.y) then + game:stepMonsterToward(companion, game.player.x, game.player.y) + end +end + +return Companions From 4c5bae5a0001c2052f5a3c46b7e673749282ac53 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 11:48:10 +0200 Subject: [PATCH 023/172] feat: add Compendium.new, Compendium.noteSighting and Conductivity.isWet --- src/compendium.lua | 93 ++++++++++++++++++++++++++++++++++++++++++++ src/conductivity.lua | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/compendium.lua create mode 100644 src/conductivity.lua diff --git a/src/compendium.lua b/src/compendium.lua new file mode 100644 index 0000000..76f65a3 --- /dev/null +++ b/src/compendium.lua @@ -0,0 +1,93 @@ +-- Cross-run bestiary: which species the player has ever laid eyes on, and how +-- many of each they have slain. A compendium lives inside meta.lua's +-- persistent save (see game.lua's Game.new, which reuses options.meta's own +-- table by reference so every sighting and kill recorded mid-run is already +-- sitting in the very table warren.lua writes back out with Meta.save) so it +-- survives death exactly like renown and perks; a run started without +-- --meta still gets a fresh, un-persisted one so every code path exercises +-- the same bookkeeping either way. + +local Monsters = require("monsters") +local Bosses = require("bosses") + +local Compendium = {} + +-- One evocative line per species, keyed the same as monsters.lua's species +-- key or, for a boss, its bosses.lua catalog key (without the "boss_" +-- prefix game.lua stamps onto the actual entity's `species` field). +Compendium.FLAVOR = { + rat = "Bold enough to bite an ankle, never bold enough to fight fair.", + bat = "Flits erratically through the dark on a course only it can hear.", + kobold = "Travels in twos and threes; alone it barely has the nerve to bite.", + goblin = "Quick with a rusty blade, quicker to run once its pack scatters.", + archer = "A skeleton archer, more patient dead than most men are living.", + orc = "Broad-shouldered muscle with no plan beyond the next swing.", + wretch = "Something that used to be a person, now just hungry and afraid.", + troll = "Wounds close almost as fast as they open; fire is the only argument it respects.", + mage = "A dark mage who traded caution for whatever is behind that door.", + dragon = "Young enough to still be reckless, old enough to already breathe fire.", + zombie = "Shambles forward on rot and spite alone.", + slime = "Splits when struck; killing one carelessly just makes two problems.", + footpad = "Light-fingered and lighter on its feet - it wants your purse, not a fight.", + shaman = "Chants curses from a safe distance and flees the moment that fails.", + necromancer = "Would rather raise a friend from your kills than make one of its own.", + cleric = "Keeps its allies standing longer than they have any right to.", + chieftain = "Rallies its kin with a bellow that carries through the whole floor.", + shopkeeper = "Minds its own business and its stock, right up until you take both.", + warlord = "The Orc Warlord, whose rage only sharpens the longer a fight runs.", + lich = "The Lich, a will that outlasted its own body decades ago.", + bonequeen = "The Bone Queen, who never lacks for company - the dead keep answering her call.", +} + +function Compendium.new() + return { seen = {}, kills = {} } +end + +-- Marks `speciesKey` as sighted. Returns true only the first time it is ever +-- seen, so a caller can choose whether to make anything of that moment; a +-- repeat sighting is silently a no-op. +function Compendium.noteSighting(compendium, speciesKey) + if not speciesKey or compendium.seen[speciesKey] then return false end + compendium.seen[speciesKey] = true + return true +end + +function Compendium.noteKill(compendium, speciesKey) + if not speciesKey then return end + compendium.kills[speciesKey] = (compendium.kills[speciesKey] or 0) + 1 +end + +function Compendium.killCount(compendium, speciesKey) + return compendium.kills[speciesKey] or 0 +end + +function Compendium.hasSeen(compendium, speciesKey) + return compendium.seen[speciesKey] == true +end + +-- Every species named in the wild bestiary or the boss catalog, in a fixed +-- order (wild species first, then bosses), each row carrying its catalogued +-- name, flavor line and this compendium's own seen/kill tally. `key` here +-- matches exactly what an actual entity's `species` field would hold, so a +-- caller can cross-reference the two without re-deriving the "boss_" prefix. +function Compendium.entries(compendium) + local rows = {} + for _, spec in ipairs(Monsters.species) do + rows[#rows + 1] = { + key = spec.key, name = spec.name, flavor = Compendium.FLAVOR[spec.key], + seen = Compendium.hasSeen(compendium, spec.key), + kills = Compendium.killCount(compendium, spec.key), + } + end + for _, spec in ipairs(Bosses.catalog) do + local key = "boss_" .. spec.key + rows[#rows + 1] = { + key = key, name = spec.name, flavor = Compendium.FLAVOR[spec.key], + seen = Compendium.hasSeen(compendium, key), + kills = Compendium.killCount(compendium, key), + } + end + return rows +end + +return Compendium diff --git a/src/conductivity.lua b/src/conductivity.lua new file mode 100644 index 0000000..24dec0c --- /dev/null +++ b/src/conductivity.lua @@ -0,0 +1,80 @@ +-- Elemental terrain interaction between open water and the fire/lightning +-- damage types resistances.lua already defines. Stepping into water leaves an +-- entity soaked for a few turns after it moves on, not just on the exact +-- water tile - the lingering dampness other systems read through Conductivity +-- rather than re-checking the tile underfoot themselves. A soaked entity +-- resists catching fire outright (Status.apply refuses a BURNING application, +-- the same way a water tile already douses tile fire in Hazards.step) and, +-- for damage that already lands, dampens further fire (Conductivity.applyFire, +-- used by hazards.lua's fire tick and status.lua's own BURNING dot) while +-- conducting lightning for a bonus (Conductivity.applyLightning, used by +-- spells.lua's shock bolt) - the water clinging to them is exactly what makes +-- a live wire so much worse to be standing in. This is scoped to fire/ +-- lightning that already routes through here; an instantaneous hit like +-- lava contact or a breath attack that computes its own damage inline is +-- left alone - only ignition-prevention (resistsIgnition) reaches those. +-- +-- LIGHTNING was previously a fully declared Resistances type with nothing in +-- the game that ever dealt it - elites could roll a resist tier for a damage +-- type nothing ever used. Conductivity.applyLightning plus spells.lua's new +-- shock bolt spell are what makes an elite's rolled lightning resistance +-- (see elites.lua) matter for the first time. + +local Tiles = require("tiles") +local Resistances = require("resistances") + +local Conductivity = {} + +Conductivity.SOAK_DURATION = 3 +Conductivity.LIGHTNING_BONUS_PERCENT = 50 +Conductivity.FIRE_DAMPEN_PERCENT = 50 + +function Conductivity.isWet(entity) + return (entity.wet or 0) > 0 +end + +-- Refreshes `entity`'s soak counter to the full duration when it ends its +-- turn standing on open water, or counts it down by one otherwise - the same +-- "renew on cause, decay on absence" shape a stacked status.lua effect's own +-- duration follows, but tracked as a plain entity field since it is a terrain +-- reaction rather than something any status.lua effect key describes. +function Conductivity.tick(level, entity) + if level.map:get(entity.x, entity.y) == Tiles.WATER then + entity.wet = Conductivity.SOAK_DURATION + elseif (entity.wet or 0) > 0 then + entity.wet = entity.wet - 1 + end +end + +-- Fire damage against a soaked target is dampened on top of whatever +-- Resistances.tierOf already resolves - drenched cloth or hide smoulders +-- rather than catching properly. Immune still floors it at zero. +function Conductivity.applyFire(entity, amount) + local dealt = Resistances.apply(entity, amount, Resistances.FIRE) + if dealt > 0 and Conductivity.isWet(entity) then + dealt = math.max(1, dealt - (dealt * Conductivity.FIRE_DAMPEN_PERCENT) // 100) + end + return dealt +end + +-- Lightning damage against a soaked target conducts through the water +-- clinging to them for a bonus on top of whatever Resistances.tierOf already +-- resolves - immune still blocks it outright, same as any other type. +function Conductivity.applyLightning(entity, amount) + local dealt = Resistances.apply(entity, amount, Resistances.LIGHTNING) + if dealt > 0 and Conductivity.isWet(entity) then + dealt = dealt + (dealt * Conductivity.LIGHTNING_BONUS_PERCENT) // 100 + end + return dealt +end + +-- A soaked target's own dampness snuffs any attempt to set them alight +-- outright, before the BURNING effect is ever recorded (see status.lua's +-- Status.apply) - the same protection a water tile already gives tile fire +-- in Hazards.step, extended to an entity that carries its own wetness onto +-- dry ground. +function Conductivity.resistsIgnition(entity) + return Conductivity.isWet(entity) +end + +return Conductivity From 41f23f839873afdf74ae7537971997107ccc7f22 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 11:48:56 +0200 Subject: [PATCH 024/172] feat: add Corpses.leavesCorpse and nutritionFor --- src/corpses.lua | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/corpses.lua diff --git a/src/corpses.lua b/src/corpses.lua new file mode 100644 index 0000000..5b77b09 --- /dev/null +++ b/src/corpses.lua @@ -0,0 +1,103 @@ +-- Raw meat left behind by a fallen wild monster - a second, turn-costed food +-- source alongside a found or bought ration (see Items.food, shop.lua). A +-- corpse decays over a fixed number of rounds whether or not the player is +-- even on this floor to see it (Corpses.step runs off Game:endPlayerTurn +-- exactly like hazards.lua's fire/gas and nodes.lua's vein cooldown); left +-- alone past its rot threshold it still butchers in one stroke, but the +-- reward turns from a meal into a bout of poisoning instead. Undead crumble +-- to dust and leave nothing to carve (see factions.lua's UNDEAD tag), a mimic +-- was never alive to begin with (mimics.lua), and a boss's fixed loot drop +-- (bosses.lua) is reward enough on its own - none of the three spawn a +-- corpse. Corpses are plain data on level.corpses, exactly like +-- level.hazards and level.nodes, so they serialize with no special +-- save-format handling. + +local Factions = require("factions") +local Items = require("items") +local Status = require("status") + +local Corpses = {} + +Corpses.DECAY_TURNS = 60 +Corpses.ROT_TURNS = 25 + +Corpses.NUTRITION_MIN = 30 +Corpses.NUTRITION_MAX = 200 +Corpses.NUTRITION_PER_HP = 8 + +-- Wild monster corpses only: the undead were already dead before the player +-- got there, a mimic's "corpse" would just be its loot disguise falling +-- apart, and a boss's own scripted drop already covers its reward. +function Corpses.leavesCorpse(target) + return target.faction ~= Factions.UNDEAD and not target.mimic and not target.isBoss +end + +-- Nutrition scales with how tough the felled species was (maxHp, itself +-- depth-scaled per monsters.lua), clamped to a fixed band so a late-game +-- dragon corpse never trivializes hunger outright. +local function nutritionFor(maxHp) + local raw = maxHp * Corpses.NUTRITION_PER_HP + return math.max(Corpses.NUTRITION_MIN, math.min(Corpses.NUTRITION_MAX, raw)) +end + +function Corpses.new(target) + return { + x = target.x, y = target.y, + species = target.species, name = target.name, + nutrition = nutritionFor(target.maxHp), + decay = Corpses.DECAY_TURNS, + } +end + +-- Appends a fresh corpse for `target` onto `level.corpses`, or does nothing +-- for anything Corpses.leavesCorpse rules out. +function Corpses.spawn(level, target) + if not Corpses.leavesCorpse(target) then return end + level.corpses[#level.corpses + 1] = Corpses.new(target) +end + +function Corpses.isRotten(corpse) + return corpse.decay <= Corpses.ROT_TURNS +end + +-- Advances every corpse on the level one round toward fully rotting away, +-- exactly like hazards.step and nodes.step - called once per round, not per +-- actor, so a seeded run always loses a given corpse on the identical turn +-- regardless of how many actors acted that round. +function Corpses.step(level) + for i = #level.corpses, 1, -1 do + local corpse = level.corpses[i] + corpse.decay = corpse.decay - 1 + if corpse.decay <= 0 then table.remove(level.corpses, i) end + end +end + +-- Drops `corpse` off level.corpses outright - shared by every way of +-- spending one (see Corpses.butcher below and scavenging.lua's own harvest), +-- so a corpse can never be carved twice over. +function Corpses.remove(level, corpse) + for i, candidate in ipairs(level.corpses) do + if candidate == corpse then + table.remove(level.corpses, i) + return + end + end +end + +-- Carves up `corpse` in one stroke, always spending it whether the meat is +-- still good or not. A corpse still inside its rot threshold yields a fresh +-- Items.food ration into the player's inventory, eaten later exactly like a +-- found one; a corpse butchered past that threshold instead poisons the +-- player outright - the corpse itself is just as gone either way. +function Corpses.butcher(game, corpse) + Corpses.remove(game.level, corpse) + if Corpses.isRotten(corpse) then + Status.apply(game.player, Status.POISON, 5, 2) + return string.format("the %s corpse has rotted; you gag it down and immediately regret it", corpse.name) + end + local food = Items.food(corpse.name .. " meat", corpse.nutrition) + game.player.inventory[#game.player.inventory + 1] = food + return string.format("you carve %d nutrition worth of meat from the %s corpse", corpse.nutrition, corpse.name) +end + +return Corpses From f0626d51ada88412140a1dd50aced704c4d2569d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 17:26:28 +0200 Subject: [PATCH 025/172] feat: add isElement and Crafting.gem --- src/crafting.lua | 181 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/crafting.lua 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 From bdf3624b02f2ed6ffbd08c2da59d35e76b2c000f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 18:29:50 +0200 Subject: [PATCH 026/172] 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 From c214f5c12777f9a7edeee74b664e7463f42a29a6 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 18:42:26 +0200 Subject: [PATCH 027/172] feat: add Digging.canDig, Digging.swing and Disarm.sweep --- src/digging.lua | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ src/disarm.lua | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/digging.lua create mode 100644 src/disarm.lua diff --git a/src/digging.lua b/src/digging.lua new file mode 100644 index 0000000..2422cb5 --- /dev/null +++ b/src/digging.lua @@ -0,0 +1,113 @@ +-- Tunneling through plain rock, player-driven with a limited-use pick and +-- monster-driven for a burrower born to it (see monsters.lua's "burrower" +-- species and ai.lua's "tunnel" behaviour). A dug wall never clears in one +-- swing: it crumbles to Tiles.RUBBLE first - already walkable and already +-- open to sight, unlike the wall it replaced - and only a second swing +-- against that same cell clears it the rest of the way to open floor, so a +-- tunnel always costs two hits, never one, whoever is swinging. A vault's +-- own boundary (Tiles.VAULT_DOOR) never yields no matter how many swings it +-- takes - only the lever it guards opens it (see Game:pullLever). Every +-- swing risks a cave-in that buries the digger for damage instead of +-- yielding progress, and a lucky one strikes a raw vein outright - clearing +-- the wall in a single stroke and paying out essence exactly like a spent +-- nodes.lua vein, but only for a digger that tracks essence at all (the +-- player; a no-op reward for anything else, i.e. a monster). +-- Digging mutates the map tile grid directly, exactly like terrain.lua's +-- hazards bake straight into it, so a dug tunnel needs no special +-- save-format handling of its own - the map already serializes. + +local Tiles = require("tiles") + +local Digging = {} + +Digging.CAVE_IN_CHANCE_DEN = 8 +Digging.CAVE_IN_DAMAGE_MIN = 2 +Digging.CAVE_IN_DAMAGE_MAX = 5 +Digging.ORE_STRIKE_CHANCE_DEN = 10 +Digging.ORE_ESSENCE_MIN = 4 +Digging.ORE_ESSENCE_MAX = 9 +Digging.DEFAULT_USES = 20 + +-- A burrower senses the player's direction straight through solid rock out +-- to this many Chebyshev cells - beyond it, it idles exactly like "flee" +-- idles once it loses sight, rather than clawing forever from across the map. +Digging.SENSE_RANGE = 6 + +-- Only a plain wall or the rubble left by a prior swing can be dug further; +-- open floor has nothing left to carve and a vault door never yields. +function Digging.canDig(map, x, y) + if not map:inBounds(x, y) then return false end + local tile = map:get(x, y) + return tile == Tiles.WALL or tile == Tiles.RUBBLE +end + +-- Resolves one swing of `digger`'s pick (or a burrower's own claws) against +-- (x, y), already known diggable by the caller. Mutates the map tile in +-- place on progress, deals cave-in damage to the digger on bad luck, and +-- always returns a log line describing what happened. +function Digging.swing(game, digger, x, y) + local map = game.level.map + local tile = map:get(x, y) + local isPlayer = digger == game.player + if game.rng:chance(1, Digging.CAVE_IN_CHANCE_DEN) then + local dealt = game.rng:range(Digging.CAVE_IN_DAMAGE_MIN, Digging.CAVE_IN_DAMAGE_MAX) + digger.hp = digger.hp - dealt + if isPlayer then return string.format("the rock groans and buries you for %d", dealt) end + return string.format("the rock groans and buries %s for %d", digger.name, dealt) + end + if tile == Tiles.WALL then + if game.rng:chance(1, Digging.ORE_STRIKE_CHANCE_DEN) then + map:set(x, y, Tiles.FLOOR) + if digger.essence ~= nil then + local amount = game.rng:range(Digging.ORE_ESSENCE_MIN, Digging.ORE_ESSENCE_MAX) + digger.essence = digger.essence + amount + return string.format("you strike a vein and pocket %d essence", amount) + end + return string.format("%s claws straight through a vein of ore", digger.name) + end + map:set(x, y, Tiles.RUBBLE) + if isPlayer then return "you carve through the rock" end + return string.format("%s carves through the rock", digger.name) + end + map:set(x, y, Tiles.FLOOR) + if isPlayer then return "you clear the rubble away" end + return string.format("%s clears the rubble away", digger.name) +end + +-- Consumes one use from `entity.pick`'s durability, discarding the tool +-- once its edge dulls past use. A no-op (returns nil) for anything without +-- a pick equipped - a burrower digs with its own claws instead, which never +-- wear out. Returns a log line only on the swing that breaks the tool. +function Digging.wearPick(entity) + local pick = entity.pick + if not pick then return nil end + pick.uses = pick.uses - 1 + if pick.uses <= 0 then + entity.pick = nil + return "your " .. pick.name .. " crumbles apart from the strain" + end + return nil +end + +-- Orthogonal step directions from (mx, my) toward (tx, ty), longer axis +-- first, in the same 4-connected grid every monster already moves and +-- paths on (see game.lua's MONSTER_DIRS, astar.lua's DIRS) - never a +-- diagonal, so a dug tunnel is always walkable by the same step logic that +-- carved it. Returns an empty list when already on the target cell. +function Digging.directionsToward(mx, my, tx, ty) + local ddx = tx - mx + local ddy = ty - my + local dx = ddx > 0 and 1 or (ddx < 0 and -1 or 0) + local dy = ddy > 0 and 1 or (ddy < 0 and -1 or 0) + local dirs = {} + if math.abs(ddx) >= math.abs(ddy) then + if dx ~= 0 then dirs[#dirs + 1] = { dx, 0 } end + if dy ~= 0 then dirs[#dirs + 1] = { 0, dy } end + else + if dy ~= 0 then dirs[#dirs + 1] = { 0, dy } end + if dx ~= 0 then dirs[#dirs + 1] = { dx, 0 } end + end + return dirs +end + +return Digging diff --git a/src/disarm.lua b/src/disarm.lua new file mode 100644 index 0000000..3f651c4 --- /dev/null +++ b/src/disarm.lua @@ -0,0 +1,82 @@ +-- Passive trap sense and the deliberate act of disarming what it finds. +-- Every trap already carries a hidden/revealed pair (level.traps entries - +-- see level.lua and game.lua's Game:springTrap, which flips a trap's +-- `revealed` the instant it is triggered, whether by a footstep or a failed +-- disarm below). This module adds a third state in between the two, +-- `detected`: noticed but not yet sprung. Detection is a passive roll +-- against every still-hidden trap within a step of the player, rolled once +-- per player turn from Game:endPlayerTurn - once per round rather than once +-- per actor, exactly like hazards.step and events.step already advance the +-- world. Disarming a detected trap is the deliberate, risky counterpart: +-- succeed and it is gone for good, plus a handful of essence for the +-- salvaged parts; fail and it springs on the disarmer exactly as if they +-- had blundered onto it outright, through the very same Game:springTrap +-- every other trigger already shares. + +local Disarm = {} + +Disarm.DETECT_NUMERATOR = 1 +Disarm.DETECT_DENOMINATOR = 4 + +Disarm.DISARM_BASE_NUMERATOR = 3 +Disarm.DISARM_BASE_DENOMINATOR = 5 +Disarm.LEVEL_BONUS_DIVISOR = 4 + +Disarm.SALVAGE_MIN = 3 +Disarm.SALVAGE_MAX = 6 + +-- Sweeps every trap within Chebyshev distance 1 of (px, py) - the same +-- adjacency a monster's melee reach and a shrine's own footprint are judged +-- by elsewhere - that is neither already detected nor already sprung, and +-- rolls each independently. Returns the traps newly detected this call (in +-- level.traps order), so a caller can report exactly what was noticed. +function Disarm.sweep(level, rng, px, py) + local found = {} + for _, trap in ipairs(level.traps) do + if not trap.detected and not trap.revealed + and math.max(math.abs(trap.x - px), math.abs(trap.y - py)) <= 1 + and rng:chance(Disarm.DETECT_NUMERATOR, Disarm.DETECT_DENOMINATOR) then + trap.detected = true + found[#found + 1] = trap + end + end + return found +end + +-- Odds of successfully disarming a detected trap: a flat base that climbs +-- slightly with player level, capped a point shy of the denominator so +-- disarming never becomes a sure thing no matter how experienced the +-- player gets. +function Disarm.oddsFor(player) + local numerator = Disarm.DISARM_BASE_NUMERATOR + player.level // Disarm.LEVEL_BONUS_DIVISOR + if numerator > Disarm.DISARM_BASE_DENOMINATOR - 1 then + numerator = Disarm.DISARM_BASE_DENOMINATOR - 1 + end + return numerator, Disarm.DISARM_BASE_DENOMINATOR +end + +-- Resolves one disarm attempt against `trap`, already known to `game`'s +-- player (see Game:disarmTrap, which enforces that it was actually +-- detected first). On success the trap is spliced out of level.traps for +-- good and its salvage is credited as essence; on failure it springs on the +-- player through Game:springTrap, the identical consequence a careless +-- footstep would have caused. Returns (true, line) or (false, nil) - a +-- failure logs nothing of its own since springTrap already does. +function Disarm.attempt(game, trap) + local numerator, denominator = Disarm.oddsFor(game.player) + if not game.rng:chance(numerator, denominator) then + game:springTrap(trap, game.player) + return false, nil + end + for i, candidate in ipairs(game.level.traps) do + if candidate == trap then + table.remove(game.level.traps, i) + break + end + end + local salvage = game.rng:range(Disarm.SALVAGE_MIN, Disarm.SALVAGE_MAX) + game.player.essence = game.player.essence + salvage + return true, string.format("you carefully disarm the trap, salvaging %d essence", salvage) +end + +return Disarm From fc02e0fe8d9c6f313c9f66d41b4839d7fe01886c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 19:20:05 +0200 Subject: [PATCH 028/172] feat: add Disease.isInfected and Disease.hasErupted --- src/disease.lua | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/disease.lua diff --git a/src/disease.lua b/src/disease.lua new file mode 100644 index 0000000..8eee9e6 --- /dev/null +++ b/src/disease.lua @@ -0,0 +1,131 @@ +-- Progressive affliction caught from a carrier's bite, distinct from both +-- status.lua's per-turn buffs/debuffs (a fixed potency for a fixed +-- duration) and curses.lua's item-bound penalties (permanent until a +-- specific remedy). A disease sits silently through its incubation with no +-- outward sign at all, then erupts and starts dealing damage every turn, +-- worsening to a harsher stage on a fixed clock if nothing cures it first. +-- Its tick damage routes through resistances.lua exactly like poison's own +-- damage-over-time (see status.lua's POISON tick), so a plague-resistant +-- survivor shrugs off what would gut anyone else. A disease is plain data +-- on entity.disease, so it serializes with no special handling; the carrier +-- tag a species bite leaves on entity.biteDisease (see monsters.lua) is +-- copied on exactly like breath or ability already are. + +local Resistances = require("resistances") + +local Disease = {} + +Disease.ROT = "rot" +Disease.PLAGUE = "plague" +Disease.PARASITES = "parasites" + +-- Fixed order so a given rng draw always names the same affliction. +Disease.order = { Disease.ROT, Disease.PLAGUE, Disease.PARASITES } + +Disease.MAX_STAGE = 3 + +Disease.defs = { + [Disease.ROT] = { + name = "wound rot", incubation = 4, stageTurns = 8, + stagePotency = { 2, 4, 7 }, + }, + [Disease.PLAGUE] = { + name = "black plague", incubation = 6, stageTurns = 6, + stagePotency = { 3, 6, 10 }, + }, + [Disease.PARASITES] = { + name = "gut parasites", incubation = 3, stageTurns = 10, + stagePotency = { 1, 3, 5 }, + }, +} + +function Disease.isInfected(entity) + return entity.disease ~= nil +end + +-- Whether the infection has finished incubating and is actively dealing +-- damage each tick, as opposed to sitting silent and undetected. +function Disease.hasErupted(entity) + return entity.disease ~= nil and entity.disease.stage > 0 +end + +-- Seeds a fresh infection of `key` on `entity`, refusing to pile a second +-- affliction on top of one already incubating or erupted - the same +-- one-at-a-time rule status.lua's own stacking already enforces for e.g. +-- poison overwriting duration rather than compounding. Returns whether the +-- infection actually took hold. +function Disease.infect(entity, key) + if Disease.isInfected(entity) then return false end + local def = Disease.defs[key] + if not def then return false end + entity.disease = { key = key, incubation = def.incubation, stage = 0, sinceStage = 0 } + return true +end + +-- Lifts whatever infection `entity` is carrying, incubating or already +-- erupted alike. Returns false without state change when there was nothing +-- to cure. +function Disease.cure(entity) + if not Disease.isInfected(entity) then return false end + entity.disease = nil + return true +end + +-- The stage's flat tick damage before resistance is applied, or 0 while +-- still incubating (no outward symptom yet) or entirely uninfected. +function Disease.stagePotency(entity) + local infection = entity.disease + if not infection or infection.stage == 0 then return 0 end + return Disease.defs[infection.key].stagePotency[infection.stage] +end + +-- One tick per turn on the host's own clock: counts incubation down in +-- silence, breaks out into stage one the moment it reaches zero, then +-- deals that stage's potency each further tick and escalates to the next +-- stage once it has lingered untreated for its full stageTurns window (the +-- top stage simply keeps ticking rather than escalating further). Returns a +-- log line for anything a player would actually notice - the eruption +-- itself, an escalation, or a nonzero tick of damage - and nil for a turn +-- that only ticked incubation down uneventfully. +-- True only for the player, whose entity.name is the pronoun "you" - a +-- subject that takes plain ("you break out", "you are wracked") verb forms +-- in English rather than the third-person-singular ("breaks out", "is +-- wracked") a named host like "orc" takes. Every message below branches on +-- this exactly like Game:springTrap and Game:checkTerrainFor already branch +-- on the same pronoun problem elsewhere in the engine. +local function isPlayer(entity) + return entity.kind == "player" +end + +function Disease.tick(entity) + local infection = entity.disease + if not infection then return nil end + local def = Disease.defs[infection.key] + if infection.stage == 0 then + infection.incubation = infection.incubation - 1 + if infection.incubation <= 0 then + infection.stage = 1 + infection.sinceStage = 0 + return isPlayer(entity) and string.format("you break out in %s", def.name) + or string.format("%s breaks out in %s", entity.name, def.name) + end + return nil + end + local dealt = Resistances.apply(entity, def.stagePotency[infection.stage], Resistances.POISON) + entity.hp = entity.hp - dealt + infection.sinceStage = infection.sinceStage + 1 + local line = nil + if dealt > 0 then + line = isPlayer(entity) and string.format("you are wracked by %s for %d", def.name, dealt) + or string.format("%s is wracked by %s for %d", entity.name, def.name, dealt) + end + if infection.sinceStage >= def.stageTurns and infection.stage < Disease.MAX_STAGE then + infection.stage = infection.stage + 1 + infection.sinceStage = 0 + line = isPlayer(entity) and string.format("your %s worsens", def.name) + or string.format("%s's %s worsens", entity.name, def.name) + end + return line +end + +return Disease From 142051057b97a6a0a539c61d5ff5af1b2b61b3d8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 19:50:46 +0200 Subject: [PATCH 029/172] feat: add Durability.ensure and Durability.isBroken --- src/durability.lua | 127 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/durability.lua diff --git a/src/durability.lua b/src/durability.lua new file mode 100644 index 0000000..17929b4 --- /dev/null +++ b/src/durability.lua @@ -0,0 +1,127 @@ +-- Weapon, armor and bow wear: unlike digging.lua's pick, a single limited-use +-- tool discarded outright once spent, a piece of gear wears down from +-- ordinary use but survives reaching zero - it just stops contributing to +-- combat until repaired, exactly like disenchanting a rune off a weapon +-- strips its bonus without destroying the weapon itself (see crafting.lua). +-- A weapon or armor's power is baked straight into the wielder's +-- attack/defense the instant it is equipped (see items.lua's +-- Items.equipWeapon/equipArmor), so breaking or repairing one here adjusts +-- that same baked total by the identical delta swapping gear already uses - +-- never a separate "effective power" read live in combat. A bow is the one +-- exception: its power is read live at the point of firing (see +-- ranged.lua's Ranged.fire), so Durability.effectivePower covers that +-- instead. Only the player's own gear wears at all - monsters have no +-- inventory management or repair access, so wear is never applied to theirs. + +local Durability = {} + +Durability.MAX = 30 +Durability.WEAR_PER_HIT = 1 +Durability.REPAIR_COST_PER_POINT = 2 + +-- Lazily stamps a fresh durability pair onto `item` the moment anything +-- first asks about it, so an item built before this field existed on it +-- (a raw table assembled somewhere this module cannot see) still gets a +-- sensible default rather than an error. +function Durability.ensure(item) + if not item.durability then + item.durability = Durability.MAX + item.maxDurability = Durability.MAX + end + return item +end + +function Durability.isBroken(item) + Durability.ensure(item) + return item.durability <= 0 +end + +-- A broken bow contributes nothing to a shot's damage; read live in +-- Ranged.fire in place of a bare `bow.power`. +function Durability.effectivePower(item) + if not item then return 0 end + if Durability.isBroken(item) then return 0 end + return item.power or 0 +end + +local function isPlayerOwned(entity) + return entity.kind == "player" +end + +-- Wears one point off `entity.weapon`, subtracting its power out of +-- entity.attack the instant it actually breaks (never before, and never +-- twice for the same break). A no-op, nil return, for an unarmed entity or +-- one whose weapon already sits at zero. +function Durability.wearWeapon(entity) + local item = entity.weapon + if not item then return nil end + Durability.ensure(item) + if item.durability <= 0 then return nil end + item.durability = item.durability - Durability.WEAR_PER_HIT + if item.durability <= 0 then + entity.attack = entity.attack - item.power + return isPlayerOwned(entity) and ("your " .. item.name .. " breaks apart from wear") + or (entity.name .. "'s " .. item.name .. " breaks apart from wear") + end + return nil +end + +function Durability.wearArmor(entity) + local item = entity.armor + if not item then return nil end + Durability.ensure(item) + if item.durability <= 0 then return nil end + item.durability = item.durability - Durability.WEAR_PER_HIT + if item.durability <= 0 then + entity.defense = entity.defense - item.power + return isPlayerOwned(entity) and ("your " .. item.name .. " is battered to uselessness") + or (entity.name .. "'s " .. item.name .. " is battered to uselessness") + end + return nil +end + +-- A bow's power is never baked into attack, so breaking one touches no stat +-- - Ranged.fire simply stops reading any power out of it via +-- Durability.effectivePower. +function Durability.wearBow(entity) + local item = entity.bow + if not item then return nil end + Durability.ensure(item) + if item.durability <= 0 then return nil end + item.durability = item.durability - Durability.WEAR_PER_HIT + if item.durability <= 0 then + return isPlayerOwned(entity) and ("your " .. item.name .. " snaps from wear") + or (entity.name .. "'s " .. item.name .. " snaps from wear") + end + return nil +end + +function Durability.missing(item) + Durability.ensure(item) + return item.maxDurability - item.durability +end + +function Durability.repairCost(item) + return Durability.missing(item) * Durability.REPAIR_COST_PER_POINT +end + +-- Spends `entity.gold` to restore `entity`'s gear in `slotName` (one of +-- "weapon", "armor", "bow") fully, folding its power back into attack or +-- defense if the break had already stripped it out. Fails without spending +-- anything for an empty slot, an already-pristine item, or too thin a purse. +function Durability.repair(entity, slotName) + local item = entity[slotName] + if not item then return false, "nothing equipped there" end + Durability.ensure(item) + if item.durability >= item.maxDurability then return false, "already in perfect condition" end + local cost = Durability.repairCost(item) + if entity.gold < cost then return false, "not enough gold" end + local wasBroken = item.durability <= 0 + entity.gold = entity.gold - cost + item.durability = item.maxDurability + if wasBroken and slotName == "weapon" then entity.attack = entity.attack + item.power end + if wasBroken and slotName == "armor" then entity.defense = entity.defense + item.power end + return true, cost +end + +return Durability From 2c8fea0556ddf8ead29124c4ad0c9dc315f23148 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 20:10:59 +0200 Subject: [PATCH 030/172] feat: extend effects with isPlayer --- src/effects.lua | 118 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/src/effects.lua b/src/effects.lua index f0e03c8..c901f23 100644 --- a/src/effects.lua +++ b/src/effects.lua @@ -4,37 +4,120 @@ local Status = require("status") local Identify = require("identify") +local Hazards = require("hazards") +local Resistances = require("resistances") +local Curses = require("curses") +local Sanity = require("sanity") +local Disease = require("disease") local Effects = {} local handlers = {} +-- Every handler below phrases its own log line by hand rather than always +-- templating a bare "%s " onto target.name, +-- because Effects.apply's only call site (Game:useItem) always hands it the +-- player, whose name is the pronoun "you" - a subject that takes the plain +-- ("you heal", "you are poisoned") verb form English gives second person, +-- not the third-person-singular form ("heals", "is") a named monster like +-- "orc" would take. Kept branching on target.kind rather than hardcoding +-- "you" outright so a future non-player caller (a companion drinking its +-- own potion, say) still reads correctly without touching this file - the +-- same isPlayer-branch convention Game:springTrap and Game:checkTerrainFor +-- already use for the identical pronoun problem. +local function isPlayer(target) + return target.kind == "player" +end + handlers.heal = function(game, target, item) local before = target.hp target.hp = math.min(target.maxHp, target.hp + item.power) - return { string.format("%s heals %d", target.name, target.hp - before) } + local healed = target.hp - before + return { isPlayer(target) and string.format("you heal %d", healed) + or string.format("%s heals %d", target.name, healed) } end handlers.poison = function(game, target, item) Status.apply(target, Status.POISON, 6, item.power) - return { string.format("%s is poisoned", target.name) } + return { isPlayer(target) and "you are poisoned" or string.format("%s is poisoned", target.name) } end handlers.strength = function(game, target, _) Status.apply(target, Status.STRENGTH, 30, 3) - return { string.format("%s feels mightier", target.name) } + return { isPlayer(target) and "you feel mightier" or string.format("%s feels mightier", target.name) } end handlers.haste = function(game, target, _) Status.apply(target, Status.HASTE, 20, 1) - return { string.format("%s speeds up", target.name) } + return { isPlayer(target) and "you speed up" or string.format("%s speeds up", target.name) } +end + +handlers.fortify = function(game, target, _) + Status.apply(target, Status.SHIELD, 15, 1) + return { isPlayer(target) and "you feel fortified" or string.format("%s feels fortified", target.name) } +end + +-- Same shape as haste/fortify - a timed self-buff, not a hostile effect - +-- but see status.lua's Status.LEVITATE for what it actually unlocks: a +-- levitating target ignores lava, chasms, ice, and ground-pressure traps +-- entirely until it wears off (Game:checkTerrainFor/checkTrapFor). +handlers.levitate = function(game, target, _) + Status.apply(target, Status.LEVITATE, 20, 1) + return { isPlayer(target) and "you rise off the ground" or string.format("%s rises off the ground", target.name) } +end + +handlers.clarity = function(game, target, item) + Sanity.adjust(target, item.power) + return { isPlayer(target) and string.format("your mind steadies (sanity %d/%d)", Sanity.current(target), Sanity.MAX) + or string.format("%s's mind steadies (sanity %d/%d)", target.name, Sanity.current(target), Sanity.MAX) } +end + +-- "Cleansing" always meant purging disease; it never touched the status +-- effects (poison, burning, stun, slow, weaken, discord, confuse, blind) +-- that every other cure-shaped word in the game implies, leaving those +-- curable only by waiting out their duration. Now strips both. +handlers.cure = function(game, target, _) + local diseaseCured = Disease.cure(target) + local liftedStatuses = Status.cureHostile(target) + local statusList = table.concat(liftedStatuses, ", ") + if diseaseCured and #liftedStatuses > 0 then + return { isPlayer(target) + and string.format("your affliction is cleansed away, and the %s fades", statusList) + or string.format("%s's affliction is cleansed away, and the %s fades", target.name, statusList) } + elseif diseaseCured then + return { isPlayer(target) and "your affliction is cleansed away" + or string.format("%s's affliction is cleansed away", target.name) } + elseif #liftedStatuses > 0 then + return { isPlayer(target) and string.format("your %s fades", statusList) + or string.format("%s's %s fades", target.name, statusList) } + end + return { isPlayer(target) and "you feel a faint tingle, but had nothing to cleanse" + or string.format("%s feels a faint tingle, but had nothing to cleanse", target.name) } +end + +-- Same duration/potency as the confuse spell and trap (see status.lua's +-- Status.CONFUSE and its mirrors in Game:movePlayer/Ai.act) so drinking an +-- unidentified potion of confusion is exactly as bad as walking into a +-- confuse trap, not a weaker or stronger flavor of the same status. +handlers.confuse = function(game, target, _) + Status.apply(target, Status.CONFUSE, 6, 1) + return { isPlayer(target) and "your mind reels with confusion" + or string.format("%s's mind reels with confusion", target.name) } +end + +-- Same duration/potency as the blind trap (see Game:springTrap) so drinking +-- an unidentified potion of blindness is exactly as bad as walking into a +-- flash trap, not a weaker or stronger flavor of the same status. +handlers.blind = function(game, target, _) + Status.apply(target, Status.BLIND, 6, 1) + return { isPlayer(target) and "you are struck blind" or string.format("%s is struck blind", target.name) } end handlers.teleport = function(game, target, _) local x, y = game:randomFloorCell() if x then target.x, target.y = x, y - return { target.name .. " blinks away" } + return { isPlayer(target) and "you blink away" or (target.name .. " blinks away") } end return { "the teleport fizzles" } end @@ -45,11 +128,28 @@ handlers.mapping = function(game, _, _) end handlers.identify = function(game, target, _) + local lines = {} local revealed = Identify.revealFirstUnknown(game.identify, target.inventory) if revealed then - return { "it is " .. revealed.name } + lines[#lines + 1] = "it is " .. revealed.name end - return { "nothing left to identify" } + local cursedFind = Curses.revealFirstUnknown(target) + if cursedFind then + lines[#lines + 1] = "you sense a curse upon your " .. cursedFind.name + end + if #lines == 0 then + lines[#lines + 1] = "nothing left to identify" + end + return lines +end + +handlers.removecurse = function(game, target, _) + local count = Curses.cleanseWorn(target) + if count == 0 then + return { "you feel a faint tingle, but nothing you wear is cursed" } + end + return { string.format("a wave of purifying light lifts %d curse%s from your gear", + count, count == 1 and "" or "s") } end handlers.fireball = function(game, target, _) @@ -59,12 +159,14 @@ handlers.fireball = function(game, target, _) if monster:isAlive() then local dist = math.max(math.abs(monster.x - target.x), math.abs(monster.y - target.y)) if dist <= radius then - monster.hp = monster.hp - 8 + monster.hp = monster.hp - Resistances.apply(monster, 8, Resistances.FIRE) Status.apply(monster, Status.BURNING, 3, 2) + Hazards.ignite(game.level, monster.x, monster.y, 3, 2) burned = burned + 1 end end end + Hazards.ignite(game.level, target.x, target.y, 3, 2) game:cleanupDead() return { string.format("fire engulfs %d foes", burned) } end From f4c8fcd5fce20688e9f63fbd699b710a50b091d4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 20:33:35 +0200 Subject: [PATCH 031/172] feat: add applyAffix and Elites.eligible --- src/elites.lua | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/elites.lua diff --git a/src/elites.lua b/src/elites.lua new file mode 100644 index 0000000..7e67ab3 --- /dev/null +++ b/src/elites.lua @@ -0,0 +1,162 @@ +-- Elite monsters: rare, affixed variants of the ordinary bestiary. Builder +-- rolls promotion once a floor's entities are already fully placed (appended +-- dead last in Builder.build, after every other placement - artifacts, the +-- boss, the quest board - so it can never perturb an earlier system's rng +-- sequence, exactly like those do for their own rolls). An elite still rides +-- the same ai.lua/abilities.lua dispatch and scheduler as any other creature; +-- only its stats, its name, and the on-hit/on-death/lifesteal hooks Game +-- wires in separately set it apart from a plain spawn of the same species. +-- A floor deep enough for a second affix ("champion") draws it from the +-- remaining pool, so a champion never doubles up on the same one. + +local Resistances = require("resistances") + +local Elites = {} + +Elites.MIN_DEPTH = 3 +Elites.CHAMPION_DEPTH = 7 + +Elites.affixes = { + { key = "brutal", name = "brutal", hpMult = 1.2, attack = 3 }, + { key = "armored", name = "armored", hpMult = 1.3, defense = 3 }, + { key = "swift", name = "swift", speed = 30 }, + { key = "regenerating", name = "regenerating", regen = 3 }, + { key = "warded", name = "warded", resist = Resistances.RESIST }, + { key = "vampiric", name = "vampiric", lifesteal = 0.5 }, + { key = "chilling", name = "chilling", onHit = { status = "slow", duration = 3, potency = 1 } }, + { key = "volatile", name = "volatile", onDeathDamage = 8, onDeathType = Resistances.FIRE }, +} + +Elites.byKey = {} +for _, affix in ipairs(Elites.affixes) do + Elites.byKey[affix.key] = affix +end + +-- Elemental types a "warded" roll can pick from - physical is excluded since +-- every hit already respects the plain attack/defense floor in combat.lua. +local RESISTABLE = { + Resistances.FIRE, Resistances.COLD, Resistances.POISON, + Resistances.LIGHTNING, Resistances.ARCANE, +} + +local function applyAffix(entity, affix, rng) + entity.eliteAffixes[#entity.eliteAffixes + 1] = affix.key + if affix.hpMult then + local bonus = math.floor(entity.maxHp * (affix.hpMult - 1)) + entity.maxHp = entity.maxHp + bonus + entity.hp = entity.hp + bonus + end + if affix.attack then + entity.attack = entity.attack + affix.attack + entity.baseAttack = entity.baseAttack + affix.attack + end + if affix.defense then + entity.defense = entity.defense + affix.defense + entity.baseDefense = entity.baseDefense + affix.defense + end + if affix.speed then entity.speed = entity.speed + affix.speed end + if affix.regen then entity.regen = (entity.regen or 0) + affix.regen end + if affix.resist then + local element = RESISTABLE[rng:range(1, #RESISTABLE)] + entity.resist = entity.resist or {} + if entity.resist[element] ~= Resistances.IMMUNE then + entity.resist[element] = affix.resist + end + end + entity.name = affix.name .. " " .. entity.name +end + +-- Eligible for promotion: a plain bestiary spawn, never a boss (already the +-- rarer, sturdier variant) or the passive shopkeeper. +function Elites.eligible(entity) + return entity.kind == "monster" and not entity.isBoss and entity.ai ~= "vend" +end + +-- Rolled once per eligible monster after a floor's entities are already +-- placed. Mutates `entity` in place; a no-op below Elites.MIN_DEPTH, on an +-- ineligible entity, or when the roll simply misses. +function Elites.maybePromote(rng, entity, depth) + if depth < Elites.MIN_DEPTH or not Elites.eligible(entity) then return end + if not rng:chance(1, 6) then return end + entity.elite = true + entity.eliteAffixes = {} + local pool = {} + for i, affix in ipairs(Elites.affixes) do pool[i] = affix end + local rolls = 1 + if depth >= Elites.CHAMPION_DEPTH and rng:chance(1, 3) then rolls = 2 end + for _ = 1, rolls do + if #pool == 0 then break end + local idx = rng:range(1, #pool) + applyAffix(entity, pool[idx], rng) + table.remove(pool, idx) + end + entity.eliteTier = #entity.eliteAffixes >= 2 and "champion" or "elite" +end + +-- The first volatile affix on `entity`, if any - the death burst +-- Game:awardKill triggers right after Abilities.onDeath. +function Elites.explosionFor(entity) + if not entity.eliteAffixes then return nil end + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.onDeathDamage then return affix end + end + return nil +end + +-- The status rider (if any) a hit landed by `entity` should apply to its +-- victim - same shape as a weapon affix's onHit (see affixes.lua), so Game +-- can hand it straight to Status.apply. +function Elites.onHitRider(entity) + if not entity.eliteAffixes then return nil end + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.onHit then return affix.onHit end + end + return nil +end + +-- Fraction of dealt damage `entity` heals back on a hit it lands, summed +-- across its affixes (never more than one vampiric roll in practice). +function Elites.lifestealFraction(entity) + if not entity.eliteAffixes then return 0 end + local total = 0 + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.lifesteal then total = total + affix.lifesteal end + end + return total +end + +-- Bonus xp a felled elite is worth on top of its plain species reward: half +-- again for a single-affix elite, a full second helping for a champion. +function Elites.xpBonus(entity, baseXp) + if not entity.elite then return 0 end + local mult = entity.eliteTier == "champion" and 1.0 or 0.5 + return math.floor(baseXp * mult) +end + +-- Fired from Game:awardKill right after Abilities.onDeath: a volatile +-- elite's corpse bursts, scorching the player and any companion or monster +-- adjacent to it (itself excluded) for its cataloged damage and type. +function Elites.onDeath(game, monster) + local affix = Elites.explosionFor(monster) + if not affix then return nil end + local hit = {} + local targets = { game.player } + for _, other in ipairs(game:currentEntities()) do targets[#targets + 1] = other end + for _, victim in ipairs(targets) do + if victim ~= monster and victim:isAlive() + and math.abs(victim.x - monster.x) <= 1 and math.abs(victim.y - monster.y) <= 1 then + local dealt = Resistances.apply(victim, affix.onDeathDamage, affix.onDeathType) + if dealt > 0 then + victim.hp = victim.hp - dealt + hit[#hit + 1] = string.format("%s for %d", victim.name, dealt) + end + end + end + if #hit == 0 then return nil end + return string.format("%s bursts, scorching %s", monster.name, table.concat(hit, ", ")) +end + +return Elites From 7fa20d459df2c1bde058a4a2241b24efbb7a9505 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 21:19:03 +0200 Subject: [PATCH 032/172] feat: extend encumbrance with Encumbrance.itemWeight --- src/encumbrance.lua | 123 ++++++++++++++++++++++++++++++++++++++++++++ src/entity.lua | 67 ++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/encumbrance.lua diff --git a/src/encumbrance.lua b/src/encumbrance.lua new file mode 100644 index 0000000..9a7a084 --- /dev/null +++ b/src/encumbrance.lua @@ -0,0 +1,123 @@ +-- Carry weight. Every item kind has a physical weight (gear scales a little +-- with its power, the way a heavier blade hits harder); a carrier's capacity +-- grows with level, the same knob progression.lua turns for hp. Exceeding +-- capacity does not block a pickup outright - it slows the carrier down +-- instead (see Entity:effectiveSpeed), and a wide overage on top of that also +-- costs defense, the same shape a status.lua debuff takes (see +-- Entity:effectiveDefense). Nothing here is persisted: weight is entirely a +-- function of entity.inventory and entity.level, both already serialized, so +-- a save/load round trip recomputes the identical tier for free. +-- +-- Monsters are never excluded from these checks (there is no species flag to +-- opt out), but their inventory is always empty in practice - abilities.lua +-- and combat.lua never stash loot on a monster - so in play this only ever +-- matters for the player. + +local Items = require("items") + +local Encumbrance = {} + +Encumbrance.BASE_CAPACITY = 12 +Encumbrance.CAPACITY_PER_LEVEL = 4 +Encumbrance.BURDENED_MULT = 1.5 + +Encumbrance.LIGHT = "light" +Encumbrance.BURDENED = "burdened" +Encumbrance.OVERLOADED = "overloaded" + +Encumbrance.BURDENED_SPEED_PENALTY = -20 +Encumbrance.OVERLOADED_SPEED_PENALTY = -45 +Encumbrance.OVERLOADED_DEFENSE_PENALTY = -2 + +-- Base weight per item kind, before any power-scaling below. Kinds absent +-- here (gold, keys) default to a flat 1 rather than 0: even a featherweight +-- trinket takes up some of the pack, and a strictly-zero-weight kind would +-- let a hoard of them carry for free. +local BASE_WEIGHT = { + [Items.POTION] = 2, + [Items.SCROLL] = 1, + [Items.WEAPON] = 6, + [Items.ARMOR] = 9, + [Items.RING] = 1, + [Items.FOOD] = 3, + [Items.KEY] = 1, + [Items.TOME] = 4, + [Items.AMULET] = 2, + [Items.BOW] = 7, + [Items.AMMO] = 1, + [Items.GOLD] = 0, + [Items.GEM] = 1, + [Items.CHARM] = 2, + [Items.KIT] = 3, + [Items.HERB] = 1, + [Items.CONTRACT] = 1, +} + +-- Kinds whose power rating (a straightforward proxy for how much metal went +-- into them) adds a little extra weight on top of the flat base. +local POWER_SCALES = { + [Items.WEAPON] = true, + [Items.ARMOR] = true, + [Items.BOW] = true, +} + +-- Kinds that stack into one inventory slot via a `count`/`amount` field, so +-- their weight must scale with the stack rather than being charged once. +local STACK_FIELD = { + [Items.AMMO] = "count", + [Items.GOLD] = "amount", +} + +function Encumbrance.itemWeight(item) + local weight = BASE_WEIGHT[item.kind] or 1 + if POWER_SCALES[item.kind] then + weight = weight + (item.power or 0) // 3 + end + local stackField = STACK_FIELD[item.kind] + if stackField then + weight = weight * (item[stackField] or 1) + end + return weight +end + +-- Loose inventory plus whatever is currently worn or wielded: a sword on +-- your belt weighs exactly as much as one in your pack. +function Encumbrance.carriedWeight(entity) + local total = 0 + for _, item in ipairs(entity.inventory) do + total = total + Encumbrance.itemWeight(item) + end + for _, slot in ipairs({ "weapon", "armor", "ring", "bow" }) do + local equipped = entity[slot] + if equipped then total = total + Encumbrance.itemWeight(equipped) end + end + return total +end + +function Encumbrance.capacityFor(entity) + return Encumbrance.BASE_CAPACITY + (entity.level or 1) * Encumbrance.CAPACITY_PER_LEVEL +end + +function Encumbrance.tierFor(entity) + local carried = Encumbrance.carriedWeight(entity) + local capacity = Encumbrance.capacityFor(entity) + if carried <= capacity then return Encumbrance.LIGHT end + if carried <= capacity * Encumbrance.BURDENED_MULT then return Encumbrance.BURDENED end + return Encumbrance.OVERLOADED +end + +function Encumbrance.speedPenalty(entity) + local tier = Encumbrance.tierFor(entity) + if tier == Encumbrance.BURDENED then return Encumbrance.BURDENED_SPEED_PENALTY end + if tier == Encumbrance.OVERLOADED then return Encumbrance.OVERLOADED_SPEED_PENALTY end + return 0 +end + +function Encumbrance.defensePenalty(entity) + if Encumbrance.tierFor(entity) == Encumbrance.OVERLOADED then + return Encumbrance.OVERLOADED_DEFENSE_PENALTY + end + return 0 +end + +return Encumbrance diff --git a/src/entity.lua b/src/entity.lua index 136d2b0..7c51444 100644 --- a/src/entity.lua +++ b/src/entity.lua @@ -3,6 +3,11 @@ -- and speed drive the turn scheduler; statuses layer temporary modifiers on top. local Status = require("status") +local Encumbrance = require("encumbrance") +local Momentum = require("momentum") +local Sanity = require("sanity") +local Proficiency = require("proficiency") +local Hunger = require("hunger") local Entity = {} Entity.__index = Entity @@ -30,6 +35,8 @@ function Entity.new(spec) weapon = nil, armor = nil, ring = nil, + bow = nil, + pick = nil, }, Entity) end @@ -38,19 +45,25 @@ function Entity:isAlive() end function Entity:effectiveAttack() - return self.attack + Status.attackModifier(self) + return self.attack + Status.attackModifier(self) + Momentum.attackBonus(self) + Sanity.attackPenalty(self) + + Proficiency.attackBonus(self) + Hunger.attackPenalty(self) end function Entity:effectiveDefense() - return self.defense + return self.defense + Status.defenseModifier(self) + Encumbrance.defensePenalty(self) + Sanity.defensePenalty(self) + + Hunger.defensePenalty(self) end function Entity:effectiveSpeed() - local speed = self.speed + Status.speedModifier(self) + local speed = self.speed + Status.speedModifier(self) + Encumbrance.speedPenalty(self) if speed < 10 then speed = 10 end return speed end +function Entity:encumbranceTier() + return Encumbrance.tierFor(self) +end + function Entity:toData() return { id = self.id, @@ -66,6 +79,54 @@ function Entity:toData() weapon = self.weapon, armor = self.armor, ring = self.ring, + bow = self.bow, + pick = self.pick, + regen = self.regen, + onDeath = self.onDeath, + breath = self.breath, + ability = self.ability, + summonSpecies = self.summonSpecies, + summonedBy = self.summonedBy, + cooldown = self.cooldown, + fleeing = self.fleeing, + stolen = self.stolen, + splitGen = self.splitGen, + hostile = self.hostile, + isBoss = self.isBoss, + bossKey = self.bossKey, + enraged = self.enraged, + desperate = self.desperate, + channeling = self.channeling, + novaSpent = self.novaSpent, + resist = self.resist, + faction = self.faction, + provoked = self.provoked, + elite = self.elite, + eliteAffixes = self.eliteAffixes, + eliteTier = self.eliteTier, + mimic = self.mimic, + grave = self.grave, + graveClass = self.graveClass, + graveGold = self.graveGold, + graveEssence = self.graveEssence, + proficiency = self.proficiency, + awareness = self.awareness, + lastKnownX = self.lastKnownX, + lastKnownY = self.lastKnownY, + lostTurns = self.lostTurns, + searchTurns = self.searchTurns, + combo = self.combo, + decay = self.decay, + wage = self.wage, + loyalty = self.loyalty, + wet = self.wet, + nemesis = self.nemesis, + nemesisGrudge = self.nemesisGrudge, + disease = self.disease, + biteDisease = self.biteDisease, + companionXp = self.companionXp, + companionLevel = self.companionLevel, + companionHealBonus = self.companionHealBonus, } end From cea6315a9583f45827ed9179a042af44da177d85 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 22:23:27 +0200 Subject: [PATCH 033/172] feat: add Events.describe and Events.endDescribe --- src/events.lua | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/events.lua diff --git a/src/events.lua b/src/events.lua new file mode 100644 index 0000000..4bb1b8a --- /dev/null +++ b/src/events.lua @@ -0,0 +1,89 @@ +-- Timed dungeon-wide events. Unlike atmosphere.lua's single weather reading +-- drawn once at generation and fixed for a floor's entire lifetime, an +-- event can start and end mid-visit: from depth two on, any turn with none +-- already running has a small chance of rolling one fresh, and a running +-- one always expires back to nothing after a fixed duration rather than +-- lingering for the rest of the floor. Only one runs at a time. +-- level.event is nil or { key, turnsLeft }, plain data like level.weather, +-- so it serializes with no special save-format handling. + +local Events = {} + +Events.BLOODMOON = "bloodmoon" +Events.RICHVEINS = "richveins" +Events.HUSH = "hush" + +-- Fixed order so a given rng draw always names the same event. +Events.order = { Events.BLOODMOON, Events.RICHVEINS, Events.HUSH } + +Events.DURATION = 15 + +Events.START_NUMERATOR = 1 +Events.START_DENOMINATOR = 60 + +Events.BLOODMOON_MULTIPLIER = 1.5 +Events.RICHVEINS_MULTIPLIER = 2 +Events.HUSH_SIGHT_PENALTY = 1 + +function Events.describe(key) + if key == Events.BLOODMOON then return "a bloodmoon rises; the monsters here hit harder" end + if key == Events.RICHVEINS then return "the walls hum with rich veins; essence flows freely" end + if key == Events.HUSH then return "a hush falls over the floor, dulling your senses" end + return nil +end + +function Events.endDescribe(key) + if key == Events.BLOODMOON then return "the bloodmoon fades" end + if key == Events.RICHVEINS then return "the veins fall quiet again" end + if key == Events.HUSH then return "the hush lifts" end + return nil +end + +-- A monster's raw attack-minus-defense swing at the player is scaled by +-- this multiplier, read live wherever a monster lands a hit +-- (Game:monsterMelee, Game:monsterShoot, Game:triggerMimic's ambush) - +-- never baked permanently into a monster's own stats the way +-- atmosphere.lua's INFESTED reading is, since it must lift again the +-- instant the event ends. +function Events.monsterDamageMultiplier(event) + if event and event.key == Events.BLOODMOON then return Events.BLOODMOON_MULTIPLIER end + return 1 +end + +-- Essence earned from a kill (Game:awardKill) or an ore vein +-- (Nodes.harvest) is scaled by this multiplier, read live at the point +-- essence is actually credited. +function Events.essenceMultiplier(event) + if event and event.key == Events.RICHVEINS then return Events.RICHVEINS_MULTIPLIER end + return 1 +end + +-- Read alongside atmosphere.lua's own sightPenalty in Game:effectiveRadius. +function Events.sightPenalty(event) + if event and event.key == Events.HUSH then return Events.HUSH_SIGHT_PENALTY end + return 0 +end + +-- Advances a running event's clock by one turn, or rolls a fresh one onto +-- `level` when none is running. Returns a log line on start or expiry, nil +-- on an ordinary quiet turn - exactly like sanity.lua's Sanity.tick, which +-- reports only on a tier crossing rather than every single call. +function Events.step(level, rng) + if level.event then + level.event.turnsLeft = level.event.turnsLeft - 1 + if level.event.turnsLeft <= 0 then + local line = Events.endDescribe(level.event.key) + level.event = nil + return line + end + return nil + end + if level.depth < 2 or not rng:chance(Events.START_NUMERATOR, Events.START_DENOMINATOR) then + return nil + end + local key = rng:pick(Events.order) + level.event = { key = key, turnsLeft = Events.DURATION } + return Events.describe(key) +end + +return Events From 104157745fa2ae5e2fba09da0322a640d6739747 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 22:44:04 +0200 Subject: [PATCH 034/172] feat: add bonusText and kindDetail --- src/examine.lua | 206 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/examine.lua diff --git a/src/examine.lua b/src/examine.lua new file mode 100644 index 0000000..4b5875d --- /dev/null +++ b/src/examine.lua @@ -0,0 +1,206 @@ +-- Detailed, read-only inspection of a single inventory item or a visible +-- monster. Aggregates facts that several other modules already track (an +-- affix's on-hit rider, a socketed gem's element, a runeword's live bonus, +-- an elite's affix names, a monster's resistances/awareness/status effects) +-- into one readable report - none of which any existing HUD line +-- (render.lua's status panel, commands.lua's own inventory/look) surfaces +-- beyond a bare name. Every function here is read-only: it never mutates +-- the item or monster it describes, and never spends a turn (see +-- commands.lua's own examine handler). + +local Items = require("items") +local Identify = require("identify") +local Curses = require("curses") +local Durability = require("durability") +local Runewords = require("runewords") +local Sets = require("sets") +local Shop = require("shop") +local Resistances = require("resistances") +local Factions = require("factions") +local Stealth = require("stealth") +local Disease = require("disease") + +local Examine = {} + +local AWARENESS_LABEL = { + [Stealth.DORMANT] = "asleep", + [Stealth.SEARCHING] = "searching for you", + [Stealth.HUNTING] = "hunting you", +} + +-- Joins whichever of attack/defense/regen a runeword or set threshold bonus +-- carries, in a fixed order, so the same helper reads either shape. +local function bonusText(bonus) + local parts = {} + if bonus.attack and bonus.attack ~= 0 then parts[#parts + 1] = string.format("attack +%d", bonus.attack) end + if bonus.defense and bonus.defense ~= 0 then parts[#parts + 1] = string.format("defense +%d", bonus.defense) end + if bonus.regen and bonus.regen ~= 0 then parts[#parts + 1] = string.format("regen +%d", bonus.regen) end + return table.concat(parts, ", ") +end + +local NO_PRICE = { [Items.GOLD] = true, [Items.KEY] = true } + +-- Extra facts specific to one gear/consumable kind, appended after the +-- shared label; nil for kinds with nothing beyond their name worth calling +-- out (a plain key, a riftstone shard...). +local function kindDetail(item) + if item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.BOW then + local parts = { string.format("power %d", item.power) } + if item.range then parts[#parts + 1] = "range " .. item.range end + if item.damageType then parts[#parts + 1] = "deals " .. item.damageType .. " damage" end + Durability.ensure(item) + if Durability.isBroken(item) then + parts[#parts + 1] = string.format("durability %d/%d (broken)", item.durability, item.maxDurability) + else + parts[#parts + 1] = string.format("durability %d/%d", item.durability, item.maxDurability) + end + if item.temper and item.temper > 0 then parts[#parts + 1] = string.format("tempered +%d", item.temper) end + return table.concat(parts, ", ") + elseif item.kind == Items.RING then + return string.format("attack +%d, defense +%d", item.attack, item.defense) + elseif item.kind == Items.FOOD then + return string.format("restores %d nutrition", item.nutrition) + elseif item.kind == Items.TOME then + return "teaches the spell: " .. item.spell + elseif item.kind == Items.AMMO then + return string.format("%d in the stack", item.count) + elseif item.kind == Items.CHARM then + return "summons a companion: " .. item.companion + elseif item.kind == Items.KIT then + return "trap effect: " .. item.effect + elseif item.kind == Items.CONTRACT then + return "hires: " .. item.hireling + elseif item.kind == Items.PICK then + return string.format("%d uses left", item.uses) + elseif item.kind == Items.GEM then + return string.format("tier %d, power %d", item.tier, item.power) + end + return nil +end + +-- Every non-kind-specific trait a piece of gear may carry, appended in a +-- fixed order regardless of which of them are actually present. +local function gearTraits(item, lines) + if item.affix then + local desc = item.affix.name + if item.affix.onHit then + desc = string.format("%s (on hit: %s, %d turns, potency %d)", desc, + item.affix.onHit.status, item.affix.onHit.duration, item.affix.onHit.potency) + end + lines[#lines + 1] = "affix: " .. desc + end + + if item.artifact then + lines[#lines + 1] = "artifact power: " .. (item.unique or "passive bonus") + end + + if item.set then + local spec = Sets.byKey[item.set] + lines[#lines + 1] = "set piece: " .. (spec and spec.name or item.set) + end + + if item.socket then + lines[#lines + 1] = string.format("socketed: tier %d %s gem", item.socket.tier, item.socket.element) + end + + if item.runes and #item.runes > 0 then + local word = Runewords.activeWord(item) + if word then + lines[#lines + 1] = string.format("runeword %s: %s", word.name, bonusText(word)) + else + lines[#lines + 1] = "runes: " .. table.concat(item.runes, "-") + end + end + + if item.resist then + local parts = {} + for damageType, tier in pairs(item.resist) do + parts[#parts + 1] = damageType .. " " .. tier + end + table.sort(parts) + lines[#lines + 1] = "grants: " .. table.concat(parts, ", ") + end + + if Curses.isCursed(item) and Curses.isRevealed(item) then + lines[#lines + 1] = "cursed (" .. item.curseKind .. ")" + end +end + +-- A multi-line, semicolon-joined report on a single inventory item: its +-- (possibly still-disguised, see identify.lua) label, then whatever the +-- fields above actually populate. Unidentified potions/scrolls only ever +-- reach the label + value lines, since kindDetail and gearTraits both key +-- off fields those kinds never carry. +function Examine.item(identifyState, item) + local lines = { Curses.label(item, Identify.label(identifyState, item)) } + local detail = kindDetail(item) + if detail then lines[#lines + 1] = detail end + gearTraits(item, lines) + if not NO_PRICE[item.kind] then + lines[#lines + 1] = "worth " .. Shop.priceOf(item) .. " gold" + end + return table.concat(lines, "; ") +end + +-- A multi-line, semicolon-joined report on a monster the player can +-- currently see: its combat numbers (already folded through +-- Entity:effectiveAttack/effectiveDefense, so momentum/hunger/sanity +-- modifiers show up exactly as they would land the next swing), its +-- alertness, standing faction and reputation tier, elite/boss tags, any +-- non-normal resistance, and whatever status effects currently afflict it. +function Examine.monster(player, monster) + local lines = { monster.name } + lines[#lines + 1] = string.format("hp %d/%d, attack %d, defense %d", + monster.hp, monster.maxHp, monster:effectiveAttack(), monster:effectiveDefense()) + lines[#lines + 1] = "awareness: " .. (AWARENESS_LABEL[monster.awareness] or "hunting you") + if monster.provoked then lines[#lines + 1] = "provoked" end + + if monster.faction then + local tier = Factions.tier(Factions.reputationFor(player, monster.faction)) + lines[#lines + 1] = string.format("faction: %s (%s)", monster.faction, tier) + end + + if monster.elite then + lines[#lines + 1] = (monster.eliteTier or "elite") .. ": " .. table.concat(monster.eliteAffixes or {}, ", ") + end + + if monster.isBoss then + local tags = {} + if monster.enraged then tags[#tags + 1] = "enraged" end + if monster.desperate then tags[#tags + 1] = "desperate" end + lines[#lines + 1] = #tags > 0 and ("boss, " .. table.concat(tags, ", ")) or "boss" + end + + local resistParts = {} + for _, damageType in ipairs(Resistances.TYPES) do + local tier = Resistances.tierOf(monster, damageType) + if tier ~= Resistances.NORMAL then + resistParts[#resistParts + 1] = damageType .. " " .. tier + end + end + if #resistParts > 0 then lines[#lines + 1] = "resistances: " .. table.concat(resistParts, ", ") end + + if monster.statuses and #monster.statuses > 0 then + local parts = {} + for _, active in ipairs(monster.statuses) do + parts[#parts + 1] = string.format("%s (%d)", active.kind, active.duration) + end + lines[#lines + 1] = "afflicted: " .. table.concat(parts, ", ") + end + + -- Only reachable via infighting.lua's own bite chance (a rival monster or + -- the player's own companion/hireling/thrall can now catch a disease from + -- a carrier's bite, not just the player) - shown the same way a + -- durability-damaged item's examine line reports a fact only visible + -- through inspection, not the bare glyph on the map. + if Disease.isInfected(monster) then + local def = Disease.defs[monster.disease.key] + lines[#lines + 1] = Disease.hasErupted(monster) + and string.format("diseased: %s (stage %d)", def.name, monster.disease.stage) + or string.format("diseased: %s (incubating)", def.name) + end + + return table.concat(lines, "; ") +end + +return Examine From e1883553866a4c4dc0876e8860a55ebd35507e54 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 23:39:28 +0200 Subject: [PATCH 035/172] feat: add csvField, Export.leaderboardCsv and Factions.areRivals --- src/export.lua | 48 +++++++++++++++++ src/factions.lua | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/export.lua create mode 100644 src/factions.lua diff --git a/src/export.lua b/src/export.lua new file mode 100644 index 0000000..d1d0c83 --- /dev/null +++ b/src/export.lua @@ -0,0 +1,48 @@ +-- Renders meta.lua's own leaderboard as CSV text, one row per entry, for a +-- player who wants their run history in a spreadsheet rather than +-- warren.lua's own --report listing. A leaderboard entry's exact field set +-- (see leaderboard.lua's Leaderboard.record) becomes the exact column set +-- here, so the header row and every data row are always built from the +-- same list and can never drift apart from each other. + +local Compendium = require("compendium") + +local Export = {} + +Export.COLUMNS = { "rank", "score", "className", "depth", "turn", "won", "seed" } +Export.COMPENDIUM_COLUMNS = { "key", "name", "seen", "kills" } + +local function csvField(value) + if value == nil then return "" end + if value == true then return "true" end + if value == false then return "false" end + return tostring(value) +end + +-- One CSV document: a header row naming Export.COLUMNS, then one row per +-- `board` entry in its own existing order (best score first, see +-- Leaderboard.record) with a 1-based rank column prepended. +function Export.leaderboardCsv(board) + local lines = { table.concat(Export.COLUMNS, ",") } + for rank, entry in ipairs(board) do + local row = { rank, entry.score, entry.className, entry.depth, entry.turn, entry.won, entry.seed } + for i, value in ipairs(row) do row[i] = csvField(value) end + lines[#lines + 1] = table.concat(row, ",") + end + return table.concat(lines, "\n") .. "\n" +end + +-- One CSV document over every species Compendium.entries knows about (see +-- compendium.lua), sighted or not - so an export always lists the whole +-- bestiary's shape, not just whatever a given save has stumbled across. +function Export.compendiumCsv(compendium) + local lines = { table.concat(Export.COMPENDIUM_COLUMNS, ",") } + for _, entry in ipairs(Compendium.entries(compendium)) do + local row = { entry.key, entry.name, entry.seen, entry.kills } + for i, value in ipairs(row) do row[i] = csvField(value) end + lines[#lines + 1] = table.concat(row, ",") + end + return table.concat(lines, "\n") .. "\n" +end + +return Export diff --git a/src/factions.lua b/src/factions.lua new file mode 100644 index 0000000..77cf6a7 --- /dev/null +++ b/src/factions.lua @@ -0,0 +1,130 @@ +-- Faction standing. Every hostile species in the bestiary carries a `faction` +-- tag; the player carries a `reputation` table scoring how each faction feels +-- about them, starting neutral (an absent entry reads as 0). Killing a +-- faction's members sours that score; paying tribute or turning in a bounty +-- sweetens it. Reputation then feeds back into the world two ways: a +-- tribal faction tagged "neutral" (see neutralFactions) leaves its members +-- passively unprovoked until the player either strikes first or has enraged +-- the whole tribe by other means, and the merchant guild's prices rise or +-- fall with how the player stands with it. + +local Factions = {} + +Factions.MERCHANT = "merchant" +Factions.GOBLINOID = "goblinoid" +Factions.VERMIN = "vermin" +Factions.ORCISH = "orcish" +Factions.UNDEAD = "undead" +Factions.BEAST = "beast" +Factions.CULT = "cult" +Factions.DRAKE = "drake" +Factions.BANDIT = "bandit" + +-- Factions whose rank-and-file stay passive - see Factions.isWary - unless +-- provoked in melee/at range or already hated into open hostility. +Factions.neutralFactions = { [Factions.GOBLINOID] = true } + +-- Faction pairs that loathe each other regardless of the player: two +-- monsters from an unrelated pair here fight on sight if they ever share a +-- floor (see infighting.lua), no discord spell required. Listed once per +-- pair; Factions.areRivals checks both directions. Deliberately excludes any +-- faction pairing with a summon relationship (CULT raises UNDEAD minions) +-- so a caster is never made to loathe its own summoned brood. +Factions.RIVALRIES = { + { Factions.GOBLINOID, Factions.ORCISH }, + { Factions.BANDIT, Factions.MERCHANT }, + { Factions.VERMIN, Factions.BEAST }, +} + +function Factions.areRivals(factionA, factionB) + if not factionA or not factionB or factionA == factionB then return false end + for _, pair in ipairs(Factions.RIVALRIES) do + if (pair[1] == factionA and pair[2] == factionB) or (pair[1] == factionB and pair[2] == factionA) then + return true + end + end + return false +end + +local HOSTILE_MAX = -30 +local UNFRIENDLY_MAX = -10 +local FRIENDLY_MIN = 25 +local HONORED_MIN = 60 + +local PRICE_MULTIPLIER = { + hostile = 1.5, unfriendly = 1.15, neutral = 1.0, friendly = 0.85, honored = 0.65, +} + +local KILL_PENALTY = 5 +local BOSS_KILL_PENALTY = 15 +local BASE_APPEASE_COST = 20 +local APPEASE_GAIN = 15 + +function Factions.reputationFor(player, faction) + return (player.reputation and player.reputation[faction]) or 0 +end + +-- The named tier a raw reputation score falls into, from most soured to most +-- warm. Ties resolve toward the harsher tier so a score sitting exactly on a +-- boundary reads as the tier it just reached rather than the one it left. +function Factions.tier(rep) + if rep <= HOSTILE_MAX then return "hostile" end + if rep <= UNFRIENDLY_MAX then return "unfriendly" end + if rep >= HONORED_MIN then return "honored" end + if rep >= FRIENDLY_MIN then return "friendly" end + return "neutral" +end + +-- Nudges `faction`'s standing by `delta`, clamped to +-100, creating the +-- player's reputation table on first use. +function Factions.adjust(player, faction, delta) + player.reputation = player.reputation or {} + local updated = Factions.reputationFor(player, faction) + delta + if updated > 100 then updated = 100 end + if updated < -100 then updated = -100 end + player.reputation[faction] = updated + return updated +end + +-- Called once per monster a player action fells: unfactioned kills (traps, +-- hazards with no owning species) are simply ignored. +function Factions.registerKill(player, target) + if not target.faction then return end + Factions.adjust(player, target.faction, target.isBoss and -BOSS_KILL_PENALTY or -KILL_PENALTY) +end + +-- Whether `faction`'s members currently hold back from attacking on sight. +-- Non-tribal factions are never wary; a tribal one stops being wary the +-- instant the whole faction's standing curdles into open hostility. +function Factions.isWary(player, monster) + local faction = monster.faction + if not (faction and Factions.neutralFactions[faction]) then return false end + if monster.provoked then return false end + return Factions.tier(Factions.reputationFor(player, faction)) ~= "hostile" +end + +function Factions.priceMultiplier(player, faction) + return PRICE_MULTIPLIER[Factions.tier(Factions.reputationFor(player, faction))] +end + +-- Gold cost of the next tribute to `faction`: a flat base that climbs the +-- deeper the grudge already runs, so digging out of "hostile" costs more +-- than smoothing over a merely "unfriendly" one. +function Factions.appeaseCost(player, faction) + return BASE_APPEASE_COST + math.max(0, -Factions.reputationFor(player, faction)) * 2 +end + +-- Spends gold to buy `faction`'s standing up by a fixed amount. Returns +-- (true, costPaid) on success; (false, cost) when the player cannot afford +-- it; (false, 0) when the faction is already maxed out and has nothing left +-- to sell. +function Factions.appease(player, faction) + if Factions.reputationFor(player, faction) >= 100 then return false, 0 end + local cost = Factions.appeaseCost(player, faction) + if player.gold < cost then return false, cost end + player.gold = player.gold - cost + Factions.adjust(player, faction, APPEASE_GAIN) + return true, cost +end + +return Factions From 2d1cf09afabaf193b40ffd6b4b85e18e441f7b8e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 11:51:48 +0200 Subject: [PATCH 036/172] feat: extend game with Game.new and Game:metaStats --- src/game.lua | 189 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 185 insertions(+), 4 deletions(-) diff --git a/src/game.lua b/src/game.lua index 3df0b76..4a06ff6 100644 --- a/src/game.lua +++ b/src/game.lua @@ -10,51 +10,232 @@ local Los = require("los") local Light = require("light") local Combat = require("combat") local Items = require("items") +local Shop = require("shop") local Effects = require("effects") local Status = require("status") local Identify = require("identify") +local Builder = require("builder") local Dungeon = require("dungeon") local Entity = require("entity") local MessageLog = require("messagelog") local Scheduler = require("scheduler") local Ai = require("ai") -local Serialize = require("serialize") +local SaveGame = require("savegame") local Render = require("render") +local Progression = require("progression") +local Monsters = require("monsters") +local Spells = require("spells") +local Throwing = require("throwing") +local Ranged = require("ranged") +local Abilities = require("abilities") +local Hazards = require("hazards") +local Terrain = require("terrain") +local Shrines = require("shrines") +local Classes = require("classes") +local Skills = require("skills") +local Crafting = require("crafting") +local Companions = require("companions") +local Mercenaries = require("mercenaries") +local Artifacts = require("artifacts") +local Sets = require("sets") +local Hunger = require("hunger") +local Mimics = require("mimics") +local Bosses = require("bosses") +local Phases = require("phases") +local Quests = require("quests") +local Meta = require("meta") +local Alchemy = require("alchemy") +local Resistances = require("resistances") +local Factions = require("factions") +local Encumbrance = require("encumbrance") +local Elites = require("elites") +local Vaults = require("vaults") +local Runewords = require("runewords") +local Atmosphere = require("atmosphere") +local Events = require("events") +local Stealth = require("stealth") +local Compendium = require("compendium") +local Nodes = require("nodes") +local Curses = require("curses") +local Momentum = require("momentum") +local Corpses = require("corpses") +local Digging = require("digging") +local Sanity = require("sanity") +local Graves = require("graves") +local Proficiency = require("proficiency") +local Ascension = require("ascension") +local Durability = require("durability") +local Conductivity = require("conductivity") +local Disarm = require("disarm") +local Reanimation = require("reanimation") +local Secrets = require("secrets") +local Chests = require("chests") +local Nemesis = require("nemesis") +local Scavenging = require("scavenging") +local Infighting = require("infighting") +local Blocks = require("blocks") +local Noise = require("noise") +local Disease = require("disease") +local Captives = require("captives") +local Level = require("level") +local Horde = require("horde") +local Branches = require("branches") +local Commands = require("commands") + +-- Real feature history, oldest first, matching the fields Game.fromData +-- below has always had to guard with "or" because they arrived after the +-- original snapshot shape: essence and the vault/set-piece tallies, then the +-- skill tree and reputation, then sanity. A run save from before any of +-- those landed is missing exactly the fields each step below fills in. +local GameSave = SaveGame.new({ + [1] = function(data) + data.playerExtra.essence = data.playerExtra.essence or 0 + data.stats = data.stats or { kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0 } + data.stats.vaultsOpened = data.stats.vaultsOpened or 0 + data.stats.setPiecesFound = data.stats.setPiecesFound or 0 + return data + end, + [2] = function(data) + data.playerExtra.skills = data.playerExtra.skills or {} + data.playerExtra.skillPoints = data.playerExtra.skillPoints or 0 + data.playerExtra.skillCooldowns = data.playerExtra.skillCooldowns or {} + data.playerExtra.reputation = data.playerExtra.reputation or {} + return data + end, + [3] = function(data) + data.playerExtra.sanity = data.playerExtra.sanity or Sanity.MAX + return data + end, +}) local Game = {} Game.__index = Game local FOV_RADIUS = 8 +-- Which equip slot each wearable item kind occupies, used to check a slot +-- for a bound curse before letting a new piece take its place. +local slotByItemKind = { + [Items.WEAPON] = "weapon", [Items.ARMOR] = "armor", + [Items.RING] = "ring", [Items.BOW] = "bow", +} + local function sign(n) if n > 0 then return 1 elseif n < 0 then return -1 else return 0 end end -function Game.new(seed) +function Game.new(seed, options) local game = setmetatable({}, Game) game.seed = seed game.rng = Rng.new(seed) game.depth = 1 game.turn = 0 + game.won = false game.log = MessageLog.new(32) game.identify = Identify.new(game.rng) - game.dungeon = Dungeon.new(seed) + game.dungeon = Dungeon.new(seed, options) + game.maxDepth = Builder.maxDepth(options) game.level = game.dungeon:get(1) game.player = Entity.new({ id = 0, x = game.level.upX, y = game.level.upY, glyph = "@", name = "you", kind = "player", hp = 30, maxHp = 30, attack = 5, defense = 1, speed = 100, }) - game.player.satiation = 1000 + game.player.satiation = Hunger.START + game.player.sanity = Sanity.MAX + game.player.gold = 20 + game.player.essence = 0 game.player.light = { fuel = 400, radius = 8 } + game.player.level = 1 + game.player.xp = 0 + game.player.xpNext = Progression.xpToNext(1) + game.player.mana = 10 + game.player.maxMana = 10 + game.player.spellsKnown = { "missile" } game.player.inventory = { Items.potion(10), Items.potion(10), Items.food("ration", 400), Items.scroll("mapping"), } + game.player.skills = {} + game.player.skillPoints = 1 + game.player.skillCooldowns = {} + game.player.reputation = {} + game.deepestDepth = 1 + game.stats = { + kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 0, + setPiecesFound = 0, chestsOpened = 0, + } + if options and options.meta then + Meta.applyAll(options.meta, game.player) + -- Reuses options.meta's own table (rather than copying it) so every + -- sighting/kill recorded mid-run is already sitting in the table + -- warren.lua writes back out with Meta.save at the end of the run. + options.meta.compendium = options.meta.compendium or Compendium.new() + game.compendium = options.meta.compendium + else + game.compendium = Compendium.new() + end + if options and options.class and Classes.isValid(options.class) then + Classes.apply(game.player, options.class) + end + game.ascension = Ascension.resolve(options and options.meta, options and options.mutators) + Ascension.applyToPlayer(game.ascension, game.player) game.visible = {} + game.monsterSeq = 0 + game.nemesis = nil game:computeVisible() return game end +-- Snapshot fed to meta.lua at the end of a run (see warren.lua's --meta +-- flag): everything Meta.renownFor and every achievement condition read, +-- gathered in one place so neither has to know Game's own field names. +function Game:metaStats() + return { + deepestDepth = self.deepestDepth, + kills = self.stats.kills, + bossKills = self.stats.bossKills, + questsCompleted = self.stats.questsCompleted, + artifactsFound = self.stats.artifactsFound, + vaultsOpened = self.stats.vaultsOpened, + setPiecesFound = self.stats.setPiecesFound, + chestsOpened = self.stats.chestsOpened, + won = self.won, + mutators = self.ascension, + } +end + +-- The grave this run leaves behind for a future one to stumble across (see +-- graves.lua and Builder's options.graves): only warren.lua's --meta path +-- ever calls this, and only once a run has actually ended in death - +-- winning walks out with the Amulet, not a grave. className falls back to +-- "wanderer" for a run that never picked a class at all. +function Game:graveEntry() + return { + className = self.player.class or "wanderer", + depth = self.deepestDepth, + gold = self.player.gold, + essence = self.player.essence, + turn = self.turn, + } +end + +-- Fresh id for a monster spawned mid-game (summon, split), disjoint from the +-- small sequential ids the builder hands out at level generation. +function Game:nextMonsterId() + self.monsterSeq = self.monsterSeq + 1 + return 100000 + self.monsterSeq +end + +function Game:spawnMinion(speciesKey, x, y) + local spec = Monsters.byKey[speciesKey] + local minion = Monsters.create(spec, self:nextMonsterId(), x, y, self.depth) + -- Summoned or split mid-fight, so it is already aware rather than asleep - + -- see stealth.lua's header for why builder-placed wild monsters differ. + minion.awareness = Stealth.HUNTING + self.level.entities[#self.level.entities + 1] = minion + return minion +end + function Game:currentEntities() return self.level.entities end From d04add4a7b9c2bf2490509a330e4921d56b86b67 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 13:10:10 +0200 Subject: [PATCH 037/172] feat: extend game with Game:monsterDamageMultiplier --- src/game.lua | 154 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 6 deletions(-) diff --git a/src/game.lua b/src/game.lua index 4a06ff6..a78b980 100644 --- a/src/game.lua +++ b/src/game.lua @@ -241,9 +241,34 @@ function Game:currentEntities() end function Game:effectiveRadius() - if not self.level.dark then return FOV_RADIUS end - if self.player.light.fuel > 0 then return self.player.light.radius end - return 2 + local radius = FOV_RADIUS + if self.level.dark then + radius = self.player.light.fuel > 0 and self.player.light.radius or 2 + end + radius = radius - Atmosphere.sightPenalty(self.level.weather) - Events.sightPenalty(self.level.event) + - Ascension.sightPenalty(self.ascension) + if radius < 1 then radius = 1 end + -- Blindness overrides every other light/weather/mutator term rather than + -- stacking with them: a blinded player run through with a full lantern in + -- clear weather still can't see past their own hand. + if Status.has(self.player, Status.BLIND) then radius = 1 end + return radius +end + +-- Folds events.lua's level-scoped monster damage swing together with +-- whatever ascension.lua mutators the player opted into for this run, read +-- live at the point a monster actually lands a hit (Game:monsterMelee, +-- Game:monsterShoot, Game:triggerMimic and Game:triggerGrave's ambush +-- swings) rather than baked into any monster's own stats. +function Game:monsterDamageMultiplier() + return Events.monsterDamageMultiplier(self.level.event) * Ascension.damageMultiplier(self.ascension) +end + +-- Folds events.lua's rich-veins swing together with ascension.lua's own +-- essence mutators, read live wherever essence is actually credited +-- (Game:awardKill, Nodes.harvest). +function Game:essenceMultiplier() + return Events.essenceMultiplier(self.level.event) * Ascension.essenceMultiplier(self.ascension) end function Game:computeVisible() @@ -252,6 +277,19 @@ function Game:computeVisible() for idx in pairs(self.visible) do self.level:markExplored(idx) end + self:noteSightings() +end + +-- Records every currently-visible living monster's species into the +-- compendium (see compendium.lua) - silent bookkeeping, never touches the +-- message log, so it cannot perturb anything that reads recent log lines. +function Game:noteSightings() + local map = self.level.map + for _, entity in ipairs(self.level.entities) do + if entity.kind == "monster" and entity:isAlive() and self.visible[map:index(entity.x, entity.y)] then + Compendium.noteSighting(self.compendium, entity.species) + end + end end function Game:cleanupDead() @@ -265,6 +303,13 @@ function Game:hasKey() return false end +function Game:hasAmulet() + for _, item in ipairs(self.player.inventory) do + if item.kind == Items.AMULET then return true end + end + return false +end + function Game:randomFloorCell() local map = self.level.map local cells = {} @@ -294,9 +339,39 @@ function Game:playerAdjacent(monster) return math.abs(monster.x - self.player.x) + math.abs(monster.y - self.player.y) == 1 end +-- The player-aligned companion, hireling, or thrall standing right next to +-- `monster` (Manhattan-1, the same cardinal-only adjacency Game:playerAdjacent +-- itself uses), or nil if none is that close. A hostile monster whose only +-- route toward the player runs through a one-wide corridor an ally is +-- standing in cannot path around it (Game:stepMonsterToward's own +-- blockedForMonster treats any occupied non-goal cell as a wall), so without +-- this check every ai.lua pursuit behaviour just holds position forever once +-- it reaches that ally's doorstep - the ally becomes an unkillable, un- +-- fightable roadblock rather than the combatant Game:onActorDeath and the +-- companion/mercenary/thrall noise-emission work already treat it as. Ties +-- break toward the lower entity id, matching Game:nearestAlly's own rule. +function Game:adjacentAlly(monster) + local best + for _, other in ipairs(self.level.entities) do + if other ~= monster and other:isAlive() + and (other.kind == "companion" or other.kind == "hireling" or other.kind == "thrall") + and math.abs(other.x - monster.x) + math.abs(other.y - monster.y) == 1 + and (not best or other.id < best.id) then + best = other + end + end + return best +end + function Game:canSeePlayer(monster, range) + local penalty = Skills.detectionPenalty(self.player, self.level.dark) + local effectiveRange = math.max(range - penalty, 0) + -- A blinded monster can still feel the player out at melee range but loses + -- every longer-ranged aggro/pack/flee/ranged check in ai.lua - the same + -- clamp Game:effectiveRadius applies to a blinded player's own sight. + if Status.has(monster, Status.BLIND) then effectiveRange = math.min(effectiveRange, 1) end local cheb = math.max(math.abs(monster.x - self.player.x), math.abs(monster.y - self.player.y)) - if cheb > range then return false end + if cheb > effectiveRange then return false end return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y) end @@ -305,20 +380,87 @@ function Game:hasClearShot(monster) end function Game:monsterMelee(monster) - local outcome = Combat.attack(monster, self.player) + if Skills.evades(self, self.player) then + self.log:add(string.format("you evade %s's attack", monster.name)) + return + end + local outcome = Combat.attack(monster, self.player, self:monsterDamageMultiplier()) + Momentum.onDamaged(self.player) self.log:add(string.format("%s hits you for %d", monster.name, outcome.damage)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end + self:applyEliteOnHit(monster, outcome.damage, self.player) + local thorns = Artifacts.thornsAmount(self.player, outcome.damage) + if thorns > 0 then + -- Aegis of Embers "sears" its attacker by name and by log line, so this + -- reflect is fire damage, not a bare stat subtraction - a fire-immune + -- dragon (bestiary-real, reachable from depth 6) shrugs it off entirely, + -- exactly like every other fire source in the engine already does. + local dealt = Resistances.apply(monster, thorns, Resistances.FIRE) + if dealt > 0 then + monster.hp = monster.hp - dealt + self.log:add(string.format("your armor sears %s for %d", monster.name, dealt)) + if not monster:isAlive() then self:awardKill(monster) end + else + self.log:add(string.format("your armor's embers wash harmlessly over %s", monster.name)) + end + end + if monster.biteDisease and self.player:isAlive() and self.rng:chance(1, 4) then + if Disease.infect(self.player, monster.biteDisease) then + self.log:add(string.format("%s's bite leaves you feeling ill", monster.name)) + end + end + self:emitNoise(monster.x, monster.y, Noise.MELEE) +end + +-- A vampiric elite heals a portion of the damage it just dealt; a chilling +-- one leaves its status rider on the victim. Shared by melee and a ranged +-- shot so either delivery route triggers the same elite hooks - and, since +-- Infighting.strike resolves an elite fighting a rival monster or the +-- player's own companion/hireling/thrall through this same method, the log +-- line names the actual victim instead of always assuming it is the player. +function Game:applyEliteOnHit(attacker, dealt, victim) + local lifesteal = Elites.lifestealFraction(attacker) + if lifesteal > 0 then + local healed = math.floor(dealt * lifesteal) + if healed > 0 and attacker:isAlive() then + attacker.hp = math.min(attacker.maxHp, attacker.hp + healed) + local from = victim == self.player and "you" or victim.name + self.log:add(string.format("%s drains %d hp from %s", attacker.name, healed, from)) + end + end + local rider = Elites.onHitRider(attacker) + if rider and victim:isAlive() then + Status.apply(victim, rider.status, rider.duration, rider.potency) + local who = victim == self.player and "you" or victim.name + self.log:add(string.format("%s's touch leaves %s %s", attacker.name, who, rider.status)) + end end function Game:monsterShoot(monster) - local dealt = Combat.damage(monster, self.player) + local damageType = monster.breath == "fire" and Resistances.FIRE or Resistances.PHYSICAL + local dealt = Combat.damage(monster, self.player, self:monsterDamageMultiplier(), damageType) self.player.hp = self.player.hp - dealt + Momentum.onDamaged(self.player) self.log:add(string.format("%s shoots you for %d", monster.name, dealt)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end + self:applyEliteOnHit(monster, dealt, self.player) + if monster.breath == "fire" then + if Resistances.tierOf(self.player, Resistances.FIRE) == Resistances.IMMUNE then + self.log:add("the flames wash over you harmlessly") + else + Status.apply(self.player, Status.BURNING, 3, 2) + self.log:add("the flames catch you alight") + end + end end local function blockedForMonster(game, self, goalX, goalY) return function(x, y) if x == goalX and y == goalY then return false end if not game.level.map:isWalkable(x, y) then return true end + if game.level:blockAt(x, y) then return true end if x == game.player.x and y == game.player.y then return true end local occupant = game.level:entityAt(x, y) return occupant ~= nil and occupant ~= self From 928f1e6a9edc50afa166853e4a5c309e2604b521 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 14:31:40 +0200 Subject: [PATCH 038/172] feat: extend game with Game:checkTrapFor and Game:fallIntoChasm --- src/game.lua | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/src/game.lua b/src/game.lua index a78b980..39c371c 100644 --- a/src/game.lua +++ b/src/game.lua @@ -467,6 +467,81 @@ local function blockedForMonster(game, self, goalX, goalY) end end +-- Springs whatever untouched trap sits under `entity` after it has just +-- moved there - a player-planted trap kit harms a monster or companion +-- exactly as a builder-placed one harms the player, since both live in the +-- same level.traps list and share springTrap's resolution. A levitating +-- entity (Status.LEVITATE) drifts clear of the pressure plate entirely - +-- deliberately silent and without marking the trap revealed, unlike the +-- lava/chasm skip in checkTerrainFor: a hidden trap is information the +-- player never earned just by floating over it, so levitation buys safety +-- without also buying detection. +function Game:checkTrapFor(entity) + if Status.has(entity, Status.LEVITATE) then return end + local trap = self.level:trapAt(entity.x, entity.y) + if trap and not trap.revealed then + self:springTrap(trap, entity) + if not entity:isAlive() then self:onActorDeath(entity) end + end +end + +-- A pit with no floor below it: the player falls to the next depth and +-- takes fall damage, a monster that blunders in is simply gone. Builder +-- never seeds a chasm on the final floor, so there is always a level below +-- to land on. +function Game:fallIntoChasm(entity) + if entity == self.player then + local dealt = 6 + self.rng:range(0, 6) + self.player.hp = self.player.hp - dealt + self.log:add(string.format("the floor gives way - you plunge into the dark for %d", dealt)) + if not self.player:isAlive() then return end + self.depth = self.depth + 1 + self.level = self.dungeon:get(self.depth) + local x, y = self:randomFloorCell() + if not x then x, y = self.level.upX, self.level.upY end + self.player.x, self.player.y = x, y + if self.depth > self.deepestDepth then self.deepestDepth = self.depth end + self:computeVisible() + else + entity.hp = 0 + self.log:add(string.format("%s plunges into the chasm and is gone", entity.name)) + self:onActorDeath(entity) + end +end + +-- Runs after entity has just come to rest on (dx, dy) from its previous +-- cell: ice keeps sliding it onward, lava burns it in place, and a chasm +-- ends the move altogether. Order matters - a slide can carry a traveller +-- straight off the ice and into whatever waits beyond it. A levitating +-- entity (Status.LEVITATE) never touches the ground at all, so it short +-- circuits every branch below - no slide, no burn, no fall - though it +-- still gets a log line when that skip actually mattered (standing over +-- lava or a chasm), since both are visible on the map and worth calling out. +function Game:checkTerrainFor(entity, dx, dy) + if not entity:isAlive() then return end + if Status.has(entity, Status.LEVITATE) then + if self.level.map:get(entity.x, entity.y) == Tiles.LAVA or Terrain.isChasm(self.level, entity) then + self.log:add(entity == self.player and "you drift safely over the hazard below" + or string.format("%s drifts safely over the hazard below", entity.name)) + end + return + end + if self.level.map:get(entity.x, entity.y) == Tiles.ICE then + Terrain.slide(self.level, entity, dx, dy) + end + local line = Terrain.afflictLava(self.level, entity) + if line then + self.log:add(line) + if not entity:isAlive() then + if entity ~= self.player then self:onActorDeath(entity) end + return + end + end + if Terrain.isChasm(self.level, entity) then + self:fallIntoChasm(entity) + end +end + function Game:stepMonsterToward(monster, tx, ty) local path = Astar.find(self.level.map, monster.x, monster.y, tx, ty, blockedForMonster(self, monster, tx, ty)) @@ -474,7 +549,10 @@ function Game:stepMonsterToward(monster, tx, ty) local step = path[2] if self.level.map:isWalkable(step.x, step.y) and not self.level:entityAt(step.x, step.y) and not (step.x == self.player.x and step.y == self.player.y) then + local dx, dy = step.x - monster.x, step.y - monster.y monster.x, monster.y = step.x, step.y + self:checkTrapFor(monster) + self:checkTerrainFor(monster, dx, dy) end end end @@ -494,15 +572,89 @@ function Game:stepMonsterAway(monster, tx, ty) end end end - if bx then monster.x, monster.y = bx, by end + if bx then + local dx, dy = bx - monster.x, by - monster.y + monster.x, monster.y = bx, by + self:checkTrapFor(monster) + self:checkTerrainFor(monster, dx, dy) + end end +-- An erratic wanderer's random step or a confused chaser's overridden one +-- (Ai.act, both routed here) can land on an ally's cell exactly as easily as +-- the player's - Infighting.strike resolves that the same generic way +-- Game:adjacentAlly's own callers do, rather than the bump silently doing +-- nothing the way it would against an impassable wall. function Game:moveMonster(monster, dx, dy) local nx, ny = monster.x + dx, monster.y + dy if nx == self.player.x and ny == self.player.y then self:monsterMelee(monster) - elseif self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) then + return + end + local occupant = self.level:entityAt(nx, ny) + if occupant and (occupant.kind == "companion" or occupant.kind == "hireling" or occupant.kind == "thrall") then + Infighting.strike(self, monster, occupant) + return + end + if self.level.map:isWalkable(nx, ny) and not occupant then monster.x, monster.y = nx, ny + self:checkTrapFor(monster) + self:checkTerrainFor(monster, dx, dy) + end +end + +-- Mirrors ai.lua's own Ai.act confusion stumble for the three player-aligned +-- actor kinds (companion/hireling/thrall). None of those ever dispatch +-- through Ai.act, so without this they would leave Status.CONFUSE completely +-- inert on them despite Game:springTrap and friends already applying it to +-- "any victim" regardless of kind. Returns true whenever the roll actually +-- fires (whether or not the stumble itself finds anywhere to go), so each +-- caller can `if game:allyActsConfused(ally) then return end` first thing, +-- exactly the precedence Ai.act gives the same roll over chase/pack/etc. +-- Unlike a confused monster's stumble (moveMonster above, which treats a +-- step onto the player's cell as an attack), a confused ally's stumble onto +-- its own master's cell is harmless - an ally never attacks the player it is +-- bonded or hired to protect, so that one direction is simply blocked like a +-- wall rather than routed into any melee helper. +function Game:allyActsConfused(ally) + if not Status.has(ally, Status.CONFUSE) then return false end + if not self.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then return false end + local dir = MONSTER_DIRS[self.rng:range(1, #MONSTER_DIRS)] + local nx, ny = ally.x + dir[1], ally.y + dir[2] + if not (nx == self.player.x and ny == self.player.y) + and self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) then + ally.x, ally.y = nx, ny + self:checkTrapFor(ally) + self:checkTerrainFor(ally, dir[1], dir[2]) + end + return true +end + +-- A burrower's turn when it can neither reach nor see the player through +-- open floor: senses the player's general direction through solid rock out +-- to Digging.SENSE_RANGE away and, of the two candidate cells toward them +-- (longer axis first - see Digging.directionsToward), either claws one +-- swing into the first one that is still diggable, exactly like Game:dig +-- but toolless and self-directed, or - if open floor already leads that +-- way - simply steps there so it closes the distance before it ever needs +-- to swing at all. Idles once out of range or once neither candidate cell +-- is diggable nor walkable, the same way "flee" idles once it loses sight. +function Game:monsterDig(monster, tx, ty) + local cheb = math.max(math.abs(monster.x - tx), math.abs(monster.y - ty)) + if cheb == 0 or cheb > Digging.SENSE_RANGE then return end + for _, dir in ipairs(Digging.directionsToward(monster.x, monster.y, tx, ty)) do + local nx, ny = monster.x + dir[1], monster.y + dir[2] + if Digging.canDig(self.level.map, nx, ny) then + self.log:add(Digging.swing(self, monster, nx, ny)) + if not monster:isAlive() then self:onActorDeath(monster) end + return + elseif self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) + and not (nx == self.player.x and ny == self.player.y) then + monster.x, monster.y = nx, ny + self:checkTrapFor(monster) + self:checkTerrainFor(monster, dir[1], dir[2]) + return + end end end @@ -519,10 +671,28 @@ function Game:nearestAlly(monster) return ally end +-- A monster's death sweeps xp and essence to the player; a companion fights +-- for the player rather than yielding a reward for dying, so its death is +-- only logged and swept. +function Game:onActorDeath(actor) + if actor.kind == "companion" then + self.log:add("your " .. actor.name .. " falls") + self:cleanupDead() + elseif actor.kind == "hireling" then + self.log:add("your " .. actor.name .. " falls") + self:cleanupDead() + elseif actor.kind == "thrall" then + self.log:add("your " .. actor.name .. " crumbles back to bones") + self:cleanupDead() + else + self:awardKill(actor) + end +end + function Game:takeMonsterTurn(monster) self.log:addAll(Status.tick(monster)) if not monster:isAlive() then - self:cleanupDead() + self:onActorDeath(monster) return end if not Status.skipsTurn(monster) then From 9d17fcb7d8e2c6e1337220e8f490fcc0e9316c0a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 15:12:41 +0200 Subject: [PATCH 039/172] feat: extend game with Game:awardKill and Game:applyWeaponRiders --- src/game.lua | 174 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 8 deletions(-) diff --git a/src/game.lua b/src/game.lua index 39c371c..79cff2f 100644 --- a/src/game.lua +++ b/src/game.lua @@ -695,8 +695,53 @@ function Game:takeMonsterTurn(monster) self:onActorDeath(monster) return end - if not Status.skipsTurn(monster) then - Ai.act(self, monster) + Conductivity.tick(self.level, monster) + local hazardLine = Hazards.afflict(self.level, monster) + if hazardLine then self.log:add(hazardLine) end + if not monster:isAlive() then + self:onActorDeath(monster) + return + end + -- Mirrors Game:hungerTorchTick's own Disease.tick(self.player) call - a + -- disease is plain generic entity state (see disease.lua), and since + -- Infighting.strike can now infect a rival monster or the player's own + -- companion/hireling/thrall (not just the player), every non-player actor + -- needs its own infection to actually progress on its turn too, or an + -- infected ally would sit permanently incubating with no way to ever + -- erupt, escalate, or be noticed. + local diseaseLine = Disease.tick(monster) + if diseaseLine then self.log:add(diseaseLine) end + if not monster:isAlive() then + self:onActorDeath(monster) + return + end + local regenLine = Abilities.regenTick(monster) + if regenLine then self.log:add(regenLine) end + local enrageLine = Bosses.tryEnrage(monster) + if enrageLine then self.log:add(enrageLine) end + local phaseLine, phaseConsumedTurn = Phases.tick(self, monster) + if phaseLine then self.log:add(phaseLine) end + if not phaseConsumedTurn and not Status.skipsTurn(monster) then + if monster.kind == "companion" then + Companions.act(self, monster) + elseif monster.kind == "hireling" then + Mercenaries.act(self, monster) + elseif monster.kind == "thrall" then + Reanimation.act(self, monster) + elseif Infighting.tryAct(self, monster) then + -- A rivalry or a discorded mind has nothing to do with how this + -- monster currently feels about the player: it fights regardless of + -- wariness or - unlike Stealth.tick's own gating - whether it has + -- otherwise noticed the player at all this turn. + elseif Factions.isWary(self.player, monster) then + -- A wary tribe member holds its ground: neither provoked nor hated + -- into open hostility yet, it leaves the player be this turn. + elseif Stealth.tick(self, monster) then + Abilities.tickCooldown(monster) + if not Abilities.tryAct(self, monster) then + Ai.act(self, monster) + end + end end Scheduler.spend(monster) self:cleanupDead() @@ -713,15 +758,128 @@ end -- Player actions. -function Game:playerAttack(target) - local outcome = Combat.attack(self.player, target) - self.log:add(string.format("you hit %s for %d", target.name, outcome.damage)) - local weapon = self.player.weapon - if weapon and weapon.affix and weapon.affix.onHit and target:isAlive() then - local rider = weapon.affix.onHit +-- Common tail of any player-caused kill: log it, grant the xp it was worth, +-- and sweep it off the level. Shared by melee and offensive spells so xp +-- always tracks a felled monster regardless of what felled it. +function Game:awardKill(target) + self.log:add(target.name .. " dies") + local burst = Abilities.onDeath(self, target) + if burst then self.log:add(burst) end + local eliteBurst = Elites.onDeath(self, target) + if eliteBurst then self.log:add(eliteBurst) end + self.stats.kills = self.stats.kills + 1 + if target.isBoss then self.stats.bossKills = self.stats.bossKills + 1 end + Factions.registerKill(self.player, target) + local xp = target.isBoss and Bosses.xpFor(target) or target.mimic and Mimics.xpFor() + or target.grave and Graves.xpFor() or Monsters.xpFor(target) + xp = xp + Elites.xpBonus(target, xp) + if target.nemesis then xp = xp + Nemesis.xpBonus(target.nemesisGrudge or 1) end + self.log:addAll(Progression.grantXp(self.player, xp)) + self.player.essence = self.player.essence + math.floor(Crafting.essenceFor(xp) * self:essenceMultiplier()) + Quests.registerKill(self.player, target.species) + Compendium.noteKill(self.compendium, target.species) + Corpses.spawn(self.level, target) + self:resolveQuest() + if target.isBoss then + local loot = Bosses.lootFor(target) + if loot then + self.level.floorItems[#self.level.floorItems + 1] = { x = target.x, y = target.y, item = loot } + self.log:add(string.format("%s drops %s", target.name, loot.name)) + end + end + if target.grave then + local gold, essence = Graves.lootFor(target) + if gold > 0 then + self.player.gold = self.player.gold + gold + self.log:add(string.format("the shade scatters %d gold as it fades", gold)) + end + if essence > 0 then + self.player.essence = self.player.essence + essence + end + end + if target.nemesis then + local bounty = Nemesis.bountyGold(target.nemesisGrudge or 1) + self.player.gold = self.player.gold + bounty + self.log:add(string.format("the hunt is over - you claim %d gold from %s", bounty, target.name)) + self.nemesis = nil + end + self:cleanupDead() +end + +-- Applies a weapon's on-hit rider - rolled affix or socketed gem, or both - +-- to a still-living target, logging each status it lands. +function Game:applyWeaponRiders(weapon, target) + if not (weapon and target:isAlive()) then return end + local riders = {} + if weapon.affix and weapon.affix.onHit then riders[#riders + 1] = weapon.affix.onHit end + if weapon.socket and weapon.socket.onHit then riders[#riders + 1] = weapon.socket.onHit end + for _, rider in ipairs(riders) do Status.apply(target, rider.status, rider.duration, rider.potency) self.log:add(string.format("%s is %s", target.name, rider.status)) end +end + +-- Marks a monster as no longer wary (see factions.lua): once the player has +-- aimed any offensive action at it, a tribal target that used to hold back +-- fights (or flees) like any other monster from then on, hit or miss. Any +-- offensive action also wakes a dormant target outright (see stealth.lua) - +-- an arrow or a spell that misses still gives away exactly where you are. +function Game:provoke(target) + if target and target.kind == "monster" then + target.provoked = true + Stealth.alert(target, self.player.x, self.player.y) + end +end + +-- Floods a burst of noise.lua sound out from (x, y) and gives every other +-- still-dormant monster the flood actually reaches a chance to stir - a +-- clash of steel three rooms over does not single out whichever target +-- Game:provoke already woke outright, it startles whatever else is close +-- enough to hear it too. A monster startled this way only ever starts +-- searching the noise's origin (see Stealth.hear), never snaps straight to +-- hunting the way a direct hit does. +function Game:emitNoise(x, y, loudness) + local field = Noise.propagate(self.level.map, x, y, loudness) + for _, entity in ipairs(self.level.entities) do + if entity.kind == "monster" and Stealth.isUnaware(entity) then + local intensity = Noise.intensityAt(field, self.level.map, entity.x, entity.y) + if intensity > 0 and self.rng:chance(intensity, loudness) then + Stealth.hear(entity, x, y) + self.log:add(string.format("%s stirs at the noise", entity.name)) + end + end + end +end + +function Game:playerAttack(target) + -- Read before provoke() wakes the target below, so the bonus reflects + -- whatever it actually was the instant the blow landed. + local sneak = Stealth.sneakMultiplier(target) + self:provoke(target) + self:emitNoise(self.player.x, self.player.y, Noise.MELEE) + local multiplier = Skills.meleeDamageMultiplier(self.player, target) * sneak + local outcome = Combat.attack(self.player, target, multiplier) + Momentum.onMeleeHit(self.player) + if sneak > 1 then + self.log:add(string.format("you catch %s completely unaware", target.name)) + end + self.log:add(string.format("you hit %s for %d", target.name, outcome.damage)) + local broke = Durability.wearWeapon(self.player) + if broke then self.log:add(broke) end + local lifesteal = Artifacts.lifestealAmount(self.player, outcome.damage) + if lifesteal > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + lifesteal) + self.log:add(string.format("your %s drinks %d hp from %s", self.player.weapon.name, lifesteal, target.name)) + end + self:applyWeaponRiders(self.player.weapon, target) + if Skills.consumePoisonBlade(self.player) and target:isAlive() then + Status.apply(target, Status.POISON, 5, 2) + self.log:add(string.format("%s is envenomed", target.name)) + end + local masteredKey, masteredBonus = Proficiency.onMeleeHit(self.player) + if masteredKey then + self.log:add(string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus)) + end if outcome.killed then self.log:add(target.name .. " dies") self:cleanupDead() From c33c502307523aaaad055811683f96376c47a2d5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 15:37:04 +0200 Subject: [PATCH 040/172] feat: extend game with Game:castSpell and Game:throwAt --- src/game.lua | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/game.lua b/src/game.lua index 79cff2f..27ade50 100644 --- a/src/game.lua +++ b/src/game.lua @@ -881,7 +881,53 @@ function Game:playerAttack(target) self.log:add(string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus)) end if outcome.killed then - self.log:add(target.name .. " dies") + self:awardKill(target) + else + local splitLine = Abilities.onHit(self, target) + if splitLine then self.log:add(splitLine) end + end +end + +-- Attempts to cast a known spell, optionally at a target monster. Returns +-- true and logs its effect on success; on failure logs why and leaves all +-- state - mana included - untouched. +function Game:castSpell(key, target) + self:provoke(target) + local success, lines = Spells.cast(self, self.player, key, target) + self.log:addAll(lines) + if success then self:emitNoise(self.player.x, self.player.y, Noise.SPELL) end + return success +end + +-- Hurls the inventory item at `index` at a monster. The item always leaves +-- the inventory on a successful throw, whatever it did on arrival. +function Game:throwAt(index, target) + local item = self.player.inventory[index] + if not item then return false end + self:provoke(target) + local success, lines = Throwing.resolve(self, self.player, item, target) + if success then + table.remove(self.player.inventory, index) + self:emitNoise(self.player.x, self.player.y, Noise.THROW) + end + self.log:addAll(lines) + return success +end + +-- Throws a net from the inventory at `index` at a battered monster instead +-- of finishing it off in melee (see captives.lua). The net always leaves +-- the inventory once thrown, whether it actually holds the target or not; +-- a genuine catch removes the target from level.entities without ever +-- routing through Game:awardKill, so it earns no xp, essence, quest credit +-- or faction kill penalty - only a captive left standing on the floor. +function Game:captureAt(index, target) + local item = self.player.inventory[index] + if not item then return false end + self:provoke(target) + local success, lines = Captives.resolve(self, self.player, item, target) + if success then + table.remove(self.player.inventory, index) + self:emitNoise(self.player.x, self.player.y, Noise.THROW) self:cleanupDead() end end From b3943d9b6e3a0e2dade56689a70be2a28a7eacbe Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 16:04:58 +0200 Subject: [PATCH 041/172] feat: extend game with Game:fireAt and Game:appeaseFaction --- src/game.lua | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/game.lua b/src/game.lua index 27ade50..8b116b9 100644 --- a/src/game.lua +++ b/src/game.lua @@ -930,6 +930,179 @@ function Game:captureAt(index, target) self:emitNoise(self.player.x, self.player.y, Noise.THROW) self:cleanupDead() end + self.log:addAll(lines) + return success +end + +-- Looses an arrow from the player's equipped bow at a monster. The arrow +-- always leaves the quiver on a successful shot, whatever it did on arrival. +function Game:fireAt(target) + self:provoke(target) + local success, lines = Ranged.fire(self, self.player, target) + self.log:addAll(lines) + if success then self:emitNoise(self.player.x, self.player.y, Noise.RANGED) end + return success +end + +-- Spends gold to smooth over the player's standing with `faction` (see +-- factions.lua): cools a grudge, or pads an existing friendship, by a fixed +-- amount. The asking price climbs the deeper the relationship has soured. +function Game:appeaseFaction(faction) + local success, cost = Factions.appease(self.player, faction) + if success then + self.log:add(string.format("your tribute smooths things over with the %s (%d gold)", faction, cost)) + elseif cost == 0 then + self.log:add(string.format("the %s already think as well of you as they're going to", faction)) + else + self.log:add(string.format("you need %d gold to make peace with the %s", cost, faction)) + end + return success +end + +-- Prays at the shrine standing under the player, spending its gold price for +-- a shot at a permanent blessing (or, with rising odds the more this altar +-- has already been used, a curse instead). Returns false without any state +-- change when there is no shrine here or its price cannot be met. +function Game:prayAt(shrine) + local success, lines = Shrines.pray(self, shrine) + self.log:addAll(lines) + return success +end + +-- Swings the player's equipped pick at whatever lies one step away in +-- (dx, dy): a plain wall or the rubble left by an earlier swing. Returns +-- false without spending the swing when there is no pick readied or the +-- target cell has nothing left to dig (open floor, a vault door, out of +-- bounds); otherwise always spends one use of the pick's durability, even on +-- an unlucky cave-in that costs hp instead of progress (see digging.lua). +function Game:dig(dx, dy) + if not self.player.pick then + self.log:add("you have nothing to dig with") + return false + end + local nx, ny = self.player.x + dx, self.player.y + dy + if not Digging.canDig(self.level.map, nx, ny) then + self.log:add("there is nothing here to dig") + return false + end + self.log:add(Digging.swing(self, self.player, nx, ny)) + local brokenLine = Digging.wearPick(self.player) + if brokenLine then self.log:add(brokenLine) end + self:emitNoise(nx, ny, Noise.DIG) + return true +end + +-- Harvests whatever resource node stands under the player: an ore vein pays +-- out essence, a gem seam yields a raw gem, both free of any gold cost - +-- unlike a shrine's prayer, the only thing rationing this is the node's own +-- charges (see nodes.lua). Returns false without state change when there is +-- no node here or it has already been picked dry this trip. +function Game:harvestNode() + local node = self.level:nodeAt(self.player.x, self.player.y) + if not node then + self.log:add("there is nothing here to harvest") + return false + end + local success, line = Nodes.harvest(self, node) + self.log:add(line) + return success +end + +-- Butchers whatever corpse lies under the player into raw meat (Items.FOOD), +-- eaten later exactly like a found ration - unless the corpse has already +-- gone past its rot threshold (corpses.lua), in which case it poisons the +-- player instead. Either way the corpse itself is spent in one stroke. +-- Returns false without state change when there is nothing here to carve. +function Game:butcherCorpse() + local corpse = self.level:corpseAt(self.player.x, self.player.y) + if not corpse then + self.log:add("there is nothing here to butcher") + return false + end + self.log:add(Corpses.butcher(self, corpse)) + return true +end + +-- Raises whatever fresh corpse lies under the player into a temporary +-- skeletal thrall (see reanimation.lua), spending both the corpse and a +-- bite of sanity in one stroke - a deliberate trade the autopilot never +-- makes on its own, unlike Game:butcherCorpse's free meal, so this is only +-- ever called directly. Returns false without state change when there is +-- no corpse here or Reanimation.raise itself refuses the attempt. +function Game:raiseCorpse() + local corpse = self.level:corpseAt(self.player.x, self.player.y) + if not corpse then + self.log:add("there is nothing here to raise") + return false + end + local thrall, failMsg = Reanimation.raise(self, corpse) + if not thrall then + self.log:add(failMsg) + return false + end + self.log:add("bones knit and rise - your " .. thrall.name .. " answers the call") + return true +end + +-- Carves whatever fresh corpse lies under the player for the herb its own +-- kind carries (see scavenging.lua) instead of a meal or a thrall - a third +-- deliberate trade for the same tile, only ever called directly like +-- Game:raiseCorpse, never by the autopilot. Returns false without state +-- change when there is no corpse here or Scavenging.harvest itself refuses. +function Game:scavengeCorpse() + local corpse = self.level:corpseAt(self.player.x, self.player.y) + if not corpse then + self.log:add("there is nothing here to scavenge") + return false + end + local harvested, message = Scavenging.harvest(self, corpse) + self.log:add(message) + return harvested +end + +-- Opens whatever chest stands under the player. An unlocked one (or one +-- already forced open) yields its hoard outright; a locked one first tries +-- a carried key, exactly like Game:openDoor's own certain trade, and only +-- falls back to a lockpick gamble (Chests.pick, which risks springing a +-- trapped lock) once no key can be found. Returns false without state +-- change when there is nothing here to open, it is already empty, or it is +-- locked with neither a key nor a lockpick on hand. +function Game:openChest() + local chest = self.level:chestAt(self.player.x, self.player.y) + if not chest then + self.log:add("there is nothing here to open") + return false + end + if chest.opened then + self.log:add("this chest is already empty") + return false + end + local success, line + if not Chests.isLocked(chest) then + success, line = Chests.open(self.player, chest) + else + success, line = Chests.unlockWithKey(self.player, chest) + if not success then success, line = Chests.pick(self, chest) end + end + self.log:add(line) + if success then self.stats.chestsOpened = self.stats.chestsOpened + 1 end + return success +end + +-- Frees whatever captive stands under the player back to its own kind for +-- nothing but goodwill (see Captives.ransom) - the one resolution that can +-- pull a wary tribal faction (factions.lua's neutralFactions) back out of +-- open hostility rather than deepening it further. Returns false without +-- state change when there is no captive here. +function Game:ransomCaptive() + local captive = self.level:captiveAt(self.player.x, self.player.y) + if not captive then + self.log:add("there is nothing here to ransom") + return false + end + Captives.ransom(self.level, self.player, captive) + self.log:add(string.format("you release %s - the %s think a little better of you for it", captive.name, captive.faction)) + return true end function Game:openDoor(x, y) From 6f54f4431d26a4b994beaed0ec93d5076f5eb958 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 16:40:52 +0200 Subject: [PATCH 042/172] feat: extend game with Game:sellCaptive and Game:interrogateCaptive --- src/game.lua | 191 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 181 insertions(+), 10 deletions(-) diff --git a/src/game.lua b/src/game.lua index 8b116b9..17a9b57 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1105,6 +1105,61 @@ function Game:ransomCaptive() return true end +-- Sells whatever captive stands under the player for gold at its own +-- faction's expense (see Captives.sell). Returns false without state change +-- when there is no captive here. +function Game:sellCaptive() + local captive = self.level:captiveAt(self.player.x, self.player.y) + if not captive then + self.log:add("there is nothing here to sell") + return false + end + local paid = Captives.sell(self.level, self.player, captive) + self.log:add(string.format("you trade %s away for %d gold", captive.name, paid)) + return true +end + +-- Interrogates whatever captive stands under the player instead of ransoming +-- or selling it (see Captives.interrogate): no gold, no reputation swing, +-- just every currently-hidden secret door on this floor found outright plus +-- a trickle of essence. Returns false without state change when there is no +-- captive here. +function Game:interrogateCaptive() + local captive = self.level:captiveAt(self.player.x, self.player.y) + if not captive then + self.log:add("there is nothing here to interrogate") + return false + end + local revealed = Captives.interrogate(self.level, self.player, captive) + self.log:add(string.format("%s breaks - %d hidden door%s in this floor give themselves away", + captive.name, #revealed, #revealed == 1 and "" or "s")) + return true +end + +-- Binds the player to `board`'s bounty for free, logging the pact struck or +-- the reason it could not be. See Quests.accept for the acceptance rules. +function Game:tryAcceptQuest(board) + local success, line = Quests.accept(self.player, board) + self.log:add(line) + return success +end + +-- Pays out the player's active bounty the moment it is fulfilled, granting +-- gold, xp and essence in one stroke and freeing the player to bind another. +-- A no-op with no active bounty or one still unmet. +function Game:resolveQuest() + local bounty = self.player.quest + if not bounty or not Quests.resolved(self.player, self.depth) then return false end + self.player.gold = self.player.gold + bounty.rewardGold + self.log:addAll(Progression.grantXp(self.player, bounty.rewardXp)) + self.player.essence = self.player.essence + Crafting.essenceFor(bounty.rewardXp) + self.player.quest = nil + self.stats.questsCompleted = self.stats.questsCompleted + 1 + Factions.adjust(self.player, Factions.MERCHANT, 3) + self.log:add(string.format("the bounty resolves: %d gold and %d xp", bounty.rewardGold, bounty.rewardXp)) + return true +end + function Game:openDoor(x, y) local map = self.level.map if map:get(x, y) == Tiles.DOOR_LOCKED then @@ -1124,32 +1179,148 @@ function Game:openDoor(x, y) return true end -function Game:triggerTrap(trap) +-- Springs `trap` on `victim`, whoever that is: reveals it on the map and +-- resolves its effect against that entity's own stats and position. Kept +-- generic so a player-planted trap kit (Game:useItem's Items.KIT branch) +-- harms a monster exactly as a builder-placed one harms the player. +function Game:springTrap(trap, victim) trap.revealed = true self.level.map:set(trap.x, trap.y, Tiles.TRAP) + local isPlayer = victim == self.player + local name = isPlayer and "you" or victim.name if trap.effect == "dart" then local dmg = 3 + self.depth // 2 - self.player.hp = self.player.hp - dmg - self.log:add(string.format("a dart trap hits you for %d", dmg)) + victim.hp = victim.hp - dmg + self.log:add(isPlayer and string.format("a dart trap hits you for %d", dmg) + or string.format("a dart trap hits %s for %d", name, dmg)) elseif trap.effect == "poison" then - Status.apply(self.player, Status.POISON, 5, 2) - self.log:add("you stumble into a poison trap") + Status.apply(victim, Status.POISON, 5, 2) + self.log:add(isPlayer and "you stumble into a poison trap" + or string.format("%s stumbles into a poison trap", name)) + elseif trap.effect == "confuse" then + Status.apply(victim, Status.CONFUSE, 6, 1) + self.log:add(isPlayer and "a cloud of disorienting spores bursts around you" + or string.format("a cloud of disorienting spores bursts around %s", name)) + elseif trap.effect == "blind" then + Status.apply(victim, Status.BLIND, 6, 1) + self.log:add(isPlayer and "a flash of searing light leaves you blind" + or string.format("a flash of searing light leaves %s blind", name)) + elseif trap.effect == "stun" then + Status.apply(victim, Status.STUN, 2, 1) + self.log:add(isPlayer and "a jolt of current locks up your muscles" + or string.format("a jolt of current locks up %s's muscles", name)) else local x, y = self:randomFloorCell() if x then - self.player.x, self.player.y = x, y - self.log:add("a trap flings you across the level") + victim.x, victim.y = x, y + self.log:add(isPlayer and "a trap flings you across the level" + or string.format("a trap flings %s across the level", name)) end end end +function Game:triggerTrap(trap) + self:springTrap(trap, self.player) +end + +-- Attempts to disarm `trap`, already noticed (see disarm.lua's Disarm.sweep, +-- rolled once per turn in Game:endPlayerTurn) but not yet sprung. Refuses +-- outright, without even risking a spring, against a trap never detected or +-- one already triggered - there is nothing left here to disarm. +function Game:disarmTrap(trap) + if not trap or not trap.detected or trap.revealed then + self.log:add("there is nothing here you have noticed to disarm") + return false + end + local success, line = Disarm.attempt(self, trap) + if success then self.log:add(line) end + return success +end + +-- Throws `lever`, a no-op once it has already been thrown. The instant this +-- pull completes every lever sharing its vault id, the shared door swings +-- open (its tile flips to a plain open door - see Vaults.pull) and the run's +-- vault tally advances for meta scoring; otherwise the lever just clunks +-- into its pulled state. +function Game:pullLever(lever) + local pulled, door = Vaults.pull(self.level, lever) + if not pulled then return false end + self.level.map:set(lever.x, lever.y, Tiles.LEVER_PULLED) + if door then + door.opened = true + self.level.map:set(door.x, door.y, Tiles.DOOR_OPEN) + self.stats.vaultsOpened = self.stats.vaultsOpened + 1 + self.log:add("the vault door grinds open") + else + self.log:add("you pull the lever - it clunks into place") + end + return true +end + +-- A priced ware is only free for the taking once its shopkeeper is dead; +-- otherwise it charges the stamped price and is left on the ground when the +-- player cannot afford it yet (they simply walk past it). +function Game:wardedByLiveKeeper(ground) + if not ground.price then return false end + local keeper = self.level:entityById(ground.shopId) + return keeper ~= nil and keeper:isAlive() +end + +-- Player-facing wrapper so callers never need to know encumbrance.lua works +-- entirely off entity.inventory/entity.level rather than any saved field. +function Game:encumbranceTier() + return Encumbrance.tierFor(self.player) +end + function Game:pickupHere() + local tierBefore = self:encumbranceTier() for i = #self.level.floorItems, 1, -1 do local ground = self.level.floorItems[i] if ground.x == self.player.x and ground.y == self.player.y then - self.player.inventory[#self.player.inventory + 1] = ground.item - self.log:add("you pick up " .. Identify.label(self.identify, ground.item)) - table.remove(self.level.floorItems, i) + local item = ground.item + if item.mimic then + table.remove(self.level.floorItems, i) + self:triggerMimic(ground) + elseif item.grave then + table.remove(self.level.floorItems, i) + self:triggerGrave(ground) + elseif item.kind == Items.GOLD then + self.player.gold = self.player.gold + item.amount + self.log:add("you find " .. item.amount .. " gold") + table.remove(self.level.floorItems, i) + elseif self:wardedByLiveKeeper(ground) then + local paid = Shop.buy(self, ground) + if paid then + self.log:add(string.format("you buy %s for %d gold", + Identify.label(self.identify, item), paid)) + table.remove(self.level.floorItems, i) + end + elseif item.kind == Items.AMMO then + local stack = self:findInInventory(function(existing) + return existing.kind == Items.AMMO and existing.name == item.name + end) + if stack then + self.player.inventory[stack].count = self.player.inventory[stack].count + item.count + else + self.player.inventory[#self.player.inventory + 1] = item + end + self.log:add(string.format("you pick up %d %s", item.count, item.name)) + table.remove(self.level.floorItems, i) + else + if item.artifact then self.stats.artifactsFound = self.stats.artifactsFound + 1 end + if item.set then self.stats.setPiecesFound = self.stats.setPiecesFound + 1 end + self.player.inventory[#self.player.inventory + 1] = item + self.log:add("you pick up " .. Identify.label(self.identify, item)) + table.remove(self.level.floorItems, i) + end + end + end + local tierAfter = self:encumbranceTier() + if tierAfter ~= tierBefore and tierAfter ~= Encumbrance.LIGHT then + if tierAfter == Encumbrance.OVERLOADED then + self.log:add("you are overloaded and moving sluggishly") + else + self.log:add("your pack grows heavy") end end end From a43ad023f7d78e8773b6e9902121a0b59e35596a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 17:06:04 +0200 Subject: [PATCH 043/172] feat: extend game with Game:triggerMimic and Game:triggerGrave --- src/game.lua | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/game.lua b/src/game.lua index 17a9b57..dafae90 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1325,6 +1325,179 @@ function Game:pickupHere() end end +-- Unmasks a disguised floorItem the moment the player reaches for it: +-- drops the real hostile monster in its place (already hunting, never +-- dormant - see stealth.lua's header for why a builder-placed wild +-- monster differs) and lands one free ambush swing through combat.lua, +-- so the surprise still respects the player's defense and resistances +-- instead of being a flat, unavoidable trap tick. +function Game:triggerMimic(ground) + local mimic = Mimics.reveal(self:nextMonsterId(), ground.x, ground.y, self.depth) + mimic.awareness = Stealth.HUNTING + self.level.entities[#self.level.entities + 1] = mimic + self.log:add(ground.item.name .. " was a mimic!") + local outcome = Combat.attack(mimic, self.player, self:monsterDamageMultiplier()) + Momentum.onDamaged(self.player) + self.log:add(string.format("it lashes out at you for %d damage", outcome.damage)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end +end + +-- Wakes the vengeful shade sleeping under a disturbed grave marker (see +-- graves.lua): unlike a mimic's disguise, the marker never hid what it +-- was, but stepping onto it is still a mistake the same instant Game: +-- pickupHere makes it - one free ambush swing through combat.lua before +-- the shade settles into an ordinary chase on its own turns from then on. +function Game:triggerGrave(ground) + local shade = Graves.reveal(self:nextMonsterId(), ground.x, ground.y, ground) + shade.awareness = Stealth.HUNTING + self.level.entities[#self.level.entities + 1] = shade + self.log:add("the grave stirs - a vengeful shade rises!") + local outcome = Combat.attack(shade, self.player, self:monsterDamageMultiplier()) + Momentum.onDamaged(self.player) + self.log:add(string.format("it lashes out at you for %d damage", outcome.damage)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end +end + +-- Discards an inventory item onto the floor at the player's feet: the +-- opposite of pickupHere, needed once a hoard grows heavier than is +-- comfortable to carry (see encumbrance.lua). Equipped gear never reaches +-- this path - it always trades in place through items.equipWeapon and +-- friends - so this only ever touches the loose inventory array. +function Game:dropItem(index) + local item = self.player.inventory[index] + if not item then return nil end + table.remove(self.player.inventory, index) + self.level.floorItems[#self.level.floorItems + 1] = { x = self.player.x, y = self.player.y, item = item } + self.log:add("you drop " .. Identify.label(self.identify, item)) + return item +end + +-- The cheapest inventory item that is safe to shed first when overloaded: +-- never the amulet (the entire point of a run) or a key (worthless to a +-- shop and needed right where it was found). Returns nil if nothing but +-- those two remains. +function Game:cheapestDroppableItem() + local bestIndex, bestPrice + for i, item in ipairs(self.player.inventory) do + if item.kind ~= Items.AMULET and item.kind ~= Items.KEY then + local price = Shop.priceOf(item) + if not bestPrice or price < bestPrice then + bestIndex, bestPrice = i, price + end + end + end + return bestIndex +end + +-- Sells an inventory item back to the economy at half its catalog price. +function Game:sellItem(index) + local item = self.player.inventory[index] + local value = Shop.sell(self, index) + if value then + self.log:add(string.format("you sell %s for %d gold", Identify.label(self.identify, item), value)) + end + return value +end + +-- Spends gold to restore whichever of the player's weapon, armor or bow sits +-- in `slotName` back to full durability (see durability.lua), folding its +-- power back into attack/defense if wear had already broken it. +function Game:repairEquipped(slotName) + local item = self.player[slotName] + local success, result = Durability.repair(self.player, slotName) + if success then + self.log:add(string.format("you repair your %s for %d gold", item.name, result)) + else + self.log:add("cannot repair: " .. result) + end + return success +end + +-- The first of weapon/armor/bow, in that priority order, that has been worn +-- clean through to uselessness - never a merely dinged one still pulling its +-- weight, so the autopilot only ever reaches for its purse over gear that +-- has actually stopped working. +function Game:brokenEquippedSlot() + for _, slot in ipairs({ "weapon", "armor", "bow" }) do + local item = self.player[slot] + if item and Durability.isBroken(item) then return slot end + end + return nil +end + +-- Taking a warded item without paying: the item is stolen outright and its +-- shopkeeper turns permanently hostile, chasing the player like any monster. +function Game:stealFromShop(index) + local ground = self.level.floorItems[index] + if not ground or not self:wardedByLiveKeeper(ground) then return false end + self.player.inventory[#self.player.inventory + 1] = ground.item + local keeper = self.level:entityById(ground.shopId) + keeper.hostile = true + Factions.adjust(self.player, Factions.MERCHANT, -20) + self.log:add("you steal " .. Identify.label(self.identify, ground.item) .. + "! the shopkeeper turns on you") + table.remove(self.level.floorItems, index) + return true +end + +-- Resolves a crafting target: "weapon"/"armor" reach the currently equipped +-- piece (which has left the inventory array entirely - see items.equipWeapon/ +-- equipArmor), anything else is an inventory index. +function Game:resolveGear(target) + if target == "weapon" then return self.player.weapon end + if target == "armor" then return self.player.armor end + return self.player.inventory[target] +end + +function Game:forgeGem(element) + local ok, result = Crafting.forge(self.player, element) + if not ok then + self.log:add("cannot forge: " .. result) + return false + end + self.player.inventory[#self.player.inventory + 1] = result + self.log:add("you forge a " .. result.name) + return true +end + +function Game:mergeGemsAt(indexA, indexB) + local gemA, gemB = self.player.inventory[indexA], self.player.inventory[indexB] + if not gemA or not gemB or gemA.kind ~= Items.GEM or gemB.kind ~= Items.GEM or indexA == indexB then + return false + end + local success, reason, merged = Crafting.mergeGems(gemA, gemB) + if not success then + self.log:add("cannot merge gems: " .. reason) + return false + end + local hi, lo = math.max(indexA, indexB), math.min(indexA, indexB) + table.remove(self.player.inventory, hi) + table.remove(self.player.inventory, lo) + self.player.inventory[#self.player.inventory + 1] = merged + self.log:add("you merge two gems into a " .. merged.name) + return true +end + +function Game:socketItem(target, gemIndex) + local item = self:resolveGear(target) + local gem = self.player.inventory[gemIndex] + if not item or not gem or gem.kind ~= Items.GEM then return false end + local success, reason = Crafting.socket(self.player, item, gem) + if not success then + self.log:add("cannot socket: " .. reason) + return false + end + table.remove(self.player.inventory, gemIndex) + self.log:add("you socket a " .. gem.name .. " into your " .. item.name) + return true +end + +-- Socketing a rune never removes it from the game the way a failed gem +-- socket attempt would leave the gem untouched in inventory - the rune is +-- always spent once it joins the chain, whether or not that completes a +-- runeword, since there is no "wrong" rune to protect the player from (see function Game:movePlayer(dx, dy) local nx, ny = self.player.x + dx, self.player.y + dy local map = self.level.map From 3427a1a8f09ae862fcff44af8d4922b2655f5437 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 17:40:31 +0200 Subject: [PATCH 044/172] feat: extend game with Game:socketRune and Game:temperItem --- src/game.lua | 204 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/src/game.lua b/src/game.lua index dafae90..7cbca5b 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1498,25 +1498,167 @@ end -- socket attempt would leave the gem untouched in inventory - the rune is -- always spent once it joins the chain, whether or not that completes a -- runeword, since there is no "wrong" rune to protect the player from (see +-- Runewords.socket). +function Game:socketRune(target, runeIndex) + local item = self:resolveGear(target) + local rune = self.player.inventory[runeIndex] + if not item or not rune or rune.kind ~= Items.RUNE then return false end + local success, reason = Runewords.socket(item, rune) + if not success then + self.log:add("cannot socket rune: " .. reason) + return false + end + table.remove(self.player.inventory, runeIndex) + local word = Runewords.activeWord(item) + if word then + self.log:add("the runes align into " .. word.name .. "! your " .. item.name .. " hums with power") + else + self.log:add("you socket a " .. rune.name .. " into your " .. item.name) + end + return true +end + +function Game:temperItem(target) + local item = self:resolveGear(target) + if not item then return false end + local success, reason = Crafting.temper(self.player, item) + if not success then + self.log:add("cannot temper: " .. reason) + return false + end + self.log:add("you temper your " .. item.name) + return true +end + +function Game:enchantItem(target, affixKey) + local item = self:resolveGear(target) + if not item then return false end + local success, reason = Crafting.enchant(self.player, item, affixKey) + if not success then + self.log:add("cannot enchant: " .. reason) + return false + end + self.log:add("you enchant your " .. item.name) + return true +end + +function Game:disenchantItem(target) + local item = self:resolveGear(target) + if not item then return false end + local success, refund = Crafting.disenchant(self.player, item) + if not success then + self.log:add("cannot disenchant: " .. refund) + return false + end + self.log:add(string.format("you disenchant %s for %d essence", item.name, refund)) + return true +end + +-- Combines the two carried herbs at indexA and indexB into whichever potion +-- Alchemy.brew resolves the pair to. Both herbs are spent regardless of the +-- outcome - a botched brew that curdles is not a free retry, and vents its +-- fumes as a gas hazard right underfoot instead of handing back a potion. +function Game:brewAt(indexA, indexB) + if not self.level:cauldronAt(self.player.x, self.player.y) then + self.log:add("there is no cauldron here") + return false + end + if indexA == indexB then return false end + local herbA, herbB = self.player.inventory[indexA], self.player.inventory[indexB] + if not herbA or not herbB or herbA.kind ~= Items.HERB or herbB.kind ~= Items.HERB then + self.log:add("you need two herbs to brew") + return false + end + local hi, lo = math.max(indexA, indexB), math.min(indexA, indexB) + table.remove(self.player.inventory, hi) + table.remove(self.player.inventory, lo) + local potion = Alchemy.brew(herbA.herb, herbB.herb) + if not potion then + Hazards.spawnGas(self.level, self.player.x, self.player.y, 3, 2) + self.log:add(string.format("the %s and %s curdle into choking fumes", herbA.name, herbB.name)) + return false + end + Identify.reveal(self.identify, potion) + self.player.inventory[#self.player.inventory + 1] = potion + self.log:add("you brew " .. potion.name) + return true +end + +-- Companions, hirelings and thralls are all player-owned allies (see +-- Game:onActorDeath), never a valid melee target for the player's own bump- +-- attack or autopilot targeting - both Game:movePlayer and Game:adjacentMonster +-- below share this one check so neither can turn "walk into your own hound" +-- into an accidental kill. +local function isPlayerAlly(entity) + return entity.kind == "companion" or entity.kind == "hireling" or entity.kind == "thrall" +end + +local CONFUSE_DIRS = { + { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }, +} + +-- Whether `dx, dy` (the direction the player actually typed, or the one +-- autopilot's own targeting picked) gets overridden by a random one of the +-- 8 compass directions this call - the confusion-lands roll only ever fires +-- once per Game:movePlayer, never re-rolled mid-call. Confusion strikes +-- before Game:movePlayer even looks at what's in the chosen direction, so a +-- stumble can just as easily open a door, bump a rival into a fight, shove a +-- block, or walk the player straight onto a trap as a deliberate move would. +local function confusionRedirect(self, dx, dy) + if not Status.has(self.player, Status.CONFUSE) then return dx, dy, false end + if not self.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then + return dx, dy, false + end + local dir = CONFUSE_DIRS[self.rng:range(1, #CONFUSE_DIRS)] + return dir[1], dir[2], true +end + +-- `stumbled` (below) is what makes a confused player's failed lurch still +-- cost a turn: an ordinary bump into a wall or an unpushable block returns +-- false and Game:manualStep spends nothing, exactly as a rejected typed +-- command should, but a confused player who meant to go one way and staggered +-- into a wall instead still acted - the turn is spent on the stumble itself, +-- not on whatever (if anything) it accomplished. function Game:movePlayer(dx, dy) + local stumbled + dx, dy, stumbled = confusionRedirect(self, dx, dy) + if stumbled then self.log:add("your senses reel and you stumble sideways") end local nx, ny = self.player.x + dx, self.player.y + dy local map = self.level.map if Tiles.isClosedDoor(map:get(nx, ny)) then return self:openDoor(nx, ny) end local target = self.level:entityAt(nx, ny) - if target then + if target and not isPlayerAlly(target) then self:playerAttack(target) return true end + if not target then + local block = self.level:blockAt(nx, ny) + if block then + local pushed, line = Blocks.push(self.level, block, dx, dy) + if not pushed then return stumbled end + self.log:add(line) + Blocks.updateGates(self.level) + end + end if map:isWalkable(nx, ny) then + if target then + target.x, target.y = self.player.x, self.player.y + self.log:add("you swap places with your " .. target.name) + end self.player.x, self.player.y = nx, ny local trap = self.level:trapAt(nx, ny) - if trap and not trap.revealed then self:triggerTrap(trap) end + if trap and not trap.revealed and not Status.has(self.player, Status.LEVITATE) then + self:triggerTrap(trap) + end + self:checkTerrainFor(self.player, dx, dy) + local lever = self.level:vaultLeverAt(nx, ny) + if lever then self:pullLever(lever) end self:pickupHere() return true end - return false + return stumbled end function Game:useItem(index) @@ -1527,12 +1669,60 @@ function Game:useItem(index) Identify.reveal(self.identify, item) table.remove(self.player.inventory, index) elseif item.kind == Items.FOOD then - self.player.satiation = self.player.satiation + item.nutrition - self.log:add("you eat the " .. item.name) + local bloated = Hunger.eat(self.player, item.nutrition) + if bloated then + self.log:add("you force down the " .. item.name .. " past comfort and feel bloated") + else + self.log:add("you eat the " .. item.name) + end table.remove(self.player.inventory, index) - elseif item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.RING then + elseif item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.RING + or item.kind == Items.BOW then + local slot = slotByItemKind[item.kind] + if not Curses.tryRelease(self.player, slot) then + self.log:add("the " .. Curses.label(self.player[slot], Identify.label(self.identify, self.player[slot])) .. + " will not come off!") + return false + end Items.useFromInventory(self.player, index) - self.log:add("you equip " .. Identify.label(self.identify, item)) + self.log:add("you equip " .. Curses.label(item, Identify.label(self.identify, item))) + if item.set then + local count = Sets.equippedCounts(self.player)[item.set] + local bonus = Sets.bonusFor(item.set, count) + if bonus then + self.log:add("the " .. Sets.byKey[item.set].name .. " set (" .. count .. " pieces) awakens") + end + end + elseif item.kind == Items.TOME then + if Spells.learn(self.player, item.spell) then + self.log:add("you learn " .. Spells.catalog[item.spell].name) + else + self.log:add("you already know that spell") + end + table.remove(self.player.inventory, index) + elseif item.kind == Items.CHARM then + local companion = Companions.summon(self, item.companion) + if not companion then return false end + self.log:add("your " .. companion.name .. " bonds to you") + table.remove(self.player.inventory, index) + elseif item.kind == Items.CONTRACT then + local hireling = Mercenaries.hire(self, item.hireling) + if not hireling then return false end + self.log:add("your " .. hireling.name .. " joins you for hire") + table.remove(self.player.inventory, index) + elseif item.kind == Items.KIT then + if self.level:trapAt(self.player.x, self.player.y) then + self.log:add("there is already a trap here") + return false + end + self.level.traps[#self.level.traps + 1] = { + x = self.player.x, y = self.player.y, effect = item.effect, revealed = false, placedByPlayer = true, + } + self.log:add("you plant a " .. item.name) + table.remove(self.player.inventory, index) + elseif item.kind == Items.PICK then + Items.useFromInventory(self.player, index) + self.log:add("you ready your " .. item.name) else return false end From 7e20884d3d5561856c32bb9747faccab4e051a36 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 18:54:54 +0200 Subject: [PATCH 045/172] feat: extend game with Game:nemesisFollow and Game:nemesisTryEmerge --- src/game.lua | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 2 deletions(-) diff --git a/src/game.lua b/src/game.lua index 7cbca5b..72c9868 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1729,30 +1729,229 @@ function Game:useItem(index) return true end +-- Removes the currently-live nemesis (nemesis.lua) from the floor about to +-- be left and escalates its grudge, so Game:nemesisArrive rebuilds it +-- stronger on the other side of the stairwell. Unlike every other hostile +-- (level.lua's own header: a revisited floor keeps its exact state, dead +-- monsters and all), a nemesis is never left behind - it always follows. +function Game:nemesisFollow() + for i, entity in ipairs(self.level.entities) do + if entity.nemesis then + table.remove(self.level.entities, i) + Nemesis.escalate(self.nemesis) + return + end + end +end + +-- Rolls a brand new nemesis into being from whichever hostile was already +-- hunting the player on the floor just cleared. Only tried on the way down +-- (Game:descend) and only while no nemesis is following yet - ordinary +-- backtracking upstairs never manufactures one. +function Game:nemesisTryEmerge() + if not self.rng:chance(Nemesis.CHANCE_NUMERATOR, Nemesis.CHANCE_DENOMINATOR) then return end + local hunter = Nemesis.pickHunter(self.level, self.player) + if not hunter then return end + for i, entity in ipairs(self.level.entities) do + if entity == hunter then + table.remove(self.level.entities, i) + break + end + end + self.nemesis = Nemesis.beginFrom(hunter) +end + +-- Drops the pursuing nemesis onto whichever floor Game:descend/ascend has +-- just switched self.level/self.depth to. A no-op without one following. +function Game:nemesisArrive() + if not self.nemesis then return end + local x, y = self:randomFloorCell() + if not x then return end + local entity = Nemesis.spawn(self.nemesis, self:nextMonsterId(), x, y, self.depth) + entity.awareness = Stealth.HUNTING + self.level.entities[#self.level.entities + 1] = entity + self.log:add(entity.name .. " has followed you") +end + +local ALLY_CARRY_KINDS = { companion = true, hireling = true, thrall = true } +local ALLY_CARRY_DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } } + +-- Pulls every living player-aligned ally (companion/hireling/thrall) off +-- `fromLevel`'s own entities list, ready to be handed to +-- Game:plantCarriedAllies once self.level/self.player.x/y already point at +-- the destination. companions.lua/mercenaries.lua/reanimation.lua's own +-- module comments describe a bonded ally as persisting "across the whole +-- run" (or, for a hireling, demanding its wage "for as long as it lives") - +-- without this pair, every one of them was actually abandoned in place the +-- instant the player took a single flight of stairs or crossed a rift, +-- silently contradicting that promise. Mirrors Game:nemesisFollow's own +-- table.remove-while-scanning shape, just for up to three kinds at once +-- instead of one flagged entity. +function Game:detachCarriedAllies(fromLevel) + local carried = {} + for i = #fromLevel.entities, 1, -1 do + local entity = fromLevel.entities[i] + if ALLY_CARRY_KINDS[entity.kind] and entity:isAlive() then + table.remove(fromLevel.entities, i) + carried[#carried + 1] = entity + end + end + return carried +end + +-- Re-plants entities Game:detachCarriedAllies pulled off the old floor onto +-- self.level, each on the first free cell around the player's new position - +-- the same 8-directional, first-free-cell convention Companions.summon/ +-- Mercenaries.hire/Reanimation.raise already use to place a brand new one - +-- falling back to Game:randomFloorCell (the same primitive Game:nemesisArrive +-- uses) only if every adjacent cell is blocked, so an ally is never simply +-- lost for want of room next to the stairs. +function Game:plantCarriedAllies(carried) + for _, ally in ipairs(carried) do + local placed = false + for _, dir in ipairs(ALLY_CARRY_DIRS) do + local x, y = self.player.x + dir[1], self.player.y + dir[2] + if self.level.map:isWalkable(x, y) and not self.level:entityAt(x, y) then + ally.x, ally.y = x, y + placed = true + break + end + end + if not placed then + local x, y = self:randomFloorCell() + if x then ally.x, ally.y = x, y; placed = true end + end + if placed then + self.level.entities[#self.level.entities + 1] = ally + self.log:add("your " .. ally.name .. " follows you") + end + end +end + function Game:descend() + if self.rift then return false end if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end + if self.depth >= self.maxDepth then + self.log:add("the stairwell ends in solid rock here") + return false + end + if self.nemesis then self:nemesisFollow() else self:nemesisTryEmerge() end + local carriedAllies = self:detachCarriedAllies(self.level) self.depth = self.depth + 1 self.level = self.dungeon:get(self.depth) self.player.x, self.player.y = self.level.upX, self.level.upY + if self.depth > self.deepestDepth then self.deepestDepth = self.depth end self.log:add("you descend to depth " .. self.depth) + self:nemesisArrive() + self:plantCarriedAllies(carriedAllies) self:computeVisible() return true end function Game:ascend() + if self.rift then return false end if self.player.x ~= self.level.upX or self.player.y ~= self.level.upY then return false end if self.depth <= 1 then - self.log:add("you climb toward the surface") + if self:hasAmulet() then + self.won = true + self.log:add("you escape the dungeon with the Amulet of Warren - you win!") + else + self.log:add("you climb toward the surface") + end return true end + if self.nemesis then self:nemesisFollow() end + local carriedAllies = self:detachCarriedAllies(self.level) self.depth = self.depth - 1 self.level = self.dungeon:get(self.depth) self.player.x, self.player.y = self.level.downX, self.level.downY self.log:add("you climb to depth " .. self.depth) + self:nemesisArrive() + self:plantCarriedAllies(carriedAllies) self:computeVisible() return true end +-- Crosses the rift the player is currently standing on (see branches.lua), +-- swapping the run's active level for that rift's own pocket floor. The +-- pocket floor is generated once per origin depth and cached on +-- self.riftLevels exactly like self.dungeon caches every main-track floor, +-- so leaving and coming back always finds it exactly as it was left, dead +-- monsters and all - and only the very first crossing of a given rift spends +-- the one carried shard the stabilization costs; every crossing after that, +-- in either direction, is free. Returns false without side effects when the +-- player is not standing on a rift, is already across one, or (on a first +-- crossing only) is not carrying a shard to spend. +function Game:enterRift() + if self.rift then return false end + local rift = self.level:riftAt(self.player.x, self.player.y) + if not rift then return false end + self.riftLevels = self.riftLevels or {} + local firstCrossing = self.riftLevels[self.depth] == nil + if firstCrossing then + local shardIndex = self:findInInventory(function(item) return item.kind == Items.RIFTSTONE end) + if not shardIndex then return false end + table.remove(self.player.inventory, shardIndex) + self.riftLevels[self.depth] = Branches.build(self.seed, self.depth, self.dungeon.options) + end + local carriedAllies = self:detachCarriedAllies(self.level) + self.riftReturn = { level = self.level, depth = self.depth, x = self.player.x, y = self.player.y } + self.level = self.riftLevels[self.depth] + self.rift = true + self.player.x, self.player.y = self.level.upX, self.level.upY + self.log:add(firstCrossing and "the shard crumbles to dust as you step through the rift" + or "you step through the stabilized rift once more") + self:plantCarriedAllies(carriedAllies) + self:computeVisible() + return true +end + +-- Steps back out of a rift crossing at its own pocket floor's downX/downY +-- (Branches.build reuses an ordinary Builder.build floor's ordinary "stairs +-- down" pair as the one way back rather than inventing a second tile kind), +-- restoring the exact main-track level, depth and position Game:enterRift +-- saved before the crossing. Returns false without effect when the player is +-- not currently across a rift, or has not yet reached its own exit point. +function Game:exitRift() + if not self.rift then return false end + if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end + local carriedAllies = self:detachCarriedAllies(self.level) + self.level = self.riftReturn.level + self.depth = self.riftReturn.depth + self.player.x, self.player.y = self.riftReturn.x, self.riftReturn.y + self.rift = false + self.riftReturn = nil + self.log:add("you step back through the rift, blinking in familiar torchlight") + self:plantCarriedAllies(carriedAllies) + self:computeVisible() + return true +end + +-- Starts a Horde Arena trial for the run in progress: swaps the player's +-- current level for a fresh closed arena (see horde.lua's own header for +-- why this can bypass dungeon.lua/builder.lua entirely and still work with +-- every existing turn system unmodified) and spawns its first wave through +-- the very same Horde.tick that clears each wave after it. The dungeon +-- floor left behind is simply abandoned - descending is not something an +-- arena run does, only Game:endPlayerTurn's escalating waves. A bonded +-- companion/hireling/thrall is not abandoned along with it though: the same +-- detachCarriedAllies/plantCarriedAllies pair Game:descend/ascend/enterRift/ +-- exitRift already use follows the player in here too, planted around the +-- arena's centre anchor (cx, cy) rather than left to die alone on a floor +-- the run can never return to - an ally bonded specifically to fight beside +-- the player has the most reason to stand with them in a survival trial. +function Game:startHorde() + local carriedAllies = self:detachCarriedAllies(self.level) + local map, cx, cy = Horde.buildArena(self.rng, Horde.WIDTH, Horde.HEIGHT) + self.level = Level.new({ depth = self.depth, generator = "horde", map = map }) + self.player.x, self.player.y = cx, cy + self.horde = Horde.newState() + self:plantCarriedAllies(carriedAllies) + local line = Horde.tick(self) + if line then self.log:add(line) end + self:computeVisible() +end + -- Autopilot. function Game:findInInventory(predicate) @@ -1765,7 +1964,7 @@ end function Game:adjacentMonster() for _, dir in ipairs(MONSTER_DIRS) do local monster = self.level:entityAt(self.player.x + dir[1], self.player.y + dir[2]) - if monster then return monster end + if monster and not isPlayerAlly(monster) then return monster end end return nil end @@ -1775,6 +1974,7 @@ function Game:playerPathTo(tx, ty) return Astar.find(self.level.map, self.player.x, self.player.y, tx, ty, function(x, y) if x == tx and y == ty then return false end if not self.level.map:isPassable(x, y, hasKey) then return true end + if self.level:blockAt(x, y) then return true end return self.level:entityAt(x, y) ~= nil end) end From b4e2c7a43104d92b69288a2b4c95fb37a383c7c4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 19:07:12 +0200 Subject: [PATCH 046/172] feat: extend game with Game:anyHostileVisible --- src/game.lua | 204 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/src/game.lua b/src/game.lua index 72c9868..86ab860 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2040,6 +2040,22 @@ function Game:anyMonsterVisible() return false end +-- Only true hostiles (isPlayerAlly's same companion/hireling/thrall +-- allowlist that Game:movePlayer's own bump-attack already respects) - +-- unlike Game:anyMonsterVisible above, which choosePlayerAction uses to gate +-- inventory downtime and deliberately treats ANY visible entity as "not +-- safe to fiddle right now". Game:autoExplore needs the narrower question: +-- a loyal ally standing in plain sight next to the player it always follows +-- must never interrupt an autoexplore run on its own. +function Game:anyHostileVisible() + for _, entity in ipairs(self.level.entities) do + if not isPlayerAlly(entity) and self.visible[self.level.map:index(entity.x, entity.y)] then + return true + end + end + return false +end + -- Inventory index of the single best equipment upgrade to put on this turn, or -- nil. Weapons and armour compare on power, rings on attack plus defence. function Game:bestUpgrade() @@ -2059,11 +2075,135 @@ function Game:bestUpgrade() return nil end +-- The first not-yet-known skill of the player's class whose level requirement +-- is already met, or nil. Learning costs no turn, so the autopilot spends +-- any banked skill point the moment it can, in the tree's fixed order. +function Game:nextLearnableSkill() + local player = self.player + if not player.class or (player.skillPoints or 0) <= 0 then return nil end + for _, key in ipairs(Skills.forClass(player.class)) do + if not Skills.has(player, key) then + local def = Skills.active[key] or Skills.passive[key] + if player.level >= def.reqLevel then return key end + end + end + return nil +end + +function Game:hasCompanion() + for _, entity in ipairs(self.level.entities) do + if entity.kind == "companion" and entity:isAlive() then return true end + end + return false +end + +function Game:hasHireling() + for _, entity in ipairs(self.level.entities) do + if entity.kind == "hireling" and entity:isAlive() then return true end + end + return false +end + +-- Voluntary, penalty-free release of the live hireling, if any - contrast +-- Mercenaries.payWage's desertion, which fires on its own once loyalty +-- bottoms out from missed wages rather than on the player's own say-so. +function Game:dismissHireling() + local name = Mercenaries.dismiss(self) + if not name then + self.log:add("you have no one under contract to release") + return false + end + self.log:add("you release your " .. name .. " from service") + return true +end + function Game:choosePlayerAction() local player = self.player + local learnable = self:nextLearnableSkill() + if learnable then self:learnSkill(learnable) end + if not player.quest then + local board = self.level:questBoardAt(player.x, player.y) + if board and not board.claimed then self:tryAcceptQuest(board) end + end if not self:anyMonsterVisible() then local upgrade = self:bestUpgrade() if upgrade then self:useItem(upgrade); return end + if not self:hasCompanion() then + local charm = self:findInInventory(function(item) return item.kind == Items.CHARM end) + if charm then self:useItem(charm); return end + end + if not self:hasHireling() then + local contract = self:findInInventory(function(item) return item.kind == Items.CONTRACT end) + if contract then self:useItem(contract); return end + end + -- Only ever risks a shrine's first, cheapest, least cursed-prone prayer: + -- the autopilot is not greedy enough to keep re-praying at rising odds. + local shrine = self.level:shrineAt(player.x, player.y) + if shrine and shrine.uses == 0 and player.gold >= Shrines.costFor(shrine) then + self:prayAt(shrine) + return + end + -- Restores whatever gear has actually stopped working the moment the + -- purse can cover it, same "take it as soon as it's affordable" spirit + -- as the shrine check just above. + local brokenSlot = self:brokenEquippedSlot() + if brokenSlot and player.gold >= Durability.repairCost(player[brokenSlot]) then + self:repairEquipped(brokenSlot) + return + end + -- Free income with no downside, so the autopilot takes it on sight + -- rather than weighing it like a shrine's costed, risky prayer. + local node = self.level:nodeAt(player.x, player.y) + if node and not Nodes.isFallow(node) then + self:harvestNode() + return + end + -- Free protein with no gold cost, so the autopilot butchers a corpse on + -- sight same as it strip-mines a node - but only while the meat is + -- still good; a rotten one it leaves alone rather than poisoning itself + -- for nothing. + local corpse = self.level:corpseAt(player.x, player.y) + if corpse and not Corpses.isRotten(corpse) then + self:butcherCorpse() + return + end + -- A chest is worth cracking on sight exactly like the node and corpse + -- above, but only once it can actually be: an unlocked one is free + -- money, and a locked one is only ever tried with a key or lockpick + -- already in hand (see Game:openChest). Skipping a locked chest with + -- neither on hand keeps the autopilot from parking on it and burning + -- every remaining turn on a lock it has no way to open. + local chest = self.level:chestAt(player.x, player.y) + if chest and not chest.opened and (not Chests.isLocked(chest) or self:hasKey() + or self:findInInventory(function(item) return item.kind == Items.LOCKPICK end)) then + self:openChest() + return + end + -- A held captive is worth cashing in on sight exactly like the chest + -- above: the autopilot always takes the plain gold of Game:sellCaptive + -- rather than gambling on Game:ransomCaptive's goodwill or + -- Game:interrogateCaptive's map intel, neither of which it is smart + -- enough here to weigh against the other. + local captive = self.level:captiveAt(player.x, player.y) + if captive then + self:sellCaptive() + return + end + -- A rift crossing is only ever attempted at good health: its own pocket + -- floor is deliberately tuned harder than the main-track floor the rift + -- itself sits on (see branches.lua), so the autopilot never spends its + -- one shard gambling into a fight it is not ready for. + if player.hp >= player.maxHp * 0.8 and self:enterRift() then + return + end + -- Sheds the cheapest thing it is hauling once genuinely overloaded, but + -- only in this same downtime window as the checks above - never instead + -- of fighting or fleeing, since a visible monster always skips this + -- whole block. + if self:encumbranceTier() == Encumbrance.OVERLOADED then + local junk = self:cheapestDroppableItem() + if junk then self:dropItem(junk); return end + end end if player.hp < player.maxHp // 3 then local potion = self:findInInventory(function(item) @@ -2077,14 +2217,60 @@ function Game:choosePlayerAction() end local target = self:adjacentMonster() if target then + -- A carried net beats the killing blow once the target is already + -- battered down to Captives.eligible's own threshold: a capture forgoes + -- this kill's xp and essence, but a live captive can still be cashed in + -- for gold moments later (see the sell-on-sight check above), so this is + -- a trade the autopilot is always willing to make rather than a strict + -- loss. + if Captives.eligible(target) then + local net = self:findInInventory(function(item) return item.kind == Items.NET end) + if net then + self:captureAt(net, target) + return + end + end self:movePlayer(sign(target.x - player.x), sign(target.y - player.y)) return end - if player.x == self.level.downX and player.y == self.level.downY then + + -- Across a rift, the only objective is its own pocket floor's exit: never + -- the main track's descend/ascend goals below, which Game:descend and + -- Game:ascend both refuse outright for as long as self.rift holds anyway. + if self.rift then + if player.x == self.level.downX and player.y == self.level.downY then + self:exitRift() + return + end + local path = self:playerPathTo(self.level.downX, self.level.downY) + if path and #path >= 2 then + self:movePlayer(path[2].x - player.x, path[2].y - player.y) + return + end + local hunt = self:reachableMonsterStep() + if hunt then + self:movePlayer(hunt.x - player.x, hunt.y - player.y) + return + end + local ex, ey = self:exploreStep() + if ex then self:movePlayer(ex, ey) end + return + end + + -- Once the amulet is in hand the objective flips: every remaining step + -- heads for the up stairs instead of the down stairs, floor by floor. + local goalX, goalY = self.level.downX, self.level.downY + if self:hasAmulet() then + goalX, goalY = self.level.upX, self.level.upY + if player.x == goalX and player.y == goalY then + self:ascend() + return + end + elseif self.depth < self.maxDepth and player.x == goalX and player.y == goalY then self:descend() return end - local path = self:playerPathTo(self.level.downX, self.level.downY) + local path = self:playerPathTo(goalX, goalY) if path and #path >= 2 then self:movePlayer(path[2].x - player.x, path[2].y - player.y) return @@ -2099,11 +2285,15 @@ function Game:choosePlayerAction() end function Game:hungerTorchTick() - self.player.satiation = self.player.satiation - 1 - if self.player.satiation <= 0 then - self.player.satiation = 0 - self.player.hp = self.player.hp - 1 - self.log:add("you are starving") + if not Hunger.suppressesRegen(self.player) then + local ringRegen = Artifacts.ringRegen(self.player) + Sets.regenAmount(self.player) + if ringRegen > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + ringRegen) + end + local weatherRegen = Atmosphere.regenBonus(self.level.weather) + if weatherRegen > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + weatherRegen) + end end if self.player.light.fuel > 0 then self.player.light.fuel = self.player.light.fuel - 1 From e850ef2d5ef9a0f45777fd3b4fe93d4d52b1c59e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 19:51:54 +0200 Subject: [PATCH 047/172] feat: extend game with Game:learnSkill and Game:useSkill --- src/game.lua | 123 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/src/game.lua b/src/game.lua index 86ab860..7c60943 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2295,27 +2295,134 @@ function Game:hungerTorchTick() self.player.hp = math.min(self.player.maxHp, self.player.hp + weatherRegen) end end + local curseDrain = Curses.drainAmount(self.player) + if curseDrain > 0 then + self.player.hp = self.player.hp - curseDrain + self.log:add("a cursed affliction saps your strength") + end + local diseaseLine = Disease.tick(self.player) + if diseaseLine then self.log:add(diseaseLine) end + local hungerLine = Hunger.tick(self.player) + if hungerLine then self.log:add(hungerLine) end if self.player.light.fuel > 0 then self.player.light.fuel = self.player.light.fuel - 1 end + if self.player.mana < self.player.maxMana then + self.player.mana = self.player.mana + 1 + end end function Game:endPlayerTurn() + self.turnState = nil Scheduler.spend(self.player) self:hungerTorchTick() + Hazards.step(self.level, self.rng) + Nodes.step(self.level) + Corpses.step(self.level) + for _, trap in ipairs(Disarm.sweep(self.level, self.rng, self.player.x, self.player.y)) do + self.log:add("you notice a hidden trap nearby") + end + for _, door in ipairs(Secrets.sweep(self.level, self.rng, self.player.x, self.player.y)) do + self.log:add("you notice a hidden door in the wall") + end + local eventLine = Events.step(self.level, self.rng) + if eventLine then self.log:add(eventLine) end + Skills.tickCooldowns(self.player) + self:resolveQuest() self.turn = self.turn + 1 + if self.turn % Mercenaries.WAGE_INTERVAL == 0 then + local wageLine = Mercenaries.payWage(self) + if wageLine then self.log:add(wageLine) end + end self:computeVisible() + local sanityLine = Sanity.tick(self) + if sanityLine then self.log:add(sanityLine) end + if Sanity.rollScream(self.rng, self) then + self.log:add("a scream of raw terror tears from your throat") + end + if self.horde then + local hordeLine = Horde.tick(self) + if hordeLine then self.log:add(hordeLine) end + end +end + +-- Spends a skill point to permanently learn `key`, gated by class, level and +-- point balance (see skills.lua). Free of any turn cost. +function Game:learnSkill(key) + local success, message = Skills.learn(self.player, key) + if message then self.log:add(message) end + return success +end + +-- Attempts to use the active skill `key`, forwarding any extra arguments +-- (a target monster, or two for multishot) straight to skills.lua. +function Game:useSkill(key, ...) + local success, lines = Skills.use(self, key, ...) + self.log:addAll(lines) + return success +end + +-- Shared head of any player turn, whether the autopilot or a typed command +-- is about to decide what happens next: runs every other actor up to the +-- player, ticks the status/water/hazard afflictions that land on the +-- player's own turn boundary, and rolls the stun/panic checks that can +-- consume the turn before any action is even chosen. Returns "halt" once +-- play cannot continue this call (already won, or dead at any point along +-- the way - the caller must not act or spend a turn), "skip" once the turn +-- is already spent on a stun or a panic scream, or "ready" once the caller +-- is clear to choose and perform an action of its own. +-- +-- Cached on self.turnState until Game:endPlayerTurn clears it, so calling +-- this more than once before a turn is actually spent - the one new +-- situation Game:manualStep introduces, since a rejected typed command +-- spends no turn and its caller is free to just try again - never re-rolls +-- the stun/panic check or re-ticks poison, fire or hunger a second time for +-- a turn the player has not actually finished taking yet. Game:autoStep +-- never revisits an unspent turn this way, so the cache is invisible to it. +function Game:beginTurn() + if self.turnState then return self.turnState end + if self.won then self.turnState = "halt"; return "halt" end + self:runToPlayer() + if not self.player:isAlive() then self.turnState = "halt"; return "halt" end + self.log:addAll(Status.tick(self.player)) + if not self.player:isAlive() then self.turnState = "halt"; return "halt" end + Conductivity.tick(self.level, self.player) + local hazardLine = Hazards.afflict(self.level, self.player) + if hazardLine then self.log:add(hazardLine) end + if not self.player:isAlive() then self.turnState = "halt"; return "halt" end + if Status.skipsTurn(self.player) then + self.log:add("you are stunned") + self.turnState = "skip" + elseif Sanity.rollPanic(self.rng, self.player) then + self.log:add("terror seizes you and the turn slips away") + self.turnState = "skip" + else + self.turnState = "ready" + end + return self.turnState end function Game:autoStep() - self:runToPlayer() - if not self.player:isAlive() then return end - self.log:addAll(Status.tick(self.player)) - if not self.player:isAlive() then return end - if Status.skipsTurn(self.player) then - self.log:add("you are stunned") - else - self:choosePlayerAction() + local state = self:beginTurn() + if state == "halt" then return end + if state == "ready" then self:choosePlayerAction() end + self:endPlayerTurn() +end + +-- Human-driven counterpart to Game:autoStep: runs the exact same pre-turn +-- checks, then hands `text` to commands.lua instead of letting +-- choosePlayerAction decide on its own. Returns (consumesTurn, message) from +-- Commands.apply so a caller (warren.lua's --play REPL, or a test driving a +-- fixed script of commands) can tell a wasted keystroke from a turn the +-- world actually answered. A stun or panic rolled during beginTurn still +-- spends the turn and ends it exactly like autoStep, without ever looking at +-- `text` - the player was never in control of that turn to begin with. +function Game:manualStep(text) + local state = self:beginTurn() + if state == "halt" then return false, "there is nothing left to do" end + if state == "skip" then + self:endPlayerTurn() + return true, nil end self:endPlayerTurn() end From 5f13d7012ea4d25ee73c5f2edb197dd153cfff6b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 19:59:51 +0200 Subject: [PATCH 048/172] feat: extend game with Game:autoExplore and Game:restUntilHealed --- src/game.lua | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 1 deletion(-) diff --git a/src/game.lua b/src/game.lua index 7c60943..cc66fa3 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2424,7 +2424,201 @@ function Game:manualStep(text) self:endPlayerTurn() return true, nil end - self:endPlayerTurn() + local consumesTurn, message = Commands.apply(self, text) + -- "explore" (Game:autoExplore, wired up in commands.lua) can advance and + -- close several turns entirely on its own inside this one call, unlike + -- every other handler here which only ever acts within the single turn + -- already opened above - self.turnState is already nil by the time it + -- returns, so this must not end an already-ended turn a second time - and + -- must never end one at all if the very last thing that happened was a + -- halt (self.turnState == "halt"), exactly like Game:autoStep and this + -- same function's own "halt" branch above never do either. Every other + -- command leaves turnState exactly as beginTurn set it above ("ready"), + -- so the extra check below is a no-op for them. + if consumesTurn and self.turnState == "ready" then self:endPlayerTurn() end + return consumesTurn, message +end + +local EXPLORE_STEP_CAP = 2000 + +-- Repeats Game:exploreStep/Game:movePlayer turn after turn inside a single +-- call, the way autoexplore works in most other roguelikes, instead of +-- making the player retype "explore" once per step. Built on the exact same +-- beginTurn/endPlayerTurn pair Game:autoStep already drives, so monster AI, +-- status ticks and hazards all run normally on every turn it takes - the +-- only difference from a hand-typed run of single moves is that this stops +-- itself the instant something worth the player's attention shows up, +-- rather than walking on regardless. Returns (consumesTurn, message) in the +-- exact same shape every Commands.apply handler does: consumesTurn is true +-- iff at least one turn was actually spent, so "already interrupted before +-- moving at all" and "an ordinary rejected command" both read the same way +-- to a caller that only checks that first value. +function Game:autoExplore() + local steps = 0 + while steps < EXPLORE_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop exploring - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop exploring" + end + if state == "ready" then + local ex, ey = self:exploreStep() + if not ex then + if steps == 0 then return false, "there is nothing left to explore" end + -- beginTurn above already ran this turn's monster moves and status + -- ticks for real, even though there turns out to be nothing left to + -- walk toward - so, unlike the steps == 0 case above (nothing at + -- all has happened yet, left dangling for a retry within the same + -- still-open turn, exactly like a rejected command), this turn must + -- still be closed out properly rather than abandoned half-finished. + self:endPlayerTurn() + return true, "you have finished exploring" + end + self:movePlayer(ex, ey) + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.hp < hpBefore then + return true, "you stop exploring - you have taken damage" + end + end + return true, "you stop exploring - that is far enough for now" +end + +local REST_STEP_CAP = 500 + +-- Repeats a do-nothing turn (Handlers.wait's own move) inside a single call +-- until the player's hp is back to full, the way "rest" works in most other +-- roguelikes, instead of making the player retype "wait" once per turn. +-- Built on the same beginTurn/endPlayerTurn pair Game:autoExplore already +-- drives, so status ticks, hazards and the passive hp regen sources that +-- actually restore hp (a regen ring or artifact, Status.REGEN, favorable +-- weather - see Game:hungerTorchTick and Status.tick) all run exactly as +-- they would turn by turn. Stops itself the instant a hostile comes into +-- sight, the player's hp actually drops, or two full turns in a row pass +-- with the player's hp completely unchanged - the last case catches the +-- common situation where none of those regen sources are active at all, so +-- waiting here would otherwise never do anything but burn turns up to the +-- safety cap below. Returns (consumesTurn, message) in the exact same shape +-- every other handler in commands.lua does, exactly like Game:autoExplore. +-- +-- The "no progress" check deliberately waits for a *second* completed turn +-- (steps > 1) rather than judging the very first one: Game:manualStep (the +-- only caller a real player's typed "rest" ever goes through) already calls +-- Game:beginTurn once of its own, before Commands.apply even runs, to check +-- whether the turn is allowed to start at all - so by the time this loop's +-- own first Game:beginTurn call runs, self.turnState may already be primed +-- from that earlier call, and it is a no-op cache hit rather than a fresh +-- tick (see Game:beginTurn's own comment). Any regen this call's Status.tick +-- and Game:hungerTorchTick would otherwise have produced already landed on +-- self.player.hp *before* this function's very first hpBefore snapshot was +-- even taken, so a first-iteration hp==hpBefore reading is inconclusive, not +-- proof that nothing is healing the player - judging it anyway would have +-- reported a perfectly working regen ring as "nothing here is healing you" +-- after a single real turn every time rest is typed in the actual game. +-- Every iteration from the second one on always starts with self.turnState +-- already nil (this loop's own prior Game:endPlayerTurn call clears it), so +-- its own Game:beginTurn call is always a fresh tick there - reliable either +-- way this function is entered, direct or through Game:manualStep. +function Game:restUntilHealed() + if self.player.hp >= self.player.maxHp then + return false, "you are already at full health" + end + local steps = 0 + while steps < REST_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop resting - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop resting" + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.hp >= self.player.maxHp then + return true, "you rest until your wounds close" + end + if self.player.hp < hpBefore then + return true, "you stop resting - you have taken damage" + end + if steps > 1 and self.player.hp == hpBefore then + return true, "you stop resting - nothing here is healing you" + end + end + return true, "you stop resting - that is far enough for now" +end + +local TRAVEL_STEP_CAP = 500 + +-- Repeats Game:playerPathTo/Game:movePlayer turn after turn toward a fixed +-- destination inside a single call, the way "travel"/autotravel works in +-- most other roguelikes (DCSS's `>`, Brogue's travel cursor) - instead of +-- making the player retype a direction once per step to walk back to a spot +-- they already know is safe (the stairs they just left behind). Built on the +-- exact same beginTurn/endPlayerTurn pair Game:autoExplore/Game:restUntilHealed +-- already drive, so monster AI, status ticks and hazards all run normally on +-- every turn it takes. Reuses Game:playerPathTo, the same pathfinder +-- choosePlayerAction already relies on to walk the autopilot toward the +-- stairs turn by turn - this only exposes that existing primitive to a typed +-- command instead of teaching a second way to path anywhere. Stops itself +-- the instant a hostile comes into sight, the player's hp actually drops, the +-- destination is reached, or the route becomes blocked (a monster or a +-- spawned block stepped onto the only way through). Returns (consumesTurn, +-- message) in the exact same shape every other Commands.apply handler does. +function Game:travelTo(tx, ty) + if self.player.x == tx and self.player.y == ty then + return false, "you are already there" + end + if not self:playerPathTo(tx, ty) then + return false, "you cannot find a path there" + end + local steps = 0 + while steps < TRAVEL_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop traveling - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop traveling" + end + if state == "ready" then + local path = self:playerPathTo(tx, ty) + if not path or #path < 2 then + -- beginTurn above already ran this turn's monster moves and status + -- ticks for real (see Game:autoExplore's own "nothing left to + -- explore" branch for the identical reasoning), so unlike the + -- steps == 0 case below - where nothing has happened yet and the + -- turn is left open for a retry, exactly like a rejected command - + -- a route that only went dead after steps have already been taken + -- must close out the turn that already ran rather than abandon it + -- half-finished. + if steps == 0 then return false, "you cannot find a path there" end + self:endPlayerTurn() + return true, "you stop traveling - the way is blocked" + end + self:movePlayer(path[2].x - self.player.x, path[2].y - self.player.y) + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.x == tx and self.player.y == ty then + return true, "you arrive" + end + if self.player.hp < hpBefore then + return true, "you stop traveling - you have taken damage" + end + end + return true, "you stop traveling - that is far enough for now" end function Game:render() @@ -2436,6 +2630,10 @@ function Game:toData() seed = self.seed, depth = self.depth, turn = self.turn, + won = self.won, + monsterSeq = self.monsterSeq, + deepestDepth = self.deepestDepth, + stats = self.stats, rng = self.rng:serialize(), identify = self.identify, dungeon = self.dungeon:toData(), From cb014788903844a05980e8e730cddae08eb1da9f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:02:24 +0200 Subject: [PATCH 049/172] feat: extend game with Graves.record and Graves.eligibleFor --- src/game.lua | 81 +++++++++++++++++++++++++++++++++++-- src/graves.lua | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/graves.lua diff --git a/src/game.lua b/src/game.lua index cc66fa3..d30453e 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2638,35 +2638,108 @@ function Game:toData() identify = self.identify, dungeon = self.dungeon:toData(), player = self.player:toData(), - playerExtra = { satiation = self.player.satiation, light = self.player.light }, + playerExtra = { + satiation = self.player.satiation, sanity = self.player.sanity, light = self.player.light, + level = self.player.level, xp = self.player.xp, xpNext = self.player.xpNext, + mana = self.player.mana, maxMana = self.player.maxMana, spellsKnown = self.player.spellsKnown, + gold = self.player.gold, essence = self.player.essence, + class = self.player.class, skills = self.player.skills, + skillPoints = self.player.skillPoints, skillCooldowns = self.player.skillCooldowns, + quest = self.player.quest, reputation = self.player.reputation, + pendingPoisonBlade = self.player.pendingPoisonBlade, + }, log = self.log:toData(), + compendium = self.compendium, + ascension = self.ascension, + nemesis = self.nemesis, + rift = self.rift or false, + riftLevels = Branches.levelsToData(self.riftLevels), + riftReturn = self.riftReturn and { + depth = self.riftReturn.depth, x = self.riftReturn.x, y = self.riftReturn.y, + } or nil, + -- The arena itself lives outside self.dungeon (see Game:startHorde), so + -- without its own snapshot here a mid-arena save would resume from + -- self.dungeon:get(self.depth) instead - the ordinary floor the arena + -- replaced, abandoned but still cached. horde.wave rides along in the + -- same field, since a restored arena with no wave counter would strand + -- the player once the surviving wave dies with nothing left to trigger + -- the next one. + horde = self.horde, + hordeLevel = self.horde and self.level:toData() or nil, } end +-- Expects a complete, current-shape snapshot: every field GameSave's +-- migration chain owns (essence, the skill-tree fields, reputation, sanity, +-- stats.vaultsOpened/setPiecesFound - see the migrations above) must already +-- be present. Game.load guarantees that by running GameSave.load first; +-- Game:toData()'s own output always has them too. A handful of unversioned +-- fields (won, monsterSeq, deepestDepth, the whole stats table) keep their +-- own "or" defaults here since they can be missing outright rather than just +-- missing individual leaves, which the migration chain does not model. function Game.fromData(snapshot) local game = setmetatable({}, Game) game.seed = snapshot.seed game.depth = snapshot.depth game.turn = snapshot.turn + game.won = snapshot.won or false + game.monsterSeq = snapshot.monsterSeq or 0 + game.deepestDepth = snapshot.deepestDepth or snapshot.depth + game.stats = snapshot.stats or { + kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 0, + setPiecesFound = 0, + } game.rng = Rng.deserialize(snapshot.rng) game.identify = snapshot.identify game.dungeon = Dungeon.fromData(snapshot.dungeon) - game.level = game.dungeon:get(game.depth) + game.maxDepth = Builder.maxDepth(game.dungeon.options) + game.rift = snapshot.rift or false + game.riftLevels = Branches.levelsFromData(snapshot.riftLevels) + game.riftReturn = snapshot.riftReturn and { + depth = snapshot.riftReturn.depth, x = snapshot.riftReturn.x, y = snapshot.riftReturn.y, + level = game.dungeon:get(snapshot.riftReturn.depth), + } or nil + game.horde = snapshot.horde + game.level = game.horde and Level.fromData(snapshot.hordeLevel) + or (game.rift and game.riftLevels[game.depth] or game.dungeon:get(game.depth)) game.player = Entity.fromData(snapshot.player) game.player.satiation = snapshot.playerExtra.satiation + game.player.sanity = snapshot.playerExtra.sanity game.player.light = snapshot.playerExtra.light + game.player.level = snapshot.playerExtra.level + game.player.xp = snapshot.playerExtra.xp + game.player.xpNext = snapshot.playerExtra.xpNext + game.player.mana = snapshot.playerExtra.mana + game.player.maxMana = snapshot.playerExtra.maxMana + game.player.spellsKnown = snapshot.playerExtra.spellsKnown + game.player.gold = snapshot.playerExtra.gold + game.player.essence = snapshot.playerExtra.essence + game.player.class = snapshot.playerExtra.class + game.player.skills = snapshot.playerExtra.skills + game.player.skillPoints = snapshot.playerExtra.skillPoints + game.player.skillCooldowns = snapshot.playerExtra.skillCooldowns + game.player.quest = snapshot.playerExtra.quest + game.player.reputation = snapshot.playerExtra.reputation + game.player.pendingPoisonBlade = snapshot.playerExtra.pendingPoisonBlade game.log = MessageLog.fromData(snapshot.log) + game.compendium = snapshot.compendium or Compendium.new() + game.ascension = snapshot.ascension or {} + game.nemesis = snapshot.nemesis game.visible = {} game:computeVisible() return game end function Game:save(path) - Serialize.saveFile(path, self:toData()) + GameSave.save(path, self:toData()) end function Game.load(path) - return Game.fromData(Serialize.loadFile(path)) + local data, err = GameSave.load(path) + if not data then + error("cannot load save " .. path .. ": " .. err) + end + return Game.fromData(data) end return Game diff --git a/src/graves.lua b/src/graves.lua new file mode 100644 index 0000000..d5d2655 --- /dev/null +++ b/src/graves.lua @@ -0,0 +1,106 @@ +-- Cross-run bones: the moment a run ends in death (see warren.lua's --meta +-- path and Game:graveEntry), a grave entry is appended to meta.lua's +-- persistent save - what the fallen was still carrying, and how deep they +-- got. From the very next run on, Builder.build may roll one of those +-- graves onto a floor at or shallower than where it was dug (bones drift +-- upward over time, never down - see Graves.eligibleFor) as an inert +-- floorItem, drawn dead last of all so a run without any recorded deaths +-- (or one played without --meta at all) makes no extra rng draw and its +-- floor layouts stay byte-identical to before this module existed. +-- +-- Unlike mimics.lua's disguise, a grave marker is honest about what it is - +-- disturbing it (Game:pickupHere, mirroring Game:triggerMimic) wakes a +-- vengeful shade rather than pretending to be ordinary loot. The shade's +-- hp and attack scale with how much the fallen was carrying at death, and +-- felling it in turn pays out a fraction of that same hoard - a richer +-- death makes for a tougher fight and a richer reward, but always strictly +-- less than what was actually lost. + +local Entity = require("entity") +local Factions = require("factions") + +local Graves = {} + +-- Oldest graves fall off once a save accumulates more than this many, so a +-- long-lived meta file never grows without bound. +Graves.MAX_RECORDED = 20 + +-- Appends `entry` (see Game:graveEntry) to meta.graves, most recent first, +-- trimming anything past Graves.MAX_RECORDED. +function Graves.record(meta, entry) + meta.graves = meta.graves or {} + table.insert(meta.graves, 1, entry) + while #meta.graves > Graves.MAX_RECORDED do + table.remove(meta.graves) + end +end + +-- Graves eligible to be rolled onto `depth`: any dug at that depth or +-- deeper. A grave dug on floor 7 can still turn up waiting on floor 4, but +-- a floor 2 grave never surfaces on floor 7 - bones drift shallower with +-- time, never the other way around. +function Graves.eligibleFor(graves, depth) + local eligible = {} + if not graves then return eligible end + for _, grave in ipairs(graves) do + if grave.depth >= depth then eligible[#eligible + 1] = grave end + end + return eligible +end + +-- The inert floorItem a builder drops for a rolled grave: visibly a grave +-- from the moment it is seen (no disguise to unmask), carrying just enough +-- of the fallen's own data for Graves.reveal to build their shade later. +function Graves.floorItem(grave) + return { + kind = "grave", grave = true, name = "a weathered grave marker", glyph = "b", + graveClass = grave.className, graveGold = grave.gold, graveEssence = grave.essence, + } +end + +-- The hostile shade a grave marker becomes the instant it is disturbed: +-- hp and attack both scale with the wealth it is guarding, capped so even +-- the richest grave never spawns something the player cannot fight back +-- against. Mirrors Mimics.reveal's shape (a fresh monster entity, tagged so +-- awardKill and corpses.lua both know to treat it specially). +function Graves.reveal(id, x, y, ground) + local gold = ground.graveGold or 0 + local essence = ground.graveEssence or 0 + local wealth = gold + essence * 2 + local bonusHp = math.min(20, wealth // 8) + local bonusAttack = math.min(6, wealth // 30) + local entity = Entity.new({ + id = id, x = x, y = y, + glyph = "G", name = "vengeful shade", kind = "monster", + species = "shade", ai = "chase", + hp = 10 + bonusHp, maxHp = 10 + bonusHp, + attack = 4 + bonusAttack, defense = 1, speed = 105, + }) + entity.cooldown = 0 + entity.faction = Factions.UNDEAD + entity.grave = true + entity.graveClass = ground.graveClass + entity.graveGold = gold + entity.graveEssence = essence + return entity +end + +-- Flat xp reward for felling a revealed shade - kept separate from +-- Monsters.xpFor exactly like Mimics.xpFor, since a shade is never a +-- member of the wild bestiary Monsters.forDepth draws its per-floor +-- population from. +function Graves.xpFor() + return 12 +end + +-- What a felled shade actually leaves behind: half the gold and a third of +-- the essence the fallen was carrying at the moment of death - the rest is +-- gone for good, so a grave is always a net loss across the two runs it +-- touches, never a way to launder wealth forward. +function Graves.lootFor(entity) + local gold = (entity.graveGold or 0) // 2 + local essence = (entity.graveEssence or 0) // 3 + return gold, essence +end + +return Graves From e92b6d844f2982e62340d5b547ff95beb5402e9b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:13:32 +0200 Subject: [PATCH 050/172] feat: add Hazards.at and Hazards.spawn --- src/hazards.lua | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/hazards.lua diff --git a/src/hazards.lua b/src/hazards.lua new file mode 100644 index 0000000..b7a9c7b --- /dev/null +++ b/src/hazards.lua @@ -0,0 +1,114 @@ +-- Timed area hazards that live on the map itself rather than on an entity: +-- fire and caustic gas. Both linger on their cell, hurt and afflict whoever +-- ends a turn standing there, and can spread into open neighbouring floor +-- before burning or blowing themselves out. Fire is doused outright by water. +-- Hazards are plain data on level.hazards so they serialize like floor items +-- and traps do, with no special save-format handling. + +local Tiles = require("tiles") +local Status = require("status") +local Resistances = require("resistances") +local Conductivity = require("conductivity") + +local Hazards = {} + +Hazards.FIRE = "fire" +Hazards.GAS = "gas" + +local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- First hazard occupying (x, y), optionally filtered to a specific kind so +-- fire and gas can coexist on the same cell without merging into each other. +function Hazards.at(level, x, y, kind) + for _, hz in ipairs(level.hazards) do + if hz.x == x and hz.y == y and (not kind or hz.kind == kind) then return hz end + end + return nil +end + +-- Drops a hazard on (x, y), or refreshes one already there of the same kind +-- to whichever of the two durations and potencies is stronger. Walls never +-- catch fire or hold gas. +function Hazards.spawn(level, x, y, kind, duration, potency) + if not level.map:inBounds(x, y) then return nil end + if level.map:get(x, y) == Tiles.WALL then return nil end + local existing = Hazards.at(level, x, y, kind) + if existing then + existing.duration = math.max(existing.duration, duration) + existing.potency = math.max(existing.potency, potency) + return existing + end + local hz = { x = x, y = y, kind = kind, duration = duration, potency = potency } + level.hazards[#level.hazards + 1] = hz + return hz +end + +function Hazards.ignite(level, x, y, duration, potency) + return Hazards.spawn(level, x, y, Hazards.FIRE, duration or 3, potency or 2) +end + +function Hazards.spawnGas(level, x, y, duration, potency) + return Hazards.spawn(level, x, y, Hazards.GAS, duration or 4, potency or 2) +end + +-- Applies whichever hazard occupies entity's current cell, mirroring how +-- Status.tick afflicts the same actor once per turn. Fire and gas both deal +-- their potency in direct damage and lay down a fading status effect on top, +-- so stepping out of the hazard still leaves a lingering burn or sickness. +function Hazards.afflict(level, entity) + local hz = Hazards.at(level, entity.x, entity.y) + if not hz then return nil end + if hz.kind == Hazards.FIRE then + local dealt = Conductivity.applyFire(entity, hz.potency) + entity.hp = entity.hp - dealt + Status.apply(entity, Status.BURNING, 2, math.max(1, hz.potency // 2)) + if Conductivity.isWet(entity) then + return string.format("flames hiss against %s's soaked skin for %d", entity.name, dealt) + end + return string.format("flames engulf %s for %d", entity.name, dealt) + end + local dealt = Resistances.apply(entity, hz.potency, Resistances.POISON) + entity.hp = entity.hp - dealt + Status.apply(entity, Status.POISON, 2, math.max(1, hz.potency // 2)) + return string.format("choking fumes sicken %s for %d", entity.name, dealt) +end + +local function canSpreadInto(level, x, y) + if not level.map:inBounds(x, y) then return false end + local tile = level.map:get(x, y) + return Tiles.isWalkable(tile) and tile ~= Tiles.WATER +end + +-- Advances every hazard one environmental step: each has an even chance to +-- bloom into an open neighbouring cell that isn't already burning or gassed +-- the same way, water douses fire outright, and every hazard's clock counts +-- down until it burns or blows itself out. Called once per world round (not +-- per actor) so a seeded run always spreads identically regardless of how +-- many actors acted that round. +function Hazards.step(level, rng) + local spawns = {} + for _, hz in ipairs(level.hazards) do + for _, dir in ipairs(DIRS) do + local nx, ny = hz.x + dir[1], hz.y + dir[2] + if canSpreadInto(level, nx, ny) and not Hazards.at(level, nx, ny, hz.kind) + and rng:chance(1, 2) then + spawns[#spawns + 1] = { nx, ny, hz.kind, hz.duration, hz.potency } + end + end + end + for _, s in ipairs(spawns) do + Hazards.spawn(level, s[1], s[2], s[3], s[4], s[5]) + end + + local kept = {} + for _, hz in ipairs(level.hazards) do + local doused = hz.kind == Hazards.FIRE and level.map:get(hz.x, hz.y) == Tiles.WATER + hz.duration = hz.duration - 1 + if hz.duration > 0 and not doused then + kept[#kept + 1] = hz + end + end + level.hazards = kept +end + +return Hazards From 58994a61323cc8c6bb9ff9ce84c126f35705d149 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:13:45 +0200 Subject: [PATCH 051/172] feat: add chebyshev and Horde.spawnAnchors --- src/horde.lua | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/horde.lua diff --git a/src/horde.lua b/src/horde.lua new file mode 100644 index 0000000..2861e8f --- /dev/null +++ b/src/horde.lua @@ -0,0 +1,159 @@ +-- Horde Arena: an optional survival trial layered onto an existing run, +-- entirely separate from the depth-descent dungeon (dungeon.lua/builder.lua +-- never run for it - Game:startHorde builds one closed Level directly, see +-- Horde.buildArena) yet it still runs through every existing turn system +-- untouched: scheduler.lua, ai.lua and combat.lua all just see a Level like +-- any other, since Level.new already defaults every collection an arena has +-- no use for (shrines, nodes, traps, quest boards...) to an empty table. +-- What makes it a *horde* rather than one more static floor is Horde.tick, +-- wired into Game:endPlayerTurn behind a game.horde check: the instant +-- every monster the current wave placed is dead, it banks a reward, heals a +-- slice of damage back, and throws the next wave in immediately - there is +-- never a turn where the arena stands empty. A wave's difficulty is driven +-- by the same Monsters.forDepth/Elites.maybePromote a real floor already +-- uses, just fed the wave number as if it were depth, so it never plateaus: +-- long past the point every species has unlocked, waves just keep growing +-- larger and rolling elites more often, exactly as if still descending. +-- +-- Scope: a fresh Game.new never has a game.horde field at all, exactly like +-- game.nemesis before a nemesis has ever spawned. Because the arena level +-- lives outside self.dungeon entirely, Game:toData/fromData snapshot both +-- game.horde and the arena's own Level separately (hordeLevel) so a save +-- taken mid-arena resumes the exact wave in progress rather than silently +-- dropping the player back onto the ordinary floor the arena replaced. + +local Map = require("map") +local Tiles = require("tiles") +local Connect = require("gen.connect") +local Monsters = require("monsters") +local Elites = require("elites") +local Stealth = require("stealth") + +local Horde = {} + +Horde.WIDTH = 19 +Horde.HEIGHT = 13 +Horde.PILLAR_NUMERATOR = 1 +Horde.PILLAR_DENOMINATOR = 9 + +-- Chebyshev radius around each corner anchor left pillar-free by +-- Horde.buildArena, and the radius Horde.spawnPoints itself searches within +-- of the very same anchors - the two have to agree, or a spawn queue built +-- wider than the guaranteed-clear zone could try to place a monster on a +-- cell that turned out to be a pillar. +Horde.SPAWN_CLEARANCE = 2 + +Horde.BASE_COUNT = 3 +Horde.COUNT_PER_WAVE = 1 +Horde.MAX_COUNT = 12 + +Horde.REST_HEAL = 4 +Horde.ESSENCE_PER_WAVE = 5 + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +-- Fixed corner anchors, inset one cell from the border wall so they are +-- never the border itself and always inside the interior floor Horde.buildArena +-- carves - plain open floor by construction, regardless of anything rolled +-- on the rng that built the rest of the arena. +function Horde.spawnAnchors(width, height) + return { + { x = 2, y = 2 }, { x = width - 1, y = 2 }, + { x = 2, y = height - 1 }, { x = width - 1, y = height - 1 }, + } +end + +-- The deterministic placement queue Horde.spawnWave fills wave by wave: +-- every walkable cell within Horde.SPAWN_CLEARANCE of any anchor, round- +-- robin across the four anchors (first cell of each, then second cell of +-- each...) rather than anchor-by-anchor, so even a small wave arrives from +-- more than one direction instead of clumping in a single corner. Each +-- underlying map cell can only ever claim one slot in the queue, even if +-- two anchors' clearance zones happened to overlap. +function Horde.spawnPoints(map) + local anchors = Horde.spawnAnchors(map.width, map.height) + local perAnchor, claimed = {}, {} + for _, anchor in ipairs(anchors) do + local cells = {} + for y = anchor.y - Horde.SPAWN_CLEARANCE, anchor.y + Horde.SPAWN_CLEARANCE do + for x = anchor.x - Horde.SPAWN_CLEARANCE, anchor.x + Horde.SPAWN_CLEARANCE do + local idx = map:inBounds(x, y) and map:index(x, y) + if idx and not claimed[idx] and map:isWalkable(x, y) then + claimed[idx] = true + cells[#cells + 1] = { x = x, y = y } + end + end + end + perAnchor[#perAnchor + 1] = cells + end + local points = {} + local i, more = 1, true + while more do + more = false + for _, cells in ipairs(perAnchor) do + if cells[i] then + points[#points + 1] = cells[i] + more = true + end + end + i = i + 1 + end + return points +end + +-- One fully-enclosed arena: a rectangle of floor ringed by wall, its +-- interior sparsely sown with single-tile pillars for cover - except within +-- Horde.SPAWN_CLEARANCE of any corner anchor, and the centre cell (the +-- player's own start, see Game:startHorde), both deliberately left clear +-- regardless of what the pillar pass would otherwise roll there. +-- Connect.ensure runs last, exactly like every generator gen.init already +-- dispatches to, so a pillar can never wall off a pocket of floor even +-- though placement itself never checks for that. Returns the map plus the +-- centre cell, since Game:startHorde needs it for the player's own start. +function Horde.buildArena(rng, width, height) + width = width or Horde.WIDTH + height = height or Horde.HEIGHT + local map = Map.new(width, height) + for y = 2, height - 1 do + for x = 2, width - 1 do + map:set(x, y, Tiles.FLOOR) + end + end + local anchors = Horde.spawnAnchors(width, height) + local cx, cy = width // 2 + 1, height // 2 + 1 + for y = 2, height - 1 do + for x = 2, width - 1 do + local clear = x == cx and y == cy + if not clear then + for _, anchor in ipairs(anchors) do + if chebyshev(x, y, anchor.x, anchor.y) <= Horde.SPAWN_CLEARANCE then + clear = true + break + end + end + end + if not clear and rng:chance(Horde.PILLAR_NUMERATOR, Horde.PILLAR_DENOMINATOR) then + map:set(x, y, Tiles.WALL) + end + end + end + Connect.ensure(map, rng) + return map, cx, cy +end + +function Horde.newState() + return { wave = 0 } +end + +function Horde.depthFor(wave) + return wave +end + +function Horde.countFor(wave) + local count = Horde.BASE_COUNT + (wave - 1) * Horde.COUNT_PER_WAVE + if count > Horde.MAX_COUNT then count = Horde.MAX_COUNT end + return count +end + From 1f4ea71a1fd62d1f7dd506bd303485dc223943ac Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 21:49:13 +0200 Subject: [PATCH 052/172] feat: extend horde with Horde.essenceFor and Horde.pool --- src/horde.lua | 85 +++++++++++++++++++++++++++++++++++ src/hunger.lua | 112 +++++++++++++++++++++++++++++++++++++++++++++++ src/identify.lua | 2 +- 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/hunger.lua diff --git a/src/horde.lua b/src/horde.lua index 2861e8f..cde5a2d 100644 --- a/src/horde.lua +++ b/src/horde.lua @@ -157,3 +157,88 @@ function Horde.countFor(wave) return count end +function Horde.essenceFor(wave) + return wave * Horde.ESSENCE_PER_WAVE +end + +-- Species eligible for wave `wave`, drawn from Monsters.forDepth at +-- Horde.depthFor(wave) exactly like a real floor's population, minus the +-- passive vend-behaviour shopkeeper - a wave the player can never provoke +-- into fighting back would leave Horde.cleared permanently false. +function Horde.pool(wave) + local eligible = {} + for _, spec in ipairs(Monsters.forDepth(Horde.depthFor(wave))) do + if spec.ai ~= "vend" then eligible[#eligible + 1] = spec end + end + return eligible +end + +-- True once every hostile monster the arena has ever placed is dead - +-- vacuously true against a still-empty arena too, which is exactly what +-- lets Horde.tick reuse the same check to bootstrap wave 1 as it does to +-- clear every wave after it (see Horde.tick's own comment). Filtered to +-- entity.kind == "monster", the same "hostile" tag every other module +-- (companions.lua/mercenaries.lua/reanimation.lua/abilities.lua...) uses: +-- Game:startHorde can now carry a bonded companion/hireling/thrall into the +-- arena, and an ally that never dies to the wave itself would otherwise +-- keep this permanently false, freezing the arena on wave 0 forever. +function Horde.cleared(level) + for _, entity in ipairs(level.entities) do + if entity.kind == "monster" and entity:isAlive() then return false end + end + return true +end + +-- Populates `game.level` with wave `wave`'s monsters, drawn from +-- Horde.pool and placed one to a cell along Horde.spawnPoints's queue - +-- never doubling two monsters onto the same cell, the one-entity-per-cell +-- invariant every other spawn path in game.lua already relies on. Every +-- spawn is already hunting, exactly like Game:spawnMinion's own summons - +-- an arena has no stealth approach to sneak past. Returns the spawned +-- entities. +function Horde.spawnWave(game, wave) + local level = game.level + local points = Horde.spawnPoints(level.map) + local pool = Horde.pool(wave) + local count = math.min(Horde.countFor(wave), #points) + local depth = Horde.depthFor(wave) + local spawned = {} + for i = 1, count do + local point = points[i] + local spec = game.rng:pick(pool) + local entity = Monsters.create(spec, game:nextMonsterId(), point.x, point.y, depth) + entity.awareness = Stealth.HUNTING + Elites.maybePromote(game.rng, entity, depth) + level.entities[#level.entities + 1] = entity + spawned[#spawned + 1] = entity + end + return spawned +end + +-- Advances horde state by one player turn, called from Game:endPlayerTurn +-- behind a `game.horde` check. A no-op while the current wave still has a +-- living monster in it; the instant it does not - including the very first +-- call, against the still-empty arena Game:startHorde just built, since +-- Horde.cleared reads that the same way as a genuinely finished wave - it +-- banks Horde.essenceFor(wave) essence (zero the first time, wave still +-- being 0), heals Horde.REST_HEAL, advances the wave counter, and spawns +-- the new wave outright, so the arena is never left standing empty for +-- even a single turn. Returns a log line, or nil when nothing happened. +function Horde.tick(game) + local horde = game.horde + if not horde then return nil end + if not Horde.cleared(game.level) then return nil end + local reward = Horde.essenceFor(horde.wave) + if reward > 0 then + game.player.essence = game.player.essence + reward + end + game.player.hp = math.min(game.player.maxHp, game.player.hp + Horde.REST_HEAL) + horde.wave = horde.wave + 1 + Horde.spawnWave(game, horde.wave) + if horde.wave == 1 then + return "the horde arena falls silent - wave 1 begins" + end + return string.format("the arena falls silent - wave %d begins (+%d essence)", horde.wave, reward) +end + +return Horde diff --git a/src/hunger.lua b/src/hunger.lua new file mode 100644 index 0000000..b1ed56e --- /dev/null +++ b/src/hunger.lua @@ -0,0 +1,112 @@ +-- Named bands layered over the player's raw satiation counter, the same +-- shape sanity.lua already gives its own slow-moving meter: a plain number +-- that Game:hungerTorchTick drains by one every round (untouched here), +-- classified into FED/HUNGRY/WEAK/STARVING tiers that a caller reads back +-- instead of re-deriving the cutoffs itself. Falling into WEAK or worse +-- costs real attack and defense - wired into entity.lua's effectiveAttack/ +-- effectiveDefense alongside Sanity's own penalty, so a gaunt fighter hits +-- and blocks softer without combat.lua needing to know hunger exists at +-- all - and blocks the passive mending hungerTorchTick already grants from +-- a worn ring, an ironclad set or consecrated ground: a starving adventurer +-- can still down a potion, just not coast on a ring of regeneration while +-- fading. STARVING alone keeps hungerTorchTick's own flat hp drain, exactly +-- as before. +-- +-- Eating is the mirror case: Hunger.eat folds fresh nutrition into the same +-- counter, capped at Hunger.CAP, but food eaten while already at that cap +-- is entirely wasted and leaves the diner Status.SLOW for a few turns - the +-- same overfull stumble a real glutton earns, reusing a status the engine +-- already ticks, serializes and renders with no special handling of its own. + +local Status = require("status") + +local Hunger = {} + +Hunger.FED = "fed" +Hunger.HUNGRY = "hungry" +Hunger.WEAK = "weak" +Hunger.STARVING = "starving" + +-- Starting ration for a fresh game (Game.new) and the ceiling Hunger.eat +-- refuses to bank past. Comfortably above START so a couple of found +-- rations can stack up before a long dive, without letting the pool grow +-- without bound. +Hunger.START = 1000 +Hunger.CAP = 1200 + +-- Tier ceilings: a value at or below HUNGRY_MAX but above WEAK_MAX reads as +-- HUNGRY, and so on downward. 0 itself is always STARVING regardless of +-- where these ceilings sit. +Hunger.HUNGRY_MAX = 300 +Hunger.WEAK_MAX = 100 + +Hunger.BLOAT_DURATION = 4 + +local ATTACK_PENALTY = { + [Hunger.FED] = 0, [Hunger.HUNGRY] = 0, [Hunger.WEAK] = -2, [Hunger.STARVING] = -4, +} +local DEFENSE_PENALTY = { + [Hunger.FED] = 0, [Hunger.HUNGRY] = 0, [Hunger.WEAK] = -1, [Hunger.STARVING] = -3, +} + +-- A fresh entity - or a monster, which never accrues hunger of its own +-- since nothing ever calls Hunger.tick for one - reads as fully fed. +function Hunger.current(entity) + return entity.satiation or Hunger.CAP +end + +function Hunger.tierOf(entity) + local value = Hunger.current(entity) + if value <= 0 then return Hunger.STARVING end + if value <= Hunger.WEAK_MAX then return Hunger.WEAK end + if value <= Hunger.HUNGRY_MAX then return Hunger.HUNGRY end + return Hunger.FED +end + +function Hunger.attackPenalty(entity) + return ATTACK_PENALTY[Hunger.tierOf(entity)] +end + +function Hunger.defensePenalty(entity) + return DEFENSE_PENALTY[Hunger.tierOf(entity)] +end + +-- WEAK or worse blocks the passive regen hungerTorchTick otherwise grants +-- from a ring, a set bonus or friendly weather - a fading body has nothing +-- left over to spend on mending. +function Hunger.suppressesRegen(entity) + local tier = Hunger.tierOf(entity) + return tier == Hunger.WEAK or tier == Hunger.STARVING +end + +-- One round of drain. Returns a log line only when the tier actually +-- changes, except STARVING which - matching the flat drain this replaces - +-- reports and bites for 1 hp every single round it holds, not just on the +-- turn it is first reached. +function Hunger.tick(entity) + local before = Hunger.tierOf(entity) + entity.satiation = math.max(0, Hunger.current(entity) - 1) + local after = Hunger.tierOf(entity) + if after == Hunger.STARVING then + entity.hp = entity.hp - 1 + return "you are starving" + end + if after ~= before then + return "you feel " .. after + end + return nil +end + +-- Feeds `nutrition` in, clamped at Hunger.CAP. Returns true when the diner +-- was already at the cap - all of it wasted, and left Status.SLOW for +-- Hunger.BLOAT_DURATION turns - or false for an ordinary meal that landed. +function Hunger.eat(entity, nutrition) + local wasFull = Hunger.current(entity) >= Hunger.CAP + entity.satiation = math.min(Hunger.CAP, Hunger.current(entity) + nutrition) + if wasFull then + Status.apply(entity, Status.SLOW, Hunger.BLOAT_DURATION, 1) + end + return wasFull +end + +return Hunger diff --git a/src/identify.lua b/src/identify.lua index e6b2b55..1778b9b 100644 --- a/src/identify.lua +++ b/src/identify.lua @@ -8,7 +8,7 @@ local Items = require("items") local Identify = {} local potionLooks = { - "ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous", + "ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous", "inky", "frothy", } local scrollLooks = { "XYZZY", "ELBERETH", "ZELGO KAI", "JUYED AWK", "VE FORBRYDERNE", From 2faa8dbe75924940bdb3dcef82e6ddd0a4b18b8d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 22:53:04 +0200 Subject: [PATCH 053/172] feat: add canSee and isAlly --- src/infighting.lua | 110 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/infighting.lua diff --git a/src/infighting.lua b/src/infighting.lua new file mode 100644 index 0000000..e4c1794 --- /dev/null +++ b/src/infighting.lua @@ -0,0 +1,110 @@ +-- Monsters that share no faction bond will occasionally fight each other +-- outright instead of always beelining for the player. Two rival factions +-- (see Factions.RIVALRIES) throw down on sight, and any monster carrying +-- Status.DISCORD - landed by the discord spell, see spells.lua - is +-- compelled to strike whatever creature stands nearest it, friend or foe, +-- for the effect's duration. Either way the fight runs through combat.lua +-- exactly like a player's own attack - same damage formula, same +-- resistances, same Game:applyEliteOnHit hooks (an elite attacker's +-- lifesteal or on-hit status rider fires here too, not just against the +-- player) and the same disease.lua bite chance (a plague-carrier's bite can +-- infect a rival monster or the player's own companion/hireling/thrall, +-- not just the player Game:monsterMelee already covers) - so a discorded +-- troll among goblins is a real threat to both sides, not a scripted +-- distraction. A plain monster felled this way +-- still leaves a corpse (scavenging/reanimation can use it) but pays out no +-- xp, essence, faction standing, or compendium credit - the player didn't +-- land the blow. A companion, hireling, or thrall killed this way (only +-- reachable through discord, since none of them carry a faction) dies +-- exactly as it would to any other monster. + +local Combat = require("combat") +local Status = require("status") +local Factions = require("factions") +local Los = require("los") +local Corpses = require("corpses") +local Noise = require("noise") +local Disease = require("disease") + +local Infighting = {} + +Infighting.SIGHT_RANGE = 8 + +local function canSee(game, a, b) + local cheb = math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) + if cheb > Infighting.SIGHT_RANGE then return false end + return Los.clear(game.level.map, a.x, a.y, b.x, b.y) +end + +local function isAlly(actor) + return actor.kind == "companion" or actor.kind == "hireling" or actor.kind == "thrall" +end + +-- The other living, non-player creature `monster` should be fighting this +-- turn instead of following its usual Ai.act behaviour, or nil if none +-- qualifies. A rival-faction monster always qualifies once seen; under +-- Status.DISCORD, the nearest visible creature of any kind - including the +-- player's own companion, hireling, or thrall - qualifies too, since +-- discord scrambles a mind's sense of friend and foe entirely. Ties break +-- toward the lower entity id, matching Game:nearestAlly's own tie rule. +function Infighting.target(game, monster) + local discorded = Status.has(monster, Status.DISCORD) + local best, bestDist = nil, math.huge + for _, other in ipairs(game.level.entities) do + if other ~= monster and other:isAlive() then + local qualifies = discorded or Factions.areRivals(monster.faction, other.faction) + if qualifies and canSee(game, monster, other) then + local dist = math.max(math.abs(other.x - monster.x), math.abs(other.y - monster.y)) + if dist < bestDist or (dist == bestDist and other.id < best.id) then + best, bestDist = other, dist + end + end + end + end + return best +end + +-- Resolves one monster-vs-monster hit through the same damage formula any +-- other attack uses. A defender that falls is swept immediately: a +-- companion/hireling/thrall dies exactly as game.lua already reports that, +-- while a plain hostile monster just drops - Corpses.spawn still leaves +-- remains behind, but no awardKill means no xp/essence/faction/compendium +-- credit for a kill the player never made. +function Infighting.strike(game, attacker, defender) + local outcome = Combat.attack(attacker, defender) + game.log:add(string.format("%s hits %s for %d", attacker.name, defender.name, outcome.damage)) + game:applyEliteOnHit(attacker, outcome.damage, defender) + if attacker.biteDisease and defender:isAlive() and game.rng:chance(1, 4) then + if Disease.infect(defender, attacker.biteDisease) then + game.log:add(string.format("%s's bite leaves %s feeling ill", attacker.name, defender.name)) + end + end + game:emitNoise(attacker.x, attacker.y, Noise.MELEE) + if not defender:isAlive() then + if isAlly(defender) then + game:onActorDeath(defender) + else + game.log:add(defender.name .. " dies") + Corpses.spawn(game.level, defender) + end + end + return outcome +end + +-- Entry point mirroring Abilities.tryAct's contract: returns true when +-- infighting consumed the monster's turn (attacked an adjacent target or +-- stepped toward a farther one), false to let the caller fall through to +-- Ai.act. +function Infighting.tryAct(game, monster) + local target = Infighting.target(game, monster) + if not target then return false end + local dist = math.max(math.abs(target.x - monster.x), math.abs(target.y - monster.y)) + if dist <= 1 then + Infighting.strike(game, monster, target) + else + game:stepMonsterToward(monster, target.x, target.y) + end + return true +end + +return Infighting From 9a647af6e3c31e5f6811b18f1f2cf0efd09eac09 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 11:21:37 +0200 Subject: [PATCH 054/172] feat: extend items with Items.tome and Items.amulet --- src/items.lua | 146 ++++++++++++++++++++++++++++++++++++++++---- src/leaderboard.lua | 55 +++++++++++++++++ 2 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 src/leaderboard.lua diff --git a/src/items.lua b/src/items.lua index bb274ff..aea40c0 100644 --- a/src/items.lua +++ b/src/items.lua @@ -3,6 +3,8 @@ -- modifies effective stats when equipped and may carry an affix. Potion and -- scroll type catalogs drive the identification system. +local Durability = require("durability") + local Items = {} Items.POTION = "potion" @@ -12,21 +14,48 @@ Items.ARMOR = "armor" Items.RING = "ring" Items.FOOD = "food" Items.KEY = "key" +Items.TOME = "tome" +Items.AMULET = "amulet" +Items.BOW = "bow" +Items.AMMO = "ammo" +Items.GOLD = "gold" +Items.GEM = "gem" +Items.CHARM = "charm" +Items.KIT = "kit" +Items.HERB = "herb" +Items.RUNE = "rune" +Items.PICK = "pick" +Items.LOCKPICK = "lockpick" +Items.CONTRACT = "contract" +Items.NET = "net" +Items.RIFTSTONE = "riftstone" -- Catalogs list every distinct type in a fixed order; identification assigns a -- random appearance to each entry, so the order here must stay stable. +-- New entries append at the end: identify.lua assigns appearances by index +-- into this list, so inserting anywhere else would reshuffle which look +-- every earlier, already-loot-tested effect gets for a given seed. There are +-- exactly as many potionLooks in identify.lua as there are entries here, so +-- adding one more type here requires a matching look there too. Items.potionTypes = { { effect = "heal", name = "potion of healing", power = 10 }, { effect = "poison", name = "potion of poison", power = 3 }, { effect = "strength", name = "potion of strength", power = 0 }, { effect = "haste", name = "potion of speed", power = 0 }, + { effect = "fortify", name = "potion of fortification", power = 0 }, + { effect = "clarity", name = "potion of clarity", power = 30 }, + { effect = "cure", name = "potion of cleansing", power = 0 }, + { effect = "confuse", name = "potion of confusion", power = 0 }, + { effect = "blind", name = "potion of blindness", power = 0 }, + { effect = "levitate", name = "potion of levitation", power = 0 }, } Items.scrollTypes = { - { effect = "teleport", name = "scroll of teleportation" }, - { effect = "identify", name = "scroll of identify" }, - { effect = "mapping", name = "scroll of magic mapping" }, - { effect = "fireball", name = "scroll of fireball" }, + { effect = "teleport", name = "scroll of teleportation" }, + { effect = "identify", name = "scroll of identify" }, + { effect = "mapping", name = "scroll of magic mapping" }, + { effect = "fireball", name = "scroll of fireball" }, + { effect = "removecurse", name = "scroll of remove curse" }, } function Items.typeId(item) @@ -54,11 +83,13 @@ function Items.scroll(effect) end function Items.weapon(name, power) - return { kind = Items.WEAPON, name = name, glyph = "/", power = power } + return { kind = Items.WEAPON, name = name, glyph = "/", power = power, + durability = Durability.MAX, maxDurability = Durability.MAX } end function Items.armor(name, power) - return { kind = Items.ARMOR, name = name, glyph = "[", power = power } + return { kind = Items.ARMOR, name = name, glyph = "[", power = power, + durability = Durability.MAX, maxDurability = Durability.MAX } end function Items.ring(name, attack, defense) @@ -73,26 +104,115 @@ function Items.key(name) return { kind = Items.KEY, name = name or "iron key", glyph = "*" } end +function Items.tome(spellKey, name) + return { kind = Items.TOME, spell = spellKey, name = name or ("tome of " .. spellKey), glyph = "&" } +end + +function Items.amulet(name) + return { kind = Items.AMULET, name = name or "Amulet of Warren", glyph = "\"" } +end + +function Items.bow(name, power, range) + return { kind = Items.BOW, name = name, glyph = "}", power = power, range = range, + durability = Durability.MAX, maxDurability = Durability.MAX } +end + +function Items.ammo(name, count) + return { kind = Items.AMMO, name = name or "arrow", glyph = "^", count = count } +end + +function Items.gold(amount) + return { kind = Items.GOLD, name = "gold", glyph = "$", amount = amount } +end + +function Items.charm(companionKey, name) + return { kind = Items.CHARM, companion = companionKey, name = name or ("charm of the " .. companionKey), glyph = "0" } +end + +function Items.kit(effect) + return { kind = Items.KIT, effect = effect, name = effect .. " trap kit", glyph = "t" } +end + +-- A shopkeeper's contract to hire mercenaries.lua's `key` roster entry: paid +-- for outright at the shop's own price (see shop.lua's CONTRACT branch), so +-- using it just takes the mercenary on rather than spending any further +-- gold - unlike a trap kit above, which is free to find but costs nothing +-- to use either. +function Items.contract(key, name) + return { kind = Items.CONTRACT, hireling = key, name = name or (key .. " contract"), glyph = "P" } +end + +function Items.pick(name, uses) + return { kind = Items.PICK, name = name or "miner's pick", glyph = "\\", uses = uses or 20 } +end + +-- A single-use gamble against a locked chest's own lock (see chests.lua's +-- Chests.pick) - consumed win or lose, unlike the miner's pick above which +-- wears down over many swings instead of vanishing in one. +function Items.lockpick(name) + return { kind = Items.LOCKPICK, name = name or "lockpick", glyph = ";" } +end + +-- A weighted throwing net (see captives.lua's Captives.resolve): unlike a +-- thrown weapon above, which is aimed to hurt, a net is aimed to subdue a +-- battered wild monster alive instead of finishing it off - and is spent +-- from the inventory whether or not it actually holds, exactly like a +-- lockpick's own single gamble. +function Items.net(name) + return { kind = Items.NET, name = name or "net", glyph = "o" } +end + +-- Fuel for a single rift crossing (see branches.lua): unlike a key, which +-- only ever opens the one lock it was cut for, a shard is spent the instant +-- Game:enterRift first stabilizes a given rift, not on every crossing after - +-- so carrying more than one only ever matters if the run finds more than one +-- rift to open. +function Items.riftstone(name) + return { kind = Items.RIFTSTONE, name = name or "rift shard", glyph = "R" } +end + +-- Adds/removes a weapon or armor's power using its own durability-aware +-- effective power (see durability.lua) rather than its raw `power` field, so +-- unequipping and later re-equipping an already-broken piece never bakes +-- power back in for free. function Items.equipWeapon(item, entity) if entity.weapon then - entity.attack = entity.attack - entity.weapon.power + entity.attack = entity.attack - Durability.effectivePower(entity.weapon) entity.inventory[#entity.inventory + 1] = entity.weapon end entity.weapon = item - entity.attack = entity.attack + item.power - return item.power + local gained = Durability.effectivePower(item) + entity.attack = entity.attack + gained + return gained end function Items.equipArmor(item, entity) if entity.armor then - entity.defense = entity.defense - entity.armor.power + entity.defense = entity.defense - Durability.effectivePower(entity.armor) entity.inventory[#entity.inventory + 1] = entity.armor end entity.armor = item - entity.defense = entity.defense + item.power + local gained = Durability.effectivePower(item) + entity.defense = entity.defense + gained + return gained +end + +function Items.equipBow(item, entity) + if entity.bow then + entity.inventory[#entity.inventory + 1] = entity.bow + end + entity.bow = item return item.power end +function Items.equipPick(item, entity) + if entity.pick then + entity.inventory[#entity.inventory + 1] = entity.pick + end + entity.pick = item + return item.uses +end + function Items.equipRing(item, entity) if entity.ring then entity.attack = entity.attack - entity.ring.attack @@ -119,6 +239,10 @@ function Items.use(item, entity) return Items.equipArmor(item, entity) elseif item.kind == Items.RING then return Items.equipRing(item, entity) + elseif item.kind == Items.BOW then + return Items.equipBow(item, entity) + elseif item.kind == Items.PICK then + return Items.equipPick(item, entity) end return 0 end diff --git a/src/leaderboard.lua b/src/leaderboard.lua new file mode 100644 index 0000000..674bed3 --- /dev/null +++ b/src/leaderboard.lua @@ -0,0 +1,55 @@ +-- Persistent top-N high score table. A run's own numeric score (see +-- morgue.lua's Morgue.score) only matters across runs if something +-- remembers it - this is that memory, carried inside meta.lua's own save +-- file exactly like renown and achievements already are, so a leaderboard +-- outlives every single run that fed it. + +local Leaderboard = {} + +Leaderboard.CAPACITY = 10 + +function Leaderboard.new() + return {} +end + +-- Shifts board's tail down to open a gap for `entry` at the first slot +-- whose current occupant does not outscore it, then drops it there. +-- Returns the 1-based rank entry lands at, counting past Leaderboard.CAPACITY +-- when the board is already full - the caller trims that overflow back off. +local function insertSorted(board, entry) + local i = #board + 1 + while i > 1 and board[i - 1].score < entry.score do + board[i] = board[i - 1] + i = i - 1 + end + board[i] = entry + return i +end + +-- Inserts `entry` (expects at least a numeric .score field) into `board`, +-- keeping it sorted best-score-first and capped at Leaderboard.CAPACITY. +-- A run that does not crack the cap is dropped outright rather than kept +-- around uncounted - a leaderboard only remembers what actually placed. +-- Returns the rank it landed at, or nil if it did not make the cut. +function Leaderboard.record(board, entry) + local rank = insertSorted(board, entry) + while #board > Leaderboard.CAPACITY do + table.remove(board) + end + if rank > Leaderboard.CAPACITY then return nil end + return rank +end + +function Leaderboard.top(board, count) + local out = {} + for i = 1, math.min(count or Leaderboard.CAPACITY, #board) do + out[i] = board[i] + end + return out +end + +function Leaderboard.bestScore(board) + return board[1] and board[1].score or 0 +end + +return Leaderboard From d273ea8e325cd8cc7da10212078eba257e39ad9b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 12:08:31 +0200 Subject: [PATCH 055/172] feat: extend level with Level:entityById and Level:shrineAt --- src/level.lua | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/level.lua b/src/level.lua index 994e2ee..b3d92ff 100644 --- a/src/level.lua +++ b/src/level.lua @@ -18,6 +18,25 @@ function Level.new(fields) floorItems = fields.floorItems or {}, traps = fields.traps or {}, lights = fields.lights or {}, + hazards = fields.hazards or {}, + shrines = fields.shrines or {}, + questBoards = fields.questBoards or {}, + cauldrons = fields.cauldrons or {}, + nodes = fields.nodes or {}, + corpses = fields.corpses or {}, + chests = fields.chests or {}, + captives = fields.captives or {}, + vaultLevers = fields.vaultLevers or {}, + vaultDoors = fields.vaultDoors or {}, + secretDoors = fields.secretDoors or {}, + blocks = fields.blocks or {}, + plates = fields.plates or {}, + gates = fields.gates or {}, + prefabVaults = fields.prefabVaults or {}, + rifts = fields.rifts or {}, + weather = fields.weather, + biome = fields.biome, + event = fields.event, dark = fields.dark or false, explored = fields.explored or {}, upX = fields.upX, upY = fields.upY, @@ -34,6 +53,13 @@ function Level:entityAt(x, y) return nil end +function Level:entityById(id) + for _, entity in ipairs(self.entities) do + if entity.id == id then return entity end + end + return nil +end + function Level:trapAt(x, y) for _, trap in ipairs(self.traps) do if trap.x == x and trap.y == y then return trap end @@ -41,6 +67,90 @@ function Level:trapAt(x, y) return nil end +function Level:shrineAt(x, y) + for _, shrine in ipairs(self.shrines) do + if shrine.x == x and shrine.y == y then return shrine end + end + return nil +end + +function Level:questBoardAt(x, y) + for _, board in ipairs(self.questBoards) do + if board.x == x and board.y == y then return board end + end + return nil +end + +function Level:cauldronAt(x, y) + for _, cauldron in ipairs(self.cauldrons) do + if cauldron.x == x and cauldron.y == y then return cauldron end + end + return nil +end + +function Level:nodeAt(x, y) + for _, node in ipairs(self.nodes) do + if node.x == x and node.y == y then return node end + end + return nil +end + +function Level:corpseAt(x, y) + for _, corpse in ipairs(self.corpses) do + if corpse.x == x and corpse.y == y then return corpse end + end + return nil +end + +function Level:chestAt(x, y) + for _, chest in ipairs(self.chests) do + if chest.x == x and chest.y == y then return chest end + end + return nil +end + +function Level:captiveAt(x, y) + for _, captive in ipairs(self.captives) do + if captive.x == x and captive.y == y then return captive end + end + return nil +end + +function Level:vaultLeverAt(x, y) + for _, lever in ipairs(self.vaultLevers) do + if lever.x == x and lever.y == y then return lever end + end + return nil +end + +function Level:blockAt(x, y) + for _, block in ipairs(self.blocks) do + if block.x == x and block.y == y then return block end + end + return nil +end + +function Level:plateAt(x, y) + for _, plate in ipairs(self.plates) do + if plate.x == x and plate.y == y then return plate end + end + return nil +end + +function Level:gateAt(x, y) + for _, gate in ipairs(self.gates) do + if gate.x == x and gate.y == y then return gate end + end + return nil +end + +function Level:riftAt(x, y) + for _, rift in ipairs(self.rifts) do + if rift.x == x and rift.y == y then return rift end + end + return nil +end + function Level:itemsAt(x, y) local here = {} for _, ground in ipairs(self.floorItems) do @@ -79,6 +189,25 @@ function Level:toData() floorItems = self.floorItems, traps = self.traps, lights = self.lights, + hazards = self.hazards, + shrines = self.shrines, + questBoards = self.questBoards, + cauldrons = self.cauldrons, + nodes = self.nodes, + corpses = self.corpses, + chests = self.chests, + captives = self.captives, + vaultLevers = self.vaultLevers, + vaultDoors = self.vaultDoors, + secretDoors = self.secretDoors, + blocks = self.blocks, + plates = self.plates, + gates = self.gates, + prefabVaults = self.prefabVaults, + rifts = self.rifts, + weather = self.weather, + biome = self.biome, + event = self.event, dark = self.dark, explored = explored, upX = self.upX, upY = self.upY, @@ -103,6 +232,25 @@ function Level.fromData(snapshot) floorItems = snapshot.floorItems, traps = snapshot.traps, lights = snapshot.lights, + hazards = snapshot.hazards, + shrines = snapshot.shrines, + questBoards = snapshot.questBoards, + cauldrons = snapshot.cauldrons, + nodes = snapshot.nodes, + corpses = snapshot.corpses, + chests = snapshot.chests, + captives = snapshot.captives, + vaultLevers = snapshot.vaultLevers, + vaultDoors = snapshot.vaultDoors, + secretDoors = snapshot.secretDoors, + blocks = snapshot.blocks, + plates = snapshot.plates, + gates = snapshot.gates, + prefabVaults = snapshot.prefabVaults, + rifts = snapshot.rifts, + weather = snapshot.weather, + biome = snapshot.biome, + event = snapshot.event, dark = snapshot.dark, explored = explored, upX = snapshot.upX, upY = snapshot.upY, From f8ae5122a5f2e9cc1844fe3a88ba3f71248ce536 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 14:15:17 +0200 Subject: [PATCH 056/172] feat: add Mercenaries.hire and Mercenaries.dismiss --- src/mercenaries.lua | 191 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/mercenaries.lua diff --git a/src/mercenaries.lua b/src/mercenaries.lua new file mode 100644 index 0000000..b551222 --- /dev/null +++ b/src/mercenaries.lua @@ -0,0 +1,191 @@ +-- Paid allies hired from a shop's contract rather than bonded for free like +-- companions.lua's charm or spent raising reanimation.lua's thrall. A +-- hireling costs nothing further to take on - its hireCost is already paid +-- the instant the contract itself is bought from a shopkeeper (see +-- shop.lua's CONTRACT pricing) - but it keeps demanding a wage every +-- Mercenaries.WAGE_INTERVAL turns (Game:endPlayerTurn) for as long as it +-- lives. Gold covers it, the hireling's loyalty tops back up; gold falls +-- short, loyalty takes a hit instead, and a hireling whose loyalty bottoms +-- out deserts outright. A merely sulking hireling (loyalty still positive +-- but under Mercenaries.SULKING_THRESHOLD) keeps defending itself if a fight +-- finds it, but stops chasing distant prey or shadowing the player - the +-- one place loyalty reaches into combat, and it does so entirely inside +-- Mercenaries.act rather than through combat.lua or entity.lua, so hiring +-- one never risks a require cycle with either. +-- Only one hireling walks at a time, the same one-bond-at-a-time rule +-- companions.lua keeps for its own charm - but a hireling and a companion +-- may walk together, since they are bonded through entirely separate +-- currencies (gold contract vs. found charm) and tagged with distinct kinds. +-- Hirelings are plain entities on level.entities, exactly like companions +-- and thralls, so they serialize with no special save-format handling +-- beyond the two extra fields (wage, loyalty) entity.lua's toData whitelist +-- adds. + +local Entity = require("entity") +local Combat = require("combat") +local Los = require("los") +local Status = require("status") +local Noise = require("noise") + +local Mercenaries = {} + +Mercenaries.roster = { + { key = "sellsword", glyph = "w", name = "sellsword", hp = 24, attack = 6, defense = 2, speed = 100, + sight = 6, hireCost = 60, wage = 14 }, + { key = "skirmisher", glyph = "k", name = "skirmisher", hp = 15, attack = 5, defense = 1, speed = 115, + sight = 8, hireCost = 50, wage = 11 }, + { key = "medic", glyph = "m", name = "field medic", hp = 13, attack = 2, defense = 1, speed = 100, + sight = 6, hireCost = 70, wage = 16, heal = 3 }, +} + +Mercenaries.byKey = {} +Mercenaries.order = {} +for _, spec in ipairs(Mercenaries.roster) do + Mercenaries.byKey[spec.key] = spec + Mercenaries.order[#Mercenaries.order + 1] = spec.key +end + +Mercenaries.WAGE_INTERVAL = 25 +Mercenaries.START_LOYALTY = 100 +Mercenaries.MAX_LOYALTY = 100 +Mercenaries.LOYALTY_LOSS_UNPAID = 40 +Mercenaries.LOYALTY_GAIN_PAID = 15 +Mercenaries.SULKING_THRESHOLD = 30 + +local ADJACENT = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } } + +-- Takes `key` into service on a free cell next to the player, retiring any +-- earlier hireling first. Returns the new hireling, or nil for an unknown +-- roster key or when every cell around the player is blocked. +function Mercenaries.hire(game, key) + local spec = Mercenaries.byKey[key] + if not spec then return nil end + for _, other in ipairs(game.level.entities) do + if other.kind == "hireling" and other:isAlive() then other.hp = 0 end + end + game:cleanupDead() + local player = game.player + for _, dir in ipairs(ADJACENT) do + local x, y = player.x + dir[1], player.y + dir[2] + if game.level.map:isWalkable(x, y) and not game.level:entityAt(x, y) then + local hireling = Entity.new({ + id = game:nextMonsterId(), x = x, y = y, + glyph = spec.glyph, name = spec.name, kind = "hireling", + species = key, hp = spec.hp, attack = spec.attack, defense = spec.defense, speed = spec.speed, + }) + hireling.wage = spec.wage + hireling.loyalty = Mercenaries.START_LOYALTY + game.level.entities[#game.level.entities + 1] = hireling + return hireling + end + end + return nil +end + +-- Releases the live hireling (if any) with no penalty to either side. +-- Returns its name for the caller to log, or nil when nobody is under +-- contract. +function Mercenaries.dismiss(game) + for _, entity in ipairs(game.level.entities) do + if entity.kind == "hireling" and entity:isAlive() then + local name = entity.name + entity.hp = 0 + game:cleanupDead() + return name + end + end + return nil +end + +-- Charges the live hireling's wage, or shorts it: paid tops loyalty back up +-- (capped at MAX_LOYALTY), unpaid knocks it down by LOYALTY_LOSS_UNPAID and, +-- once it bottoms out, the hireling deserts outright - removed from the +-- level exactly like a voluntary dismiss, just for the opposite reason. +-- Returns a log line, or nil when no hireling is currently in the party to +-- bill. +function Mercenaries.payWage(game) + local hireling + for _, entity in ipairs(game.level.entities) do + if entity.kind == "hireling" and entity:isAlive() then hireling = entity end + end + if not hireling then return nil end + if game.player.gold >= hireling.wage then + game.player.gold = game.player.gold - hireling.wage + hireling.loyalty = math.min(Mercenaries.MAX_LOYALTY, hireling.loyalty + Mercenaries.LOYALTY_GAIN_PAID) + return "you pay your " .. hireling.name .. " " .. hireling.wage .. " gold" + end + hireling.loyalty = hireling.loyalty - Mercenaries.LOYALTY_LOSS_UNPAID + if hireling.loyalty <= 0 then + hireling.hp = 0 + game:cleanupDead() + return "your " .. hireling.name .. " deserts, unpaid and unwilling to go on" + end + return "your " .. hireling.name .. " grumbles, unpaid this floor" +end + +local function adjacent(a, b) + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) == 1 +end + +-- Nearest living monster the hireling can see within its species' sight +-- radius, ties broken by ascending id for determinism - the same shape +-- companions.lua's own nearestHostile follows, including its blind clamp +-- (Game:canSeePlayer's own melee-range fallback for a blinded monster). +local function nearestHostile(game, hireling) + local spec = Mercenaries.byKey[hireling.species] + local sight = spec.sight + if Status.has(hireling, Status.BLIND) then sight = math.min(sight, 1) end + local best, bestDist = nil, math.huge + for _, entity in ipairs(game.level.entities) do + if entity.kind == "monster" and entity:isAlive() then + local dist = math.max(math.abs(entity.x - hireling.x), math.abs(entity.y - hireling.y)) + if dist <= sight and Los.clear(game.level.map, hireling.x, hireling.y, entity.x, entity.y) then + if dist < bestDist or (dist == bestDist and entity.id < best.id) then + best, bestDist = entity, dist + end + end + end + end + return best +end + +-- A field medic mends its employer a little on any turn it does not spend +-- fighting or sulking - the only roster entry with a passive off-turn +-- effect, the same shape companions.lua's hearth sprite follows. +local function tendPlayer(game, hireling) + local spec = Mercenaries.byKey[hireling.species] + if spec.heal and game.player.hp < game.player.maxHp then + game.player.hp = math.min(game.player.maxHp, game.player.hp + spec.heal) + end +end + +-- The hireling's full turn: engage the nearest visible monster if one is in +-- reach regardless of loyalty, but once loyalty has sunk to +-- SULKING_THRESHOLD or below, stop there - no chasing a target it can see +-- but not yet reach, no shadowing the player, no tending. A loyal hireling +-- gets the full three-branch shape companions.act and Reanimation.act both +-- follow: engage, close in, or stay within a step of the player. +function Mercenaries.act(game, hireling) + if game:allyActsConfused(hireling) then return end + local target = nearestHostile(game, hireling) + if target and adjacent(hireling, target) then + local outcome = Combat.attack(hireling, target) + game.log:add(string.format("your %s hits %s for %d", hireling.name, target.name, outcome.damage)) + game:emitNoise(hireling.x, hireling.y, Noise.MELEE) + if outcome.killed then game:awardKill(target) end + return + end + if hireling.loyalty <= Mercenaries.SULKING_THRESHOLD then + return + end + if target then + game:stepMonsterToward(hireling, target.x, target.y) + return + end + tendPlayer(game, hireling) + if not adjacent(hireling, game.player) and not (hireling.x == game.player.x and hireling.y == game.player.y) then + game:stepMonsterToward(hireling, game.player.x, game.player.y) + end +end + +return Mercenaries From 06714fc72a2155c642b9c95ba22204504f15eb51 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 14:16:08 +0200 Subject: [PATCH 057/172] feat: add Meta.new and Meta.load --- src/meta.lua | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/meta.lua diff --git a/src/meta.lua b/src/meta.lua new file mode 100644 index 0000000..2883235 --- /dev/null +++ b/src/meta.lua @@ -0,0 +1,200 @@ +-- Progression that survives a single run: a "renown" currency banked when a +-- run ends (in death or victory), scored from that run's own tally, plus +-- achievements unlocked once a run's tally crosses a fixed threshold and +-- permanent perks bought with banked renown. Meta state lives in its own +-- save file (see warren.lua's --meta flag), entirely separate from a +-- mid-run save (serialize.lua/game.lua toData) - a run ending in death still +-- destroys that run's save, but its renown and any newly met achievement +-- persist into the next one. +-- +-- A perk is applied once, in Game.new, before options.class runs (see +-- Meta.applyAll's caller) so a class delta always lands on top of whatever a +-- perk already raised - the same ordering classes.lua itself relies on for +-- its own one-time deltas. + +local SaveGame = require("savegame") +local Ascension = require("ascension") +local Streaks = require("streaks") + +local Meta = {} + +-- Real feature history, oldest first: graves and the leaderboard arrived +-- after the original renown/achievements/perks core, and runHistory (for +-- Streaks) after that - so a meta file written before either feature landed +-- is missing those fields, not just hypothetically but in practice for any +-- save from before this module tracked them. +local MetaSave = SaveGame.new({ + [1] = function(data) + data.graves = data.graves or {} + data.leaderboard = data.leaderboard or {} + return data + end, + [2] = function(data) + data.runHistory = data.runHistory or {} + return data + end, +}) + +Meta.PERKS = { + { key = "vigor", name = "Vigor", cost = 30, apply = function(player) + player.maxHp = player.maxHp + 5 + player.hp = player.hp + 5 + end }, + { key = "purse", name = "Deep Pockets", cost = 20, apply = function(player) + player.gold = player.gold + 25 + end }, + { key = "focus", name = "Arcane Focus", cost = 25, apply = function(player) + player.maxMana = player.maxMana + 5 + player.mana = player.mana + 5 + end }, + { key = "keeneye", name = "Keen Eye", cost = 40, apply = function(player) + player.baseAttack = player.baseAttack + 1 + player.attack = player.attack + 1 + end }, + { key = "tough", name = "Thick Hide", cost = 40, apply = function(player) + player.baseDefense = player.baseDefense + 1 + player.defense = player.defense + 1 + end }, + { key = "lightbearer", name = "Lightbearer", cost = 25, apply = function(player) + player.light.fuel = player.light.fuel + 200 + player.light.radius = player.light.radius + 1 + end }, +} + +Meta.byKey = {} +for _, def in ipairs(Meta.PERKS) do + Meta.byKey[def.key] = def +end + +-- Each condition reads a run's own metaStats snapshot (see Game:metaStats): +-- deepestDepth, kills, bossKills, questsCompleted, artifactsFound, +-- vaultsOpened, setPiecesFound, chestsOpened, won - plus, for the rare +-- condition that needs more than a single run's own tally can offer, the +-- persistent meta state itself, already updated with this very run's own +-- outcome by the time Meta.recordRun evaluates any of these. +Meta.ACHIEVEMENTS = { + { key = "first_blood", name = "First Blood", condition = function(stats) return stats.kills >= 1 end }, + { key = "boss_slayer", name = "Boss Slayer", condition = function(stats) return stats.bossKills >= 1 end }, + { key = "deep_delver", name = "Deep Delver", condition = function(stats) return stats.deepestDepth >= 5 end }, + { key = "bounty_hunter", name = "Bounty Hunter", condition = function(stats) return stats.questsCompleted >= 3 end }, + { key = "artificer", name = "Artificer", condition = function(stats) return stats.artifactsFound >= 1 end }, + { key = "vault_breaker", name = "Vault Breaker", condition = function(stats) return (stats.vaultsOpened or 0) >= 1 end }, + { key = "set_collector", name = "Set Collector", condition = function(stats) return (stats.setPiecesFound or 0) >= 3 end }, + { key = "amulet_bearer", name = "Amulet Bearer", condition = function(stats) return stats.won end }, + { key = "hoarder", name = "Hoarder", condition = function(stats) return (stats.chestsOpened or 0) >= 3 end }, + -- Needs meta.runHistory (see streaks.lua), already carrying this very run + -- as its newest entry - a win right after a loss, not just any win. + { key = "comeback", name = "Comeback", condition = function(stats, meta) + local h = meta.runHistory + return stats.won == true and #h >= 2 and h[#h] == true and h[#h - 1] == false + end }, +} + +function Meta.new() + return { + renown = 0, totalRuns = 0, wins = 0, bestDepth = 0, + achievements = {}, perks = {}, graves = {}, leaderboard = {}, runHistory = {}, + } +end + +-- Loads meta state from `path`, or a fresh state if the file does not exist +-- yet (a player's first run). A file that exists but fails to parse or fails +-- its checksum is corruption, not a first run - that gets a warning on +-- stderr rather than silently discarding banked renown and achievements, but +-- still hands back a fresh state so the caller always has something to play +-- with. Fields missing on an older-version save default in via MetaSave's +-- migration chain; fields missing despite matching CURRENT_VERSION (a +-- hand-edited or partially-written file that still happens to parse and +-- checksum) default in here as a second line of defense. +function Meta.load(path) + local data, err = MetaSave.load(path) + if not data then + if err ~= "missing" then + io.stderr:write("warning: meta file " .. path .. " is " .. err .. "; starting fresh\n") + end + return Meta.new() + end + -- graves, leaderboard and runHistory are not re-defaulted here: MetaSave's + -- migration chain above already guarantees them on anything that reaches + -- this point. The base fields below predate versioning itself (there is no + -- earlier step that could have filled them), so they keep their own + -- fallback against a hand-edited or partially-written file that still + -- parses and checksums. + data.renown = data.renown or 0 + data.totalRuns = data.totalRuns or 0 + data.wins = data.wins or 0 + data.bestDepth = data.bestDepth or 0 + data.achievements = data.achievements or {} + data.perks = data.perks or {} + return data +end + +function Meta.save(meta, path) + MetaSave.save(path, meta) +end + +-- Renown a single completed run banks, from its own tally alone: depth +-- reached, bosses felled, bounties resolved, vaults cracked open, set +-- pieces gathered, chests cracked open, and - far above any of those - +-- escaping with the Amulet. Any ascension.lua mutator active for the run +-- (stats.mutators, see Game:metaStats) then tops that base score up by its +-- own renown bonus percentage, rewarding the extra risk taken on. +function Meta.renownFor(stats) + local total = stats.deepestDepth * 4 + stats.bossKills * 15 + stats.questsCompleted * 8 + + (stats.vaultsOpened or 0) * 10 + (stats.setPiecesFound or 0) * 3 + (stats.chestsOpened or 0) * 2 + if stats.won then total = total + 100 end + local bonusPercent = Ascension.renownBonusPercent(stats.mutators) + if bonusPercent > 0 then + total = total + (total * bonusPercent) // 100 + end + return total +end + +-- Folds a finished run's stats into the persistent meta state: banks its +-- renown, advances the run/win/best-depth tallies, and unlocks any +-- achievement whose condition the run's tally satisfies for the first time. +-- Returns the newly unlocked achievement names, in catalog order, so a +-- caller (warren.lua) can announce them; an achievement already owned from an +-- earlier run is silently skipped rather than re-announced. +function Meta.recordRun(meta, stats) + meta.totalRuns = meta.totalRuns + 1 + if stats.won then meta.wins = meta.wins + 1 end + if stats.deepestDepth > meta.bestDepth then meta.bestDepth = stats.deepestDepth end + meta.renown = meta.renown + Meta.renownFor(stats) + Streaks.record(meta.runHistory, stats.won == true) + local unlocked = {} + for _, ach in ipairs(Meta.ACHIEVEMENTS) do + if not meta.achievements[ach.key] and ach.condition(stats, meta) then + meta.achievements[ach.key] = true + unlocked[#unlocked + 1] = ach.name + end + end + return unlocked +end + +function Meta.isOwned(meta, key) + return meta.perks[key] == true +end + +-- Spends renown to permanently unlock perk `key`. Fails without spending +-- anything given an unknown key, a perk already owned, or too little banked +-- renown; returns (false, reason) in each case. +function Meta.purchase(meta, key) + local def = Meta.byKey[key] + if not def then return false, "no such perk" end + if meta.perks[key] then return false, "already owned" end + if meta.renown < def.cost then return false, "not enough renown" end + meta.renown = meta.renown - def.cost + meta.perks[key] = true + return true, "unlocked " .. def.name +end + +-- Applies every perk `meta` already owns to a freshly created player, in +-- catalog order. Meant to run once in Game.new, before any class delta. +function Meta.applyAll(meta, player) + for _, def in ipairs(Meta.PERKS) do + if meta.perks[def.key] then def.apply(player) end + end +end + +return Meta From 6a26f1f65efd331556b7a8439281dd432aaf89f5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 14:30:20 +0200 Subject: [PATCH 058/172] feat: extend mimics with Mimics.floorItem and Mimics.reveal --- src/mimics.lua | 60 ++++++++++++++++++++++++++++++++++++++++ src/momentum.lua | 39 ++++++++++++++++++++++++++ src/monsters.lua | 72 ++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 src/mimics.lua create mode 100644 src/momentum.lua diff --git a/src/mimics.lua b/src/mimics.lua new file mode 100644 index 0000000..32a208e --- /dev/null +++ b/src/mimics.lua @@ -0,0 +1,60 @@ +-- A mimic disguises itself as ordinary loot until a hand reaches for it. +-- Builder.build drops one as a plain floorItem tagged `mimic = true`, +-- wearing a random disguise's name and glyph (Mimics.DISGUISES) - utterly +-- indistinguishable at rest from a real item, since render.lua and +-- Game:pickupHere both just read an ordinary floorItem's own `.item.glyph` +-- and `.item.name`, with no idea a given tile might be a trap. The instant +-- the player steps onto it (Game:triggerMimic, called from pickupHere), +-- Mimics.reveal builds the real hostile monster underneath, scaled by +-- depth the same way Monsters.create scales a wild species's hp, and gets +-- one free ambush swing in (via combat.lua, so it still respects the +-- player's defense and resistances) before it settles into an ordinary +-- chase like any other monster on its own turns from then on. + +local Entity = require("entity") +local Factions = require("factions") + +local Mimics = {} + +Mimics.FACTION = Factions.BEAST + +Mimics.DISGUISES = { + { name = "a weathered chest", glyph = "=" }, + { name = "a glinting pile of gold", glyph = "$" }, + { name = "an ornate tome", glyph = "&" }, + { name = "a discarded shield", glyph = "[" }, +} + +-- The inert floorItem a builder drops: it looks exactly like whichever +-- disguise it rolled until Game:pickupHere unmasks it. +function Mimics.floorItem(rng) + local disguise = Mimics.DISGUISES[rng:range(1, #Mimics.DISGUISES)] + return { kind = "mimic", mimic = true, name = disguise.name, glyph = disguise.glyph } +end + +-- The hostile monster a disguised item becomes the instant it is +-- disturbed, its hp scaled by depth exactly the way Monsters.create scales +-- a wild species's own base hp. +function Mimics.reveal(id, x, y, depth) + local bonusHp = depth - 1 + local entity = Entity.new({ + id = id, x = x, y = y, + glyph = "M", name = "mimic", kind = "monster", + species = "mimic", ai = "chase", + hp = 12 + bonusHp, maxHp = 12 + bonusHp, + attack = 5, defense = 1, speed = 100, + }) + entity.cooldown = 0 + entity.faction = Mimics.FACTION + entity.mimic = true + return entity +end + +-- Flat xp reward for felling a revealed mimic - kept separate from +-- Monsters.xpFor since a mimic is never a member of the wild bestiary +-- Monsters.forDepth draws its ordinary per-floor population from. +function Mimics.xpFor() + return 10 +end + +return Mimics diff --git a/src/momentum.lua b/src/momentum.lua new file mode 100644 index 0000000..72cc551 --- /dev/null +++ b/src/momentum.lua @@ -0,0 +1,39 @@ +-- A player-only combo meter that rewards sustained melee aggression: every +-- landed melee swing raises the stack by one, up to a hard cap, and each +-- stack adds a flat attack bonus that reads live through +-- Entity:effectiveAttack - the very next swing already hits harder, not +-- just some future one. Taking a hit from a monster's claw, breath or +-- ambush snaps the whole stack back to zero outright - unlike a status +-- effect's timed decay, there is no partial forgiveness, so holding a combo +-- against several foes at once is a far harder rhythm than a lone duel. An +-- evaded attack (see skills.lua) never reaches Combat.attack at all, so it +-- never breaks the stack either - only a blow that actually lands does. +-- Ranged shots, thrown items and environmental damage (traps, poison, +-- lava) neither build nor break it: momentum is specifically a melee-vs- +-- monster reward, not a universal combat buff. + +local Momentum = {} + +Momentum.MAX_STACKS = 5 +Momentum.BONUS_PER_STACK = 1 + +function Momentum.stacks(entity) + return entity.combo or 0 +end + +-- Raises `entity`'s stack by one landed melee hit, capped at MAX_STACKS. +function Momentum.onMeleeHit(entity) + entity.combo = math.min(Momentum.MAX_STACKS, Momentum.stacks(entity) + 1) +end + +-- Snaps the stack back to zero the instant `entity` takes a hit. +function Momentum.onDamaged(entity) + entity.combo = 0 +end + +-- The flat attack bonus `entity`'s current stack grants. +function Momentum.attackBonus(entity) + return Momentum.stacks(entity) * Momentum.BONUS_PER_STACK +end + +return Momentum diff --git a/src/monsters.lua b/src/monsters.lua index b6c9a7f..976359e 100644 --- a/src/monsters.lua +++ b/src/monsters.lua @@ -4,20 +4,41 @@ -- depth so lower floors bite harder. local Entity = require("entity") +local Factions = require("factions") +local Disease = require("disease") local Monsters = {} Monsters.species = { - { key = "rat", glyph = "r", name = "rat", hp = 4, attack = 2, defense = 0, speed = 110, ai = "chase", minDepth = 1 }, - { key = "bat", glyph = "v", name = "cave bat", hp = 3, attack = 2, defense = 0, speed = 150, ai = "erratic", minDepth = 1 }, - { key = "kobold", glyph = "k", name = "kobold", hp = 6, attack = 3, defense = 1, speed = 100, ai = "pack", minDepth = 1 }, - { key = "goblin", glyph = "g", name = "goblin", hp = 5, attack = 3, defense = 0, speed = 110, ai = "pack", minDepth = 2 }, - { key = "archer", glyph = "a", name = "skeleton archer", hp = 6, attack = 3, defense = 1, speed = 100, ai = "ranged", minDepth = 2, range = 5 }, - { key = "orc", glyph = "o", name = "orc", hp = 10, attack = 4, defense = 1, speed = 100, ai = "chase", minDepth = 3 }, - { key = "wretch", glyph = "w", name = "cave wretch", hp = 8, attack = 2, defense = 0, speed = 120, ai = "flee", minDepth = 3 }, - { key = "troll", glyph = "T", name = "troll", hp = 16, attack = 6, defense = 2, speed = 80, ai = "chase", minDepth = 4 }, - { key = "mage", glyph = "m", name = "dark mage", hp = 8, attack = 2, defense = 0, speed = 100, ai = "ranged", minDepth = 5, range = 6 }, - { key = "dragon", glyph = "D", name = "young dragon", hp = 30, attack = 8, defense = 3, speed = 90, ai = "ranged", minDepth = 6, range = 4 }, + { key = "rat", glyph = "r", name = "rat", hp = 4, attack = 2, defense = 0, speed = 110, ai = "chase", minDepth = 1, xp = 3, faction = Factions.VERMIN, disease = Disease.PARASITES }, + { key = "bat", glyph = "v", name = "cave bat", hp = 3, attack = 2, defense = 0, speed = 150, ai = "erratic", minDepth = 1, xp = 3, faction = Factions.VERMIN }, + { key = "kobold", glyph = "k", name = "kobold", hp = 6, attack = 3, defense = 1, speed = 100, ai = "pack", minDepth = 1, xp = 5, faction = Factions.GOBLINOID, disease = Disease.PLAGUE }, + { key = "goblin", glyph = "g", name = "goblin", hp = 5, attack = 3, defense = 0, speed = 110, ai = "pack", minDepth = 2, xp = 5, faction = Factions.GOBLINOID }, + { key = "archer", glyph = "a", name = "skeleton archer", hp = 6, attack = 3, defense = 1, speed = 100, ai = "ranged", minDepth = 2, range = 5, xp = 6, faction = Factions.UNDEAD }, + { key = "orc", glyph = "o", name = "orc", hp = 10, attack = 4, defense = 1, speed = 100, ai = "chase", minDepth = 3, xp = 9, faction = Factions.ORCISH }, + { key = "wretch", glyph = "w", name = "cave wretch", hp = 8, attack = 2, defense = 0, speed = 120, ai = "flee", minDepth = 3, xp = 6, onDeath = "spores", faction = Factions.BEAST }, + { key = "troll", glyph = "T", name = "troll", hp = 16, attack = 6, defense = 2, speed = 80, ai = "chase", minDepth = 4, xp = 15, regen = 3, resist = { fire = "weak" }, faction = Factions.BEAST }, + { key = "mage", glyph = "m", name = "dark mage", hp = 8, attack = 2, defense = 0, speed = 100, ai = "ranged", minDepth = 5, range = 6, xp = 12, faction = Factions.CULT }, + { key = "dragon", glyph = "D", name = "young dragon", hp = 30, attack = 8, defense = 3, speed = 90, ai = "ranged", minDepth = 6, range = 4, xp = 25, breath = "fire", resist = { fire = "immune", cold = "weak" }, faction = Factions.DRAKE }, + + -- Ability bearers: each carries an `ability` tag resolved by abilities.lua's + -- tryAct dispatch, which runs before the plain ai.lua behaviour listed here + -- and only falls through to it when the ability is on cooldown or has no + -- valid target. minDepth is kept at 4+ so these never appear on the shallow + -- floors exercised by the fixed-maxDepth win-condition tests. + { key = "zombie", glyph = "z", name = "shambling zombie", hp = 7, attack = 3, defense = 1, speed = 90, ai = "chase", minDepth = 4, xp = 5, faction = Factions.UNDEAD, disease = Disease.ROT }, + { key = "slime", glyph = "j", name = "gelatinous slime", hp = 10, attack = 2, defense = 0, speed = 90, ai = "chase", minDepth = 4, xp = 5, ability = "split", faction = Factions.BEAST }, + { key = "footpad", glyph = "f", name = "footpad", hp = 6, attack = 2, defense = 0, speed = 120, ai = "chase", minDepth = 4, xp = 6, ability = "steal", faction = Factions.BANDIT }, + { key = "shaman", glyph = "s", name = "goblin shaman", hp = 7, attack = 2, defense = 0, speed = 100, ai = "flee", minDepth = 4, xp = 9, ability = "curse", faction = Factions.GOBLINOID }, + { key = "necromancer", glyph = "n", name = "necromancer", hp = 9, attack = 2, defense = 0, speed = 95, ai = "flee", minDepth = 4, xp = 10, ability = "summon", summonSpecies = "zombie", faction = Factions.CULT }, + { key = "cleric", glyph = "c", name = "dark cleric", hp = 8, attack = 2, defense = 1, speed = 100, ai = "pack", minDepth = 5, xp = 9, ability = "heal", faction = Factions.CULT }, + { key = "chieftain", glyph = "C", name = "orc chieftain", hp = 14, attack = 5, defense = 2, speed = 100, ai = "pack", minDepth = 6, xp = 14, ability = "rally", faction = Factions.GOBLINOID }, + { key = "banshee", glyph = "b", name = "banshee", hp = 7, attack = 2, defense = 0, speed = 100, ai = "flee", minDepth = 5, xp = 11, ability = "shriek", faction = Factions.UNDEAD }, + + -- Shopkeeper: a passive vendor guarding priced wares (see shop.lua). Its + -- "vend" behaviour idles until game.lua flags it hostile after a theft, at + -- which point it falls back to a plain chase like any other monster. + { key = "shopkeeper", glyph = "h", name = "shopkeeper", hp = 8, attack = 3, defense = 1, speed = 100, ai = "vend", minDepth = 4, xp = 7, faction = Factions.MERCHANT }, } Monsters.byKey = {} @@ -36,9 +57,28 @@ function Monsters.forDepth(depth) return eligible end +-- Xp reward for felling an entity, looked up by the species key stamped onto +-- it at creation. Falls back to a flat amount for anything without one. +function Monsters.xpFor(entity) + local spec = Monsters.byKey[entity.species] + return spec and spec.xp or 4 +end + +-- A fresh copy of a species' innate resist table, never the catalog's own +-- object: elites.lua's "warded" affix mutates entity.resist in place once +-- rolled, and a shared reference there would leak that bump onto every +-- other spawn of the same species for the rest of the process, not just +-- the one entity actually promoted. +local function copyResist(resist) + if not resist then return nil end + local copy = {} + for damageType, tier in pairs(resist) do copy[damageType] = tier end + return copy +end + function Monsters.create(spec, id, x, y, depth) local bonusHp = depth - 1 - return Entity.new({ + local entity = Entity.new({ id = id, x = x, y = y, glyph = spec.glyph, name = spec.name, kind = "monster", @@ -47,6 +87,16 @@ function Monsters.create(spec, id, x, y, depth) attack = spec.attack, defense = spec.defense, speed = spec.speed, range = spec.range or 0, }) + entity.regen = spec.regen + entity.onDeath = spec.onDeath + entity.breath = spec.breath + entity.ability = spec.ability + entity.summonSpecies = spec.summonSpecies + entity.cooldown = 0 + entity.resist = copyResist(spec.resist) + entity.faction = spec.faction + entity.biteDisease = spec.disease + return entity end return Monsters From 9eec4709bf396f53e6fcbd91b07ce13d5f9d9b77 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 14:54:17 +0200 Subject: [PATCH 059/172] feat: add Morgue.score and Morgue.equipment --- src/morgue.lua | 132 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/morgue.lua diff --git a/src/morgue.lua b/src/morgue.lua new file mode 100644 index 0000000..aa7adce --- /dev/null +++ b/src/morgue.lua @@ -0,0 +1,132 @@ +-- End-of-run obituary. Turns a finished game's own stats, gear and message +-- log into a NetHack-style morgue report plus a single comparable score, +-- reusing exactly the field names Game:metaStats/graveEntry already expose +-- so a future stat only has to land there to show up here too. + +local Curses = require("curses") +local Compendium = require("compendium") +local Titles = require("titles") + +local Morgue = {} + +Morgue.SCORE_WEIGHTS = { + depth = 100, kill = 10, bossKill = 250, quest = 150, artifact = 200, + vault = 120, setPiece = 180, chest = 15, playerLevel = 50, win = 1000, +} + +-- A single deterministic number from a Morgue.build report, so two runs +-- (or a run against its own best-so-far) can be ranked without re-reading +-- every field by hand. +function Morgue.score(report) + local w = Morgue.SCORE_WEIGHTS + local total = report.deepestDepth * w.depth + report.kills * w.kill + + report.bossKills * w.bossKill + report.questsCompleted * w.quest + + report.artifactsFound * w.artifact + report.vaultsOpened * w.vault + + report.setPiecesFound * w.setPiece + report.chestsOpened * w.chest + + report.playerLevel * w.playerLevel + report.gold + if report.won then total = total + w.win end + return total +end + +Morgue.EQUIP_SLOTS = { "weapon", "armor", "ring", "bow" } + +-- One line per worn slot, empty-handed slots omitted outright rather than +-- padded with "(nothing)" - an obituary should read like a eulogy, not a +-- character sheet. +function Morgue.equipment(player) + local lines = {} + for _, slot in ipairs(Morgue.EQUIP_SLOTS) do + local item = player[slot] + if item then + lines[#lines + 1] = slot .. ": " .. Curses.label(item, item.name) + end + end + return lines +end + +-- Every species this compendium has ever seen the player slay, heaviest +-- kill count first, ties broken by catalog order (Compendium.entries' own +-- fixed order) rather than name so the listing never depends on whatever +-- order table.sort happens to leave equal keys in. +function Morgue.killTally(compendium) + local rows = {} + for i, entry in ipairs(Compendium.entries(compendium)) do + if entry.kills > 0 then + rows[#rows + 1] = { key = entry.key, name = entry.name, kills = entry.kills, order = i } + end + end + table.sort(rows, function(a, b) + if a.kills ~= b.kills then return a.kills > b.kills end + return a.order < b.order + end) + return rows +end + +-- Gathers everything Morgue.format needs straight off a live or just-ended +-- game, mirroring Game:metaStats' own field names so the two never drift. +-- `meta` is optional (see warren.lua's --meta flag) - a meta-free run's +-- report simply carries no title, exactly like it banks no renown. +function Morgue.build(game, meta) + return { + won = game.won, + alive = game.player:isAlive(), + className = game.player.class or "wanderer", + title = meta and Titles.forRenown(meta.renown) or nil, + playerLevel = game.player.level, + deepestDepth = game.deepestDepth, + turn = game.turn, + gold = game.player.gold, + essence = game.player.essence, + kills = game.stats.kills, + bossKills = game.stats.bossKills, + questsCompleted = game.stats.questsCompleted, + artifactsFound = game.stats.artifactsFound, + vaultsOpened = game.stats.vaultsOpened, + setPiecesFound = game.stats.setPiecesFound, + chestsOpened = game.stats.chestsOpened, + equipment = Morgue.equipment(game.player), + killTally = Morgue.killTally(game.compendium), + finalMessages = game.log:recent(5), + } +end + +local function outcomeLine(report) + if report.won then return "escaped the warren with the Amulet" end + if report.alive then return "still drawing breath, for now" end + return "died in the dark" +end + +-- Renders `report` (see Morgue.build) as a fixed-shape text obituary: +-- outcome, vitals, gear, kills, final words, score - always in that order +-- so two runs diff cleanly against each other. +function Morgue.format(report) + local lines = {} + local heading = report.alive and (report.won and "Victorious" or "Wandering") or "Late" + if report.title then + lines[#lines + 1] = string.format("%s the %s, the %s", heading, report.className, report.title) + else + lines[#lines + 1] = string.format("%s the %s", heading, report.className) + end + lines[#lines + 1] = outcomeLine(report) + lines[#lines + 1] = string.format("depth %d, turn %d, level %d", report.deepestDepth, report.turn, + report.playerLevel) + lines[#lines + 1] = string.format("gold %d, essence %d", report.gold, report.essence) + if #report.equipment > 0 then + lines[#lines + 1] = "equipment:" + for _, line in ipairs(report.equipment) do lines[#lines + 1] = " " .. line end + end + if #report.killTally > 0 then + lines[#lines + 1] = "kills:" + for _, row in ipairs(report.killTally) do + lines[#lines + 1] = string.format(" %-14s x%d", row.name, row.kills) + end + end + if #report.finalMessages > 0 then + lines[#lines + 1] = "final moments:" + for _, line in ipairs(report.finalMessages) do lines[#lines + 1] = " " .. line end + end + lines[#lines + 1] = string.format("score: %d", Morgue.score(report)) + return table.concat(lines, "\n") .. "\n" +end + +return Morgue From dfa4ef9bbc9e39e67660f51ff31f689f8eb96aeb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 15:12:32 +0200 Subject: [PATCH 060/172] feat: add Nemesis.isEligible and chebyshev --- src/nemesis.lua | 155 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/nemesis.lua diff --git a/src/nemesis.lua b/src/nemesis.lua new file mode 100644 index 0000000..77950b3 --- /dev/null +++ b/src/nemesis.lua @@ -0,0 +1,155 @@ +-- A cross-floor hunter. Every other hostile in the bestiary is confined to +-- the one floor builder.lua drops it on (level.lua's own header: "revisiting +-- one restores its exact state, dead monsters and all") - a nemesis is the +-- single exception. Any ordinary monster actually hunting the player the +-- moment they take a stairwell has a chance to follow them through it +-- instead of being left behind; from then on it keeps following, growing +-- stronger each trip, until the player either kills it or dies first. +-- +-- Unlike elites.lua's affixed variant (rolled stronger the instant builder.lua +-- places it) or bosses.lua's fixed per-floor set piece, a nemesis is born +-- mid-run from a plain wild monster and its escalation is entirely +-- player-earned: it does not exist at all until a hunt actually happens, and +-- it only ever grows because the player failed to finish it off before +-- reaching the stairs. Bosses, mimics, graves, elites and the shopkeeper are +-- all excluded from ever becoming one - each already has its own dedicated +-- escalation path, and letting them double up would just be two systems +-- fighting over the same monster. + +local Monsters = require("monsters") +local Stealth = require("stealth") + +local Nemesis = {} + +-- Odds a hunting monster actually gives chase down the stairs, rolled once +-- per descent (Game:nemesisTryEmerge) only while no nemesis is following yet. +Nemesis.CHANCE_NUMERATOR = 1 +Nemesis.CHANCE_DENOMINATOR = 5 + +Nemesis.MAX_GRUDGE = 5 +Nemesis.HP_BONUS_PER_GRUDGE = 4 +Nemesis.ATTACK_BONUS_PER_GRUDGE = 1 + +-- How much of its lost hp a nemesis recovers between the floor it last fought +-- on and the one it just followed the player onto - capped at full, so a +-- nemesis engaged over and over never shows up weaker than this floor alone +-- would explain, but also never becomes unkillable simply by being ignored. +Nemesis.HEAL_FRACTION_PER_FOLLOW = 0.2 + +-- One epithet per grudge tier, read by index and clamped to the last entry +-- once grudge outgrows the list - the name keeps escalating in flavour even +-- after Nemesis.MAX_GRUDGE stops raising its stats any further. +Nemesis.EPITHETS = { + "the Wounded", "the Relentless", "the Scarred", "the Undying", "the Dreaded", +} + +-- ai tags eligible to actually give chase: a permanent "flee" species (the +-- cave wretch, the goblin shaman, the necromancer) breaks off the instant it +-- is adjacent so it would never follow anyone down a stairwell, and the +-- shopkeeper's "vend" idles unless provoked into a plain chase of its own - +-- by then it is already just a chaser under a different tag, still excluded +-- here because Nemesis.isEligible also checks awareness below. +Nemesis.ELIGIBLE_AI = { chase = true, pack = true, ranged = true, erratic = true, tunnel = true } + +-- Whether `monster` could give chase this descent: alive, a plain hostile +-- under its own self-directed AI, already actively hunting the player (not +-- dormant, not searching, not merely wandering into the stairwell by +-- coincidence), and not already spoken for by another escalation system. +function Nemesis.isEligible(monster) + return monster:isAlive() + and monster.kind == "monster" + and Nemesis.ELIGIBLE_AI[monster.ai] == true + and monster.awareness == Stealth.HUNTING + and not monster.isBoss + and not monster.mimic + and not monster.grave + and not monster.elite + and not monster.nemesis +end + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +-- The eligible hunter standing closest to the player, ties broken by +-- level.entities order - deterministic without spending any rng draw of its +-- own, since only whether a nemesis emerges at all needs to be a coin flip. +function Nemesis.pickHunter(level, player) + local best, bestDist + for _, monster in ipairs(level.entities) do + if Nemesis.isEligible(monster) then + local dist = chebyshev(monster.x, monster.y, player.x, player.y) + if not best or dist < bestDist then + best, bestDist = monster, dist + end + end + end + return best +end + +-- The persistent descriptor Game keeps between floors (see game.nemesis) - +-- not a live entity, just enough to rebuild one, exactly like graves.lua +-- stores a fallen player's own remnants instead of keeping a live shade +-- around between runs. +function Nemesis.beginFrom(monster) + return { + species = monster.species, + grudge = 1, + hpFraction = monster.hp / monster.maxHp, + } +end + +-- Called every time the nemesis follows the player through a stairwell +-- without having been killed first: its grudge climbs (capped) and it stews +-- back toward full health (also capped), so each escape makes the next +-- encounter both tougher and a little less finished business than before. +function Nemesis.escalate(pursuit) + pursuit.grudge = math.min(Nemesis.MAX_GRUDGE, pursuit.grudge + 1) + pursuit.hpFraction = math.min(1, pursuit.hpFraction + Nemesis.HEAL_FRACTION_PER_FOLLOW) + return pursuit +end + +function Nemesis.bonusHp(grudge) + return grudge * Nemesis.HP_BONUS_PER_GRUDGE +end + +function Nemesis.bonusAttack(grudge) + return grudge * Nemesis.ATTACK_BONUS_PER_GRUDGE +end + +-- Flat xp on top of the species' own plain reward (Monsters.xpFor already +-- applies, since Nemesis.spawn stamps a real species key onto the entity) - +-- mirrors elites.lua's xpBonus riding on top of the same base reward. +function Nemesis.xpBonus(grudge) + return 10 + grudge * 5 +end + +-- Gold the nemesis's hoard finally pays out once it is actually put down, +-- credited alongside (not instead of) the ordinary awardKill flow. +function Nemesis.bountyGold(grudge) + return 10 * grudge +end + +function Nemesis.nameFor(baseName, grudge) + local index = math.min(grudge, #Nemesis.EPITHETS) + return baseName .. ", " .. Nemesis.EPITHETS[index] +end + +-- Rebuilds a live entity from `pursuit` at (x, y) on the floor just reached, +-- depth-scaled exactly like any other freshly created monster of the same +-- species plus its own escalated bonuses. hpFraction survives the trip so a +-- nemesis last seen half-dead still shows up half-dead here, healed only by +-- however much Nemesis.escalate already restored between floors. +function Nemesis.spawn(pursuit, id, x, y, depth) + local spec = Monsters.byKey[pursuit.species] + local entity = Monsters.create(spec, id, x, y, depth) + entity.maxHp = entity.maxHp + Nemesis.bonusHp(pursuit.grudge) + entity.hp = math.max(1, math.floor(entity.maxHp * pursuit.hpFraction)) + entity.attack = entity.attack + Nemesis.bonusAttack(pursuit.grudge) + entity.nemesis = true + entity.nemesisGrudge = pursuit.grudge + entity.name = Nemesis.nameFor(spec.name, pursuit.grudge) + return entity +end + +return Nemesis From a10eb10d22bde0d8df32b1a35849107ea664cb33 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:22:25 +0200 Subject: [PATCH 061/172] feat: add Nodes.new, Nodes.isFallow and Noise.propagate --- src/nodes.lua | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/noise.lua | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/nodes.lua create mode 100644 src/noise.lua diff --git a/src/nodes.lua b/src/nodes.lua new file mode 100644 index 0000000..1a43db0 --- /dev/null +++ b/src/nodes.lua @@ -0,0 +1,75 @@ +-- Renewable resource nodes scattered on the map: an ore vein pays out +-- crafting essence (crafting.lua's forging currency) and a gem seam yields a +-- raw tier-1 gem outright, both without spending a single turn in combat - +-- a genuine second income alongside killing monsters (Crafting.essenceFor), +-- not a strictly better replacement for it. Each node holds a fixed number +-- of charges before it plays out; a played-out node does not vanish, it goes +-- fallow and slowly reopens on its own, so a floor already cleared of +-- monsters still rewards a return visit. Nodes are plain data on +-- level.nodes, exactly like level.shrines and level.cauldrons, so they +-- serialize with no special save-format handling. + +local Crafting = require("crafting") + +local Nodes = {} + +Nodes.ORE = "ore" +Nodes.GEM = "gem" + +Nodes.glyphs = { [Nodes.ORE] = "O", [Nodes.GEM] = "M" } + +Nodes.MAX_CHARGES = 3 +Nodes.REGEN_TURNS = 40 +Nodes.ORE_YIELD_MIN = 3 +Nodes.ORE_YIELD_MAX = 7 + +-- A freshly placed node at full charges, never on cooldown. Builder stamps +-- x/y directly onto the returned table, matching how it stamps every other +-- floor feature (shrines, cauldrons, vault levers). +function Nodes.new(kind, element) + return { kind = kind, element = element, charges = Nodes.MAX_CHARGES, cooldown = 0 } +end + +function Nodes.isFallow(node) + return node.charges <= 0 +end + +function Nodes.glyph(node) + return Nodes.glyphs[node.kind] +end + +-- Extracts one charge's worth of resource from `node` into `game.player`. +-- Returns (false, line) without touching the node when it has nothing left +-- to give, or (true, line) once a charge is spent - dropping the node into +-- cooldown the instant that spends its last one. +function Nodes.harvest(game, node) + if Nodes.isFallow(node) then + return false, "the vein is played out; give it time to reopen" + end + node.charges = node.charges - 1 + if node.charges <= 0 then node.cooldown = Nodes.REGEN_TURNS end + local player = game.player + if node.kind == Nodes.ORE then + local amount = math.floor(game.rng:range(Nodes.ORE_YIELD_MIN, Nodes.ORE_YIELD_MAX) * game:essenceMultiplier()) + player.essence = player.essence + amount + return true, string.format("you chip %d essence from the ore vein", amount) + end + local gem = Crafting.gem(node.element, 1) + player.inventory[#player.inventory + 1] = gem + return true, "you pry a " .. gem.name .. " loose from the seam" +end + +-- Advances every fallow node's cooldown one world round, exactly like +-- hazards.step advances fire and gas - called once per round, not per actor, +-- so a seeded run always reopens a vein on the identical turn regardless of +-- how many actors acted that round. +function Nodes.step(level) + for _, node in ipairs(level.nodes) do + if node.cooldown > 0 then + node.cooldown = node.cooldown - 1 + if node.cooldown <= 0 then node.charges = Nodes.MAX_CHARGES end + end + end +end + +return Nodes diff --git a/src/noise.lua b/src/noise.lua new file mode 100644 index 0000000..7440ba2 --- /dev/null +++ b/src/noise.lua @@ -0,0 +1,74 @@ +-- Sound propagation. Unlike los.lua's straight sightline, a noisy action's +-- racket bends around corners: it floods outward from its origin through +-- open, walkable cells only, using the same four-directional adjacency +-- astar.lua's own pathing already assumes (see its DIRS and game.lua's +-- MONSTER_DIRS), and fades by exactly one point of loudness for every step +-- the flood has to take to get there. A closed door or a solid wall stops +-- it dead - nothing leaks through a wall diagonally the way it might with +-- an eight-directional flood, so a noise never reaches anywhere a monster +-- could not actually have walked to investigate it. +-- +-- The flood itself has no idea what a monster or a player is; it is a pure +-- function of the map, an origin and a starting loudness. game.lua's +-- Game:emitNoise is what turns a resulting field into a dormant monster +-- actually stirring (see stealth.lua's Stealth.hear). + +local Noise = {} + +local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- Loudness constants for the actions game.lua wires up to emitNoise, tuned +-- so a whisper-quiet action (nothing currently that quiet) would decay +-- within a couple of cells while a pick striking stone carries the length +-- of a small floor. +Noise.MELEE = 6 +Noise.RANGED = 6 +Noise.THROW = 5 +Noise.SPELL = 8 +Noise.DIG = 9 + +-- Floods outward from (x, y) through walkable cells, returning a table +-- keyed by map:index(nx, ny) -> the loudness still remaining once the sound +-- has traveled there (loudness itself at the origin, decaying by one per +-- step, never revisiting a cell at an intensity no better than one already +-- recorded there). A cell the flood never reaches simply has no entry. The +-- origin itself need not be walkable - a pick striking solid rock (see +-- game.lua's Game:dig) is exactly as loud as one striking rubble - only the +-- cells the flood actually spreads into have to be open for anything to +-- ever stand in them and hear it. +function Noise.propagate(map, x, y, loudness) + local field = {} + if loudness <= 0 or not map:inBounds(x, y) then return field end + local startIdx = map:index(x, y) + field[startIdx] = loudness + local queue = { { x = x, y = y, intensity = loudness } } + local head = 1 + while head <= #queue do + local cur = queue[head] + head = head + 1 + if cur.intensity > 0 then + local nextIntensity = cur.intensity - 1 + for _, dir in ipairs(DIRS) do + local nx, ny = cur.x + dir[1], cur.y + dir[2] + if map:isWalkable(nx, ny) then + local idx = map:index(nx, ny) + if (field[idx] or -1) < nextIntensity then + field[idx] = nextIntensity + queue[#queue + 1] = { x = nx, y = ny, intensity = nextIntensity } + end + end + end + end + end + return field +end + +-- Remaining loudness a previously computed field still carries at (x, y), +-- or 0 for anywhere the flood never reached (including out of bounds, which +-- map:index would otherwise happily compute an index for). +function Noise.intensityAt(field, map, x, y) + if not map:inBounds(x, y) then return 0 end + return field[map:index(x, y)] or 0 +end + +return Noise From b31072d58719934cebe91b03c7428e86478aa41f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:34:51 +0200 Subject: [PATCH 062/172] feat: add chebyshev, novaTargets and Polymorph.pool --- src/phases.lua | 91 +++++++++++++++++++++++++++++++++++++++++++++++ src/polymorph.lua | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/phases.lua create mode 100644 src/polymorph.lua diff --git a/src/phases.lua b/src/phases.lua new file mode 100644 index 0000000..9508800 --- /dev/null +++ b/src/phases.lua @@ -0,0 +1,91 @@ +-- A boss's second, harder-won escalation beyond bosses.lua's enrage. Enrage +-- is a single instantaneous stat bump that never costs the boss a turn; a +-- desperation phase is a two-turn state machine that consumes both turns +-- outright once the boss is battered all the way down to its catalog +-- desperateAt threshold: +-- 1. brace - the boss telegraphs for one full turn, a visible defense +-- spike (desperateDefenseBonus) warning the player something is coming +-- without yet hurting anyone. +-- 2. unleash - on its very next turn the boss reverts that defense spike +-- and detonates a single cataclysm, dealing novaDamage to the player +-- and to every companion, hireling or thrall still within novaRadius. +-- The cataclysm fires at most once per boss: novaSpent latches permanently +-- the moment it lands, so a fight survived past it settles back into the +-- boss's ordinary ability/ai behaviour (abilities.lua) for good. A boss with +-- no desperateAt in its catalog entry (none outside bosses.lua today) never +-- enters this state machine at all. +-- +-- Bracing and unleashing both consume the boss's entire turn - the caller +-- (Game:takeMonsterTurn) must skip Stealth.tick, Abilities.tryAct and +-- Ai.act whenever Phases.tick reports the turn consumed, exactly as it +-- already skips them all under Status.skipsTurn. + +local Bosses = require("bosses") + +local Phases = {} + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +-- Everyone the cataclysm can reach: the player when in range, plus every +-- living companion, hireling or thrall within the same radius - the same +-- three-way ally kind the rest of the engine treats as a single "player's +-- own side" (see game.lua's isPlayerAlly, infighting.lua, and the noise/ +-- confuse/blind symmetry work across companions.lua/mercenaries.lua/ +-- reanimation.lua). A wild monster caught nearby is never a target - a +-- boss's desperate gambit is aimed at the party that cornered it, not at +-- whatever else happens to be on the floor (mirrors Bosses itself only ever +-- threatening the player's side). +local function novaTargets(game, monster, radius) + local targets = {} + if chebyshev(game.player.x, game.player.y, monster.x, monster.y) <= radius then + targets[#targets + 1] = game.player + end + for _, other in ipairs(game.level.entities) do + if other:isAlive() and (other.kind == "companion" or other.kind == "hireling" or other.kind == "thrall") + and chebyshev(other.x, other.y, monster.x, monster.y) <= radius then + targets[#targets + 1] = other + end + end + return targets +end + +-- Called once per boss turn, at the same point Bosses.tryEnrage already +-- runs in Game:takeMonsterTurn (after status/hazard/regen, before every +-- other action). Returns (line, consumedTurn); consumedTurn true means the +-- caller must skip everything else this turn, bracing and unleashing both +-- being the entire point of the turn they happen on. +function Phases.tick(game, monster) + if not monster.isBoss or not monster:isAlive() then return nil, false end + local spec = Bosses.byKey[monster.bossKey] + if not spec or not spec.desperateAt then return nil, false end + + if monster.channeling then + monster.channeling = false + monster.novaSpent = true + monster.defense = monster.defense - spec.desperateDefenseBonus + monster.baseDefense = monster.baseDefense - spec.desperateDefenseBonus + local hit = {} + for _, victim in ipairs(novaTargets(game, monster, spec.novaRadius)) do + victim.hp = victim.hp - spec.novaDamage + hit[#hit + 1] = victim == game.player and "you" or victim.name + end + if #hit == 0 then + return string.format("%s unleashes a cataclysm that finds no one in range", monster.name), true + end + return string.format("%s unleashes a cataclysm on %s for %d", monster.name, + table.concat(hit, ", "), spec.novaDamage), true + end + + if monster.novaSpent or monster.desperate then return nil, false end + if monster.hp > monster.maxHp * spec.desperateAt then return nil, false end + + monster.desperate = true + monster.channeling = true + monster.defense = monster.defense + spec.desperateDefenseBonus + monster.baseDefense = monster.baseDefense + spec.desperateDefenseBonus + return string.format("%s reels and begins channeling a desperate cataclysm", monster.name), true +end + +return Phases diff --git a/src/polymorph.lua b/src/polymorph.lua new file mode 100644 index 0000000..3691f14 --- /dev/null +++ b/src/polymorph.lua @@ -0,0 +1,63 @@ +-- Rewrites an entity's very body into a different bestiary species mid-run. +-- Unlike affixes.lua or runewords.lua, which layer bonuses on top of a fixed +-- body, a polymorph replaces the body outright: name, glyph, ai behaviour, +-- attack, defense, speed, resistances, faction and any ability tag all come +-- from the new species wholesale, the same fields Monsters.create stamps +-- onto a fresh spawn. Only the current hp fraction survives the swap, so a +-- polymorph is never a cheap full heal and never an outright kill either. +-- Mutation happens in place on the entity table already held everywhere +-- else (the turn scheduler, level.entities, a live nemesis reference), so +-- nothing needs to re-look-up or re-insert anything after a transform. + +local Monsters = require("monsters") + +local Polymorph = {} + +-- Species a polymorph may land on at `depth`, in Monsters.species order so a +-- given rng draw is deterministic. Excludes the shopkeeper outright: its +-- "vend" behaviour idles forever once out of the shop context a polymorphed +-- body would never occupy, leaving it neither hostile nor useful to anyone. +function Polymorph.pool(depth) + local eligible = {} + for _, spec in ipairs(Monsters.species) do + if spec.key ~= "shopkeeper" and spec.minDepth <= depth then + eligible[#eligible + 1] = spec + end + end + return eligible +end + +-- Transforms `entity` in place into a random species drawn from `pool`, +-- excluding whatever species it already is so a polymorph always changes +-- something. Returns the species table it became, or nil (entity untouched) +-- if `pool` has nothing left to offer once the current species is excluded. +function Polymorph.transform(entity, rng, pool) + local choices = {} + for _, spec in ipairs(pool) do + if spec.key ~= entity.species then choices[#choices + 1] = spec end + end + if #choices == 0 then return nil end + local spec = choices[rng:range(1, #choices)] + local fraction = entity.maxHp > 0 and (entity.hp / entity.maxHp) or 1 + entity.glyph = spec.glyph + entity.name = spec.name + entity.species = spec.key + entity.ai = spec.ai + entity.maxHp = spec.hp + entity.hp = math.max(1, math.floor(spec.hp * fraction + 0.5)) + entity.attack = spec.attack + entity.defense = spec.defense + entity.speed = spec.speed + entity.range = spec.range or 0 + entity.regen = spec.regen + entity.onDeath = spec.onDeath + entity.breath = spec.breath + entity.ability = spec.ability + entity.summonSpecies = spec.summonSpecies + entity.cooldown = 0 + entity.resist = spec.resist + entity.faction = spec.faction + return spec +end + +return Polymorph From 54569d9e04c0f476d62ac87c4c5fe565c63bbe2c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:39:28 +0200 Subject: [PATCH 063/172] feat: add Proficiency.keyFor, Proficiency.hits and Progression.xpToNext --- src/proficiency.lua | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/progression.lua | 48 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/proficiency.lua create mode 100644 src/progression.lua diff --git a/src/proficiency.lua b/src/proficiency.lua new file mode 100644 index 0000000..09f3783 --- /dev/null +++ b/src/proficiency.lua @@ -0,0 +1,88 @@ +-- Weapon mastery: every melee swing the player lands (Game:playerAttack) +-- nudges their familiarity with whichever weapon delivered it - or their +-- bare fists, wielding nothing - forward by one point, keyed by the +-- weapon's own name so a build's proficiency carries seamlessly from one +-- identical drop to the next with no separate catalog of weapon +-- "categories" to keep in sync with builder.lua's loot tables, artifacts. +-- lua's named weapons, or a set piece's own name. Crossing a rank threshold +-- grants a small, permanent flat attack bonus with that specific weapon (or +-- fists) alone, read live by Entity:effectiveAttack exactly like momentum. +-- lua's combo stacks or sanity.lua's dread penalty - so re-equipping a +-- weapon already mastered picks the bonus straight back up, but switching +-- to something unfamiliar drops it until that one is mastered in turn. +-- +-- Melee reads and writes its own key automatically (Proficiency.keyFor +-- reads the equipped weapon slot, or Proficiency.UNARMED bare-handed), but +-- the underlying Proficiency.onHitWith/bonusFor pair takes any explicit key +-- instead - ranged.lua uses this to track mastery of an equipped bow by its +-- own name, entirely separate from whatever melee weapon (if any) happens +-- to be equipped alongside it, since Ranged.fire deliberately deals damage +-- off baseAttack rather than effectiveAttack (see its own header). + +local Proficiency = {} + +Proficiency.UNARMED = "fists" + +-- Hits needed to cross each mastery rank, and the flat attack bonus that +-- rank grants from then on. Only the highest rank crossed applies (each is +-- already the running total, not a stacking delta), but the gap between +-- ranks widens the deeper it goes, so the later ones are meant to take a +-- genuinely long, dedicated run with one weapon to ever reach. +Proficiency.RANKS = { + { hits = 15, bonus = 1 }, + { hits = 40, bonus = 2 }, + { hits = 80, bonus = 3 }, + { hits = 150, bonus = 4 }, +} + +-- The mastery key `entity` is currently swinging: an equipped weapon's own +-- name, or Proficiency.UNARMED for bare fists. +function Proficiency.keyFor(entity) + return entity.weapon and entity.weapon.name or Proficiency.UNARMED +end + +function Proficiency.hits(entity, key) + return (entity.proficiency and entity.proficiency[key]) or 0 +end + +-- Records one more landed hit under an explicit `key`, and returns that key +-- plus the newly reached rank's bonus the instant a threshold is first +-- crossed - nil otherwise, so a caller only ever logs a genuine milestone +-- rather than every single swing or shot. +function Proficiency.onHitWith(entity, key) + entity.proficiency = entity.proficiency or {} + local before = entity.proficiency[key] or 0 + local after = before + 1 + entity.proficiency[key] = after + for _, rank in ipairs(Proficiency.RANKS) do + if before < rank.hits and after >= rank.hits then + return key, rank.bonus + end + end + return nil +end + +-- The flat attack bonus `entity` currently draws from mastery of `key` +-- alone - never a total across every weapon it has ever swung or fired, so +-- switching weapons genuinely costs something. +function Proficiency.bonusFor(entity, key) + local hits = Proficiency.hits(entity, key) + local bonus = 0 + for _, rank in ipairs(Proficiency.RANKS) do + if hits >= rank.hits then bonus = rank.bonus end + end + return bonus +end + +-- Melee's own auto-keyed shorthand over the two generic functions above: +-- always reads and writes whatever Proficiency.keyFor currently reports for +-- `entity` (its equipped weapon, or bare fists). +function Proficiency.onMeleeHit(entity) + return Proficiency.onHitWith(entity, Proficiency.keyFor(entity)) +end + +function Proficiency.attackBonus(entity) + return Proficiency.bonusFor(entity, Proficiency.keyFor(entity)) +end + +return Proficiency diff --git a/src/progression.lua b/src/progression.lua new file mode 100644 index 0000000..c4bad8e --- /dev/null +++ b/src/progression.lua @@ -0,0 +1,48 @@ +-- Player experience and leveling. Killing a monster grants xp scaled by the +-- species catalog; once xp clears the threshold for the current level the +-- player levels up, gaining permanent hp/attack/defense and a full heal-up on +-- the hp gained. A single large xp grant can trigger several level-ups in a +-- row, so the gain loops rather than checking once. + +local Progression = {} + +-- Threshold to advance from `level` to `level + 1`. Linear on purpose: pure +-- and easy to reason about in tests, unlike an exponential curve. +function Progression.xpToNext(level) + return 20 + (level - 1) * 15 +end + +local function hpGain(level) + return 4 + level +end + +local function levelUp(player) + player.level = player.level + 1 + local gain = hpGain(player.level) + player.maxHp = player.maxHp + gain + player.hp = player.hp + gain + player.attack = player.attack + 1 + player.baseAttack = player.baseAttack + 1 + if player.level % 2 == 0 then + player.defense = player.defense + 1 + player.baseDefense = player.baseDefense + 1 + end + player.skillPoints = (player.skillPoints or 0) + 1 + player.xp = player.xp - player.xpNext + player.xpNext = Progression.xpToNext(player.level) + return string.format("you reach level %d", player.level) +end + +-- Applies an xp gain to the player, running as many level-ups as the gain +-- covers. Returns the log lines produced, in order. +function Progression.grantXp(player, amount) + if amount <= 0 then return {} end + player.xp = player.xp + amount + local lines = {} + while player.xp >= player.xpNext do + lines[#lines + 1] = levelUp(player) + end + return lines +end + +return Progression From 10488b48e863bbc2358b931089996a84eda49ca8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 18:59:25 +0200 Subject: [PATCH 064/172] feat: add Quests.generate, Quests.describe and Ranged.ammoIndex --- src/quests.lua | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/ranged.lua | 73 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/quests.lua create mode 100644 src/ranged.lua diff --git a/src/quests.lua b/src/quests.lua new file mode 100644 index 0000000..c727c63 --- /dev/null +++ b/src/quests.lua @@ -0,0 +1,90 @@ +-- Bounty boards scattered through the dungeon from depth two on. Each board +-- is built with exactly one bounty already written on it (a cull, a hoard or +-- a depth pact), fixed at generation time from the floor's own rng so a given +-- (worldSeed, depth) always offers the identical bounty. Standing on an +-- unclaimed board binds the player to its pact for free (no turn spent, same +-- as learning a skill); a board only ever grants one bounty in its lifetime, +-- so re-rolling by revisiting an already-claimed board is not possible. A +-- bound pact resolves itself the instant its condition is met - no trip back +-- to the board required - paying out gold, xp and essence in one stroke. + +local Monsters = require("monsters") + +local Quests = {} + +Quests.CULL = "cull" +Quests.HOARD = "hoard" +Quests.DEPTH = "depth" + +-- Fixed order so a given rng draw always names the same kind. +Quests.order = { Quests.CULL, Quests.HOARD, Quests.DEPTH } + +-- Builds the one bounty a freshly placed board offers on `depth`, clamping any +-- depth target to `maxDepth` so a pact can never demand the unreachable. +function Quests.generate(rng, depth, maxDepth) + local kind = Quests.order[rng:range(1, #Quests.order)] + local bounty = { + kind = kind, + rewardGold = 15 + depth * 8, + rewardXp = 10 + depth * 4, + } + if kind == Quests.CULL then + local eligible = Monsters.forDepth(depth) + local spec = eligible[rng:range(1, #eligible)] + bounty.species = spec.key + bounty.speciesName = spec.name + bounty.need = rng:range(2, 4) + bounty.have = 0 + elseif kind == Quests.HOARD then + bounty.need = 40 + depth * 20 + else + bounty.need = math.min(depth + rng:range(2, 4), maxDepth) + end + return bounty +end + +function Quests.describe(bounty) + if bounty.kind == Quests.CULL then + return string.format("cull %d %s (%d/%d)", bounty.need, bounty.speciesName, bounty.have, bounty.need) + elseif bounty.kind == Quests.HOARD then + return string.format("amass %d gold", bounty.need) + end + return string.format("reach depth %d", bounty.need) +end + +-- Binds `player` to `board`'s bounty, provided the player is not already +-- bound to one and the board has not already been claimed. Returns (true, +-- line) on success or (false, line) explaining why not. +function Quests.accept(player, board) + if player.quest then + return false, "you already hold a bounty" + end + if board.claimed then + return false, "this board's ledger is already spent" + end + board.claimed = true + player.quest = board.quest + return true, "you bind yourself to a bounty: " .. Quests.describe(board.quest) +end + +-- Registers a kill toward the player's active cull bounty if the species +-- matches. A no-op for any other kind, an unrelated species, or no bounty. +function Quests.registerKill(player, species) + local bounty = player.quest + if bounty and bounty.kind == Quests.CULL and species == bounty.species and bounty.have < bounty.need then + bounty.have = bounty.have + 1 + end +end + +-- True once `player`'s active bounty (if any) is fulfilled by the current +-- state: a cull once its tally is met, a hoard once live gold clears its +-- absolute target, a depth pact once the player has reached it. +function Quests.resolved(player, depth) + local bounty = player.quest + if not bounty then return false end + if bounty.kind == Quests.CULL then return bounty.have >= bounty.need end + if bounty.kind == Quests.HOARD then return player.gold >= bounty.need end + return depth >= bounty.need +end + +return Quests diff --git a/src/ranged.lua b/src/ranged.lua new file mode 100644 index 0000000..73a82ce --- /dev/null +++ b/src/ranged.lua @@ -0,0 +1,73 @@ +-- Player ranged combat via an equipped bow and a stack of arrows carried in +-- the inventory. Unlike throwing, firing never costs the launcher itself - +-- only ammunition - so a bow stays equipped shot after shot as long as the +-- quiver holds out. A loosed arrow lands where the target stood either way, +-- recoverable exactly like a thrown weapon. + +local Los = require("los") +local Items = require("items") +local Skills = require("skills") +local Artifacts = require("artifacts") +local Resistances = require("resistances") +local Proficiency = require("proficiency") +local Durability = require("durability") + +local Ranged = {} + +-- Inventory index of the first non-empty arrow stack, or nil if the quiver +-- is dry. Ammo of the same name merges into one stack on pickup, so in +-- practice there is at most one. +function Ranged.ammoIndex(entity) + for i, item in ipairs(entity.inventory) do + if item.kind == Items.AMMO and item.count > 0 then return i end + end + return nil +end + +function Ranged.canReach(game, x1, y1, x2, y2, range) + local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2)) + return dist <= range and Los.clear(game.level.map, x1, y1, x2, y2) +end + +-- Looses one arrow from `shooter`'s equipped bow at `target`. Returns (true, +-- lines) if a shot went out - whatever it did on arrival - or (false, lines) +-- if it never left the string: no bow readied, no arrows, or no clear shot. +-- Damage comes from baseAttack rather than effectiveAttack so a melee +-- weapon's power does not also inflate every arrow; the bow's own power and +-- the target's armour are what separate a longbow from a sling. Mastery of +-- that specific bow (see proficiency.lua) still applies on top, since it is +-- read and recorded explicitly by the bow's own name rather than riding +-- effectiveAttack's blended total. +function Ranged.fire(game, shooter, target) + local bow = shooter.bow + if not bow then return false, { "you have no bow readied" } end + if not target or not target:isAlive() then return false, { "no target" } end + local ammoIdx = Ranged.ammoIndex(shooter) + if not ammoIdx then return false, { "you have no arrows left" } end + if not Ranged.canReach(game, shooter.x, shooter.y, target.x, target.y, bow.range) then + return false, { "you have no clear shot" } + end + local ammo = shooter.inventory[ammoIdx] + ammo.count = ammo.count - 1 + if ammo.count <= 0 then table.remove(shooter.inventory, ammoIdx) end + local defense = math.max(0, target:effectiveDefense() - Artifacts.bowPierce(shooter)) + local dealt = shooter.baseAttack + Durability.effectivePower(bow) + Skills.rangedDamageBonus(shooter) + + Proficiency.bonusFor(shooter, bow.name) - defense + if dealt < 1 then dealt = 1 end + dealt = Resistances.apply(target, dealt, Resistances.PHYSICAL) + target.hp = target.hp - dealt + local lines = { string.format("your %s hits %s for %d", ammo.name, target.name, dealt) } + game.level.floorItems[#game.level.floorItems + 1] = { + x = target.x, y = target.y, item = { kind = Items.AMMO, name = ammo.name, glyph = ammo.glyph, count = 1 }, + } + local masteredKey, masteredBonus = Proficiency.onHitWith(shooter, bow.name) + if masteredKey then + lines[#lines + 1] = string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus) + end + local broke = Durability.wearBow(shooter) + if broke then lines[#lines + 1] = broke end + if not target:isAlive() then game:awardKill(target) end + return true, lines +end + +return Ranged From bd949a25a507fca0184f85c55ad83978745c3e43 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 19:02:31 +0200 Subject: [PATCH 065/172] feat: add statsFor and Reanimation.raise --- src/reanimation.lua | 158 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/reanimation.lua diff --git a/src/reanimation.lua b/src/reanimation.lua new file mode 100644 index 0000000..f2f7965 --- /dev/null +++ b/src/reanimation.lua @@ -0,0 +1,158 @@ +-- Necromancy: consumes a still-fresh corpse underfoot to raise it as a +-- temporary skeletal thrall that fights beside the player until its +-- borrowed animation runs out. Unlike companions.lua's charm-bonded ally, +-- which persists across the whole run once bonded, a thrall is spent the +-- instant it collapses - reduced to inert bones, nothing left to butcher a +-- second time - and raising one costs the player a bite of sanity.lua's own +-- meter, the same currency dread already drains, since bending a corpse +-- back into motion is exactly the kind of wrongness that meter tracks. +-- Only one thrall may walk at a time; raising a second dismisses the first +-- outright, the same one-bond-at-a-time rule companions.lua keeps for its +-- own charm. Thralls are plain entities on level.entities, exactly like +-- companions, so they serialize with no special save-format handling +-- beyond the one extra field (decay) entity.lua's toData whitelist adds. + +local Entity = require("entity") +local Combat = require("combat") +local Los = require("los") +local Monsters = require("monsters") +local Corpses = require("corpses") +local Status = require("status") +local Noise = require("noise") + +local Reanimation = {} + +Reanimation.LIFESPAN = 40 +Reanimation.SANITY_COST = 15 +Reanimation.HP_FRACTION = 0.6 +Reanimation.ATTACK_PENALTY = 1 +Reanimation.SIGHT = 6 + +local ADJACENT = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } } + +-- Combat numbers for the thrall raised from a corpse of `speciesKey`: a +-- fraction of the living species' own hp, attack blunted by one point for +-- fighting with brittle bone instead of flesh, and defense and speed left +-- exactly as they were - an animated skeleton is exactly as easy or hard to +-- land a blow on, and exactly as quick underfoot, as the creature it once +-- was. Falls back to a flat, unremarkable skeleton for any corpse whose +-- species the bestiary no longer recognises (none today, but a save could +-- in principle outlive a species removed from a later build). +local function statsFor(speciesKey) + local spec = Monsters.byKey[speciesKey] + if not spec then return { hp = 8, attack = 2, defense = 0, speed = 100 } end + return { + hp = math.max(4, math.floor(spec.hp * Reanimation.HP_FRACTION)), + attack = math.max(1, spec.attack - Reanimation.ATTACK_PENALTY), + defense = spec.defense, + speed = spec.speed, + } +end + +-- Raises `corpse` beside the player, retiring any earlier thrall first and +-- spending the corpse and a bite of sanity outright. Returns the new +-- thrall, or (nil, line) with nothing changed when the corpse has already +-- rotted past use, the player's own mind cannot bear the cost, or every +-- cell around the player is blocked. +function Reanimation.raise(game, corpse) + if Corpses.isRotten(corpse) then + return nil, "this corpse has rotted too far to answer the call" + end + if game.player.sanity < Reanimation.SANITY_COST then + return nil, "your grip on your own mind is too frail for this" + end + local player = game.player + local x, y + for _, dir in ipairs(ADJACENT) do + local cx, cy = player.x + dir[1], player.y + dir[2] + if game.level.map:isWalkable(cx, cy) and not game.level:entityAt(cx, cy) then + x, y = cx, cy + break + end + end + if not x then + return nil, "there is nowhere for a thrall to rise" + end + for _, other in ipairs(game.level.entities) do + if other.kind == "thrall" and other:isAlive() then other.hp = 0 end + end + game:cleanupDead() + for i, candidate in ipairs(game.level.corpses) do + if candidate == corpse then + table.remove(game.level.corpses, i) + break + end + end + player.sanity = math.max(0, player.sanity - Reanimation.SANITY_COST) + local stats = statsFor(corpse.species) + local thrall = Entity.new({ + id = game:nextMonsterId(), x = x, y = y, + glyph = "Z", name = corpse.name .. " thrall", kind = "thrall", + species = corpse.species, hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + }) + thrall.decay = Reanimation.LIFESPAN + game.level.entities[#game.level.entities + 1] = thrall + return thrall +end + +local function adjacent(a, b) + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) == 1 +end + +-- Nearest living monster the thrall can see within Reanimation.SIGHT, ties +-- broken by ascending id for determinism - the same shape companions.lua's +-- own nearestHostile follows, including its blind clamp (Game:canSeePlayer's +-- own melee-range fallback for a blinded monster). +local function nearestHostile(game, thrall) + local sight = Reanimation.SIGHT + if Status.has(thrall, Status.BLIND) then sight = math.min(sight, 1) end + local best, bestDist = nil, math.huge + for _, entity in ipairs(game.level.entities) do + if entity.kind == "monster" and entity:isAlive() then + local dist = math.max(math.abs(entity.x - thrall.x), math.abs(entity.y - thrall.y)) + if dist <= sight and Los.clear(game.level.map, thrall.x, thrall.y, entity.x, entity.y) then + if dist < bestDist or (dist == bestDist and entity.id < best.id) then + best, bestDist = entity, dist + end + end + end + end + return best +end + +-- The thrall's full turn: its borrowed animation runs out by one turn +-- first, and once it hits zero the thrall collapses back into inert bones +-- through the very same Game:onActorDeath path a slain one already falls +-- through - called directly here, exactly like Game:checkTrapFor calls it +-- directly for a trap kill mid-move, since nothing later in the caller's +-- own takeMonsterTurn pipeline re-checks aliveness once dispatch returns. +-- Otherwise it fights whatever hostile it can already reach, closes on one +-- it can see but not yet reach, or shadows the player - the same +-- three-branch shape as Companions.act, minus any tending since a thrall +-- has nothing of its own to spare. +function Reanimation.act(game, thrall) + thrall.decay = thrall.decay - 1 + if thrall.decay <= 0 then + thrall.hp = 0 + game:onActorDeath(thrall) + return + end + if game:allyActsConfused(thrall) then return end + local target = nearestHostile(game, thrall) + if target and adjacent(thrall, target) then + local outcome = Combat.attack(thrall, target) + game.log:add(string.format("your %s claws %s for %d", thrall.name, target.name, outcome.damage)) + game:emitNoise(thrall.x, thrall.y, Noise.MELEE) + if outcome.killed then game:awardKill(target) end + return + end + if target then + game:stepMonsterToward(thrall, target.x, target.y) + return + end + if not adjacent(thrall, game.player) and not (thrall.x == game.player.x and thrall.y == game.player.y) then + game:stepMonsterToward(thrall, game.player.x, game.player.y) + end +end + +return Reanimation From 63a9fb5f907f5776547ea39ad9d91ca1d5bb5927 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 19:16:44 +0200 Subject: [PATCH 066/172] feat: extend render with dormantCount and gearLabel --- src/render.lua | 114 +++++++++++++++++++++++++++++++++++++++++++++---- src/replay.lua | 62 +++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 src/replay.lua diff --git a/src/render.lua b/src/render.lua index 5685760..4f44d68 100644 --- a/src/render.lua +++ b/src/render.lua @@ -4,9 +4,25 @@ local Status = require("status") local Identify = require("identify") +local Hazards = require("hazards") +local Encumbrance = require("encumbrance") +local Stealth = require("stealth") +local Nodes = require("nodes") +local Sets = require("sets") +local Curses = require("curses") +local Momentum = require("momentum") +local Conductivity = require("conductivity") +local Sanity = require("sanity") +local Events = require("events") +local Proficiency = require("proficiency") +local Durability = require("durability") +local Hunger = require("hunger") +local Disease = require("disease") local Render = {} +local hazardGlyphs = { [Hazards.FIRE] = "`", [Hazards.GAS] = "," } + local function cell(game, x, y) local level = game.level local map = level.map @@ -16,36 +32,118 @@ local function cell(game, x, y) if game.player.x == x and game.player.y == y then return "@" end local monster = level:entityAt(x, y) if monster then return monster.glyph end + local hazard = Hazards.at(level, x, y) + if hazard then return hazardGlyphs[hazard.kind] end + if level:shrineAt(x, y) then return "_" end + if level:blockAt(x, y) then return "0" end + if level:plateAt(x, y) then return ";" end + if level:cauldronAt(x, y) then return "U" end + local node = level:nodeAt(x, y) + if node then return Nodes.glyph(node) end + local board = level:questBoardAt(x, y) + if board then return board.claimed and "=" or "&" end + if level:corpseAt(x, y) then return "%" end + local chest = level:chestAt(x, y) + if chest then return chest.opened and "b" or "B" end + if level:riftAt(x, y) then return "Q" end local here = level:itemsAt(x, y) if #here > 0 then return here[1].item.glyph end end return map:get(x, y) end +-- Disease was previously invisible outside the transient log lines +-- Disease.tick emits (the eruption, a damage tick, an escalation) - once +-- one of those scrolled off, a player mid-incubation or mid-plague had no +-- persistent way to check their own affliction at all. Folded into the same +-- comma-joined tag line as the timed statuses above, since both are +-- "what's currently afflicting this entity" facts the HUD already exists to +-- surface. local function statusTags(entity) local tags = {} for _, active in ipairs(entity.statuses) do tags[#tags + 1] = active.kind end + if Disease.isInfected(entity) then + local infection = entity.disease + local name = Disease.defs[infection.key].name + tags[#tags + 1] = Disease.hasErupted(entity) + and string.format("%s(stage %d)", name, infection.stage) + or string.format("%s(incubating)", name) + end if #tags == 0 then return "none" end return table.concat(tags, ",") end +-- How many of the level's living monsters are still asleep, out of the total +-- - purely informational, so autopilot and other callers never read it. +local function dormantCount(level) + local count = 0 + for _, entity in ipairs(level.entities) do + if entity.kind == "monster" and entity:isAlive() and Stealth.isUnaware(entity) then + count = count + 1 + end + end + return count +end + +-- Appends a wear indicator to `label` only once `item` has actually taken +-- damage - broken outright, or merely dinged - leaving a pristine piece's +-- label exactly as clean as before this module existed. +local function gearLabel(item, label) + if Durability.missing(item) == 0 then return label end + if Durability.isBroken(item) then return label .. " (broken)" end + return string.format("%s (%d/%d)", label, item.durability, item.maxDurability) +end + function Render.panel(game) local player = game.player local level = game.level - local weapon = player.weapon and Identify.label(game.identify, player.weapon) or "none" - local armor = player.armor and Identify.label(game.identify, player.armor) or "none" - local ring = player.ring and player.ring.name or "none" + local weapon = player.weapon + and gearLabel(player.weapon, Curses.label(player.weapon, Identify.label(game.identify, player.weapon))) or "none" + local armor = player.armor + and gearLabel(player.armor, Curses.label(player.armor, Identify.label(game.identify, player.armor))) or "none" + local ring = player.ring and Curses.label(player.ring, player.ring.name) or "none" + local bow = player.bow and gearLabel(player.bow, Curses.label(player.bow, player.bow.name)) or "none" local torch = player.light.fuel > 0 and tostring(player.light.fuel) or "spent" + local asleep = dormantCount(level) + local foes = asleep > 0 and string.format("%d (%d asleep)", #level.entities, asleep) + or tostring(#level.entities) local lines = { - string.format("HP %d/%d ATK %d DEF %d depth %d turn %d %s%s", + string.format("HP %d/%d ATK %d DEF %d lvl %d depth %d turn %d %s%s%s%s", player.hp, player.maxHp, player:effectiveAttack(), player:effectiveDefense(), - game.depth, game.turn, level.generator, level.dark and " dark" or ""), - string.format("food %d torch %s status %s", player.satiation, torch, statusTags(player)), - string.format("weapon %s armor %s ring %s items %d foes %d", - weapon, armor, ring, #player.inventory, #level.entities), + player.level, game.depth, game.turn, level.generator, level.dark and " dark" or "", + level.weather and (" " .. level.weather) or "", game.won and " WON" or ""), + string.format("xp %d/%d mana %d/%d food %d torch %s gold %d status %s", + player.xp, player.xpNext, player.mana, player.maxMana, player.satiation, torch, player.gold, statusTags(player)), + string.format("load %s (%d/%d)", + player:encumbranceTier(), Encumbrance.carriedWeight(player), Encumbrance.capacityFor(player)), + string.format("weapon %s armor %s ring %s bow %s items %d foes %s", + weapon, armor, ring, bow, #player.inventory, foes), } + local activeSets = Sets.activeDescriptions(player) + if activeSets then lines[#lines + 1] = "set " .. activeSets end + local combo = Momentum.stacks(player) + if combo > 0 then lines[#lines + 1] = string.format("combo x%d", combo) end + if Conductivity.isWet(player) then lines[#lines + 1] = string.format("wet (%d)", player.wet) end + local masteryBonus = Proficiency.attackBonus(player) + if masteryBonus > 0 then + lines[#lines + 1] = string.format("mastery +%d with %s", masteryBonus, Proficiency.keyFor(player)) + end + local sanityTier = Sanity.tierOf(player) + if sanityTier ~= Sanity.COMPOSED then + lines[#lines + 1] = string.format("sanity %d/%d (%s)", Sanity.current(player), Sanity.MAX, sanityTier) + end + local hungerTier = Hunger.tierOf(player) + if hungerTier ~= Hunger.FED then + lines[#lines + 1] = string.format("hunger %d/%d (%s)", Hunger.current(player), Hunger.CAP, hungerTier) + end + if level.event then + lines[#lines + 1] = string.format("event %s (%d turns left)", level.event.key, level.event.turnsLeft) + end + if game.ascension and #game.ascension > 0 then + lines[#lines + 1] = "ascension " .. table.concat(game.ascension, ",") + end local last = game.log:last() if last then lines[#lines + 1] = "- " .. last end return table.concat(lines, "\n") diff --git a/src/replay.lua b/src/replay.lua new file mode 100644 index 0000000..2696ac9 --- /dev/null +++ b/src/replay.lua @@ -0,0 +1,62 @@ +-- Re-simulates a run from nothing but its own seed, options and step count +-- to confirm it actually reproduces a recorded outcome - the same guarantee +-- rngDeterminism and mapDeterminism already lean on internally, spelled out +-- here as a standalone check a caller can run against any (seed, options, +-- steps) triple, e.g. to confirm a regression fix left a known-good seed's +-- ending untouched, or to flag a leaderboard entry whose recorded report no +-- longer matches what that seed actually plays out to. +-- +-- Scope: this replays purely from (seed, options, steps) - it says nothing +-- about a run that also carried mutable meta.lua state (perks already +-- owned, a compendium already partway filled), since that state moves +-- forward with every run and is never itself snapshotted. Call it only for +-- a plain run, or pass the exact historical options a meta-bearing run +-- actually started with. + +local Game = require("game") +local Morgue = require("morgue") + +local Replay = {} + +-- Runs a fresh game for exactly `steps` auto-steps (or until the player +-- dies first) and returns its final Morgue.build report. +function Replay.run(seed, options, steps) + local game = Game.new(seed, options) + for _ = 1, steps do + if not game.player:isAlive() then break end + game:autoStep() + end + return Morgue.build(game) +end + +-- Every field Morgue.score actually reads, plus won/alive - deliberately not +-- equipment/killTally/finalMessages, which are free-form listings a caller +-- rarely wants compared byte-for-byte. +Replay.COMPARED_FIELDS = { + "won", "alive", "deepestDepth", "turn", "gold", "essence", + "kills", "bossKills", "questsCompleted", "artifactsFound", + "vaultsOpened", "setPiecesFound", "chestsOpened", "playerLevel", +} + +-- True iff `report` and `replayed` agree on every Replay.COMPARED_FIELDS +-- entry. Returns (false, field) naming the first mismatch found, in +-- Replay.COMPARED_FIELDS order, so a caller can say exactly what diverged +-- rather than just that something did. +function Replay.matches(report, replayed) + for _, field in ipairs(Replay.COMPARED_FIELDS) do + if report[field] ~= replayed[field] then return false, field end + end + return true, nil +end + +-- Re-simulates (seed, options, steps) and checks whether it reproduces +-- `report` (see Replay.matches). Always returns the score the replay itself +-- earned, whether or not it matched, so a caller can tell a merely-outdated +-- record from one whose replay actually played worse. +function Replay.verify(seed, options, steps, report) + local replayed = Replay.run(seed, options, steps) + local matches, mismatchField = Replay.matches(report, replayed) + return matches, mismatchField, Morgue.score(replayed) +end + +return Replay From cf286f9da3332f50e28a9888ccafd2a54301aaa2 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 19:36:14 +0200 Subject: [PATCH 067/172] feat: add tierFrom, Resistances.tierOf and wordKey --- src/resistances.lua | 67 +++++++++++++++++++++++++++++++++++++++ src/runewords.lua | 77 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/resistances.lua create mode 100644 src/runewords.lua 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 From 35b97b6c04e82ac5b9dfa5fcc17d1f25d3c1d1bc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 20:24:53 +0200 Subject: [PATCH 068/172] feat: add chebyshev and Sanity.current --- src/sanity.lua | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/sanity.lua diff --git a/src/sanity.lua b/src/sanity.lua new file mode 100644 index 0000000..1007809 --- /dev/null +++ b/src/sanity.lua @@ -0,0 +1,201 @@ +-- The player's grip on their own mind, tracked as a slow-moving meter +-- alongside hp and mana rather than a temporary status.lua effect: nothing +-- brews or casts sanity away in a single stroke, it only bleeds out from +-- sustained exposure to dread (standing in the dark with a spent torch, +-- corpses piling up nearby, undead in plain sight, a curse whose bite is +-- already known, an infested floor's own creeping wrongness) and claws +-- back just as slowly, one calm turn - or one potion of clarity - at a +-- time. Falling tiers cost real attack and defense, exactly like +-- encumbrance.lua's overloaded penalty, and the worst tier risks losing a +-- turn outright to panic or giving the player's own position away with an +-- involuntary scream, so the meter is never just flavour text on the +-- status panel. + +local Factions = require("factions") +local Curses = require("curses") +local Atmosphere = require("atmosphere") +local Stealth = require("stealth") + +local Sanity = {} + +Sanity.MAX = 100 + +Sanity.COMPOSED = "composed" +Sanity.SHAKEN = "shaken" +Sanity.TERRIFIED = "terrified" +Sanity.BROKEN = "broken" + +-- Tier ceilings: a value at or below TERRIFIED_MAX but above 0 reads as +-- TERRIFIED, and so on upward. 0 itself is always BROKEN regardless of +-- where these ceilings sit. +Sanity.TERRIFIED_MAX = 30 +Sanity.SHAKEN_MAX = 60 + +Sanity.DARKNESS_DREAD = 2 +Sanity.CORPSE_DREAD_PER = 1 +Sanity.CORPSE_DREAD_CAP = 4 +Sanity.UNDEAD_DREAD_PER = 2 +Sanity.UNDEAD_DREAD_CAP = 6 +Sanity.CURSE_DREAD = 2 +Sanity.WEATHER_DREAD = 2 + +-- Passive drift back toward composure on any turn that accrued no dread +-- at all - never enough on its own to outrun a floor still actively +-- haunting the player, but reliable once they step clear of it. +Sanity.CALM_RECOVERY = 1 + +Sanity.PANIC_NUMERATOR = 1 +Sanity.PANIC_DENOMINATOR = 3 + +Sanity.SCREAM_NUMERATOR = 1 +Sanity.SCREAM_DENOMINATOR = 4 +Sanity.SCREAM_RADIUS = 6 + +local ATTACK_PENALTY = { + [Sanity.COMPOSED] = 0, [Sanity.SHAKEN] = -1, [Sanity.TERRIFIED] = -3, [Sanity.BROKEN] = -5, +} +local DEFENSE_PENALTY = { + [Sanity.COMPOSED] = 0, [Sanity.SHAKEN] = -1, [Sanity.TERRIFIED] = -2, [Sanity.BROKEN] = -4, +} + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +-- A fresh entity - or a monster, which never accrues any dread of its own +-- since nothing ever calls Sanity.tick for one - reads as fully composed. +function Sanity.current(entity) + return entity.sanity or Sanity.MAX +end + +function Sanity.tierOf(entity) + local value = Sanity.current(entity) + if value <= 0 then return Sanity.BROKEN end + if value <= Sanity.TERRIFIED_MAX then return Sanity.TERRIFIED end + if value <= Sanity.SHAKEN_MAX then return Sanity.SHAKEN end + return Sanity.COMPOSED +end + +function Sanity.adjust(entity, delta) + local updated = Sanity.current(entity) + delta + if updated > Sanity.MAX then updated = Sanity.MAX end + if updated < 0 then updated = 0 end + entity.sanity = updated + return updated +end + +function Sanity.attackPenalty(entity) + return ATTACK_PENALTY[Sanity.tierOf(entity)] +end + +function Sanity.defensePenalty(entity) + return DEFENSE_PENALTY[Sanity.tierOf(entity)] +end + +-- Each dread source below mirrors the vocabulary an existing module already +-- uses (Atmosphere's weather keys, Factions' undead tag, Curses' reveal +-- flag) rather than inventing a parallel one, and every source caps its own +-- contribution so no single cause can spike the meter to zero in one turn. + +function Sanity.darknessDread(game) + if not game.level.dark then return 0 end + if game.player.light.fuel > 0 then return 0 end + return Sanity.DARKNESS_DREAD +end + +function Sanity.corpseDread(game) + local count = 0 + for _, corpse in ipairs(game.level.corpses) do + if chebyshev(corpse.x, corpse.y, game.player.x, game.player.y) <= 1 then + count = count + 1 + end + end + return math.min(count * Sanity.CORPSE_DREAD_PER, Sanity.CORPSE_DREAD_CAP) +end + +-- Only the undead currently visible weigh on the mind - one lurking +-- somewhere unseen on the floor cannot, unlike a nearby corpse the player +-- can smell without looking. +function Sanity.undeadDread(game) + local count = 0 + local map = game.level.map + for _, monster in ipairs(game.level.entities) do + if monster:isAlive() and monster.faction == Factions.UNDEAD + and game.visible[map:index(monster.x, monster.y)] then + count = count + 1 + end + end + return math.min(count * Sanity.UNDEAD_DREAD_PER, Sanity.UNDEAD_DREAD_CAP) +end + +-- A curse still hiding behind an ordinary appearance has not yet given its +-- wearer anything to dread - only a revealed one gnaws at the mind. +function Sanity.curseDread(entity) + for _, slot in ipairs(Curses.SLOTS) do + local item = entity[slot] + if Curses.isCursed(item) and Curses.isRevealed(item) then return Sanity.CURSE_DREAD end + end + return 0 +end + +function Sanity.weatherDread(weather) + if weather == Atmosphere.INFESTED then return Sanity.WEATHER_DREAD end + return 0 +end + +function Sanity.totalDread(game) + return Sanity.darknessDread(game) + + Sanity.corpseDread(game) + + Sanity.undeadDread(game) + + Sanity.curseDread(game.player) + + Sanity.weatherDread(game.level.weather) +end + +-- Advances the player's sanity by one turn: every dread source at once, or +-- - on a turn none of them fired at all - a small step back toward +-- composure instead. Returns a log line the instant the player's tier +-- actually changes, in either direction, or nil while it holds. +function Sanity.tick(game) + local before = Sanity.tierOf(game.player) + local dread = Sanity.totalDread(game) + local worsening = dread > 0 + if worsening then + Sanity.adjust(game.player, -dread) + else + Sanity.adjust(game.player, Sanity.CALM_RECOVERY) + end + local after = Sanity.tierOf(game.player) + if after == before then return nil end + if worsening then + if after == Sanity.BROKEN then return "your mind gives way to raw terror" end + if after == Sanity.TERRIFIED then return "dread claws at the edges of your sanity" end + return "your nerves begin to fray" + end + if after == Sanity.COMPOSED then return "your mind is your own again" end + return "your nerves start to steady" +end + +-- BROKEN alone risks losing the turn outright to a fit of terror; every +-- other tier always lets the player act. Rolls against `rng` so a seeded +-- run always panics on the identical turns. +function Sanity.rollPanic(rng, entity) + if Sanity.tierOf(entity) ~= Sanity.BROKEN then return false end + return rng:chance(Sanity.PANIC_NUMERATOR, Sanity.PANIC_DENOMINATOR) +end + +-- TERRIFIED or worse risks an involuntary scream that alerts every living +-- monster within earshot (reuses stealth.lua's own wake path) - the fear +-- itself becomes a hazard to whoever else is on the floor. +function Sanity.rollScream(rng, game) + local tier = Sanity.tierOf(game.player) + if tier ~= Sanity.TERRIFIED and tier ~= Sanity.BROKEN then return false end + if not rng:chance(Sanity.SCREAM_NUMERATOR, Sanity.SCREAM_DENOMINATOR) then return false end + for _, monster in ipairs(game.level.entities) do + if monster:isAlive() and chebyshev(monster.x, monster.y, game.player.x, game.player.y) <= Sanity.SCREAM_RADIUS then + Stealth.alert(monster, game.player.x, game.player.y) + end + end + return true +end + +return Sanity From f920e8d57fa680662195f48a6ab7a898c615b655 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 21:47:00 +0200 Subject: [PATCH 069/172] feat: add SaveGame.fnv1a32, migrateForward and Scavenging.herbFor --- src/savegame.lua | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/scavenging.lua | 64 ++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/savegame.lua create mode 100644 src/scavenging.lua diff --git a/src/savegame.lua b/src/savegame.lua new file mode 100644 index 0000000..b2da845 --- /dev/null +++ b/src/savegame.lua @@ -0,0 +1,112 @@ +-- A reusable versioned, checksummed save container laid on top of +-- serialize.lua's plain Lua-expression encoding. serialize.lua only knows how +-- to turn a value into text and back; it has no notion of "this text might be +-- truncated, hand-edited, or written by a codebase three versions ago". This +-- module adds that layer: an envelope carrying a schema version and an +-- FNV-1a32 checksum of its payload, plus a migration chain that walks old +-- data forward one version at a time. +-- +-- A file written before this module existed - a bare Serialize.saveFile dump +-- with no envelope at all - is treated as implicit version 1, so an existing +-- save on disk keeps loading instead of being rejected outright. +-- +-- SaveGame.new(migrations) builds one codec for one call site. `migrations` +-- is a 1-indexed array where migrations[v] transforms data from version v to +-- v+1; the codec's CURRENT_VERSION is #migrations + 1. Two call sites with +-- differently shaped data (a run's game state, a player's meta progression) +-- each get their own codec with their own migration chain - this module +-- knows nothing about either shape. + +local Serialize = require("serialize") + +local SaveGame = {} + +local MAGIC = "WARRENSAVE" +local MASK32 = 0xFFFFFFFF + +-- FNV-1a, 32-bit. Verified below against the algorithm's published test +-- vectors (fnv1a32("") and fnv1a32("a")), not just self-consistency. +function SaveGame.fnv1a32(str) + local hash = 2166136261 + for i = 1, #str do + hash = (hash ~ str:byte(i)) & MASK32 + hash = (hash * 16777619) & MASK32 + end + return hash +end + +local function migrateForward(data, fromVersion, migrations, currentVersion) + for v = fromVersion, currentVersion - 1 do + data = migrations[v](data) + end + return data +end + +-- Builds a codec bound to one migration chain. `migrations` may be empty +-- (CURRENT_VERSION == 1); a version-1-only codec still gets checksumming and +-- corruption detection for free. +function SaveGame.new(migrations) + migrations = migrations or {} + local currentVersion = #migrations + 1 + local codec = { CURRENT_VERSION = currentVersion } + + function codec.encode(data) + local payload = Serialize.encode(data) + local envelope = { + magic = MAGIC, + version = currentVersion, + checksum = SaveGame.fnv1a32(payload), + payload = payload, + } + return Serialize.encode(envelope) + end + + -- Returns (data) on success, or (nil, reason) on any failure. `reason` is + -- always one of: "missing" (only from .load, never from .decode), a string + -- starting with "corrupt:", or a string starting with "future version:". + function codec.decode(text) + local ok, value = pcall(Serialize.decode, text) + if not ok or type(value) ~= "table" then + return nil, "corrupt: cannot parse save data" + end + if value.magic ~= MAGIC then + -- No envelope at all: a save from before this codec existed. The + -- decoded value IS the raw data, at implicit version 1. + return migrateForward(value, 1, migrations, currentVersion) + end + if type(value.version) ~= "number" or type(value.payload) ~= "string" + or type(value.checksum) ~= "number" then + return nil, "corrupt: malformed save envelope" + end + if value.version > currentVersion then + return nil, "future version: save is v" .. value.version + .. ", this build reads up to v" .. currentVersion + end + if SaveGame.fnv1a32(value.payload) ~= value.checksum then + return nil, "corrupt: checksum mismatch" + end + local ok2, data = pcall(Serialize.decode, value.payload) + if not ok2 or type(data) ~= "table" then + return nil, "corrupt: cannot parse save payload" + end + return migrateForward(data, value.version, migrations, currentVersion) + end + + function codec.save(path, data) + local file = assert(io.open(path, "w")) + file:write(codec.encode(data)) + file:close() + end + + function codec.load(path) + local file = io.open(path, "r") + if not file then return nil, "missing" end + local text = file:read("a") + file:close() + return codec.decode(text) + end + + return codec +end + +return SaveGame diff --git a/src/scavenging.lua b/src/scavenging.lua new file mode 100644 index 0000000..3470471 --- /dev/null +++ b/src/scavenging.lua @@ -0,0 +1,64 @@ +-- A third way to spend a fresh corpse, alongside Game:butcherCorpse's meal +-- and Game:raiseCorpse's thrall: carving it for the herb its own kind +-- carries rather than for food or an undead ally, so a cauldron (see +-- alchemy.lua) can draw on combat itself and not only on whatever the floor +-- happened to scatter. Every wild faction maps to a fixed herb, so the same +-- species always yields the same ingredient no matter which floor or seed +-- felled it - a goblinoid's crude poultice weed is always bloodroot, a +-- drake's scorched hide is always emberleaf. A corpse already past its rot +-- threshold (see Corpses.isRotten) has nothing left worth carving, and one +-- left behind by anything without a bestiary entry - a bonded companion or +-- a hired ally, never a wild monster - carries no herb at all; the corpse +-- itself is spent either way it fails, exactly like Corpses.butcher's own +-- certain trade, this is a deliberate trade the autopilot never makes on +-- its own (see Game:scavengeCorpse), the same way it never raises one. + +local Corpses = require("corpses") +local Alchemy = require("alchemy") +local Monsters = require("monsters") +local Factions = require("factions") + +local Scavenging = {} + +Scavenging.HERB_BY_FACTION = { + [Factions.VERMIN] = Alchemy.NIGHTCAP, + [Factions.GOBLINOID] = Alchemy.BLOODROOT, + [Factions.ORCISH] = Alchemy.BLOODROOT, + [Factions.BEAST] = Alchemy.EMBERLEAF, + [Factions.CULT] = Alchemy.NIGHTCAP, + [Factions.DRAKE] = Alchemy.EMBERLEAF, + [Factions.BANDIT] = Alchemy.SUNPETAL, + [Factions.MERCHANT] = Alchemy.SUNPETAL, +} + +-- The herb key `corpse` would yield, or nil when its species is unknown to +-- the bestiary (a companion's or hireling's own remains) or belongs to a +-- faction (UNDEAD is the only one) with nothing mapped above - moot in +-- practice since Corpses.leavesCorpse already refuses the undead a corpse +-- at all, kept here only so an unmapped faction fails closed rather than +-- erroring. +function Scavenging.herbFor(corpse) + local spec = Monsters.byKey[corpse.species] + local faction = spec and spec.faction + return faction and Scavenging.HERB_BY_FACTION[faction] +end + +-- Carves `corpse` for its herb instead of food, spending it in one stroke. +-- Refuses without spending anything when the corpse has already rotted past +-- use or its species yields no known herb; returns (false, reason) in +-- either case so a caller never has to re-derive why. +function Scavenging.harvest(game, corpse) + if Corpses.isRotten(corpse) then + return false, "the corpse has rotted too far to yield anything worth carving" + end + local herbKey = Scavenging.herbFor(corpse) + if not herbKey then + return false, "there is nothing worth carving from this corpse" + end + Corpses.remove(game.level, corpse) + local herb = Alchemy.herb(herbKey) + game.player.inventory[#game.player.inventory + 1] = herb + return true, string.format("you carve a %s from the %s corpse", herb.name, corpse.name) +end + +return Scavenging From 00b89a91332e466aea9f44a7802eeedf63759268 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 22:08:28 +0200 Subject: [PATCH 070/172] feat: add Secrets.sweep and Secrets.revealAll --- src/secrets.lua | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/secrets.lua diff --git a/src/secrets.lua b/src/secrets.lua new file mode 100644 index 0000000..8a3854a --- /dev/null +++ b/src/secrets.lua @@ -0,0 +1,59 @@ +-- Secret doors: a wall tile that reads as ordinary rock (Tiles.WALL, same +-- glyph, same isPassable/blocksSight as any other wall) until the player +-- happens across it, gating a single hidden alcove otherwise unreachable +-- from the rest of the floor (see builder.lua's dead-last placement). +-- Detection is a passive roll against every still-hidden door within a +-- step of the player, rolled once per player turn from Game:endPlayerTurn - +-- the same shape disarm.lua's own trap sense already takes, and for the +-- same reason: a once-per-round sweep rather than once per actor keeps a +-- seeded run's discovery turn identical regardless of how many actors +-- acted that round. Once found, the wall becomes a plain Tiles.DOOR_CLOSED - +-- opened, from then on, through Game:openDoor exactly like any door the +-- builder cut in the open, since nothing about a secret door's mechanics +-- differs from an ordinary one once it is known to be there. + +local Tiles = require("tiles") + +local Secrets = {} + +Secrets.SEARCH_NUMERATOR = 1 +Secrets.SEARCH_DENOMINATOR = 5 + +-- Sweeps every secret door within Chebyshev distance 1 of (px, py) that is +-- not yet discovered, rolling each independently. Opens each newly-found +-- door's map tile into a plain closed door on the spot and returns the +-- doors discovered this call (in level.secretDoors order), so a caller only +-- ever needs to report what was found. +function Secrets.sweep(level, rng, px, py) + local found = {} + for _, door in ipairs(level.secretDoors) do + if not door.discovered + and math.max(math.abs(door.x - px), math.abs(door.y - py)) <= 1 + and rng:chance(Secrets.SEARCH_NUMERATOR, Secrets.SEARCH_DENOMINATOR) then + door.discovered = true + level.map:set(door.x, door.y, Tiles.DOOR_CLOSED) + found[#found + 1] = door + end + end + return found +end + +-- Discovers every still-hidden secret door on the floor outright, with no +-- distance limit and no per-door roll - unlike Secrets.sweep's passive, +-- one-step, one-in-five search above. Used only by captives.lua's +-- interrogation, which trades a held captive for exactly this shortcut. +-- Returns the doors this actually revealed (in level.secretDoors order), so +-- a caller only ever needs to report what was found, same as Secrets.sweep. +function Secrets.revealAll(level) + local found = {} + for _, door in ipairs(level.secretDoors) do + if not door.discovered then + door.discovered = true + level.map:set(door.x, door.y, Tiles.DOOR_CLOSED) + found[#found + 1] = door + end + end + return found +end + +return Secrets From 2eb903beb5e5537da7202e0485f1fe4fadad76f1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 22:37:18 +0200 Subject: [PATCH 071/172] feat: add Sets.create and Sets.equippedCounts --- src/sets.lua | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/sets.lua diff --git a/src/sets.lua b/src/sets.lua new file mode 100644 index 0000000..3cb88a8 --- /dev/null +++ b/src/sets.lua @@ -0,0 +1,159 @@ +-- Item sets: matching gear that all share a `set` tag and grants an +-- escalating stat bonus once enough pieces are worn together at once - a +-- fixed weapon/armor/ring/bow catalog of named pieces (see Sets.create), +-- unlike affixes.lua's random roll bolted onto an ordinary base item. The +-- bonus is read live off however many pieces of a given set the wearer +-- currently has equipped (Sets.attackBonus/defenseBonus, wired into +-- combat.lua exactly the way runewords.lua's own live bonus is), so +-- swapping a single piece out mid-run instantly drops back to a lower +-- threshold - or none at all - rather than needing an explicit uninstall +-- step the way a socketed gem or an enchant would. + +local Items = require("items") + +local Sets = {} + +local SLOT_ORDER = { "weapon", "armor", "ring", "bow" } + +Sets.catalog = { + { + key = "ironclad", name = "Ironclad", + pieces = { + weapon = { name = "Ironclad Greatsword", power = 3 }, + armor = { name = "Ironclad Plate", power = 3 }, + ring = { name = "Ironclad Signet", attack = 0, defense = 1 }, + }, + -- Independent totals, not a stacking ladder: reaching the 3-piece + -- threshold replaces the 2-piece bonus rather than adding to it. + bonuses = { + [2] = { attack = 1, defense = 1 }, + [3] = { attack = 2, defense = 3, regen = 1 }, + }, + }, + { + key = "duskrunner", name = "Duskrunner", + pieces = { + weapon = { name = "Duskrunner Fang", power = 2 }, + bow = { name = "Duskrunner Bow", power = 2, range = 5 }, + ring = { name = "Duskrunner Loop", attack = 1, defense = 0 }, + }, + bonuses = { + [2] = { attack = 1 }, + [3] = { attack = 3, defense = 1 }, + }, + }, +} + +Sets.byKey = {} +for _, spec in ipairs(Sets.catalog) do + Sets.byKey[spec.key] = spec +end + +-- Every (set, slot) pairing flattened in a fixed order, mirroring +-- artifacts.lua's own Artifacts.order: the list a builder placement rolls +-- a single uniform index into so every piece of every set is equally +-- likely to turn up on a given floor. +Sets.order = {} +for _, spec in ipairs(Sets.catalog) do + for _, slot in ipairs(SLOT_ORDER) do + if spec.pieces[slot] then + Sets.order[#Sets.order + 1] = { set = spec.key, slot = slot } + end + end +end + +-- Builds the dropped item for `setKey`'s `slot` piece in the same shape +-- items.lua's own constructors return for that slot, tagged with the +-- `set` field every function below keys its live bonus off. +function Sets.create(setKey, slot) + local spec = Sets.byKey[setKey] + local piece = spec and spec.pieces[slot] + if not piece then return nil end + local item + if slot == "weapon" then item = Items.weapon(piece.name, piece.power) + elseif slot == "armor" then item = Items.armor(piece.name, piece.power) + elseif slot == "ring" then item = Items.ring(piece.name, piece.attack, piece.defense) + elseif slot == "bow" then item = Items.bow(piece.name, piece.power, piece.range) + end + item.set = setKey + return item +end + +-- How many of `entity`'s four equip slots currently carry a piece of each +-- set it has at least one piece of, keyed by set key. Walks a fixed slot +-- order rather than pairs() over the entity itself, so the result never +-- depends on Lua's unspecified table iteration order. +function Sets.equippedCounts(entity) + local counts = {} + for _, slot in ipairs(SLOT_ORDER) do + local item = entity[slot] + if item and item.set then + counts[item.set] = (counts[item.set] or 0) + 1 + end + end + return counts +end + +-- The richest bonus tier `count` pieces of `setKey` qualify for, and the +-- threshold it came from, or nil below its lowest threshold. +function Sets.bonusFor(setKey, count) + local spec = Sets.byKey[setKey] + if not spec or not count or count <= 0 then return nil end + local best, bestThreshold + for threshold, bonus in pairs(spec.bonuses) do + if count >= threshold and (not bestThreshold or threshold > bestThreshold) then + best, bestThreshold = bonus, threshold + end + end + return best, bestThreshold +end + +function Sets.attackBonus(entity) + local total = 0 + local counts = Sets.equippedCounts(entity) + for _, spec in ipairs(Sets.catalog) do + local bonus = Sets.bonusFor(spec.key, counts[spec.key]) + if bonus then total = total + (bonus.attack or 0) end + end + return total +end + +function Sets.defenseBonus(entity) + local total = 0 + local counts = Sets.equippedCounts(entity) + for _, spec in ipairs(Sets.catalog) do + local bonus = Sets.bonusFor(spec.key, counts[spec.key]) + if bonus then total = total + (bonus.defense or 0) end + end + return total +end + +-- Hp `entity` regenerates this turn from any active set bonus that grants +-- one, summed the same way attack/defense are above. +function Sets.regenAmount(entity) + local total = 0 + local counts = Sets.equippedCounts(entity) + for _, spec in ipairs(Sets.catalog) do + local bonus = Sets.bonusFor(spec.key, counts[spec.key]) + if bonus then total = total + (bonus.regen or 0) end + end + return total +end + +-- A human-readable "Name Npc" tag for every set currently meeting its +-- lowest threshold, in catalog order - read by render.lua's status panel +-- and by nothing that affects game logic. +function Sets.activeDescriptions(entity) + local counts = Sets.equippedCounts(entity) + local parts = {} + for _, spec in ipairs(Sets.catalog) do + local count = counts[spec.key] + if Sets.bonusFor(spec.key, count) then + parts[#parts + 1] = spec.name .. " " .. count .. "pc" + end + end + if #parts == 0 then return nil end + return table.concat(parts, ", ") +end + +return Sets From 3fe3f21de44ac9a3a1c939382fc1d7c0ecd4acb2 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 23:28:12 +0200 Subject: [PATCH 072/172] feat: add Shop.priceOf and Shop.sellValue --- src/shop.lua | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/shop.lua diff --git a/src/shop.lua b/src/shop.lua new file mode 100644 index 0000000..0494f1e --- /dev/null +++ b/src/shop.lua @@ -0,0 +1,115 @@ +-- Gold economy: a price catalog for tradeable goods plus the buy/sell math for +-- a shopkeeper's wares. Wares seeded on the floor by the builder carry a +-- `price` and `shopId` tag on the ground entry (not the item), so a purchased +-- item is indistinguishable from a found one once it reaches the inventory. + +local Items = require("items") +local Affixes = require("affixes") +local Factions = require("factions") +local Mercenaries = require("mercenaries") + +local Shop = {} + +local POTION_PRICE = { + heal = 15, poison = 6, strength = 45, haste = 25, clarity = 20, cure = 30, + confuse = 8, blind = 8, levitate = 20, +} +local SCROLL_PRICE = { teleport = 25, identify = 18, mapping = 20, fireball = 35 } +local FLAT_PRICE = { [Items.FOOD] = 5, [Items.TOME] = 55, [Items.PICK] = 40 } +local AMMO_UNIT_PRICE = 2 + +-- Catalog price for any tradeable item. Gear scales with its effective power +-- or bonuses so a heavily-affixed drop costs more than a plain one. +function Shop.priceOf(item) + if item.kind == Items.POTION then + return POTION_PRICE[item.effect] or 10 + elseif item.kind == Items.SCROLL then + return SCROLL_PRICE[item.effect] or 10 + elseif item.kind == Items.WEAPON or item.kind == Items.ARMOR then + return 20 + item.power * 15 + elseif item.kind == Items.BOW then + return 25 + item.power * 12 + elseif item.kind == Items.RING then + return 30 + (item.attack + item.defense) * 20 + elseif item.kind == Items.AMMO then + return AMMO_UNIT_PRICE * (item.count or 1) + elseif item.kind == Items.CONTRACT then + return Mercenaries.byKey[item.hireling].hireCost + else + return FLAT_PRICE[item.kind] or 10 + end +end + +function Shop.sellValue(item) + return math.max(1, Shop.priceOf(item) // 2) +end + +-- The price a shopkeeper actually charges once the player's standing with +-- the merchant guild (see factions.lua) has had its say: sour relations mark +-- goods up, warm ones discount them. `ground.price` (stamped once at +-- generation - see builder.lua) stays the neutral catalog price throughout, +-- so it keeps meaning "what a stranger pays" even as this moves the amount +-- actually charged. +function Shop.effectivePrice(game, ground) + return math.max(1, math.floor(ground.price * Factions.priceMultiplier(game.player, Factions.MERCHANT))) +end + +-- Buys a warded ground item if the player can afford its reputation-adjusted +-- price: deducts gold and transfers the item into the inventory. Returns the +-- gold actually spent, or nil without side effects when the player is short. +function Shop.buy(game, ground) + if not ground.price then return nil end + local price = Shop.effectivePrice(game, ground) + if game.player.gold < price then return nil end + game.player.gold = game.player.gold - price + game.player.inventory[#game.player.inventory + 1] = ground.item + return price +end + +-- Sells an inventory item at half its catalog price (further scaled by +-- merchant standing) and removes it. +function Shop.sell(game, index) + local item = game.player.inventory[index] + if not item then return nil end + local value = math.max(1, math.floor(Shop.sellValue(item) / Factions.priceMultiplier(game.player, Factions.MERCHANT))) + game.player.gold = game.player.gold + value + table.remove(game.player.inventory, index) + return value +end + +local weaponBases = { { name = "rapier", power = 3 }, { name = "war hammer", power = 5 } } +local armorBases = { { name = "banded mail", power = 2 } } + +-- Rolls a small selection of wares for a shop room. Weapons and armour may +-- still carry an affix, so the shop is a genuine alternative to a lucky drop +-- rather than a strictly worse copy of one. +function Shop.stock(rng) + local wares = {} + local slots = rng:range(3, 5) + for _ = 1, slots do + local roll = rng:range(1, 100) + local item + if roll <= 30 then + local effects = { "heal", "strength", "haste", "clarity", "cure" } + item = Items.potionOf(effects[rng:range(1, #effects)]) + elseif roll <= 45 then + local effects = { "identify", "mapping", "fireball" } + item = Items.scroll(effects[rng:range(1, #effects)]) + elseif roll <= 62 then + local base = weaponBases[rng:range(1, #weaponBases)] + item = Affixes.decorate(Items.weapon(base.name, base.power), Affixes.rollWeapon(rng)) + elseif roll <= 75 then + local base = armorBases[rng:range(1, #armorBases)] + item = Affixes.decorate(Items.armor(base.name, base.power), Affixes.rollArmor(rng)) + elseif roll <= 85 then + item = Items.ring("ring of the merchant", rng:range(0, 2), rng:range(0, 2)) + else + local key = Mercenaries.order[rng:range(1, #Mercenaries.order)] + item = Items.contract(key, "contract: hire the " .. Mercenaries.byKey[key].name) + end + wares[#wares + 1] = item + end + return wares +end + +return Shop From 23fdd0245e32fcbb18c24892239dfa0c4d9587ec Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 23:43:14 +0200 Subject: [PATCH 073/172] feat: add Shrines.costFor and Shrines.curseChance --- src/shrines.lua | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/shrines.lua diff --git a/src/shrines.lua b/src/shrines.lua new file mode 100644 index 0000000..679e8f5 --- /dev/null +++ b/src/shrines.lua @@ -0,0 +1,111 @@ +-- Wayside altars the player can pray at for a permanent boon at a gold price. +-- A shrine's favor wears thin with use: each prayer at the same altar is more +-- likely to backfire into a curse than the last, so greedily re-praying at a +-- single shrine is a real risk rather than a free stat pump. Shrines are plain +-- data on level.shrines, keyed by position, so they serialize like traps and +-- hazards do with no special save-format handling. + +local Status = require("status") +local Curses = require("curses") + +local Shrines = {} + +Shrines.WAR = "war" +Shrines.VITALITY = "vitality" +Shrines.WISDOM = "wisdom" +Shrines.FORTUNE = "fortune" + +-- Fixed order so a given rng draw always names the same kind. +Shrines.order = { Shrines.WAR, Shrines.VITALITY, Shrines.WISDOM, Shrines.FORTUNE } + +Shrines.names = { + [Shrines.WAR] = "shrine of war", + [Shrines.VITALITY] = "shrine of vitality", + [Shrines.WISDOM] = "shrine of wisdom", + [Shrines.FORTUNE] = "shrine of fortune", +} + +local BASE_COST = 20 +local COST_STEP = 10 + +-- Prayer cost climbs with prior use at this specific altar, the same shape as +-- its rising curse risk: the shrine asks more and gives worse odds once its +-- patience with the petitioner is spent. +function Shrines.costFor(shrine) + return BASE_COST + shrine.uses * COST_STEP +end + +-- Numerator over a denominator of 6: the first prayer risks 1-in-6, climbing +-- by one for every prior use, capped at a certainty of 6-in-6. +function Shrines.curseChance(shrine) + return math.min(shrine.uses + 1, 6), 6 +end + +local function grantBlessing(player, kind) + if kind == Shrines.WAR then + player.attack = player.attack + 1 + player.baseAttack = player.baseAttack + 1 + return "your grip tightens with martial favor" + elseif kind == Shrines.VITALITY then + player.maxHp = player.maxHp + 5 + player.hp = player.hp + 5 + return "vigor floods through you" + elseif kind == Shrines.WISDOM then + player.maxMana = player.maxMana + 3 + player.mana = player.maxMana + return "arcane insight fills your mind" + else + local cleansed = false + local kept = {} + for _, active in ipairs(player.statuses) do + if active.kind == Status.POISON or active.kind == Status.BURNING + or active.kind == Status.WEAKEN or active.kind == Status.SLOW then + cleansed = true + else + kept[#kept + 1] = active + end + end + player.statuses = kept + Status.apply(player, Status.SHIELD, 10, 1) + if Curses.cleanseWorn(player) > 0 then cleansed = true end + if cleansed then + return "fortune cleanses your ailments and wards you" + end + return "fortune wards you against harm" + end +end + +local function inflictCurse(player) + Status.apply(player, Status.WEAKEN, 8, 1) + Status.apply(player, Status.SLOW, 8, 1) + return "the shrine's favor turns against you" +end + +-- Attempts a prayer at `shrine` for `game.player`. When the price cannot be +-- met nothing is spent and this returns (false, lines). Otherwise the gold is +-- always spent, the shrine's use count always advances (so its price and +-- curse odds keep climbing), and this returns (true, lines) describing +-- whichever of a blessing or a curse landed. +function Shrines.pray(game, shrine) + local player = game.player + local cost = Shrines.costFor(shrine) + if player.gold < cost then + return false, { "you cannot afford " .. Shrines.names[shrine.kind] .. "'s price" } + end + player.gold = player.gold - cost + local numerator, denominator = Shrines.curseChance(shrine) + local cursed = game.rng:chance(numerator, denominator) + shrine.uses = shrine.uses + 1 + local line + if cursed then + line = inflictCurse(player) + else + line = grantBlessing(player, shrine.kind) + end + return true, { + string.format("you pray at the %s for %d gold", Shrines.names[shrine.kind], cost), + line, + } +end + +return Shrines From 8bfda74526a169e41e6d4e06c6789054a5d27a8c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 10:44:52 +0200 Subject: [PATCH 074/172] feat: add defOf and Skills.forClass --- src/skills.lua | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/skills.lua diff --git a/src/skills.lua b/src/skills.lua new file mode 100644 index 0000000..dd0604c --- /dev/null +++ b/src/skills.lua @@ -0,0 +1,151 @@ +-- Class skill trees. Each class (classes.lua) unlocks three skills, one +-- learned per skill point (granted on level-up, see progression.lua): an +-- active that spends the player's turn like a spell, and passives that fold +-- straight into combat/spells/ranged. Passive checks all guard on +-- `entity.skills` being present, so calling any of these on a monster (which +-- never has the field) is always a safe no-op - the same shape status.lua +-- uses for its own modifiers. + +local Los = require("los") +local Items = require("items") +local Resistances = require("resistances") + +local Skills = {} + +Skills.active = { + cleave = { name = "cleave", class = "fighter", reqLevel = 1, cooldown = 3 }, + secondWind = { name = "second wind", class = "fighter", reqLevel = 3, cooldown = 12 }, + poisonBlade = { name = "poison blade", class = "rogue", reqLevel = 5, cooldown = 6 }, + meditate = { name = "meditate", class = "mage", reqLevel = 3, cooldown = 0 }, + multishot = { name = "multishot", class = "ranger", reqLevel = 1, cooldown = 5 }, +} + +Skills.passive = { + berserk = { name = "berserk", class = "fighter", reqLevel = 5 }, + backstab = { name = "backstab", class = "rogue", reqLevel = 1 }, + evasion = { name = "evasion", class = "rogue", reqLevel = 3 }, + overload = { name = "overload", class = "mage", reqLevel = 1 }, + manaShield = { name = "mana shield", class = "mage", reqLevel = 5 }, + camouflage = { name = "camouflage", class = "ranger", reqLevel = 3 }, + trueshot = { name = "trueshot", class = "ranger", reqLevel = 5 }, +} + +local function defOf(key) + return Skills.active[key] or Skills.passive[key] +end + +-- Every skill key belonging to `classKey`, active and passive alike, in a +-- fixed alphabetical order so autopilot and tests see a stable pick sequence +-- regardless of Lua's unordered `pairs` iteration over the catalogs above. +function Skills.forClass(classKey) + local list = {} + for key, def in pairs(Skills.active) do + if def.class == classKey then list[#list + 1] = key end + end + for key, def in pairs(Skills.passive) do + if def.class == classKey then list[#list + 1] = key end + end + table.sort(list) + return list +end + +function Skills.has(entity, key) + return entity.skills ~= nil and entity.skills[key] == true +end + +-- Spends one of the player's skill points to permanently learn `key`. +-- Returns (true, message) on success or (false, message) with no state +-- changed - no point spent - on failure. +function Skills.learn(player, key) + local def = defOf(key) + if not def then return false, "there is no such skill" end + if player.class ~= def.class then return false, "that skill is not part of your training" end + if player.level < def.reqLevel then return false, "you are not experienced enough for that yet" end + if Skills.has(player, key) then return false, "you already know that" end + if (player.skillPoints or 0) <= 0 then return false, "you have no skill points to spend" end + player.skillPoints = player.skillPoints - 1 + player.skills[key] = true + return true, "you learn " .. def.name +end + +function Skills.tickCooldowns(player) + for key, turns in pairs(player.skillCooldowns) do + if turns > 0 then player.skillCooldowns[key] = turns - 1 end + end +end + +-- Passive hooks. Each is a small, pure function of entity/game state so +-- combat.lua, spells.lua, ranged.lua and game.lua can call straight into it +-- without either side needing to know about the other's active-skill logic. + +-- Rogue: a target still at full hp has not yet reacted to the fight, so the +-- first strike lands twice as hard. +function Skills.meleeDamageMultiplier(attacker, defender) + if Skills.has(attacker, "backstab") and defender.hp == defender.maxHp then + return 2 + end + return 1 +end + +-- Rogue: a trained dodge fully negates an incoming melee hit one time in +-- four. Checks `has` before touching the rng so an unlearned/unlearnable +-- defender never perturbs the rng stream. +function Skills.evades(game, defender) + if not Skills.has(defender, "evasion") then return false end + return game.rng:chance(1, 4) +end + +-- Fighter: a flat attack surge once hp drops to a third or less of max. +function Skills.attackBonus(entity) + if entity.skills and entity.skills.berserk and entity.hp <= entity.maxHp * 0.34 then + return 4 + end + return 0 +end + +-- Mage: an actively maintained ward that lapses once the pool it draws on +-- runs low. +function Skills.defenseBonus(entity) + if entity.skills and entity.skills.manaShield and (entity.mana or 0) >= 5 then + return 3 + end + return 0 +end + +-- Mage: damaging spells hit a quarter harder. +function Skills.spellDamageMultiplier(caster) + if Skills.has(caster, "overload") then return 1.25 end + return 1 +end + +-- Ranger: a steadier draw adds flat damage to every loosed arrow. +function Skills.rangedDamageBonus(entity) + if Skills.has(entity, "trueshot") then return 3 end + return 0 +end + +-- Ranger: blends into the dark. Only ever shrinks a monster's detection +-- range on a lightless floor - camouflage on a lit one would just be standing +-- in the open. +function Skills.detectionPenalty(player, dark) + if dark and Skills.has(player, "camouflage") then return 3 end + return 0 +end + +-- Active skills. Each takes the live `game` object (duck-typed, mirroring +-- spells.lua's Spells.cast) so it can log, award kills and touch level state +-- without skills.lua ever requiring the game module back. + +local EIGHT_DIRS = { + { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }, +} + +local function statAttack(entity) + return entity.effectiveAttack and entity:effectiveAttack() or entity.attack +end + +local function statDefense(entity) + return entity.effectiveDefense and entity:effectiveDefense() or entity.defense +end + From a9bc66b73dc57ef4b16000925310bc8ff51b7590 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 11:16:35 +0200 Subject: [PATCH 075/172] feat: add Skills.useCleave and Skills.useSecondWind to skills --- src/skills.lua | 130 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/skills.lua b/src/skills.lua index dd0604c..ab2781c 100644 --- a/src/skills.lua +++ b/src/skills.lua @@ -149,3 +149,133 @@ local function statDefense(entity) return entity.effectiveDefense and entity:effectiveDefense() or entity.defense end +-- Fighter: strikes the primary target in full through the normal attack path +-- (so backstab/riders/kill-xp all still apply) and every other monster +-- standing next to the player for half that raw swing. +function Skills.useCleave(game, target) + if not target or not target:isAlive() then return false, { "there is nothing there to cleave" } end + local player = game.player + local dist = math.max(math.abs(target.x - player.x), math.abs(target.y - player.y)) + if dist > 1 then return false, { "cleave only reaches an adjacent foe" } end + player.skillCooldowns.cleave = Skills.active.cleave.cooldown + local lines = {} + game:playerAttack(target) + local damageType = (player.weapon and player.weapon.damageType) or Resistances.PHYSICAL + for _, dir in ipairs(EIGHT_DIRS) do + local other = game.level:entityAt(player.x + dir[1], player.y + dir[2]) + if other and other ~= target and other:isAlive() then + local dealt = math.floor((statAttack(player) - statDefense(other)) * 0.5) + if dealt < 1 then dealt = 1 end + dealt = Resistances.apply(other, dealt, damageType) + other.hp = other.hp - dealt + lines[#lines + 1] = string.format("your cleave catches %s for %d", other.name, dealt) + if not other:isAlive() then game:awardKill(other) end + end + end + return true, lines +end + +-- Fighter: an instant breather, restoring three tenths of max hp. +function Skills.useSecondWind(game) + local player = game.player + player.skillCooldowns.secondWind = Skills.active.secondWind.cooldown + local amount = math.floor(player.maxHp * 0.3) + local before = player.hp + player.hp = math.min(player.maxHp, player.hp + amount) + return true, { string.format("you catch your breath and recover %d", player.hp - before) } +end + +-- Rogue: readies venom on the next melee swing rather than dealing damage +-- itself. Cooldown starts immediately so it cannot be re-readied before the +-- swing that consumes it even lands. +function Skills.usePoisonBlade(game) + local player = game.player + player.skillCooldowns.poisonBlade = Skills.active.poisonBlade.cooldown + player.pendingPoisonBlade = true + return true, { "your blade drips with venom" } +end + +-- Consumes a readied poison blade, if any. Called from Game:playerAttack +-- after every melee hit so the very next swing - and only that one - carries +-- the venom. +function Skills.consumePoisonBlade(player) + if player.pendingPoisonBlade then + player.pendingPoisonBlade = nil + return true + end + return false +end + +-- Mage: trades the turn for a flat mana refill, cheaper than waiting out the +-- passive one-per-turn regen from hungerTorchTick. +function Skills.useMeditate(game) + local player = game.player + local before = player.mana + player.mana = math.min(player.maxMana, player.mana + 6) + return true, { string.format("you meditate and recover %d mana", player.mana - before) } +end + +local function firstAmmoIndex(entity) + for i, item in ipairs(entity.inventory) do + if item.kind == Items.AMMO and item.count > 0 then return i end + end + return nil +end + +local function canReach(game, x1, y1, x2, y2, range) + local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2)) + return dist <= range and Los.clear(game.level.map, x1, y1, x2, y2) +end + +-- Ranger: a single arrow splits to strike up to two targets - the first for +-- a full shot, the second for half - and only ever costs one arrow. +function Skills.useMultishot(game, t1, t2) + local shooter = game.player + local bow = shooter.bow + if not bow then return false, { "you have no bow readied" } end + local ammoIdx = firstAmmoIndex(shooter) + if not ammoIdx then return false, { "you have no arrows left" } end + local targets = {} + for _, t in ipairs({ t1, t2 }) do + if t and t:isAlive() and canReach(game, shooter.x, shooter.y, t.x, t.y, bow.range) then + targets[#targets + 1] = t + end + end + if #targets == 0 then return false, { "no target in range" } end + shooter.skillCooldowns.multishot = Skills.active.multishot.cooldown + local ammo = shooter.inventory[ammoIdx] + ammo.count = ammo.count - 1 + if ammo.count <= 0 then table.remove(shooter.inventory, ammoIdx) end + local lines = {} + for i, t in ipairs(targets) do + local share = (i == 1) and 1 or 0.5 + local dealt = math.floor((shooter.baseAttack + bow.power - t:effectiveDefense()) * share) + if dealt < 1 then dealt = 1 end + dealt = Resistances.apply(t, dealt, Resistances.PHYSICAL) + t.hp = t.hp - dealt + lines[#lines + 1] = string.format("your arrow splits and strikes %s for %d", t.name, dealt) + if not t:isAlive() then game:awardKill(t) end + end + return true, lines +end + +-- Attempts to use the active skill `key` on behalf of the player. Returns +-- (true, lines) on success or (false, lines) with an explanatory line and no +-- state changed on failure - unknown skill, still on cooldown, or the +-- specific skill's own preconditions. +function Skills.use(game, key, ...) + local def = Skills.active[key] + if not def then return false, { "you have no such skill to use" } end + local player = game.player + if not Skills.has(player, key) then return false, { "you haven't learned that skill" } end + if (player.skillCooldowns[key] or 0) > 0 then return false, { "that skill is not ready yet" } end + if key == "cleave" then return Skills.useCleave(game, ...) + elseif key == "secondWind" then return Skills.useSecondWind(game) + elseif key == "poisonBlade" then return Skills.usePoisonBlade(game) + elseif key == "meditate" then return Skills.useMeditate(game) + elseif key == "multishot" then return Skills.useMultishot(game, ...) + end + return false, { "that skill does nothing yet" } +end + +return Skills From 73bc8c133bb3045cbc4d299ee2ae8238e5e9bcfc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 13:50:39 +0200 Subject: [PATCH 076/172] feat: add reachableWithin --- src/spells.lua | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/spells.lua diff --git a/src/spells.lua b/src/spells.lua new file mode 100644 index 0000000..f11f978 --- /dev/null +++ b/src/spells.lua @@ -0,0 +1,158 @@ +-- Mana-fuelled spellbook. Spells are learned permanently from tomes (see +-- Items.TOME) and cast by spending mana from a pool that regenerates each +-- player turn. Targeted spells additionally need line of sight and range, +-- checked here before any mana is spent so a bad cast is free. Damage and +-- teleport spells reach into the game the same way potion/scroll effects do. + +local Los = require("los") +local Status = require("status") +local Skills = require("skills") +local Resistances = require("resistances") +local Conductivity = require("conductivity") +local Polymorph = require("polymorph") +local Disease = require("disease") + +local Spells = {} + +local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- Every walkable, unoccupied cell within `radius` orthogonal steps of the +-- caster, excluding the caster's own cell. Used by blink so a short-range +-- teleport only ever lands somewhere the caster could in principle walk to. +local function reachableWithin(game, caster, radius) + local map = game.level.map + local start = map:index(caster.x, caster.y) + local dist = { [start] = 0 } + local queue = { { caster.x, caster.y, start } } + local head = 1 + local cells = {} + while head <= #queue do + local cur = queue[head] + head = head + 1 + local cx, cy, idx = cur[1], cur[2], cur[3] + local d = dist[idx] + if idx ~= start then cells[#cells + 1] = { x = cx, y = cy } end + if d < radius then + for _, dir in ipairs(DIRS4) do + local nx, ny = cx + dir[1], cy + dir[2] + if map:inBounds(nx, ny) and map:isWalkable(nx, ny) then + local nIdx = map:index(nx, ny) + if not dist[nIdx] then + dist[nIdx] = d + 1 + queue[#queue + 1] = { nx, ny, nIdx } + end + end + end + end + end + return cells +end + +Spells.catalog = { + missile = { + name = "magic missile", cost = 3, targeted = true, range = 6, + cast = function(game, caster, target) + local raw = math.floor((6 + (caster.level or 1)) * Skills.spellDamageMultiplier(caster)) + local dmg = Resistances.apply(target, raw, Resistances.ARCANE) + target.hp = target.hp - dmg + if dmg <= 0 then + game.log:add(string.format("a bolt of force washes over %s harmlessly", target.name)) + else + game.log:add(string.format("a bolt of force hits %s for %d", target.name, dmg)) + end + if not target:isAlive() then game:awardKill(target) end + return {} + end, + }, + heal = { + name = "minor heal", cost = 5, targeted = false, + cast = function(_, caster) + local amount = 10 + (caster.level or 1) * 2 + local before = caster.hp + caster.hp = math.min(caster.maxHp, caster.hp + amount) + return { string.format("you mend for %d", caster.hp - before) } + end, + }, + -- Mirrors effects.lua's "cure" potion handler (both lift disease and every + -- Status.HOSTILE debuff) but reachable without needing the item on hand - + -- until now the only way to shed poison/burning/stun/slow/weaken/discord/ + -- confuse/blind was to survive until they expired on their own. + cleanse = { + name = "cleansing ward", cost = 5, targeted = false, + cast = function(_, caster) + local diseaseCured = Disease.cure(caster) + local lifted = Status.cureHostile(caster) + if diseaseCured and #lifted > 0 then + return { string.format("your affliction lifts, and the %s fades", table.concat(lifted, ", ")) } + elseif diseaseCured then + return { "your affliction lifts" } + elseif #lifted > 0 then + return { string.format("the %s fades", table.concat(lifted, ", ")) } + end + return { "you feel a faint tingle, but had nothing to cleanse" } + end, + }, + shield = { + name = "arcane shield", cost = 4, targeted = false, + cast = function(_, caster) + Status.apply(caster, Status.SHIELD, 15, 1) + return { "a shimmering ward surrounds you" } + end, + }, + shock = { + name = "shock bolt", cost = 4, targeted = true, range = 5, + cast = function(game, caster, target) + local raw = math.floor((5 + (caster.level or 1)) * Skills.spellDamageMultiplier(caster)) + local dmg = Conductivity.applyLightning(target, raw) + target.hp = target.hp - dmg + if dmg <= 0 then + game.log:add(string.format("a crackle of lightning washes over %s harmlessly", target.name)) + elseif Conductivity.isWet(target) then + game.log:add(string.format("lightning arcs through the water clinging to %s for %d", target.name, dmg)) + else + game.log:add(string.format("a bolt of lightning strikes %s for %d", target.name, dmg)) + end + if not target:isAlive() then game:awardKill(target) end + return {} + end, + }, + -- Fractures the target's mind for a few turns: while it lasts, the + -- target attacks whatever creature stands nearest it - ally or foe, + -- including the caster's own companion/hireling/thrall if one strays + -- close enough - instead of chasing the caster (see infighting.lua). No + -- direct damage of its own; the value is entirely in redirecting a + -- threat, or turning a cluster of monsters against itself. + discord = { + name = "discord", cost = 5, targeted = true, range = 6, + cast = function(_, _, target) + Status.apply(target, Status.DISCORD, 8, 1) + return { string.format("%s's mind fractures with discord", target.name) } + end, + }, + -- Scrambles the target's own sense of direction for a few turns (see + -- status.lua's Status.CONFUSE, and its mirror in Game:movePlayer/Ai.act) - + -- a lucky roll can send a dangerous chaser stumbling into a wall, a lava + -- pool, or one of its own allies instead of at the caster. + confuse = { + name = "confusing hex", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.CONFUSE, 6, 1) + return { string.format("%s's senses reel with confusion", target.name) } + end, + }, + -- Snuffs the target's sight down to melee range for a few turns (see + -- status.lua's Status.BLIND, folded into Game:effectiveRadius/canSeePlayer) + -- - unlike confuse, which garbles the target's own movement, a blinded + -- monster keeps walking straight at its last known heading and simply + -- stops noticing anything past arm's reach, useful against archers and + -- casters whose whole threat depends on seeing the caster from a distance. + blind = { + name = "veil of shadow", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.BLIND, 6, 1) + return { string.format("a veil of shadow falls over %s's eyes", target.name) } + end, + }, + -- Saps the target's fighting strength for a few turns (see status.lua's + -- Status.WEAKEN). Until now WEAKEN only ever flowed toward the player - a + -- shaman's ranged curse, a cursed shrine's backfired prayer - with no way From 7516aae0e4d79cd0681b40cf5a71246b8ebab2d3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 14:12:50 +0200 Subject: [PATCH 077/172] feat: add Spells.knows and Spells.learn to spells --- src/spells.lua | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/spells.lua b/src/spells.lua index f11f978..6a24034 100644 --- a/src/spells.lua +++ b/src/spells.lua @@ -156,3 +156,94 @@ Spells.catalog = { -- Saps the target's fighting strength for a few turns (see status.lua's -- Status.WEAKEN). Until now WEAKEN only ever flowed toward the player - a -- shaman's ranged curse, a cursed shrine's backfired prayer - with no way + -- for the player to return the favor against a monster; this spell is + -- that missing direction, shaped exactly like confuse/blind above (no + -- direct damage, just a debuff riding on Status.apply). + weaken = { + name = "withering hex", cost = 4, targeted = true, range = 5, + cast = function(_, _, target) + Status.apply(target, Status.WEAKEN, 6, 1) + return { string.format("%s's strength withers", target.name) } + end, + }, + -- Rewrites the target's body outright rather than damaging or debuffing + -- it (see polymorph.lua). A pricier cast than any other targeted spell + -- here, since a lucky roll can turn a real threat into something the + -- caster can safely ignore - and an unlucky one can just as easily make + -- it worse, the same double-edged risk a shrine's backfired prayer carries. + polymorph = { + name = "polymorph", cost = 9, targeted = true, range = 5, + cast = function(game, _, target) + local oldName = target.name + local pool = Polymorph.pool(game.depth) + local spec = Polymorph.transform(target, game.rng, pool) + if not spec then + return { string.format("the magic washes over %s, changing nothing", oldName) } + end + return { string.format("%s convulses and reforms into a %s", oldName, spec.name) } + end, + }, + -- A self-buff shaped exactly like shield, but see status.lua's + -- Status.LEVITATE for the payoff: for as long as it lasts, the caster + -- drifts clear of lava, a chasm's drop, an ice tile's slide, and any + -- ground-pressure trap (Game:checkTerrainFor/checkTrapFor) instead of + -- soaking a hit. + levitate = { + name = "levitation", cost = 5, targeted = false, + cast = function(_, caster) + Status.apply(caster, Status.LEVITATE, 20, 1) + return { "you rise off the ground" } + end, + }, + blink = { + name = "blink", cost = 6, targeted = false, + cast = function(game, caster) + local free = {} + for _, cell in ipairs(reachableWithin(game, caster, 5)) do + if not game.level:entityAt(cell.x, cell.y) then + free[#free + 1] = cell + end + end + if #free == 0 then return { "there is nowhere to blink" } end + local pick = free[game.rng:range(1, #free)] + caster.x, caster.y = pick.x, pick.y + return { "you blink through space" } + end, + }, +} + +function Spells.knows(caster, key) + for _, known in ipairs(caster.spellsKnown) do + if known == key then return true end + end + return false +end + +function Spells.learn(caster, key) + if not Spells.catalog[key] then return false end + if Spells.knows(caster, key) then return false end + caster.spellsKnown[#caster.spellsKnown + 1] = key + return true +end + +-- Attempts to cast `key` on behalf of `caster`. Returns (true, lines) on a +-- successful cast, or (false, lines) with an explanatory line and no state +-- changed - no mana spent, no cooldown, nothing - on failure. +function Spells.cast(game, caster, key, target) + local spell = Spells.catalog[key] + if not spell then return false, { "you don't know that spell" } end + if not Spells.knows(caster, key) then return false, { "you haven't learned that spell" } end + if caster.mana < spell.cost then return false, { "not enough mana" } end + if spell.targeted then + if not target or not target:isAlive() then return false, { "no target" } end + local dist = math.max(math.abs(target.x - caster.x), math.abs(target.y - caster.y)) + if dist > spell.range then return false, { "out of range" } end + if not Los.clear(game.level.map, caster.x, caster.y, target.x, target.y) then + return false, { "no clear shot" } + end + end + caster.mana = caster.mana - spell.cost + return true, spell.cast(game, caster, target) or {} +end + +return Spells From 249d2d047b7e605e10d59daaa5af096174a6d19c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 15:36:02 +0200 Subject: [PATCH 078/172] feat: extend status with Status.remove and Status.cureHostile --- src/status.lua | 120 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 5 deletions(-) diff --git a/src/status.lua b/src/status.lua index e57c671..8ee9d9a 100644 --- a/src/status.lua +++ b/src/status.lua @@ -2,6 +2,12 @@ -- damage-over-time drains hp, regeneration restores it, stun skips the action, -- and haste/slow/strength feed modifiers into speed and combat. Effects are -- plain data on entity.statuses so they serialize without special handling. +-- Poison and burning route their tick damage through resistances.lua, so a +-- poison-immune undead shrugs off venom entirely and a fire-resistant hide +-- only smoulders at half the usual potency. + +local Resistances = require("resistances") +local Conductivity = require("conductivity") local Status = {} @@ -12,20 +18,54 @@ Status.REGEN = "regen" Status.HASTE = "haste" Status.SLOW = "slow" Status.STRENGTH = "strength" +Status.SHIELD = "shield" +Status.WEAKEN = "weaken" +Status.DISCORD = "discord" +Status.CONFUSE = "confuse" +Status.BLIND = "blind" +Status.LEVITATE = "levitate" + +-- Odds, rolled fresh each turn a confused actor tries to act, that its own +-- chosen direction gets overridden by a random one instead - see +-- Game:movePlayer's stumble branch and Ai.act's mirror of it for monsters. +Status.CONFUSE_NUMERATOR = 1 +Status.CONFUSE_DENOMINATOR = 2 + +-- Debuffs a cleansing effect (see effects.lua's "cure" handler) is allowed to +-- strip early. Buffs (REGEN/HASTE/STRENGTH/SHIELD/LEVITATE) are deliberately +-- excluded - a curative should not also strip a buff the entity chose to be +-- carrying. +Status.HOSTILE = { + [Status.POISON] = true, + [Status.BURNING] = true, + [Status.STUN] = true, + [Status.SLOW] = true, + [Status.WEAKEN] = true, + [Status.DISCORD] = true, + [Status.CONFUSE] = true, + [Status.BLIND] = true, +} Status.defs = { [Status.POISON] = { glyph = "p", tick = function(entity, potency) - entity.hp = entity.hp - potency - return string.format("poison saps %s for %d", entity.name, potency) + local dealt = Resistances.apply(entity, potency, Resistances.POISON) + entity.hp = entity.hp - dealt + if dealt <= 0 then + return entity.kind == "player" and "you shrug off the poison" + or string.format("%s shrugs off the poison", entity.name) + end + return string.format("poison saps %s for %d", entity.name, dealt) end, }, [Status.BURNING] = { glyph = "b", tick = function(entity, potency) - entity.hp = entity.hp - potency - return string.format("flames sear %s for %d", entity.name, potency) + local dealt = Conductivity.applyFire(entity, potency) + entity.hp = entity.hp - dealt + if dealt <= 0 then return string.format("the flames wash over %s harmlessly", entity.name) end + return string.format("flames sear %s for %d", entity.name, dealt) end, }, [Status.REGEN] = { @@ -34,7 +74,9 @@ Status.defs = { local before = entity.hp entity.hp = math.min(entity.maxHp, entity.hp + potency) if entity.hp > before then - return string.format("%s knits flesh for %d", entity.name, entity.hp - before) + local healed = entity.hp - before + return entity.kind == "player" and string.format("you knit flesh for %d", healed) + or string.format("%s knits flesh for %d", entity.name, healed) end return nil end, @@ -43,9 +85,38 @@ Status.defs = { [Status.HASTE] = { glyph = "h", speed = 60 }, [Status.SLOW] = { glyph = "l", speed = -50 }, [Status.STRENGTH] = { glyph = "S", attack = 3 }, + [Status.SHIELD] = { glyph = "d", defense = 4 }, + [Status.WEAKEN] = { glyph = "c", attack = -3 }, + -- No tick or stat modifier of its own: infighting.lua reads Status.has + -- directly to decide whether a monster's turn goes to Ai.act at all. + [Status.DISCORD] = { glyph = "o" }, + -- No tick or stat modifier of its own either: Game:movePlayer and Ai.act + -- both read Status.has directly, at the point they would otherwise commit + -- to a chosen direction, to roll whether that direction gets overridden. + [Status.CONFUSE] = { glyph = "f" }, + -- No tick or stat modifier of its own: Game:effectiveRadius and + -- Game:canSeePlayer both read Status.has directly, clamping vision instead + -- of touching hp or combat stats - confuse scrambles where you go, blind + -- scrambles what you can tell is there in the first place. + [Status.BLIND] = { glyph = "!" }, + -- No tick or stat modifier of its own: Game:checkTerrainFor and + -- Game:checkTrapFor (plus movePlayer's own trap check) read Status.has + -- directly, letting a levitating entity drift clear of lava, a chasm's + -- drop, an ice tile's slide, and any ground-pressure trap without ever + -- touching whatever would otherwise trigger them. + [Status.LEVITATE] = { glyph = "y" }, } +-- Records `kind` as active on `entity` for `duration` more ticks at `potency`, +-- or refreshes an already-active instance of the same kind to whichever of +-- the two durations and potencies is stronger. A soaked entity's own +-- dampness (see conductivity.lua) refuses a BURNING application outright, +-- before anything is recorded - returns nil and leaves entity.statuses +-- untouched, exactly as if the call had never been made. function Status.apply(entity, kind, duration, potency) + if kind == Status.BURNING and Conductivity.resistsIgnition(entity) then + return nil + end potency = potency or 1 for _, active in ipairs(entity.statuses) do if active.kind == kind then @@ -66,6 +137,36 @@ function Status.has(entity, kind) return false end +-- Strips one named status outright, independent of its remaining duration. +-- Returns whether anything was actually removed. +function Status.remove(entity, kind) + for i, active in ipairs(entity.statuses) do + if active.kind == kind then + table.remove(entity.statuses, i) + return true + end + end + return false +end + +-- Strips every active status listed in Status.HOSTILE (poison, burning, +-- stun, slow, weaken, discord, confuse, blind), leaving buffs untouched. +-- Returns the kinds actually removed, in the order they were active, for +-- callers that want to report exactly what a cleansing effect lifted. +function Status.cureHostile(entity) + local removed = {} + local kept = {} + for _, active in ipairs(entity.statuses) do + if Status.HOSTILE[active.kind] then + removed[#removed + 1] = active.kind + else + kept[#kept + 1] = active + end + end + entity.statuses = kept + return removed +end + -- Advances every effect one step, applying its tick and dropping expired ones. -- Returns the log lines produced this step, in effect order. function Status.tick(entity) @@ -104,6 +205,15 @@ function Status.attackModifier(entity) return delta end +function Status.defenseModifier(entity) + local delta = 0 + for _, active in ipairs(entity.statuses) do + local def = Status.defs[active.kind] + if def.defense then delta = delta + def.defense end + end + return delta +end + function Status.skipsTurn(entity) for _, active in ipairs(entity.statuses) do if Status.defs[active.kind].skipsTurn then return true end From 530042d48baaaeec59b583b86be8cf879e4d1047 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 15:45:19 +0200 Subject: [PATCH 079/172] feat: add Stealth.isUnaware and Stealth.sneakMultiplier --- src/stealth.lua | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/stealth.lua diff --git a/src/stealth.lua b/src/stealth.lua new file mode 100644 index 0000000..075e1f5 --- /dev/null +++ b/src/stealth.lua @@ -0,0 +1,154 @@ +-- Monster alertness state machine layered on top of ai.lua's chase/pack/etc +-- behaviours. A wild monster the builder places starts dormant (asleep) and +-- takes no action at all until something wakes it: on each of its own turns +-- a still-dormant monster rolls a wake chance driven by distance, line of +-- sight and the player's detection-penalty skills (see skills.lua's +-- camouflage), guaranteed once the player stands right next to it. Landing +-- any hit on it - melee, spell or ranged - wakes it outright regardless of +-- the roll. +-- +-- Once alert a monster hunts exactly as ai.lua always has; losing line of +-- sight for several turns in a row drops it to searching, where it makes +-- for wherever it last saw the player instead of running its usual +-- behaviour. Giving up the search with nothing found puts it back to sleep. +-- +-- A blow struck against a monster that is still dormant is a sneak attack: +-- it never saw the swing coming, so it lands for several times the normal +-- damage (see Stealth.sneakMultiplier, read by Game:playerAttack). +-- +-- Bosses, the shopkeeper and anything summoned or split mid-fight (see +-- game.lua's spawnMinion and bosses.lua's create) start "hunting" outright +-- rather than dormant, so none of this changes behaviour that was already +-- always-alert. A monster built directly without ever passing through +-- builder.lua's placement loop - as most of tests/run.lua's fixtures do - +-- also defaults to "hunting" whenever its awareness field is simply absent, +-- which keeps every existing hand-built scenario acting exactly as it did +-- before this module existed. + +local Los = require("los") +local Skills = require("skills") + +local Stealth = {} + +Stealth.DORMANT = "dormant" +Stealth.HUNTING = "hunting" +Stealth.SEARCHING = "searching" + +Stealth.WAKE_RADIUS = 6 +Stealth.TRACK_RADIUS = 20 +Stealth.LOST_TURNS_LIMIT = 4 +Stealth.SEARCH_TURNS_LIMIT = 4 +Stealth.SNEAK_MULTIPLIER = 3 + +function Stealth.isUnaware(monster) + return monster.awareness == Stealth.DORMANT +end + +-- Read by Game:playerAttack before Stealth.alert (below) fires on the same +-- swing, so the bonus is computed against the state the monster was +-- actually in the instant the blow landed. +function Stealth.sneakMultiplier(monster) + if Stealth.isUnaware(monster) then return Stealth.SNEAK_MULTIPLIER end + return 1 +end + +-- Called the instant a monster is provoked by any offensive player action - +-- whatever it was doing before, it now knows exactly where the player is. +function Stealth.alert(monster, px, py) + monster.awareness = Stealth.HUNTING + monster.lastKnownX, monster.lastKnownY = px, py + monster.lostTurns = 0 +end + +-- Called when noise.lua's flood reaches a still-dormant monster (see +-- game.lua's Game:emitNoise). Unlike alert() above - a direct provocation, +-- which snaps a monster straight to hunting with the player's exact +-- position - a monster that only heard something does not know it was the +-- player specifically, or where they actually are now: it starts searching +-- the noise's own origin instead, exactly like a hunter that just lost +-- track (see Stealth.tick's searching branch below), so it may well arrive +-- to find nothing there and give up. A monster that is not currently +-- dormant is already tracking something better than a noise and ignores +-- this outright. +function Stealth.hear(monster, x, y) + if monster.awareness ~= Stealth.DORMANT then return end + monster.awareness = Stealth.SEARCHING + monster.lastKnownX, monster.lastKnownY = x, y + monster.searchTurns = 0 +end + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +local function canSpot(game, monster, radius) + local penalty = Skills.detectionPenalty(game.player, game.level.dark) + local effective = radius - penalty + if effective < 1 then effective = 1 end + if chebyshev(monster.x, monster.y, game.player.x, game.player.y) > effective then return false end + return Los.clear(game.level.map, monster.x, monster.y, game.player.x, game.player.y) +end + +-- Whether a dormant monster notices the player this turn: guaranteed once +-- adjacent, otherwise a chance that improves the closer the player stands, +-- scaled against the monster's own (penalty-adjusted) waking radius. +local function rollWake(game, monster) + if not canSpot(game, monster, Stealth.WAKE_RADIUS) then return false end + local dist = chebyshev(monster.x, monster.y, game.player.x, game.player.y) + if dist <= 1 then return true end + local penalty = Skills.detectionPenalty(game.player, game.level.dark) + local radius = math.max(Stealth.WAKE_RADIUS - penalty, 1) + local roll = game.rng:range(1, radius) + return roll <= (radius - dist + 1) +end + +-- Advances one monster's alertness ahead of its upcoming turn and reports +-- whether game.lua should still run its normal ability/ai dispatch this +-- turn. A monster that stays dormant, or one that spends the turn marching +-- back toward wherever it last saw the player, returns false - stealth.lua +-- has already fully resolved its turn (for dormant, that means doing +-- nothing at all). +function Stealth.tick(game, monster) + local awareness = monster.awareness or Stealth.HUNTING + if awareness == Stealth.DORMANT then + if rollWake(game, monster) then + Stealth.alert(monster, game.player.x, game.player.y) + game.log:add(string.format("%s stirs awake", monster.name)) + return true + end + return false + end + if awareness == Stealth.SEARCHING then + if canSpot(game, monster, Stealth.TRACK_RADIUS) then + Stealth.alert(monster, game.player.x, game.player.y) + return true + end + monster.searchTurns = (monster.searchTurns or 0) + 1 + if monster.lastKnownX and (monster.x ~= monster.lastKnownX or monster.y ~= monster.lastKnownY) then + game:stepMonsterToward(monster, monster.lastKnownX, monster.lastKnownY) + end + if monster.searchTurns >= Stealth.SEARCH_TURNS_LIMIT then + monster.awareness = Stealth.DORMANT + monster.lastKnownX, monster.lastKnownY = nil, nil + monster.lostTurns, monster.searchTurns = nil, nil + game.log:add(string.format("%s gives up the search and settles back down", monster.name)) + end + return false + end + -- hunting + if canSpot(game, monster, Stealth.TRACK_RADIUS) then + monster.lastKnownX, monster.lastKnownY = game.player.x, game.player.y + monster.lostTurns = 0 + return true + end + monster.lostTurns = (monster.lostTurns or 0) + 1 + if monster.lostTurns >= Stealth.LOST_TURNS_LIMIT and monster.lastKnownX then + monster.awareness = Stealth.SEARCHING + monster.searchTurns = 0 + game.log:add(string.format("%s loses track of you", monster.name)) + return Stealth.tick(game, monster) + end + return true +end + +return Stealth From b7a55eabedf1d28d1e8b2c569f20cb53596b976e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 15:57:47 +0200 Subject: [PATCH 080/172] feat: add Streaks.new, Streaks.record and Terrain.slide --- src/streaks.lua | 57 ++++++++++++++++++++++++++++++++ src/terrain.lua | 49 ++++++++++++++++++++++++++++ src/throwing.lua | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/streaks.lua create mode 100644 src/terrain.lua create mode 100644 src/throwing.lua diff --git a/src/streaks.lua b/src/streaks.lua new file mode 100644 index 0000000..42f3660 --- /dev/null +++ b/src/streaks.lua @@ -0,0 +1,57 @@ +-- Win/loss streak tracking over meta.lua's own run history: not "how many +-- wins in total" (Meta.recordRun's own totalRuns/wins tally already answers +-- that) but how many of the *most recent* runs in a row ended the same +-- way - the number that actually swings the instant a lucky or unlucky run +-- lands, unlike a lifetime win rate that a hundred prior runs already numbs. + +local Streaks = {} + +Streaks.HISTORY_CAP = 50 + +function Streaks.new() + return {} +end + +-- Appends `won` (a boolean) as the newest entry, trimming the oldest once +-- history grows past Streaks.HISTORY_CAP - a streak only ever needs to look +-- back so far, and an unbounded history would grow meta.lua's save file +-- forever for no further benefit. +function Streaks.record(history, won) + history[#history + 1] = won + while #history > Streaks.HISTORY_CAP do + table.remove(history, 1) + end +end + +-- Length of the run of identical outcomes ending at the most recent entry, +-- and which outcome that is - (0, nil) for an empty history. A single win +-- right after a loss reads as a streak of exactly 1, not 0: the run just +-- recorded is itself part of its own streak. +function Streaks.current(history) + if #history == 0 then return 0, nil end + local outcome = history[#history] + local length = 0 + for i = #history, 1, -1 do + if history[i] ~= outcome then break end + length = length + 1 + end + return length, outcome +end + +-- The longest run of consecutive wins anywhere in history, not just the one +-- still active - a player's all-time best winning streak survives even +-- after it eventually breaks. +function Streaks.bestWinStreak(history) + local best, running = 0, 0 + for _, won in ipairs(history) do + if won then + running = running + 1 + if running > best then best = running end + else + running = 0 + end + end + return best +end + +return Streaks diff --git a/src/terrain.lua b/src/terrain.lua new file mode 100644 index 0000000..1c16dea --- /dev/null +++ b/src/terrain.lua @@ -0,0 +1,49 @@ +-- Permanent map terrain that punishes careless movement, as opposed to +-- hazards.lua's fire and gas which are timed and spawned mid-game. Lava +-- scorches whoever stands on it every time they step there, a chasm drops +-- the player to the floor below (or swallows a monster outright), and ice +-- keeps sliding a traveller in the direction they started moving until +-- something stops them. None of it spreads, fades, or needs saving beyond +-- the glyph already baked into the map by the builder. + +local Tiles = require("tiles") +local Status = require("status") +local Resistances = require("resistances") + +local Terrain = {} + +Terrain.LAVA_DAMAGE = 8 + +-- Slides entity through consecutive ice tiles in direction (dx, dy), from +-- wherever it just came to rest. Stops the instant the current tile isn't +-- ice, the next cell is unwalkable, or another actor already occupies it - +-- so ice never carries a traveller through a wall or on top of someone else. +function Terrain.slide(level, entity, dx, dy) + local map = level.map + while map:get(entity.x, entity.y) == Tiles.ICE do + local nx, ny = entity.x + dx, entity.y + dy + if not map:isWalkable(nx, ny) then break end + if level:entityAt(nx, ny) then break end + entity.x, entity.y = nx, ny + end +end + +-- Applies lava's burn to whoever is currently standing on it. Returns a log +-- line, or nil when the tile underfoot isn't lava. +function Terrain.afflictLava(level, entity) + if level.map:get(entity.x, entity.y) ~= Tiles.LAVA then return nil end + local dealt = Resistances.apply(entity, Terrain.LAVA_DAMAGE, Resistances.FIRE) + entity.hp = entity.hp - dealt + if dealt <= 0 then + return entity.kind == "player" and "you wade through the lava unharmed" + or string.format("%s wades through the lava unharmed", entity.name) + end + Status.apply(entity, Status.BURNING, 2, math.max(1, dealt // 3)) + return string.format("molten rock sears %s for %d", entity.name, dealt) +end + +function Terrain.isChasm(level, entity) + return level.map:get(entity.x, entity.y) == Tiles.CHASM +end + +return Terrain diff --git a/src/throwing.lua b/src/throwing.lua new file mode 100644 index 0000000..93eb7ca --- /dev/null +++ b/src/throwing.lua @@ -0,0 +1,85 @@ +-- Player ranged option that costs no mana: any spare weapon or potion sitting +-- in the inventory (not the one currently wielded) can be hurled at a monster +-- in range and line of sight. A thrown weapon hits harder than a melee swing +-- and lands where it struck, ready to be picked back up; a thrown potion +-- shatters and visits its effect on the target instead of the thrower, which +-- only makes sense for potions with a hostile reading. + +local Los = require("los") +local Status = require("status") +local Items = require("items") +local Hazards = require("hazards") +local Resistances = require("resistances") + +local Throwing = {} + +Throwing.RANGE = 6 +local POWER_MULT = 2 + +-- Hostile reading of each potion effect. Potions with no meaningful effect on +-- an enemy (healing, strength) just shatter harmlessly rather than helping +-- whoever they were thrown at. +local hostileEffects = { + poison = function(game, target) + Status.apply(target, Status.POISON, 6, 3) + Hazards.spawnGas(game.level, target.x, target.y, 4, 2) + return string.format("the potion shatters, poisoning %s in a cloud of fumes", target.name) + end, + haste = function(_, target) + Status.apply(target, Status.HASTE, 10, 1) + return string.format("the potion shatters, and %s bolts away", target.name) + end, + confuse = function(_, target) + Status.apply(target, Status.CONFUSE, 6, 1) + return string.format("the potion shatters, and %s reels in confusion", target.name) + end, + blind = function(_, target) + Status.apply(target, Status.BLIND, 6, 1) + return string.format("the potion shatters in a blinding flash, and %s is struck blind", target.name) + end, + -- Same hostile reading as haste above: lifting a dangerous target off the + -- ground lets it drift clear of whatever lava, chasm, or trap the thrower + -- was counting on to block or punish it (see status.lua's Status.LEVITATE), + -- so handing it to an enemy is a liability, not a gift. + levitate = function(_, target) + Status.apply(target, Status.LEVITATE, 10, 1) + return string.format("the potion shatters, and %s rises off the ground", target.name) + end, +} + +function Throwing.canThrow(item) + return item.kind == Items.WEAPON or item.kind == Items.POTION +end + +function Throwing.canReach(game, x1, y1, x2, y2) + local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2)) + return dist <= Throwing.RANGE and Los.clear(game.level.map, x1, y1, x2, y2) +end + +-- Resolves a thrown item against a target. Returns (true, lines) if the item +-- left the thrower's hand - whether or not it did anything useful on arrival +-- - or (false, lines) if the throw never happened at all. +function Throwing.resolve(game, thrower, item, target) + if not Throwing.canThrow(item) then + return false, { "that can't be thrown" } + end + if not target or not target:isAlive() then + return false, { "no target" } + end + if not Throwing.canReach(game, thrower.x, thrower.y, target.x, target.y) then + return false, { "you have no clear throw" } + end + if item.kind == Items.WEAPON then + local dmg = Resistances.apply(target, item.power * POWER_MULT, item.damageType or Resistances.PHYSICAL) + target.hp = target.hp - dmg + local lines = { string.format("your thrown %s hits %s for %d", item.name, target.name, dmg) } + game.level.floorItems[#game.level.floorItems + 1] = { x = target.x, y = target.y, item = item } + if not target:isAlive() then game:awardKill(target) end + return true, lines + end + local handler = hostileEffects[item.effect] + local line = handler and handler(game, target) or "the potion shatters harmlessly" + return true, { line } +end + +return Throwing From 598dfaf79ef5f16e035e9f5ecc7dbd5a774a83af Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 16:04:55 +0200 Subject: [PATCH 081/172] feat: extend tiles with Tiles.isLava and Tiles.isChasm --- src/tiles.lua | 36 ++++++++++++++++++++++++++++ src/titles.lua | 37 +++++++++++++++++++++++++++++ src/vaults.lua | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/titles.lua create mode 100644 src/vaults.lua diff --git a/src/tiles.lua b/src/tiles.lua index 9a5fa42..aa8cb2d 100644 --- a/src/tiles.lua +++ b/src/tiles.lua @@ -14,6 +14,14 @@ Tiles.DOOR_LOCKED = "x" Tiles.TRAP = "^" Tiles.WATER = "~" Tiles.RUBBLE = ":" +Tiles.LAVA = "%" +Tiles.CHASM = "V" +Tiles.ICE = "-" +Tiles.LEVER = "H" +Tiles.LEVER_PULLED = "I" +Tiles.VAULT_DOOR = "K" +Tiles.GATE_CLOSED = "8" +Tiles.GATE_OPEN = "9" local props = { [Tiles.WALL] = { walkable = false, blocksSight = true, door = false }, @@ -26,6 +34,14 @@ local props = { [Tiles.TRAP] = { walkable = true, blocksSight = false, door = false }, [Tiles.WATER] = { walkable = true, blocksSight = false, door = false }, [Tiles.RUBBLE] = { walkable = true, blocksSight = false, door = false }, + [Tiles.LAVA] = { walkable = true, blocksSight = false, door = false, lava = true }, + [Tiles.CHASM] = { walkable = true, blocksSight = false, door = false, chasm = true }, + [Tiles.ICE] = { walkable = true, blocksSight = false, door = false, ice = true }, + [Tiles.LEVER] = { walkable = true, blocksSight = false, door = false }, + [Tiles.LEVER_PULLED] = { walkable = true, blocksSight = false, door = false }, + [Tiles.VAULT_DOOR] = { walkable = false, blocksSight = true, door = false, vault = true }, + [Tiles.GATE_CLOSED] = { walkable = false, blocksSight = true, door = false, gate = true }, + [Tiles.GATE_OPEN] = { walkable = true, blocksSight = false, door = false, gate = true }, } local wallProps = props[Tiles.WALL] @@ -50,4 +66,24 @@ function Tiles.isClosedDoor(glyph) return glyph == Tiles.DOOR_CLOSED or glyph == Tiles.DOOR_LOCKED end +function Tiles.isLava(glyph) + return Tiles.of(glyph).lava == true +end + +function Tiles.isChasm(glyph) + return Tiles.of(glyph).chasm == true +end + +function Tiles.isIce(glyph) + return Tiles.of(glyph).ice == true +end + +function Tiles.isVaultDoor(glyph) + return Tiles.of(glyph).vault == true +end + +function Tiles.isGate(glyph) + return Tiles.of(glyph).gate == true +end + return Tiles diff --git a/src/titles.lua b/src/titles.lua new file mode 100644 index 0000000..2f9324d --- /dev/null +++ b/src/titles.lua @@ -0,0 +1,37 @@ +-- Renown-gated titles: a flavor rank meta.lua's own banked renown climbs +-- through, one tier per crossed threshold, entirely separate from a +-- character's class - a title describes how far this save file's whole +-- history has come, not what any single run's build looked like. + +local Titles = {} + +Titles.LADDER = { + { threshold = 0, name = "Whelp" }, + { threshold = 50, name = "Delver" }, + { threshold = 150, name = "Warden" }, + { threshold = 350, name = "Vanquisher" }, + { threshold = 700, name = "Warren Lord" }, + { threshold = 1500, name = "Undying" }, +} + +-- The highest title whose threshold `renown` clears, walked from the top of +-- the ladder down so a renown total that clears several thresholds at once +-- always resolves to the hardest-earned name rather than the first match. +function Titles.forRenown(renown) + for i = #Titles.LADDER, 1, -1 do + if renown >= Titles.LADDER[i].threshold then return Titles.LADDER[i].name end + end + return Titles.LADDER[1].name +end + +-- Renown still needed to clear the next threshold above `renown`, or nil +-- once renown has already cleared the top of the ladder and there is +-- nothing left to climb toward. +function Titles.toNext(renown) + for _, tier in ipairs(Titles.LADDER) do + if renown < tier.threshold then return tier.threshold - renown end + end + return nil +end + +return Titles diff --git a/src/vaults.lua b/src/vaults.lua new file mode 100644 index 0000000..d0a9659 --- /dev/null +++ b/src/vaults.lua @@ -0,0 +1,64 @@ +-- Lever-gated treasure rooms. A vault door only swings open once every lever +-- sharing its id has been pulled; the builder places the door at a doorway +-- pinch point and scatters its levers only on the side already reachable +-- without crossing it, so the puzzle is always solvable and the reward +-- sealed behind the door is always genuinely gated by it (see builder.lua's +-- vault placement, drawn dead last so it never perturbs any earlier +-- system's rng sequence). + +local Items = require("items") +local Affixes = require("affixes") + +local Vaults = {} + +local treasureWeapons = { + { name = "vault greatsword", power = 7 }, + { name = "vault warhammer", power = 8 }, +} +local treasureArmors = { + { name = "vault aegis", power = 6 }, +} + +-- A guaranteed affix on a base already well above anything the floor's own +-- loot table can roll at this depth - the deterministic reward for solving +-- the puzzle, distinct from a normal drop's coin-flip affix (see +-- affixes.rollWeapon/rollArmor). +function Vaults.treasure(rng, depth) + if rng:chance(1, 2) then + local base = treasureWeapons[rng:range(1, #treasureWeapons)] + local item = Items.weapon(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.weapon[rng:range(1, #Affixes.weapon)]) + end + local base = treasureArmors[rng:range(1, #treasureArmors)] + local item = Items.armor(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.armor[rng:range(1, #Affixes.armor)]) +end + +function Vaults.doorFor(level, id) + for _, door in ipairs(level.vaultDoors) do + if door.id == id then return door end + end + return nil +end + +local function allPulled(level, id) + for _, lever in ipairs(level.vaultLevers) do + if lever.id == id and not lever.pulled then return false end + end + return true +end + +-- Pulls `lever`, an idempotent no-op returning false if it was already +-- thrown. Returns (true, door) the instant this pull completes every lever +-- sharing its id and the door has not opened yet, or (true, nil) for a pull +-- that leaves the vault still sealed (or already open). +function Vaults.pull(level, lever) + if lever.pulled then return false end + lever.pulled = true + if not allPulled(level, lever.id) then return true, nil end + local door = Vaults.doorFor(level, lever.id) + if door and not door.opened then return true, door end + return true, nil +end + +return Vaults From 2dbaa308133eedcf7e1ed03d21768f10f910af11 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 16:18:53 +0200 Subject: [PATCH 082/172] feat(gen): extend init with shuffledDirs and Maze.generate --- src/gen/init.lua | 7 +++- src/gen/maze.lua | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/gen/maze.lua diff --git a/src/gen/init.lua b/src/gen/init.lua index ab6916f..719464a 100644 --- a/src/gen/init.lua +++ b/src/gen/init.lua @@ -12,10 +12,15 @@ Gen.generators = { bsp = require("gen.bsp").generate, cellular = require("gen.cellular").generate, drunkard = require("gen.drunkard").generate, + maze = require("gen.maze").generate, } -- Deterministic ordering of names for callers that want to cycle generators by --- depth without depending on hash iteration order. +-- depth without depending on hash iteration order. gen.maze is deliberately +-- absent: builder.lua's chosenGenerator indexes into this list by depth, and +-- every existing seed-pinned test downstream of it already assumes today's +-- four-way cycle. Reaching a maze floor is always explicit, via +-- options.generator = "maze" (see Gen.has/Gen.build below). Gen.order = { "rooms", "bsp", "cellular", "drunkard" } function Gen.has(name) diff --git a/src/gen/maze.lua b/src/gen/maze.lua new file mode 100644 index 0000000..445eb15 --- /dev/null +++ b/src/gen/maze.lua @@ -0,0 +1,90 @@ +-- Recursive-backtracker maze: a perfect spanning tree of one-wide corridors +-- over a grid of cells spaced two tiles apart, carved by an iterative +-- depth-first walk (an explicit stack, never Lua's own call stack, so +-- nothing here bounds how large a maze can grow). The result is a topology +-- none of the other generators produce - dense and twisty, with exactly one +-- path between any two cells - unlike the open rooms of gen.rooms/gen.bsp +-- or the organic blobs of gen.cellular/gen.drunkard. A braiding pass +-- afterward knocks down a fixed fraction of the walls still separating two +-- already-carved cells, so a fraction of what would otherwise be dead ends +-- gain a second way out instead. Selectable like any other entry in +-- gen.init's registry, but deliberately left out of Gen.order: nothing +-- downstream (builder.lua's rng-ordering discipline, every seed-pinned +-- depth test) currently expects the auto-cycle to ever hand it a floor, and +-- inserting a fifth entry there would reshuffle which of the existing four +-- generators every depth from five onward gets, and with it every rng draw +-- a builder places afterward. A maze floor is instead reached deliberately, +-- via options.generator = "maze". + +local Map = require("map") +local Tiles = require("tiles") + +local Maze = {} + +Maze.BRAID_NUMERATOR = 1 +Maze.BRAID_DENOMINATOR = 5 + +local DIRS = { { 2, 0 }, { -2, 0 }, { 0, 2 }, { 0, -2 } } + +local function shuffledDirs(rng) + local dirs = { DIRS[1], DIRS[2], DIRS[3], DIRS[4] } + for i = 4, 2, -1 do + local j = rng:range(1, i) + dirs[i], dirs[j] = dirs[j], dirs[i] + end + return dirs +end + +function Maze.generate(width, height, rng, options) + options = options or {} + local braidNumerator = options.braidNumerator or Maze.BRAID_NUMERATOR + local braidDenominator = options.braidDenominator or Maze.BRAID_DENOMINATOR + local map = Map.new(width, height) + + local function key(x, y) return y * width + x end + local visited = {} + + local startX, startY = 2, 2 + visited[key(startX, startY)] = true + map:set(startX, startY, Tiles.FLOOR) + local stack = { { startX, startY } } + + while #stack > 0 do + local top = stack[#stack] + local cx, cy = top[1], top[2] + local advanced = false + for _, dir in ipairs(shuffledDirs(rng)) do + local nx, ny = cx + dir[1], cy + dir[2] + if nx >= 2 and ny >= 2 and nx <= width - 1 and ny <= height - 1 and not visited[key(nx, ny)] then + local wx, wy = cx + dir[1] // 2, cy + dir[2] // 2 + map:set(wx, wy, Tiles.FLOOR) + map:set(nx, ny, Tiles.FLOOR) + visited[key(nx, ny)] = true + stack[#stack + 1] = { nx, ny } + advanced = true + break + end + end + if not advanced then stack[#stack] = nil end + end + + -- Braiding: a wall cell with floor on exactly one opposing pair of sides + -- (east-west or north-south) sits between two cells the backtracker above + -- already connected some longer way around; knocking a fixed fraction of + -- these down trims some, but never all, of the maze's dead ends. + for y = 3, height - 2 do + for x = 3, width - 2 do + if map:get(x, y) == Tiles.WALL then + local linksEastWest = map:get(x - 1, y) == Tiles.FLOOR and map:get(x + 1, y) == Tiles.FLOOR + local linksNorthSouth = map:get(x, y - 1) == Tiles.FLOOR and map:get(x, y + 1) == Tiles.FLOOR + if (linksEastWest or linksNorthSouth) and rng:chance(braidNumerator, braidDenominator) then + map:set(x, y, Tiles.FLOOR) + end + end + end + end + + return map +end + +return Maze From 15e1cbd8a5b5a113eb109346f37a129d831c76e5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 16:30:10 +0200 Subject: [PATCH 083/172] feat: extend warren with splitCommaList and playLoop --- warren.lua | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/warren.lua b/warren.lua index c7f7ef8..72c4cd1 100644 --- a/warren.lua +++ b/warren.lua @@ -1,12 +1,44 @@ -- Headless entry point. Examples: -- lua warren.lua --seed 42 --auto 50 -- lua warren.lua --seed 7 --auto 30 --save run.sav +-- lua warren.lua --seed 3 --class ranger --auto 50 -- lua warren.lua --load run.sav +-- lua warren.lua --meta legacy.sav --seed 3 --auto 200 +-- lua warren.lua --meta legacy.sav --buy vigor +-- lua warren.lua --meta legacy.sav --report +-- lua warren.lua --meta legacy.sav --seed 3 --auto 200 --mutators famine,nightblind +-- lua warren.lua --seed 3 --auto 200 --morgue run.morgue +-- lua warren.lua --seed 3 --auto 200 --verify +-- lua warren.lua --challenge 42 --auto 200 +-- lua warren.lua --meta legacy.sav --export-csv runs.csv +-- lua warren.lua --meta legacy.sav --export-compendium bestiary.csv +-- lua warren.lua --seed 3 --class fighter --play +-- lua warren.lua --load run.sav --play local scriptDir = arg[0]:match("^(.*)[/\\]") or "." package.path = scriptDir .. "/src/?.lua;" .. scriptDir .. "/../src/?.lua;" .. package.path local Game = require("game") +local Meta = require("meta") +local Compendium = require("compendium") +local Graves = require("graves") +local Ascension = require("ascension") +local Morgue = require("morgue") +local Leaderboard = require("leaderboard") +local Replay = require("replay") +local Challenges = require("challenges") +local Analytics = require("analytics") +local Titles = require("titles") +local Export = require("export") +local Streaks = require("streaks") + +local function splitCommaList(text) + local list = {} + for part in text:gmatch("[^,]+") do + list[#list + 1] = part + end + return list +end local function parseArgs(argv) local opts = { seed = os.time() & 0xFFFF, auto = 0 } @@ -17,13 +49,121 @@ local function parseArgs(argv) elseif flag == "--auto" then opts.auto = math.tointeger(tonumber(argv[i + 1])); i = i + 2 elseif flag == "--save" then opts.save = argv[i + 1]; i = i + 2 elseif flag == "--load" then opts.load = argv[i + 1]; i = i + 2 + elseif flag == "--class" then opts.class = argv[i + 1]; i = i + 2 + elseif flag == "--meta" then opts.metaPath = argv[i + 1]; i = i + 2 + elseif flag == "--buy" then opts.buy = argv[i + 1]; i = i + 2 + elseif flag == "--report" then opts.report = true; i = i + 1 + elseif flag == "--morgue" then opts.morguePath = argv[i + 1]; i = i + 2 + elseif flag == "--verify" then opts.verify = true; i = i + 1 + elseif flag == "--mutators" then opts.mutators = splitCommaList(argv[i + 1]); i = i + 2 + elseif flag == "--challenge" then opts.challengeDay = math.tointeger(tonumber(argv[i + 1])); i = i + 2 + elseif flag == "--export-csv" then opts.exportPath = argv[i + 1]; i = i + 2 + elseif flag == "--export-compendium" then opts.exportCompendiumPath = argv[i + 1]; i = i + 2 + elseif flag == "--play" then opts.play = true; i = i + 1 else i = i + 1 end end return opts end +-- Interactive terminal loop: renders the current frame, reads one line of +-- input, and hands it straight to Game:manualStep - the same command +-- interpreter (see commands.lua) a scripted test drives without a real +-- terminal attached. "quit"/"exit"/"q" leave the run exactly where it +-- stands rather than ending it, so a save taken right after --play still +-- captures whatever the last accepted command left behind. Runs until the +-- player dies, wins, quits, or stdin closes (a redirected script of +-- commands hitting EOF ends the loop the same way an interactive quit +-- would). +local function playLoop(game) + io.write(game:render(), "\n") + while game.player:isAlive() and not game.won do + io.write("> ") + io.flush() + local line = io.read("l") + if not line then break end + local command = line:match("^%s*(.-)%s*$") + if command == "quit" or command == "exit" or command == "q" then + break + elseif command ~= "" then + local _, message = game:manualStep(command) + if message then io.write(message, "\n") end + io.write(game:render(), "\n") + end + end + if game.won then + io.write("you escape with the Amulet of Warren - you win!\n") + elseif not game.player:isAlive() then + io.write("you have died.\n") + end +end + +local function reportMeta(meta) + local toNext = Titles.toNext(meta.renown) + local titleLine = toNext and string.format("%s (%d renown to next title)", Titles.forRenown(meta.renown), toNext) + or string.format("%s (top of the ladder)", Titles.forRenown(meta.renown)) + io.write(string.format("renown %d | runs %d | wins %d | best depth %d | win rate %.0f%% | title %s\n", + meta.renown, meta.totalRuns, meta.wins, meta.bestDepth, Analytics.winRate(meta) * 100, titleLine)) + local streakLength, streakOutcome = Streaks.current(meta.runHistory) + io.write(string.format("streak: %d %s in a row | best win streak %d\n", + streakLength, streakOutcome == nil and "runs" or (streakOutcome and "wins" or "losses"), + Streaks.bestWinStreak(meta.runHistory))) + for _, def in ipairs(Meta.PERKS) do + io.write(string.format(" perk %-12s %s (%d renown)\n", + def.key, Meta.isOwned(meta, def.key) and "[owned]" or "[locked]", def.cost)) + end + io.write("challenges:\n") + for _, preset in ipairs(Challenges.CATALOG) do + io.write(" " .. Challenges.describe(preset) .. "\n") + end + for _, ach in ipairs(Meta.ACHIEVEMENTS) do + io.write(string.format(" achievement %-14s %s\n", + ach.key, meta.achievements[ach.key] and "[unlocked]" or "[locked]")) + end + for _, def in ipairs(Ascension.MUTATORS) do + io.write(string.format(" mutator %-12s %s (+%d%% renown) %s\n", + def.key, def.desc, def.renownBonus, Ascension.isUnlocked(meta, def.key) and "[unlocked]" or "[locked]")) + end + io.write("compendium:\n") + for _, entry in ipairs(Compendium.entries(meta.compendium or Compendium.new())) do + if entry.seen then + io.write(string.format(" %-14s slain %-3d %s\n", entry.name, entry.kills, entry.flavor or "")) + else + io.write(string.format(" %-14s [undiscovered]\n", entry.key)) + end + end + local board = meta.leaderboard or Leaderboard.new() + io.write("leaderboard:\n") + for rank, entry in ipairs(Leaderboard.top(board)) do + io.write(string.format(" #%-2d %-6d %-10s depth %-2d turn %-5d seed %s %s\n", + rank, entry.score, entry.className, entry.depth, entry.turn, tostring(entry.seed), + entry.won and "(won)" or "")) + end + if #board > 0 then + io.write(string.format(" average score %.1f | favorite class %s\n", + Analytics.averageScore(board), Analytics.favoriteClass(board) or "none")) + end +end + local opts = parseArgs(arg) -local game = opts.load and Game.load(opts.load) or Game.new(opts.seed) + +local challengePreset +if opts.challengeDay then + challengePreset, opts.seed = Challenges.forDay(opts.challengeDay) + opts.class = challengePreset.class + opts.mutators = challengePreset.mutators + io.write(string.format("challenge: %s, seed %d\n", Challenges.describe(challengePreset), opts.seed)) +end + +local meta = opts.metaPath and Meta.load(opts.metaPath) or nil + +if meta and opts.buy then + local success, message = Meta.purchase(meta, opts.buy) + io.write(message, "\n") + if not success then os.exit(1) end +end + +local game = opts.load and Game.load(opts.load) + or Game.new(opts.seed, { class = opts.class, meta = meta, graves = meta and meta.graves, mutators = opts.mutators }) for _ = 1, opts.auto do if not game.player:isAlive() then break end From 6d392eed880b968c1bd076c48f88d8b447d29898 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 16:48:36 +0200 Subject: [PATCH 084/172] feat: extend warren --- warren.lua | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/warren.lua b/warren.lua index 72c4cd1..f4d146c 100644 --- a/warren.lua +++ b/warren.lua @@ -170,9 +170,94 @@ for _ = 1, opts.auto do game:autoStep() end -io.write(game:render(), "\n") +if opts.play then + playLoop(game) +else + io.write(game:render(), "\n") +end + +if meta and opts.auto > 0 and (not game.player:isAlive() or game.won) then + local stats = game:metaStats() + local gained = Meta.renownFor(stats) + local unlocked = Meta.recordRun(meta, stats) + io.write(string.format("run recorded: +%d renown (total %d)\n", gained, meta.renown)) + for _, name in ipairs(unlocked) do + io.write("achievement unlocked: " .. name .. "\n") + end + local report = Morgue.build(game, meta) + local score = Morgue.score(report) + local percentile = Analytics.percentile(meta.leaderboard, score) + local rank = Leaderboard.record(meta.leaderboard, { + score = score, className = report.className, depth = report.deepestDepth, + turn = report.turn, won = report.won, seed = game.seed, + }) + if rank then + io.write(string.format("leaderboard: rank #%d with score %d (beat %.0f%% of prior runs)\n", + rank, score, percentile * 100)) + end +end + +if meta and opts.auto > 0 and not game.player:isAlive() then + Graves.record(meta, game:graveEntry()) + io.write(string.format("a grave is left at depth %d\n", game.deepestDepth)) +end + +if meta then + Meta.save(meta, opts.metaPath) +end + +if opts.exportPath then + local file = io.open(opts.exportPath, "w") + file:write(Export.leaderboardCsv(meta and meta.leaderboard or Leaderboard.new())) + file:close() + io.write("leaderboard exported to " .. opts.exportPath .. "\n") +end + +if opts.exportCompendiumPath then + local file = io.open(opts.exportCompendiumPath, "w") + file:write(Export.compendiumCsv(meta and meta.compendium or Compendium.new())) + file:close() + io.write("compendium exported to " .. opts.exportCompendiumPath .. "\n") +end + +if opts.report then + reportMeta(meta or Meta.new()) +end + +if opts.verify and not opts.load then + if meta then + io.write("replay skipped: verification only covers meta-free runs (see replay.lua)\n") + else + local report = Morgue.build(game) + local matches, mismatchField, score = + Replay.verify(opts.seed, { class = opts.class, mutators = opts.mutators }, opts.auto, report) + if matches then + io.write(string.format("replay verified: seed %d reproduces this run (score %d)\n", opts.seed, score)) + else + io.write(string.format("replay mismatch at '%s': seed %d replayed to a different outcome (score %d)\n", + mismatchField, opts.seed, score)) + end + end +end if opts.save then game:save(opts.save) io.write("saved to " .. opts.save .. "\n") end + +if opts.morguePath and (game.won or not game.player:isAlive()) then + local report = Morgue.build(game, meta) + local file = io.open(opts.morguePath, "w") + file:write(Morgue.format(report)) + file:close() + io.write(string.format("morgue written to %s (score %d)\n", opts.morguePath, Morgue.score(report))) +end + +if challengePreset and opts.auto > 0 then + local satisfied, reason = Challenges.satisfiedBy(challengePreset, game) + if satisfied then + io.write("challenge honored: every requirement was active for this run\n") + else + io.write("challenge broken: " .. reason .. "\n") + end +end From 25c412bcd34cb731a7abd5c89125fde5b9be326b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:03:25 +0200 Subject: [PATCH 085/172] test: cover tests.mazeGeneratorIsDeterministicDiffersAcrossSeedsAndLe... --- tests/run.lua | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 07021bb..bb5aee7 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -15,6 +15,7 @@ local Entity = require("entity") local Combat = require("combat") local Items = require("items") local Affixes = require("affixes") +local Shop = require("shop") local Identify = require("identify") local Effects = require("effects") local Status = require("status") @@ -26,8 +27,78 @@ local Gen = require("gen.init") local Connect = require("gen.connect") local Level = require("level") local Dungeon = require("dungeon") +local Builder = require("builder") local Serialize = require("serialize") local Game = require("game") +local Progression = require("progression") +local Spells = require("spells") +local Abilities = require("abilities") +local Hazards = require("hazards") +local Shrines = require("shrines") +local Classes = require("classes") +local Skills = require("skills") +local Crafting = require("crafting") +local Companions = require("companions") +local Artifacts = require("artifacts") +local Bosses = require("bosses") +local Elites = require("elites") +local Quests = require("quests") +local Meta = require("meta") +local Alchemy = require("alchemy") +local Resistances = require("resistances") +local Factions = require("factions") +local Encumbrance = require("encumbrance") +local Render = require("render") +local Terrain = require("terrain") +local Vaults = require("vaults") +local Runewords = require("runewords") +local Atmosphere = require("atmosphere") +local Stealth = require("stealth") +local Compendium = require("compendium") +local Nodes = require("nodes") +local Sets = require("sets") +local Mimics = require("mimics") +local Curses = require("curses") +local Momentum = require("momentum") +local Conductivity = require("conductivity") +local Corpses = require("corpses") +local Digging = require("digging") +local Sanity = require("sanity") +local Hunger = require("hunger") +local Events = require("events") +local Graves = require("graves") +local Proficiency = require("proficiency") +local Ascension = require("ascension") +local Durability = require("durability") +local Disarm = require("disarm") +local Chests = require("chests") +local Reanimation = require("reanimation") +local Secrets = require("secrets") +local Mercenaries = require("mercenaries") +local Morgue = require("morgue") +local Leaderboard = require("leaderboard") +local Replay = require("replay") +local Challenges = require("challenges") +local Analytics = require("analytics") +local Titles = require("titles") +local Export = require("export") +local Streaks = require("streaks") +local Nemesis = require("nemesis") +local Biomes = require("biomes") +local Scavenging = require("scavenging") +local Infighting = require("infighting") +local Blocks = require("blocks") +local SaveGame = require("savegame") +local Polymorph = require("polymorph") +local Prefab = require("gen.prefab") +local Noise = require("noise") +local Disease = require("disease") +local Captives = require("captives") +local Horde = require("horde") +local Branches = require("branches") +local Phases = require("phases") +local Commands = require("commands") +local Examine = require("examine") local passed, failed = 0, 0 local failures = {} @@ -251,6 +322,123 @@ function tests.generators() end end +function tests.mazeGeneratorIsDeterministicDiffersAcrossSeedsAndLeavesEveryFloorCellReachable() + local m1, reach1 = Gen.build("maze", 60, 24, Rng.new(2024)) + local m2 = Gen.build("maze", 60, 24, Rng.new(2024)) + eq(table.concat(m1.tiles), table.concat(m2.tiles), "maze is deterministic") + local other = Gen.build("maze", 60, 24, Rng.new(2025)) + ok(table.concat(m1.tiles) ~= table.concat(other.tiles), "maze differs across seeds") + local walkable = 0 + for y = 1, m1.height do for x = 1, m1.width do if m1:isWalkable(x, y) then walkable = walkable + 1 end end end + ok(walkable > 0, "maze carves open floor") + eq(#reach1, walkable, "maze leaves every floor cell reachable without gen.connect stitching anything extra") +end + +function tests.mazeGeneratorIsRegisteredButExcludedFromTheDepthCycle() + ok(Gen.has("maze"), "maze is a selectable generator") + local found = false + for _, name in ipairs(Gen.order) do + if name == "maze" then found = true end + end + ok(not found, "maze is deliberately absent from the auto-cycled depth order") +end + +function tests.mazeGeneratorProducesOneWideCorridorsRatherThanOpenRooms() + -- Braiding deliberately opens a handful of the diagonal cells a pure + -- backtracker never touches (see mazeGeneratorBraidingOpensSomeDeadEnds... + -- below), so this checks the unbraided core algorithm's own shape rather + -- than the default, braided output. + local map = Gen.generators.maze(60, 24, Rng.new(7), { braidNumerator = 0, braidDenominator = 1 }) + local openTwoByTwoBlocks = 0 + for y = 2, map.height - 2 do + for x = 2, map.width - 2 do + if map:isWalkable(x, y) and map:isWalkable(x + 1, y) + and map:isWalkable(x, y + 1) and map:isWalkable(x + 1, y + 1) then + openTwoByTwoBlocks = openTwoByTwoBlocks + 1 + end + end + end + eq(openTwoByTwoBlocks, 0, "a maze never opens a 2x2 block of floor the way a room-based generator does") +end + +function tests.mazeGeneratorBraidingOpensSomeDeadEndsWithoutTouchingConnectivity() + local braided = Gen.generators.maze(60, 24, Rng.new(3), { braidNumerator = 1, braidDenominator = 1 }) + local unbraided = Gen.generators.maze(60, 24, Rng.new(3), { braidNumerator = 0, braidDenominator = 1 }) + local function walkableCount(map) + local total = 0 + for y = 1, map.height do for x = 1, map.width do if map:isWalkable(x, y) then total = total + 1 end end end + return total + end + ok(walkableCount(braided) > walkableCount(unbraided), "raising the braid odds opens strictly more floor") + local reachable = Connect.ensure(unbraided, Rng.new(3)) + eq(#reachable, walkableCount(unbraided), "an unbraided perfect maze is already fully connected on its own") +end + +function tests.prefabVaultSizeMatchesItsWidestRowAndItsRowCount() + local vault = { rows = { "####", "#..#", "####" } } + local w, h = Prefab.size(vault) + eq(w, 4, "size reports the width of a template row") + eq(h, 3, "size reports the number of template rows") +end + +function tests.prefabVaultFitsAcceptsAnAnchorWithAWallGapToEveryBorderAndRejectsOneWithout() + local map = Map.new(20, 15) + local vault = { rows = { "###", "#.#", "###" } } + ok(Prefab.fits(map, vault, 2, 2), "an anchor two cells in on every side leaves a wall gap to the border") + ok(not Prefab.fits(map, vault, 1, 2), "an anchor flush against the west border is rejected") + ok(not Prefab.fits(map, vault, 2, 1), "an anchor flush against the north border is rejected") + local w, h = Prefab.size(vault) + ok(not Prefab.fits(map, vault, map.width - w + 2, 2), "an anchor that spills past the east border is rejected") + ok(not Prefab.fits(map, vault, 2, map.height - h + 2), "an anchor that spills past the south border is rejected") +end + +function tests.prefabVaultStampWritesEveryGlyphAndCollectsTreasureMonsterAndLootMarkersInMapOrder() + local map = Map.new(20, 15) + local vault = { + name = "testVault", + rows = { + "#####", + "#*M+#", + "#.L.#", + "#####", + }, + } + local result = Prefab.stamp(map, vault, 3, 4) + eq(result.name, "testVault", "the stamp result remembers the template's own name") + eq(map:get(3, 4), Tiles.WALL, "a '#' glyph becomes a wall tile") + eq(map:get(4, 5), Tiles.FLOOR, "a '*' treasure marker still becomes walkable floor") + eq(map:get(5, 5), Tiles.FLOOR, "an 'M' monster marker still becomes walkable floor") + eq(map:get(6, 5), Tiles.DOOR_CLOSED, "a '+' glyph becomes a closed door") + eq(map:get(5, 6), Tiles.FLOOR, "an 'L' loot marker still becomes walkable floor") + eq(#result.treasure, 1, "exactly one treasure anchor is collected") + eq(result.treasure[1].x, 4, "the treasure anchor's x is offset by the stamp's own anchor") + eq(result.treasure[1].y, 5, "the treasure anchor's y is offset by the stamp's own anchor") + eq(#result.monsters, 1, "exactly one monster anchor is collected") + eq(result.monsters[1].x, 5, "the monster anchor's x is offset by the stamp's own anchor") + eq(#result.loot, 1, "exactly one loot anchor is collected") + eq(result.loot[1].x, 5, "the loot anchor's x is offset by the stamp's own anchor") + eq(result.loot[1].y, 6, "the loot anchor's y is offset by the stamp's own anchor") +end + +function tests.prefabVaultStampIsDeterministicForAFixedAnchor() + local vault = Prefab.vaults[1] + local m1, m2 = Map.new(20, 15), Map.new(20, 15) + Prefab.stamp(m1, vault, 3, 3) + Prefab.stamp(m2, vault, 3, 3) + eq(table.concat(m1.tiles), table.concat(m2.tiles), "stamping the same vault at the same anchor twice is byte-identical") +end + +function tests.builderCanBeExplicitlySteeredToAMazeFloorAndStillYieldsAPlayableLevel() + local level = Builder.build(3, Rng.new(11), { generator = "maze" }) + eq(level.generator, "maze", "the level remembers it was built with the maze generator") + eq(level.map:get(level.upX, level.upY), Tiles.STAIRS_UP, "a maze floor still gets its up stairs") + eq(level.map:get(level.downX, level.downY), Tiles.STAIRS_DOWN, "a maze floor still gets its down stairs") + local path = Astar.find(level.map, level.upX, level.upY, level.downX, level.downY, function(x, y) + return not level.map:isPassable(x, y, true) + end) + ok(path ~= nil, "the down stairs are reachable from the up stairs on a maze floor") +end + function tests.connectivityRepair() local map = Map.new(15, 7) for y = 2, 4 do for x = 2, 4 do map:set(x, y, Map.FLOOR) end end From 9601bf29491a50222de591b54250f9210e427e42 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:12:53 +0200 Subject: [PATCH 086/172] test: cover tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing --- tests/run.lua | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index bb5aee7..9754330 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -538,6 +538,221 @@ function tests.monsterAiDeterministic() ok(x1 == x2 and y1 == y2, "erratic behaviour is deterministic per seed") end +-- A confused chaser rolls the exact same override chance every turn that a +-- confused player rolls in Game:movePlayer (see status.lua's shared +-- CONFUSE_NUMERATOR/DENOMINATOR) - some seeds let its own chase behaviour +-- through untouched, others send it stumbling in a random direction instead. +function tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing() + local sawChase, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) + game.level.entities = { orc } + Status.apply(orc, Status.CONFUSE, 5, 1) + Ai.act(game, orc) + if orc.x == 11 and orc.y == 6 then + sawChase = true + else + sawStumble = true + end + if sawChase and sawStumble then break end + end + ok(sawChase, "a confused monster's roll sometimes lets its normal behaviour run") + ok(sawStumble, "a confused monster's roll sometimes overrides its normal behaviour") +end + +-- Without the status at all, a chaser's step toward the player is exactly as +-- deterministic and direct as tests.monsterAiChase already established - +-- confusion introduces randomness only once Status.CONFUSE is actually +-- present, never as a side effect of the roll/pick machinery merely existing. +function tests.aiActWithoutConfusionAlwaysRunsTheNormalBehaviour() + for seed = 1, 20 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) + game.level.entities = { orc } + Ai.act(game, orc) + eq(orc.x, 11, "an unconfused chaser always steps straight toward the player") + eq(orc.y, 6, "an unconfused chaser never drifts off its own row") + end +end + +-- Every ai.lua behaviour (chase/pack/ranged/flee/erratic/tunnel) resolves +-- through Game:canSeePlayer, so blinding a monster is a single choke point +-- that mutes every one of them at once - no per-behaviour special casing +-- needed. An archer is the sharpest demonstration: with sight it holds its +-- ground and snipes from range (tests.monsterAiRanged); blinded at the exact +-- same distance it neither shoots nor closes in, it just idles in place. +function tests.aiActBlindedArcherNeitherShootsNorChasesEvenWithAClearShot() + local game = stagedGame(1, arena(21, 11), 3, 6) + local archer = Monsters.create(Monsters.byKey.archer, 1, 8, 6, 1) + game.level.entities = { archer } + Status.apply(archer, Status.BLIND, 6, 1) + local hpBefore = game.player.hp + Ai.act(game, archer) + eq(game.player.hp, hpBefore, "a blinded archer with an otherwise clear shot does not fire") + eq(archer.x, 8, "a blinded archer does not close the distance either") + eq(archer.y, 6, "a blinded archer does not drift off its own row") +end + +function tests.gameAdjacentAllyFindsACompanionStandingRightNextToAMonster() + local game = stagedGame(1, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) + game.level.entities[#game.level.entities + 1] = orc + eq(game:adjacentAlly(orc), golem, "a companion standing right next to a monster is found") +end + +function tests.gameAdjacentAllyIgnoresAHostileMonsterAndOnlyCountsCardinalAdjacency() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 5, 11, 6, 2) + game.level.entities = { orc, rat } + eq(game:adjacentAlly(orc), nil, "a hostile monster standing adjacent is never mistaken for an ally") + + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 9, 7 + eq(game:adjacentAlly(orc), nil, + "a companion only diagonally adjacent does not count - cardinal-only, like Game:playerAdjacent") + golem.x, golem.y = 9, 6 + eq(game:adjacentAlly(orc), golem, "moving the same companion to cardinal adjacency makes it found") +end + +function tests.gameAdjacentAllyBreaksTiesByTheLowerEntityIdAndIgnoresADeadAlly() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 999) + game.level.entities = { orc } + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + golem.x, golem.y = 9, 6 + hireling.x, hireling.y = 11, 6 + ok(golem.id < hireling.id, "sanity check: the companion summoned first has the lower entity id") + eq(game:adjacentAlly(orc), golem, "ties between two equally adjacent allies break toward the lower entity id") + golem.hp = 0 + eq(game:adjacentAlly(orc), hireling, "a dead ally is skipped even though it is still standing adjacent") +end + +-- Reproduces the exact shape Game:aStarNeverRoutesAMonsterThroughABlockedCell +-- already proved for a pushable block: a wall spanning every row except one +-- floor gap leaves stepMonsterToward with no route at all once something is +-- standing in that gap. The difference is what stands there - a living +-- companion, not an inert block - so where a block earns a permanent, +-- correct stalemate, a companion should be fought, not stonewalled forever. +function tests.aiChaseAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = orc + local hpBefore = golem.hp + Ai.act(game, orc) + ok(golem.hp < hpBefore, "a chaser with no route around a blocking ally fights it instead of idling") + eq(orc.x, 12, "the chaser never steps onto the ally's occupied cell") + eq(orc.y, 6, "the chaser's y is unchanged - it attacked in place rather than moving") +end + +function tests.aiChasePrefersAnAdjacentPlayerOverAnAdjacentAllyWhenBothAreInMeleeRange() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 4, 6, 1) + game.level.entities = { orc } + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 4, 7 + local playerHpBefore, golemHpBefore = game.player.hp, golem.hp + Ai.act(game, orc) + ok(game.player.hp < playerHpBefore, "a monster adjacent to both the player and an ally still attacks the player") + eq(golem.hp, golemHpBefore, "the ally adjacent to the same monster takes no damage when the player is also in range") +end + +function tests.aiPackAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local kobold = Monsters.create(Monsters.byKey.kobold, 10, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = kobold + local hpBefore = golem.hp + Ai.act(game, kobold) + ok(golem.hp < hpBefore, "a pack monster with no route around a blocking ally fights it instead of idling") +end + +function tests.aiTunnelAttacksAnAllyBlockingItsOnlyGapInsteadOfIdling() + local level = arena(21, 11) + for y = 2, 10 do level.map:set(11, y, Tiles.WALL) end + level.map:set(11, 6, Tiles.FLOOR) + local game = stagedGame(1, level, 3, 6) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local burrower = Entity.new({ + id = game:nextMonsterId(), x = 12, y = 6, glyph = "b", name = "rock burrower", kind = "monster", + ai = "tunnel", hp = 20, maxHp = 20, attack = 5, defense = 0, speed = 100, + }) + game.level.entities[#game.level.entities + 1] = burrower + local hpBefore = golem.hp + Ai.act(game, burrower) + ok(golem.hp < hpBefore, "a burrower blocked by a living ally fights it rather than trying (and failing) to path around") + eq(burrower.x, 12, "the burrower stays put while it attacks - it never steps onto the ally's cell") +end + +function tests.aiRangedAttacksABlockingAllyWhenItHasNoLineOfSightToThePlayerAtAll() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(8, y, Tiles.WALL) end + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local archer = Monsters.create(Monsters.byKey.archer, 6, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = archer + local hpBefore, playerHpBefore = golem.hp, game.player.hp + Ai.act(game, archer) + ok(golem.hp < hpBefore, "an archer with no line of sight to the player still fights an ally standing next to it") + eq(game.player.hp, playerHpBefore, "the archer never reaches or harms the player while its view is fully walled off") +end + +function tests.gameMoveMonsterAttacksAnAllyItBumpsIntoInsteadOfDoingNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) + game.level.entities[#game.level.entities + 1] = orc + local hpBefore = golem.hp + game:moveMonster(orc, -1, 0) + ok(golem.hp < hpBefore, "bumping into an ally's cell fights it rather than silently doing nothing") + eq(orc.x, golem.x + 1, "the bumping monster does not step onto the ally's occupied cell") +end + +-- Mirrors aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own seed +-- loop: a confused monster's random stumble (Ai.act -> Game:moveMonster) can +-- land it squarely on a blocking ally's cell exactly as often as the +-- player's, so the fix belongs in Game:moveMonster itself, not only in the +-- deliberate per-behaviour pathing checks above. +-- Isolates the stumble path from chase()'s own independent attackBlockingAlly +-- check: Ai.behaviours.chase is swapped for a stub that only records whether +-- it ran, so a turn where the confusion roll fails (falling through to real +-- chase) is detectable and excluded - any hp drop counted here can only have +-- come from the top-of-Ai.act stumble branch calling Game:moveMonster +-- directly, never from chase's own fix a few lines below it. +function tests.aiActConfusedMonsterCanStumbleIntoAttackingABlockingAllyInsteadOfIdling() + local sawAttack = false + local realChase = Ai.behaviours.chase + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 1) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + game.level.entities[#game.level.entities + 1] = orc + Status.apply(orc, Status.CONFUSE, 5, 1) + local hpBefore = golem.hp + local behaviourRan = false + Ai.behaviours.chase = function() behaviourRan = true end + Ai.act(game, orc) + Ai.behaviours.chase = realChase + if not behaviourRan and golem.hp < hpBefore then + sawAttack = true + break + end + end + ok(sawAttack, "a confused monster's stumble (Ai.act's own branch, not chase's) can land an attack on a blocking ally via Game:moveMonster") +end + function tests.doorsAndKeys() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.inventory = {} From 62e694d52260b4b57df741cea8ff89ef81de3fd8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:13:56 +0200 Subject: [PATCH 087/172] test: cover tests.gameSpringTrapConfuseEffectAppliesToAnyVictimNotJus... --- tests/run.lua | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 9754330..b813b74 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -785,6 +785,137 @@ function tests.traps() poisonGame.level.traps = { { x = 4, y = 6, effect = "poison", revealed = false } } poisonGame:movePlayer(1, 0) eq(Status.has(poisonGame.player, Status.POISON), true, "a poison trap poisons the stepper") + + local confuseGame = stagedGame(1, arena(21, 11), 3, 6) + confuseGame.level.traps = { { x = 4, y = 6, effect = "confuse", revealed = false } } + confuseGame:movePlayer(1, 0) + eq(Status.has(confuseGame.player, Status.CONFUSE), true, "a confuse trap confuses the stepper") + eq(confuseGame.level.traps[1].revealed, true, "a sprung confuse trap becomes revealed too") + + local blindGame = stagedGame(1, arena(21, 11), 3, 6) + blindGame.level.traps = { { x = 4, y = 6, effect = "blind", revealed = false } } + blindGame:movePlayer(1, 0) + eq(Status.has(blindGame.player, Status.BLIND), true, "a blind trap blinds the stepper") + eq(blindGame.level.traps[1].revealed, true, "a sprung blind trap becomes revealed too") + eq(blindGame:effectiveRadius(), 1, "the freshly blinded stepper's own sight radius is clamped straight away") + + local stunGame = stagedGame(1, arena(21, 11), 3, 6) + stunGame.level.traps = { { x = 4, y = 6, effect = "stun", revealed = false } } + stunGame:movePlayer(1, 0) + eq(Status.has(stunGame.player, Status.STUN), true, "a stun trap stuns the stepper") + eq(stunGame.level.traps[1].revealed, true, "a sprung stun trap becomes revealed too") +end + +-- Game:springTrap is written to work against whatever `victim` it is handed, +-- not just self.player - the same generic shape a builder-placed trap needs +-- to also harm a monster that blunders into a player-planted trap kit. +function tests.gameSpringTrapConfuseEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "confuse", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.CONFUSE), true, "a confuse trap sprung on a monster confuses that monster") + eq(Status.has(game.player, Status.CONFUSE), false, "the player is untouched by a trap sprung on someone else") +end + +function tests.gameSpringTrapBlindEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "blind", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.BLIND), true, "a blind trap sprung on a monster blinds that monster") + eq(Status.has(game.player, Status.BLIND), false, "the player is untouched by a trap sprung on someone else") +end + +-- STUN previously had the opposite gap from BLIND (see abilities.lua's +-- tryShriek comment): a player's crafted weapon could already stun a +-- monster, but nothing on the environment/monster side could ever stun the +-- player. A trap is a fitting, low-risk source for that missing direction - +-- rare and avoidable/disarmable, unlike say an elite's on-hit rider firing +-- on every single blow it lands. +function tests.gameSpringTrapStunEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "stun", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.STUN), true, "a stun trap sprung on a monster stuns that monster") + eq(Status.has(game.player, Status.STUN), false, "the player is untouched by a trap sprung on someone else") +end + +-- A confused player's every attempted move rolls the same override chance a +-- confused monster's Ai.act does (status.lua's shared CONFUSE_NUMERATOR/ +-- DENOMINATOR): in the middle of an open arena every one of the 8 compass +-- directions leads to open floor, so either outcome always still moves the +-- player somewhere and always spends the turn. +function tests.gameMovePlayerConfusionEitherFollowsTheChosenDirectionOrStumblesElsewhere() + local sawStraight, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 10, 6) + Status.apply(game.player, Status.CONFUSE, 5, 1) + local consumesTurn = game:movePlayer(1, 0) + ok(consumesTurn, "a confused player's attempted move always spends the turn on open floor") + if game.player.x == 11 and game.player.y == 6 then + sawStraight = true + else + sawStumble = true + end + if sawStraight and sawStumble then break end + end + ok(sawStraight, "confusion sometimes lets the chosen direction through untouched") + ok(sawStumble, "confusion sometimes overrides the chosen direction") +end + +-- The turn-spending contract that makes confusion different from an ordinary +-- rejected move: bumping a wall on purpose spends nothing (see +-- tests.doorsAndKeys's locked-door case and moveHandler's own "you cannot go +-- that way"), but a confused player who stumbles into a wall instead of their +-- own chosen direction still spent the turn on the stumble itself. +function tests.gameMovePlayerConfusionStumbleIntoAWallStillSpendsTheTurn() + local level = arena(21, 11) + -- Wall off every neighbour of (10, 6) except due east, so any redirect + -- other than the player's own chosen direction bumps a wall in place. + for _, cell in ipairs({ { 9, 5 }, { 10, 5 }, { 11, 5 }, { 9, 6 }, { 9, 7 }, { 10, 7 }, { 11, 7 } }) do + level.map:set(cell[1], cell[2], Tiles.WALL) + end + local sawBlockedStumble = false + for seed = 1, 500 do + local game = stagedGame(seed, level, 10, 6) + Status.apply(game.player, Status.CONFUSE, 5, 1) + local consumesTurn = game:movePlayer(1, 0) + ok(consumesTurn, "a confused move, straight or stumbled into this walled-off pocket, always spends the turn") + if game.player.x == 10 and game.player.y == 6 then + sawBlockedStumble = true + end + end + ok(sawBlockedStumble, "at least one seed in range stumbled into a wall instead of moving east") +end + +-- Without Status.CONFUSE present at all, Game:movePlayer never consults the +-- rng for a redirect - the roll/pick machinery added for confusion must be a +-- true no-op for every unconfused move the engine already relied on. +function tests.gameMovePlayerWithoutConfusionNeverStumbles() + for seed = 1, 20 do + local game = stagedGame(seed, arena(21, 11), 10, 6) + game:movePlayer(1, 0) + eq(game.player.x, 11, "an unconfused move always lands exactly where it was aimed") + eq(game.player.y, 6, "an unconfused move never drifts off its own row") + end +end + +-- Game:allyActsConfused is the companion/hireling/thrall counterpart of the +-- confusion machinery above - unlike Game:movePlayer and Ai.act, it is never +-- called at all unless a caller opts in, so an entity without Status.CONFUSE +-- must bail out before it ever touches the rng (companionsAct*/mercenaryAct*/ +-- reanimationAct* below cover the roll actually firing). +function tests.gameAllyActsConfusedReturnsFalseImmediatelyWithoutTheStatus() + local game = stagedGame(1, arena(21, 11), 5, 5) + local golem = Companions.summon(game, "golem") + local rollsBefore = game.rng.state + eq(game:allyActsConfused(golem), false, "an ally without Status.CONFUSE never triggers a stumble") + eq(game.rng.state, rollsBefore, "checking an unconfused ally never advances the rng") end function tests.lightAndDark() @@ -816,6 +947,19 @@ function tests.identification() eq(Identify.label(a, healing), "potion of healing", "identifying reveals the true name") end +function tests.identifyGivesEveryPotionTypeItsOwnDistinctUnidentifiedLook() + local state = Identify.new(Rng.new(3)) + local seen = {} + for _, entry in ipairs(Items.potionTypes) do + local label = Identify.label(state, Items.potionOf(entry.effect)) + ok(seen[label] == nil, "no two potion types share an unidentified look (collided on " .. label .. ")") + seen[label] = true + end + local count = 0 + for _ in pairs(seen) do count = count + 1 end + eq(count, #Items.potionTypes, "every potion type, including confuse, got its own look") +end + function tests.effects() local game = Game.new(31) game.player.hp = 10 @@ -824,6 +968,12 @@ function tests.effects() Effects.apply("poison", game, game.player, { power = 3 }) eq(Status.has(game.player, Status.POISON), true, "poison effect afflicts the target") + Effects.apply("confuse", game, game.player, {}) + eq(Status.has(game.player, Status.CONFUSE), true, "confuse effect afflicts the drinker just like poison") + + Effects.apply("blind", game, game.player, {}) + eq(Status.has(game.player, Status.BLIND), true, "blind effect afflicts the drinker just like confuse") + Effects.apply("mapping", game, game.player, {}) local map = game.level.map local exploredAll = true From a1f6ad3feb512c45964bca115f9aa32e6ddc0c22 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:52:05 +0200 Subject: [PATCH 088/172] test: cover tests.effectsMessagesAddressThePlayerInSecondPersonNotThi... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index b813b74..e0e83d7 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -999,6 +999,179 @@ function tests.effects() Effects.apply("fireball", blastGame, blastGame.player, {}) eq(orc.hp, 12, "fireball burns nearby monsters") eq(Status.has(orc, Status.BURNING), true, "fireball sets monsters alight") + ok(Hazards.at(blastGame.level, orc.x, orc.y, Hazards.FIRE) ~= nil, + "fireball leaves a lingering flame under a scorched monster") + ok(Hazards.at(blastGame.level, blastGame.player.x, blastGame.player.y, Hazards.FIRE) ~= nil, + "fireball also ignites the ground at its own point of origin") +end + +-- Effects.apply's only real caller (Game:useItem) always hands it the +-- player, whose entity.name is the pronoun "you" - a subject that takes the +-- plain verb form English gives second person ("you heal"), not the +-- third-person-singular form ("heals") a named monster like "orc" would take +-- through the exact same handler. Before this fix every line below actually +-- read "you heals 5", "you is poisoned", etc. on every real potion/scroll +-- use in the game - these are the literal strings a real player sees. +function tests.effectsMessagesAddressThePlayerInSecondPersonNotThirdPersonSingular() + local game = Game.new(31) + game.player.hp = 10 + eq(Effects.apply("heal", game, game.player, { power = 5 })[1], "you heal 5", + "heal addresses the player correctly, not 'you heals 5'") + eq(Effects.apply("poison", game, game.player, { power = 3 })[1], "you are poisoned", + "poison addresses the player correctly, not 'you is poisoned'") + eq(Effects.apply("strength", game, game.player, {})[1], "you feel mightier", + "strength addresses the player correctly, not 'you feels mightier'") + eq(Effects.apply("haste", game, game.player, {})[1], "you speed up", + "haste addresses the player correctly, not 'you speeds up'") + eq(Effects.apply("fortify", game, game.player, {})[1], "you feel fortified", + "fortify addresses the player correctly, not 'you feels fortified'") + eq(Effects.apply("levitate", game, game.player, {})[1], "you rise off the ground", + "levitate addresses the player correctly, not 'you rises off the ground'") + eq(Effects.apply("confuse", game, game.player, {})[1], "your mind reels with confusion", + "confuse addresses the player possessively, not \"you's mind reels with confusion\"") + eq(Effects.apply("blind", game, game.player, {})[1], "you are struck blind", + "blind addresses the player correctly, not 'you is struck blind'") + eq(Effects.apply("teleport", game, game.player, {})[1], "you blink away", + "teleport addresses the player correctly, not 'you blinks away'") +end + +function tests.effectsClarityMessageAddressesThePlayerPossessively() + local game = Game.new(31) + Sanity.adjust(game.player, -20) + local line = Effects.apply("clarity", game, game.player, { power = 10 })[1] + ok(contains(line, "your mind steadies"), "clarity addresses the player possessively, not \"you's mind steadies\"") +end + +function tests.effectsCureMessagesAddressThePlayerPossessivelyForAllFourOutcomes() + local game = Game.new(31) + Disease.infect(game.player, Disease.ROT) + Status.apply(game.player, Status.POISON, 5, 2) + eq(Effects.apply("cure", game, game.player, {})[1], "your affliction is cleansed away, and the poison fades", + "curing both addresses the player possessively, not \"you's affliction ... you's poison\"") + + Disease.infect(game.player, Disease.ROT) + eq(Effects.apply("cure", game, game.player, {})[1], "your affliction is cleansed away", + "curing disease alone addresses the player possessively, not \"you's affliction\"") + + Status.apply(game.player, Status.POISON, 5, 2) + eq(Effects.apply("cure", game, game.player, {})[1], "your poison fades", + "curing a status alone addresses the player possessively, not \"you's poison fades\"") + + eq(Effects.apply("cure", game, game.player, {})[1], "you feel a faint tingle, but had nothing to cleanse", + "curing nothing addresses the player correctly, not 'you feels a faint tingle'") +end + +-- The exact same pronoun problem effects.lua had also lived in status.lua's +-- own POISON/REGEN tick messages, disease.lua's tick messages, and +-- terrain.lua's lava message - every one of them templated a +-- third-person-singular verb onto entity.name with no branch at all for the +-- player, whose name is "you". These four tests cover each of those sites +-- directly against a real player entity. +function tests.statusPoisonShrugAndRegenKnitMessagesAddressThePlayerInSecondPerson() + local immunePlayer = Entity.new({ + x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 10, maxHp = 10, attack = 1, defense = 0, + }) + immunePlayer.resist = { poison = Resistances.IMMUNE } + Status.apply(immunePlayer, Status.POISON, 3, 4) + eq(Status.tick(immunePlayer)[1], "you shrug off the poison", + "an immune player shrugs off poison in second person, not 'you shrugs off the poison'") + + local hurtPlayer = Entity.new({ + x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 5, maxHp = 10, attack = 1, defense = 0, + }) + Status.apply(hurtPlayer, Status.REGEN, 2, 3) + eq(Status.tick(hurtPlayer)[1], "you knit flesh for 3", + "a regenerating player knits flesh in second person, not 'you knits flesh for 3'") +end + +function tests.diseaseTickMessagesAddressThePlayerInSecondPersonForEruptionDamageAndEscalation() + local player = Entity.new({ + x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 1000, maxHp = 1000, attack = 1, defense = 0, + }) + Disease.infect(player, Disease.PARASITES) + local incubation = Disease.defs[Disease.PARASITES].incubation + for _ = 1, incubation - 1 do Disease.tick(player) end + eq(Disease.tick(player), "you break out in gut parasites", + "eruption addresses the player in second person, not 'you breaks out in gut parasites'") + eq(Disease.tick(player), "you are wracked by gut parasites for 1", + "a damaging tick addresses the player in second person, not 'you is wracked by gut parasites for 1'") + + local stageTurns = Disease.defs[Disease.PARASITES].stageTurns + for _ = 2, stageTurns - 1 do Disease.tick(player) end + eq(Disease.tick(player), "your gut parasites worsens", + "escalation addresses the player possessively, not \"you's gut parasites worsens\"") +end + +function tests.terrainAfflictLavaUnharmedMessageAddressesThePlayerInSecondPerson() + local level = arena(21, 11) + level.map:set(5, 5, Tiles.LAVA) + local game = stagedGame(1, level, 5, 5) + game.player.resist = { fire = Resistances.IMMUNE } + eq(Terrain.afflictLava(level, game.player), "you wade through the lava unharmed", + "an immune player wading through lava is addressed in second person, not 'you wades through the lava unharmed'") +end + +function tests.hazards() + local level = arena(21, 11) + + local fire = Hazards.ignite(level, 5, 5, 3, 4) + eq(fire.kind, Hazards.FIRE, "ignite drops a fire hazard") + eq(Hazards.at(level, 5, 5, Hazards.FIRE), fire, "the hazard is found by its cell and kind") + + Hazards.ignite(level, 5, 5, 5, 1) + eq(#level.hazards, 1, "re-igniting the same cell refreshes it instead of stacking a duplicate") + eq(fire.duration, 5, "refreshing a hazard keeps the stronger of the two durations") + eq(fire.potency, 4, "refreshing a hazard keeps the stronger of the two potencies") + + local victim = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local hpBefore = victim.hp + local line = Hazards.afflict(level, victim) + ok(contains(line, "flames"), "afflict describes a fire hazard as flames") + eq(victim.hp, hpBefore - fire.potency, "standing in fire deals its potency in damage") + eq(Status.has(victim, Status.BURNING), true, "fire afflict also lays down a burning status") + + local elsewhere = Monsters.create(Monsters.byKey.rat, 2, 9, 9, 1) + eq(Hazards.afflict(level, elsewhere), nil, "afflict does nothing to an entity off any hazard cell") + + local gas = Hazards.spawnGas(level, 12, 8, 4, 2) + eq(gas.kind, Hazards.GAS, "spawnGas drops a gas hazard") + local choker = Monsters.create(Monsters.byKey.rat, 3, 12, 8, 1) + local gasLine = Hazards.afflict(level, choker) + ok(contains(gasLine, "fumes"), "afflict describes a gas hazard as fumes") + eq(Status.has(choker, Status.POISON), true, "gas afflict lays down a poison status") + + local wet = arena(21, 11) + wet.map:set(6, 6, Tiles.WATER) + Hazards.ignite(wet, 6, 6, 3, 2) + Hazards.step(wet, Rng.new(1)) + eq(Hazards.at(wet, 6, 6, Hazards.FIRE), nil, + "fire sitting on water is doused on the very next environmental step") + + local fading = arena(21, 11) + Hazards.spawn(fading, 15, 5, Hazards.FIRE, 1, 2) + Hazards.step(fading, Rng.new(1)) + eq(Hazards.at(fading, 15, 5, Hazards.FIRE), nil, "a hazard is removed once its duration runs out") + + local spread = false + for seed = 1, 200 do + local field = arena(21, 11) + Hazards.ignite(field, 10, 5, 3, 2) + Hazards.step(field, Rng.new(seed)) + if Hazards.at(field, 11, 5) or Hazards.at(field, 9, 5) + or Hazards.at(field, 10, 4) or Hazards.at(field, 10, 6) then + spread = true + break + end + end + ok(spread, "fire eventually spreads into open neighbouring floor on some seed") + + local rendered = stagedGame(1, arena(21, 11), 3, 6) + Hazards.ignite(rendered.level, 5, 6, 3, 2) + rendered:computeVisible() + ok(contains(rendered:render(), "`"), "a fire hazard on a visible cell renders as its glyph") +end + +local function shrinePlayer(gold) end function tests.messageLog() From ea74bc68f731110d12bcedb927d2184425cd78b3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:57:07 +0200 Subject: [PATCH 089/172] test: cover tests.shrinePrayGrantsWarBlessingWhenFavorHolds --- tests/run.lua | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index e0e83d7..a3c0ad4 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1172,6 +1172,133 @@ function tests.hazards() end local function shrinePlayer(gold) + return { gold = gold, attack = 5, baseAttack = 5, maxHp = 30, hp = 30, maxMana = 10, mana = 10, + statuses = {} } +end + +function tests.shrinePrayGrantsWarBlessingWhenFavorHolds() + local player = shrinePlayer(50) + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } + local success, lines = Shrines.pray(game, shrine) + eq(success, true, "a prayer with enough gold always resolves") + eq(player.gold, 30, "the shrine's base price is deducted from the wallet") + eq(player.attack, 6, "a war blessing raises attack") + eq(player.baseAttack, 6, "a war blessing raises base attack permanently") + eq(shrine.uses, 1, "a prayer advances the shrine's use count") + ok(contains(lines[2], "favor"), "the blessing is logged") +end + +function tests.shrinePrayGrantsVitalityBlessing() + local player = shrinePlayer(50) + player.hp = 20 + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.VITALITY, uses = 0 } + Shrines.pray(game, shrine) + eq(player.maxHp, 35, "a vitality blessing raises max hp") + eq(player.hp, 25, "a vitality blessing heals by the same amount as the max hp gain") +end + +function tests.shrinePrayGrantsWisdomBlessing() + local player = shrinePlayer(50) + player.mana = 2 + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.WISDOM, uses = 0 } + Shrines.pray(game, shrine) + eq(player.maxMana, 13, "a wisdom blessing raises max mana") + eq(player.mana, 13, "a wisdom blessing refills mana to the new max") +end + +function tests.shrinePrayGrantsFortuneBlessingAndCleansesAilments() + local player = shrinePlayer(50) + Status.apply(player, Status.POISON, 5, 2) + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.FORTUNE, uses = 0 } + Shrines.pray(game, shrine) + eq(Status.has(player, Status.POISON), false, "a fortune blessing cleanses poison") + eq(Status.has(player, Status.SHIELD), true, "a fortune blessing also wards the petitioner") +end + +function tests.shrinePrayCanCurseInsteadOfBless() + local player = shrinePlayer(50) + local game = { player = player, rng = Rng.new(2) } + local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } + local success, lines = Shrines.pray(game, shrine) + eq(success, true, "a cursed prayer still resolves and spends its price") + eq(player.gold, 30, "a curse still costs the shrine's price") + eq(player.attack, 5, "a curse grants no blessing") + eq(Status.has(player, Status.WEAKEN), true, "a curse weakens the petitioner") + eq(Status.has(player, Status.SLOW), true, "a curse slows the petitioner") + ok(contains(lines[2], "against you"), "the curse is logged") +end + +function tests.shrinePrayFailsWithoutEnoughGold() + local player = shrinePlayer(5) + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } + local success, lines = Shrines.pray(game, shrine) + eq(success, false, "a prayer the player cannot afford fails outright") + eq(player.gold, 5, "a failed prayer spends no gold") + eq(shrine.uses, 0, "a failed prayer does not advance the use count") + ok(contains(lines[1], "afford"), "the failure is logged") +end + +function tests.shrineCostAndCurseOddsRiseWithUse() + eq(Shrines.costFor({ uses = 0 }), 20, "an untouched shrine charges its base price") + eq(Shrines.costFor({ uses = 3 }), 50, "the price climbs by a fixed step per prior use") + local n0, d0 = Shrines.curseChance({ uses = 0 }) + eq(n0, 1, "an untouched shrine risks a curse on one face of the die") + eq(d0, 6, "curse odds are read against six faces") + local n5 = Shrines.curseChance({ uses = 10 }) + eq(n5, 6, "curse odds cap out at a certainty rather than exceeding the die") +end + +function tests.shrineFieldsSurviveSerialization() + local level = arena(21, 11) + level.shrines = { { x = 4, y = 4, kind = Shrines.FORTUNE, uses = 2 } } + local restored = Level.fromData(level:toData()) + eq(#restored.shrines, 1, "the shrine list survives a round trip") + eq(restored.shrines[1].kind, Shrines.FORTUNE, "a shrine's kind survives a round trip") + eq(restored.shrines[1].uses, 2, "a shrine's use count survives a round trip") +end + +function tests.builderOccasionallyPlacesAShrineFromDepthFour() + local level = Builder.build(4, Rng.new(1), {}) + ok(#level.shrines >= 1, "this seed's depth-4 floor places a shrine") + eq(level.shrines[1].uses, 0, "a freshly built shrine starts unused") +end + +function tests.shrinesStayOffTheShallowFloors() + for seed = 1, 5 do + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.shrines, 0, "no shrine appears above depth 4") + end + end +end + +function tests.autopilotPraysAtAnUnspentShrineWhenAffordable() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.gold = 50 + game.level.shrines = { { x = 5, y = 5, kind = Shrines.WAR, uses = 0 } } + game:choosePlayerAction() + eq(game.level.shrines[1].uses, 1, "the autopilot prays at an unspent shrine it stands on") + ok(game.player.gold < 50, "the autopilot's prayer spends the shrine's price") +end + +function tests.autopilotSkipsAnAlreadyUsedShrine() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.gold = 50 + game.level.shrines = { { x = 5, y = 5, kind = Shrines.WAR, uses = 1 } } + game:choosePlayerAction() + eq(game.level.shrines[1].uses, 1, "the autopilot never re-prays at a shrine it has already used") +end + +function tests.shrineRendersItsGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + rendered.level.shrines = { { x = 5, y = 6, kind = Shrines.WAR, uses = 0 } } + rendered:computeVisible() + ok(contains(rendered:render(), "_"), "a shrine on a visible cell renders as its glyph") end function tests.messageLog() From 1b577329eee5c02c73873e065f481e3654a4e186 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 18:41:33 +0200 Subject: [PATCH 090/172] test: cover tests.progressionLeveling --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index a3c0ad4..8a8532e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1321,6 +1321,179 @@ function tests.weaponAffixOnHit() eq(Status.has(orc, Status.BURNING), true, "a flaming weapon ignites what it hits") end +function tests.progressionLeveling() + local player = { + level = 1, xp = 0, xpNext = Progression.xpToNext(1), + hp = 20, maxHp = 20, attack = 5, baseAttack = 5, defense = 1, baseDefense = 1, + } + local lines = Progression.grantXp(player, 5) + eq(#lines, 0, "an xp gain under the threshold produces no level-up") + eq(player.xp, 5, "xp accumulates") + + lines = Progression.grantXp(player, 100) + eq(player.level > 1, true, "a large xp gain levels the player up") + eq(#lines, player.level - 1, "one log line is produced per level gained") + eq(player.maxHp > 20, true, "leveling raises max hp") + eq(player.hp, player.maxHp, "leveling tops up hp by the same amount as the max hp gain") + eq(player.attack > 5, true, "leveling raises attack") + ok(player.xp < player.xpNext, "leftover xp after leveling stays below the new threshold") +end + +function tests.progressionMonsterKillGrantsXp() + local game = stagedGame(9, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + local xpBefore = game.player.xp + game:playerAttack(rat) + eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "felling a monster in melee grants its xp") + eq(#game.level.entities, 0, "the dead monster is swept from the level") +end + +function tests.spellCastMissileKillsAndGrantsXp() + local game = stagedGame(5, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + local manaBefore = game.player.mana + local success = game:castSpell("missile", rat) + eq(success, true, "a known spell in range with a clear shot casts successfully") + eq(game.player.mana, manaBefore - Spells.catalog.missile.cost, "casting spends mana") + eq(#game.level.entities, 0, "the missile kills the rat and sweeps it from the level") + eq(game.player.xp, Monsters.xpFor(rat), "a spell kill grants xp same as a melee kill") +end + +function tests.spellCastFailsWithoutSpendingState() + local game = stagedGame(5, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game.player.mana = 0 + local success = game:castSpell("missile", rat) + eq(success, false, "casting without enough mana fails") + eq(game.player.mana, 0, "a failed cast does not touch mana") + eq(rat.hp, rat.maxHp, "a failed cast deals no damage") + + game.player.mana = 10 + local success2 = game:castSpell("heal") + eq(success2, false, "casting a spell that exists but was never learned fails") + eq(game.player.mana, 10, "an unlearned spell cast spends no mana") + + local success3 = game:castSpell("fireball", rat) + eq(success3, false, "casting a key that matches no spell in the catalog fails") +end + +function tests.spellLearnFromTome() + local game = stagedGame(1, arena(21, 11), 3, 6) + eq(Spells.knows(game.player, "heal"), false, "the starting kit does not know heal") + game.player.inventory[#game.player.inventory + 1] = Items.tome("heal") + game:useItem(#game.player.inventory) + eq(Spells.knows(game.player, "heal"), true, "reading a tome permanently learns its spell") + + local before = #game.player.inventory + game.player.inventory[#game.player.inventory + 1] = Items.tome("heal") + game:useItem(#game.player.inventory) + eq(#game.player.inventory, before, "the tome is consumed whether or not the spell was new") +end + +function tests.spellHealAndShield() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "heal") + Spells.learn(game.player, "shield") + game.player.hp = 10 + local manaBefore = game.player.mana + game:castSpell("heal") + eq(game.player.hp > 10, true, "the heal spell restores hp") + eq(game.player.mana, manaBefore - Spells.catalog.heal.cost, "casting heal spends its mana cost") + + local defenseBefore = game.player:effectiveDefense() + game:castSpell("shield") + eq(game.player:effectiveDefense() > defenseBefore, true, "the shield spell raises effective defense") + ok(Status.has(game.player, Status.SHIELD), "shield applies the shield status") +end + +function tests.spellBlinkMovesWithinRadius() + local game = stagedGame(3, arena(21, 11), 10, 6) + Spells.learn(game.player, "blink") + local startX, startY = game.player.x, game.player.y + local success = game:castSpell("blink") + eq(success, true, "blink casts when mana and known") + local moved = game.player.x ~= startX or game.player.y ~= startY + ok(moved, "blink relocates the caster") + local dist = math.max(math.abs(game.player.x - startX), math.abs(game.player.y - startY)) + ok(dist <= 5, "blink lands within its radius") + eq(game.level.map:isWalkable(game.player.x, game.player.y), true, "blink lands on walkable ground") +end + +function tests.manaRegeneratesEachTurn() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.mana = 0 + game.level.entities = {} + game:endPlayerTurn() + eq(game.player.mana, 1, "mana regenerates by one per player turn") + game.player.mana = game.player.maxMana + game:endPlayerTurn() + eq(game.player.mana, game.player.maxMana, "mana regeneration is capped at max mana") +end + +function tests.throwWeaponHitsAndLands() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + local dagger = Items.weapon("dagger", 2) + game.player.inventory[#game.player.inventory + 1] = dagger + local index = #game.player.inventory + local before = #game.player.inventory + local success = game:throwAt(index, rat) + eq(success, true, "a weapon throw with a clear line succeeds") + eq(#game.player.inventory, before - 1, "the thrown weapon leaves the inventory") + eq(rat.hp, rat.maxHp - dagger.power * 2, "a thrown weapon deals double its melee power") + local landed = game.level:itemsAt(rat.x, rat.y) + eq(#landed, 1, "the thrown weapon lands on the ground at the target") + eq(landed[1].item, dagger, "the landed item is the same weapon that was thrown") +end + +function tests.throwWeaponKillGrantsXp() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.weapon("battle axe", 5) + local xpBefore = game.player.xp + game:throwAt(#game.player.inventory, rat) + eq(#game.level.entities, 0, "a lethal throw sweeps the monster from the level") + eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "a thrown-weapon kill grants xp") +end + +function tests.throwPotionAppliesHostileEffect() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.potionOf("poison", 3) + local success = game:throwAt(#game.player.inventory, rat) + eq(success, true, "a potion throw with a clear line succeeds") + eq(Status.has(rat, Status.POISON), true, "a thrown poison potion poisons the target instead of the thrower") + eq(Status.has(game.player, Status.POISON), false, "the thrower is untouched by their own thrown potion") + eq(Hazards.at(game.level, rat.x, rat.y, Hazards.GAS) ~= nil, true, + "a shattered poison potion leaves a lingering cloud of fumes") +end + +function tests.throwConfusePotionAppliesTheStatusToTheTargetNotTheThrower() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.potionOf("confuse") + local success = game:throwAt(#game.player.inventory, rat) + eq(success, true, "a confuse potion throw with a clear line succeeds") + eq(Status.has(rat, Status.CONFUSE), true, "a thrown confuse potion confuses the target instead of the thrower") + eq(Status.has(game.player, Status.CONFUSE), false, "the thrower is untouched by their own thrown potion") +end + +function tests.throwBlindPotionAppliesTheStatusToTheTargetNotTheThrower() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.potionOf("blind") +end + function tests.saveLoadRoundTrip() local game = Game.new(2024) for _ = 1, 80 do From 40369e5a092d9152daf1ae977efda70232690fd4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 18:41:47 +0200 Subject: [PATCH 091/172] test: cover tests.throwLevitatePotionAppliesTheStatusToTheTargetNotTh... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 8a8532e..3ceb356 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1492,6 +1492,179 @@ function tests.throwBlindPotionAppliesTheStatusToTheTargetNotTheThrower() local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.potionOf("blind") + local success = game:throwAt(#game.player.inventory, rat) + eq(success, true, "a blind potion throw with a clear line succeeds") + eq(Status.has(rat, Status.BLIND), true, "a thrown blind potion blinds the target instead of the thrower") + eq(Status.has(game.player, Status.BLIND), false, "the thrower is untouched by their own thrown potion") +end + +function tests.throwLevitatePotionAppliesTheStatusToTheTargetNotTheThrower() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.potionOf("levitate") + local success = game:throwAt(#game.player.inventory, rat) + eq(success, true, "a levitation potion throw with a clear line succeeds") + eq(Status.has(rat, Status.LEVITATE), true, "a thrown levitation potion lifts the target instead of the thrower") + eq(Status.has(game.player, Status.LEVITATE), false, "the thrower is untouched by their own thrown potion") +end + +function tests.throwOutOfRangeFails() + local game = stagedGame(4, arena(21, 11), 2, 2) + local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.weapon("dagger", 2) + local index = #game.player.inventory + local before = #game.player.inventory + local success = game:throwAt(index, rat) + eq(success, false, "a throw beyond range fails") + eq(#game.player.inventory, before, "a failed throw does not consume the item") + eq(rat.hp, rat.maxHp, "a failed throw deals no damage") +end + +function tests.rangedShotHitsAtRangeAndConsumesAmmo() + local game = stagedGame(4, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game.level.entities = { orc } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local expected = game.player.baseAttack + 2 - orc:effectiveDefense() + local success = game:fireAt(orc) + eq(success, true, "firing at a target within range and line of sight succeeds") + eq(orc.hp, orc.maxHp - expected, "the shot deals baseAttack plus bow power minus target defense") + local stack + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.AMMO then stack = item end + end + eq(stack.count, 1, "firing consumes one arrow from the stack") + local landed = game.level:itemsAt(orc.x, orc.y) + eq(#landed, 1, "the spent arrow lands where the target stood") + eq(landed[1].item.kind, Items.AMMO, "the landed item is a spent arrow") +end + +function tests.rangedShotKillGrantsXp() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 9, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.player.bow = Items.bow("long bow", 3, 7) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 1) + local xpBefore = game.player.xp + game:fireAt(rat) + eq(#game.level.entities, 0, "a lethal shot sweeps the monster from the level") + eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "a ranged kill grants xp") +end + +function tests.rangedFailsWithoutAmmo() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 9, 6, 1) + game.level.entities = { rat } + game.player.bow = Items.bow("short bow", 2, 6) + local success = game:fireAt(rat) + eq(success, false, "firing without arrows fails") + eq(rat.hp, rat.maxHp, "a failed shot deals no damage") +end + +function tests.rangedFailsOutOfRange() + local game = stagedGame(4, arena(21, 11), 2, 2) + local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) + game.level.entities = { rat } + game.player.bow = Items.bow("short bow", 2, 3) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 3) + local success = game:fireAt(rat) + eq(success, false, "firing beyond the bow's range fails") + local stack + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.AMMO then stack = item end + end + eq(stack.count, 3, "a failed shot does not consume ammunition") +end + +function tests.ammoStacksOnPickup() + local game = stagedGame(4, arena(21, 11), 3, 3) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.ammo("arrow", 5) } + game:pickupHere() + local stacks, total = 0, 0 + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.AMMO then + stacks = stacks + 1 + total = item.count + end + end + eq(stacks, 1, "picking up matching ammo merges into a single stack") + eq(total, 7, "the merged stack sums both counts") +end + +function tests.trollRegenerateUnlessBurning() + local troll = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 1) + troll.hp = troll.maxHp - 5 + local line = Abilities.regenTick(troll) + ok(line ~= nil, "a damaged troll regenerates and logs it") + eq(troll.hp, troll.maxHp - 5 + Monsters.byKey.troll.regen, "regen heals by the species regen amount") + + troll.hp = troll.maxHp + eq(Abilities.regenTick(troll), nil, "a troll already at full hp produces no regen line") + + troll.hp = troll.maxHp - 5 + Status.apply(troll, Status.BURNING, 3, 2) + eq(Abilities.regenTick(troll), nil, "burning suppresses troll regeneration") +end + +function tests.ratHasNoRegen() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + rat.hp = 1 + eq(Abilities.regenTick(rat), nil, "a species without a regen tag never heals passively") +end + +function tests.wretchDeathBurstsPoisonSpores() + local game = stagedGame(1, arena(21, 11), 4, 6) + local wretch = Monsters.create(Monsters.byKey.wretch, 1, 5, 6, 1) + wretch.hp = 1 + game.level.entities = { wretch } + eq(Status.has(game.player, Status.POISON), false, "the player starts unpoisoned") + game:playerAttack(wretch) + eq(Status.has(game.player, Status.POISON), true, "a wretch's death burst poisons an adjacent player") + ok(contains(game.log:recent(3)[1] or "", "spores") or contains(game.log:recent(3)[2] or "", "spores") + or contains(game.log:recent(3)[3] or "", "spores"), "the spore burst is logged") +end + +function tests.dragonBreathIgnitesPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) + game.level.entities = { dragon } + local hpBefore = game.player.hp + Ai.act(game, dragon) + ok(game.player.hp < hpBefore, "a dragon in range breathes fire for damage") + eq(Status.has(game.player, Status.BURNING), true, "dragon breath ignites the player") +end + +-- Layered special abilities: summon/curse/heal/rally run through +-- Abilities.tryAct, which gates on cooldown and range before falling through +-- to the species' ordinary ai.lua behaviour; split reacts to a survived hit; +-- steal is a stateful chase-steal-flee sequence with no cooldown at all. + +function tests.necromancerSummonsZombie() + local game = stagedGame(1, arena(21, 11), 3, 6) + local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) + game.level.entities = { necro } + local acted = Abilities.tryAct(game, necro) + eq(acted, true, "a necromancer in range summons instead of moving") + eq(#game.level.entities, 2, "summoning adds a minion to the level") + local minion = game.level.entities[2] + eq(minion.species, "zombie", "the necromancer summons its configured species") + eq(minion.summonedBy, necro.id, "the minion is tagged with its summoner's id") + eq(necro.cooldown, 6, "summoning starts the ability's cooldown") + ok(contains(game.log:last(), "calls forth"), "the summon is logged") +end + +function tests.summonRespectsPopulationCap() + local game = stagedGame(1, arena(21, 11), 3, 6) + local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) + local minionA = Monsters.create(Monsters.byKey.zombie, 2, 7, 6, 4) + local minionB = Monsters.create(Monsters.byKey.zombie, 3, 6, 7, 4) + minionA.summonedBy = necro.id + minionB.summonedBy = necro.id end function tests.saveLoadRoundTrip() From c3f3bc3c4ff028e943471c7af805742841a723cc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 18:55:28 +0200 Subject: [PATCH 092/172] test: cover tests.curseAtRangeHurtsAndWeakens --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 3ceb356..bff8297 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1665,6 +1665,179 @@ function tests.summonRespectsPopulationCap() local minionB = Monsters.create(Monsters.byKey.zombie, 3, 6, 7, 4) minionA.summonedBy = necro.id minionB.summonedBy = necro.id + game.level.entities = { necro, minionA, minionB } + local acted = Abilities.tryAct(game, necro) + eq(acted, false, "a necromancer already at its summon cap does not summon a third") + eq(#game.level.entities, 3, "the level still has exactly the two existing minions") +end + +function tests.curseAtRangeHurtsAndWeakens() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) + game.level.entities = { shaman } + local hpBefore = game.player.hp + local acted = Abilities.tryAct(game, shaman) + eq(acted, true, "a shaman with a clear shot curses instead of moving") + eq(game.player.hp, hpBefore - 1, "the curse bolt runs through Combat.damage (shaman attack 2 minus the player's base defense 1)") + eq(Status.has(game.player, Status.WEAKEN), true, "the curse also weakens the player") + eq(shaman.cooldown, 4, "casting the curse starts its cooldown") + ok(contains(game.log:last(), "curses you for 1"), "the logged amount matches the mitigated damage, not a flat number") +end + +-- The curse bolt used to bypass Combat.damage entirely (a flat, unmitigated +-- subtraction from hp), so a shaman hit for the same 3 no matter how armored +-- the player was, never broke a melee combo, and never wore armor - every +-- other monster-on-player attack (monsterMelee, monsterShoot, mimic/grave +-- ambush) already ran through Combat.damage + Momentum.onDamaged + +-- Durability.wearArmor. These three lock in the fix. +function tests.curseDamageIsMitigatedByThePlayersDefenseLikeAnyOtherAttack() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) + game.level.entities = { shaman } + game.player.defense = game.player.defense + 10 + local hpBefore = game.player.hp + Abilities.tryAct(game, shaman) + eq(game.player.hp, hpBefore - 1, "a shaman's attack (2) minus a heavily-defended player floors at Combat.damage's minimum of 1, not a flat 3") +end + +function tests.curseBreaksThePlayersMeleeMomentumStack() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) + game.level.entities = { shaman } + game.player.combo = 4 + Abilities.tryAct(game, shaman) + eq(Momentum.stacks(game.player), 0, "a landed curse bolt snaps momentum to zero exactly like a melee hit or a breath attack") +end + +function tests.curseWearsThePlayersEquippedArmor() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) + game.level.entities = { shaman } + game.player.armor = { name = "chainmail", kind = Items.ARMOR, power = 2, defense = 2 } + Durability.ensure(game.player.armor) + local durabilityBefore = game.player.armor.durability + Abilities.tryAct(game, shaman) + eq(game.player.armor.durability, durabilityBefore - 1, "the curse wears equipped armor exactly like a landed melee or breath hit does") +end + +function tests.curseWithheldWhenAdjacent() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 4, 6, 4) + game.level.entities = { shaman } + local acted = Abilities.tryAct(game, shaman) + eq(acted, false, "a shaman standing next to the player withholds its curse") +end + +function tests.shriekAtRangeBlindsThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local banshee = Monsters.create(Monsters.byKey.banshee, 1, 7, 6, 5) + game.level.entities = { banshee } + eq(Status.has(game.player, Status.BLIND), false, "the player starts able to see") + local acted = Abilities.tryAct(game, banshee) + eq(acted, true, "a banshee with a clear shot shrieks instead of moving") + eq(Status.has(game.player, Status.BLIND), true, "the shriek blinds the player") + eq(banshee.cooldown, 6, "shrieking starts its cooldown") + ok(contains(game.log:last(), "blinds you"), "the shriek is logged") +end + +function tests.shriekWithheldWhenAdjacent() + local game = stagedGame(1, arena(21, 11), 3, 6) + local banshee = Monsters.create(Monsters.byKey.banshee, 1, 4, 6, 5) + game.level.entities = { banshee } + local acted = Abilities.tryAct(game, banshee) + eq(acted, false, "a banshee standing next to the player withholds its shriek") + eq(Status.has(game.player, Status.BLIND), false, "no shriek means no blindness") +end + +function tests.shriekIsUnavailableOutOfRange() + local game = stagedGame(1, arena(21, 11), 3, 6) + local banshee = Monsters.create(Monsters.byKey.banshee, 1, 20, 6, 5) + game.level.entities = { banshee } + local acted = Abilities.tryAct(game, banshee) + eq(acted, false, "a banshee out of shriek range falls through to its base ai") + eq(Status.has(game.player, Status.BLIND), false, "no shriek means no blindness") +end + +function tests.shriekBlindnessReducesTheVictimsEffectiveSightRadius() + local game = stagedGame(1, arena(21, 11), 3, 6) + local banshee = Monsters.create(Monsters.byKey.banshee, 1, 7, 6, 5) + game.level.entities = { banshee } + Abilities.tryAct(game, banshee) + eq(game:effectiveRadius(), 1, "blind (also inflicted by a banshee) still narrows sight to game.lua's own radius") +end + +function tests.abilityCooldownBlocksImmediateReuse() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) + game.level.entities = { shaman } + Abilities.tryAct(game, shaman) + local hpAfterFirst = game.player.hp + local acted = Abilities.tryAct(game, shaman) + eq(acted, false, "an ability on cooldown does not fire again") + eq(game.player.hp, hpAfterFirst, "no further damage is dealt while on cooldown") + for _ = 1, 4 do Abilities.tickCooldown(shaman) end + eq(shaman.cooldown, 0, "four ticks fully drains a four-turn cooldown") + local actedAgain = Abilities.tryAct(game, shaman) + eq(actedAgain, true, "the ability is usable again once its cooldown has ticked down") +end + +function tests.healerMendsNeediestAlly() + local game = stagedGame(1, arena(21, 11), 3, 6) + local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) + local hurt = Monsters.create(Monsters.byKey.rat, 2, 8, 6, 5) + hurt.hp = 1 + game.level.entities = { cleric, hurt } + local acted = Abilities.tryAct(game, cleric) + eq(acted, true, "a cleric with a hurt ally in sight heals instead of moving") + eq(hurt.hp, 7, "healing restores the fixed amount, capped at max hp") + eq(cleric.cooldown, 5, "healing starts its cooldown") +end + +function tests.healerSkipsWhenAlliesAreHealthy() + local game = stagedGame(1, arena(21, 11), 3, 6) + local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) + local ally = Monsters.create(Monsters.byKey.rat, 2, 8, 6, 5) + game.level.entities = { cleric, ally } + local acted = Abilities.tryAct(game, cleric) + eq(acted, false, "a cleric with no hurt allies falls through to its base ai") +end + +function tests.rallyBuffsAdjacentAllies() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) + local grunt = Monsters.create(Monsters.byKey.goblin, 2, 11, 6, 6) + game.level.entities = { chieftain, grunt } + eq(Status.has(grunt, Status.STRENGTH), false, "the grunt starts unbuffed") + local acted = Abilities.tryAct(game, chieftain) + eq(acted, true, "a chieftain next to an unbuffed ally rallies instead of moving") + eq(Status.has(grunt, Status.STRENGTH), true, "rallying buffs the adjacent ally") + eq(Status.has(chieftain, Status.STRENGTH), true, "rallying also buffs the chieftain itself") + eq(chieftain.cooldown, 8, "rallying starts its cooldown") +end + +function tests.rallySkipsWhenNoUnbuffedAlliesAdjacent() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) + game.level.entities = { chieftain } + local acted = Abilities.tryAct(game, chieftain) + eq(acted, false, "a chieftain with no adjacent allies falls through to its base ai") +end + +function tests.healerNeverMendsThePlayersOwnCompanionHirelingOrThrall() + local game = stagedGame(1, arena(21, 11), 3, 6) + local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) + local companion = Companions.summon(game, "hound") + companion.x, companion.y = 8, 6 + companion.hp = 1 + game.level.entities = { cleric, companion } + local acted = Abilities.tryAct(game, cleric) + eq(acted, false, "a hurt companion is not a valid heal target, so the cleric falls through instead") + eq(companion.hp, 1, "the player's own companion is left exactly as hurt as it was") +end + +function tests.rallyNeverBuffsThePlayersOwnCompanionHirelingOrThrall() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) end function tests.saveLoadRoundTrip() From abc27cd5e15bd14e302dc1ff08857a0e161ca9a0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 20:39:05 +0200 Subject: [PATCH 093/172] test: cover tests.footpadStealsThenFlees --- tests/run.lua | 148 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/tests/run.lua b/tests/run.lua index bff8297..2694f66 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -1838,6 +1838,145 @@ end function tests.rallyNeverBuffsThePlayersOwnCompanionHirelingOrThrall() local game = stagedGame(1, arena(21, 11), 3, 6) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) + local companion = Companions.summon(game, "hound") + companion.x, companion.y = 11, 6 + game.level.entities = { chieftain, companion } + local acted = Abilities.tryAct(game, chieftain) + eq(acted, false, "an adjacent companion is not a valid rally target, so the chieftain falls through instead") + eq(Status.has(companion, Status.STRENGTH), false, "the player's own companion is never buffed by a hostile chieftain") + eq(Status.has(chieftain, Status.STRENGTH), false, "the chieftain does not even buff itself when it has no monster ally to rally") +end + +function tests.footpadStealsThenFlees() + local game = stagedGame(1, arena(21, 11), 3, 6) + local footpad = Monsters.create(Monsters.byKey.footpad, 1, 4, 6, 4) + game.level.entities = { footpad } + local before = #game.player.inventory + local acted = Abilities.tryAct(game, footpad) + eq(acted, true, "an adjacent footpad steals instead of attacking") + eq(#game.player.inventory, before - 1, "the stolen item leaves the player's inventory") + eq(footpad.fleeing, true, "the footpad starts fleeing after the theft") + ok(contains(game.log:last(), "steals"), "the theft is logged") + + local xBefore = footpad.x + local actedAgain = Abilities.tryAct(game, footpad) + eq(actedAgain, true, "a fleeing footpad keeps acting through its ability") + eq(#game.player.inventory, before - 1, "a fleeing footpad does not steal again") + ok(footpad.x ~= xBefore, "a fleeing footpad moves away from the player") +end + +function tests.footpadWithNothingToStealStillFlees() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.inventory = {} + local footpad = Monsters.create(Monsters.byKey.footpad, 1, 4, 6, 4) + game.level.entities = { footpad } + local acted = Abilities.tryAct(game, footpad) + eq(acted, true, "a footpad with nothing to steal still consumes its turn") + eq(footpad.fleeing, true, "it flees even without a prize") + ok(contains(game.log:last(), "nothing worth stealing"), "the empty-handed theft attempt is logged") +end + +function tests.slimeSplitsOnSurvivedHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local slime = Monsters.create(Monsters.byKey.slime, 1, 4, 6, 4) + game.level.entities = { slime } + eq(slime.hp, 13, "a depth-4 slime spawns with its bonus hp") + game:playerAttack(slime) + eq(slime:isAlive(), true, "the slime survives a single hit") + eq(slime.maxHp, 6, "a surviving split halves the slime's max hp") + eq(slime.hp, 6, "the split slime is left at its new max hp") + eq(#game.level.entities, 2, "the split spawns a clone next to the original") + local clone = game.level.entities[2] + eq(clone.species, "slime", "the clone is the same species as the original") + eq(clone.maxHp, 6, "the clone shares the halved max hp") + eq(clone.splitGen, 1, "the clone is tagged as a first-generation split") + ok(contains(game.log:last(), "splits in two"), "the split is logged") +end + +function tests.splitCapsAtTwoGenerations() + local game = stagedGame(1, arena(21, 11), 3, 6) + local slime = Monsters.create(Monsters.byKey.slime, 1, 4, 6, 4) + slime.splitGen = 2 + game.level.entities = { slime } + local before = #game.level.entities + local line = Abilities.onHit(game, slime) + eq(line, nil, "a slime that already split twice does not split again") + eq(#game.level.entities, before, "no clone is added once the split cap is reached") +end + +function tests.takeMonsterTurnPrefersAbilityOverMovement() + local game = stagedGame(1, arena(21, 11), 3, 6) + local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) + game.level.entities = { necro } + game:takeMonsterTurn(necro) + eq(necro.x, 6, "summoning consumes the turn instead of the necromancer's flee movement") + eq(necro.y, 6, "summoning leaves the necromancer's y unchanged too") + eq(#game.level.entities, 2, "the full turn pipeline invokes the summon ability") +end + +function tests.takeMonsterTurnFallsBackToAiWhenOnCooldown() + local game = stagedGame(1, arena(21, 11), 3, 6) + local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) + necro.cooldown = 3 + game.level.entities = { necro } + game:takeMonsterTurn(necro) + eq(#game.level.entities, 1, "no minion is summoned while the ability is on cooldown") + eq(necro.cooldown, 2, "the cooldown ticks down even on a turn spent fleeing") + ok(necro.x ~= 6 or necro.y ~= 6, "the necromancer falls back to its flee ai instead") +end + +function tests.abilityFieldsSurviveSerialization() + local necro = Monsters.create(Monsters.byKey.necromancer, 5, 6, 6, 4) + necro.cooldown = 3 + necro.fleeing = true + necro.splitGen = 1 + necro.summonedBy = 2 + necro.stolen = Items.potion(10) + local restored = Entity.fromData(necro:toData()) + eq(restored.ability, "summon", "the ability tag survives a round trip") + eq(restored.summonSpecies, "zombie", "the summon species survives a round trip") + eq(restored.cooldown, 3, "cooldown survives a round trip") + eq(restored.fleeing, true, "the fleeing flag survives a round trip") + eq(restored.splitGen, 1, "the split generation survives a round trip") + eq(restored.summonedBy, 2, "the summoner id survives a round trip") + eq(restored.stolen.name, "potion of healing", "a stolen item's data survives a round trip") +end + +function tests.statusEffectKillGrantsXpAndLogsDeath() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 15, 9, 1) + Status.apply(rat, Status.POISON, 1, 50) + game.level.entities = { rat } + local xpBefore = game.player.xp + game:takeMonsterTurn(rat) + eq(#game.level.entities, 0, "a monster that dies to its own status tick is swept from the level") + eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "dying to a status effect still grants its xp") + ok(contains(game.log:recent(4)[1] or "", "dies") or contains(game.log:recent(4)[2] or "", "dies") + or contains(game.log:recent(4)[3] or "", "dies") or contains(game.log:recent(4)[4] or "", "dies"), + "the status-effect kill is logged as a death") +end + +function tests.hazardKillDuringMonsterTurnGrantsXp() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 15, 9, 1) + rat.hp = 1 + Hazards.ignite(game.level, rat.x, rat.y, 3, 50) + game.level.entities = { rat } + local xpBefore = game.player.xp + game:takeMonsterTurn(rat) + eq(#game.level.entities, 0, "a monster that dies standing in fire is swept from the level") + eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "dying to a hazard still grants its xp") +end + +function tests.hazardStepRunsOncePerPlayerRound() + local game = stagedGame(1, arena(21, 11), 3, 6) + local px, py = game.player.x, game.player.y + Hazards.ignite(game.level, px, py, 1, 3) + local hpBefore = game.player.hp + game:autoStep() + eq(game.player.hp, hpBefore - 3, "the player takes hazard damage while standing in fire on their turn") + eq(Hazards.at(game.level, px, py, Hazards.FIRE), nil, + "the hazard's duration is spent by the environmental step at the end of the round") end function tests.saveLoadRoundTrip() @@ -1883,9 +2022,14 @@ function tests.autoplayDescends() end return game end - local run = playout(2024) + -- Seed re-picked when the blind potion/trap effect joined the shared loot + -- and trap-effect tables (builder.lua): appending a new outcome changes the + -- denominator of every rng:range(1, #effects) draw those tables feed, which + -- reshuffles the whole downstream procedural sequence for any fixed seed - + -- 2024 itself did not get more dangerous, its particular draw just moved. + local run = playout(2025) ok(run.depth >= 3, "the autopilot descends several levels") - local again = playout(2024) + local again = playout(2025) eq(again.depth, run.depth, "the autopilot reaches the same depth on the same seed") eq(again.turn, run.turn, "the autopilot takes the same number of turns") end From e7b64c1e92a7f88b190ed974654e94623e4dc73d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:00:13 +0200 Subject: [PATCH 094/172] test: cover tests.amuletSpawnsOnFinalFloorOnly --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 2694f66..bad457e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2034,6 +2034,179 @@ function tests.autoplayDescends() eq(again.turn, run.turn, "the autopilot takes the same number of turns") end +function tests.amuletSpawnsOnFinalFloorOnly() + local finalLevel = Builder.build(Builder.MAX_DEPTH, Rng.new(999), {}) + local finalCount = 0 + for _, ground in ipairs(finalLevel.floorItems) do + if ground.item.kind == Items.AMULET then finalCount = finalCount + 1 end + end + eq(finalCount, 1, "exactly one amulet is placed on the final floor") + + local shallowLevel = Builder.build(Builder.MAX_DEPTH - 1, Rng.new(999), {}) + local shallowCount = 0 + for _, ground in ipairs(shallowLevel.floorItems) do + if ground.item.kind == Items.AMULET then shallowCount = shallowCount + 1 end + end + eq(shallowCount, 0, "no amulet appears above the final floor") +end + +function tests.descendIsBlockedAtTheFinalFloor() + local game = Game.new(555) + game.depth = game.maxDepth + game.level = game.dungeon:get(game.maxDepth) + game.player.x, game.player.y = game.level.downX, game.level.downY + local result = game:descend() + eq(result, false, "descend fails once at the final floor") + eq(game.depth, game.maxDepth, "depth is unchanged after a blocked descend") + ok(contains(game.log:last(), "rock"), "a message explains why the descent is blocked") +end + +function tests.ascendWithoutAmuletDoesNotWin() + local game = Game.new(7) + game.player.x, game.player.y = game.level.upX, game.level.upY + local result = game:ascend() + eq(result, true, "climbing out at depth 1 is still a valid move") + eq(game.won, false, "climbing out without the amulet does not win the game") +end + +function tests.ascendWithAmuletWinsTheGame() + local game = Game.new(7) + game.player.inventory[#game.player.inventory + 1] = Items.amulet() + game.player.x, game.player.y = game.level.upX, game.level.upY + local result = game:ascend() + eq(result, true, "climbing out at depth 1 with the amulet succeeds") + eq(game.won, true, "carrying the amulet to the surface wins the game") + ok(contains(game.log:last(), "win"), "the victory is logged") +end + +function tests.wonGameHaltsTheAutopilot() + local game = Game.new(7) + game.won = true + local turnBefore = game.turn + game:autoStep() + eq(game.turn, turnBefore, "autoStep is a no-op once the game is already won") +end + +function tests.autopilotFetchesTheAmuletAndWins() + local function playout(seed) + local game = Game.new(seed, { maxDepth = 3 }) + for _ = 1, 900 do + if not game.player:isAlive() or game.won then break end + game:autoStep() + end + return game + end + local run = playout(67) + ok(run.player:isAlive(), "the autopilot survives the round trip on this seed") + eq(run.won, true, "the autopilot retrieves the amulet and escapes") + eq(run.depth, 1, "winning leaves the player back at depth 1") + ok(run:hasAmulet(), "the amulet is still in the winning player's inventory") + local again = playout(67) + eq(again.turn, run.turn, "the winning playthrough takes the same number of turns on the same seed") +end + +function tests.shopPriceCatalogScalesWithPower() + local base = Shop.priceOf(Items.weapon("dagger", 2)) + local strong = Shop.priceOf(Items.weapon("battle axe", 5)) + ok(strong > base, "a more powerful weapon costs more at the shop") + eq(Shop.priceOf(Items.potionOf("heal")), 15, "a catalogued potion has its fixed price") + eq(Shop.sellValue(Items.potionOf("heal")), 7, "selling nets half the catalog price, rounded down") +end + +function tests.shopStockRollsAffordableWares() + local wares = Shop.stock(Rng.new(7)) + ok(#wares >= 3 and #wares <= 5, "a shop rolls three to five wares") + for _, item in ipairs(wares) do + ok(Shop.priceOf(item) > 0, "every ware has a positive price") + end +end + +function tests.goldPickupAddsToWalletNotInventory() + local game = stagedGame(1, arena(21, 11), 3, 3) + local before = #game.player.inventory + local goldBefore = game.player.gold + game.level.floorItems = { { x = 4, y = 3, item = Items.gold(35) } } + game:movePlayer(1, 0) + eq(game.player.gold, goldBefore + 35, "walking onto gold adds it to the wallet") + eq(#game.player.inventory, before, "gold never occupies an inventory slot") + eq(#game.level.floorItems, 0, "the gold pile is removed from the floor") +end + +function tests.affordableWareIsBoughtAutomaticallyOnPickup() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.player.gold = 50 + local before = #game.player.inventory + game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } + game:movePlayer(1, 0) + eq(game.player.gold, 35, "the ware's stamped price is deducted from the wallet") + eq(#game.player.inventory, before + 1, "the bought ware joins the inventory") + eq(#game.level.floorItems, 0, "the purchased ware leaves the ground") +end + +function tests.unaffordableWareStaysOnTheGround() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.player.gold = 5 + local before = #game.player.inventory + game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } + game:movePlayer(1, 0) + eq(game.player.gold, 5, "an unaffordable ware is never bought, so no gold changes hands") + eq(#game.player.inventory, before, "the ware never reaches the inventory") + eq(#game.level.floorItems, 1, "the unbought ware is left in place") +end + +function tests.deadKeeperMakesWaresFreeToLoot() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + keeper.hp = 0 + game.level.entities = { keeper } + game.player.gold = 0 + local before = #game.player.inventory + game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } + game:movePlayer(1, 0) + eq(game.player.gold, 0, "looting a dead keeper's stock costs nothing") + eq(#game.player.inventory, before + 1, "the ware is picked up normally once its keeper is dead") +end + +function tests.stealingFromShopTurnsTheKeeperHostile() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.player.gold = 0 + local before = #game.player.inventory + game.level.floorItems = { { x = 4, y = 3, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } + local stolen = game:stealFromShop(1) + eq(stolen, true, "stealing succeeds against a live keeper's ware") + eq(game.player.gold, 0, "theft costs no gold") + eq(#game.player.inventory, before + 1, "the stolen item joins the inventory") + eq(keeper.hostile, true, "the keeper turns hostile after the theft") + ok(contains(game.log:last(), "steal"), "the theft is logged") +end + +function tests.hostileShopkeeperChasesLikeAnyMonster() + local game = stagedGame(1, arena(21, 11), 3, 6) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 8, 6, 4) + keeper.hostile = true + game.level.entities = { keeper } + Ai.act(game, keeper) + eq(keeper.x, 7, "a hostile shopkeeper closes in on the player like a chaser") +end + +function tests.passiveShopkeeperIgnoresTheAdjacentPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 4, 6, 4) + game.level.entities = { keeper } + local hpBefore = game.player.hp + Ai.act(game, keeper) + eq(keeper.x, 4, "an un-robbed shopkeeper does not move") + eq(game.player.hp, hpBefore, "an un-robbed shopkeeper does not attack even when adjacent") +end + +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From f5f4e93cd3c9aa27172c7b6abbe41c0e00632aad Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:19:47 +0200 Subject: [PATCH 095/172] test: cover tests.sellItemGrantsHalfPriceAndRemovesIt --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index bad457e..35a7e33 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2205,6 +2205,179 @@ function tests.passiveShopkeeperIgnoresTheAdjacentPlayer() eq(game.player.hp, hpBefore, "an un-robbed shopkeeper does not attack even when adjacent") end +function tests.sellItemGrantsHalfPriceAndRemovesIt() + local game = stagedGame(1, arena(21, 11), 3, 3) + local index = #game.player.inventory + local item = game.player.inventory[index] + local expected = Shop.sellValue(item) + local goldBefore = game.player.gold + local value = game:sellItem(index) + eq(value, expected, "selling returns the catalog half-price") + eq(game.player.gold, goldBefore + expected, "the sale proceeds land in the wallet") + eq(#game.player.inventory, index - 1, "the sold item leaves the inventory") +end + +function tests.builderOccasionallySeedsAShopWithMatchingWares() + local level = Builder.build(4, Rng.new(1), {}) + local keeper + for _, e in ipairs(level.entities) do + if e.species == "shopkeeper" then keeper = e end + end + ok(keeper ~= nil, "this seed's depth-4 floor spawns a shopkeeper") + local waresFound = 0 + for _, ground in ipairs(level.floorItems) do + if ground.price then + waresFound = waresFound + 1 + eq(ground.shopId, keeper.id, "every priced ware is tagged with its shopkeeper's id") + ok(ground.price > 0, "every priced ware has a positive price") + end + end + ok(waresFound >= 3, "the shop stocks at least three wares") +end + +function tests.shopAndLooseGoldStayOffTheShallowFloors() + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(1), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.GOLD, "no loose gold appears above depth 4") + ok(not ground.price, "no shop ware appears above depth 4") + end + for _, e in ipairs(level.entities) do + ok(e.species ~= "shopkeeper", "no shopkeeper appears above depth 4") + end + end +end + +local function arenaDark(w, h) + local map = Map.new(w, h) + for y = 2, h - 1 do for x = 2, w - 1 do map:set(x, y, Map.FLOOR) end end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = true, explored = {}, + upX = 2, upY = 2, downX = w - 1, downY = h - 1, + }) +end + +function tests.classApplyGrantsFighterStatBonuses() + local base = Game.new(9) + local fighter = Game.new(9, { class = "fighter" }) + eq(fighter.player.class, "fighter", "the requested class is stamped onto the player") + eq(fighter.player.maxHp, base.player.maxHp + 8, "fighter grants a flat max hp bonus") + eq(fighter.player.hp, base.player.hp + 8, "the hp bonus also tops up current hp") + eq(fighter.player.attack, base.player.attack + 2, "fighter grants a flat attack bonus") +end + +function tests.classApplyGrantsRogueGoldAndAttack() + local base = Game.new(9) + local rogue = Game.new(9, { class = "rogue" }) + eq(rogue.player.gold, base.player.gold + 15, "rogue starts with extra gold") + eq(rogue.player.attack, base.player.attack + 1, "rogue grants a small attack bonus") +end + +function tests.classApplyGrantsMageManaAndAttack() + local base = Game.new(9) + local mage = Game.new(9, { class = "mage" }) + eq(mage.player.maxMana, base.player.maxMana + 8, "mage grants a flat mana bonus") + eq(mage.player.mana, base.player.mana + 8, "the mana bonus also tops up current mana") + eq(mage.player.attack, base.player.attack - 1, "mage trades some attack for its mana pool") +end + +function tests.classApplyGrantsRangerBowAndAmmo() + local ranger = Game.new(9, { class = "ranger" }) + ok(ranger.player.bow ~= nil, "ranger starts with a bow equipped") + local hasAmmo = false + for _, item in ipairs(ranger.player.inventory) do + if item.kind == Items.AMMO then hasAmmo = true end + end + ok(hasAmmo, "ranger starts with a stack of arrows") +end + +function tests.invalidClassIsIgnored() + local base = Game.new(9) + local weird = Game.new(9, { class = "wizard" }) + eq(weird.player.class, nil, "an unrecognised class name is not applied") + eq(weird.player.maxHp, base.player.maxHp, "stats are unaffected by an invalid class") +end + +function tests.skillLearnRespectsClassLevelAndPoints() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skillPoints = 0 + eq(game:learnSkill("cleave"), false, "learning fails without a banked skill point") + game.player.skillPoints = 1 + eq(game:learnSkill("backstab"), false, "learning a skill from another class fails") + eq(game.player.skillPoints, 1, "a failed learn spends no point") + eq(game:learnSkill("berserk"), false, "learning fails below the skill's level requirement") + eq(game:learnSkill("cleave"), true, "a same-class, level-eligible skill with a point banked learns") + eq(game.player.skillPoints, 0, "learning spends the point") + eq(Skills.has(game.player, "cleave"), true, "the skill is now known") + game.player.skillPoints = 1 + eq(game:learnSkill("cleave"), false, "an already-known skill cannot be learned twice") + eq(game.player.skillPoints, 1, "re-learning a known skill spends no point") +end + +function tests.skillCooldownBlocksImmediateReuseAndTicksDown() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.secondWind = true + game.player.hp = game.player.maxHp - 10 + eq(game:useSkill("secondWind"), true, "second wind activates while off cooldown") + eq(game:useSkill("secondWind"), false, "an immediate reuse fails while on cooldown") + for _ = 1, Skills.active.secondWind.cooldown do + game:endPlayerTurn() + end + eq(game.player.skillCooldowns.secondWind, 0, "the cooldown ticks down to zero over that many player turns") + game.player.hp = game.player.maxHp - 10 + eq(game:useSkill("secondWind"), true, "second wind is usable again once its cooldown fully elapses") +end + +function tests.fighterCleaveHitsPrimaryAndAdjacentMonsters() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.cleave = true + local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local splash = Monsters.create(Monsters.byKey.orc, 2, 3, 5, 1) + game.level.entities = { primary, splash } + local success = game:useSkill("cleave", primary) + eq(success, true, "cleave lands on an adjacent primary target") + ok(primary.hp < primary.maxHp, "the primary target takes damage") + ok(splash.hp < splash.maxHp, "a second monster adjacent to the player also takes splash damage") + ok((splash.maxHp - splash.hp) < (primary.maxHp - primary.hp), "the splash hit deals less than the primary hit") + eq(game.player.skillCooldowns.cleave, Skills.active.cleave.cooldown, "cleave starts its own cooldown") +end + +-- Cleave's splash targets must be mitigated by the wielder's own weapon +-- damage type exactly like the primary target already is through +-- Combat.damage/Combat.attack (see combat.lua's damageTypeOf and affixes.lua's +-- own comment that a flaming sword "can do nothing at all against something +-- fire-immune") - before this test the splash loop subtracted a raw +-- attack-minus-defense number straight off hp with no Resistances.apply call +-- at all, so a flaming weapon's cleave splash burned through fire immunity +-- that the very same swing's primary hit already respected. +function tests.fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.cleave = true + game.player.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) + eq(game.player.weapon.damageType, "fire", "the flaming affix retags the dagger's swing as fire") + local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local immune = Monsters.create(Monsters.byKey.dragon, 2, 3, 5, 6) + game.level.entities = { primary, immune } + game:useSkill("cleave", primary) + ok(primary.hp < primary.maxHp, "the primary target still burns for full mitigated damage") + eq(immune.hp, immune.maxHp, "a fire-immune splash target takes zero cleave splash from a flaming weapon") +end + +function tests.fighterSecondWindRestoresPortionOfMaxHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.secondWind = true + game.player.hp = 5 + game:useSkill("secondWind") + eq(game.player.hp, 5 + math.floor(game.player.maxHp * 0.3), "second wind restores three tenths of max hp") + + game.player.skillCooldowns.secondWind = 0 + game.player.hp = game.player.maxHp - 1 end local order = { From 1a36a061ba8559830d02cd79abe2d234e944b0fe Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:32:23 +0200 Subject: [PATCH 096/172] test: cover tests.fighterBerserkBoostsAttackAtLowHp --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 35a7e33..fa1e410 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2378,6 +2378,179 @@ function tests.fighterSecondWindRestoresPortionOfMaxHp() game.player.skillCooldowns.secondWind = 0 game.player.hp = game.player.maxHp - 1 + game:useSkill("secondWind") + eq(game.player.hp, game.player.maxHp, "second wind never heals past max hp") +end + +function tests.fighterBerserkBoostsAttackAtLowHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.berserk = true + local full = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + game.level.entities = { full } + game.player.hp = game.player.maxHp + game:playerAttack(full) + local dmgFull = full.maxHp - full.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + game2.player.class = "fighter" + game2.player.skills.berserk = true + local low = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) + game2.level.entities = { low } + game2.player.hp = math.floor(game2.player.maxHp * 0.3) + game2:playerAttack(low) + local dmgLow = low.maxHp - low.hp + + ok(dmgLow > dmgFull, "berserk raises attack once hp drops to a third of max") +end + +function tests.rogueBackstabDoublesDamageOnFullHpTarget() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.backstab = true + local fresh = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + game.level.entities = { fresh } + game:playerAttack(fresh) + local dmgFresh = fresh.maxHp - fresh.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + game2.player.class = "rogue" + game2.player.skills.backstab = true + local hurt = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) + hurt.hp = hurt.hp - 1 + game2.level.entities = { hurt } + game2:playerAttack(hurt) + local dmgHurt = (hurt.maxHp - 1) - hurt.hp + + eq(dmgFresh, dmgHurt * 2, "backstab doubles damage against a target still at full hp") +end + +function tests.rogueEvasionCanFullyNegateMeleeHit() + local defender = { skills = { evasion = true } } + local sawDodge, sawHit = false, false + local rng = Rng.new(1) + for _ = 1, 60 do + if Skills.evades({ rng = rng }, defender) then sawDodge = true else sawHit = true end + end + ok(sawDodge, "evasion dodges at least once over enough attempts") + ok(sawHit, "evasion is a chance, not a guarantee, over enough attempts") + eq(Skills.evades({ rng = Rng.new(1) }, {}), false, "a defender without the skill never evades") +end + +function tests.poisonBladeAppliesPoisonOnNextMeleeHitOnly() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.poisonBlade = true + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 100, 100 + game.level.entities = { orc } + eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") + game:playerAttack(orc) + ok(Status.has(orc, Status.POISON), "the readied venom lands on the very next melee hit") + orc.statuses = {} + game:playerAttack(orc) + eq(Status.has(orc, Status.POISON), false, "poison blade is spent after one hit, not reapplied on the next") +end + +-- pendingPoisonBlade lives on the player between "ready the venom" and "land +-- the next swing" - a window a save/quit can happen inside, exactly like +-- reputation, sanity or the skill tree already have to survive Game:toData. +function tests.pendingPoisonBladeReadinessSurvivesAGameSaveLoadRoundTrip() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.poisonBlade = true + eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") + eq(game.player.pendingPoisonBlade, true, "readying the skill sets the pending flag before any save/load happens") + local restored = Game.fromData(game:toData()) + eq(restored.player.pendingPoisonBlade, true, "a poison blade readied before a save is still primed after loading it back") + ok(Skills.consumePoisonBlade(restored.player), "the restored pending venom is still consumable on the very next swing") +end + +function tests.mageOverloadBoostsMissileDamage() + local game = stagedGame(5, arena(21, 11), 3, 6) + local base = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { base } + game:castSpell("missile", base) + local dmgBase = base.maxHp - base.hp + + local game2 = stagedGame(5, arena(21, 11), 3, 6) + game2.player.class = "mage" + game2.player.skills.overload = true + local boosted = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game2.level.entities = { boosted } + game2:castSpell("missile", boosted) + local dmgBoosted = boosted.maxHp - boosted.hp + + ok(dmgBoosted > dmgBase, "overload raises missile spell damage") +end + +function tests.mageMeditateRestoresManaWithinCap() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "mage" + game.player.skills.meditate = true + game.player.mana = 2 + game:useSkill("meditate") + eq(game.player.mana, 8, "meditate restores a flat amount of mana") + + game.player.mana = game.player.maxMana - 1 + game:useSkill("meditate") + eq(game.player.mana, game.player.maxMana, "meditate never overfills the mana pool") +end + +function tests.mageManaShieldRaisesDefenseWhileManaHeld() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "mage" + game.player.skills.manaShield = true + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local before = game.player.hp + game.player.mana = 10 + game:monsterMelee(orc) + local dmgWithShield = before - game.player.hp + + game.player.hp = before + game.player.mana = 0 + game:monsterMelee(orc) + local dmgWithoutShield = before - game.player.hp + + ok(dmgWithoutShield > dmgWithShield, "mana shield reduces damage taken while mana holds") +end + +function tests.rangerMultishotHitsTwoTargetsForOneArrow() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "ranger" + game.player.skills.multishot = true + game.player.bow = Items.bow("shortbow", 3, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) + game.level.entities = { first, second } + local success = game:useSkill("multishot", first, second) + eq(success, true, "multishot succeeds with two reachable targets") + ok(first.hp < first.maxHp, "the primary target takes damage") + ok(second.hp < second.maxHp, "the secondary target takes damage") + local stack + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.AMMO then stack = item end + end + eq(stack.count, 4, "multishot consumes only a single arrow for both targets") +end + +-- Multishot's split arrows must run through Resistances.apply exactly like a +-- single Ranged.fire shot does (both always tag physical - arrows never +-- carry an elemental damageType) - before this test the loop subtracted a +-- raw attack-minus-defense number straight off hp for both targets with no +-- mitigation call at all, so a physically resistant monster took a full, +-- unresisted hit from a skill that is supposed to be "an arrow that splits", +-- not a different damage model than the bow it comes out of. +function tests.rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows() + local function primaryDamageDealt(resistant) + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "ranger" + game.player.skills.multishot = true + game.player.bow = Items.bow("shortbow", 3, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + first.hp, first.maxHp = 100, 100 end local order = { From d46d2a49d7bc3b79433a5f88a18d647091e7b89a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 22:38:22 +0200 Subject: [PATCH 097/172] test: cover tests.rangerCamouflageShrinksDetectionOnDarkFloors --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index fa1e410..92a60a7 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2551,6 +2551,179 @@ function tests.rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows() game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) first.hp, first.maxHp = 100, 100 + if resistant then first.resist = { physical = Resistances.RESIST } end + local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) + second.hp, second.maxHp = 100, 100 + game.level.entities = { first, second } + game:useSkill("multishot", first, second) + return 100 - first.hp + end + local plain = primaryDamageDealt(false) + local resisted = primaryDamageDealt(true) + ok(resisted < plain, "a physically resistant target takes measurably less multishot damage than an unresisted one") + eq(resisted, math.floor(plain / 2), "physical resistance halves the split-arrow hit, the same mitigation Resistances.apply gives a single Ranged.fire shot") +end + +function tests.rangerCamouflageShrinksDetectionOnDarkFloors() + local game = stagedGame(1, arenaDark(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 11, 6, 1) + eq(game:canSeePlayer(orc, 10), true, "without camouflage a dark floor does not shrink detection") + + game.player.class = "ranger" + game.player.skills.camouflage = true + eq(game:canSeePlayer(orc, 10), false, "camouflage shrinks a monster's detection range on a dark floor") + + game.level.dark = false + eq(game:canSeePlayer(orc, 10), true, "camouflage does nothing once the floor is lit") +end + +-- Blindness overrides Game:effectiveRadius outright rather than stacking as +-- another penalty term (contrast Atmosphere.sightPenalty/Events.sightPenalty/ +-- Ascension.sightPenalty, which all just shave a fixed amount off) - a fully +-- lit, unpenalized floor still clamps to a radius of one the instant the +-- player is blinded, and returns to normal the instant the status is gone. +function tests.effectiveRadiusBlindStatusOverridesLightAndWeatherToAMinimumOfOne() + local game = stagedGame(1, arena(21, 11), 3, 6) + local clear = game:effectiveRadius() + ok(clear > 1, "an unblinded, unpenalized floor has more than a radius of one to begin with") + + Status.apply(game.player, Status.BLIND, 6, 1) + eq(game:effectiveRadius(), 1, "a blinded player's sight radius clamps to one regardless of light or weather") + + game.player.statuses = {} + eq(game:effectiveRadius(), clear, "sight radius returns to normal once blindness is gone") +end + +-- A blinded monster keeps its ordinary listed detection range on paper, but +-- Game:canSeePlayer clamps every one of ai.lua's behaviours (chase/pack/ +-- ranged/flee/erratic/tunnel all route through this same helper) down to +-- melee-only awareness while it lasts. +function tests.canSeePlayerBlindedMonsterOnlyDetectsThePlayerAtMeleeRange() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 8, 6, 1) + eq(game:canSeePlayer(orc, 10), true, "an unblinded monster within its listed range sees the player") + + Status.apply(orc, Status.BLIND, 6, 1) + eq(game:canSeePlayer(orc, 10), false, "a blinded monster loses a five-cell-away sighting despite its listed range") + + local adjacentOrc = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) + Status.apply(adjacentOrc, Status.BLIND, 6, 1) + eq(game:canSeePlayer(adjacentOrc, 10), true, "a blinded monster still senses the player once adjacent") +end + +function tests.rangerTrueshotAddsFlatRangedDamage() + local game = stagedGame(4, arena(21, 11), 3, 6) + local base = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game.level.entities = { base } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + game:fireAt(base) + local dmgBase = base.maxHp - base.hp + + local game2 = stagedGame(4, arena(21, 11), 3, 6) + game2.player.class = "ranger" + game2.player.skills.trueshot = true + local boosted = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game2.level.entities = { boosted } + game2.player.bow = Items.bow("short bow", 2, 6) + game2.player.inventory[#game2.player.inventory + 1] = Items.ammo("arrow", 2) + game2:fireAt(boosted) + local dmgTrueshot = boosted.maxHp - boosted.hp + + eq(dmgTrueshot, dmgBase + 3, "trueshot adds a flat +3 to every ranged hit") +end + +function tests.autopilotLearnsSkillsAutomaticallyAsPointsAllow() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skillPoints = 1 + game:choosePlayerAction() + ok(Skills.has(game.player, "cleave"), + "the autopilot spends a banked point on the first level-eligible skill in class order") + eq(game.player.skillPoints, 0, "learning the skill spends the banked point") +end + +function tests.skillFieldsSurviveSerialization() + local game = Game.new(5, { class = "mage" }) + game.player.skills.overload = true + game.player.skillCooldowns.meditate = 2 + game.player.skillPoints = 3 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.class, "mage", "class survives a save/load round trip") + eq(restored.player.skillPoints, 3, "skill points survive a save/load round trip") + eq(Skills.has(restored.player, "overload"), true, "learned skills survive a save/load round trip") + eq(restored.player.skillCooldowns.meditate, 2, "skill cooldowns survive a save/load round trip") +end + +function tests.craftingForgeSpendsEssenceForATierOneGem() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.essence = Crafting.FORGE_COST - 1 + local shortSuccess, shortReason = Crafting.forge(game.player, Crafting.FIRE) + eq(shortSuccess, false, "forging refuses when essence is short of the cost") + eq(shortReason, "not enough essence", "the refusal names the reason") + + game.player.essence = Crafting.FORGE_COST + local success, gem = Crafting.forge(game.player, Crafting.FIRE) + ok(success, "forging succeeds with exactly the cost in essence") + eq(gem.tier, 1, "a forged gem starts at tier one") + eq(gem.element, Crafting.FIRE, "the gem carries the requested element") + eq(game.player.essence, 0, "the forge cost is spent") + + local badSuccess, badReason = Crafting.forge(game.player, "shadow") + eq(badSuccess, false, "an unknown element is refused") + eq(badReason, "no such element", "the refusal names the reason") +end + +function tests.craftingMergeGemsCombinesMatchingPairsIntoTheNextTier() + local success, _, merged = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 1)) + ok(success, "two matching chip gems merge") + eq(merged.tier, 2, "the merged gem is one tier higher") + eq(merged.element, Crafting.FIRE, "the merged gem keeps the shared element") + eq(merged.power, 2, "a gem's power always matches its tier") + + local mismatchElement, elementReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FROST, 1)) + eq(mismatchElement, false, "gems of different elements refuse to merge") + eq(elementReason, "elements do not match", "the refusal names the reason") + + local mismatchTier, tierReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 2)) + eq(mismatchTier, false, "gems of different tiers refuse to merge") + eq(tierReason, "tiers do not match", "the refusal names the reason") + + local atCap, capReason = Crafting.mergeGems( + Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER), Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER)) + eq(atCap, false, "a gem already at the maximum tier cannot merge further") + eq(capReason, "already at maximum tier", "the refusal names the reason") +end + +function tests.craftingSocketAddsPowerAndSwapsRiderOnResocket() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + local attackBaseline = game.player.attack + + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FIRE, 1)), + "a gem sockets into an equipped weapon") + eq(weapon.power, 3, "the gem's power lands on the item") + eq(game.player.attack, attackBaseline + 1, "socketing an equipped weapon updates attack immediately") + eq(weapon.socket.element, Crafting.FIRE, "the socket records its element") + + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FROST, 2)), + "resocketing replaces the previous gem") + eq(weapon.power, 4, "power reflects only the new gem, not a stack of both") + eq(game.player.attack, attackBaseline + 2, "attack reflects the swap, not an accumulation") + eq(weapon.socket.element, Crafting.FROST, "the new gem's element replaces the old one") + + local success, reason = Crafting.socket(game.player, weapon, { kind = Items.WEAPON }) + eq(success, false, "a non-gem refuses to socket") + eq(reason, "not a gem", "the refusal names the reason") +end + +function tests.craftingTemperRaisesPowerAtRisingCostUpToACap() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) end local order = { From bf8645b0ad1d50be8c28bc94c555f113b9910748 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 13:05:13 +0200 Subject: [PATCH 098/172] test: cover tests.craftingTemperRefusesWithoutEnoughEssence --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 92a60a7..8084e1e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2724,6 +2724,179 @@ end function tests.craftingTemperRaisesPowerAtRisingCostUpToACap() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + local attackBaseline = game.player.attack + local totalCost = 0 + for level = 0, Crafting.TEMPER_MAX - 1 do + totalCost = totalCost + (level + 1) * 4 + end + game.player.essence = totalCost + + for level = 1, Crafting.TEMPER_MAX do + ok(Crafting.temper(game.player, weapon), "temper step " .. level .. " succeeds with enough essence") + end + eq(weapon.power, 2 + Crafting.TEMPER_MAX, "each temper step adds exactly one flat power") + eq(game.player.attack, attackBaseline + Crafting.TEMPER_MAX, + "tempering an equipped weapon updates attack immediately") + eq(game.player.essence, 0, "the full ladder of rising costs is spent exactly") + + local success, reason = Crafting.temper(game.player, weapon) + eq(success, false, "a fully tempered item refuses a further step") + eq(reason, "fully tempered", "the refusal names the reason") +end + +function tests.craftingTemperRefusesWithoutEnoughEssence() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("mace", 3) + Items.equipWeapon(weapon, game.player) + game.player.essence = 3 + local success, reason = Crafting.temper(game.player, weapon) + eq(success, false, "tempering fails when essence is short of the cost") + eq(reason, "not enough essence", "the refusal names the reason") + eq(weapon.power, 3, "a failed temper leaves power untouched") + eq(game.player.essence, 3, "a failed temper spends nothing") +end + +function tests.craftingEnchantGrantsAChosenAffixOnce() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + local attackBaseline = game.player.attack + game.player.essence = Crafting.ENCHANT_COST + + ok(Crafting.enchant(game.player, weapon, "flaming"), "enchanting an unaffixed weapon succeeds") + eq(weapon.affix.key, "flaming", "the chosen affix is attached, not a random roll") + eq(weapon.power, 3, "the affix's power lands on the item") + eq(weapon.name, "flaming dagger", "the affix prefix is applied to the name") + eq(game.player.attack, attackBaseline + 1, "enchanting an equipped weapon updates attack immediately") + eq(game.player.essence, 0, "the enchant cost is spent") + + local repeatSuccess, repeatReason = Crafting.enchant(game.player, weapon, "vicious") + eq(repeatSuccess, false, "an already-affixed weapon refuses a second enchant") + eq(repeatReason, "already bears an affix", "the refusal names the reason") + + local badSuccess, badReason = Crafting.enchant(game.player, Items.armor("leather armor", 1), "not-a-real-affix") + eq(badSuccess, false, "an unknown affix key is refused") + eq(badReason, "unknown affix", "the refusal names the reason") + + local ringSuccess, ringReason = Crafting.enchant(game.player, Items.ring("ring of power", 1, 1), "sharp") + eq(ringSuccess, false, "gear without an affix pool cannot be enchanted") + eq(ringReason, "cannot be enchanted", "the refusal names the reason") +end + +function tests.craftingDisenchantStripsAffixAndRefundsEssence() + local game = stagedGame(1, arena(21, 11), 3, 6) + local armor = Affixes.decorate(Items.armor("chain mail", 2), Affixes.armor[2]) + Items.equipArmor(armor, game.player) + local defenseBaseline = game.player.defense + eq(armor.name, "reinforced chain mail", "sanity check: the drop rolled the expected affix") + + local success, refund = Crafting.disenchant(game.player, armor) + ok(success, "disenchanting an affixed armor succeeds") + eq(refund, Crafting.FORGE_COST + 2 * 3, "the refund scales with the stripped affix's power") + eq(armor.power, 2, "power drops back to its base value") + eq(armor.name, "chain mail", "the affix prefix is removed from the name") + eq(armor.affix, nil, "the affix is gone") + eq(game.player.defense, defenseBaseline - 2, "disenchanting an equipped armor updates defense immediately") + eq(game.player.essence, refund, "the refund lands in the player's essence") + + local again, reason = Crafting.disenchant(game.player, armor) + eq(again, false, "an already-plain item has nothing left to disenchant") + eq(reason, "nothing to disenchant", "the refusal names the reason") +end + +function tests.craftingSocketedGemRidersStackWithARolledAffixOnHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[4]) + Items.equipWeapon(weapon, game.player) + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), + "a shock gem sockets alongside the rolled affix") + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 30, 30 + game.level.entities = { orc } + game:playerAttack(orc) + eq(Status.has(orc, Status.POISON), true, "the rolled venomed affix still lands its rider") + eq(Status.has(orc, Status.STUN), true, "the socketed shock gem lands its own rider on the same hit") +end + +-- Regression: a shock rider applied during the PLAYER's turn is ticked for +-- the first time on the very same round, inside the victim's own upcoming +-- Game:takeMonsterTurn (Status.tick runs there before Status.skipsTurn is +-- ever checked) - so a duration of 1 would already read 0 and be gone +-- before skipsTurn saw it, and the "stun" would silently never do anything. +-- This confirms the fix (duration 2, see crafting.lua) actually skips the +-- victim's very next turn, not just that the status gets attached. +function tests.craftingShockRiderActuallyStunsTheVictimOnItsNextTurnNotJustOnPaper() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), + "a bare shock gem sockets onto an unaffixed weapon") + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 30, 30 + game.level.entities = { orc } + + game:playerAttack(orc) + eq(Status.has(orc, Status.STUN), true, "the shock rider lands immediately on the hit") + + local hpBefore, xBefore, yBefore = game.player.hp, orc.x, orc.y + game:takeMonsterTurn(orc) + eq(game.player.hp, hpBefore, "a stunned orc's very next turn deals no melee damage") + eq(orc.x, xBefore, "a stunned orc's very next turn does not move") + eq(orc.y, yBefore, "a stunned orc's very next turn does not move") + + game:takeMonsterTurn(orc) + eq(Status.has(orc, Status.STUN), false, "the stun has fully expired by the turn after that") + ok(game.player.hp < hpBefore, "once unstunned the orc resumes attacking normally") +end + +function tests.gameCraftingWrappersDriveTheFullPipeline() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + local attackBaseline = game.player.attack + + game.player.essence = Crafting.FORGE_COST * 2 + ok(game:forgeGem(Crafting.FIRE), "forging a gem through the game wrapper succeeds") + ok(game:forgeGem(Crafting.FIRE), "forging a second matching gem succeeds") + local invBefore = #game.player.inventory + + local firstGemIndex, secondGemIndex + for i, item in ipairs(game.player.inventory) do + if item.kind == Items.GEM then + if not firstGemIndex then firstGemIndex = i else secondGemIndex = i end + end + end + ok(game:mergeGemsAt(firstGemIndex, secondGemIndex), "merging through the game wrapper succeeds") + eq(#game.player.inventory, invBefore - 1, "merging removes both source gems and adds one merged gem") + + local mergedIndex + for i, item in ipairs(game.player.inventory) do + if item.kind == Items.GEM then mergedIndex = i end + end + ok(game:socketItem("weapon", mergedIndex), "socketing the equipped weapon through the game wrapper succeeds") + eq(weapon.socket.element, Crafting.FIRE, "the socketed gem's element lands on the weapon") + eq(game.player.attack, attackBaseline + 2, "socketing a tier-2 gem adds its power to attack") + ok(not game:socketItem("weapon", mergedIndex), "the gem slot is consumed and cannot be socketed twice") + + game.player.essence = Crafting.temperCost(weapon) + ok(game:temperItem("weapon"), "tempering the equipped weapon through the game wrapper succeeds") + eq(weapon.power, 2 + 2 + 1, "temper stacks on top of the socket's power") + eq(game.player.attack, attackBaseline + 3, "tempering the equipped weapon updates attack immediately") + + game.player.essence = Crafting.ENCHANT_COST + ok(game:enchantItem("weapon", "vicious"), "enchanting the equipped weapon through the game wrapper succeeds") + eq(weapon.affix.key, "vicious", "the chosen affix lands on the weapon") + + local beforeDisenchant = game.player.attack + local expectedRefund = Crafting.FORGE_COST + 2 * 3 + ok(game:disenchantItem("weapon"), "disenchanting the equipped weapon through the game wrapper succeeds") + eq(game.player.attack, beforeDisenchant - 2, "disenchanting removes the affix's power from attack") + eq(game.player.essence, expectedRefund, "the disenchant refund lands in essence") + ok(contains(game.log:recent(1)[1] or "", "disenchant"), "the disenchant is logged") +end + +function tests.monsterKillsGrantEssenceScaledToTheirXpReward() end local order = { From 1d0de98b92b6af9e49ced1a46be1b7d7972117bf Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 13:36:21 +0200 Subject: [PATCH 099/172] test: cover tests.craftingFieldsSurviveSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 8084e1e..0d06528 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2897,6 +2897,179 @@ function tests.gameCraftingWrappersDriveTheFullPipeline() end function tests.monsterKillsGrantEssenceScaledToTheirXpReward() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + local essenceBefore = game.player.essence + game:playerAttack(rat) + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Monsters.xpFor(rat)), + "a kill grants essence scaled to the monster's xp reward") +end + +function tests.craftingFieldsSurviveSerialization() + local game = Game.new(5) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + Crafting.socket(game.player, weapon, Crafting.gem(Crafting.VENOM, 2)) + game.player.essence = 37 + game.player.inventory[#game.player.inventory + 1] = Crafting.gem(Crafting.SHOCK, 1) + + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + + eq(restored.player.essence, 37, "essence survives a save/load round trip") + eq(restored.player.weapon.socket.element, Crafting.VENOM, "a socketed gem's element survives a save/load round trip") + eq(restored.player.weapon.socket.tier, 2, "a socketed gem's tier survives a save/load round trip") + local restoredGem + for _, item in ipairs(restored.player.inventory) do + if item.kind == Items.GEM then restoredGem = item end + end + ok(restoredGem ~= nil, "a loose gem in the inventory survives a save/load round trip") + eq(restoredGem.element, Crafting.SHOCK, "the loose gem's element survives a save/load round trip") +end + +function tests.alchemyBrewResolvesAKnownPairToItsFixedPotion() + local potion = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.SUNPETAL) + eq(potion.effect, "heal", "a matched pair of the same herb brews that herb's aspect") + eq(potion.power, 18, "a doubled herb brews a stronger potion than a mixed pair") +end + +function tests.alchemyBrewIsOrderIndependent() + local forward = Alchemy.brew(Alchemy.BLOODROOT, Alchemy.NIGHTCAP) + local backward = Alchemy.brew(Alchemy.NIGHTCAP, Alchemy.BLOODROOT) + eq(forward.effect, backward.effect, "brewing a pair does not depend on which herb is named first") + eq(forward.power, backward.power, "a brewed potion's power does not depend on herb order either") +end + +function tests.alchemyBrewCurdlesOnAnOpposedPairing() + local potion, reason = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.NIGHTCAP) + eq(potion, nil, "light and shade herbs curdle rather than brewing a potion") + eq(reason, "curdle", "a curdled brew names its failure") +end + +function tests.alchemyLookupRejectsAnUnknownHerb() + eq(Alchemy.lookup("mud", Alchemy.SUNPETAL), nil, "a nonexistent herb key has no recipe") +end + +function tests.gameBrewAtCombinesTwoHerbsIntoAPotionAtACauldron() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + ok(game:brewAt(1, 2), "brewing two herbs at a cauldron underfoot succeeds") + eq(#game.player.inventory, 1, "both source herbs are spent and one potion is added") + local brewed = game.player.inventory[1] + eq(brewed.kind, Items.POTION, "brewing yields an ordinary catalog potion") + eq(brewed.effect, "heal", "the brewed potion matches the herb pair's fixed recipe") + eq(brewed.power, 10, "the brewed potion's power matches the recipe, not the catalog default") + ok(Identify.isKnown(game.identify, brewed), "brewing a potion yourself identifies its type for free") +end + +function tests.gameBrewAtFailsWithoutACauldronUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + ok(not game:brewAt(1, 2), "brewing away from any cauldron fails") + eq(#game.player.inventory, 2, "a failed brew for lack of a cauldron spends nothing") +end + +function tests.gameBrewAtRequiresTwoHerbItems() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL) } + ok(not game:brewAt(1, 1), "brewing a herb against itself by index is rejected") + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Items.potion(10) } + ok(not game:brewAt(1, 2), "a non-herb item cannot stand in for the second half of a brew") + eq(#game.player.inventory, 2, "a rejected brew attempt spends nothing") +end + +function tests.gameBrewAtCurdlesIntoGasInsteadOfAPotion() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.NIGHTCAP) } + ok(not game:brewAt(1, 2), "a curdled brew reports failure") + eq(#game.player.inventory, 0, "a curdled brew still spends both herbs") + ok(Hazards.at(game.level, 5, 5, Hazards.GAS) ~= nil, "a curdled brew vents a gas hazard at the brewer's feet") + ok(contains(game.log:recent(1)[1] or "", "curdle"), "the curdle is logged") +end + +function tests.buildersScatterHerbsFromTheFirstFloor() + local level = Builder.build(1, Rng.new(1), {}) + local herbs = 0 + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.HERB then herbs = herbs + 1 end + end + ok(herbs >= 2, "the entrance floor already scatters foraging herbs") +end + +function tests.cauldronsStayOffTheEntranceFloor() + for seed = 1, 5 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.cauldrons, 0, "no cauldron appears on the entrance floor") + end +end + +function tests.buildersOccasionallyPlaceACauldronFromDepthTwo() + local level = Builder.build(2, Rng.new(14), {}) + eq(#level.cauldrons, 1, "this seed's depth-2 floor places a cauldron") +end + +function tests.cauldronFieldsSurviveSerialization() + local level = arena(21, 11) + level.cauldrons = { { x = 4, y = 4 } } + local restored = Level.fromData(level:toData()) + eq(#restored.cauldrons, 1, "the cauldron list survives a round trip") + eq(restored.cauldrons[1].x, 4, "a cauldron's position survives a round trip") +end + +function tests.cauldronRendersItsGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + rendered.level.cauldrons = { { x = 5, y = 6 } } + rendered:computeVisible() + ok(contains(rendered:render(), "U"), "a cauldron on a visible cell renders as its glyph") +end + +local function nodePlayer() + return { essence = 0, inventory = {} } +end + +local function nodeGame(seed) + local game = { player = nodePlayer(), rng = Rng.new(seed), level = { event = nil }, ascension = {} } + function game:essenceMultiplier() + return Events.essenceMultiplier(self.level.event) * Ascension.essenceMultiplier(self.ascension) + end + return game +end + +function tests.nodesHarvestingAnOreVeinPaysEssenceAndSpendsACharge() + local node = Nodes.new(Nodes.ORE) + local game = nodeGame(3) + local success, line = Nodes.harvest(game, node) + ok(success, "harvesting a fresh ore vein succeeds") + eq(node.charges, Nodes.MAX_CHARGES - 1, "a harvest spends exactly one charge") + ok(game.player.essence >= Nodes.ORE_YIELD_MIN and game.player.essence <= Nodes.ORE_YIELD_MAX, + "the essence paid out falls within the vein's yield range") + ok(contains(line, "essence"), "the harvest is logged") +end + +function tests.nodesHarvestingAnOreVeinDoublesItsYieldDuringRichVeins() + local node = Nodes.new(Nodes.ORE) + local game = nodeGame(3) + game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } + Nodes.harvest(game, node) + ok(game.player.essence >= Nodes.ORE_YIELD_MIN * 2 and game.player.essence <= Nodes.ORE_YIELD_MAX * 2, + "rich veins doubles the vein's ordinary yield range") +end + +function tests.nodesHarvestingAGemSeamYieldsARawTierOneGemOfItsElement() + local node = Nodes.new(Nodes.GEM, Crafting.FIRE) + local game = nodeGame(3) + local success = Nodes.harvest(game, node) + ok(success, "harvesting a fresh gem seam succeeds") + eq(#game.player.inventory, 1, "a gem lands in the inventory") + local gem = game.player.inventory[1] + eq(gem.kind, Items.GEM, "the yield is a gem item") end local order = { From 183e67da750e25b9d9d6783ba2855ea317678d48 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 14:11:32 +0200 Subject: [PATCH 100/172] test: cover tests.nodesGoingFallowRefusesFurtherHarvestsUntilItReopens --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 0d06528..1b841d3 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3070,6 +3070,179 @@ function tests.nodesHarvestingAGemSeamYieldsARawTierOneGemOfItsElement() eq(#game.player.inventory, 1, "a gem lands in the inventory") local gem = game.player.inventory[1] eq(gem.kind, Items.GEM, "the yield is a gem item") + eq(gem.element, Crafting.FIRE, "the gem matches the seam's own element") + eq(gem.tier, 1, "a freshly pried gem is raw, tier one") +end + +function tests.nodesGoingFallowRefusesFurtherHarvestsUntilItReopens() + local node = Nodes.new(Nodes.ORE) + node.charges = 1 + local game = nodeGame(9) + ok(Nodes.harvest(game, node), "the last charge still pays out") + eq(node.charges, 0, "spending the last charge empties the vein") + eq(node.cooldown, Nodes.REGEN_TURNS, "emptying a vein starts its regen cooldown") + local success, line = Nodes.harvest(game, node) + eq(success, false, "a fallow vein refuses to be harvested again") + ok(contains(line, "played out"), "the refusal is logged") +end + +function tests.nodesStepCountsDownAndReopensAFallowNode() + local level = { nodes = { Nodes.new(Nodes.ORE) } } + local node = level.nodes[1] + node.charges = 0 + node.cooldown = 2 + Nodes.step(level) + eq(node.cooldown, 1, "each step counts the cooldown down by one") + eq(node.charges, 0, "the vein stays fallow until its cooldown reaches zero") + Nodes.step(level) + eq(node.cooldown, 0, "the cooldown reaches zero on schedule") + eq(node.charges, Nodes.MAX_CHARGES, "reaching zero cooldown fully reopens the vein") +end + +function tests.nodesFieldsSurviveSerialization() + local level = arena(21, 11) + local node = Nodes.new(Nodes.GEM, Crafting.FROST) + node.x, node.y = 4, 4 + node.charges = 1 + node.cooldown = 5 + level.nodes = { node } + local restored = Level.fromData(level:toData()) + eq(#restored.nodes, 1, "the node list survives a round trip") + eq(restored.nodes[1].kind, Nodes.GEM, "a node's kind survives a round trip") + eq(restored.nodes[1].element, Crafting.FROST, "a node's element survives a round trip") + eq(restored.nodes[1].charges, 1, "a node's charges survive a round trip") + eq(restored.nodes[1].cooldown, 5, "a node's cooldown survives a round trip") +end + +function tests.nodeRendersItsGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + local ore = Nodes.new(Nodes.ORE) + ore.x, ore.y = 5, 6 + rendered.level.nodes = { ore } + rendered:computeVisible() + ok(contains(rendered:render(), "O"), "an ore vein on a visible cell renders as its glyph") +end + +function tests.gameHarvestNodeAtAnOreVeinAddsEssenceAndSpendsACharge() + local game = stagedGame(1, arena(21, 11), 5, 5) + local ore = Nodes.new(Nodes.ORE) + ore.x, ore.y = 5, 5 + game.level.nodes = { ore } + local before = game.player.essence + ok(game:harvestNode(), "harvesting the ore vein underfoot succeeds") + ok(game.player.essence > before, "harvesting an ore vein raises the player's essence") + eq(ore.charges, Nodes.MAX_CHARGES - 1, "harvesting spends one of the vein's charges") +end + +function tests.gameHarvestNodeFailsWithoutANodeUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:harvestNode(), "harvesting empty ground fails") +end + +function tests.gameHarvestNodeOnAFallowVeinFails() + local game = stagedGame(1, arena(21, 11), 5, 5) + local ore = Nodes.new(Nodes.ORE) + ore.x, ore.y, ore.charges = 5, 5, 0 + game.level.nodes = { ore } + ok(not game:harvestNode(), "a fallow vein cannot be harvested") +end + +function tests.autopilotHarvestsAnOreVeinWhenIdleAndStandingOnIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local ore = Nodes.new(Nodes.ORE) + ore.x, ore.y = 5, 5 + game.level.nodes = { ore } + local before = game.player.essence + game:choosePlayerAction() + eq(ore.charges, Nodes.MAX_CHARGES - 1, "the autopilot harvests an unspent vein it stands on") + ok(game.player.essence > before, "the autopilot's harvest actually pays out essence") +end + +function tests.autopilotSkipsAFallowNode() + local game = stagedGame(1, arena(21, 11), 5, 5) + local ore = Nodes.new(Nodes.ORE) + ore.x, ore.y, ore.charges = 5, 5, 0 + game.level.nodes = { ore } + game:choosePlayerAction() + eq(ore.charges, 0, "the autopilot never touches a fallow node") +end + +function tests.buildersNeverPlaceANodeOnTheEntranceFloor() + for seed = 1, 5 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.nodes, 0, "no resource node appears on the entrance floor") + end +end + +function tests.buildersAlwaysPlaceAtLeastOneNodeFromDepthTwoOn() + for seed = 1, 5 do + for depth = 2, 5 do + local level = Builder.build(depth, Rng.new(seed), {}) + ok(#level.nodes >= 1, "every depth-2-and-deeper floor places at least one resource node") + ok(#level.nodes <= 2, "no floor places more than two resource nodes") + for _, node in ipairs(level.nodes) do + ok(node.kind == Nodes.ORE or node.kind == Nodes.GEM, "every placed node is a known kind") + end + end + end +end + +local function corpsePlayer() + return { inventory = {}, statuses = {} } +end + +function tests.corpsesSpawnsForAWildMonsterButNotForUndeadMimicOrBoss() + local level = { corpses = {} } + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(level, rat) + eq(#level.corpses, 1, "a wild monster leaves a corpse behind") + + local zombie = Monsters.create(Monsters.byKey.zombie, 2, 6, 6, 4) + Corpses.spawn(level, zombie) + eq(#level.corpses, 1, "an undead monster crumbles to dust, leaving no corpse") + + local mimic = Mimics.reveal(3, 7, 7, 4) + Corpses.spawn(level, mimic) + eq(#level.corpses, 1, "a mimic was never alive, so it leaves no corpse") + + local warlord = Bosses.create(Bosses.byKey.warlord, 4, 8, 8) + Corpses.spawn(level, warlord) + eq(#level.corpses, 1, "a boss's own scripted loot drop replaces a corpse") +end + +function tests.corpsesNutritionScalesWithTheFelledSpeciesMaxHpClampedToAFixedBand() + local bat = Monsters.create(Monsters.byKey.bat, 1, 5, 5, 1) + local batCorpse = Corpses.new(bat) + eq(batCorpse.nutrition, Corpses.NUTRITION_MIN, "a frail species clamps to the nutrition floor") + + local troll = Monsters.create(Monsters.byKey.troll, 2, 5, 5, 5) + local trollCorpse = Corpses.new(troll) + eq(trollCorpse.nutrition, troll.maxHp * Corpses.NUTRITION_PER_HP, "a tougher species scales nutrition with maxHp") + + local dragon = Monsters.create(Monsters.byKey.dragon, 3, 5, 5, 6) + local dragonCorpse = Corpses.new(dragon) + eq(dragonCorpse.nutrition, Corpses.NUTRITION_MAX, "an especially tough species clamps to the nutrition ceiling") +end + +function tests.corpsesStepCountsDownAndRotsAwayEntirely() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local level = { corpses = {} } + Corpses.spawn(level, rat) + local corpse = level.corpses[1] + for _ = 1, Corpses.DECAY_TURNS - 1 do Corpses.step(level) end + eq(#level.corpses, 1, "a corpse survives right up to its final round") + eq(corpse.decay, 1, "decay counts down by exactly one each step") + Corpses.step(level) + eq(#level.corpses, 0, "a corpse vanishes outright once its decay reaches zero") +end + +function tests.corpsesGoesRottenPastItsThresholdButStaysButcherableUntilThen() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local corpse = Corpses.new(rat) + ok(not Corpses.isRotten(corpse), "a freshly spawned corpse is not yet rotten") + corpse.decay = Corpses.ROT_TURNS + ok(Corpses.isRotten(corpse), "a corpse right at its rot threshold counts as rotten") + corpse.decay = Corpses.ROT_TURNS + 1 end local order = { From c58888f5c045611b0def5d51a88420e37cf48261 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 14:26:48 +0200 Subject: [PATCH 101/172] test: cover tests.corpsesButcheringFreshMeatAddsFoodToInventoryAndCon... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 1b841d3..fb60dd5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3243,6 +3243,179 @@ function tests.corpsesGoesRottenPastItsThresholdButStaysButcherableUntilThen() corpse.decay = Corpses.ROT_TURNS ok(Corpses.isRotten(corpse), "a corpse right at its rot threshold counts as rotten") corpse.decay = Corpses.ROT_TURNS + 1 + ok(not Corpses.isRotten(corpse), "a corpse one round above the threshold is still good") +end + +function tests.corpsesButcheringFreshMeatAddsFoodToInventoryAndConsumesTheCorpse() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local level = { corpses = {} } + Corpses.spawn(level, rat) + local corpse = level.corpses[1] + local game = { player = corpsePlayer(), level = level } + local line = Corpses.butcher(game, corpse) + eq(#level.corpses, 0, "butchering removes the corpse from the level") + eq(#game.player.inventory, 1, "butchering fresh meat adds a food item to the inventory") + eq(game.player.inventory[1].kind, Items.FOOD, "the yield is a food item") + eq(game.player.inventory[1].nutrition, corpse.nutrition, "the food item carries the corpse's own nutrition value") + ok(contains(line, "carve"), "a fresh butchering is logged as carving meat") + ok(#game.player.statuses == 0, "fresh meat never poisons the player") +end + +function tests.corpsesButcheringRottenMeatPoisonsThePlayerInsteadAndStillConsumesTheCorpse() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local level = { corpses = {} } + Corpses.spawn(level, rat) + local corpse = level.corpses[1] + corpse.decay = Corpses.ROT_TURNS + local game = { player = corpsePlayer(), level = level } + local line = Corpses.butcher(game, corpse) + eq(#level.corpses, 0, "butchering removes a rotten corpse too") + eq(#game.player.inventory, 0, "rotten meat yields no food item") + ok(Status.has(game.player, Status.POISON), "eating rotten meat poisons the player") + ok(contains(line, "rotted"), "a rotten butchering is logged as such") +end + +function tests.corpseFieldsSurviveSerialization() + local level = arena(21, 11) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(level, rat) + level.corpses[1].x, level.corpses[1].y = 4, 4 + local restored = Level.fromData(level:toData()) + eq(#restored.corpses, 1, "the corpse list survives a round trip") + eq(restored.corpses[1].species, "rat", "a corpse's species survives a round trip") + eq(restored.corpses[1].nutrition, level.corpses[1].nutrition, "a corpse's nutrition survives a round trip") + eq(restored.corpses[1].decay, level.corpses[1].decay, "a corpse's decay survives a round trip") +end + +function tests.corpseRendersItsGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + Corpses.spawn(rendered.level, rat) + rendered:computeVisible() + ok(contains(rendered:render(), "%"), "a corpse on a visible cell renders as its glyph") +end + +function tests.gameAwardKillLeavesACorpseForAWildMonsterButNotForUndeadOrMimic() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 0 + game.level.entities = { rat } + game:awardKill(rat) + eq(#game.level.corpses, 1, "killing a wild monster through awardKill leaves a corpse") + eq(game.level.corpses[1].x, 4, "the corpse sits exactly where the monster fell") + + local zombie = Monsters.create(Monsters.byKey.zombie, 2, 4, 7, 4) + zombie.hp = 0 + game.level.entities = { zombie } + game:awardKill(zombie) + eq(#game.level.corpses, 1, "killing an undead monster through awardKill leaves no new corpse") +end + +function tests.gameButcherCorpseAtFreshMeatFeedsThePlayerAndClearsTheCorpse() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local before = #game.player.inventory + ok(game:butcherCorpse(), "butchering the corpse underfoot succeeds") + eq(#game.level.corpses, 0, "the corpse is gone after butchering") + eq(#game.player.inventory, before + 1, "the player's inventory gains the carved meat") +end + +function tests.gameButcherCorpseFailsWithoutACorpseUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:butcherCorpse(), "butchering empty ground fails") +end + +function tests.autopilotButchersAFreshCorpseWhenIdleAndStandingOnIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local before = #game.player.inventory + game:choosePlayerAction() + eq(#game.level.corpses, 0, "the autopilot butchers a fresh corpse it stands on") + eq(#game.player.inventory, before + 1, "the autopilot's butchering actually yields food") +end + +function tests.autopilotLeavesARottenCorpseAloneRatherThanRiskingPoison() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + game.level.corpses[1].decay = Corpses.ROT_TURNS + game:choosePlayerAction() + eq(#game.level.corpses, 1, "the autopilot never touches a rotten corpse") + ok(not Status.has(game.player, Status.POISON), "the autopilot never risks poisoning itself on rotten meat") +end + +function tests.hungerCurrentDefaultsToCapForAFreshEntity() + eq(Hunger.current({}), Hunger.CAP, "an entity with no satiation field reads as fully fed") +end + +function tests.hungerTierOfClassifiesEachBandAtItsBoundaries() + eq(Hunger.tierOf({ satiation = Hunger.CAP }), Hunger.FED, "a full meter is fed") + eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX + 1 }), Hunger.FED, "just above the hungry ceiling is still fed") + eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX }), Hunger.HUNGRY, "the hungry ceiling itself reads as hungry") + eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX + 1 }), Hunger.HUNGRY, "just above the weak ceiling is still hungry") + eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX }), Hunger.WEAK, "the weak ceiling itself reads as weak") + eq(Hunger.tierOf({ satiation = 1 }), Hunger.WEAK, "one point above zero is still weak, never starving") + eq(Hunger.tierOf({ satiation = 0 }), Hunger.STARVING, "zero itself is starving") +end + +function tests.hungerAttackAndDefensePenaltiesEscalateByTier() + eq(Hunger.attackPenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no attack penalty") + eq(Hunger.attackPenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no attack penalty") + eq(Hunger.attackPenalty({ satiation = Hunger.WEAK_MAX }), -2, "weak costs two points of attack") + eq(Hunger.attackPenalty({ satiation = 0 }), -4, "starving costs four points of attack") + eq(Hunger.defensePenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no defense penalty") + eq(Hunger.defensePenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no defense penalty") + eq(Hunger.defensePenalty({ satiation = Hunger.WEAK_MAX }), -1, "weak costs one point of defense") + eq(Hunger.defensePenalty({ satiation = 0 }), -3, "starving costs three points of defense") +end + +function tests.entityEffectiveAttackAndDefenseFoldInTheHungerPenalty() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 4 }) + eq(entity:effectiveAttack(), 6, "a fresh entity with no satiation field carries no penalty") + entity.satiation = 0 + eq(entity:effectiveAttack(), 2, "a starving body's attack penalty is live in effectiveAttack") + eq(entity:effectiveDefense(), 1, "a starving body's defense penalty is live in effectiveDefense") +end + +function tests.hungerSuppressesRegenOnlyAtWeakOrWorse() + ok(not Hunger.suppressesRegen({ satiation = Hunger.HUNGRY_MAX }), "merely hungry never blocks passive regen") + ok(Hunger.suppressesRegen({ satiation = Hunger.WEAK_MAX }), "weak blocks passive regen") + ok(Hunger.suppressesRegen({ satiation = 0 }), "starving blocks passive regen") +end + +function tests.hungerTickDrainsByOneAndLogsOnlyOnATierCrossing() + local entity = { satiation = Hunger.HUNGRY_MAX + 2, hp = 20 } + eq(Hunger.tick(entity), nil, "a drain turn that stays within its tier reports no line") + eq(entity.satiation, Hunger.HUNGRY_MAX + 1, "the drain was still deducted even without a tier crossing") + eq(Hunger.tick(entity), "you feel hungry", "crossing down into hungry is announced") + eq(entity.satiation, Hunger.HUNGRY_MAX, "the meter keeps draining turn over turn") + eq(entity.hp, 20, "hunger alone never costs hp above starving") +end + +function tests.hungerTickBitesForOneHpEveryRoundWhileStarving() + local entity = { satiation = 1, hp = 10 } + eq(Hunger.tick(entity), "you are starving", "crossing down into starving is announced") + eq(entity.satiation, 0, "starving is a floor, not a negative value") + eq(entity.hp, 9, "reaching starving costs a point of hp immediately") + eq(Hunger.tick(entity), "you are starving", "starving keeps announcing and biting every round it holds") + eq(entity.hp, 8, "a second starving round costs a second point of hp") +end + +function tests.hungerEatCapsAtTheCeilingAndWastesAnOverfullMealIntoBloat() + local entity = { satiation = Hunger.CAP, statuses = {} } + local bloated = Hunger.eat(entity, 400) + ok(bloated, "eating while already at the cap reports the meal as wasted") + eq(entity.satiation, Hunger.CAP, "an overfull meal never lifts satiation past the cap") + ok(Status.has(entity, Status.SLOW), "an overfull meal leaves the diner slowed") +end + +function tests.hungerEatBelowTheCeilingBanksNutritionWithNoWaste() + local entity = { satiation = Hunger.HUNGRY_MAX, statuses = {} } + local bloated = Hunger.eat(entity, 100) + ok(not bloated, "an ordinary meal below the cap is never wasted") + eq(entity.satiation, Hunger.HUNGRY_MAX + 100, "the full nutrition value is banked") end local order = { From b35beaeee7aa5de05952d90b664285235218c90a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:41:03 +0200 Subject: [PATCH 102/172] test: cover tests.hungerEatClampsAMealThatWouldOverflowPastTheCapWith... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index fb60dd5..1080e17 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3416,6 +3416,179 @@ function tests.hungerEatBelowTheCeilingBanksNutritionWithNoWaste() local bloated = Hunger.eat(entity, 100) ok(not bloated, "an ordinary meal below the cap is never wasted") eq(entity.satiation, Hunger.HUNGRY_MAX + 100, "the full nutrition value is banked") + ok(not Status.has(entity, Status.SLOW), "an ordinary meal never bloats the diner") +end + +function tests.hungerEatClampsAMealThatWouldOverflowPastTheCapWithoutBloating() + local entity = { satiation = Hunger.CAP - 50, statuses = {} } + local bloated = Hunger.eat(entity, 400) + ok(not bloated, "a meal eaten while still short of the cap is never wasted, even if it overflows") + eq(entity.satiation, Hunger.CAP, "the surplus past the cap is simply clamped away") + ok(not Status.has(entity, Status.SLOW), "clamping alone never bloats the diner") +end + +function tests.gameHungerTorchTickSuppressesRingRegenOnceThePlayerIsWeak() + local game = Game.new(1) + Items.equipRing(Artifacts.create("ring_of_the_tide"), game.player) + game.player.hp = game.player.maxHp - 10 + game.player.satiation = Hunger.WEAK_MAX + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before, "a weak player's ring of the tide grants no regen this turn") +end + +function tests.gameUseItemEatingFoodRestoresSatiationAndLogsTheMeal() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.satiation = 200 + game.player.inventory = { Items.food("ration", 400) } + ok(game:useItem(1), "eating a carried ration succeeds") + eq(game.player.satiation, 600, "the ration's full nutrition is banked") + eq(#game.player.inventory, 0, "the eaten ration leaves the inventory") + ok(contains(game.log:last(), "eat"), "the meal is logged") +end + +function tests.gameUseItemEatingFoodWhileAlreadyFullAppliesSlowAndLogsBloat() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.satiation = Hunger.CAP + game.player.inventory = { Items.food("ration", 400) } + ok(game:useItem(1), "eating while already full still succeeds") + eq(game.player.satiation, Hunger.CAP, "an overfull meal never lifts satiation past the cap") + ok(Status.has(game.player, Status.SLOW), "eating past the cap leaves the player bloated and slow") + ok(contains(game.log:last(), "bloated"), "the wasted meal is logged as bloating") +end + +function tests.reanimationRaiseCreatesAThrallAdjacentToThePlayerAndSpendsTheCorpseAndSanity() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local corpse = game.level.corpses[1] + local sanityBefore = game.player.sanity + local thrall = Reanimation.raise(game, corpse) + ok(thrall ~= nil, "raising a fresh corpse succeeds") + eq(thrall.kind, "thrall", "a raised thrall is tagged with the thrall kind") + eq(math.max(math.abs(thrall.x - 5), math.abs(thrall.y - 5)), 1, "the thrall rises adjacent to the player") + eq(#game.level.corpses, 0, "raising the corpse consumes it") + eq(game.player.sanity, sanityBefore - Reanimation.SANITY_COST, "raising a thrall costs a fixed bite of sanity") +end + +function tests.reanimationRaiseRefusesARottenCorpseWithoutSpendingAnything() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + game.level.corpses[1].decay = Corpses.ROT_TURNS + local sanityBefore = game.player.sanity + local thrall, line = Reanimation.raise(game, game.level.corpses[1]) + eq(thrall, nil, "a rotten corpse cannot be raised") + eq(#game.level.corpses, 1, "a refused raise leaves the corpse in place") + eq(game.player.sanity, sanityBefore, "a refused raise spends no sanity") + ok(contains(line, "rotted"), "the refusal explains the corpse has rotted too far") +end + +function tests.reanimationRaiseRefusesWithoutEnoughSanityAndLeavesTheCorpseAlone() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + game.player.sanity = Reanimation.SANITY_COST - 1 + local thrall, line = Reanimation.raise(game, game.level.corpses[1]) + eq(thrall, nil, "raising fails when sanity is too low to pay the cost") + eq(#game.level.corpses, 1, "a refused raise for lack of sanity leaves the corpse untouched") + ok(contains(line, "mind"), "the refusal blames the player's own frail grip on their mind") +end + +function tests.reanimationRaiseScalesThrallStatsDownFromTheOriginSpecies() + local game = stagedGame(1, arena(21, 11), 5, 5) + local troll = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 5) + Corpses.spawn(game.level, troll) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + eq(thrall.hp, math.floor(Monsters.byKey.troll.hp * Reanimation.HP_FRACTION), + "a thrall's hp is a fixed fraction of the living species' own hp") + eq(thrall.attack, Monsters.byKey.troll.attack - Reanimation.ATTACK_PENALTY, + "a thrall's attack is blunted by a fixed amount from the living species") + eq(thrall.defense, Monsters.byKey.troll.defense, "a thrall keeps the living species' own defense") +end + +function tests.reanimationRaiseFallsBackToAPlainSkeletonForAnUnrecognisedSpecies() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.corpses[1] = { x = 5, y = 5, species = "unknown-thing", name = "unknown-thing", decay = Corpses.DECAY_TURNS } + local thrall = Reanimation.raise(game, game.level.corpses[1]) + ok(thrall ~= nil, "a corpse of an unrecognised species can still be raised") + eq(thrall.hp, 8, "an unrecognised species falls back to a flat skeleton hp") + eq(thrall.attack, 2, "an unrecognised species falls back to a flat skeleton attack") +end + +function tests.reanimationRaiseRetiresAnEarlierThrallWhenRaisingAnother() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat1 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat1) + local first = Reanimation.raise(game, game.level.corpses[1]) + local rat2 = Monsters.create(Monsters.byKey.rat, 2, 6, 5, 1) + Corpses.spawn(game.level, rat2) + local second = Reanimation.raise(game, game.level.corpses[1]) + ok(not first:isAlive(), "raising a new thrall retires the earlier one") + ok(second:isAlive(), "the freshly raised thrall is alive") + local alive = 0 + for _, e in ipairs(game.level.entities) do + if e.kind == "thrall" and e:isAlive() then alive = alive + 1 end + end + eq(alive, 1, "only one thrall is ever alive at a time") +end + +function tests.reanimationActAttacksAnAdjacentHostileMonsterAndCanKillIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 5, 5, 6) + Corpses.spawn(game.level, chieftain) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local xpBefore = game.player.xp + Reanimation.act(game, thrall) + eq(rat.hp, 0, "a thrall's hit kills the weak rat standing next to it") + ok(game.player.xp > xpBefore, "the player is credited xp for its thrall's kill") +end + +function tests.reanimationActAdvancesOnAVisibleMonsterItCannotYetReach() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local orc = Monsters.create(Monsters.byKey.orc, 50, thrall.x + 5, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX = thrall.x + Reanimation.act(game, thrall) + ok(thrall.x > startX, "the thrall closes the distance to a monster it can see but not reach") +end + +function tests.reanimationActIgnoresAMonsterBeyondItsSightRadius() + local game = stagedGame(1, arena(21, 11), 3, 3) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 3, 3, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX, startY = thrall.x, thrall.y + Reanimation.act(game, thrall) + eq(thrall.x, startX, "a monster far outside sight range does not pull the thrall off course (x)") + eq(thrall.y, startY, "a monster far outside sight range does not pull the thrall off course (y)") +end + +-- Same gap as companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNot +-- OneWithinItsSightRadius, but for Reanimation.act's own nearestHostile. +function tests.reanimationActBlindedThrallOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local farOrc = Monsters.create(Monsters.byKey.orc, 50, thrall.x + 5, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = farOrc + Status.apply(thrall, Status.BLIND, 6, 1) + local startX = thrall.x + Reanimation.act(game, thrall) + eq(thrall.x, startX, "a blinded thrall loses a sighting well within its listed sight radius") + + local rat = Monsters.create(Monsters.byKey.rat, 51, thrall.x + 1, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local ratHpBefore = rat.hp + Reanimation.act(game, thrall) end local order = { From 73de8d4a93ef30e14322bb7bcfec4a71455efa5c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:53:47 +0200 Subject: [PATCH 103/172] test: cover tests.reanimationActAttackingAnAdjacentMonsterEmitsNoiseT... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 1080e17..1908f40 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3589,6 +3589,179 @@ function tests.reanimationActBlindedThrallOnlyDetectsAnAdjacentHostileNotOneWith game.level.entities[#game.level.entities + 1] = rat local ratHpBefore = rat.hp Reanimation.act(game, thrall) + ok(rat.hp < ratHpBefore, "a blinded thrall still fights a hostile once it stands adjacent") +end + +-- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise..., mirrored +-- onto the thrall side. Fresh game per seed rather than a shared one both to +-- match that sibling test's shape and because Reanimation.act decrements +-- thrall.decay on every call, so a single thrall could not absorb 200 calls +-- regardless. +function tests.reanimationActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local corpseSource = Monsters.create(Monsters.byKey.rat, 40, game.player.x + 1, game.player.y, 1) + Corpses.spawn(game.level, corpseSource) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local target = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 51, thrall.x + 4, thrall.y, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities[#game.level.entities + 1] = target + game.level.entities[#game.level.entities + 1] = bystander + Reanimation.act(game, thrall) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a thrall's own melee is exactly as loud as any other ally's or the player's") +end + +function tests.reanimationActFollowsThePlayerWhenNoMonsterIsVisible() + local game = stagedGame(1, arena(21, 11), 10, 5) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 10, 5, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + thrall.x, thrall.y = 3, 5 + local startDist = math.abs(thrall.x - game.player.x) + Reanimation.act(game, thrall) + ok(math.abs(thrall.x - game.player.x) < startDist, "a thrall with no monster in sight closes in on the player") +end + +-- Same wiring proof as the companion/hireling versions above, but for +-- Reanimation.act - placed after its own decay tick/death check (upkeep +-- the confusion roll must never skip), just before the behaviour it can +-- override. +function tests.reanimationActConfusedThrallSometimesStumblesInsteadOfFightingAnAdjacentHostile() + local sawAttack, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 3, 6, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = rat + Status.apply(thrall, Status.CONFUSE, 5, 1) + local hpBefore = rat.hp + Reanimation.act(game, thrall) + if rat.hp < hpBefore then + sawAttack = true + else + sawStumble = true + end + if sawAttack and sawStumble then break end + end + ok(sawAttack, "a confused thrall's roll sometimes lets it fight an adjacent hostile as normal") + ok(sawStumble, "a confused thrall's roll sometimes overrides even a point-blank attack") +end + +function tests.reanimationActCollapsesOnceItsBorrowedAnimationRunsOut() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat0) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + thrall.decay = 1 + game:takeMonsterTurn(thrall) + eq(#game.level.entities, 0, "a thrall whose animation runs out is swept from the level") + ok(contains(game.log:last(), "crumbles"), "a collapsing thrall's fate is logged") +end + +function tests.takeMonsterTurnRoutesAThrallThroughReanimationAct() + local game = stagedGame(1, arena(21, 11), 5, 5) + local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 5, 5, 6) + Corpses.spawn(game.level, chieftain) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) + game.level.entities[#game.level.entities + 1] = rat + game:takeMonsterTurn(thrall) + eq(rat.hp, 0, "the full monster-turn pipeline dispatches a thrall's turn to reanimation.act") +end + +function tests.gameRaiseCorpseSucceedsAndLogsTheCallAnswered() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + ok(game:raiseCorpse(), "raising the corpse underfoot succeeds") + eq(#game.level.corpses, 0, "the corpse is gone after raising") + local hasThrall = false + for _, e in ipairs(game.level.entities) do + if e.kind == "thrall" then hasThrall = true end + end + ok(hasThrall, "a thrall joins the level once raised through the game action") + ok(contains(game.log:last(), "answers the call"), "a successful raise is logged") +end + +function tests.gameRaiseCorpseFailsWithoutACorpseUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:raiseCorpse(), "raising empty ground fails") + ok(contains(game.log:last(), "nothing here"), "the failure is logged") +end + +function tests.scavengingHerbForMapsEachWildFactionToItsOwnFixedHerb() + eq(Scavenging.herbFor({ species = "rat" }), Alchemy.NIGHTCAP, "vermin always yields nightcap") + eq(Scavenging.herbFor({ species = "goblin" }), Alchemy.BLOODROOT, "goblinoids always yield bloodroot") + eq(Scavenging.herbFor({ species = "troll" }), Alchemy.EMBERLEAF, "beasts always yield emberleaf") + eq(Scavenging.herbFor({ species = "dragon" }), Alchemy.EMBERLEAF, "drakes yield the same herb as any other beast-aspected foe") +end + +function tests.scavengingHerbForReturnsNilForACorpseWithNoBestiaryEntry() + eq(Scavenging.herbFor({ species = "hound" }), nil, + "a bonded companion's own remains carry no bestiary-mapped herb") +end + +function tests.gameScavengeCorpseCarvesTheMatchingHerbAndClearsTheCorpse() + local game = stagedGame(1, arena(21, 11), 5, 5) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 5, 5, 2) + Corpses.spawn(game.level, goblin) + local before = #game.player.inventory + ok(game:scavengeCorpse(), "scavenging the corpse underfoot succeeds") + eq(#game.level.corpses, 0, "the corpse is gone after scavenging") + eq(#game.player.inventory, before + 1, "the player's inventory gains the carved herb") + local herb = game.player.inventory[#game.player.inventory] + eq(herb.herb, Alchemy.BLOODROOT, "the carved herb matches the goblin's own goblinoid aspect") + ok(contains(game.log:last(), "carve"), "a successful scavenge is logged") +end + +function tests.gameScavengeCorpseFailsWithoutACorpseUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:scavengeCorpse(), "scavenging empty ground fails") + ok(contains(game.log:last(), "nothing here"), "the failure is logged") +end + +function tests.gameScavengeCorpseFailsOnARottenCorpse() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + game.level.corpses[1].decay = Corpses.ROT_TURNS + local before = #game.player.inventory + ok(not game:scavengeCorpse(), "a rotten corpse yields no herb") + eq(#game.level.corpses, 1, "a failed scavenge leaves the corpse in place rather than spending it") + eq(#game.player.inventory, before, "no herb is added on a failed scavenge") +end + +function tests.gameScavengeCorpseFailsForASpeciesWithNoKnownHerb() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.corpses = { { x = 5, y = 5, species = "hound", name = "hound", nutrition = 40, decay = Corpses.DECAY_TURNS } } + ok(not game:scavengeCorpse(), "a companion's own remains carry nothing worth carving") + eq(#game.level.corpses, 1, "a failed scavenge leaves the corpse in place") +end + +function tests.reanimationFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + thrall.decay = 17 + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.entities, 1, "the thrall survives a level round trip") + eq(restoredLevel.entities[1].kind, "thrall", "the thrall's kind survives a round trip") + eq(restoredLevel.entities[1].decay, 17, "the thrall's remaining animation survives a round trip") +end + +function tests.herbItemsSurviveSerialization() + local game = Game.new(5) + game.player.inventory[#game.player.inventory + 1] = Alchemy.herb(Alchemy.NIGHTCAP) + + local path = os.tmpname() + game:save(path) end local order = { From bc7d3c88d540d39d88de9d89a034491aeb965a38 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 18:01:40 +0200 Subject: [PATCH 104/172] test: cover tests.potionOfFortificationAppliesAShield --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 1908f40..d172d6a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3762,6 +3762,179 @@ function tests.herbItemsSurviveSerialization() local path = os.tmpname() game:save(path) + local restored = Game.load(path) + os.remove(path) + + local restoredHerb + for _, item in ipairs(restored.player.inventory) do + if item.kind == Items.HERB then restoredHerb = item end + end + ok(restoredHerb ~= nil, "a foraged herb survives a save/load round trip") + eq(restoredHerb.herb, Alchemy.NIGHTCAP, "a herb's species survives a save/load round trip") +end + +function tests.potionOfFortificationAppliesAShield() + local game = Game.new(31) + Effects.apply("fortify", game, game.player, {}) + ok(Status.has(game.player, Status.SHIELD), "a fortify effect wards the drinker") +end + +function tests.usingAFortifyPotionAppliesShieldAndConsumesIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local potion = Items.potionOf("fortify") + game.player.inventory = { potion } + ok(game:useItem(1), "drinking a fortify potion succeeds") + eq(#game.player.inventory, 0, "drinking the potion consumes it") + ok(Status.has(game.player, Status.SHIELD), "the fortify potion wards the player") +end + +function tests.potionOfLevitationAppliesTheLevitateStatus() + local game = Game.new(31) + Effects.apply("levitate", game, game.player, {}) + ok(Status.has(game.player, Status.LEVITATE), "a levitate effect lifts the drinker off the ground") +end + +function tests.usingAPotionOfLevitationAppliesLevitateAndConsumesIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local potion = Items.potionOf("levitate") + game.player.inventory = { potion } + ok(game:useItem(1), "drinking a levitation potion succeeds") + eq(#game.player.inventory, 0, "drinking the potion consumes it") + ok(Status.has(game.player, Status.LEVITATE), "the levitation potion lifts the player off the ground") +end + +function tests.companionSummonPlacesItAdjacentToThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + ok(hound ~= nil, "summoning a valid species succeeds next to the player") + eq(hound.kind, "companion", "a summoned companion is tagged with the companion kind") + eq(math.max(math.abs(hound.x - 5), math.abs(hound.y - 5)), 1, "the companion lands adjacent to the player") + eq(#game.level.entities, 1, "the companion joins the level's entity list") +end + +function tests.companionSummonRejectsAnUnknownSpecies() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Companions.summon(game, "wyrm"), nil, "an unknown species key summons nothing") + eq(#game.level.entities, 0, "no entity is added for an unknown species") +end + +function tests.companionSummonReplacesAnEarlierBond() + local game = stagedGame(1, arena(21, 11), 5, 5) + local first = Companions.summon(game, "hound") + local second = Companions.summon(game, "golem") + ok(not first:isAlive(), "bonding a new companion retires the earlier one") + ok(second:isAlive(), "the freshly bonded companion is alive") + local alive = 0 + for _, e in ipairs(game.level.entities) do + if e.kind == "companion" and e:isAlive() then alive = alive + 1 end + end + eq(alive, 1, "only one companion is ever alive at a time") +end + +function tests.companionAttacksAdjacentHostileMonsterAndCanKillIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local xpBefore = game.player.xp + Companions.act(game, hound) + eq(rat.hp, 0, "a hound's hit kills the weak rat standing next to it") + ok(game.player.xp > xpBefore, "the player is credited xp for its companion's kill") +end + +-- Before this fix, companions.lua's own "adjacent" helper was Manhattan-1 +-- (cardinal only) while mercenaries.lua's and reanimation.lua's were +-- Chebyshev-1 (8-directional) despite both explicitly claiming to mirror +-- Companions.act's "same three-branch shape" - a hound diagonally next to a +-- monster fell through to stepMonsterToward instead of attacking, unlike a +-- hireling or thrall caught in the identical geometry. +function tests.companionAttacksADiagonallyAdjacentHostileMonsterJustLikeACardinalOne() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y + 1, 1) + game.level.entities[#game.level.entities + 1] = rat + Companions.act(game, hound) + eq(rat.hp, 0, "a hound's hit kills a weak rat standing diagonally next to it, not just cardinally") +end + +function tests.companionAdvancesOnAVisibleMonsterItCannotYetReach() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local orc = Monsters.create(Monsters.byKey.orc, 50, hound.x + 5, hound.y, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX = hound.x + Companions.act(game, hound) + ok(hound.x > startX, "the companion closes the distance to a monster it can see but not reach") +end + +function tests.companionIgnoresAMonsterBeyondItsSightRadius() + local game = stagedGame(1, arena(21, 11), 3, 3) + local hound = Companions.summon(game, "hound") + local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX, startY = hound.x, hound.y + Companions.act(game, hound) + eq(hound.x, startX, "a monster far outside sight range does not pull the companion off course (x)") + eq(hound.y, startY, "a monster far outside sight range does not pull the companion off course (y)") +end + +-- Before Companions.act's own nearestHostile clamped its sight to melee +-- range under Status.BLIND, a blinded companion kept its full listed sight +-- radius - the same gap Game:canSeePlayer's own clamp already closed for a +-- blinded hostile monster (canSeePlayerBlindedMonsterOnlyDetectsThePlayer +-- AtMeleeRange), just never mirrored onto the ally side. +function tests.companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local farOrc = Monsters.create(Monsters.byKey.orc, 50, hound.x + 5, hound.y, 1) + game.level.entities[#game.level.entities + 1] = farOrc + Status.apply(hound, Status.BLIND, 6, 1) + local startX = hound.x + Companions.act(game, hound) + eq(hound.x, startX, "a blinded companion loses a sighting well within its listed sight radius") + + local rat = Monsters.create(Monsters.byKey.rat, 51, hound.x + 1, hound.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local ratHpBefore = rat.hp + Companions.act(game, hound) + ok(rat.hp < ratHpBefore, "a blinded companion still fights a hostile once it stands adjacent") +end + +-- Companions.act called Combat.attack directly without ever routing through +-- Game:emitNoise, unlike Game:playerAttack/Game:monsterMelee which both do +-- (see gamePlayerAttackCanStirADormantBystanderThatIsNotEvenTheTarget and +-- gameMonsterMeleeCanStirADormantBystanderTooNotJustPlayerActions) - a +-- companion's own melee was silent to every dormant monster nearby, no +-- matter how loud its species' attack would be for the player or a hostile. +-- Fresh game per seed (unlike those two tests' single reused game) since a +-- companion attack, unlike a repeatable playerAttack/monsterMelee call, can +-- kill its one-hp-pool rat target outright. +function tests.companionActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local hound = Companions.summon(game, "hound") + local target = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 51, hound.x + 4, hound.y, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities[#game.level.entities + 1] = target + game.level.entities[#game.level.entities + 1] = bystander + Companions.act(game, hound) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a companion's own melee is exactly as loud as the player's or a hostile monster's") +end + +function tests.companionFollowsThePlayerWhenNoMonsterIsVisible() + local game = stagedGame(1, arena(21, 11), 10, 5) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 3, 5 + local startDist = math.abs(golem.x - game.player.x) + Companions.act(game, golem) + ok(math.abs(golem.x - game.player.x) < startDist, "a companion with no monster in sight closes in on the player") +end + +-- Companions.act never dispatches through ai.lua, so without Game:allyActs end local order = { From 9b44abbbfd79f571d99dca5386b769435295ade1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:11:06 +0200 Subject: [PATCH 105/172] test: cover tests.companionActConfusedCompanionSometimesStumblesInste... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index d172d6a..68bc204 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3935,6 +3935,179 @@ function tests.companionFollowsThePlayerWhenNoMonsterIsVisible() end -- Companions.act never dispatches through ai.lua, so without Game:allyActs +-- Confused wired in first it would leave Status.CONFUSE completely inert on +-- a companion despite Game:springTrap already applying it to "any victim". +-- Mirrors tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own +-- loop-over-seeds shape, but for a point-blank melee decision instead of a +-- chase step. +function tests.companionActConfusedCompanionSometimesStumblesInsteadOfFightingAnAdjacentHostile() + local sawAttack, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local rat = Monsters.create(Monsters.byKey.rat, 50, golem.x + 1, golem.y, 1) + game.level.entities[#game.level.entities + 1] = rat + Status.apply(golem, Status.CONFUSE, 5, 1) + local hpBefore = rat.hp + Companions.act(game, golem) + if rat.hp < hpBefore then + sawAttack = true + else + sawStumble = true + end + if sawAttack and sawStumble then break end + end + ok(sawAttack, "a confused companion's roll sometimes lets it fight an adjacent hostile as normal") + ok(sawStumble, "a confused companion's roll sometimes overrides even a point-blank attack") +end + +-- Unlike a confused monster's stumble (Ai.act -> Game:moveMonster, which +-- treats a step onto the player's cell as an attack), a confused companion +-- must never land a hit on the very player it is bonded to defend - the +-- companion spawns due east of the player (Companions.summon's own +-- ADJACENT order), so a westward stumble roll aims exactly at the player's +-- cell across this seed range, and the player's hp must stay untouched +-- every single time regardless of which of the 4 directions actually rolls. +function tests.companionActConfusedCompanionNeverAttacksThePlayerEvenIfTheStumbleAimsAtItsCell() + local sawMoved = false + for seed = 1, 300 do + local game = stagedGame(seed, arena(21, 11), 5, 5) + local golem = Companions.summon(game, "golem") + Status.apply(golem, Status.CONFUSE, 5, 1) + local hpBefore = game.player.hp + Companions.act(game, golem) + eq(game.player.hp, hpBefore, "a confused companion never attacks the player even when its stumble aims at their cell") + ok(not (golem.x == game.player.x and golem.y == game.player.y), + "a confused companion's stumble is blocked, not allowed to share the player's own cell") + if golem.x ~= 6 or golem.y ~= 5 then sawMoved = true end + end + ok(sawMoved, "the confused companion's stumble roll actually moves it somewhere across many seeds") +end + +function tests.hearthSpriteMendsThePlayerWhenIdle() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.hp = game.player.maxHp - 5 + local sprite = Companions.summon(game, "sprite") + local before = game.player.hp + Companions.act(game, sprite) + eq(game.player.hp, before + Companions.byKey.sprite.heal, "a hearth sprite mends its bonded player each idle turn") +end + +function tests.useItemOnACharmBondsACompanionAndConsumesIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.charm("hound") } + ok(game:useItem(1), "using a charm succeeds") + eq(#game.player.inventory, 0, "the charm leaves the inventory once bonded") + eq(#game.level.entities, 1, "the bonded hound is added to the level") + eq(game.level.entities[1].species, "hound", "the bonded companion matches the charm's species") +end + +function tests.takeMonsterTurnRoutesACompanionThroughCompanionsAct() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) + game.level.entities[#game.level.entities + 1] = rat + game:takeMonsterTurn(hound) + eq(rat.hp, 0, "the full monster-turn pipeline dispatches a companion's turn to companions.act") +end + +-- Game:hungerTorchTick already ticks Disease.tick(self.player) every player +-- turn; Game:takeMonsterTurn never called the same generic, entity-agnostic +-- function for anyone else, so an ally infected through the new +-- infighting.lua bite chance above would sit permanently incubating - +-- never erupting, never escalating, never curable by anything that reads +-- disease state - since nothing ever advanced its clock. +function tests.gameTakeMonsterTurnTicksAnInfectedCompanionsDiseaseExactlyLikeThePlayersOwnEndOfTurnDoes() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + hound.hp, hound.maxHp = 1000, 1000 + Disease.infect(hound, Disease.PARASITES) + local incubation = Disease.defs[Disease.PARASITES].incubation + for _ = 1, incubation - 1 do + game:takeMonsterTurn(hound) + eq(hound.disease.stage, 0, "the companion's infection keeps quietly incubating turn by turn") + end + game:takeMonsterTurn(hound) + eq(hound.disease.stage, 1, "the companion's infection erupts on schedule, exactly like the player's own would") + ok(contains(game.log:last(), "breaks out in gut parasites"), "the eruption is logged with the companion's own name") +end + +function tests.aFallenCompanionIsLoggedAndSweptWithoutAwardingXp() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + hound.hp = 1 + Status.apply(hound, Status.POISON, 1, 5) + local xpBefore = game.player.xp + game:takeMonsterTurn(hound) + eq(#game.level.entities, 0, "a companion that dies to its own status tick is swept from the level") + eq(game.player.xp, xpBefore, "a fallen companion grants the player no xp") + ok(contains(game.log:last(), "falls"), "a fallen companion's death is logged") +end + +function tests.builderOccasionallyPlacesACharmFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.CHARM then found = ground.item end + end + ok(found ~= nil, "this seed's depth-3 floor places a companion's charm") + ok(Companions.byKey[found.companion] ~= nil, "the charm names a real companion species") +end + +function tests.charmsStayOffTheShallowestFloors() + for seed = 1, 5 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.CHARM, "no charm appears above depth 3") + end + end + end +end + +function tests.companionFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + Companions.summon(game, "sprite") + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.entities, 1, "the companion survives a level round trip") + eq(restoredLevel.entities[1].kind, "companion", "the companion's kind survives a round trip") + eq(restoredLevel.entities[1].species, "sprite", "the companion's species survives a round trip") +end + +function tests.companionGainXpBelowFirstThresholdBanksXpWithoutEvolving() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 5) + eq(hound.companionXp, 5, "xp below the first threshold still accumulates") + eq(hound.companionLevel, 1, "a companion under its first threshold stays at level 1") + eq(hound.name, "hunting hound", "an unevolved companion keeps its base name") + eq(hound.attack, 4, "an unevolved companion keeps its base attack") +end + +function tests.companionGainXpAtFirstThresholdEvolvesRenamesAndGrowsStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 12) + eq(hound.companionLevel, 2, "reaching the first threshold advances the companion to level 2") + eq(hound.name, "warhound", "the first evolution renames the companion") + eq(hound.maxHp, 20, "the first evolution's hp bonus raises max hp") + eq(hound.hp, 20, "the first evolution's hp bonus also heals the same amount") + eq(hound.attack, 6, "the first evolution's attack bonus is applied") + eq(hound.defense, 1, "the first evolution's defense bonus is applied") + ok(contains(game.log:last(), "evolves"), "an evolution is logged") +end + +function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumulatively() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 40) + eq(hound.companionLevel, 3, "a single large xp grant can vault past an intermediate threshold") + eq(hound.name, "dire hound", "the companion carries the highest stage's name, not the first") + eq(hound.maxHp, 30, "both stages' hp bonuses stack (14 base + 6 + 10)") + eq(hound.attack, 9, "both stages' attack bonuses stack (4 base + 2 + 3)") + eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") +end + end local order = { From cf5e984712ee963ba7acb3699b288a298744109a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:54:36 +0200 Subject: [PATCH 106/172] test: cover tests.companionGainXpNeverReappliesAnAlreadyCrossedStage --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 68bc204..eb56231 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4108,6 +4108,179 @@ function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumul eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") end +function tests.companionGainXpNeverReappliesAnAlreadyCrossedStage() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 12) + Companions.gainXp(game, hound, 1) + eq(hound.companionXp, 13, "further xp below the next threshold keeps accumulating") + eq(hound.companionLevel, 2, "a companion does not advance again before its next threshold") + eq(hound.attack, 6, "an already-crossed stage's bonus is never applied a second time") +end + +function tests.companionGainXpAtMaxLevelJustBanksFurtherXpWithNoFurtherEvolution() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 40) + Companions.gainXp(game, hound, 100) + eq(hound.companionXp, 140, "xp keeps accumulating past the last stage") + eq(hound.companionLevel, 3, "a fully evolved companion caps out at its last stage") + eq(hound.attack, 9, "stats hold steady once every stage has already been crossed") +end + +function tests.companionGainXpIgnoresAZeroOrNegativeAmount() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 0) + Companions.gainXp(game, hound, -5) + eq(hound.companionXp, 0, "a zero or negative grant leaves companion xp untouched") +end + +function tests.companionActCreditsItsOwnKillWithXpOnTopOfThePlayers() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local xpBefore = game.player.xp + Companions.act(game, hound) + eq(rat.hp, 0, "the hound's hit still kills the rat") + eq(hound.companionXp, Monsters.xpFor(rat), "the companion banks the felled monster's own xp value") + ok(game.player.xp > xpBefore, "the player is still separately credited xp for the companion's kill") +end + +function tests.companionEvolutionBoostsAHearthSpriteHealingOnTopOfItsStaticRate() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.hp = game.player.maxHp - 10 + local sprite = Companions.summon(game, "sprite") + Companions.gainXp(game, sprite, 10) + eq(sprite.name, "kindled sprite", "the sprite's first evolution renames it") + local before = game.player.hp + Companions.act(game, sprite) + eq(game.player.hp, before + Companions.byKey.sprite.heal + 1, + "an evolved sprite heals its species rate plus its own evolution bonus") +end + +function tests.companionLevelXpAndHealBonusSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sprite = Companions.summon(game, "sprite") + Companions.gainXp(game, sprite, 10) + local restoredLevel = Level.fromData(game.level:toData()) + local restored = restoredLevel.entities[1] + eq(restored.companionLevel, 2, "the companion's level survives a round trip") + eq(restored.companionXp, 10, "the companion's banked xp survives a round trip") + eq(restored.companionHealBonus, 1, "the companion's evolved heal bonus survives a round trip") + eq(restored.name, "kindled sprite", "the companion's evolved name survives a round trip") +end + +function tests.mercenaryHireTakesOnAKnownRosterEntryAdjacentToThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + ok(sellsword ~= nil, "hiring a valid roster key succeeds next to the player") + eq(sellsword.kind, "hireling", "a hired mercenary is tagged with the hireling kind") + eq(math.max(math.abs(sellsword.x - 5), math.abs(sellsword.y - 5)), 1, "the hireling lands adjacent to the player") + eq(sellsword.loyalty, Mercenaries.START_LOYALTY, "a freshly hired mercenary starts at full loyalty") + eq(sellsword.wage, Mercenaries.byKey.sellsword.wage, "the hireling carries its roster wage") +end + +function tests.mercenaryHireRejectsAnUnknownRosterKey() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Mercenaries.hire(game, "dragon"), nil, "an unknown roster key hires nobody") + eq(#game.level.entities, 0, "no entity is added for an unknown roster key") +end + +function tests.mercenaryHireReplacesAnEarlierHireling() + local game = stagedGame(1, arena(21, 11), 5, 5) + local first = Mercenaries.hire(game, "sellsword") + local second = Mercenaries.hire(game, "medic") + ok(not first:isAlive(), "hiring a new mercenary retires the earlier one") + ok(second:isAlive(), "the freshly hired mercenary is alive") + local alive = 0 + for _, e in ipairs(game.level.entities) do + if e.kind == "hireling" and e:isAlive() then alive = alive + 1 end + end + eq(alive, 1, "only one hireling is ever alive at a time") +end + +function tests.mercenaryAttacksAdjacentHostileMonsterAndCanKillIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local xpBefore = game.player.xp + Mercenaries.act(game, sellsword) + ok(rat.hp <= 0, "a sellsword's hit kills the weak rat standing next to it") + ok(game.player.xp > xpBefore, "the player is credited xp for its hireling's kill") +end + +function tests.mercenaryAdvancesOnAVisibleMonsterItCannotYetReach() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local orc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX = sellsword.x + Mercenaries.act(game, sellsword) + ok(sellsword.x > startX, "the hireling closes the distance to a monster it can see but not reach") +end + +function tests.mercenaryIgnoresAMonsterBeyondItsSightRadius() + local game = stagedGame(1, arena(21, 11), 3, 3) + local medic = Mercenaries.hire(game, "medic") + local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX, startY = medic.x, medic.y + Mercenaries.act(game, medic) + eq(medic.x, startX, "a monster far outside sight range does not pull the hireling off course (x)") + eq(medic.y, startY, "a monster far outside sight range does not pull the hireling off course (y)") +end + +-- Same gap as companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNot +-- OneWithinItsSightRadius, but for Mercenaries.act's own nearestHostile. +function tests.mercenaryActBlindedHirelingOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local farOrc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = farOrc + Status.apply(sellsword, Status.BLIND, 6, 1) + local startX = sellsword.x + Mercenaries.act(game, sellsword) + eq(sellsword.x, startX, "a blinded hireling loses a sighting well within its listed sight radius") + + local rat = Monsters.create(Monsters.byKey.rat, 51, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local ratHpBefore = rat.hp + Mercenaries.act(game, sellsword) + ok(rat.hp < ratHpBefore, "a blinded hireling still fights a hostile once it stands adjacent") +end + +-- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise..., mirrored +-- onto the hireling side: Mercenaries.act's own Combat.attack call never +-- routed through Game:emitNoise either. +function tests.mercenaryActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local sellsword = Mercenaries.hire(game, "sellsword") + local target = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 51, sellsword.x + 4, sellsword.y, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities[#game.level.entities + 1] = target + game.level.entities[#game.level.entities + 1] = bystander + Mercenaries.act(game, sellsword) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a hireling's own melee is exactly as loud as a companion's or the player's") +end + +function tests.mercenaryFollowsThePlayerWhenNoMonsterIsVisible() + local game = stagedGame(1, arena(21, 11), 10, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.x, sellsword.y = 3, 5 + local startDist = math.abs(sellsword.x - game.player.x) + Mercenaries.act(game, sellsword) + ok(math.abs(sellsword.x - game.player.x) < startDist, "a hireling with no monster in sight closes in on the player") +end + +-- Same wiring proof as tests.companionActConfusedCompanionSometimesStumbles end local order = { From 277f28a49e8cb91425793efaeb1680afde5b535d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 22:30:21 +0200 Subject: [PATCH 107/172] test: cover tests.mercenaryActConfusedHirelingSometimesStumblesInstea... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index eb56231..260babd 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4281,6 +4281,179 @@ function tests.mercenaryFollowsThePlayerWhenNoMonsterIsVisible() end -- Same wiring proof as tests.companionActConfusedCompanionSometimesStumbles +-- InsteadOfFightingAnAdjacentHostile, but for Mercenaries.act - a distinct +-- call site that could independently have been left off Game:allyActsConfused. +function tests.mercenaryActConfusedHirelingSometimesStumblesInsteadOfFightingAnAdjacentHostile() + local sawAttack, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local sellsword = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + Status.apply(sellsword, Status.CONFUSE, 5, 1) + local hpBefore = rat.hp + Mercenaries.act(game, sellsword) + if rat.hp < hpBefore then + sawAttack = true + else + sawStumble = true + end + if sawAttack and sawStumble then break end + end + ok(sawAttack, "a confused hireling's roll sometimes lets it fight an adjacent hostile as normal") + ok(sawStumble, "a confused hireling's roll sometimes overrides even a point-blank attack") +end + +function tests.fieldMedicMendsThePlayerWhenIdle() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.hp = game.player.maxHp - 5 + local medic = Mercenaries.hire(game, "medic") + local before = game.player.hp + Mercenaries.act(game, medic) + eq(game.player.hp, before + Mercenaries.byKey.medic.heal, "a field medic mends its employer each idle turn") +end + +function tests.mercenaryStopsChasingAndFollowingOnceSulking() + local game = stagedGame(1, arena(21, 11), 10, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.loyalty = Mercenaries.SULKING_THRESHOLD + sellsword.x, sellsword.y = 3, 5 + local startX, startY = sellsword.x, sellsword.y + Mercenaries.act(game, sellsword) + eq(sellsword.x, startX, "a sulking hireling does not close in on the player it cannot reach (x)") + eq(sellsword.y, startY, "a sulking hireling does not close in on the player it cannot reach (y)") +end + +function tests.mercenaryStillFightsAnAdjacentHostileEvenWhileSulking() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.loyalty = 0 + local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + Mercenaries.act(game, sellsword) + ok(rat.hp <= 0, "a sulking hireling still fights whatever already stands next to it") +end + +function tests.mercenaryPayWagePaysWhenAffordableAndRestoresLoyalty() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.loyalty = 50 + game.player.gold = 100 + local line = Mercenaries.payWage(game) + eq(game.player.gold, 100 - sellsword.wage, "an affordable wage is deducted from the wallet") + eq(sellsword.loyalty, 50 + Mercenaries.LOYALTY_GAIN_PAID, "paying the wage restores some loyalty") + ok(contains(line, "pay"), "a paid wage is logged as a payment") +end + +function tests.mercenaryPayWageShortsAndDropsLoyaltyWhenGoldIsShort() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + game.player.gold = 0 + local line = Mercenaries.payWage(game) + eq(game.player.gold, 0, "an unaffordable wage never touches the wallet") + eq(sellsword.loyalty, Mercenaries.START_LOYALTY - Mercenaries.LOYALTY_LOSS_UNPAID, + "missing a wage knocks the hireling's loyalty down") + ok(contains(line, "grumbles"), "a missed wage that survives is logged as a grumble") +end + +function tests.mercenaryPayWageDesertsOnceLoyaltyBottomsOut() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.loyalty = 20 + game.player.gold = 0 + local line = Mercenaries.payWage(game) + ok(not sellsword:isAlive(), "a hireling whose loyalty bottoms out deserts") + eq(#game.level.entities, 0, "a deserted hireling is swept from the level") + ok(contains(line, "deserts"), "desertion is logged distinctly from a mere grumble") +end + +function tests.mercenaryPayWageIsANoOpWithNoHirelingOnTheLevel() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Mercenaries.payWage(game), nil, "paying a wage with no hireling present does nothing and reports nothing") +end + +function tests.mercenaryDismissReleasesTheHirelingWithNoPenalty() + local game = stagedGame(1, arena(21, 11), 5, 5) + Mercenaries.hire(game, "medic") + local name = Mercenaries.dismiss(game) + eq(name, "field medic", "dismissing returns the released hireling's name") + eq(#game.level.entities, 0, "the dismissed hireling leaves the level") +end + +function tests.mercenaryDismissFailsWithNoOneUnderContract() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Mercenaries.dismiss(game), nil, "dismissing with nobody hired reports nothing") +end + +function tests.useItemOnAContractHiresTheNamedMercenaryAndConsumesIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.contract("skirmisher") } + ok(game:useItem(1), "using a contract succeeds") + eq(#game.player.inventory, 0, "the contract leaves the inventory once the hireling joins") + eq(#game.level.entities, 1, "the hired mercenary is added to the level") + eq(game.level.entities[1].species, "skirmisher", "the hired mercenary matches the contract's roster key") +end + +function tests.useItemOnAContractWithAnUnknownRosterKeyFailsWithoutConsumingIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.contract("dragon") } + eq(game:useItem(1), false, "using a contract for an unknown roster key fails") + eq(#game.player.inventory, 1, "a failed hire never consumes the contract") +end + +function tests.takeMonsterTurnRoutesAHirelingThroughMercenariesAct() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + game:takeMonsterTurn(sellsword) + ok(rat.hp <= 0, "the full monster-turn pipeline dispatches a hireling's turn to mercenaries.act") +end + +function tests.aFallenHirelingIsLoggedAndSweptWithoutAwardingXp() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.hp = 1 + Status.apply(sellsword, Status.POISON, 1, 5) + local xpBefore = game.player.xp + game:takeMonsterTurn(sellsword) + eq(#game.level.entities, 0, "a hireling that dies to its own status tick is swept from the level") + eq(game.player.xp, xpBefore, "a fallen hireling grants the player no xp") + ok(contains(game.log:last(), "falls"), "a fallen hireling's death is logged") +end + +function tests.gameEndPlayerTurnPaysTheHirelingWageOnceTheIntervalElapses() + local game = Game.new(5) + local sellsword = Mercenaries.hire(game, "sellsword") + ok(sellsword ~= nil, "the starting room around a fresh game has a free cell to hire into") + game.player.gold = 1000 + game.turn = Mercenaries.WAGE_INTERVAL - 1 + local goldBefore = game.player.gold + game:endPlayerTurn() + eq(game.player.gold, goldBefore - sellsword.wage, "the wage interval turn bills the live hireling") +end + +function tests.mercenaryFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + local medic = Mercenaries.hire(game, "medic") + medic.loyalty = 55 + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.entities, 1, "the hireling survives a level round trip") + eq(restoredLevel.entities[1].kind, "hireling", "the hireling's kind survives a round trip") + eq(restoredLevel.entities[1].species, "medic", "the hireling's roster key survives a round trip") + eq(restoredLevel.entities[1].wage, Mercenaries.byKey.medic.wage, "the hireling's wage survives a round trip") + eq(restoredLevel.entities[1].loyalty, 55, "the hireling's loyalty survives a round trip") +end + +function tests.shopPriceOfAContractMatchesItsRosterHireCost() + eq(Shop.priceOf(Items.contract("sellsword")), Mercenaries.byKey.sellsword.hireCost, + "a contract's catalog price matches its roster hire cost") +end + +function tests.shopStockCanRollAContractWithAPositiveCatalogPrice() + local sawContract = false + for seed = 1, 40 do + for _, item in ipairs(Shop.stock(Rng.new(seed))) do end local order = { From 4d07d9597f52eaf97b273d2aa5c6a1d3bb2427b0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 23:34:15 +0200 Subject: [PATCH 108/172] test: cover tests.autopilotHiresACarriedContractWhenNoHirelingIsYetUn... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 260babd..ee27200 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4454,6 +4454,179 @@ function tests.shopStockCanRollAContractWithAPositiveCatalogPrice() local sawContract = false for seed = 1, 40 do for _, item in ipairs(Shop.stock(Rng.new(seed))) do + if item.kind == Items.CONTRACT then + sawContract = true + ok(Shop.priceOf(item) > 0, "a rolled contract carries a positive catalog price") + ok(Mercenaries.byKey[item.hireling] ~= nil, "a rolled contract names a real roster key") + end + end + end + ok(sawContract, "shop stock can roll a mercenary contract across a handful of seeds") +end + +function tests.autopilotHiresACarriedContractWhenNoHirelingIsYetUnderContract() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.entities = {} + game.player.inventory = { Items.contract("skirmisher") } + game:choosePlayerAction() + eq(#game.player.inventory, 0, "the autopilot uses a carried contract on sight when no hireling is yet under contract") + local hired = false + for _, e in ipairs(game.level.entities) do + if e.kind == "hireling" then hired = true end + end + ok(hired, "the autopilot's contract use actually hires the mercenary") +end + +function tests.artifactsCreateBuildsTheCatalogedItemShape() + local blade = Artifacts.create("bloodfang") + eq(blade.kind, Items.WEAPON, "bloodfang is a weapon-slot artifact") + eq(blade.unique, Artifacts.LIFESTEAL, "bloodfang carries the lifesteal tag") + eq(blade.artifact, "bloodfang", "the item remembers its catalog key") + local ring = Artifacts.create("ring_of_the_tide") + eq(ring.kind, Items.RING, "ring of the tide is a ring-slot artifact") + eq(ring.unique, Artifacts.REGEN, "ring of the tide carries the regen tag") + eq(Artifacts.create("nonexistent"), nil, "an unknown catalog key builds nothing") +end + +function tests.lifestealWeaponHealsThePlayerOnAHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Artifacts.create("bloodfang"), game.player) + game.player.hp = game.player.maxHp - 10 + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 30, 30 + game.level.entities = { orc } + local before = game.player.hp + game:playerAttack(orc) + ok(game.player.hp > before, "bloodfang heals the player for a share of the damage it deals") +end + +function tests.thornedArmorReflectsDamageBackAtAMeleeAttacker() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + game.level.entities = { orc } + local before = orc.hp + game:monsterMelee(orc) + ok(orc.hp < before, "aegis of embers burns the monster that hits its wearer") +end + +function tests.thornedArmorCanKillAWeakAttacker() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp, rat.maxHp = 1, 1 + game.level.entities = { rat } + local xpBefore = game.player.xp + game:monsterMelee(rat) + ok(not rat:isAlive(), "a thorn burn can finish off a weak attacker") + ok(game.player.xp > xpBefore, "the player is credited xp for a thorn kill") +end + +-- Aegis of Embers is fire damage by name ("Embers") and by its own log line +-- ("your armor sears") - before this fix the reflect subtracted a raw amount +-- straight off the attacker's hp with no Resistances.apply call at all, so a +-- fire-immune dragon (bestiary-real, reachable from depth 6) burned through +-- its own immunity attacking a thorned wearer. Same shape of bug +-- fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes +-- caught in skills.lua earlier the same day, this time in the thorns path. +function tests.thornedArmorRespectsAFireImmuneAttacker() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 4, 6, 1) + game.level.entities = { dragon } + local before = dragon.hp + game:monsterMelee(dragon) + eq(dragon.hp, before, "a fire-immune dragon takes zero thorns reflect from a fire-flavoured artifact") +end + +function tests.thornedArmorIsHalvedAgainstAFireResistantAttacker() + local function reflectDealt(resistant) + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 100, 100 + if resistant then orc.resist = { fire = Resistances.RESIST } end + game.level.entities = { orc } + game:monsterMelee(orc) + return 100 - orc.hp + end + local plain = reflectDealt(false) + local resisted = reflectDealt(true) + ok(resisted < plain, "a fire-resistant attacker takes measurably less thorns reflect than an unresisted one") + eq(resisted, math.floor(plain / 2), "fire resistance halves the thorns reflect, the same mitigation Resistances.apply gives every other fire source") +end + +function tests.ringOfTheTideRegeneratesHpEachTurn() + local game = Game.new(1) + Items.equipRing(Artifacts.create("ring_of_the_tide"), game.player) + game.player.hp = game.player.maxHp - 10 + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before + 2, "ring of the tide regenerates its fixed amount each turn") +end + +function tests.piercingBowIgnoresPartOfTargetDefense() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.baseAttack = 5 + Items.equipBow(Artifacts.create("gale_bow"), game.player) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 6, 6, 40, 40 + game.level.entities = { orc } + game:fireAt(orc) + eq(orc.hp, 35, "a piercing bow's shot ignores a flat share of the target's defense") +end + +function tests.builderOccasionallyPlacesAnArtifactFromDepthFive() + local level = Builder.build(5, Rng.new(4), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.artifact then found = ground.item end + end + ok(found ~= nil, "this seed's depth-5 floor places a named artifact") + ok(Artifacts.byKey[found.artifact] ~= nil, "the artifact names a real catalog entry") +end + +function tests.artifactsStayOffFloorsAboveDepthFive() + for seed = 1, 4 do + for depth = 1, 4 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(not ground.item.artifact, "no artifact appears above depth 5") + end + end + end +end + +function tests.artifactFieldsSurviveSerialization() + local game = Game.new(3) + Items.equipWeapon(Artifacts.create("bloodfang"), game.player) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.weapon.artifact, "bloodfang", "an equipped artifact's catalog key survives a save/load round trip") + eq(restored.player.weapon.unique, Artifacts.LIFESTEAL, "an equipped artifact's unique tag survives a save/load round trip") + eq(restored.player.weapon.value, 0.5, "an equipped artifact's tuning value survives a save/load round trip") +end + +function tests.useItemOnAKitPlantsAHiddenTrapOnThePlayersTile() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.kit("dart") } + ok(game:useItem(1), "using a trap kit succeeds") + eq(#game.player.inventory, 0, "the kit leaves the inventory once planted") + local trap = game.level:trapAt(5, 5) + ok(trap ~= nil, "a trap is planted on the player's tile") + eq(trap.effect, "dart", "the planted trap matches the kit's effect") + eq(trap.revealed, false, "a freshly planted trap starts hidden") + eq(game.level.map:get(5, 5), Tiles.FLOOR, "a hidden trap does not change the tile's glyph") +end + +function tests.plantingATrapWhereOneAlreadyExistsFails() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.traps = { { x = 5, y = 5, effect = "poison", revealed = false } } + game.player.inventory = { Items.kit("dart") } + ok(not game:useItem(1), "planting a trap on an occupied tile fails") end local order = { From a408c1ebfcf28062e24f131b250dc44d3229d839 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 13:23:03 +0200 Subject: [PATCH 109/172] test: cover tests.aMonsterSteppingOntoAPlayerSetTrapSpringsIt --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index ee27200..5f7357e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4627,6 +4627,179 @@ function tests.plantingATrapWhereOneAlreadyExistsFails() game.level.traps = { { x = 5, y = 5, effect = "poison", revealed = false } } game.player.inventory = { Items.kit("dart") } ok(not game:useItem(1), "planting a trap on an occupied tile fails") + eq(#game.player.inventory, 1, "a failed planting leaves the kit in the inventory") +end + +function tests.aMonsterSteppingOntoAPlayerSetTrapSpringsIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false, placedByPlayer = true } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) + game.level.entities = { orc } + local hpBefore = orc.hp + game:stepMonsterToward(orc, game.player.x, game.player.y) + eq(orc.x, 6, "the orc steps onto the trapped cell while closing on the player") + ok(orc.hp < hpBefore, "stepping onto a player-set dart trap wounds the monster") + eq(game.level.traps[1].revealed, true, "the sprung trap becomes revealed") +end + +-- Status.LEVITATE (see status.lua) makes Game:checkTrapFor and movePlayer's +-- own inline trap check both bail out before springTrap ever runs - and, +-- unlike the lava/chasm skip in checkTerrainFor, deliberately leaves the +-- trap unrevealed: floating clear of a pressure plate buys safety, not free +-- detection of what was under it. +function tests.checkTrapForSkipsAndLeavesUndetectedForALevitatingMonster() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) + Status.apply(orc, Status.LEVITATE, 20, 1) + game.level.entities = { orc } + local hpBefore = orc.hp + game:stepMonsterToward(orc, game.player.x, game.player.y) + eq(orc.x, 6, "the levitating orc still steps onto the trapped cell while closing on the player") + eq(orc.hp, hpBefore, "a levitating monster drifting over a trap takes no damage from it") + eq(game.level.traps[1].revealed, false, "the trap it floated over is never revealed") +end + +function tests.movePlayerSkipsTheInlineTrapTriggerForALevitatingPlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.traps = { { x = 6, y = 5, effect = "dart", revealed = false } } + Status.apply(game.player, Status.LEVITATE, 20, 1) + local hpBefore = game.player.hp + game:movePlayer(1, 0) + eq(game.player.x, 6, "the levitating player still moves onto the trapped cell") + eq(game.player.hp, hpBefore, "a levitating player takes no damage from the trap") + eq(game.level.traps[1].revealed, false, "the trap the player floated over stays hidden") +end + +function tests.aLethalTrapKillsAWeakMonsterAndCreditsThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false } } + local rat = Monsters.create(Monsters.byKey.rat, 1, 7, 6, 1) + rat.hp, rat.maxHp = 1, 1 + game.level.entities = { rat } + local xpBefore = game.player.xp + game:stepMonsterToward(rat, game.player.x, game.player.y) + ok(not rat:isAlive(), "a dart trap can finish off a weak monster") + ok(game.player.xp > xpBefore, "the player is credited xp for a trap kill") + eq(#game.level.entities, 0, "the fallen monster is swept from the level") +end + +function tests.aTeleportTrapRelocatesTheMonsterThatSpringsIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.traps = { { x = 6, y = 6, effect = "teleport", revealed = false } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) + game.level.entities = { orc } + game:stepMonsterToward(orc, game.player.x, game.player.y) + ok(not (orc.x == 6 and orc.y == 6), "a teleport trap relocates the monster off the trapped cell") +end + +function tests.builderOccasionallyPlacesATrapKitFromDepthTwo() + local level = Builder.build(3, Rng.new(3), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.KIT then found = ground.item end + end + ok(found ~= nil, "this seed's depth-3 floor places a trap kit") + local isKnownEffect = false + for _, effect in ipairs(Builder.TRAP_EFFECTS) do + if found.effect == effect then isKnownEffect = true end + end + ok(isKnownEffect, "the kit's effect is one of Builder.TRAP_EFFECTS") +end + +function tests.kitsStayOffTheEntranceFloor() + for seed = 1, 5 do + local level = Builder.build(1, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.KIT, "no trap kit appears on the entrance floor") + end + end +end + +function tests.plantedTrapFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.kit("poison") } + game:useItem(1) + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.traps, 1, "the planted trap survives a level round trip") + eq(restoredLevel.traps[1].effect, "poison", "the planted trap's effect survives a round trip") + eq(restoredLevel.traps[1].placedByPlayer, true, "the planted trap's origin flag survives a round trip") +end + +function tests.bossCreateBuildsAFreshUnenragedBoss() + local warlord = Bosses.create(Bosses.byKey.warlord, 9, 4, 6) + eq(warlord.isBoss, true, "a created boss is flagged isBoss") + eq(warlord.bossKey, "warlord", "a created boss remembers its catalog key") + eq(warlord.enraged, false, "a freshly created boss has not yet enraged") + eq(warlord.hp, warlord.maxHp, "a created boss starts at full hp") + eq(warlord.species, "boss_warlord", "a created boss's species is namespaced away from Monsters.byKey") +end + +function tests.bossAtFullHealthDoesNotEnrage() + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 1, 1) + local before = warlord.attack + local line = Bosses.tryEnrage(warlord) + eq(line, nil, "a boss above its enrage threshold does not enrage") + eq(warlord.enraged, false, "the boss is still marked as not enraged") + eq(warlord.attack, before, "an unenraged boss keeps its base attack") +end + +function tests.bossEnragesOncePastItsHalfHpThresholdAndOnlyOnce() + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 1, 1) + local spec = Bosses.byKey.warlord + warlord.hp = math.floor(warlord.maxHp * spec.enrageAt) + local attackBefore, speedBefore = warlord.attack, warlord.speed + local line = Bosses.tryEnrage(warlord) + ok(line ~= nil, "a bloodied boss enrages and logs a line") + eq(warlord.enraged, true, "the boss is now flagged enraged") + eq(warlord.attack, attackBefore + spec.enrageAttack, "enraging adds the catalog's flat attack bonus") + eq(warlord.baseAttack, attackBefore + spec.enrageAttack, "enraging also lifts the boss's base attack") + eq(warlord.speed, speedBefore + spec.enrageSpeed, "enraging adds the catalog's flat speed bonus") + local hpAfterFirst = warlord.hp + local second = Bosses.tryEnrage(warlord) + eq(second, nil, "an already-enraged boss does not enrage a second time") + eq(warlord.attack, attackBefore + spec.enrageAttack, "a second call never stacks the attack bonus again") + eq(warlord.hp, hpAfterFirst, "a second call grants no further heal") +end + +function tests.bossLootMatchesItsCatalogEntry() + local lich = Bosses.create(Bosses.byKey.lich, 1, 1, 1) + local loot = Bosses.lootFor(lich) + ok(loot ~= nil, "a boss with a valid bossKey drops its catalog loot") + eq(loot.artifact, "lich_phylactery", "the dropped item is the boss's exact catalog artifact") + eq(loot.unique, Artifacts.REGEN, "the boss loot carries its unique mechanic tag") + local unknown = Bosses.create({ key = "ghost" }, 1, 1, 1) + unknown.bossKey = "ghost" + eq(Bosses.lootFor(unknown), nil, "a boss with no catalog entry drops nothing") +end + +function tests.bossOnlyArtifactsAreExcludedFromTheRandomBuilderRoll() + local bossOnlyKeys = { "warlords_cleaver", "lich_phylactery", "bone_queen_carapace" } + for _, key in ipairs(bossOnlyKeys) do + ok(Artifacts.byKey[key] ~= nil, key .. " is a real catalog entry") + local inOrder = false + for _, orderKey in ipairs(Artifacts.order) do + if orderKey == key then inOrder = true end + end + ok(not inOrder, key .. " never appears in the builder's random artifact roll") + end +end + +function tests.builderOccasionallyPlacesABossFromDepthSix() + local level = Builder.build(6, Rng.new(8), {}) + local found + for _, entity in ipairs(level.entities) do + if entity.isBoss then found = entity end + end + ok(found ~= nil, "this seed's depth-6 floor places a named boss") + eq(found.bossKey, "warlord", "the only boss eligible at depth 6 is the warlord") +end + +function tests.bossesStayOffFloorsAboveDepthSix() + for seed = 1, 5 do + for depth = 1, 5 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, entity in ipairs(level.entities) do end local order = { From 94f102b1e287134dd6c6b5ada41d5adc1b0a4d31 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 13:44:14 +0200 Subject: [PATCH 110/172] test: cover tests.takeMonsterTurnEnragesABloodiedBossBeforeItsAiActs --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 5f7357e..9cbf0e5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4800,6 +4800,179 @@ function tests.bossesStayOffFloorsAboveDepthSix() for depth = 1, 5 do local level = Builder.build(depth, Rng.new(seed), {}) for _, entity in ipairs(level.entities) do + ok(not entity.isBoss, "no boss appears above depth 6") + end + end + end +end + +function tests.takeMonsterTurnEnragesABloodiedBossBeforeItsAiActs() + local game = stagedGame(1, arena(21, 11), 3, 6) + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 4, 6) + warlord.hp = math.floor(warlord.maxHp * Bosses.byKey.warlord.enrageAt) + game.level.entities = { warlord } + game:takeMonsterTurn(warlord) + eq(warlord.enraged, true, "a bloodied boss enrages the moment its turn comes up") + local found = false + for _, line in ipairs(game.log.lines) do + if line:find("rage", 1, true) then found = true end + end + ok(found, "the enrage line is written to the message log") +end + +function tests.defeatingABossGrantsBossXpAndDropsItsGuaranteedArtifact() + local game = stagedGame(1, arena(21, 11), 3, 6) + local lich = Bosses.create(Bosses.byKey.lich, 1, 4, 6) + lich.hp = 1 + game.level.entities = { lich } + local levelBefore = game.player.level + local essenceBefore = game.player.essence + game:playerAttack(lich) + -- A boss's xp reward is large enough to roll the player's level past its + -- current threshold, so plain "xp increased by exactly N" arithmetic + -- (which the leveling loop resets on every level-up) does not apply here - + -- see progression.lua's levelUp. Essence, unlike xp, is a plain running + -- total, so it still checks the exact catalog reward directly. + ok(game.player.level > levelBefore, "a boss's xp reward is enough to level the player up") + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Bosses.xpFor(lich)), + "felling a boss grants essence scaled to its catalog xp, not a plain monster's") + local dropped + for _, ground in ipairs(game.level.floorItems) do + if ground.item.artifact == "lich_phylactery" then dropped = ground.item end + end + ok(dropped ~= nil, "the boss's guaranteed artifact lands on the floor where it died") +end + +function tests.bossFieldsSurviveSerialization() + local game = stagedGame(3, arena(21, 11), 5, 5) + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 6, 5) + warlord.hp = math.floor(warlord.maxHp * Bosses.byKey.warlord.enrageAt) + Bosses.tryEnrage(warlord) + game.level.entities = { warlord } + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.entities, 1, "the boss survives a level round trip") + eq(restoredLevel.entities[1].isBoss, true, "the boss's isBoss flag survives a round trip") + eq(restoredLevel.entities[1].bossKey, "warlord", "the boss's catalog key survives a round trip") + eq(restoredLevel.entities[1].enraged, true, "the boss's enraged flag survives a round trip") +end + +function tests.phasesTickIsANoOpForAPlainMonsterOrABossStillAboveItsDesperateThreshold() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + local line, consumed = Phases.tick(game, orc) + eq(line, nil, "a plain monster never enters a desperation phase") + eq(consumed, false, "a plain monster's turn is never consumed by Phases") + + local warlord = Bosses.create(Bosses.byKey.warlord, 2, 5, 6) + line, consumed = Phases.tick(game, warlord) + eq(line, nil, "a boss above its desperateAt threshold does not brace") + eq(consumed, false, "an untouched boss's turn is never consumed by Phases") +end + +function tests.phasesTickBracesOnceABloodiedBossCrossesDesperateAtSpikingDefenseAndConsumingTheTurn() + local game = stagedGame(1, arena(21, 11), 3, 6) + local spec = Bosses.byKey.warlord + local warlord = Bosses.create(spec, 1, 5, 6) + warlord.enraged = true -- already past enrage so its heal cannot mask the desperate check below + warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) + local defenseBefore = warlord.defense + + local line, consumed = Phases.tick(game, warlord) + ok(line ~= nil and line:find("channel", 1, true) ~= nil, "a bloodied boss logs that it begins channeling") + eq(consumed, true, "bracing consumes the boss's entire turn") + eq(warlord.desperate, true, "the boss is now flagged desperate") + eq(warlord.channeling, true, "the boss is now flagged channeling, awaiting its next turn to unleash") + eq(warlord.defense, defenseBefore + spec.desperateDefenseBonus, "bracing spikes defense by the catalog's flat bonus") + eq(warlord.baseDefense, spec.defense + spec.desperateDefenseBonus, "bracing also lifts the boss's base defense") +end + +function tests.phasesTickUnleashesOnTheTurnAfterBracingHittingThePlayerAndRevertingDefense() + local game = stagedGame(1, arena(21, 11), 4, 6) + local spec = Bosses.byKey.lich + local lich = Bosses.create(spec, 1, 5, 6) + lich.enraged = true + lich.hp = math.floor(lich.maxHp * spec.desperateAt) + Phases.tick(game, lich) -- brace + local defenseBraced = lich.defense + local hpBefore = game.player.hp + + local line, consumed = Phases.tick(game, lich) + eq(consumed, true, "unleashing also consumes the boss's entire turn") + eq(lich.channeling, false, "unleashing clears the channeling flag") + eq(lich.novaSpent, true, "unleashing latches novaSpent permanently") + eq(lich.defense, defenseBraced - spec.desperateDefenseBonus, "unleashing reverts the braced defense spike") + eq(lich.defense, spec.defense, "the reverted defense lands back on the boss's unmodified catalog value") + eq(game.player.hp, hpBefore - spec.novaDamage, "the cataclysm deals the catalog's flat nova damage to the player") + ok(line:find("cataclysm", 1, true) ~= nil, "the unleash line names the cataclysm") +end + +function tests.phasesUnleashHitsCompanionsAndHirelingsInRadiusButLeavesAWildMonsterUntouched() + local game = stagedGame(1, arena(21, 11), 3, 3) + local spec = Bosses.byKey.bonequeen + local queen = Bosses.create(spec, 1, 10, 6) + queen.enraged = true + queen.hp = math.floor(queen.maxHp * spec.desperateAt) + local ally = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + local hireling = Entity.new({ + id = 3, x = 9, y = 7, glyph = "@", name = "sellsword", kind = "hireling", + hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + local bystander = Monsters.create(Monsters.byKey.rat, 4, 10, 5, 1) + game.level.entities = { queen, ally, hireling, bystander } + game.player.x, game.player.y = 20, 10 -- far outside novaRadius + + Phases.tick(game, queen) -- brace + Phases.tick(game, queen) -- unleash + + eq(ally.hp, 10 - spec.novaDamage, "a companion within radius takes the cataclysm's nova damage") + eq(hireling.hp, 10 - spec.novaDamage, "a hireling within radius takes the cataclysm's nova damage") + eq(bystander.hp, bystander.maxHp, "a wild monster caught nearby is never a cataclysm target") +end + +function tests.phasesUnleashHitsAReanimatedThrallInRadiusJustLikeACompanionOrHireling() + local game = stagedGame(1, arena(21, 11), 3, 3) + local spec = Bosses.byKey.bonequeen + local queen = Bosses.create(spec, 1, 10, 6) + queen.enraged = true + queen.hp = math.floor(queen.maxHp * spec.desperateAt) + local thrall = Entity.new({ + id = 2, x = 11, y = 6, glyph = "Z", name = "rat thrall", kind = "thrall", + hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { queen, thrall } + game.player.x, game.player.y = 20, 10 -- far outside novaRadius, only the thrall is in range + + Phases.tick(game, queen) -- brace + Phases.tick(game, queen) -- unleash + + eq(thrall.hp, 10 - spec.novaDamage, + "a player-raised thrall is just as much the boss's cornered party as a companion or hireling") +end + +function tests.phasesNovaFiresAtMostOncePerBossThenFallsBackToItsOrdinaryAbility() + local game = stagedGame(1, arena(21, 11), 3, 6) + local spec = Bosses.byKey.warlord + local warlord = Bosses.create(spec, 1, 5, 6) + warlord.enraged = true + warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) + Phases.tick(game, warlord) -- brace + Phases.tick(game, warlord) -- unleash, latches novaSpent + + local line, consumed = Phases.tick(game, warlord) + eq(line, nil, "a boss that already spent its cataclysm never braces again") + eq(consumed, false, "Phases stops consuming turns once the cataclysm is spent") +end + +function tests.takeMonsterTurnSkipsAbilityAndAiWhileABossBracesOrUnleashesItsCataclysm() + local game = stagedGame(1, arena(21, 11), 3, 6) + local spec = Bosses.byKey.warlord + local warlord = Bosses.create(spec, 1, 4, 6) + warlord.enraged = true + warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) + game.level.entities = { warlord } end local order = { From 61296a4dd31744decaffce95442e968182d8c61b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 13:44:37 +0200 Subject: [PATCH 111/172] test: cover tests.bossPhaseFieldsSurviveALevelSerializationRoundTrip --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 9cbf0e5..46ecce5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4973,6 +4973,179 @@ function tests.takeMonsterTurnSkipsAbilityAndAiWhileABossBracesOrUnleashesItsCat warlord.enraged = true warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) game.level.entities = { warlord } + + game:takeMonsterTurn(warlord) + eq(warlord.x, 4, "a bracing boss never steps toward the player through its ordinary ai") + local hpAfterBrace = game.player.hp + eq(hpAfterBrace, game.player.maxHp, "bracing alone never damages the player") + + game:takeMonsterTurn(warlord) + ok(game.player.hp < hpAfterBrace, "unleashing the cataclysm damages the adjacent player") + + local hpBeforeThird = game.player.hp + game:takeMonsterTurn(warlord) + ok(warlord.ability == nil or true, "a spent boss falls through to its plain rally ability or ai on its next turn") + ok(game.player.hp <= hpBeforeThird, "the boss keeps acting normally once its cataclysm is spent") +end + +function tests.bossPhaseFieldsSurviveALevelSerializationRoundTrip() + local game = stagedGame(3, arena(21, 11), 5, 5) + local spec = Bosses.byKey.warlord + local warlord = Bosses.create(spec, 1, 6, 5) + warlord.enraged = true + warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) + Phases.tick(game, warlord) -- brace: desperate + channeling + game.level.entities = { warlord } + + local restoredLevel = Level.fromData(game.level:toData()) + local restored = restoredLevel.entities[1] + eq(restored.desperate, true, "the boss's desperate flag survives a round trip") + eq(restored.channeling, true, "the boss's channeling flag survives a round trip") + eq(restored.defense, warlord.defense, "the boss's braced defense value survives a round trip") + + Phases.tick(game, restored) -- unleash on the restored copy + eq(restored.novaSpent, true, "a restored boss can still unleash and latch novaSpent") +end + +function tests.questGenerateProducesACullBounty() + local bounty = Quests.generate(Rng.new(2), 4, 10) + eq(bounty.kind, Quests.CULL, "this seed at depth 4 rolls a cull bounty") + eq(bounty.species, "necromancer", "the cull bounty's target species is fixed by the seed") + eq(bounty.need, 4, "the cull bounty's tally is fixed by the seed") + eq(bounty.have, 0, "a freshly generated cull bounty starts at zero progress") + eq(bounty.rewardGold, 47, "reward gold scales with the generating depth") + eq(bounty.rewardXp, 26, "reward xp scales with the generating depth") + eq(Quests.describe(bounty), "cull 4 necromancer (0/4)", "describe renders the cull bounty's live progress") +end + +function tests.questGenerateProducesAHoardBounty() + local bounty = Quests.generate(Rng.new(1), 4, 10) + eq(bounty.kind, Quests.HOARD, "this seed at depth 4 rolls a hoard bounty") + eq(bounty.need, 120, "the hoard bounty's absolute gold target scales with depth") + eq(Quests.describe(bounty), "amass 120 gold", "describe renders the hoard bounty's target") +end + +function tests.questGenerateProducesADepthBountyClampedToMaxDepth() + local shallow = Quests.generate(Rng.new(5), 4, 10) + eq(shallow.kind, Quests.DEPTH, "this seed at depth 4 rolls a depth bounty") + eq(shallow.need, 6, "an unclamped depth bounty targets a few floors further down") + local nearBottom = Quests.generate(Rng.new(5), 9, 10) + eq(nearBottom.kind, Quests.DEPTH, "the same seed at depth 9 also rolls a depth bounty") + eq(nearBottom.need, 10, "a depth bounty that would exceed the dungeon's floor is clamped to it") +end + +function tests.questAcceptBindsThePlayerAndClaimsTheBoard() + local player = { quest = nil } + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success, line = Quests.accept(player, board) + eq(success, true, "accepting an unclaimed bounty while unbound succeeds") + eq(player.quest, board.quest, "the player is bound to the board's exact bounty") + eq(board.claimed, true, "the board is claimed the instant its bounty is taken") + ok(line:find("necromancer") ~= nil, "the acceptance line names the bounty struck") +end + +function tests.questAcceptFailsWhenAlreadyHoldingOne() + local existing = Quests.generate(Rng.new(1), 4, 10) + local player = { quest = existing } + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = Quests.accept(player, board) + eq(success, false, "a player already bound to a bounty cannot bind to a second") + eq(player.quest, existing, "the player's original bounty is untouched") + eq(board.claimed, false, "the untaken board stays unclaimed") +end + +function tests.questAcceptFailsOnAnAlreadyClaimedBoard() + local player = { quest = nil } + local board = { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = Quests.accept(player, board) + eq(success, false, "a board already claimed grants no second bounty") + eq(player.quest, nil, "the player stays unbound") +end + +function tests.questRegisterKillIncrementsOnlyMatchingSpeciesWhileActive() + local player = { quest = { kind = Quests.CULL, species = "rat", need = 3, have = 0 } } + Quests.registerKill(player, "goblin") + eq(player.quest.have, 0, "a kill of an unrelated species does not advance the tally") + Quests.registerKill(player, "rat") + Quests.registerKill(player, "rat") + eq(player.quest.have, 2, "kills of the target species advance the tally") + Quests.registerKill(player, "rat") + Quests.registerKill(player, "rat") + eq(player.quest.have, 3, "the tally never climbs past the bounty's required count") +end + +function tests.questResolvedDetectsEachKindsCompletion() + local culler = { quest = { kind = Quests.CULL, need = 3, have = 2 }, gold = 0 } + eq(Quests.resolved(culler, 1), false, "a cull short of its tally is not resolved") + culler.quest.have = 3 + eq(Quests.resolved(culler, 1), true, "a cull at its tally is resolved") + + local hoarder = { quest = { kind = Quests.HOARD, need = 100 }, gold = 40 } + eq(Quests.resolved(hoarder, 1), false, "a hoard short of its gold target is not resolved") + hoarder.gold = 100 + eq(Quests.resolved(hoarder, 1), true, "a hoard at its gold target is resolved") + + local pactbound = { quest = { kind = Quests.DEPTH, need = 5 }, gold = 0 } + eq(Quests.resolved(pactbound, 4), false, "a depth pact short of its floor is not resolved") + eq(Quests.resolved(pactbound, 5), true, "a depth pact met at its floor is resolved") + + eq(Quests.resolved({ quest = nil, gold = 0 }, 5), false, "a player with no bounty is never resolved") +end + +function tests.gameResolveQuestPaysOutAndClearsAFulfilledBounty() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } + local goldBefore, essenceBefore = game.player.gold, game.player.essence + local resolved = game:resolveQuest() + eq(resolved, true, "resolveQuest reports the payout it made") + eq(game.player.gold, goldBefore + 30, "the bounty's gold reward is paid") + eq(game.player.essence, essenceBefore + Crafting.essenceFor(15), "the bounty's xp reward also converts to essence") + eq(game.player.quest, nil, "a resolved bounty frees the player to bind another") +end + +function tests.gameResolveQuestIsANoOpOnAnUnmetBounty() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 3, have = 1, rewardGold = 30, rewardXp = 15 } + local goldBefore = game.player.gold + local resolved = game:resolveQuest() + eq(resolved, false, "resolveQuest reports nothing was paid out") + eq(game.player.gold, goldBefore, "no gold changes hands on an unmet bounty") + ok(game.player.quest ~= nil, "the unmet bounty stays bound to the player") +end + +function tests.gameTryAcceptQuestBindsAndLogsWhenStandingOnAnUnclaimedBoard() + local game = stagedGame(1, arena(21, 11), 5, 5) + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = game:tryAcceptQuest(board) + eq(success, true, "tryAcceptQuest binds the player to the board it stands on") + eq(game.player.quest.kind, Quests.CULL, "the bound bounty matches the board's own") + eq(board.claimed, true, "the board is spent on acceptance") +end + +function tests.autopilotAcceptsAnUnclaimedBountyForFreeWithoutSkippingItsTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 5, 1) + game.level.entities = { orc } + local hpBefore = orc.hp + game:choosePlayerAction() + ok(game.player.quest ~= nil, "the autopilot binds itself to a bounty it stands on") + eq(game.level.questBoards[1].claimed, true, "the board is claimed once the autopilot steps onto it") + ok(orc.hp < hpBefore, "accepting the bounty costs no action, so the same turn still lands an attack") +end + +function tests.autopilotSkipsAnAlreadyClaimedBoard() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.questBoards = { { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } + game:choosePlayerAction() + eq(game.player.quest, nil, "the autopilot never binds to an already claimed board") +end + +function tests.autopilotDoesNotStackASecondBountyWhileOneIsActive() + local game = stagedGame(1, arena(21, 11), 5, 5) + local held = Quests.generate(Rng.new(1), 4, 10) + game.player.quest = held + game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } end local order = { From 24d2a4288f9e6ed9fd5650f3f32bd40b0db14176 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 14:18:36 +0200 Subject: [PATCH 112/172] test: cover tests.questBoardRendersItsGlyphAndFlipsOnceClaimed --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 46ecce5..404bbc9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5146,6 +5146,179 @@ function tests.autopilotDoesNotStackASecondBountyWhileOneIsActive() local held = Quests.generate(Rng.new(1), 4, 10) game.player.quest = held game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } + game:choosePlayerAction() + eq(game.player.quest, held, "the autopilot keeps its held bounty rather than swapping to a new one") + eq(game.level.questBoards[1].claimed, false, "a board it cannot bind to stays unclaimed for later") +end + +function tests.questBoardRendersItsGlyphAndFlipsOnceClaimed() + local game = stagedGame(1, arena(21, 11), 3, 6) + local board = { x = 5, y = 6, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + game.level.questBoards = { board } + game:computeVisible() + ok(game:render():find("&", 1, true) ~= nil, "an unclaimed bounty board renders its glyph") + board.claimed = true + ok(game:render():find("=", 1, true) ~= nil, "a claimed bounty board renders a different glyph") +end + +function tests.questFieldsSurviveSerialization() + local level = arena(21, 11) + level.questBoards = { { x = 4, y = 4, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } + local restored = Level.fromData(level:toData()) + eq(#restored.questBoards, 1, "the quest board list survives a level round trip") + eq(restored.questBoards[1].claimed, true, "a board's claimed flag survives a round trip") + eq(restored.questBoards[1].quest.kind, Quests.CULL, "a board's bounty kind survives a round trip") + eq(restored.questBoards[1].quest.need, 4, "a board's bounty tally survives a round trip") + + local game = Game.new(6) + game.player.quest = { kind = Quests.HOARD, need = 200, rewardGold = 50, rewardXp = 20 } + local path = os.tmpname() + game:save(path) + local restoredGame = Game.load(path) + os.remove(path) + eq(restoredGame.player.quest.kind, Quests.HOARD, "the player's own bound bounty survives a save/load round trip") + eq(restoredGame.player.quest.need, 200, "the bound bounty's target survives a save/load round trip") +end + +function tests.builderOccasionallyPlacesAQuestBoardFromDepthTwo() + local level = Builder.build(2, Rng.new(11), {}) + eq(#level.questBoards, 1, "this seed's depth-2 floor places a bounty board") + eq(level.questBoards[1].claimed, false, "a freshly built board starts unclaimed") + ok(level.questBoards[1].quest ~= nil, "a freshly built board already carries its one bounty") +end + +function tests.questBoardsStayOffTheEntranceFloor() + for seed = 1, 5 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.questBoards, 0, "no bounty board appears on the entrance floor") + end +end + +function tests.metaFreshStateHasNoRenownRunsOrAchievements() + local meta = Meta.new() + eq(meta.renown, 0, "a fresh meta state banks no renown") + eq(meta.totalRuns, 0, "a fresh meta state has recorded no runs") + eq(meta.wins, 0, "a fresh meta state has recorded no wins") + eq(meta.bestDepth, 0, "a fresh meta state has no best depth yet") + eq(next(meta.achievements), nil, "a fresh meta state has unlocked nothing") + eq(next(meta.perks), nil, "a fresh meta state owns no perks") +end + +function tests.metaRenownForScalesWithDepthBossesAndQuestsAndAddsAWinBonus() + local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = false }) + eq(base, 12, "renown scales with deepest depth alone") + local withBosses = Meta.renownFor({ deepestDepth = 3, bossKills = 2, questsCompleted = 0, won = false }) + eq(withBosses, base + 30, "each felled boss adds a fixed renown bonus") + local withQuests = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 4, won = false }) + eq(withQuests, base + 32, "each resolved bounty adds a fixed renown bonus") + local won = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = true }) + eq(won, base + 100, "escaping with the amulet adds a large flat bonus") +end + +function tests.metaRecordRunBanksRenownAndAdvancesTallies() + local meta = Meta.new() + Meta.recordRun(meta, { deepestDepth = 4, kills = 2, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) + eq(meta.totalRuns, 1, "recordRun advances the run tally") + eq(meta.wins, 0, "an unwon run does not advance the win tally") + eq(meta.bestDepth, 4, "recordRun raises the best depth to this run's own") + eq(meta.renown, Meta.renownFor({ deepestDepth = 4, bossKills = 0, questsCompleted = 0, won = false }), + "recordRun banks exactly the renown this run's tally is worth") + Meta.recordRun(meta, { deepestDepth = 2, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + eq(meta.totalRuns, 2, "a second run advances the run tally again") + eq(meta.wins, 1, "a won run advances the win tally") + eq(meta.bestDepth, 4, "a shallower later run does not lower the best depth") +end + +function tests.metaRecordRunUnlocksMatchingAchievementsOnlyOnce() + local meta = Meta.new() + local stats = { deepestDepth = 5, kills = 1, bossKills = 1, questsCompleted = 3, artifactsFound = 1, won = true } + local unlocked = Meta.recordRun(meta, stats) + table.sort(unlocked) + local expected = { "Amulet Bearer", "Artificer", "Boss Slayer", "Bounty Hunter", "Deep Delver", "First Blood" } + table.sort(expected) + eq(table.concat(unlocked, ","), table.concat(expected, ","), + "a run meeting every threshold unlocks every achievement at once") + for _, key in ipairs({ "first_blood", "boss_slayer", "deep_delver", "bounty_hunter", "artificer", "amulet_bearer" }) do + eq(meta.achievements[key], true, key .. " is marked unlocked") + end + local again = Meta.recordRun(meta, stats) + eq(#again, 0, "an already-unlocked achievement is not re-announced on a later run") +end + +function tests.metaPurchaseSpendsRenownAndMarksThePerkOwned() + local meta = Meta.new() + meta.renown = 30 + local success, message = Meta.purchase(meta, "vigor") + eq(success, true, "purchase succeeds with enough banked renown") + eq(message, "unlocked Vigor", "purchase reports the perk it unlocked") + eq(meta.renown, 0, "purchase spends exactly the perk's cost") + eq(Meta.isOwned(meta, "vigor"), true, "the purchased perk is marked owned") +end + +function tests.metaPurchaseFailsWithoutEnoughRenown() + local meta = Meta.new() + meta.renown = 5 + local success, message = Meta.purchase(meta, "vigor") + eq(success, false, "purchase fails without enough banked renown") + eq(message, "not enough renown", "purchase explains why it failed") + eq(meta.renown, 5, "a failed purchase spends nothing") +end + +function tests.metaPurchaseFailsForAnAlreadyOwnedPerk() + local meta = Meta.new() + meta.renown = 100 + Meta.purchase(meta, "vigor") + local renownAfterFirst = meta.renown + local success, message = Meta.purchase(meta, "vigor") + eq(success, false, "purchase refuses to sell the same perk twice") + eq(message, "already owned", "purchase explains why it failed") + eq(meta.renown, renownAfterFirst, "a refused repurchase spends nothing further") +end + +function tests.metaPurchaseFailsForAnUnknownPerk() + local meta = Meta.new() + meta.renown = 100 + local success, message = Meta.purchase(meta, "not_a_real_perk") + eq(success, false, "purchase fails for a key outside the catalog") + eq(message, "no such perk", "purchase explains why it failed") + eq(meta.renown, 100, "a failed purchase spends nothing") +end + +function tests.metaApplyAllAppliesEveryOwnedPerkToAFreshPlayer() + local meta = Meta.new() + meta.renown = 200 + Meta.purchase(meta, "vigor") + Meta.purchase(meta, "purse") + local game = Game.new(1, { meta = meta }) + eq(game.player.maxHp, 30 + 5, "the owned vigor perk raises max hp before class deltas") + eq(game.player.hp, 30 + 5, "the owned vigor perk raises current hp to match") + eq(game.player.gold, 20 + 25, "the owned deep pockets perk raises starting gold") + local plain = Game.new(1) + eq(plain.player.maxHp, 30, "a game started without a meta table gets no perk bonus") +end + +function tests.metaAppliedPerksLandBeneathAClassDelta() + local meta = Meta.new() + meta.renown = 200 + Meta.purchase(meta, "vigor") + local game = Game.new(1, { meta = meta, class = "fighter" }) + eq(game.player.maxHp, 30 + 5 + 8, "a fighter's class bonus stacks on top of an owned perk's bonus") +end + +function tests.metaLoadReturnsFreshStateForAMissingFile() + local meta = Meta.load("/tmp/warren_meta_does_not_exist_12345.sav") + eq(meta.renown, 0, "loading a missing meta file yields a fresh state") + eq(meta.totalRuns, 0, "a freshly defaulted meta state has recorded no runs") +end + +function tests.metaSaveLoadRoundTrip() + local meta = Meta.new() + meta.renown = 42 + Meta.recordRun(meta, { deepestDepth = 6, kills = 5, bossKills = 1, questsCompleted = 2, artifactsFound = 1, won = false }) + Meta.purchase(meta, "focus") + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) end local order = { From 3fe669ab4b7f51459108e44a4fd6ab2fecca259e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 16:01:11 +0200 Subject: [PATCH 113/172] test: cover tests.gameAwardKillTracksKillsAndBossKillsForMetaStats --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 404bbc9..f1d10f5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5319,6 +5319,179 @@ function tests.metaSaveLoadRoundTrip() local path = os.tmpname() Meta.save(meta, path) local restored = Meta.load(path) + os.remove(path) + eq(restored.renown, meta.renown, "renown survives a save/load round trip") + eq(restored.totalRuns, meta.totalRuns, "run tally survives a save/load round trip") + eq(restored.bestDepth, meta.bestDepth, "best depth survives a save/load round trip") + eq(restored.achievements.boss_slayer, true, "achievements survive a save/load round trip") + eq(restored.perks.focus, true, "owned perks survive a save/load round trip") +end + +function tests.gameAwardKillTracksKillsAndBossKillsForMetaStats() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game:playerAttack(rat) + eq(game.stats.kills, 1, "felling a plain monster counts toward the kill tally") + eq(game.stats.bossKills, 0, "a plain monster does not count as a boss kill") + + local lich = Bosses.create(Bosses.byKey.lich, 2, 4, 6) + lich.hp = 1 + game.level.entities = { lich } + game:playerAttack(lich) + eq(game.stats.kills, 2, "felling a boss also counts toward the plain kill tally") + eq(game.stats.bossKills, 1, "felling a boss counts toward the boss-kill tally") +end + +function tests.gameResolveQuestTracksQuestsCompletedForMetaStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 0, rewardGold = 30, rewardXp = 15 } + game:resolveQuest() + eq(game.stats.questsCompleted, 0, "an unmet bounty does not count toward quests completed") + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } + game:resolveQuest() + eq(game.stats.questsCompleted, 1, "resolving a bounty counts toward quests completed") +end + +function tests.gamePickupTracksArtifactsFoundForMetaStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.floorItems = { { x = 5, y = 5, item = Artifacts.create("bloodfang") } } + game:pickupHere() + eq(game.stats.artifactsFound, 1, "picking up an artifact counts toward artifacts found") + ok(game.player.weapon == nil, "picking up an artifact only stashes it in inventory, it is not auto-equipped") +end + +function tests.gameDescendTracksDeepestDepthEvenAfterAscending() + local game = Game.new(9, { maxDepth = 4 }) + eq(game.deepestDepth, 1, "a fresh game's deepest depth is its starting depth") + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.deepestDepth, 3, "descending twice raises the deepest depth to the floor just reached") + game.player.x, game.player.y = game.level.upX, game.level.upY + game:ascend() + eq(game.depth, 2, "ascending drops the live depth") + eq(game.deepestDepth, 3, "ascending back up does not erase the deepest depth already reached") +end + +function tests.gameMetaStatsFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game:playerAttack(rat) + game.deepestDepth = 3 + local restored = Game.fromData(game:toData()) + eq(restored.stats.kills, game.stats.kills, "the kill tally survives a save/load round trip") + eq(restored.deepestDepth, game.deepestDepth, "the deepest depth survives a save/load round trip") + local snapshot = restored:metaStats() + eq(snapshot.kills, 1, "metaStats reads the restored kill tally") + eq(snapshot.deepestDepth, 3, "metaStats reads the restored deepest depth") +end + +-- Elemental resistance (resistances.lua): a damage-type layer that sits +-- underneath every existing damage pipeline. Default behaviour (no resist +-- table anywhere) must stay perfectly transparent - that is what every test +-- above this point already relies on - so these tests build entities and +-- gear with resist tables explicitly rather than reaching for bestiary data. + +function tests.resistancesDefaultTierPassesDamageThroughUnchanged() + local plain = Entity.new({ x = 1, y = 1, glyph = "r", name = "rat", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) + eq(Resistances.tierOf(plain, Resistances.FIRE), Resistances.NORMAL, "an entity with no resist table is normal to every type") + eq(Resistances.apply(plain, 7, Resistances.FIRE), 7, "normal tier leaves the amount untouched") + eq(Resistances.apply(plain, 0, Resistances.FIRE), 0, "zero damage stays zero regardless of tier") +end + +function tests.resistancesResistHalvesAndFloors() + local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + warded.resist = { fire = Resistances.RESIST } + eq(Resistances.apply(warded, 9, Resistances.FIRE), 4, "resist halves and floors an odd amount") + eq(Resistances.apply(warded, 1, Resistances.FIRE), 0, "resist can floor a small hit all the way to zero") + eq(Resistances.apply(warded, 9, Resistances.COLD), 9, "a resist entry for one type does not bleed into another") +end + +function tests.resistancesWeakAmplifies() + local frail = Entity.new({ x = 1, y = 1, glyph = "@", name = "frail one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + frail.resist = { cold = Resistances.WEAK } + eq(Resistances.apply(frail, 4, Resistances.COLD), 6, "weak deals 1.5x, floored") + eq(Resistances.apply(frail, 4, Resistances.FIRE), 4, "an unrelated type is unaffected by a cold weakness") +end + +function tests.resistancesImmuneZeroesEvenAHugeHit() + local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + warded.resist = { poison = Resistances.IMMUNE } + eq(Resistances.apply(warded, 500, Resistances.POISON), 0, "immune drops even a massive hit to nothing") +end + +function tests.resistancesEquipmentGrantsATierOnTopOfABaseProfile() + local wearer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 5, defense = 1 }) + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.NORMAL, "an unequipped entity has no resist from thin air") + wearer.ring = Items.ring("frost band", 0, 0) + wearer.ring.resist = { cold = Resistances.RESIST } + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.RESIST, "a worn ring grants its resist tier") + eq(Resistances.apply(wearer, 10, Resistances.COLD), 5, "the granted tier scales incoming damage of that type") + wearer.armor = Items.armor("plate", 2) + wearer.armor.resist = { cold = Resistances.IMMUNE } + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.IMMUNE, "the best tier across every equipped slot wins") +end + +function tests.resistancesInnateWeaknessIsMaskedByAResistantItem() + local dragon = Entity.new({ x = 1, y = 1, glyph = "D", name = "young dragon", kind = "monster", hp = 30, maxHp = 30, attack = 8, defense = 3 }) + dragon.resist = { cold = Resistances.WEAK } + eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.WEAK, "the innate weakness applies with nothing worn") + dragon.ring = Items.ring("frost band", 0, 0) + dragon.ring.resist = { cold = Resistances.RESIST } + eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.RESIST, "a resistant ring outranks and masks an innate weakness") +end + +function tests.combatDefenderResistanceHalvesTheFinalBlow() + local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) + local defender = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + local baseline = Combat.damage(attacker, defender) + defender.resist = { physical = Resistances.RESIST } + local resisted = Combat.damage(attacker, defender) + eq(resisted, math.floor(baseline / 2), "a physical resist halves combat.damage's own output for the same matchup") +end + +function tests.combatWeaponElementOverridesPhysicalAndCanBeFullyImmune() + local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) + attacker.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) + eq(attacker.weapon.damageType, "fire", "a flaming affix retags the weapon's own damage type") + local mundane = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + ok(Combat.damage(attacker, mundane) > 0, "a flaming weapon still hurts an ordinary target") + local salamander = Entity.new({ x = 2, y = 1, glyph = "s", name = "salamander", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + salamander.resist = { fire = Resistances.IMMUNE } + eq(Combat.damage(attacker, salamander), 0, "the same flaming weapon does nothing at all against a fire-immune target") +end + +function tests.statusPoisonAndBurningTicksRespectResistance() + local immunePoison = Entity.new({ x = 1, y = 1, glyph = "z", name = "zombie", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) + immunePoison.resist = { poison = Resistances.IMMUNE } + Status.apply(immunePoison, Status.POISON, 3, 4) + local lines = Status.tick(immunePoison) + eq(immunePoison.hp, 10, "a poison-immune entity takes no damage from a poison tick") + ok(contains(lines[1], "shrugs off"), "the immune tick logs a distinct shrug-off line") + + local frailToFire = Entity.new({ x = 1, y = 1, glyph = "T", name = "troll", kind = "monster", hp = 20, maxHp = 20, attack = 6, defense = 2 }) + frailToFire.resist = { fire = Resistances.WEAK } + Status.apply(frailToFire, Status.BURNING, 3, 2) + Status.tick(frailToFire) + eq(frailToFire.hp, 17, "a fire-weak entity takes 1.5x the tick's potency, floored") +end + +function tests.hazardsAfflictAppliesTargetResistance() + local level = arena(21, 11) + Hazards.ignite(level, 5, 5, 3, 4) + local salamander = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + salamander.resist = { fire = Resistances.RESIST } + local hpBefore = salamander.hp + Hazards.afflict(level, salamander) + eq(salamander.hp, hpBefore - 2, "standing in fire as fire-resistant halves the usual potency") +end + +function tests.spellMissileAndFireballRespectTargetResistance() end local order = { From 90bf3238acecdc927c500e28409c7e15a725501b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 16:08:32 +0200 Subject: [PATCH 114/172] test: cover tests.throwingAndRangedDamageRespectResistance --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index f1d10f5..252c759 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5492,6 +5492,179 @@ function tests.hazardsAfflictAppliesTargetResistance() end function tests.spellMissileAndFireballRespectTargetResistance() + local game = stagedGame(1, arena(21, 11), 3, 6) + local wisp = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + wisp.hp, wisp.maxHp = 40, 40 + wisp.resist = { arcane = Resistances.IMMUNE } + game.level.entities = { wisp } + game:castSpell("missile", wisp) + eq(wisp.hp, 40, "an arcane-immune target takes no damage from magic missile") + + local blastGame = stagedGame(2, arena(21, 11), 5, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + orc.hp, orc.maxHp = 20, 20 + orc.resist = { fire = Resistances.RESIST } + blastGame.level.entities = { orc } + Effects.apply("fireball", blastGame, blastGame.player, {}) + eq(orc.hp, 16, "a fire-resistant target only takes half of fireball's flat burn") +end + +function tests.throwingAndRangedDamageRespectResistance() + local game = stagedGame(1, arena(21, 11), 3, 6) + local flamingDagger = Affixes.decorate(Items.weapon("dagger", 3), Affixes.weapon[3]) + game.player.inventory[#game.player.inventory + 1] = flamingDagger + local salamander = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + salamander.hp, salamander.maxHp = 40, 40 + salamander.resist = { fire = Resistances.IMMUNE } + game.level.entities = { salamander } + local success = game:throwAt(#game.player.inventory, salamander) + ok(success, "the throw itself still leaves the thrower's hand") + eq(salamander.hp, 40, "a fire-immune target takes no damage from a thrown flaming weapon") + + local rangedGame = stagedGame(1, arena(21, 11), 3, 6) + rangedGame.player.bow = Items.bow("longbow", 3, 6) + rangedGame.player.inventory[#rangedGame.player.inventory + 1] = Items.ammo("arrow", 5) + local armored = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + armored.hp, armored.maxHp = 40, 40 + armored.resist = { physical = Resistances.RESIST } + rangedGame.level.entities = { armored } + rangedGame:fireAt(armored) + eq(armored.hp, 37, "physical resistance halves the arrow's already-defense-reduced damage") +end + +function tests.equippingCinderplateAndFrostbandGrantsTheirResist() + local wearer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 5, defense = 1 }) + Items.equipArmor(Artifacts.create("cinderplate"), wearer) + eq(Resistances.tierOf(wearer, Resistances.FIRE), Resistances.RESIST, "cinderplate grants fire resist while worn") + Items.equipRing(Artifacts.create("frostband"), wearer) + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.RESIST, "frostband grants cold resist while worn") + eq(Resistances.tierOf(wearer, Resistances.POISON), Resistances.NORMAL, "neither artifact grants a resist it was not built for") +end + +function tests.disenchantingAnElementalAffixClearsItsDamageType() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[4]) + Items.equipWeapon(weapon, game.player) + eq(weapon.damageType, "poison", "venomed tags the weapon as poison-typed") + Crafting.disenchant(game.player, weapon) + eq(weapon.damageType, nil, "disenchanting clears the elemental typing along with the affix") +end + +function tests.bossBreathDamageTypeMatchesTheMonsterAndCanBeShruggedOff() + local game = stagedGame(1, arena(21, 11), 3, 6) + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) + game.level.entities = { dragon } + eq(Resistances.tierOf(dragon, Resistances.FIRE), Resistances.IMMUNE, "the bestiary dragon is immune to its own breath element") + eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.WEAK, "the bestiary dragon is innately weak to cold") + + game.player.resist = { fire = Resistances.IMMUNE } + local hpBefore = game.player.hp + game:monsterShoot(dragon) + eq(game.player.hp, hpBefore, "a fire-immune player takes no damage from dragon breath") + eq(Status.has(game.player, Status.BURNING), false, "a fire-immune player never catches alight from the breath") +end + +function tests.resistFieldsSurviveEntitySerialization() + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 5, 5, 6) + local restored = Entity.fromData(dragon:toData()) + eq(Resistances.tierOf(restored, Resistances.FIRE), Resistances.IMMUNE, "a restored entity keeps its innate resist profile") +end + +function tests.factionsTierBoundariesReflectReputationScore() + eq(Factions.tier(0), "neutral", "a fresh reputation reads neutral") + eq(Factions.tier(-10), "unfriendly", "the unfriendly ceiling is inclusive") + eq(Factions.tier(-9), "neutral", "one point above the unfriendly ceiling is still neutral") + eq(Factions.tier(-30), "hostile", "the hostile ceiling is inclusive") + eq(Factions.tier(25), "friendly", "the friendly floor is inclusive") + eq(Factions.tier(60), "honored", "the honored floor is inclusive") +end + +function tests.factionsAdjustAccumulatesAndClampsToTheHundredBounds() + local player = {} + Factions.adjust(player, Factions.GOBLINOID, -40) + eq(Factions.reputationFor(player, Factions.GOBLINOID), -40, "a first adjustment lands from the implicit zero baseline") + Factions.adjust(player, Factions.GOBLINOID, -80) + eq(Factions.reputationFor(player, Factions.GOBLINOID), -100, "reputation floors at -100") + Factions.adjust(player, Factions.GOBLINOID, 500) + eq(Factions.reputationFor(player, Factions.GOBLINOID), 100, "reputation caps at 100") + eq(Factions.reputationFor(player, Factions.MERCHANT), 0, "an untouched faction still reads neutral zero") +end + +function tests.factionsRegisterKillSoursOnlyTheFallensFaction() + local player = {} + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 1, 1, 2) + Factions.registerKill(player, goblin) + eq(Factions.reputationFor(player, Factions.GOBLINOID), -5, "felling a goblin sours goblinoid standing") + eq(Factions.reputationFor(player, Factions.VERMIN), 0, "an unrelated faction is left untouched") +end + +function tests.factionsRegisterKillPenalizesABossFarMoreThanARegularKill() + local player = {} + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 1, 1, 6) + dragon.isBoss = true + Factions.registerKill(player, dragon) + eq(Factions.reputationFor(player, Factions.DRAKE), -15, "a boss kill costs three times a rank-and-file kill") +end + +function tests.factionsRegisterKillIgnoresAFactionlessTarget() + local player = {} + Factions.registerKill(player, { faction = nil }) + eq(player.reputation, nil, "a factionless kill never even creates a reputation table") +end + +function tests.factionsPriceMultiplierScalesFromHostileToHonored() + local player = {} + eq(Factions.priceMultiplier(player, Factions.MERCHANT), 1.0, "a fresh, neutral standing charges catalog price") + Factions.adjust(player, Factions.MERCHANT, -40) + eq(Factions.priceMultiplier(player, Factions.MERCHANT), 1.5, "a hostile guild marks its prices up") + Factions.adjust(player, Factions.MERCHANT, 100) + eq(Factions.priceMultiplier(player, Factions.MERCHANT), 0.65, "an honored guild discounts its prices") +end + +function tests.factionsIsWaryTrueForAFreshGoblinoidAndFalseOnceProvoked() + local player = {} + local kobold = Monsters.create(Monsters.byKey.kobold, 1, 1, 1, 1) + eq(Factions.isWary(player, kobold), true, "an unprovoked goblinoid starts wary of a stranger") + kobold.provoked = true + eq(Factions.isWary(player, kobold), false, "a provoked goblinoid is no longer wary") +end + +function tests.factionsIsWaryIsFalseForNonTribalFactionsAndOnceTheTribeGoesHostile() + local player = {} + local rat = Monsters.create(Monsters.byKey.rat, 1, 1, 1, 1) + eq(Factions.isWary(player, rat), false, "vermin were never a wary tribe to begin with") + local kobold = Monsters.create(Monsters.byKey.kobold, 2, 1, 1, 1) + Factions.adjust(player, Factions.GOBLINOID, -40) + eq(Factions.isWary(player, kobold), false, "a tribe hated into hostility stops holding back even for a fresh member") +end + +function tests.factionsAppeaseSpendsGoldAndRaisesReputationOrFailsWithoutFunds() + local player = { gold = 10 } + local success, cost = Factions.appease(player, Factions.GOBLINOID) + eq(success, false, "an empty purse cannot buy any goodwill") + eq(cost, 20, "the failure reports the base tribute cost at neutral standing") + eq(player.gold, 10, "a failed tribute spends nothing") + player.gold = 100 + local success2, cost2 = Factions.appease(player, Factions.GOBLINOID) + eq(success2, true, "a well-stocked purse can pay the tribute") + eq(cost2, 20, "the neutral tribute costs its base amount") + eq(player.gold, 80, "the tribute's cost is deducted") + eq(Factions.reputationFor(player, Factions.GOBLINOID), 15, "the tribute raises standing by its fixed amount") +end + +function tests.factionsAppeaseCostsMoreTheDeeperTheGrudge() + local player = { gold = 1000 } + Factions.adjust(player, Factions.BANDIT, -30) + local _, cost = Factions.appease(player, Factions.BANDIT) + eq(cost, 80, "the tribute cost climbs with how negative the standing already is") +end + +function tests.factionsAppeaseFailsOnceAFactionIsAlreadyMaxedOut() + local player = { gold = 1000, reputation = { [Factions.GOBLINOID] = 100 } } + local success, cost = Factions.appease(player, Factions.GOBLINOID) + eq(success, false, "an already-maxed relationship has nothing left to buy") + eq(cost, 0, "the failure reports there was no cost, not an unaffordable one") + eq(player.gold, 1000, "nothing is spent when there is nothing to buy") end local order = { From 69951891180ccda3651b995d86fa815bec35cbea Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 16:38:26 +0200 Subject: [PATCH 115/172] test: cover tests.factionAndProvokedFieldsSurviveEntitySerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 252c759..316da60 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5667,6 +5667,179 @@ function tests.factionsAppeaseFailsOnceAFactionIsAlreadyMaxedOut() eq(player.gold, 1000, "nothing is spent when there is nothing to buy") end +function tests.factionAndProvokedFieldsSurviveEntitySerialization() + local kobold = Monsters.create(Monsters.byKey.kobold, 1, 1, 1, 1) + kobold.provoked = true + local restored = Entity.fromData(kobold:toData()) + eq(restored.faction, Factions.GOBLINOID, "a monster's faction tag survives a round trip") + eq(restored.provoked, true, "a monster's provoked flag survives a round trip") +end + +function tests.gameTakeMonsterTurnLeavesAWaryGoblinoidAloneUntilProvoked() + local game = stagedGame(1, arena(21, 11), 3, 6) + local kobold = Monsters.create(Monsters.byKey.kobold, 1, 4, 6, 1) + game.level.entities = { kobold } + local hpBefore = game.player.hp + game:takeMonsterTurn(kobold) + eq(kobold.x, 4, "an unprovoked kobold does not close in") + eq(game.player.hp, hpBefore, "an unprovoked kobold does not attack even when adjacent") +end + +function tests.gamePlayerAttackProvokesAWaryGoblinoidSoItFightsBackNextTurn() + local game = stagedGame(1, arena(21, 11), 3, 6) + local kobold = Monsters.create(Monsters.byKey.kobold, 1, 4, 6, 1) + kobold.hp = 100 + game.level.entities = { kobold } + game:playerAttack(kobold) + eq(kobold.provoked, true, "aiming a melee attack at a wary goblinoid provokes it") + local hpBefore = game.player.hp + game:takeMonsterTurn(kobold) + ok(game.player.hp < hpBefore, "a provoked, adjacent kobold now fights back") +end + +function tests.gameAwardKillSoursFactionReputationWithTheFallensFaction() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp = 1 + game.level.entities = { orc } + game:playerAttack(orc) + eq(orc:isAlive(), false, "the orc dies to the hit") + eq(Factions.reputationFor(game.player, Factions.ORCISH), -5, "the kill sours standing with the orc's faction") +end + +function tests.shopBuyIsMarkedUpWhenMerchantsAreHostile() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.player.gold = 100 + Factions.adjust(game.player, Factions.MERCHANT, -40) + game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } + game:movePlayer(1, 0) + eq(game.player.gold, 78, "a hostile guild marks the stamped price up by half, floored") +end + +function tests.shopBuyIsDiscountedWhenMerchantsAreHonored() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.player.gold = 100 + Factions.adjust(game.player, Factions.MERCHANT, 60) + game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } + game:movePlayer(1, 0) + eq(game.player.gold, 91, "an honored guild discounts the stamped price, floored") +end + +function tests.shopSellValueRisesWithMerchantStanding() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.inventory = { Items.potionOf("heal") } + local goldBefore = game.player.gold + Factions.adjust(game.player, Factions.MERCHANT, 60) + local value = game:sellItem(1) + eq(value, 10, "an honored guild pays more than the neutral half-price for the same ware") + eq(game.player.gold, goldBefore + 10, "the sale proceeds land in the wallet") +end + +function tests.stealingFromShopAlsoSoursMerchantReputation() + local game = stagedGame(1, arena(21, 11), 3, 3) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.level.floorItems = { { x = 4, y = 3, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } + game:stealFromShop(1) + eq(Factions.reputationFor(game.player, Factions.MERCHANT), -20, "a theft sours the merchant guild's opinion of the player") +end + +function tests.resolveQuestNudgesMerchantReputationUpward() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } + game:resolveQuest() + eq(Factions.reputationFor(game.player, Factions.MERCHANT), 3, "turning in a bounty pads the merchant guild's opinion of the player") +end + +function tests.gameAppeaseFactionSpendsGoldAndLogsTheOutcome() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.gold = 100 + local success = game:appeaseFaction(Factions.GOBLINOID) + eq(success, true, "a well-funded tribute succeeds") + eq(game.player.gold, 80, "the tribute's cost is deducted from the wallet") + eq(Factions.reputationFor(game.player, Factions.GOBLINOID), 15, "the tribute raises standing") + ok(contains(game.log:last(), "goblinoid"), "the successful tribute is logged") + + game.player.gold = 0 + local failure = game:appeaseFaction(Factions.BANDIT) + eq(failure, false, "an empty purse cannot pay any tribute") + ok(contains(game.log:last(), "gold"), "the failed tribute explains the shortfall") +end + +function tests.playerReputationSurvivesGameSaveLoadRoundTrip() + local game = stagedGame(1, arena(21, 11), 3, 3) + Factions.adjust(game.player, Factions.GOBLINOID, -40) + Factions.adjust(game.player, Factions.MERCHANT, 10) + local restored = Game.fromData(game:toData()) + eq(Factions.reputationFor(restored.player, Factions.GOBLINOID), -40, "goblinoid standing survives a save/load round trip") + eq(Factions.reputationFor(restored.player, Factions.MERCHANT), 10, "merchant standing survives a save/load round trip") +end + +-- Carry weight (encumbrance.lua): a hoard heavy enough to cross a capacity +-- threshold slows the carrier down, and a wide overage on top of that also +-- costs defense. Weight is derived purely from inventory/equipped items and +-- level, so nothing here needs its own save-format handling. + +function tests.encumbranceItemWeightScalesWithGearPower() + eq(Encumbrance.itemWeight(Items.weapon("dagger", 2)), 6, "a weak weapon carries close to its flat base weight") + eq(Encumbrance.itemWeight(Items.weapon("greatsword", 9)), 9, "a powerful weapon weighs more than a weak one of the same kind") + eq(Encumbrance.itemWeight(Items.armor("padded vest", 0)), 9, "armor has its own, heavier, flat base") +end + +function tests.encumbranceAmmoWeightScalesWithStackCount() + eq(Encumbrance.itemWeight(Items.ammo("arrow", 1)), 1, "a single arrow weighs its unit weight") + eq(Encumbrance.itemWeight(Items.ammo("arrow", 5)), 5, "a stack of arrows weighs the unit weight times the count") +end + +function tests.encumbranceCapacityGrowsWithLevel() + eq(Encumbrance.capacityFor({ level = 1 }), 16, "a level 1 carrier's capacity is the flat base plus one level's worth") + eq(Encumbrance.capacityFor({ level = 5 }), 32, "capacity scales linearly with level") + eq(Encumbrance.capacityFor({}), 16, "a missing level defaults to level 1's capacity") +end + +function tests.encumbranceEquippedGearCountsTowardCarriedWeight() + local carrier = { + inventory = {}, + weapon = Items.weapon("sword", 3), armor = Items.armor("mail", 6), + ring = Items.ring("band", 1, 1), bow = Items.bow("bow", 2, 3), + } + eq(Encumbrance.carriedWeight(carrier), 26, "worn and wielded gear counts exactly like the same items in the pack") +end + +function tests.encumbranceLightLoadHasNoPenalty() + local game = Game.new(1) + eq(game.player:encumbranceTier(), Encumbrance.LIGHT, "a fresh player's starting kit is well under capacity") + eq(game.player:effectiveSpeed(), 100, "a light load never slows the carrier down") + eq(game.player:effectiveDefense(), 1, "a light load never costs defense") +end + +-- Each padding armor has zero power (so bestUpgrade never mistakes it for an +-- upgrade) and so weighs exactly the base 9. +local function padWith(entity, count) + for _ = 1, count do + entity.inventory[#entity.inventory + 1] = Items.armor("lockbox", 0) + end +end + +function tests.encumbranceBurdenedSlowsMovementButNotDefense() + local game = Game.new(1) + game.player.inventory = {} + padWith(game.player, 2) + ok(Encumbrance.carriedWeight(game.player) > Encumbrance.capacityFor(game.player), + "two heavy armors alone clear the fresh player's capacity") + eq(game.player:encumbranceTier(), Encumbrance.BURDENED, "a moderate overage is merely burdened") + eq(game.player:effectiveSpeed(), 80, "a burdened carrier loses a flat chunk of speed") + eq(game.player:effectiveDefense(), 1, "a burdened carrier's defense is untouched") +end + +function tests.encumbranceOverloadedSlowsMovementAndWeakensDefense() + local game = Game.new(1) +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From a24ad322c55c7f97b942ae53cdb4ada4d94c18fd Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 17:19:46 +0200 Subject: [PATCH 116/172] test: cover tests.gameDropItemReturnsItemToTheFloorAndFreesCapacity --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 316da60..6a92952 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5838,6 +5838,179 @@ end function tests.encumbranceOverloadedSlowsMovementAndWeakensDefense() local game = Game.new(1) + game.player.inventory = {} + padWith(game.player, 3) + eq(game.player:encumbranceTier(), Encumbrance.OVERLOADED, "a wide overage is overloaded") + eq(game.player:effectiveSpeed(), 55, "an overloaded carrier loses far more speed than a merely burdened one") + eq(game.player:effectiveDefense(), -1, "an overloaded carrier also fumbles, losing defense") +end + +function tests.gameDropItemReturnsItemToTheFloorAndFreesCapacity() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.inventory = { Items.potionOf("heal") } + local before = Encumbrance.carriedWeight(game.player) + local dropped = game:dropItem(1) + eq(dropped.effect, "heal", "dropItem returns the item that was shed") + eq(#game.player.inventory, 0, "the item leaves the inventory") + eq(#game.level.floorItems, 1, "the item lands on the floor") + eq(game.level.floorItems[1].x, game.player.x, "the dropped item lands exactly at the player's feet") + eq(game.level.floorItems[1].y, game.player.y, "the dropped item lands exactly at the player's feet") + ok(Encumbrance.carriedWeight(game.player) < before, "dropping an item frees up carried weight") + ok(contains(game.log:last(), "drop"), "dropping an item is logged") +end + +function tests.gamePickupWarnsWhenNewWeightTipsIntoOverloaded() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.inventory = {} + padWith(game.player, 1) + eq(game.player:encumbranceTier(), Encumbrance.LIGHT, "one heavy armor alone still fits under capacity") + game.level.floorItems = {} + for _ = 1, 2 do + game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.armor("lockbox", 0) } + end + game:pickupHere() + eq(game.player:encumbranceTier(), Encumbrance.OVERLOADED, "the pickup tips the total weight past the overloaded threshold") + ok(contains(game.log:last(), "overloaded"), "tipping straight into overloaded is called out by name") +end + +function tests.gamePickupWarnsWithALighterMessageWhenOnlyTippingIntoBurdened() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.inventory = {} + game.level.floorItems = {} + for _ = 1, 2 do + game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.armor("lockbox", 0) } + end + game:pickupHere() + eq(game.player:encumbranceTier(), Encumbrance.BURDENED, "two heavy armors in one pickup lands in the burdened band") + ok(contains(game.log:last(), "heavy"), "tipping only into burdened gets the softer warning") +end + +function tests.autopilotDropsCheapestJunkWhenOverloadedAndSafe() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.level.entities = {} + game.player.inventory = {} + padWith(game.player, 3) + local amulet, key = Items.amulet(), Items.key() + game.player.inventory[#game.player.inventory + 1] = amulet + game.player.inventory[#game.player.inventory + 1] = key + local countBefore = #game.player.inventory + eq(game:encumbranceTier(), Encumbrance.OVERLOADED, "the staged inventory is overloaded before the autopilot acts") + game:choosePlayerAction() + eq(#game.player.inventory, countBefore - 1, "the autopilot sheds exactly one item while overloaded and safe") + ok(contains(game.log:last(), "drop"), "the shed item is logged as a drop") + ok(game:findInInventory(function(item) return item == amulet end) ~= nil, "the amulet is never sacrificed to encumbrance") + ok(game:findInInventory(function(item) return item == key end) ~= nil, "a key is never sacrificed to encumbrance") +end + +function tests.encumbranceTierIsDerivedAcrossASaveLoadRoundTrip() + local game = stagedGame(1, arena(21, 11), 3, 3) + game.player.inventory = {} + padWith(game.player, 2) + local tierBefore = game:encumbranceTier() + local restored = Game.fromData(game:toData()) + eq(restored:encumbranceTier(), tierBefore, "the same inventory yields the same tier after a save/load round trip") +end + +function tests.renderPanelShowsCarriedLoad() + local game = Game.new(1) + local panel = Render.panel(game) + ok(contains(panel, "load light ("), "the status panel reports the carrier's current load tier and weight") +end + +function tests.tilesClassifyVolatileTerrain() + eq(Tiles.isLava(Tiles.LAVA), true, "isLava recognizes the lava glyph") + eq(Tiles.isLava(Tiles.FLOOR), false, "isLava rejects plain floor") + eq(Tiles.isChasm(Tiles.CHASM), true, "isChasm recognizes the chasm glyph") + eq(Tiles.isChasm(Tiles.FLOOR), false, "isChasm rejects plain floor") + eq(Tiles.isIce(Tiles.ICE), true, "isIce recognizes the ice glyph") + eq(Tiles.isIce(Tiles.FLOOR), false, "isIce rejects plain floor") + eq(Tiles.isWalkable(Tiles.LAVA), true, "lava is walkable at the player's own risk") + eq(Tiles.isWalkable(Tiles.CHASM), true, "a chasm is walkable right up until it swallows you") + eq(Tiles.isWalkable(Tiles.ICE), true, "ice is walkable, if slippery") +end + +function tests.terrainAfflictLavaDoesNothingOffLava() + local level = arena(21, 11) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + eq(Terrain.afflictLava(level, rat), nil, "afflictLava does nothing to an entity off any lava cell") +end + +function tests.terrainLavaRespectsFireResistanceAndImmunity() + local level = arena(21, 11) + level.map:set(5, 5, Tiles.LAVA) + + local resistant = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + resistant.resist = { fire = Resistances.RESIST } + local hpBefore = resistant.hp + local line = Terrain.afflictLava(level, resistant) + eq(resistant.hp, hpBefore - math.floor(Terrain.LAVA_DAMAGE / 2), + "lava damage is halved for a fire-resistant target") + ok(contains(line, "sears"), "a damaging lava tick describes itself as searing") + eq(Status.has(resistant, Status.BURNING), true, "a scorched target is also set alight") + + local immune = Monsters.create(Monsters.byKey.rat, 2, 5, 5, 1) + immune.resist = { fire = Resistances.IMMUNE } + local hpBefore2 = immune.hp + local immuneLine = Terrain.afflictLava(level, immune) + eq(immune.hp, hpBefore2, "a fire-immune target takes no damage from lava") + ok(contains(immuneLine, "unharmed"), "a harmless lava tick says so") + eq(Status.has(immune, Status.BURNING), false, "a fire-immune target is not set alight by lava") +end + +function tests.terrainLavaBurnsAndIgnitesAPlayerWhoStepsOnIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.LAVA) + local hpBefore = game.player.hp + game:movePlayer(1, 0) + eq(game.player.hp, hpBefore - Terrain.LAVA_DAMAGE, + "stepping onto lava deals its full damage to an unresistant player") + eq(Status.has(game.player, Status.BURNING), true, "lava also sets the player alight") + ok(contains(game.log:last(), "sears"), "the burn is logged") +end + +function tests.terrainLavaCanKillAWeakMonsterAndCreditsThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 6, Tiles.LAVA) + local bat = Monsters.create(Monsters.byKey.bat, 1, 4, 6, 1) + bat.hp, bat.maxHp = 3, 3 + game.level.entities = { bat } + local killsBefore = game.stats.kills + game:moveMonster(bat, 1, 0) + eq(#game.level.entities, 0, "a monster killed by lava is removed from the level") + eq(game.stats.kills, killsBefore + 1, "a lava death still credits the player with a kill") +end + +function tests.terrainChasmDropsThePlayerToTheFloorBelowWithFallDamage() + local game = stagedGame(3, arena(21, 11), 5, 6) + game.level.map:set(6, 6, Tiles.CHASM) + local hpBefore = game.player.hp + local depthBefore = game.depth + game:movePlayer(1, 0) + ok(game.player.hp < hpBefore, "falling into a chasm deals fall damage") + eq(game.depth, depthBefore + 1, "falling into a chasm advances to the floor below") + eq(game.deepestDepth, depthBefore + 1, "falling into a chasm counts toward the deepest depth reached") + ok(contains(game.log:last(), "plunge"), "the fall is logged") +end + +function tests.terrainChasmSwallowsAMonsterAndCreditsTheKill() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 6, Tiles.CHASM) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + game.level.entities = { rat } + local killsBefore = game.stats.kills + game:moveMonster(rat, 1, 0) + eq(#game.level.entities, 0, "a monster that falls into a chasm is removed from the level") + eq(game.stats.kills, killsBefore + 1, "a chasm death still credits the player with a kill, like a lethal trap") +end + +function tests.terrainIceSlidesATravellerUntilSomethingStopsIt() + local game = stagedGame(1, arena(21, 11), 2, 6) + for x = 3, 6 do game.level.map:set(x, 6, Tiles.ICE) end + game:movePlayer(1, 0) + eq(game.player.x, 7, "stepping onto ice slides the player through every consecutive ice tile") + eq(game.player.y, 6, "sliding on ice does not drift off the axis of travel") +end + end local order = { From aca2e06b3cccdf2f1dd4b23c645f7679bfe8192d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 17:43:34 +0200 Subject: [PATCH 117/172] test: cover tests.terrainIceSlideStopsBeforeAWall --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 6a92952..20dbd1a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6011,6 +6011,179 @@ function tests.terrainIceSlidesATravellerUntilSomethingStopsIt() eq(game.player.y, 6, "sliding on ice does not drift off the axis of travel") end +function tests.terrainIceSlideStopsBeforeAWall() + local game = stagedGame(1, arena(21, 11), 2, 6) + game.level.map:set(3, 6, Tiles.ICE) + game.level.map:set(4, 6, Tiles.ICE) + game.level.map:set(5, 6, Tiles.WALL) + game:movePlayer(1, 0) + eq(game.player.x, 4, "an ice slide stops on the last ice tile before a wall") +end + +function tests.terrainIceSlideStopsAtAnOccupiedCell() + local game = stagedGame(1, arena(21, 11), 2, 6) + game.level.map:set(3, 6, Tiles.ICE) + game.level.map:set(4, 6, Tiles.ICE) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + game.level.entities = { rat } + game:movePlayer(1, 0) + eq(game.player.x, 4, "an ice slide stops just short of another actor") +end + +-- Status.LEVITATE (see status.lua and Game:checkTerrainFor) short-circuits +-- every branch below before any of it runs - no burn, no fall, no slide - +-- so these mirror the three terrain tests directly above/below them with +-- one difference: the mover carries the status first. +function tests.checkTerrainForSkipsLavaDamageAndIgnitionForALevitatingPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.LAVA) + Status.apply(game.player, Status.LEVITATE, 20, 1) + local hpBefore = game.player.hp + game:movePlayer(1, 0) + eq(game.player.hp, hpBefore, "a levitating player takes no damage crossing lava") + eq(Status.has(game.player, Status.BURNING), false, "a levitating player is never set alight by lava") + ok(contains(game.log:last(), "drift"), "the skip over a visible hazard is still logged, not silent") +end + +function tests.checkTerrainForSkipsTheChasmFallForALevitatingPlayer() + local game = stagedGame(3, arena(21, 11), 5, 6) + game.level.map:set(6, 6, Tiles.CHASM) + Status.apply(game.player, Status.LEVITATE, 20, 1) + local hpBefore = game.player.hp + local depthBefore = game.depth + game:movePlayer(1, 0) + eq(game.player.x, 6, "a levitating player actually reaches the chasm cell instead of refusing to enter it") + eq(game.player.hp, hpBefore, "a levitating player takes no fall damage over a chasm") + eq(game.depth, depthBefore, "a levitating player drifts over a chasm instead of dropping to the floor below") +end + +function tests.checkTerrainForSkipsTheIceSlideForALevitatingPlayer() + local game = stagedGame(1, arena(21, 11), 2, 6) + for x = 3, 6 do game.level.map:set(x, 6, Tiles.ICE) end + Status.apply(game.player, Status.LEVITATE, 20, 1) + game:movePlayer(1, 0) + eq(game.player.x, 3, "a levitating player enters the first ice cell but never slides across the rest") +end + +function tests.checkTerrainForSkipsLavaForALevitatingMonsterToo() + local game = stagedGame(1, arena(21, 11), 2, 2) + game.level.map:set(6, 6, Tiles.LAVA) + local bat = Monsters.create(Monsters.byKey.bat, 1, 5, 6, 1) + Status.apply(bat, Status.LEVITATE, 20, 1) + game.level.entities = { bat } + local hpBefore = bat.hp + game:moveMonster(bat, 1, 0) + eq(bat.x, 6, "the levitating monster still moves onto the lava cell") + eq(bat.hp, hpBefore, "a levitating monster crossing lava takes no damage either - the guard is not player-only") +end + +function tests.terrainGlyphsSurviveMapSerialization() + local map = Map.new(10, 6) + for y = 2, 5 do for x = 2, 9 do map:set(x, y, Map.FLOOR) end end + map:set(4, 4, Tiles.LAVA) + map:set(5, 4, Tiles.CHASM) + map:set(6, 4, Tiles.ICE) + local restored = Map.fromData(map:toData()) + eq(restored:get(4, 4), Tiles.LAVA, "a lava tile survives a map round trip") + eq(restored:get(5, 4), Tiles.CHASM, "a chasm tile survives a map round trip") + eq(restored:get(6, 4), Tiles.ICE, "an ice tile survives a map round trip") +end + +function tests.chasmFallFieldsSurviveGameSaveLoad() + local game = stagedGame(3, arena(21, 11), 5, 6) + game.level.map:set(6, 6, Tiles.CHASM) + game:movePlayer(1, 0) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.depth, game.depth, "the depth reached by falling survives a save/load round trip") + eq(restored.player.hp, game.player.hp, "hp after a chasm fall survives a save/load round trip") +end + +function tests.lavaChasmAndIceStayOffTheShallowFloors() + for seed = 1, 5 do + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + ok(tile ~= Tiles.LAVA and tile ~= Tiles.CHASM and tile ~= Tiles.ICE, + "no volatile terrain appears above depth 4") + end + end + end +end + +function tests.builderOccasionallyPlacesLavaChasmAndIceFromDepthFour() + local sawLava, sawChasm, sawIce = false, false, false + for seed = 1, 60 do + local level = Builder.build(4, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + if tile == Tiles.LAVA then sawLava = true end + if tile == Tiles.CHASM then sawChasm = true end + if tile == Tiles.ICE then sawIce = true end + end + if sawLava and sawChasm and sawIce then break end + end + ok(sawLava, "a depth-4 floor eventually places lava on some seed") + ok(sawChasm, "a depth-4 floor eventually places a chasm on some seed") + ok(sawIce, "a depth-4 floor eventually places ice on some seed") +end + +function tests.chasmNeverAppearsOnTheFinalFloor() + for seed = 1, 30 do + local level = Builder.build(Builder.MAX_DEPTH, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + ok(tile ~= Tiles.CHASM, "no chasm appears on the final floor since there is no floor below it") + end + end +end + +function tests.eliteNeverPromotesBelowMinDepth() + for seed = 1, 40 do + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 2) + Elites.maybePromote(Rng.new(seed), orc, 2) + ok(not orc.elite, "no promotion below depth 3 regardless of the roll") + end +end + +function tests.eliteEligibleExcludesBossesAndTheShopkeeper() + local boss = Bosses.create(Bosses.byKey.warlord, 1, 5, 5) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 2, 6, 5, 4) + local orc = Monsters.create(Monsters.byKey.orc, 3, 7, 5, 3) + ok(not Elites.eligible(boss), "a boss is never eligible for elite promotion") + ok(not Elites.eligible(keeper), "the shopkeeper is never eligible for elite promotion") + ok(Elites.eligible(orc), "a plain bestiary spawn is eligible for elite promotion") +end + +function tests.eliteArmoredAffixAddsFlatDefenseAndPrefixesTheName() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 3) + local baseDefense = orc.defense + Elites.maybePromote(Rng.new(8), orc, 3) + eq(orc.eliteTier, "elite", "a single-affix promotion is a plain elite, not a champion") + eq(orc.eliteAffixes[1], "armored", "seed 8 rolls the armored affix at depth 3") + eq(orc.defense, baseDefense + 3, "armored adds its flat defense bonus") + eq(orc.baseDefense, baseDefense + 3, "armored's bonus also lands on baseDefense") + eq(orc.name, "armored orc", "the affix name prefixes the species name") +end + +function tests.eliteChampionTierAtDeepDepthRollsTwoDistinctAffixes() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 7) + Elites.maybePromote(Rng.new(9), orc, 7) + eq(orc.eliteTier, "champion", "seed 9 at depth 7 rolls a champion") + eq(#orc.eliteAffixes, 2, "a champion carries exactly two affixes") + ok(orc.eliteAffixes[1] ~= orc.eliteAffixes[2], "a champion never doubles up on the same affix") +end + +function tests.eliteFieldsSurviveSerialization() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 3) + Elites.maybePromote(Rng.new(2), orc, 3) + local restored = Entity.fromData(orc:toData()) + eq(restored.elite, true, "the elite flag survives serialization") + eq(restored.eliteTier, "elite", "the elite tier survives serialization") + eq(restored.eliteAffixes[1], "swift", "the rolled affix list survives serialization") + eq(restored.speed, orc.speed, "the swift affix's speed bonus survives serialization") +end + end local order = { From 6e216940ac2b179ad0e92f129342268fe0f9a7ec Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 17:44:51 +0200 Subject: [PATCH 118/172] test: cover tests.monstersCreateNeverAliasesTheSpeciesCatalogsInnateR... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 20dbd1a..ee63909 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6184,6 +6184,179 @@ function tests.eliteFieldsSurviveSerialization() eq(restored.speed, orc.speed, "the swift affix's speed bonus survives serialization") end +-- monsters.lua: Monsters.create must hand every entity its own resist +-- table, never Monsters.byKey[species].resist itself - applyAffix above +-- mutates a promoted elite's entity.resist[element] in place the instant it +-- rolls "warded", and a shared reference there would leak that bump onto +-- every future spawn of the same species (and the catalog itself) for the +-- rest of the process, not just the one entity actually promoted. Found via +-- examine.lua's own test suite: a plain, unpromoted troll built late in a +-- long test run reported "fire resist" instead of its catalog "fire weak" +-- because an earlier test's elite promotion (through Builder.build, not +-- shown here) had already corrupted the shared table. +function tests.monstersCreateNeverAliasesTheSpeciesCatalogsInnateResistTable() + local first = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 1) + local second = Monsters.create(Monsters.byKey.troll, 2, 6, 5, 1) + ok(first.resist ~= Monsters.byKey.troll.resist, "an entity's resist table is its own, not the catalog's") + first.resist.fire = Resistances.RESIST + eq(second.resist.fire, "weak", "mutating one troll's resist never leaks onto another troll of the same species") + eq(Monsters.byKey.troll.resist.fire, "weak", "mutating an entity's resist never corrupts the species catalog itself") +end + +function tests.builderOccasionallyPromotesAMonsterToEliteFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, e in ipairs(level.entities) do + if e.elite then found = e end + end + ok(found ~= nil, "this seed's depth-3 floor promotes a monster to elite") + eq(found.eliteTier, "elite", "a depth-3 promotion is a plain elite, never a champion") +end + +function tests.elitesNeverAppearOnTheShallowestTwoFloors() + for seed = 1, 20 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, e in ipairs(level.entities) do + ok(not e.elite, "no elite appears above depth 3") + end + end + end +end + +function tests.eliteVampiricMonsterDrainsHpFromThePlayerOnAMeleeHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.elite, orc.eliteAffixes = true, { "vampiric" } + orc.hp = orc.maxHp - 5 + game.level.entities = { orc } + local before = orc.hp + game:monsterMelee(orc) + ok(orc.hp > before, "a vampiric elite heals on the melee hit it lands") +end + +function tests.eliteChillingMonsterSlowsThePlayerOnAMeleeHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.elite, orc.eliteAffixes = true, { "chilling" } + game.level.entities = { orc } + ok(not Status.has(game.player, Status.SLOW), "the player starts unslowed") + game:monsterMelee(orc) + ok(Status.has(game.player, Status.SLOW), "a chilling elite's hit leaves the player slowed") +end + +function tests.eliteVolatileMonsterExplodesOnDeathDamagingAnAdjacentPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.elite, rat.eliteAffixes = true, { "volatile" } + rat.hp = 0 + game.level.entities = { rat } + local before = game.player.hp + game:awardKill(rat) + ok(game.player.hp < before, "a volatile elite's corpse bursts, damaging the adjacent player") + ok(contains(table.concat(game.log:recent(10), " "), "bursts"), "the burst is logged") +end + +function tests.eliteVolatileMonsterDoesNotBurstAnythingBeyondItsBlastRadius() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 6, 1) + rat.elite, rat.eliteAffixes = true, { "volatile" } + rat.hp = 0 + game.level.entities = { rat } + local before = game.player.hp + game:awardKill(rat) + eq(game.player.hp, before, "a volatile corpse two cells away leaves the player untouched") +end + +function tests.eliteKillGrantsBonusXpOverThePlainSpeciesReward() + local game = stagedGame(9, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + rat.elite, rat.eliteTier, rat.eliteAffixes = true, "elite", { "brutal" } + game.level.entities = { rat } + local baseXp = Monsters.xpFor(rat) + local bonus = Elites.xpBonus(rat, baseXp) + ok(bonus > 0, "the elite bonus is a real, nonzero amount") + local xpBefore = game.player.xp + game:playerAttack(rat) + eq(game.player.xp, xpBefore + baseXp + bonus, "felling an elite grants a bonus over its plain species xp") +end + +-- Vaults (vaults.lua): a lever puzzle gating a guaranteed treasure. Levers +-- and the door they share an id with are plain data on the level, exactly +-- like shrines and cauldrons above, so most of these tests build that data +-- directly rather than waiting on a builder roll. + +function tests.vaultsPullThrowsALeverAndOpensASingleLeverVaultImmediately() + local level = { vaultLevers = {}, vaultDoors = {} } + local lever = { x = 1, y = 1, id = "v1", pulled = false } + level.vaultLevers = { lever } + level.vaultDoors = { { x = 2, y = 2, id = "v1", opened = false } } + local pulled, door = Vaults.pull(level, lever) + eq(pulled, true, "pulling a fresh lever succeeds") + eq(lever.pulled, true, "the lever is marked pulled") + ok(door ~= nil, "a single-lever vault's door opens on its only pull") + eq(door.id, "v1", "the returned door is the one this puzzle actually gates") + local pulledAgain, doorAgain = Vaults.pull(level, lever) + eq(pulledAgain, false, "pulling an already-thrown lever reports failure") + eq(doorAgain, nil, "a no-op pull reports no door") +end + +function tests.vaultsPullOpensTheDoorOnlyOnceEveryLeverIsThrown() + local level = { vaultLevers = {}, vaultDoors = {} } + local leverA = { x = 1, y = 1, id = "v2", pulled = false } + local leverB = { x = 1, y = 2, id = "v2", pulled = false } + level.vaultLevers = { leverA, leverB } + level.vaultDoors = { { x = 3, y = 3, id = "v2", opened = false } } + local pulledA, doorA = Vaults.pull(level, leverA) + eq(pulledA, true, "pulling the first of two levers succeeds") + eq(doorA, nil, "the door stays shut until every lever sharing its id is thrown") + local pulledB, doorB = Vaults.pull(level, leverB) + eq(pulledB, true, "pulling the second lever succeeds") + ok(doorB ~= nil, "the door opens the instant the last lever is thrown") +end + +function tests.vaultsTreasureRollsAGuaranteedAffixOnABaseStrongerThanAnyFloorDrop() + for seed = 1, 20 do + local item = Vaults.treasure(Rng.new(seed), 5) + ok(item.kind == Items.WEAPON or item.kind == Items.ARMOR, + "vault treasure is always a weapon or a suit of armor") + ok(item.affix ~= nil, "vault treasure always carries a guaranteed affix") + ok(item.power >= 7, "vault treasure's power exceeds anything the depth-5 floor loot table can roll") + end +end + +function tests.gamePullLeverOpensASingleLeverVaultAndTracksTheStat() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local lever = { x = 6, y = 6, id = "v3", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 10, y = 6, id = "v3", opened = false } } + eq(game.stats.vaultsOpened, 0, "a fresh run has opened no vaults") + local pulled = game:pullLever(lever) + eq(pulled, true, "pulling the lever succeeds") + eq(game.level.map:get(6, 6), Tiles.LEVER_PULLED, "the lever's own tile flips to its pulled glyph") + eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the vault door swings open once its only lever is thrown") + eq(game.stats.vaultsOpened, 1, "opening a vault advances the run's vault tally") + ok(contains(game.log:last(), "grinds open"), "opening the vault is logged") +end + +function tests.gamePullLeverOnATwoLeverVaultWaitsForTheSecondPull() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(7, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local leverA = { x = 6, y = 6, id = "v4", pulled = false } + local leverB = { x = 7, y = 6, id = "v4", pulled = false } + game.level.vaultLevers = { leverA, leverB } + game.level.vaultDoors = { { x = 10, y = 6, id = "v4", opened = false } } + game:pullLever(leverA) + eq(game.level.map:get(10, 6), Tiles.VAULT_DOOR, "the door stays sealed with a lever still unthrown") + eq(game.stats.vaultsOpened, 0, "the vault tally does not advance until the puzzle is fully solved") + ok(contains(game.log:last(), "clunks into place"), "a partial pull is logged as a clunk, not an opening") + game:pullLever(leverB) + eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the door opens the instant the last lever is thrown") end local order = { From 8f7fd7151e598336ccfb953297a3f10ff6c806b7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 18:54:24 +0200 Subject: [PATCH 119/172] test: cover tests.pullingAnAlreadyThrownLeverAgainDoesNotInflateTheVa... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index ee63909..58356b5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6357,6 +6357,179 @@ function tests.gamePullLeverOnATwoLeverVaultWaitsForTheSecondPull() ok(contains(game.log:last(), "clunks into place"), "a partial pull is logged as a clunk, not an opening") game:pullLever(leverB) eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the door opens the instant the last lever is thrown") + eq(game.stats.vaultsOpened, 1, "the vault tally advances exactly once, on the pull that completes the puzzle") +end + +function tests.pullingAnAlreadyThrownLeverAgainDoesNotInflateTheVaultTally() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local lever = { x = 6, y = 6, id = "v5", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 10, y = 6, id = "v5", opened = false } } + game:pullLever(lever) + eq(game.stats.vaultsOpened, 1, "the first pull opens the vault") + local pulledAgain = game:pullLever(lever) + eq(pulledAgain, false, "re-pulling an already-thrown lever reports failure") + eq(game.stats.vaultsOpened, 1, "re-pulling an already-thrown lever does not inflate the vault tally") +end + +function tests.movePlayerAutoPullsALeverItStepsOnto() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.map:set(6, 5, Tiles.LEVER) + local lever = { x = 6, y = 5, id = "v6", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 15, y = 5, id = "v6", opened = false } } + game:movePlayer(1, 0) + eq(game.player.x, 6, "the player actually steps onto the lever's cell") + eq(lever.pulled, true, "stepping onto a lever automatically throws it") +end + +function tests.vaultDoorBlocksMovementUntilItsLeversOpenIt() + local game = stagedGame(1, arena(21, 11), 9, 5) + game.level.map:set(10, 5, Tiles.VAULT_DOOR) + local moved = game:movePlayer(1, 0) + eq(moved, false, "an unopened vault door blocks the step onto it") + eq(game.player.x, 9, "the player does not pass through a sealed vault door") + game.level.map:set(10, 5, Tiles.DOOR_OPEN) + local movedAfter = game:movePlayer(1, 0) + eq(movedAfter, true, "once opened, the door's tile behaves like any plain open door") + eq(game.player.x, 10, "the player steps onto the formerly sealed cell") +end + +function tests.builderOccasionallyPlacesAVaultFromDepthFive() + -- Seed 41, not 1: adding the banshee species (minDepth 5) to Monsters.species + -- shifts Monsters.forDepth's eligible count for every depth-5+ monster spawn + -- roll, which desyncs Rng.new(1)'s stream from the very first monster onward - + -- seed 1 no longer rolls a vault onto this floor at all. Recomputed by + -- brute-force search (seeds 1-50) for one that still places exactly one + -- vault door with its treasure, same as every other assertion below expects. + local level = Builder.build(5, Rng.new(41), {}) + eq(#level.vaultDoors, 1, "this seed's depth-5 floor places exactly one vault door") + ok(#level.vaultLevers >= 2, "the vault's puzzle has at least two levers") + local door = level.vaultDoors[1] + eq(level.map:get(door.x, door.y), Tiles.VAULT_DOOR, "the vault door's own tile matches its sealed glyph") + for _, lever in ipairs(level.vaultLevers) do + eq(lever.id, door.id, "every placed lever shares the door's own vault id") + eq(level.map:get(lever.x, lever.y), Tiles.LEVER, "each lever's tile matches its unpulled glyph") + end + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.name and ground.item.name:find("vault", 1, true) then found = ground.item end + end + ok(found ~= nil, "the vault's guaranteed treasure is placed somewhere on the floor") +end + +function tests.vaultsStayOffFloorsAboveDepthFive() + for seed = 1, 4 do + for depth = 1, 4 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.vaultDoors, 0, "no vault door appears above depth five") + eq(#level.vaultLevers, 0, "no vault lever appears above depth five") + end + end +end + +-- Every vault the builder actually places must be genuinely solvable: each +-- of its levers sits somewhere already reachable from the entrance without +-- crossing a locked door or the vault door itself (see builder.lua's +-- fallback to a plain closed door whenever that cannot be guaranteed). +function tests.builderOnlyEverPlacesASolvableVaultPuzzle() + local checked = 0 + for seed = 1, 200 do + local level = Builder.build(5, Rng.new(seed), {}) + if #level.vaultDoors == 1 then + checked = checked + 1 + for _, lever in ipairs(level.vaultLevers) do + local path = Astar.find(level.map, level.upX, level.upY, lever.x, lever.y, function(x, y) + if x == lever.x and y == lever.y then return false end + return not level.map:isPassable(x, y, false) + end) + ok(path ~= nil, "seed " .. seed .. ": every vault lever is reachable without a key or the vault door") + end + end + end + ok(checked > 0, "at least one of the sampled seeds actually placed a vault to check") +end + +function tests.vaultFieldsSurviveSerialization() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.LEVER_PULLED) + level.map:set(10, 6, Tiles.DOOR_OPEN) + level.vaultLevers = { { x = 6, y = 6, id = "v7", pulled = true } } + level.vaultDoors = { { x = 10, y = 6, id = "v7", opened = true } } + local restored = Level.fromData(level:toData()) + eq(#restored.vaultLevers, 1, "the vault lever list survives a round trip") + eq(restored.vaultLevers[1].pulled, true, "a lever's pulled state survives a round trip") + eq(#restored.vaultDoors, 1, "the vault door list survives a round trip") + eq(restored.vaultDoors[1].opened, true, "a door's opened state survives a round trip") +end + +function tests.gameVaultsOpenedStatSurvivesSaveLoad() + local game = Game.new(1) + local lx, ly = game.player.x + 1, game.player.y + game.level.map:set(lx, ly, Tiles.LEVER) + local lever = { x = lx, y = ly, id = "v8", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = game.player.x + 2, y = game.player.y, id = "v8", opened = false } } + game:pullLever(lever) + eq(game.stats.vaultsOpened, 1, "pulling the lever opens the vault") + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.stats.vaultsOpened, 1, "the vault-opened tally survives a save/load round trip") + eq(#restored.level.vaultLevers, 1, "the level's vault lever list survives a save/load round trip") + eq(restored.level.vaultLevers[1].pulled, true, "a pulled lever's state survives a save/load round trip") +end + +function tests.metaRenownForScalesWithVaultsOpened() + local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 0, won = false }) + local withVault = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 2, won = false }) + eq(withVault, base + 20, "each opened vault adds a fixed renown bonus") +end + +function tests.metaRecordRunUnlocksVaultBreakerOnceAVaultOpens() + local meta = Meta.new() + local stats = { + deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, + artifactsFound = 0, vaultsOpened = 1, won = false, + } + local unlocked = Meta.recordRun(meta, stats) + eq(unlocked[1], "Vault Breaker", "opening a vault for the first time unlocks Vault Breaker") + eq(meta.achievements.vault_breaker, true, "vault_breaker is marked unlocked") + local again = Meta.recordRun(meta, stats) + eq(#again, 0, "an already-unlocked Vault Breaker is not re-announced on a later run") +end + +-- Runewords (runewords.lua): rune stones socket into a weapon or armor's +-- rune chain in order; an exact match against the catalog grants a fixed +-- attack/defense bonus read live by combat.lua, exactly like a skill bonus. + +function tests.runewordsSocketAppendsToTheChainInOrderAndCapsAtMaxRunes() + local sword = Items.weapon("sword", 4) + ok(Runewords.socket(sword, Runewords.rune("sol")), "socketing a rune into a bare weapon succeeds") + ok(Runewords.socket(sword, Runewords.rune("ith")), "a second rune joins the same chain") + eq(table.concat(sword.runes, "-"), "sol-ith", "the chain records runes in the order they were socketed") + ok(Runewords.socket(sword, Runewords.rune("tal")), "a third rune still fits under the cap") + local success, reason = Runewords.socket(sword, Runewords.rune("ral")) + eq(success, false, "a fourth rune overflows the chain") + eq(reason, "the rune chain is full", "socket explains why a full chain refuses another rune") +end + +function tests.runewordsSocketRefusesAnythingThatIsNotWeaponOrArmor() + local potion = Items.potion(10) + local success, reason = Runewords.socket(potion, Runewords.rune("sol")) + eq(success, false, "a potion cannot hold a rune chain") + eq(reason, "cannot hold runes", "socket explains why a non-gear item refuses a rune") +end + +function tests.runewordsActiveWordMatchesOnlyAnExactChain() + local sword = Items.weapon("sword", 4) + eq(Runewords.activeWord(sword), nil, "a bare weapon forms no runeword") + Runewords.socket(sword, Runewords.rune("sol")) + eq(Runewords.activeWord(sword), nil, "a single rune alone matches no cataloged word") + Runewords.socket(sword, Runewords.rune("ith")) end local order = { From 2c6fae4f44874a2114ea7fce9cb2f829655c0c7a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:21:51 +0200 Subject: [PATCH 120/172] test: cover tests.runewordsAttackAndDefenseBonusReadTheWielderSGearLive --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 58356b5..2a4cb73 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6530,6 +6530,179 @@ function tests.runewordsActiveWordMatchesOnlyAnExactChain() Runewords.socket(sword, Runewords.rune("sol")) eq(Runewords.activeWord(sword), nil, "a single rune alone matches no cataloged word") Runewords.socket(sword, Runewords.rune("ith")) + local word = Runewords.activeWord(sword) + ok(word ~= nil, "sol then ith completes the Zenith runeword") + eq(word.name, "Zenith", "the matched word is the cataloged Zenith") + Runewords.socket(sword, Runewords.rune("tal")) + eq(Runewords.activeWord(sword), nil, "a third rune growing past Zenith's length breaks its match") +end + +function tests.runewordsAttackAndDefenseBonusReadTheWielderSGearLive() + local player = { weapon = Items.weapon("sword", 4), armor = Items.armor("chain mail", 2) } + eq(Runewords.attackBonus(player), 0, "no bonus applies before any runeword completes") + eq(Runewords.defenseBonus(player), 0, "no defense bonus applies before any runeword completes") + Runewords.socket(player.weapon, Runewords.rune("sol")) + Runewords.socket(player.weapon, Runewords.rune("ith")) + eq(Runewords.attackBonus(player), 3, "a completed Zenith on the weapon grants its flat attack bonus") + Runewords.socket(player.armor, Runewords.rune("tal")) + Runewords.socket(player.armor, Runewords.rune("ral")) + eq(Runewords.defenseBonus(player), 3, "a completed Bastion on the armor grants its flat defense bonus") + eq(Runewords.attackBonus(player), 3, "the armor's runeword does not leak an attack bonus") +end + +function tests.gameSocketRuneAppliesItsRunewordBonusInActualCombat() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 0, 0, 40, 40 + game.level.entities = { orc } + local before = orc.hp + game:playerAttack(orc) + local plainDamage = before - orc.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game2.player) + local orc2 = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc2.defense, orc2.baseDefense, orc2.hp, orc2.maxHp = 0, 0, 40, 40 + game2.level.entities = { orc2 } + game2.player.inventory = { Runewords.rune("sol"), Runewords.rune("ith") } + ok(game2:socketRune("weapon", 1), "socketing the first rune into the equipped weapon succeeds") + ok(game2:socketRune("weapon", 1), "socketing the second rune completes the runeword") + eq(#game2.player.inventory, 0, "both runes leave the inventory once socketed") + ok(contains(game2.log:last(), "Zenith"), "completing a runeword is announced by name") + local before2 = orc2.hp + game2:playerAttack(orc2) + local runewordDamage = before2 - orc2.hp + ok(runewordDamage > plainDamage, "a completed Zenith runeword deals more damage than the bare weapon") + eq(runewordDamage - plainDamage, 3, "the extra damage matches Zenith's exact flat attack bonus") +end + +function tests.gameSocketRuneFailsOnAnUnknownTargetOrANonRuneItem() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.inventory = { Items.potion(10) } + eq(game:socketRune("weapon", 1), false, "socketing a non-rune item fails") + game.player.inventory = { Runewords.rune("sol") } + eq(game:socketRune("armor", 1), false, "socketing into gear the player has not equipped fails") + eq(#game.player.inventory, 1, "a failed socket attempt leaves the rune in inventory") +end + +function tests.builderOccasionallyPlacesARuneFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.RUNE then found = ground.item end + end + ok(found ~= nil, "this seed's depth-3 floor places a rune stone") + ok(found.rune ~= nil, "the placed rune names a real catalog key") +end + +function tests.runesStayOffTheShallowestTwoFloors() + for seed = 1, 6 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.RUNE, "no rune appears on the shallowest two floors") + end + end + end +end + +function tests.runeChainFieldsSurviveSerialization() + local game = Game.new(3) + local sword = Items.weapon("sword", 4) + Items.equipWeapon(sword, game.player) + Runewords.socket(sword, Runewords.rune("sol")) + Runewords.socket(sword, Runewords.rune("ith")) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(table.concat(restored.player.weapon.runes, "-"), "sol-ith", "a weapon's rune chain survives a save/load round trip") + ok(Runewords.activeWord(restored.player.weapon) ~= nil, "the restored chain still forms its runeword") +end + +-- Atmosphere (atmosphere.lua): a single floor-wide weather condition drawn +-- once at generation, distinct from terrain.lua's per-cell hazards - every +-- hook here is a flat modifier applying to the whole floor for its lifetime. + +function tests.atmosphereHooksAreZeroForAnyConditionTheyDoNotGovern() + eq(Atmosphere.sightPenalty(Atmosphere.CONSECRATED), 0, "consecrated ground carries no sight penalty") + eq(Atmosphere.regenBonus(Atmosphere.FOGGY), 0, "fog carries no regen bonus") + eq(Atmosphere.monsterAttackBonus(Atmosphere.FOGGY), 0, "fog carries no monster attack bonus") + eq(Atmosphere.sightPenalty(nil), 0, "a clear floor (no weather) carries no sight penalty") +end + +function tests.atmosphereFoggyShrinksSightRadiusButNeverBelowOne() + eq(Atmosphere.sightPenalty(Atmosphere.FOGGY), 2, "fog carries a fixed sight penalty") + local game = stagedGame(1, arena(21, 11), 3, 6) + local clear = game:effectiveRadius() + game.level.weather = Atmosphere.FOGGY + eq(game:effectiveRadius(), clear - 2, "fog shaves its fixed penalty off the floor's own sight radius") + + local darkGame = stagedGame(1, arenaDark(21, 11), 3, 6) + darkGame.player.light.fuel = 0 + darkGame.level.weather = Atmosphere.FOGGY + eq(darkGame:effectiveRadius(), 1, "fog on an already-dark, torchless floor floors out at a minimum radius of one") +end + +function tests.atmosphereConsecratedRegeneratesThePlayerEachTurn() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.CONSECRATED + game.player.hp = game.player.maxHp - 10 + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before + 1, "consecrated ground regenerates its fixed amount each turn") +end + +function tests.atmosphereConsecratedNeverRegeneratesPastMaxHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.CONSECRATED + game.player.hp = game.player.maxHp + game:hungerTorchTick() + eq(game.player.hp, game.player.maxHp, "consecrated regen never lifts hp past its maximum") +end + +function tests.builderOccasionallyDrawsEachWeatherConditionFromDepthTwo() + local infested = Builder.build(2, Rng.new(1), {}) + eq(infested.weather, Atmosphere.INFESTED, "this seed's depth-2 floor draws an infested reading") + local rat + for _, entity in ipairs(infested.entities) do + if entity.species == "rat" then rat = entity end + end + ok(rat ~= nil, "an infested floor still places its usual monsters") + eq(rat.attack, Monsters.byKey.rat.attack + Atmosphere.monsterAttackBonus(Atmosphere.INFESTED), + "an infested floor bakes its attack bonus into every monster placed on it") + + local consecrated = Builder.build(2, Rng.new(3), {}) + eq(consecrated.weather, Atmosphere.CONSECRATED, "this seed's depth-2 floor draws a consecrated reading") + local foggy = Builder.build(2, Rng.new(6), {}) + eq(foggy.weather, Atmosphere.FOGGY, "this seed's depth-2 floor draws a foggy reading") +end + +function tests.weatherNeverAppearsOnTheEntranceFloor() + for seed = 1, 20 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(level.weather, nil, "no weather condition is ever drawn for the entrance floor") + end +end + +function tests.weatherFieldSurvivesSerialization() + local level = arena(21, 11) + level.weather = Atmosphere.FOGGY + local restored = Level.fromData(level:toData()) + eq(restored.weather, Atmosphere.FOGGY, "a floor's weather survives a round trip") +end + +function tests.renderPanelReportsTheFloorSWeatherWhenPresent() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.INFESTED + ok(contains(Render.panel(game), "infested"), "the status panel names an active weather condition") +end + +-- Biomes: a fixed, seed-independent theme per depth (contrast with +-- Atmosphere's own dice-rolled weather above), read by the builder's biome +-- pass to occasionally reinforce a floor with one more native monster or end local order = { From 865b04ef9352a070fd286bfec1ba542aef6b8fd0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 20:11:22 +0200 Subject: [PATCH 121/172] test: cover tests.biomesForDepthCyclesThroughTheCatalogInOrder --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 2a4cb73..df9f000 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6703,6 +6703,179 @@ end -- Biomes: a fixed, seed-independent theme per depth (contrast with -- Atmosphere's own dice-rolled weather above), read by the builder's biome -- pass to occasionally reinforce a floor with one more native monster or +-- bloom a small pocket of its own hazard kind. + +function tests.biomesForDepthCyclesThroughTheCatalogInOrder() + for i, biome in ipairs(Biomes.CATALOG) do + eq(Biomes.forDepth(i), biome, "depth " .. i .. " maps to the catalog's own entry in order") + end + eq(Biomes.forDepth(#Biomes.CATALOG + 1), Biomes.CATALOG[1], + "depth wraps back to the first biome once the catalog is exhausted") + eq(Biomes.forDepth(3 * #Biomes.CATALOG), Biomes.CATALOG[#Biomes.CATALOG], + "depth keeps cycling any number of catalog lengths deep") +end + +function tests.biomesDescribeNamesTheBiomeAndItsAmbientText() + eq(Biomes.describe(Biomes.byKey.warrens), "the mossy warrens: damp stone and skittering claws", + "describe joins a biome's own name and ambient line") +end + +function tests.biomesCatalogEveryEntryNamesARealFactionAndHazardKind() + local realFactions = {} + for _, faction in pairs(Factions) do + if type(faction) == "string" then realFactions[faction] = true end + end + for _, biome in ipairs(Biomes.CATALOG) do + ok(realFactions[biome.favoredFaction], biome.key .. " names a real faction") + ok(biome.hazardKind == Hazards.FIRE or biome.hazardKind == Hazards.GAS, + biome.key .. " names a real hazard kind") + end +end + +function tests.biomesNativesAmongFiltersToTheFavoredFactionAndExcludesRangedSpecs() + local crypts = Biomes.byKey.crypts + local deepRoster = Monsters.forDepth(6) + local natives = Biomes.nativesAmong(crypts, deepRoster) + ok(#natives > 0, "crypts finds at least one native among a deep enough roster") + for _, spec in ipairs(natives) do + eq(spec.faction, Factions.UNDEAD, "every native belongs to the biome's own faction") + ok(spec.ai ~= "ranged", "no native reinforcement is ever a ranged specialist") + end + local shallowRoster = Monsters.forDepth(1) + eq(#Biomes.nativesAmong(crypts, shallowRoster), 0, + "no undead have surfaced yet this shallow, so crypts finds no native at depth one") +end + +function tests.builderLevelsCarryTheBiomeMatchingTheirDepthRegardlessOfSeed() + for depth = 1, 8 do + local expected = Biomes.forDepth(depth).key + for seed = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(level.biome, expected, "depth " .. depth .. " always carries its own fixed biome no matter the seed") + end + end +end + +function tests.builderOccasionallyReinforcesAFloorWithADormantNativeMonster() + local level = Builder.build(1, Rng.new(7), {}) + ok(#level.entities > 7, "this seed's entrance floor carries one more monster than the ordinary roster ever places") + local reinforcement = level.entities[#level.entities] + eq(reinforcement.faction, Factions.VERMIN, "the extra monster belongs to the entrance floor's own biome") + eq(reinforcement.awareness, Stealth.DORMANT, "the reinforcement starts dormant like every other wild monster") +end + +function tests.builderOccasionallyBloomsAHazardMatchingTheFloorSBiome() + local warrens = Builder.build(1, Rng.new(7), {}) + eq(warrens.hazards[1].kind, Hazards.GAS, "a gas-favoring biome only ever blooms gas at generation") + local warcamp = Builder.build(4, Rng.new(11), {}) + eq(warcamp.hazards[1].kind, Hazards.FIRE, "a fire-favoring biome only ever blooms fire at generation") +end + +function tests.biomeFieldSurvivesSerialization() + local level = arena(21, 11) + level.biome = "crypts" + local restored = Level.fromData(level:toData()) + eq(restored.biome, "crypts", "a floor's biome survives a round trip") +end + +-- Stealth: monster alertness (asleep/hunting/searching), sneak attacks. + +local function wallSplitArena() + local map = Map.new(21, 11) + for y = 2, 10 do for x = 2, 20 do map:set(x, y, Map.FLOOR) end end + for y = 2, 10 do map:set(11, y, Map.WALL) end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 2, downX = 20, downY = 10, + }) +end + +function tests.stealthHandBuiltMonsterDefaultsToAlertWhenAwarenessIsUnset() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 1) + eq(orc.awareness, nil, "a monster built directly carries no awareness field at all") + ok(not Stealth.isUnaware(orc), "an unset awareness field is treated as already alert, not asleep") + eq(Stealth.sneakMultiplier(orc), 1, "no sneak bonus applies against an already-alert target") +end + +function tests.builderPlacesWildDepthOneMonstersAsleepByDefault() + local game = Game.new(9) + ok(#game.level.entities > 0, "the opening floor spawns at least one wild monster") + for _, entity in ipairs(game.level.entities) do + eq(entity.awareness, Stealth.DORMANT, "every builder-placed wild monster on the opening floor starts asleep") + end +end + +function tests.stealthBossesStartFullyAlertRatherThanDormant() + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 5, 5) + ok(not Stealth.isUnaware(warlord), "a named boss is never asleep, unlike a wild monster") + eq(warlord.awareness, Stealth.HUNTING, "a boss starts directly in the hunting state") +end + +function tests.stealthSpawnedMinionsStartHuntingRatherThanDormant() + local game = stagedGame(1, arena(21, 11), 3, 6) + local minion = game:spawnMinion("zombie", 10, 6) + eq(minion.awareness, Stealth.HUNTING, "a minion summoned or split mid-fight is already aware, never asleep") +end + +function tests.stealthDormantMonsterWakesImmediatelyWhenThePlayerStandsNextToIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + local hpBefore = game.player.hp + game:takeMonsterTurn(orc) + eq(orc.awareness, Stealth.HUNTING, "a monster right next to the player always notices it") + ok(game.player.hp < hpBefore, "the freshly woken monster still gets to act on the very same turn") +end + +function tests.stealthDormantMonsterBeyondItsWakeRadiusNeverStirs() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + for _ = 1, 10 do + game:takeMonsterTurn(orc) + end + eq(orc.awareness, Stealth.DORMANT, "a monster far outside its wake radius stays asleep no matter how many turns pass") + eq(orc.x, 19, "an asleep monster that never wakes does not move") + eq(orc.y, 6, "an asleep monster that never wakes does not move either") +end + +function tests.stealthDormantMonsterWithinRangeEventuallyWakesOnASuccessfulRoll() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + local woke = false + for _ = 1, 20 do + game:takeMonsterTurn(orc) + if orc.awareness ~= Stealth.DORMANT then woke = true; break end + end + ok(woke, "a monster well within its wake radius eventually notices the player across enough turns") +end + +function tests.stealthMeleeAttackOnADormantMonsterDealsATripledSneakBonus() + local sneakGame = stagedGame(1, arena(21, 11), 3, 6) + local sneakOrc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + sneakOrc.awareness = Stealth.DORMANT + sneakGame.level.entities = { sneakOrc } + local before = sneakOrc.hp + sneakGame:playerAttack(sneakOrc) + local sneakDamage = before - sneakOrc.hp + eq(sneakOrc.awareness, Stealth.HUNTING, "landing the blow wakes the target immediately") + ok(contains(table.concat(sneakGame.log:recent(6), "\n"), "unaware"), "the log calls out the sneak attack") + + local plainGame = stagedGame(1, arena(21, 11), 3, 6) + local plainOrc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + plainGame.level.entities = { plainOrc } + local plainBefore = plainOrc.hp + plainGame:playerAttack(plainOrc) + local plainDamage = plainBefore - plainOrc.hp + eq(sneakDamage, plainDamage * 3, "a sneak attack deals exactly triple the damage of the same hit thrown while already noticed") +end + +function tests.stealthHuntingMonsterLosesTrackAndSwitchesToSearching() end local order = { From 50131d3e4145c51416fbf80e0e951b3b08c538e7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 22:32:44 +0200 Subject: [PATCH 122/172] test: cover tests.stealthSearchingMonsterReacquiresThePlayerAndReturn... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index df9f000..c834025 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6876,6 +6876,179 @@ function tests.stealthMeleeAttackOnADormantMonsterDealsATripledSneakBonus() end function tests.stealthHuntingMonsterLosesTrackAndSwitchesToSearching() + local game = stagedGame(1, wallSplitArena(), 15, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) + orc.awareness = Stealth.HUNTING + orc.lastKnownX, orc.lastKnownY = 8, 6 + game.level.entities = { orc } + for _ = 1, Stealth.LOST_TURNS_LIMIT do + game:takeMonsterTurn(orc) + end + eq(orc.awareness, Stealth.SEARCHING, "losing sight of the player for several turns in a row switches a hunter to searching") + eq(orc.x, 6, "the newly searching monster immediately takes its first step toward the last known position") + eq(orc.y, 6, "the search movement heads straight down the open corridor") +end + +function tests.stealthSearchingMonsterReacquiresThePlayerAndReturnsToHunting() + local game = stagedGame(1, wallSplitArena(), 15, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) + orc.awareness = Stealth.HUNTING + orc.lastKnownX, orc.lastKnownY = 8, 6 + game.level.entities = { orc } + for _ = 1, Stealth.LOST_TURNS_LIMIT do + game:takeMonsterTurn(orc) + end + game.player.x, game.player.y = 7, 6 + game:takeMonsterTurn(orc) + eq(orc.awareness, Stealth.HUNTING, "regaining line of sight during a search snaps the monster straight back to hunting") + eq(orc.lastKnownX, 7, "hunting again refreshes the last-known position to where the player actually is") +end + +function tests.stealthSearchingMonsterGivesUpAndReturnsToDormant() + local game = stagedGame(1, wallSplitArena(), 15, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) + orc.awareness = Stealth.HUNTING + orc.lastKnownX, orc.lastKnownY = 8, 6 + game.level.entities = { orc } + for _ = 1, 15 do + game:takeMonsterTurn(orc) + end + eq(orc.awareness, Stealth.DORMANT, "abandoning a fruitless search puts the monster back to sleep") + eq(orc.lastKnownX, nil, "a monster back asleep no longer remembers where it last saw the player") +end + +function tests.stealthAwarenessFieldsSurviveASaveLoadRoundTrip() + local orc = Monsters.create(Monsters.byKey.orc, 5, 6, 6, 1) + orc.awareness = Stealth.SEARCHING + orc.lastKnownX, orc.lastKnownY = 12, 9 + orc.lostTurns = 2 + orc.searchTurns = 1 + local restored = Entity.fromData(orc:toData()) + eq(restored.awareness, Stealth.SEARCHING, "the awareness state survives a round trip") + eq(restored.lastKnownX, 12, "the last-known x coordinate survives a round trip") + eq(restored.lastKnownY, 9, "the last-known y coordinate survives a round trip") + eq(restored.lostTurns, 2, "the lost-turn counter survives a round trip") + eq(restored.searchTurns, 1, "the search-turn counter survives a round trip") +end + +function tests.renderPanelFlagsSleepingFoesSeparatelyFromAwakeOnes() + local game = stagedGame(1, arena(21, 11), 3, 6) + local sleeper = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + sleeper.awareness = Stealth.DORMANT + game.level.entities = { sleeper } + ok(contains(Render.panel(game), "1 asleep"), "the status panel tallies how many visible foes are still asleep") + + local awake = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + game.level.entities = { awake } + ok(not contains(Render.panel(game), "asleep"), "the panel omits the asleep tally once every foe is awake") +end + +-- Compendium: cross-run bestiary of sightings and kills. + +function tests.compendiumNoteSightingReturnsTrueOnlyOnTheFirstSighting() + local compendium = Compendium.new() + eq(Compendium.noteSighting(compendium, "rat"), true, "the very first sighting of a species reports true") + eq(Compendium.noteSighting(compendium, "rat"), false, "a repeat sighting of the same species reports false") + ok(Compendium.hasSeen(compendium, "rat"), "the species is recorded as seen") + ok(not Compendium.hasSeen(compendium, "bat"), "an entirely different species is unaffected") +end + +function tests.compendiumNoteKillAccumulatesAcrossMultipleKills() + local compendium = Compendium.new() + eq(Compendium.killCount(compendium, "goblin"), 0, "a species starts with zero recorded kills") + Compendium.noteKill(compendium, "goblin") + Compendium.noteKill(compendium, "goblin") + Compendium.noteKill(compendium, "goblin") + eq(Compendium.killCount(compendium, "goblin"), 3, "kills accumulate one at a time") +end + +function tests.compendiumEntriesListsEveryWildSpeciesAndEveryBoss() + local entries = Compendium.entries(Compendium.new()) + eq(#entries, #Monsters.species + #Bosses.catalog, "the compendium lists every wild species and every named boss") + local byKey = {} + for _, entry in ipairs(entries) do byKey[entry.key] = entry end + ok(byKey.orc ~= nil, "a wild species keys its entry by its plain species key") + ok(byKey.boss_warlord ~= nil, "a boss keys its entry with the same boss_ prefix game.lua stamps onto its entity") + eq(byKey.orc.seen, false, "a freshly created compendium has not seen anything yet") + ok(byKey.orc.flavor ~= nil, "every catalogued species carries a flavor line") +end + +function tests.gameRecordsASightingTheFirstTimeAMonsterBecomesVisible() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game.level.entities = { orc } + game:computeVisible() + ok(Compendium.hasSeen(game.compendium, "orc"), + "a monster within sight is recorded into the compendium the moment it is seen") + + local rat = Monsters.create(Monsters.byKey.rat, 2, 19, 10, 1) + game.level.entities = { rat } + game:computeVisible() + ok(not Compendium.hasSeen(game.compendium, "rat"), + "a species standing far outside sight range is never recorded just because it exists on the level") +end + +function tests.gameRecordsAKillIntoItsCompendiumOnAwardKill() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + game.level.entities = { orc } + eq(Compendium.killCount(game.compendium, "orc"), 0, "no kills recorded before the fight starts") + game:awardKill(orc) + eq(Compendium.killCount(game.compendium, "orc"), 1, "felling a monster records exactly one kill for its species") + game:awardKill(Monsters.create(Monsters.byKey.orc, 3, 4, 6, 1)) + eq(Compendium.killCount(game.compendium, "orc"), 2, "kill counts accumulate across separate kills of the same species") +end + +function tests.compendiumFieldsSurviveAGameSaveLoadRoundTrip() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game.level.entities = { orc } + game:computeVisible() + game:awardKill(orc) + local restored = Game.fromData(game:toData()) + eq(Compendium.killCount(restored.compendium, "orc"), 1, "a recorded kill survives a save/load round trip") + ok(Compendium.hasSeen(restored.compendium, "orc"), "a recorded sighting survives a save/load round trip too") +end + +function tests.gameReusesThePassedMetaSCompendiumByReference() + local meta = Meta.new() + local game = Game.new(1, { meta = meta }) + local orc = Monsters.create(Monsters.byKey.orc, 1, game.player.x + 1, game.player.y, 1) + game.level.entities = { orc } + game:awardKill(orc) + eq(Compendium.killCount(meta.compendium, "orc"), 1, + "a kill recorded mid-run is already sitting in the exact meta table warren.lua will save back out") +end + +function tests.setsCreateBuildsTheCatalogedItemShape() + local blade = Sets.create("ironclad", "weapon") + eq(blade.kind, Items.WEAPON, "the ironclad weapon piece is a weapon") + eq(blade.set, "ironclad", "the item remembers its set key") + eq(blade.name, "Ironclad Greatsword", "the item takes the catalog's exact name") + local ring = Sets.create("duskrunner", "ring") + eq(ring.kind, Items.RING, "the duskrunner ring piece is a ring") + eq(ring.set, "duskrunner", "a second set tags its own pieces with its own key") + eq(Sets.create("nonexistent", "weapon"), nil, "an unknown set key builds nothing") + eq(Sets.create("ironclad", "bow"), nil, "a slot ironclad does not define builds nothing") +end + +function tests.setsAttackAndDefenseBonusEscalateAsMorePiecesAreEquipped() + local player = { weapon = Sets.create("ironclad", "weapon") } + eq(Sets.attackBonus(player), 0, "a single piece grants no bonus below the 2-piece threshold") + eq(Sets.defenseBonus(player), 0, "a single piece grants no defense bonus either") + player.armor = Sets.create("ironclad", "armor") + eq(Sets.attackBonus(player), 1, "two matching pieces grant the 2-piece attack bonus") + eq(Sets.defenseBonus(player), 1, "two matching pieces grant the 2-piece defense bonus") + eq(Sets.regenAmount(player), 0, "the 2-piece tier grants no regen") + player.ring = Sets.create("ironclad", "ring") + eq(Sets.attackBonus(player), 2, "the full 3-piece bonus replaces the 2-piece total rather than adding to it") + eq(Sets.defenseBonus(player), 3, "the full set's defense bonus also replaces rather than stacks") + eq(Sets.regenAmount(player), 1, "only the full set grants its regen bonus") +end + +function tests.setsBonusDropsBackWhenAPieceIsUnequipped() + local player = { + weapon = Sets.create("ironclad", "weapon"), end local order = { From 6dc9d144d223a0309f61b1cdb075de7cea1ef3e8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 12:15:41 +0200 Subject: [PATCH 123/172] test: cover tests.setsDoNotCrossTalkBetweenDifferentSets --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index c834025..02bb1d2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7049,6 +7049,179 @@ end function tests.setsBonusDropsBackWhenAPieceIsUnequipped() local player = { weapon = Sets.create("ironclad", "weapon"), + armor = Sets.create("ironclad", "armor"), + ring = Sets.create("ironclad", "ring"), + } + eq(Sets.attackBonus(player), 2, "the full set starts active") + player.ring = Items.ring("plain band", 0, 0) + eq(Sets.attackBonus(player), 1, "losing a piece drops the bonus back to the 2-piece tier") + player.armor = Items.armor("plain mail", 2) + eq(Sets.attackBonus(player), 0, "a single remaining piece grants no bonus at all") +end + +function tests.setsDoNotCrossTalkBetweenDifferentSets() + local player = { weapon = Sets.create("ironclad", "weapon"), bow = Sets.create("duskrunner", "bow") } + eq(Sets.attackBonus(player), 0, "one piece from each of two different sets completes neither") +end + +function tests.gameEquippingASetPieceAppliesItsBonusInActualCombat() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 0, 0, 40, 40 + game.level.entities = { orc } + local before = orc.hp + game:playerAttack(orc) + local plainDamage = before - orc.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Sets.create("ironclad", "weapon"), game2.player) + Items.equipArmor(Sets.create("ironclad", "armor"), game2.player) + local orc2 = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc2.defense, orc2.baseDefense, orc2.hp, orc2.maxHp = 0, 0, 40, 40 + game2.level.entities = { orc2 } + local before2 = orc2.hp + game2:playerAttack(orc2) + local setDamage = before2 - orc2.hp + -- The ironclad weapon's own power (3) is one below the plain sword's (4), + -- but the completed 2-piece bonus adds back exactly +1 attack, landing on + -- the same total damage - proof the bonus reads live in actual combat. + eq(setDamage, plainDamage, "a 2-piece Ironclad bonus makes up for the set piece's own weaker power") +end + +function tests.gameEquippingTheSecondSetPieceLogsTheSetAwakening() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.inventory = { Sets.create("ironclad", "weapon"), Sets.create("ironclad", "armor") } + ok(game:useItem(1), "equipping the first ironclad piece succeeds") + ok(not contains(game.log:last(), "awakens"), "a lone set piece does not announce a bonus") + ok(game:useItem(1), "equipping the second ironclad piece succeeds") + ok(contains(game.log:last(), "Ironclad") and contains(game.log:last(), "awakens"), + "completing the 2-piece threshold announces the set by name") +end + +function tests.setsRegenerationAppliesEachTurnAtTheFullIroncladSet() + local game = Game.new(1) + Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) + Items.equipArmor(Sets.create("ironclad", "armor"), game.player) + Items.equipRing(Sets.create("ironclad", "ring"), game.player) + game.player.hp = game.player.maxHp - 10 + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before + 1, "the full ironclad set regenerates its fixed amount each turn") +end + +function tests.builderOccasionallyPlacesASetPieceFromDepthThree() + local found + for seed = 1, 20 do + local level = Builder.build(3, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.set then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a depth-3 floor eventually places a set piece across a handful of seeds") + ok(Sets.byKey[found.set] ~= nil, "the placed piece names a real catalog set") +end + +function tests.setPiecesStayOffTheShallowestTwoFloors() + for seed = 1, 6 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(not ground.item.set, "no set piece appears on depth 1 or 2") + end + end + end +end + +function tests.setFieldsSurviveSerialization() + local game = Game.new(3) + Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.weapon.set, "ironclad", "an equipped set piece's set key survives a save/load round trip") +end + +function tests.gamePickupTracksSetPiecesFoundForMetaStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.floorItems = { { x = 5, y = 5, item = Sets.create("ironclad", "ring") } } + game:pickupHere() + eq(game.stats.setPiecesFound, 1, "picking up a set piece counts toward set pieces found") + ok(game.player.ring == nil, "picking up a set piece only stashes it in inventory, it is not auto-equipped") +end + +function tests.metaRenownForScalesWithSetPiecesFound() + local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, setPiecesFound = 0, won = false }) + local withPieces = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, setPiecesFound = 2, won = false }) + eq(withPieces, base + 6, "each set piece found adds a fixed renown bonus") +end + +function tests.metaRecordRunUnlocksSetCollectorOnceThreePiecesFound() + local meta = Meta.new() + local stats = { + deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, + artifactsFound = 0, vaultsOpened = 0, setPiecesFound = 3, won = false, + } + local unlocked = Meta.recordRun(meta, stats) + eq(unlocked[1], "Set Collector", "finding three set pieces in a run unlocks Set Collector") + eq(meta.achievements.set_collector, true, "set_collector is marked unlocked") + local again = Meta.recordRun(meta, stats) + eq(#again, 0, "an already-unlocked Set Collector is not re-announced on a later run") +end + +function tests.renderPanelShowsTheActiveSetBonusWhenOneApplies() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "set "), "the panel omits the set line without an active bonus") + Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) + Items.equipArmor(Sets.create("ironclad", "armor"), game.player) + ok(contains(Render.panel(game), "Ironclad 2pc"), "the panel names the active set and its piece count") +end + +function tests.mimicsFloorItemBuildsADisguisedLootShape() + local item = Mimics.floorItem(Rng.new(1)) + eq(item.kind, "mimic", "a mimic's floor item is its own kind, not a real item kind") + eq(item.mimic, true, "a mimic's floor item is tagged so pickupHere knows to unmask it") + local matched + for _, disguise in ipairs(Mimics.DISGUISES) do + if disguise.name == item.name and disguise.glyph == item.glyph then matched = true end + end + ok(matched, "the rolled disguise's name and glyph both come from the catalog") +end + +function tests.mimicsRevealBuildsAHostileMonsterScaledByDepth() + local shallow = Mimics.reveal(1, 4, 6, 1) + eq(shallow.kind, "monster", "a revealed mimic is a genuine monster entity") + eq(shallow.glyph, "M", "a revealed mimic shows its own glyph, not a disguise") + eq(shallow.mimic, true, "a revealed mimic stays tagged as a mimic") + eq(shallow.hp, 12, "a depth-1 mimic has its base hp with no bonus") + local deep = Mimics.reveal(2, 4, 6, 5) + eq(deep.hp, 16, "a mimic's hp scales with depth the same way Monsters.create scales a wild species") +end + +function tests.gamePickupHereRevealsAMimicAndAmbushesThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.defense, game.player.baseDefense = 0, 0 + game.level.floorItems = { { x = 5, y = 5, item = Mimics.floorItem(Rng.new(1)) } } + local hpBefore = game.player.hp + game:pickupHere() + eq(#game.level.floorItems, 0, "the disguised item leaves the floor the instant it is disturbed") + eq(#game.level.entities, 1, "a revealed mimic joins the level as a real hostile monster") + eq(game.level.entities[1].mimic, true, "the spawned monster is tagged as a mimic") + eq(game.player.hp, hpBefore - 5, "the mimic lands one free ambush hit for its full attack") + ok(contains(game.log:last(), "damage"), "the ambush hit is logged") +end + +function tests.gameKillingARevealedMimicGrantsItsFixedEssenceReward() + local game = stagedGame(1, arena(21, 11), 3, 6) + local mimic = Mimics.reveal(1, 4, 6, 1) + mimic.hp = 1 + game.level.entities = { mimic } + local essenceBefore = game.player.essence + game:playerAttack(mimic) + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Mimics.xpFor()), + "felling a mimic grants essence scaled to its own fixed xp reward, not a plain monster's") end local order = { From 232a44769253772cca18cd111a05c59782bdd11c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 13:48:18 +0200 Subject: [PATCH 124/172] test: cover tests.builderOccasionallyPlacesAMimicFromDepthTwo --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 02bb1d2..cbef589 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7224,6 +7224,179 @@ function tests.gameKillingARevealedMimicGrantsItsFixedEssenceReward() "felling a mimic grants essence scaled to its own fixed xp reward, not a plain monster's") end +function tests.builderOccasionallyPlacesAMimicFromDepthTwo() + local found + for seed = 1, 20 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.mimic then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a depth-2 floor eventually places a disguised mimic across a handful of seeds") +end + +function tests.mimicsNeverAppearOnTheEntranceFloor() + for seed = 1, 6 do + local level = Builder.build(1, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(not ground.item.mimic, "no mimic appears on the entrance floor") + end + end +end + +function tests.mimicFieldSurvivesSerialization() + local mimic = Mimics.reveal(1, 4, 6, 1) + local restored = Entity.fromData(mimic:toData()) + eq(restored.mimic, true, "a revealed mimic's tag survives a save/load round trip") + eq(restored.glyph, "M", "a revealed mimic's glyph survives a save/load round trip too") +end + +function tests.cursesRollRespectsItsOneInEightChanceAcrossSeedsAndKnownSlots() + local hit, miss = false, false + for seed = 1, 60 do + local curse = Curses.roll(Rng.new(seed), "weapon") + if curse then hit = true else miss = true end + if hit and miss then break end + end + ok(hit, "a weapon curse fires on at least one seed in the first 60") + ok(miss, "a weapon curse also misses on at least one seed in the first 60") + eq(Curses.roll(Rng.new(1), "amulet"), nil, "an unsupported slot never rolls a curse") +end + +function tests.cursesAfflictFoldsThePenaltyIntoTheItemAndKeepsItHidden() + local weapon = Items.weapon("dagger", 5) + Curses.afflict(weapon, Curses.pool.weapon[1]) + eq(weapon.power, 3, "a weakness curse folds its penalty directly into the weapon's power") + eq(weapon.cursed, true, "the item is marked cursed") + eq(weapon.curseRevealed, false, "the curse starts hidden") + eq(weapon.name, "dagger", "an unrevealed curse never touches the item's displayed name") + + local bow = Items.bow("short bow", 2, 5) + Curses.afflict(bow, Curses.pool.bow[1]) + eq(bow.power, 1, "a faulty curse also taxes a bow's power") + eq(bow.range, 3, "a faulty curse shortens a bow's range") + + local ring = Items.ring("ring of power", 1, 1) + Curses.afflict(ring, Curses.pool.ring[1]) + eq(ring.attack, 1, "an affliction curse leaves a ring's own stats untouched") + eq(ring.curseDrain, 1, "an affliction curse instead sets a per-turn drain") +end + +function tests.cursesAfflictNeverDropsPowerOrRangeBelowItsFloor() + local dagger = Items.weapon("dagger", 1) + Curses.afflict(dagger, { key = "test", powerPenalty = 5 }) + eq(dagger.power, 0, "power never drops below zero even with an oversized penalty") + + local bow = Items.bow("short bow", 2, 1) + Curses.afflict(bow, { key = "test", rangePenalty = 5 }) + eq(bow.range, 1, "range never drops below one even with an oversized penalty") +end + +function tests.cursesIsCursedAndIsRevealedReadEmptyOrCleanGearSafely() + eq(Curses.isCursed(nil), false, "a nil slot is never cursed") + local clean = Items.weapon("sword", 4) + eq(Curses.isCursed(clean), false, "a plain item is never cursed") + eq(Curses.isRevealed(clean), false, "a plain item has nothing to reveal") + Curses.afflict(clean, Curses.pool.weapon[1]) + eq(Curses.isCursed(clean), true, "afflict marks the item cursed") + eq(Curses.isRevealed(clean), false, "afflict alone does not reveal the curse") + Curses.reveal(clean) + eq(Curses.isRevealed(clean), true, "reveal flips the item to known") + Curses.reveal(nil) + ok(true, "revealing a nil item is a safe no-op") +end + +function tests.cursesIsBoundInChecksASpecificEquippedSlot() + local entity = { weapon = Items.weapon("sword", 4), armor = nil } + eq(Curses.isBoundIn(entity, "weapon"), false, "a clean equipped weapon is not bound") + eq(Curses.isBoundIn(entity, "armor"), false, "an empty slot is never bound") + Curses.afflict(entity.weapon, Curses.pool.weapon[1]) + eq(Curses.isBoundIn(entity, "weapon"), true, "a cursed equipped weapon reads as bound") +end + +function tests.cursesTryReleaseRevealsAndRefusesABoundSlotButPassesACleanOne() + local entity = { weapon = Items.weapon("sword", 4) } + eq(Curses.tryRelease(entity, "weapon"), true, "a clean weapon releases without resistance") + eq(Curses.tryRelease(entity, "armor"), true, "an empty slot always releases") + Curses.afflict(entity.weapon, Curses.pool.weapon[1]) + eq(Curses.tryRelease(entity, "weapon"), false, "a bound weapon refuses to release") + eq(Curses.isRevealed(entity.weapon), true, "the failed release reveals the curse") +end + +function tests.cursesCleanseUndoesThePenaltyAndClearsEveryField() + local weapon = Items.weapon("dagger", 5) + Curses.afflict(weapon, Curses.pool.weapon[1]) + eq(Curses.cleanse(weapon), true, "cleansing a cursed weapon reports success") + eq(weapon.power, 5, "cleansing restores the weapon's original power") + eq(weapon.cursed, nil, "cleansing clears the cursed flag") + eq(weapon.curseKind, nil, "cleansing clears the curse kind") + eq(weapon.curseRevealed, nil, "cleansing clears the revealed flag") + eq(weapon.cursePenalty, nil, "cleansing clears the stored penalty") + eq(Curses.cleanse(weapon), false, "cleansing an already-clean item is a no-op") + + local ring = Items.ring("ring of power", 0, 0) + Curses.afflict(ring, Curses.pool.ring[1]) + Curses.cleanse(ring) + eq(ring.curseDrain, nil, "cleansing a ring also drops its drain") +end + +function tests.cursesCleanseWornLiftsEveryCursedSlotAndCountsThem() + local entity = { + weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]), + armor = Items.armor("leather armor", 1), + ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]), + } + eq(Curses.cleanseWorn(entity), 2, "cleanseWorn reports exactly the cursed pieces it lifted") + eq(Curses.isCursed(entity.weapon), false, "the cursed weapon is clean afterward") + eq(Curses.isCursed(entity.ring), false, "the cursed ring is clean afterward") + eq(Curses.cleanseWorn(entity), 0, "a second pass finds nothing left to cleanse") +end + +function tests.cursesRevealFirstUnknownFindsEarliestSlotAndAdvances() + local entity = { + weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]), + armor = Curses.afflict(Items.armor("leather armor", 1), Curses.pool.armor[1]), + } + local first = Curses.revealFirstUnknown(entity) + eq(first, entity.weapon, "weapon slot order comes before armor") + eq(Curses.isRevealed(entity.weapon), true, "the found curse is now revealed") + local second = Curses.revealFirstUnknown(entity) + eq(second, entity.armor, "the next call advances to the still-hidden armor curse") + eq(Curses.revealFirstUnknown(entity), nil, "nothing left hidden once both are revealed") +end + +function tests.cursesDrainAmountOnlyReadsACursedRing() + local entity = {} + eq(Curses.drainAmount(entity), 0, "no ring means no drain") + entity.weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]) + eq(Curses.drainAmount(entity), 0, "a cursed weapon never drains hp") + entity.ring = Items.ring("ring of power", 0, 0) + eq(Curses.drainAmount(entity), 0, "a clean ring drains nothing") + Curses.afflict(entity.ring, Curses.pool.ring[1]) + eq(Curses.drainAmount(entity), 1, "a cursed ring's affliction drains its fixed amount") +end + +function tests.cursesLabelHidesUntilRevealedThenPrefixes() + local weapon = Items.weapon("dagger", 5) + eq(Curses.label(weapon, "dagger"), "dagger", "a clean item's label is untouched") + Curses.afflict(weapon, Curses.pool.weapon[1]) + eq(Curses.label(weapon, "dagger"), "dagger", "an unrevealed curse still hides behind the plain label") + Curses.reveal(weapon) + eq(Curses.label(weapon, "dagger"), "cursed dagger", "a revealed curse prefixes the label") +end + +function tests.gameEquippingOverABoundCursedWeaponFailsAndRevealsTheCurse() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.weapon = Curses.afflict(Items.weapon("dagger", 2), Curses.pool.weapon[1]) + game.player.inventory = { Items.weapon("sword", 4) } + local before = game.player.weapon + eq(game:useItem(1), false, "equipping a new weapon over a bound one fails") + ok(game.player.weapon == before, "the bound weapon stays equipped") + eq(Curses.isRevealed(before), true, "the failed swap reveals the curse") + eq(#game.player.inventory, 1, "the replacement weapon stays in the pack, never spent") +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From 3115db3fe309372d602a286410acb00f386ca814 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 14:00:28 +0200 Subject: [PATCH 125/172] test: cover tests.gameEquippingAFreshCurseHidesItUntilYouTryToTakeItOff --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index cbef589..7aec43c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7395,6 +7395,179 @@ function tests.gameEquippingOverABoundCursedWeaponFailsAndRevealsTheCurse() ok(game.player.weapon == before, "the bound weapon stays equipped") eq(Curses.isRevealed(before), true, "the failed swap reveals the curse") eq(#game.player.inventory, 1, "the replacement weapon stays in the pack, never spent") + ok(contains(game.log:last(), "will not come off"), "the log explains the weapon is stuck") +end + +function tests.gameEquippingAFreshCurseHidesItUntilYouTryToTakeItOff() + local game = stagedGame(1, arena(21, 11), 3, 6) + local cursedRing = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) + game.player.inventory = { cursedRing } + ok(game:useItem(1), "equipping an empty slot with a cursed item still succeeds") + ok(game.player.ring == cursedRing, "the cursed ring is now equipped") + ok(not contains(game.log:last(), "cursed"), "the equip message never spoils an unrevealed curse") +end + +function tests.gameRemoveCurseScrollLiftsEveryCurseCurrentlyWorn() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + game.player.inventory = { Items.scroll("removecurse") } + ok(game:useItem(1), "reading the scroll succeeds") + eq(Curses.isCursed(game.player.ring), false, "the worn ring's curse is lifted") + ok(contains(game.log:last(), "lifts"), "the log reports the cleansing") +end + +function tests.gameHungerTorchTickBleedsHpFromACursedRingEachTurn() + local game = Game.new(1) + game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before - 1, "a cursed ring's affliction bleeds its fixed drain each turn") + ok(contains(game.log:last(), "saps"), "the drain is announced in the log") +end + +function tests.shrinePrayFortuneBlessingAlsoLiftsAnyCurseCurrentlyWorn() + local player = shrinePlayer(50) + player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.FORTUNE, uses = 0 } + local success, lines = Shrines.pray(game, shrine) + eq(success, true, "the prayer resolves") + eq(Curses.isCursed(player.ring), false, "fortune's blessing lifts the worn ring's curse too") + ok(contains(lines[2], "cleanses"), "a lifted curse counts toward the cleansing message") +end + +function tests.renderPanelPrefixesCursedGearOnceRevealedButNotBefore() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]) + ok(not contains(Render.panel(game), "cursed"), "an unrevealed curse never shows in the status panel") + Curses.reveal(game.player.weapon) + ok(contains(Render.panel(game), "cursed dagger"), "a revealed curse is flagged in the status panel") +end + +function tests.builderOccasionallyPlacesACursedPieceOfGear() + local found + for seed = 1, 80 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.cursed then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a cursed piece of gear eventually turns up across a handful of seeds") + ok(found.cursePenalty ~= nil or found.curseDrain ~= nil, "a cursed drop always carries a concrete penalty") +end + +function tests.builderNeverCursesAnItemThatAlreadyHasABeneficialAffix() + for seed = 1, 30 do + for depth = 1, 5 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.affix then + ok(not ground.item.cursed, "an item with a beneficial affix is never also cursed") + end + end + end + end +end + +function tests.cursedFieldsSurviveSerialization() + local game = Game.new(3) + game.player.ring = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) + Curses.reveal(game.player.ring) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.ring.cursed, true, "a cursed ring's flag survives a save/load round trip") + eq(restored.player.ring.curseDrain, 1, "a cursed ring's drain survives a save/load round trip") + eq(restored.player.ring.curseRevealed, true, "a revealed curse stays revealed after reload") +end + +function tests.momentumStacksReadsZeroForAFreshEntity() + eq(Momentum.stacks({}), 0, "an entity with no combo field reads as zero stacks") +end + +function tests.momentumOnMeleeHitBuildsAndCapsAtItsMaxStacks() + local entity = {} + for i = 1, Momentum.MAX_STACKS do + Momentum.onMeleeHit(entity) + eq(Momentum.stacks(entity), i, "stack " .. i .. " builds one at a time") + end + Momentum.onMeleeHit(entity) + eq(Momentum.stacks(entity), Momentum.MAX_STACKS, "stacks never climb past the cap") +end + +function tests.momentumOnDamagedSnapsTheStackBackToZero() + local entity = { combo = 3 } + Momentum.onDamaged(entity) + eq(Momentum.stacks(entity), 0, "taking a hit clears the whole stack outright") +end + +function tests.momentumAttackBonusScalesLinearlyWithStacks() + local entity = {} + eq(Momentum.attackBonus(entity), 0, "no stacks means no bonus") + Momentum.onMeleeHit(entity) + Momentum.onMeleeHit(entity) + eq(Momentum.attackBonus(entity), 2 * Momentum.BONUS_PER_STACK, "the bonus scales with the current stack count") +end + +function tests.entityEffectiveAttackFoldsInMomentumAlongsideStatusModifiers() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + eq(entity:effectiveAttack(), 6, "a fresh entity's effective attack is just its base") + Momentum.onMeleeHit(entity) + Momentum.onMeleeHit(entity) + eq(entity:effectiveAttack(), 8, "two momentum stacks add their flat bonus on top of base attack") +end + +function tests.gamePlayerAttackBuildsMomentumAndItReadsLiveInEffectiveAttack() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 100, 100 + game.level.entities = { orc } + eq(game.player:effectiveAttack(), game.player.attack, "a fresh player carries no momentum bonus") + game:playerAttack(orc) + eq(game.player.combo, 1, "a landed melee hit builds one stack") + eq(game.player:effectiveAttack(), game.player.attack + 1, + "the very next read of effectiveAttack already reflects the new stack") +end + +function tests.gameMonsterMeleeSnapsMomentumBackToZero() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.combo = 4 + local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) + game:monsterMelee(orc) + eq(game.player.combo, 0, "getting hit by a monster's melee resets momentum to zero") +end + +function tests.gameEvadedMonsterAttackNeverBreaksMomentum() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.skills = { evasion = true } + local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) + local sawEvasion = false + for seed = 1, 40 do + game.rng = Rng.new(seed) + game.player.combo = 3 + game:monsterMelee(orc) + if game.player.combo == 3 then sawEvasion = true break end + end + ok(sawEvasion, "an evaded attack never reaches Combat.attack, so momentum survives it") +end + +function tests.gameMimicAmbushAlsoBreaksMomentum() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.combo = 2 + game.level.floorItems = { { x = 5, y = 5, item = Mimics.floorItem(Rng.new(1)) } } + game:pickupHere() + eq(game.player.combo, 0, "a mimic's ambush hit also breaks momentum") +end + +function tests.renderPanelShowsComboOnlyOnceItIsBuilt() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "combo"), "a fresh player with no momentum shows no combo line") + game.player.combo = 3 + ok(contains(Render.panel(game), "combo x3"), "a built-up combo is reported in the status panel") +end + end local order = { From 607c19a95fa6a7a53ee0502f16e049f38b95e47b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 14:41:19 +0200 Subject: [PATCH 126/172] test: cover tests.momentumFieldSurvivesSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 7aec43c..142cddb 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7568,6 +7568,179 @@ function tests.renderPanelShowsComboOnlyOnceItIsBuilt() ok(contains(Render.panel(game), "combo x3"), "a built-up combo is reported in the status panel") end +function tests.momentumFieldSurvivesSerialization() + local game = Game.new(3) + game.player.combo = 4 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.combo, 4, "the player's momentum stack survives a save/load round trip") +end + +function tests.conductivityIsWetReadsTheEntitysOwnWetField() + eq(Conductivity.isWet({}), false, "an entity with no wet field at all reads as dry") + eq(Conductivity.isWet({ wet = 0 }), false, "a wet counter already run down to zero reads as dry") + eq(Conductivity.isWet({ wet = 1 }), true, "any positive wet counter reads as wet") +end + +function tests.conductivityTickSoaksOnWaterAndDecaysOneStepAtATimeOffIt() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.WATER) + local entity = { x = 6, y = 6 } + Conductivity.tick(level, entity) + eq(entity.wet, Conductivity.SOAK_DURATION, "standing on water soaks an entity to the full duration") + entity.x = 7 + for i = Conductivity.SOAK_DURATION - 1, 0, -1 do + Conductivity.tick(level, entity) + eq(entity.wet, i, "the soak counter decays by exactly one per turn spent off water") + end + eq(Conductivity.isWet(entity), false, "the counter bottoms out at zero rather than going negative") +end + +function tests.conductivityTickReSoaksImmediatelyOnSteppingBackOntoWater() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.WATER) + local entity = { x = 6, y = 6, wet = 1 } + Conductivity.tick(level, entity) + eq(entity.wet, Conductivity.SOAK_DURATION, + "stepping back onto water refreshes the counter to full rather than adding to it") +end + +function tests.conductivityApplyFireDampensDamageOnlyOnceSoaked() + eq(Conductivity.applyFire({}, 10), 10, "fire damage against a dry entity is untouched") + eq(Conductivity.applyFire({ wet = 2 }, 10), 5, + "fire damage against a soaked entity is halved on top of its resistance tier") +end + +function tests.conductivityApplyLightningBonusesDamageOnlyOnceSoaked() + eq(Conductivity.applyLightning({}, 10), 10, "lightning damage against a dry entity is untouched") + eq(Conductivity.applyLightning({ wet = 2 }, 10), 15, + "lightning damage against a soaked entity gets a 50 percent conductivity bonus") +end + +function tests.conductivityNeverConjuresDamageOutOfAnImmuneResistance() + eq(Conductivity.applyFire({ wet = 2, resist = { fire = Resistances.IMMUNE } }, 10), 0, + "fire immunity still floors damage at zero even while soaked") + eq(Conductivity.applyLightning({ wet = 2, resist = { lightning = Resistances.IMMUNE } }, 10), 0, + "lightning immunity still floors damage at zero even while soaked") +end + +function tests.conductivityResistsIgnitionOnlyWhileWet() + eq(Conductivity.resistsIgnition({}), false, "a dry entity does not resist ignition") + eq(Conductivity.resistsIgnition({ wet = 3 }), true, "a soaked entity resists catching fire") +end + +function tests.statusApplyRefusesBurningOnlyWhileSoakedButOtherEffectsStillApply() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + entity.wet = 2 + local result = Status.apply(entity, Status.BURNING, 3, 2) + eq(result, nil, "applying burning to a soaked entity is refused outright") + eq(Status.has(entity, Status.BURNING), false, "a refused burning application leaves no trace on the entity") + Status.apply(entity, Status.POISON, 3, 2) + eq(Status.has(entity, Status.POISON), true, "a soaked entity still catches every other status effect normally") + entity.wet = 0 + Status.apply(entity, Status.BURNING, 3, 2) + eq(Status.has(entity, Status.BURNING), true, "once dry again, the same entity catches fire normally") +end + +function tests.statusRemoveStripsANamedStatusRegardlessOfItsRemainingDuration() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + Status.apply(entity, Status.POISON, 99, 2) + eq(Status.remove(entity, Status.POISON), true, "removing a status that is present reports success") + eq(Status.has(entity, Status.POISON), false, "the removed status no longer reads as active") + eq(Status.remove(entity, Status.POISON), false, "removing an already-absent status reports nothing to do") + eq(Status.remove(entity, Status.CONFUSE), false, "removing a status that was never applied at all also reports nothing to do") +end + +function tests.statusCureHostileLiftsEveryDebuffButLeavesBuffsUntouched() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + Status.apply(entity, Status.POISON, 5, 2) + Status.apply(entity, Status.CONFUSE, 6, 1) + Status.apply(entity, Status.STUN, 2, 1) + Status.apply(entity, Status.STRENGTH, 30, 3) + Status.apply(entity, Status.SHIELD, 15, 1) + local lifted = Status.cureHostile(entity) + eq(#lifted, 3, "cureHostile reports exactly the three debuffs it lifted") + eq(Status.has(entity, Status.POISON), false, "poison is gone after cureHostile") + eq(Status.has(entity, Status.CONFUSE), false, "confuse is gone after cureHostile") + eq(Status.has(entity, Status.STUN), false, "stun is gone after cureHostile") + ok(Status.has(entity, Status.STRENGTH), "a buff like strength survives cureHostile untouched") + ok(Status.has(entity, Status.SHIELD), "a buff like shield survives cureHostile untouched") + eq(#Status.cureHostile(entity), 0, "calling cureHostile again with nothing hostile left returns an empty list") +end + +function tests.hazardsAfflictDampensFireAndBlocksIgnitionForASoakedVictim() + local level = arena(21, 11) + Hazards.ignite(level, 5, 5, 3, 4) + local victim = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + victim.wet = 2 + local hpBefore = victim.hp + local line = Hazards.afflict(level, victim) + eq(victim.hp, hpBefore - 2, "standing in fire while soaked only deals half the usual potency") + ok(contains(line, "soaked"), "the afflict message calls out the victim's own soaked skin") + eq(Status.has(victim, Status.BURNING), false, "a soaked victim standing in fire never actually catches alight") +end + +function tests.gameConductivityTicksAMonsterEachTurnBasedOnTheTileUnderfoot() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.WATER) + local game = stagedGame(1, level, 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + game:takeMonsterTurn(rat) + eq(rat.wet, Conductivity.SOAK_DURATION, "a monster ending its turn on water is soaked to the full duration") + rat.x, rat.y = 7, 6 + game:takeMonsterTurn(rat) + eq(rat.wet, Conductivity.SOAK_DURATION - 1, "moving off water the following turn lets the soak counter decay by one") +end + +function tests.gameConductivityTicksThePlayerEachAutoStepBasedOnTheTileUnderfoot() + local level = arena(21, 11) + level.map:set(3, 6, Tiles.WATER) + local game = stagedGame(1, level, 3, 6) + game:autoStep() + eq(game.player.wet, Conductivity.SOAK_DURATION, "the player ending a turn on water is soaked to the full duration") +end + +function tests.spellShockBoltDealsMoreDamageToASoakedTargetThanADryOne() + local dryGame = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(dryGame.player, "shock") + local dry = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + dry.hp, dry.maxHp = 100, 100 + dryGame.level.entities = { dry } + local castOk = dryGame:castSpell("shock", dry) + ok(castOk, "casting a learned, in-range shock bolt succeeds") + local dryDealt = 100 - dry.hp + ok(dryDealt > 0, "shock bolt deals real lightning damage to a dry target") + + local wetGame = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(wetGame.player, "shock") + local soaked = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + soaked.hp, soaked.maxHp = 100, 100 + soaked.wet = 2 + wetGame.level.entities = { soaked } + wetGame:castSpell("shock", soaked) + local wetDealt = 100 - soaked.hp + eq(wetDealt, dryDealt + dryDealt // 2, + "shock bolt deals exactly its 50 percent conductivity bonus against a soaked target") + ok(contains(wetGame.log:last(), "water clinging"), + "casting shock bolt on a soaked target reports the conductivity through water") +end + +function tests.renderPanelShowsWetOnlyWhileSoaked() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "wet"), "a fresh dry player shows no wet line") + game.player.wet = 2 + ok(contains(Render.panel(game), "wet (2)"), "a soaked player's status panel reports the remaining soak turns") +end + +function tests.builderCanRollATomeOfTheShockBoltSpell() + -- Range widened from 100 to 200: adding "cleanse" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed, and the nearest shock tome under the new list happens to sit at + -- seed 174 - still well within "a reasonable seed range". + local found end local order = { From 7679ba954ec40a5552dcd5f8fd9cbb7824830ae7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 15:50:15 +0200 Subject: [PATCH 127/172] test: cover tests.entityWetFieldSurvivesSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 142cddb..0e59bbc 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7741,6 +7741,179 @@ function tests.builderCanRollATomeOfTheShockBoltSpell() -- seed, and the nearest shock tome under the new list happens to sit at -- seed 174 - still well within "a reasonable seed range". local found + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "shock" then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "the shock bolt tome is reachable dungeon loot within a reasonable seed range") + eq(found.name, "tome of shock", "an unnamed tome defaults its label to the spell key") +end + +function tests.entityWetFieldSurvivesSerialization() + local game = Game.new(3) + game.player.wet = 2 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.wet, 2, "the player's soak counter survives a save/load round trip") +end + +function tests.diggingCanDigAllowsOnlyWallsAndRubble() + local level = arena(21, 11) + ok(Digging.canDig(level.map, 1, 5), "the arena's own border wall is diggable") + ok(not Digging.canDig(level.map, 5, 5), "open floor has nothing left to dig") + level.map:set(5, 5, Tiles.RUBBLE) + ok(Digging.canDig(level.map, 5, 5), "rubble left by a prior swing is still diggable") + level.map:set(5, 5, Tiles.VAULT_DOOR) + ok(not Digging.canDig(level.map, 5, 5), "a vault's own boundary never yields to a pick") + ok(not Digging.canDig(level.map, 0, 5), "a cell outside the map is never diggable") +end + +function tests.diggingSwingClearsAWallToRubbleOnAnOrdinarySwing() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 5, Tiles.WALL) + game.rng = Rng.new(1) + local line = Digging.swing(game, game.player, 5, 5) + eq(game.level.map:get(5, 5), Tiles.RUBBLE, "an ordinary swing crumbles a wall to rubble, not clear") + ok(contains(line, "carve"), "the log line describes carving through rock") +end + +function tests.diggingSwingClearsRubbleToFloorOnASecondSwing() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 5, Tiles.RUBBLE) + game.rng = Rng.new(1) + local line = Digging.swing(game, game.player, 5, 5) + eq(game.level.map:get(5, 5), Tiles.FLOOR, "a second swing clears rubble the rest of the way to open floor") + ok(contains(line, "clear"), "the log line describes clearing rubble away") +end + +function tests.diggingSwingCaveInHurtsTheDiggerAndLeavesTheTileUntouched() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 5, Tiles.WALL) + game.rng = Rng.new(6) + local hpBefore = game.player.hp + local line = Digging.swing(game, game.player, 5, 5) + eq(game.level.map:get(5, 5), Tiles.WALL, "a cave-in wastes the swing; the wall is untouched") + ok(game.player.hp < hpBefore, "a cave-in deals real damage to the digger") + ok(contains(line, "buries you"), "the log line names the player directly, not by their entity name") +end + +function tests.diggingSwingOreStrikeClearsTheWallOutrightAndPaysEssenceToATrackingDigger() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 5, Tiles.WALL) + game.rng = Rng.new(2) + local essenceBefore = game.player.essence + local line = Digging.swing(game, game.player, 5, 5) + eq(game.level.map:get(5, 5), Tiles.FLOOR, "a lucky ore strike clears the wall in a single stroke") + ok(game.player.essence > essenceBefore, "striking a vein pays essence to a digger that tracks it") + ok(contains(line, "essence"), "the log line reports the essence payout") +end + +function tests.diggingSwingOreStrikeSkipsTheEssenceRewardForADiggerThatDoesNotTrackIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 5, Tiles.WALL) + local burrower = Entity.new({ id = 1, x = 4, y = 5, glyph = "b", name = "rock burrower", kind = "monster", hp = 12, attack = 4, defense = 2 }) + game.rng = Rng.new(2) + local line = Digging.swing(game, burrower, 5, 5) + eq(game.level.map:get(5, 5), Tiles.FLOOR, "the same lucky strike still clears the wall for a monster") + eq(burrower.essence, nil, "a monster never gains an essence field it never had") + ok(contains(line, "vein"), "the log line still names the vein even without a payout") +end + +function tests.diggingWearPickDepletesDurabilityAndBreaksAtZero() + local entity = { pick = Items.pick("test pick", 2) } + eq(Digging.wearPick(entity), nil, "the first swing just wears the edge down") + eq(entity.pick.uses, 1, "one use is spent") + local brokenLine = Digging.wearPick(entity) + ok(contains(brokenLine, "crumbles"), "the swing that empties the pick reports it breaking") + eq(entity.pick, nil, "a spent pick is discarded outright") +end + +function tests.diggingWearPickIsANoOpWithoutAPickEquipped() + local entity = {} + eq(Digging.wearPick(entity), nil, "wearing down a pick that was never equipped changes nothing") +end + +function tests.diggingDirectionsTowardPrefersTheLongerAxisFirst() + local dirs = Digging.directionsToward(10, 10, 15, 12) + eq(#dirs, 2, "a diagonal target yields both an x and a y candidate") + eq(dirs[1][1], 1, "the longer x delta is offered first") + eq(dirs[1][2], 0, "the first candidate is a pure x step") + eq(dirs[2][1], 0, "the second candidate is a pure y step") + eq(dirs[2][2], 1, "it steps toward the smaller but still positive y delta") + + local vertical = Digging.directionsToward(10, 10, 11, 5) + eq(vertical[1][1], 0, "when y dominates, the y step comes first") + eq(vertical[1][2], -1, "stepping toward a smaller y is a negative step") +end + +function tests.diggingDirectionsTowardOmitsAZeroDelta() + local dirs = Digging.directionsToward(10, 10, 15, 10) + eq(#dirs, 1, "a purely horizontal target yields only one candidate") + eq(dirs[1][1], 1, "the sole candidate steps toward positive x") + eq(dirs[1][2], 0, "the sole candidate carries no y component") + + local same = Digging.directionsToward(10, 10, 10, 10) + eq(#same, 0, "already standing on the target yields no candidate at all") +end + +function tests.itemsEquipPickSwapsAnyPriorPickBackIntoInventory() + local entity = { inventory = {}, pick = Items.pick("worn pick", 3) } + local fresh = Items.pick("fresh pick", 20) + Items.equipPick(fresh, entity) + eq(entity.pick, fresh, "the freshly equipped pick becomes the active one") + eq(#entity.inventory, 1, "the previously equipped pick drops back into the inventory") + eq(entity.inventory[1].name, "worn pick", "the displaced pick is the one that was equipped before") +end + +function tests.shopPicksHaveAPositiveCatalogPrice() + ok(Shop.priceOf(Items.pick()) > 0, "a miner's pick has a positive catalog price") +end + +function tests.gameDigFailsWithoutAPickEquipped() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.WALL) + local moved = game:dig(1, 0) + eq(moved, false, "digging without a pick equipped fails outright") + eq(game.level.map:get(4, 6), Tiles.WALL, "the wall is untouched without a pick") + ok(contains(game.log:last(), "nothing to dig with"), "the log explains why") +end + +function tests.gameDigFailsWhenNothingAheadIsDiggable() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.pick = Items.pick() + local moved = game:dig(1, 0) + eq(moved, false, "there is nothing to dig into open floor") + ok(contains(game.log:last(), "nothing here to dig"), "the log explains why") +end + +function tests.gameDigClearsAWallAndWearsThePickDown() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.WALL) + game.player.pick = Items.pick("miner's pick", 5) + game.rng = Rng.new(1) + local moved = game:dig(1, 0) + eq(moved, true, "a swing against a wall with a pick readied succeeds") + eq(game.level.map:get(4, 6), Tiles.RUBBLE, "the wall crumbles to rubble") + eq(game.player.pick.uses, 4, "the swing spends one use of the pick's durability") +end + +function tests.gameDigBreaksThePickOnItsLastUse() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.WALL) + game.player.pick = Items.pick("worn pick", 1) + game.rng = Rng.new(1) + game:dig(1, 0) + eq(game.player.pick, nil, "the pick's last use breaks it outright") + ok(contains(game.log:last(), "crumbles"), "the log reports the pick breaking") +end + +function tests.gameMonsterTunnelsThroughAWallToReachAndAttackThePlayer() + local level = arena(21, 11) end local order = { From 0db5c1683df2d863d49f4ce158a307e385f0885d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 16:31:36 +0200 Subject: [PATCH 128/172] test: cover tests.pickFieldSurvivesSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 0e59bbc..87e8c6f 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7914,6 +7914,179 @@ end function tests.gameMonsterTunnelsThroughAWallToReachAndAttackThePlayer() local level = arena(21, 11) + for y = 2, 10 do level.map:set(10, y, Tiles.WALL) end + local game = stagedGame(1, level, 8, 6) + local burrower = Entity.new({ + id = 1, x = 12, y = 6, glyph = "b", name = "rock burrower", kind = "monster", + ai = "tunnel", hp = 20, maxHp = 20, attack = 5, defense = 0, speed = 100, + }) + game.level.entities = { burrower } + local hpBefore = game.player.hp + local sawRubble = false + for _ = 1, 10 do + game.rng = Rng.new(1) + Ai.act(game, burrower) + if game.level.map:get(10, 6) ~= Tiles.WALL then sawRubble = true end + if game.player.hp < hpBefore then break end + end + ok(sawRubble, "the burrower actually carves through the partition wall") + ok(game.player.hp < hpBefore, "the burrower eventually reaches and melees the player through its own tunnel") +end + +function tests.pickFieldSurvivesSerialization() + local game = Game.new(3) + game.player.pick = Items.pick("saved pick", 7) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.pick.name, "saved pick", "the equipped pick's name survives a save/load round trip") + eq(restored.player.pick.uses, 7, "the equipped pick's remaining durability survives a save/load round trip") +end + +function tests.builderNeverPlacesAPickBeforeDepthTwo() + for seed = 1, 20 do + local level = Builder.build(1, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.PICK, "a pick never turns up on the entrance floor") + end + end +end + +function tests.builderOccasionallyPlacesAPickFromDepthTwo() + local level = Builder.build(2, Rng.new(6), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.PICK then found = ground.item end + end + ok(found ~= nil, "this seed's depth-2 floor places a miner's pick") + ok(Shop.priceOf(found) > 0, "the placed pick still carries a positive catalog price") +end + +function tests.sanityCurrentDefaultsToMaxForAFreshEntity() + eq(Sanity.current({}), Sanity.MAX, "an entity with no sanity field reads as fully composed") +end + +function tests.sanityTierOfClassifiesEachBandAtItsBoundaries() + eq(Sanity.tierOf({ sanity = 100 }), Sanity.COMPOSED, "a full meter is composed") + eq(Sanity.tierOf({ sanity = 61 }), Sanity.COMPOSED, "just above the shaken ceiling is still composed") + eq(Sanity.tierOf({ sanity = 60 }), Sanity.SHAKEN, "the shaken ceiling itself reads as shaken") + eq(Sanity.tierOf({ sanity = 31 }), Sanity.SHAKEN, "just above the terrified ceiling is still shaken") + eq(Sanity.tierOf({ sanity = 30 }), Sanity.TERRIFIED, "the terrified ceiling itself reads as terrified") + eq(Sanity.tierOf({ sanity = 1 }), Sanity.TERRIFIED, "one point above zero is still terrified, never broken") + eq(Sanity.tierOf({ sanity = 0 }), Sanity.BROKEN, "zero itself is broken") +end + +function tests.sanityAdjustClampsToTheZeroAndMaxBounds() + local entity = { sanity = 95 } + eq(Sanity.adjust(entity, 30), Sanity.MAX, "adjusting past the ceiling clamps to max") + eq(Sanity.adjust(entity, -1000), 0, "adjusting far past the floor clamps to zero") + eq(Sanity.adjust(entity, 5), 5, "an ordinary adjustment lands exactly where expected") +end + +function tests.sanityAttackAndDefensePenaltiesEscalateByTier() + eq(Sanity.attackPenalty({ sanity = 100 }), 0, "a composed mind carries no attack penalty") + eq(Sanity.attackPenalty({ sanity = 60 }), -1, "shaken costs a point of attack") + eq(Sanity.attackPenalty({ sanity = 30 }), -3, "terrified costs three points of attack") + eq(Sanity.attackPenalty({ sanity = 0 }), -5, "broken costs five points of attack") + eq(Sanity.defensePenalty({ sanity = 100 }), 0, "a composed mind carries no defense penalty") + eq(Sanity.defensePenalty({ sanity = 60 }), -1, "shaken costs a point of defense") + eq(Sanity.defensePenalty({ sanity = 30 }), -2, "terrified costs two points of defense") + eq(Sanity.defensePenalty({ sanity = 0 }), -4, "broken costs four points of defense") +end + +function tests.entityEffectiveAttackAndDefenseFoldInTheSanityPenalty() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 4 }) + eq(entity:effectiveAttack(), 6, "a fresh entity with no sanity field carries no penalty") + entity.sanity = 0 + eq(entity:effectiveAttack(), 1, "a broken mind's attack penalty is live in effectiveAttack") + eq(entity:effectiveDefense(), 0, "a broken mind's defense penalty is live in effectiveDefense") +end + +function tests.sanityDarknessDreadFiresOnlyOnADarkFloorWithASpentTorch() + local dark = stagedGame(1, arenaDark(21, 11), 3, 6) + dark.player.light.fuel = 0 + eq(Sanity.darknessDread(dark), Sanity.DARKNESS_DREAD, "a dark floor with a spent torch dreads the mind") + dark.player.light.fuel = 50 + eq(Sanity.darknessDread(dark), 0, "a lit torch on a dark floor holds the dread off entirely") + local lit = stagedGame(1, arena(21, 11), 3, 6) + lit.player.light.fuel = 0 + eq(Sanity.darknessDread(lit), 0, "an ordinary floor never dreads the mind regardless of torch fuel") +end + +function tests.sanityCorpseDreadCountsOnlyCorpsesAdjacentToThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.corpses = { { x = 2, y = 5 } } + eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_PER, "one adjacent corpse contributes its fixed share") + game.level.corpses[#game.level.corpses + 1] = { x = 10, y = 6 } + eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_PER, "a corpse well outside arm's reach adds nothing") +end + +function tests.sanityCorpseDreadCapsAtItsFixedCeiling() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.corpses = { + { x = 2, y = 5 }, { x = 2, y = 6 }, { x = 2, y = 7 }, { x = 3, y = 5 }, { x = 3, y = 7 }, + } + eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_CAP, "five adjacent corpses still cap at the fixed ceiling") +end + +function tests.sanityUndeadDreadCountsOnlyVisibleUndeadAndCapsAtItsCeiling() + local game = stagedGame(1, arena(21, 11), 3, 6) + local zombie = Monsters.create(Monsters.byKey.zombie, 1, 4, 6, 1) + game.level.entities = { zombie } + eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_PER, "one visible undead contributes its fixed share") + game.visible = {} + eq(Sanity.undeadDread(game), 0, "an undead outside the player's sight adds nothing") + game:computeVisible() + local orc = Monsters.create(Monsters.byKey.orc, 2, 5, 6, 1) + game.level.entities[#game.level.entities + 1] = orc + eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_PER, "a visible living monster never counts as undead dread") + for i = 3, 6 do + game.level.entities[#game.level.entities + 1] = Monsters.create(Monsters.byKey.zombie, i, 6, 6, 1) + end + eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_CAP, "a crowd of visible undead still caps at the fixed ceiling") +end + +function tests.sanityCurseDreadFiresOnlyForARevealedCurse() + local player = { ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) } + eq(Sanity.curseDread(player), 0, "a curse still hidden behind an ordinary appearance dreads nothing") + Curses.reveal(player.ring) + eq(Sanity.curseDread(player), Sanity.CURSE_DREAD, "a revealed curse gnaws at the mind") +end + +function tests.sanityWeatherDreadFiresOnlyForInfestedWeather() + eq(Sanity.weatherDread(Atmosphere.INFESTED), Sanity.WEATHER_DREAD, "an infested floor dreads the mind") + eq(Sanity.weatherDread(Atmosphere.FOGGY), 0, "a merely foggy floor is not itself dreadful") + eq(Sanity.weatherDread(nil), 0, "a clear floor carries no weather dread") +end + +function tests.sanityTotalDreadSumsEverySourceAtOnce() + local dark = stagedGame(1, arenaDark(21, 11), 3, 6) + dark.player.light.fuel = 0 + dark.level.weather = Atmosphere.INFESTED + dark.level.corpses = { { x = 2, y = 5 } } + eq(Sanity.totalDread(dark), Sanity.DARKNESS_DREAD + Sanity.WEATHER_DREAD + Sanity.CORPSE_DREAD_PER, + "every currently-active dread source is summed at once") +end + +function tests.sanityTickAppliesDreadAndLogsOnlyOnATierCrossing() + local game = stagedGame(1, arenaDark(21, 11), 3, 6) + game.player.light.fuel = 0 + game.player.sanity = 63 + eq(Sanity.tick(game), nil, "a dread turn that stays within its tier reports no line") + eq(game.player.sanity, 61, "the dread was still deducted even without a tier crossing") + eq(Sanity.tick(game), "your nerves begin to fray", "crossing down into shaken is announced") + eq(game.player.sanity, 59, "the meter keeps draining turn over turn") +end + +function tests.sanityTickAnnouncesReachingTerrifiedAndBroken() + local game = stagedGame(1, arenaDark(21, 11), 3, 6) + game.player.light.fuel = 0 + game.player.sanity = 32 + eq(Sanity.tick(game), "dread claws at the edges of your sanity", "crossing down into terrified is announced") + game.player.sanity = 2 + eq(Sanity.tick(game), "your mind gives way to raw terror", "crossing down into broken is announced") + eq(game.player.sanity, 0, "broken is a floor, not a negative value") end local order = { From 1a617008d4d6afad6e1dbb72a8d2caca891649ce Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 17:37:50 +0200 Subject: [PATCH 129/172] test: cover tests.sanityTickRecoversAndAnnouncesClimbingBackOutOfBroken --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 87e8c6f..1491f82 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8089,6 +8089,179 @@ function tests.sanityTickAnnouncesReachingTerrifiedAndBroken() eq(game.player.sanity, 0, "broken is a floor, not a negative value") end +function tests.sanityTickRecoversAndAnnouncesClimbingBackOutOfBroken() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 0 + eq(Sanity.tick(game), "your nerves start to steady", + "recovering out of broken is announced as steadying, never as fresh dread") + eq(game.player.sanity, 1, "a calm turn only ever recovers its small fixed amount") +end + +function tests.sanityTickAnnouncesFullyRegainingComposure() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 60 + eq(Sanity.tick(game), "your mind is your own again", "climbing back above the shaken ceiling is announced") +end + +function tests.sanityTickNeverClimbsPastTheMaxOrReportsAPhantomCrossing() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 100 + eq(Sanity.tick(game), nil, "an already-composed mind with nothing to recover reports no line") + eq(game.player.sanity, 100, "recovery never climbs the meter past its own maximum") +end + +function tests.sanityRollPanicNeverFiresAboveBroken() + local entity = { sanity = 1 } + for seed = 1, 50 do + ok(not Sanity.rollPanic(Rng.new(seed), entity), "terrified never risks losing the turn to panic") + end +end + +function tests.sanityRollPanicRollsAgainstItsFixedChanceOnceBroken() + local entity = { sanity = 0 } + local sawPanic, sawComposure = false, false + for seed = 1, 50 do + if Sanity.rollPanic(Rng.new(seed), entity) then sawPanic = true else sawComposure = true end + end + ok(sawPanic, "broken panics on at least some rolls across a handful of seeds") + ok(sawComposure, "broken does not panic on every single roll") +end + +function tests.sanityRollScreamNeverFiresAboveTerrified() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 31 + for seed = 1, 50 do + game.rng = Rng.new(seed) + ok(not Sanity.rollScream(game.rng, game), "merely shaken never risks an involuntary scream") + end +end + +function tests.sanityRollScreamAlertsEveryDormantMonsterWithinItsRadius() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 0 + local zombie = Monsters.create(Monsters.byKey.zombie, 1, 5, 6, 1) + zombie.awareness = Stealth.DORMANT + game.level.entities = { zombie } + local triggered = false + for seed = 1, 80 do + game.rng = Rng.new(seed) + if Sanity.rollScream(game.rng, game) then triggered = true break end + end + ok(triggered, "a broken mind eventually screams across a handful of seeds") + eq(zombie.awareness, Stealth.HUNTING, "the scream startles a nearby dormant monster fully awake") + eq(zombie.lastKnownX, game.player.x, "the alerted monster now knows exactly where the scream came from") +end + +function tests.sanityFieldSurvivesSerialization() + local game = Game.new(3) + game.player.sanity = 37 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.sanity, 37, "the player's sanity survives a save/load round trip") +end + +function tests.gameEndPlayerTurnTicksSanityAndTheLogReportsATierCrossing() + local game = stagedGame(1, arenaDark(21, 11), 3, 6) + game.player.light.fuel = 0 + game.player.sanity = 63 + game:endPlayerTurn() + eq(game.player.sanity, 61, "ending a turn on a dark, torchless floor drains the fixed darkness dread") + game:endPlayerTurn() + ok(contains(game.log:last(), "fray"), "crossing into shaken is reported in the message log") + eq(game.player:effectiveAttack(), game.player.attack - 1, "the fresh shaken penalty is already live in effectiveAttack") +end + +function tests.gameAutoStepSkipsThePlayerTurnWhenPanicFires() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 0 + local px, py = game.player.x, game.player.y + local sawPanic = false + for seed = 1, 80 do + game.rng = Rng.new(seed) + game.player.x, game.player.y = px, py + game.player.sanity = 0 + -- endPlayerTurn's own Sanity.tick line (recovery or otherwise) always + -- lands after any panic line within the same turn, so the panic line + -- itself has to be found among the turn's recent lines rather than + -- assumed to be the very last one. + game:autoStep() + if contains(table.concat(game.log:recent(3), " "), "terror seizes you") then sawPanic = true break end + end + ok(sawPanic, "a broken mind eventually loses a whole turn to panic across a handful of seeds") + eq(game.player.x, px, "a panicked turn never moves the player") + eq(game.player.y, py, "a panicked turn never moves the player") +end + +function tests.gameUseItemPotionOfClarityRestoresSanity() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.sanity = 40 + game.player.inventory = { Items.potionOf("clarity", 30) } + ok(game:useItem(1), "drinking the potion of clarity succeeds") + eq(game.player.sanity, 70, "clarity restores its fixed power to the drinker's sanity") + ok(contains(game.log:last(), "steadies"), "the log reports the mind steadying") +end + +function tests.renderPanelShowsSanityOnlyOnceItDropsBelowComposed() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "sanity"), "a fully composed player shows no sanity line at all") + game.player.sanity = 30 + ok(contains(Render.panel(game), "sanity 30/100 (terrified)"), "a shaken mind is reported in the status panel") +end + +-- Disease used to be invisible outside the transient log lines Disease.tick +-- emits (the eruption, a damage tick, an escalation) - once one of those +-- scrolled off the log, a player mid-incubation or mid-plague had no +-- persistent way to check their own infection at all, unlike every timed +-- status statusTags already folds into this same line. +function tests.renderPanelShowsTheDiseaseStageOnceThePlayerIsInfectedAndNothingBeforeInfection() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "parasites"), "an uninfected player's status line mentions no disease") + Disease.infect(game.player, Disease.PARASITES) + ok(contains(Render.panel(game), "gut parasites(incubating)"), + "an incubating infection shows up in the status panel even before it erupts") + game.player.disease.stage = 2 + ok(contains(Render.panel(game), "gut parasites(stage 2)"), + "an erupted infection reports its current stage in the status panel") +end + +function tests.builderOccasionallyRollsAPotionOfClarityAsLoot() + local found + for seed = 1, 200 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.POTION and ground.item.effect == "clarity" then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a potion of clarity eventually turns up in the depth-2 loot table across a handful of seeds") + ok(Shop.priceOf(found) > 0, "a found clarity potion still carries a positive catalog price") +end + +function tests.builderOccasionallyRollsAPotionOfConfusionAsLoot() + local found + for seed = 1, 200 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.POTION and ground.item.effect == "confuse" then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a potion of confusion eventually turns up in the depth-2 loot table across a handful of seeds") + ok(Shop.priceOf(found) > 0, "a found confusion potion still carries a positive catalog price") +end + +function tests.builderOccasionallyRollsAPotionOfBlindnessAsLoot() + local found + for seed = 1, 200 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.POTION and ground.item.effect == "blind" then found = ground.item end + end + if found then break end +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From 91171be9e163d4115c59f549d12fbc7988f4935f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:05:46 +0200 Subject: [PATCH 130/172] test: cover tests.builderOccasionallyRollsAPotionOfLevitationAsLoot --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 1491f82..7ee1b90 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8260,6 +8260,179 @@ function tests.builderOccasionallyRollsAPotionOfBlindnessAsLoot() if ground.item.kind == Items.POTION and ground.item.effect == "blind" then found = ground.item end end if found then break end + end + ok(found ~= nil, "a potion of blindness eventually turns up in the depth-2 loot table across a handful of seeds") + ok(Shop.priceOf(found) > 0, "a found blindness potion still carries a positive catalog price") +end + +function tests.builderOccasionallyRollsAPotionOfLevitationAsLoot() + local found + for seed = 1, 200 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.POTION and ground.item.effect == "levitate" then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a potion of levitation eventually turns up in the depth-2 loot table across a handful of seeds") + ok(Shop.priceOf(found) > 0, "a found levitation potion still carries a positive catalog price") +end + +function tests.eventsCatalogEntriesEachHaveDistinctStartAndEndFlavorText() + for _, key in ipairs(Events.order) do + local start = Events.describe(key) + local ending = Events.endDescribe(key) + ok(start ~= nil, key .. " has a start line") + ok(ending ~= nil, key .. " has an end line") + ok(start ~= ending, key .. "'s start and end lines read differently") + end +end + +function tests.eventsMonsterDamageMultiplierOnlyFiresDuringBloodmoon() + eq(Events.monsterDamageMultiplier(nil), 1, "no active event leaves monster damage unscaled") + eq(Events.monsterDamageMultiplier({ key = Events.RICHVEINS }), 1, "a non-bloodmoon event leaves monster damage unscaled") + eq(Events.monsterDamageMultiplier({ key = Events.BLOODMOON }), Events.BLOODMOON_MULTIPLIER, + "bloodmoon scales monster damage by its fixed multiplier") +end + +function tests.eventsEssenceMultiplierOnlyFiresDuringRichVeins() + eq(Events.essenceMultiplier(nil), 1, "no active event leaves essence unscaled") + eq(Events.essenceMultiplier({ key = Events.BLOODMOON }), 1, "a non-richveins event leaves essence unscaled") + eq(Events.essenceMultiplier({ key = Events.RICHVEINS }), Events.RICHVEINS_MULTIPLIER, + "rich veins scales essence by its fixed multiplier") +end + +function tests.eventsSightPenaltyOnlyFiresDuringHush() + eq(Events.sightPenalty(nil), 0, "no active event carries no sight penalty") + eq(Events.sightPenalty({ key = Events.BLOODMOON }), 0, "a non-hush event carries no sight penalty") + eq(Events.sightPenalty({ key = Events.HUSH }), Events.HUSH_SIGHT_PENALTY, "hush carries its fixed sight penalty") +end + +function tests.eventsStepNeverStartsOnTheFirstFloorRegardlessOfTheRoll() + for seed = 1, 100 do + local level = { depth = 1 } + eq(Events.step(level, Rng.new(seed)), nil, "a depth-one floor never rolls an event") + eq(level.event, nil, "a depth-one floor's event stays nil across every seed") + end +end + +function tests.eventsStepEventuallyStartsFromDepthTwoAcrossAHandfulOfSeeds() + local started = false + for seed = 1, 400 do + local level = { depth = 2 } + if Events.step(level, Rng.new(seed)) then started = true break end + end + ok(started, "a depth-two floor eventually rolls a fresh event across a handful of seeds") +end + +function tests.eventsStepCountsDownAndExpiresWithItsEndLine() + local level = { depth = 2, event = { key = Events.BLOODMOON, turnsLeft = 2 } } + eq(Events.step(level, Rng.new(1)), nil, "an ordinary tick of a running event reports no line") + eq(level.event.turnsLeft, 1, "the running event's clock ticks down by one") + eq(Events.step(level, Rng.new(1)), Events.endDescribe(Events.BLOODMOON), + "the event's expiry is reported the turn its clock runs out") + eq(level.event, nil, "an expired event clears itself from the level") +end + +function tests.gameMonsterMeleeDamageScalesDuringBloodmoon() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local before = game.player.hp + game:monsterMelee(orc) + local plainDamage = before - game.player.hp + + game.player.hp = before + game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } + game:monsterMelee(orc) + local bloodmoonDamage = before - game.player.hp + + eq(bloodmoonDamage, math.floor(plainDamage * Events.BLOODMOON_MULTIPLIER), + "bloodmoon scales a monster's melee damage by its fixed multiplier") +end + +function tests.gameMonsterShootDamageScalesDuringBloodmoon() + local game = stagedGame(1, arena(21, 11), 3, 6) + local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) + local before = game.player.hp + game:monsterShoot(dragon) + local plainDamage = before - game.player.hp + + game.player.hp = before + game.player.statuses = {} + game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } + game:monsterShoot(dragon) + local bloodmoonDamage = before - game.player.hp + + eq(bloodmoonDamage, math.floor(plainDamage * Events.BLOODMOON_MULTIPLIER), + "bloodmoon scales a monster's ranged damage by its fixed multiplier") +end + +function tests.gameKillEssenceIsDoubledDuringRichVeins() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } + local essenceBefore = game.player.essence + game:playerAttack(rat) + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Monsters.xpFor(rat)) * Events.RICHVEINS_MULTIPLIER, + "rich veins doubles the essence earned from a kill") +end + +function tests.gameEffectiveRadiusShrinksDuringHush() + local game = stagedGame(1, arena(21, 11), 3, 6) + local plain = game:effectiveRadius() + game.level.event = { key = Events.HUSH, turnsLeft = 5 } + eq(game:effectiveRadius(), plain - Events.HUSH_SIGHT_PENALTY, "hush dulls the player's effective sight radius") +end + +function tests.gameEndPlayerTurnEventuallyRollsAFreshEventFromDepthTwo() + local triggered = false + for seed = 1, 400 do + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.depth = 2 + game.rng = Rng.new(seed) + game:endPlayerTurn() + if game.level.event then triggered = true break end + end + ok(triggered, "ending a turn on a depth-two floor eventually rolls a fresh event across a handful of seeds") +end + +function tests.gameEndPlayerTurnAnnouncesAnEventEndingInTheLog() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.event = { key = Events.RICHVEINS, turnsLeft = 1 } + game:endPlayerTurn() + eq(game.level.event, nil, "the event's clock runs out and clears itself") + ok(contains(game.log:last(), "quiet"), "the event's end is reported in the message log") +end + +function tests.renderPanelShowsTheActiveEventAndItsRemainingTurns() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "turns left"), "no active event shows no event line at all") + game.level.event = { key = Events.BLOODMOON, turnsLeft = 7 } + ok(contains(Render.panel(game), "event bloodmoon (7 turns left)"), + "an active event reports its name and remaining turns") +end + +function tests.eventFieldSurvivesSerialization() + local game = Game.new(3) + game.level.event = { key = Events.HUSH, turnsLeft = 9 } + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.level.event.key, Events.HUSH, "the active event's key survives a save/load round trip") + eq(restored.level.event.turnsLeft, 9, "the active event's remaining turns survive a save/load round trip") +end + +-- graves.lua: cross-run bones. + +function tests.gravesRecordAppendsNewestFirstAndTrimsToMaxRecorded() + local meta = Meta.new() + for i = 1, Graves.MAX_RECORDED + 5 do + Graves.record(meta, { className = "fighter", depth = i, gold = i, essence = 0, turn = i }) + end + eq(#meta.graves, Graves.MAX_RECORDED, "recording past the cap trims the oldest graves off") end local order = { From 8613a59377ddfb8abd139d118612b2da065e2e8b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:17:31 +0200 Subject: [PATCH 131/172] test: cover tests.gravesEligibleForOnlyIncludesGravesAtOrDeeperThanTh... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 7ee1b90..7fe2d99 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8433,6 +8433,179 @@ function tests.gravesRecordAppendsNewestFirstAndTrimsToMaxRecorded() Graves.record(meta, { className = "fighter", depth = i, gold = i, essence = 0, turn = i }) end eq(#meta.graves, Graves.MAX_RECORDED, "recording past the cap trims the oldest graves off") + eq(meta.graves[1].depth, Graves.MAX_RECORDED + 5, "the most recently recorded grave sits at the front") +end + +function tests.gravesEligibleForOnlyIncludesGravesAtOrDeeperThanTheQueriedDepth() + local graves = { + { className = "a", depth = 2 }, + { className = "b", depth = 5 }, + { className = "c", depth = 5 }, + } + local atFive = Graves.eligibleFor(graves, 5) + eq(#atFive, 2, "only the two graves dug at depth 5 or deeper are eligible for a depth-5 floor") + local atOne = Graves.eligibleFor(graves, 1) + eq(#atOne, 3, "every recorded grave is eligible for the entrance floor, however deep it was dug") + eq(#Graves.eligibleFor(nil, 3), 0, "a caller with no recorded graves at all gets an empty list, not an error") +end + +function tests.gravesFloorItemBuildsAnInertGraveMarkerShape() + local grave = { className = "ranger", depth = 4, gold = 50, essence = 6, turn = 20 } + local item = Graves.floorItem(grave) + eq(item.kind, "grave", "a grave's floor item is its own kind, not a real item kind") + eq(item.grave, true, "a grave's floor item is tagged so pickupHere knows to wake it") + eq(item.graveClass, "ranger", "the marker carries the fallen's own class") + eq(item.graveGold, 50, "the marker carries the fallen's gold for Graves.reveal to read later") + eq(item.graveEssence, 6, "the marker carries the fallen's essence for Graves.reveal to read later") +end + +function tests.gravesRevealScalesHpAndAttackWithCarriedWealthAndCaps() + local poor = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) + eq(poor.hp, 10, "a penniless grave's shade has no hp bonus at all") + eq(poor.attack, 4, "a penniless grave's shade has no attack bonus at all") + eq(poor.faction, Factions.UNDEAD, "a shade is undead like every other restless dead in the bestiary") + + local rich = Graves.reveal(2, 4, 6, { graveGold = 400, graveEssence = 200 }) + eq(rich.hp, 30, "a rich grave's shade hp bonus caps at 20 rather than scaling without bound") + eq(rich.attack, 10, "a rich grave's shade attack bonus caps at 6 rather than scaling without bound") +end + +function tests.gravesLootForReturnsHalfGoldAndAThirdEssenceRoundedDown() + local shade = Graves.reveal(1, 4, 6, { graveGold = 55, graveEssence = 10 }) + local gold, essence = Graves.lootFor(shade) + eq(gold, 27, "grave loot pays out half the carried gold, rounded down") + eq(essence, 3, "grave loot pays out a third of the carried essence, rounded down") +end + +function tests.graveFieldsSurviveSerialization() + local shade = Graves.reveal(1, 4, 6, { graveClass = "ranger", graveGold = 88, graveEssence = 15 }) + local restored = Entity.fromData(shade:toData()) + eq(restored.grave, true, "a revealed shade's grave tag survives a save/load round trip") + eq(restored.graveClass, "ranger", "the fallen's class survives a save/load round trip") + eq(restored.graveGold, 88, "the grave's gold survives a save/load round trip") + eq(restored.graveEssence, 15, "the grave's essence survives a save/load round trip") +end + +function tests.gamePickupHereWakesAGraveShadeAndAmbushesThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.defense, game.player.baseDefense = 0, 0 + local grave = { className = "rogue", depth = 3, gold = 0, essence = 0, turn = 10 } + game.level.floorItems = { { x = 5, y = 5, item = Graves.floorItem(grave) } } + local hpBefore = game.player.hp + game:pickupHere() + eq(#game.level.floorItems, 0, "the grave marker leaves the floor the instant it is disturbed") + eq(#game.level.entities, 1, "a disturbed grave joins the level as a real hostile monster") + eq(game.level.entities[1].grave, true, "the spawned monster is tagged as a grave shade") + eq(game.player.hp, hpBefore - 4, "the shade lands one free ambush hit for its full attack") + ok(contains(game.log:last(), "damage"), "the ambush hit is logged") +end + +function tests.gameKillingARevealedShadeGrantsItsFixedXpAndPaysOutGraveLoot() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shade = Graves.reveal(1, 4, 6, { graveGold = 40, graveEssence = 9 }) + shade.hp = 1 + game.level.entities = { shade } + local essenceBefore, goldBefore = game.player.essence, game.player.gold + game:playerAttack(shade) + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Graves.xpFor()) + 3, + "felling a shade grants essence scaled to its own fixed xp reward, plus a third of its grave essence") + eq(game.player.gold, goldBefore + 20, "felling a shade also pays out half its grave gold") + ok(contains(game.log:last(), "gold"), "the grave payout is logged") +end + +function tests.gravesShadeLeavesNoCorpseLikeAnyOtherUndead() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shade = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) + shade.hp = 1 + game.level.entities = { shade } + game:playerAttack(shade) + eq(#game.level.corpses, 0, "a felled shade leaves no corpse behind, same as any other undead") +end + +function tests.gameGraveEntryCapturesDepthGoldEssenceAndClassAtTheMomentOfDeath() + local game = Game.new(4, { class = "mage" }) + game.deepestDepth = 6 + game.player.gold = 77 + game.player.essence = 14 + game.turn = 120 + local entry = game:graveEntry() + eq(entry.className, "mage", "the grave records the class the run actually played") + eq(entry.depth, 6, "the grave records the deepest depth reached, not just the current floor") + eq(entry.gold, 77, "the grave records exactly what gold was still on hand at death") + eq(entry.essence, 14, "the grave records exactly what essence was still on hand at death") + eq(entry.turn, 120, "the grave records the turn count at death") +end + +function tests.gameGraveEntryDefaultsToWandererForAClasslessRun() + local game = Game.new(4) + eq(game:graveEntry().className, "wanderer", "a run that never picked a class leaves a wanderer's grave") +end + +function tests.builderNeverPlacesAGraveWhenOptionsCarriesNone() + for seed = 1, 15 do + local level = Builder.build(5, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(not ground.item.grave, "no grave appears without the caller supplying any") + end + end +end + +function tests.buildersOnlyRollAGraveOntoAFloorAtOrShallowerThanWhereItWasDug() + local shallowGrave = { className = "rogue", depth = 2, gold = 40, essence = 5, turn = 30 } + local found = false + for seed = 1, 40 do + local level = Builder.build(5, Rng.new(seed), { graves = { shallowGrave } }) + for _, ground in ipairs(level.floorItems) do + if ground.item.grave then found = true end + end + end + ok(not found, "a grave dug at depth 2 never surfaces on a floor as deep as 5") +end + +function tests.buildersOccasionallyPlaceAnEligibleGraveFromSuppliedOptions() + local deepGrave = { className = "mage", depth = 6, gold = 80, essence = 12, turn = 90 } + local found + for seed = 1, 40 do + local level = Builder.build(3, Rng.new(seed), { graves = { deepGrave } }) + for _, ground in ipairs(level.floorItems) do + if ground.item.grave then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a floor eventually rolls an eligible grave across a handful of seeds") + eq(found.graveClass, "mage", "the placed marker carries the fallen's own class") +end + +function tests.buildersInsertAGraveWithoutDisturbingAnyOtherPlacement() + local grave = { className = "fighter", depth = 8, gold = 60, essence = 9, turn = 50 } + local perturbed = false + local sawExtra = false + for seed = 1, 40 do + local plain = Builder.build(4, Rng.new(seed), {}) + local withGrave = Builder.build(4, Rng.new(seed), { graves = { grave } }) + if table.concat(plain.map.tiles) ~= table.concat(withGrave.map.tiles) then perturbed = true end + if #withGrave.entities ~= #plain.entities then perturbed = true end + if #withGrave.floorItems == #plain.floorItems + 1 then + sawExtra = true + local extra = withGrave.floorItems[#withGrave.floorItems] + ok(extra.item.grave, "the one extra floor item a supplied grave adds is the grave marker itself") + elseif #withGrave.floorItems ~= #plain.floorItems then + perturbed = true + end + end + ok(not perturbed, "supplying a grave never changes the map or any other placement, only adds at most its own marker") + ok(sawExtra, "across a handful of seeds, at least one actually places the grave marker") +end + +-- proficiency.lua: weapon mastery. + +function tests.proficiencyKeyForReadsFistsWhenUnarmedAndTheWeaponsNameOnceEquipped() + eq(Proficiency.keyFor({}), Proficiency.UNARMED, "an entity with no weapon field masters bare fists") + eq(Proficiency.keyFor({ weapon = { name = "sword" } }), "sword", "an equipped weapon is keyed by its own name") +end + +function tests.proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing() + local entity = {} end local order = { From 1c5b6ab591d500a97c50f1657db82538d9f93a4a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:26:30 +0200 Subject: [PATCH 132/172] test: cover tests.proficiencyAttackBonusReadsTheHighestRankCrossedFor... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 7fe2d99..dc021d5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8606,6 +8606,179 @@ end function tests.proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing() local entity = {} + for i = 1, Proficiency.RANKS[1].hits - 1 do + local key = Proficiency.onMeleeHit(entity) + eq(key, nil, "no rank crossed yet on hit " .. i) + end + local key, bonus = Proficiency.onMeleeHit(entity) + eq(key, Proficiency.UNARMED, "the rank-crossing hit reports the mastered key") + eq(bonus, Proficiency.RANKS[1].bonus, "the rank-crossing hit reports that rank's own bonus") + local nextKey = Proficiency.onMeleeHit(entity) + eq(nextKey, nil, "the very next hit past a rank reports no further milestone") +end + +function tests.proficiencyAttackBonusReadsTheHighestRankCrossedForTheCurrentKeyAlone() + local entity = { weapon = { name = "sword" } } + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end + eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, "mastery with the equipped weapon applies") + entity.weapon = { name = "mace" } + eq(Proficiency.attackBonus(entity), 0, "switching to an unmastered weapon drops the bonus entirely") + entity.weapon = { name = "sword" } + eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, + "re-equipping the mastered weapon picks its bonus straight back up") +end + +function tests.entityEffectiveAttackFoldsInProficiencyAlongsideMomentumAndSanity() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + eq(entity:effectiveAttack(), 6, "a fresh entity carries no mastery bonus") + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end + eq(entity:effectiveAttack(), 6 + Proficiency.RANKS[1].bonus, "a mastered weapon's flat bonus lands on effective attack") +end + +function tests.gamePlayerAttackAccumulatesProficiencyAndLogsOnlyOnARankCrossing() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game.level.entities = { orc } + for _ = 1, Proficiency.RANKS[1].hits - 1 do + game:playerAttack(orc) + end + ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") + game:playerAttack(orc) + ok(contains(game.log:last(), "skilled"), "crossing a mastery rank is announced as the very last log line") + eq(game.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, + "an unarmed player's landed hits accumulate under the fists key") +end + +function tests.renderPanelShowsMasteryOnlyOnceARankIsCrossed() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "mastery"), "a fresh player with no mastery shows no mastery line") + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end + ok(contains(Render.panel(game), string.format("mastery +%d with %s", Proficiency.RANKS[1].bonus, Proficiency.UNARMED)), + "a crossed mastery rank is reported in the status panel") +end + +function tests.proficiencyFieldSurvivesSerialization() + local game = Game.new(3) + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, + "the player's accumulated mastery survives a save/load round trip") +end + +function tests.proficiencyOnHitWithAndBonusForTrackAnExplicitKeyIndependentOfKeyFor() + local entity = { weapon = { name = "sword" } } + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onHitWith(entity, "short bow") end + eq(Proficiency.bonusFor(entity, "short bow"), Proficiency.RANKS[1].bonus, + "an explicit key masters independently of whatever keyFor would report") + eq(Proficiency.attackBonus(entity), 0, + "the equipped melee weapon's own mastery is untouched by an explicit-key bow's own hits") +end + +function tests.gameFireAtAccumulatesBowSpecificProficiencySeparateFromMelee() + local game = stagedGame(4, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game.level.entities = { orc } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", Proficiency.RANKS[1].hits + 1) + for i = 1, Proficiency.RANKS[1].hits - 1 do + game:fireAt(orc) + ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") + end + game:fireAt(orc) + ok(contains(game.log:last(), "skilled"), "crossing a bow mastery rank is announced as the shot's own last line") + eq(game.player.proficiency["short bow"], Proficiency.RANKS[1].hits, "bow hits accumulate under the bow's own name") + eq(game.player.proficiency[Proficiency.UNARMED], nil, "firing a bow never touches the unarmed melee key") +end + +function tests.metaGravesFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip() + local fresh = Meta.load("/tmp/warren_meta_does_not_exist_98765.sav") + eq(#fresh.graves, 0, "a freshly defaulted meta state has no recorded graves") + local meta = Meta.new() + Graves.record(meta, { className = "rogue", depth = 4, gold = 30, essence = 2, turn = 15 }) + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) + os.remove(path) + eq(#restored.graves, 1, "a recorded grave survives a save/load round trip") + eq(restored.graves[1].className, "rogue", "a restored grave keeps its own fallen class") + eq(restored.graves[1].depth, 4, "a restored grave keeps its own depth") +end + +-- ascension.lua: opt-in run mutators traded for extra renown. + +function tests.ascensionCatalogEntriesHaveUniqueKeysAndAtLeastOneEffectHook() + local seen = {} + for _, def in ipairs(Ascension.MUTATORS) do + ok(not seen[def.key], def.key .. " appears only once in the catalog") + seen[def.key] = true + local hasHook = def.damageMultiplier or def.essenceMultiplier or def.sightPenalty or def.applyToPlayer + ok(hasHook ~= nil, def.key .. " carries at least one actual gameplay effect") + end +end + +function tests.ascensionIsUnlockedRequiresMeetingBothDepthAndWinRequirements() + local meta = Meta.new() + meta.bestDepth = 2 + ok(Ascension.isUnlocked(meta, "famine"), "famine unlocks once best depth reaches 2") + ok(not Ascension.isUnlocked(meta, "bloodbound"), "bloodbound still needs depth 3") + ok(not Ascension.isUnlocked(meta, "glasscannon"), "glasscannon still needs a first win") + meta.wins = 1 + ok(Ascension.isUnlocked(meta, "glasscannon"), "glasscannon unlocks once a win is banked") + eq(Ascension.isUnlocked(meta, "madeupkey"), false, "an unknown key is never unlocked") +end + +function tests.ascensionIsUnlockedWithNoMetaLocksEveryCatalogedMutator() + for _, key in ipairs(Ascension.order) do + ok(not Ascension.isUnlocked(nil, key), key .. " stays locked for a run started without a meta file") + end +end + +function tests.ascensionResolveDropsUnknownAndStillLockedKeys() + local meta = Meta.new() + meta.bestDepth = 2 + local active = Ascension.resolve(meta, { "famine", "bloodbound", "madeupkey" }) + eq(#active, 1, "only the one unlocked, known key survives resolution") + eq(active[1], "famine", "famine is the survivor") +end + +function tests.ascensionResolveDeduplicatesAndReordersToCatalogOrder() + local meta = Meta.new() + meta.bestDepth = 4 + local active = Ascension.resolve(meta, { "nightblind", "famine", "nightblind", "famine" }) + eq(#active, 2, "a key repeated in the request is not duplicated in the resolved list") + eq(active[1], "famine", "the resolved order follows the catalog, not the request") + eq(active[2], "nightblind", "the resolved order follows the catalog, not the request") +end + +function tests.ascensionResolveWithNoRequestReturnsEmpty() + eq(#Ascension.resolve(Meta.new(), nil), 0, "a nil request resolves to no active mutators") + eq(#Ascension.resolve(Meta.new(), {}), 0, "an empty request resolves to no active mutators") +end + +function tests.ascensionIsActiveTrueOnlyForKeysInTheActiveList() + local active = { "famine", "frail" } + ok(Ascension.isActive(active, "famine"), "famine is reported active") + ok(not Ascension.isActive(active, "bloodbound"), "bloodbound is reported inactive") + ok(not Ascension.isActive(nil, "famine"), "a nil active list reports every key inactive") +end + +function tests.ascensionDamageMultiplierMultipliesAcrossActiveMutators() + eq(Ascension.damageMultiplier(nil), 1, "no active mutators leaves damage unscaled") + eq(Ascension.damageMultiplier({ "famine" }), 1, "a mutator with no damage hook leaves damage unscaled") + eq(Ascension.damageMultiplier({ "bloodbound" }), 1.5, "bloodbound scales damage by its own fixed multiplier") +end + +function tests.ascensionEssenceMultiplierAppliesFamineAlone() + eq(Ascension.essenceMultiplier(nil), 1, "no active mutators leaves essence unscaled") + eq(Ascension.essenceMultiplier({ "bloodbound" }), 1, "a mutator with no essence hook leaves essence unscaled") + eq(Ascension.essenceMultiplier({ "famine" }), 0.5, "famine halves essence income") +end + end local order = { From a9d2900e79b0f0bd749ff6f8d9575c6d2592ac12 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:36:20 +0200 Subject: [PATCH 133/172] test: cover tests.ascensionSightPenaltySumsAcrossActiveMutators --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index dc021d5..8ccd6d9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8779,6 +8779,179 @@ function tests.ascensionEssenceMultiplierAppliesFamineAlone() eq(Ascension.essenceMultiplier({ "famine" }), 0.5, "famine halves essence income") end +function tests.ascensionSightPenaltySumsAcrossActiveMutators() + eq(Ascension.sightPenalty(nil), 0, "no active mutators carries no sight penalty") + eq(Ascension.sightPenalty({ "nightblind" }), 2, "nightblind carries its own fixed sight penalty") +end + +function tests.ascensionRenownBonusPercentSumsRatherThanCompounds() + eq(Ascension.renownBonusPercent(nil), 0, "no active mutators adds no renown bonus") + eq(Ascension.renownBonusPercent({ "famine" }), 10, "famine alone adds its own fixed bonus") + eq(Ascension.renownBonusPercent({ "famine", "nightblind" }), 20, "two mutators add their bonuses, not compound them") +end + +function tests.ascensionApplyToPlayerHalvesMaxHpAndDoublesAttackForGlassCannon() + local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 1, defense = 1 } + Ascension.applyToPlayer({ "glasscannon" }, player) + eq(player.maxHp, 15, "glass cannon halves max hp") + eq(player.hp, 15, "current hp is clamped down to the new max") + eq(player.attack, 10, "glass cannon doubles attack") + eq(player.baseAttack, 10, "glass cannon doubles base attack alongside the equipped total") + eq(player.defense, 1, "glass cannon leaves defense untouched") +end + +function tests.ascensionApplyToPlayerHalvesDefenseForFrail() + local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 3, defense = 3 } + Ascension.applyToPlayer({ "frail" }, player) + eq(player.defense, 1, "frail floors defense down by half") + eq(player.baseDefense, 1, "frail floors base defense down by half") + eq(player.attack, 5, "frail leaves attack untouched") +end + +function tests.gameNewResolvesRequestedMutatorsAgainstMetaAndAppliesTheirDeltas() + local meta = Meta.new() + meta.bestDepth = 4 + meta.wins = 1 + local game = Game.new(5, { meta = meta, mutators = { "unknownkey", "glasscannon", "frail" } }) + eq(#game.ascension, 2, "only the two unlocked, known mutators survive resolution") + eq(game.ascension[1], "frail", "active mutators are ordered by the catalog, not the request") + eq(game.ascension[2], "glasscannon", "active mutators are ordered by the catalog, not the request") + eq(game.player.maxHp, 15, "glasscannon halves the fresh player's max hp") + eq(game.player.defense, 0, "frail halves the fresh player's base defense down from 1") +end + +function tests.gameMonsterMeleeDamageStacksBloodboundWithBloodmoonMultiplicatively() + local meta = Meta.new() + meta.bestDepth = 3 + local game = Game.new(1, { meta = meta, mutators = { "bloodbound" } }) + game.level = arena(21, 11) + game.player.x, game.player.y = 3, 6 + game:computeVisible() + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + + local before = game.player.hp + game.ascension = {} + game:monsterMelee(orc) + local plainDamage = before - game.player.hp + + game.player.hp = before + game.ascension = { "bloodbound" } + game:monsterMelee(orc) + local bloodboundDamage = before - game.player.hp + eq(bloodboundDamage, math.floor(plainDamage * 1.5), "bloodbound alone scales melee damage by its own multiplier") + + game.player.hp = before + game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } + game:monsterMelee(orc) + local stacked = before - game.player.hp + eq(stacked, math.floor(plainDamage * 1.5 * Events.BLOODMOON_MULTIPLIER), + "bloodbound and bloodmoon stack multiplicatively on the same hit") +end + +function tests.gameKillEssenceStacksFamineWithRichVeinsMultiplicatively() + local meta = Meta.new() + meta.bestDepth = 2 + local game = Game.new(1, { meta = meta, mutators = { "famine" } }) + game.level = arena(21, 11) + game.player.x, game.player.y = 3, 6 + game:computeVisible() + + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.ascension = {} + local before = game.player.essence + game:playerAttack(rat) + local plainEssence = game.player.essence - before + + local rat2 = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) + rat2.hp = 1 + game.level.entities = { rat2 } + game.player.essence = before + game.ascension = { "famine" } + game:playerAttack(rat2) + local famineEssence = game.player.essence - before + eq(famineEssence, math.floor(plainEssence * 0.5), "famine alone halves essence earned from a kill") + + local rat3 = Monsters.create(Monsters.byKey.rat, 3, 4, 6, 1) + rat3.hp = 1 + game.level.entities = { rat3 } + game.player.essence = before + game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } + game:playerAttack(rat3) + local stackedEssence = game.player.essence - before + eq(stackedEssence, plainEssence, + "famine's half and rich veins' double multiply to exactly cancel back to the plain amount") +end + +function tests.gameEffectiveRadiusStacksNightblindWithHush() + local meta = Meta.new() + meta.bestDepth = 2 + local game = Game.new(1, { meta = meta, mutators = { "nightblind" } }) + game.ascension = {} + local noAscension = game:effectiveRadius() + game.ascension = { "nightblind" } + eq(game:effectiveRadius(), noAscension - 2, "nightblind alone shrinks the effective sight radius by its own penalty") + game.level.event = { key = Events.HUSH, turnsLeft = 5 } + eq(game:effectiveRadius(), noAscension - 2 - Events.HUSH_SIGHT_PENALTY, + "nightblind and hush both shrink the effective sight radius, summed together") +end + +function tests.metaRenownForAddsTheAscensionBonusOnTopOfTheBaseScore() + local stats = { deepestDepth = 5, bossKills = 0, questsCompleted = 0, won = false } + local base = Meta.renownFor(stats) + stats.mutators = { "famine" } + eq(Meta.renownFor(stats), base + (base * 10) // 100, "famine's 10% renown bonus is added on top of the base score") + stats.mutators = { "famine", "nightblind" } + eq(Meta.renownFor(stats), base + (base * 20) // 100, "two mutators' renown bonuses sum before being applied") +end + +function tests.ascensionFieldSurvivesASaveLoadRoundTrip() + local meta = Meta.new() + meta.bestDepth = 3 + local game = Game.new(4, { meta = meta, mutators = { "bloodbound" } }) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(#restored.ascension, 1, "the active ascension mutator list survives a save/load round trip") + eq(restored.ascension[1], "bloodbound", "the restored mutator key matches what was active") +end + +function tests.renderPanelShowsActiveAscensionMutatorsByKey() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "ascension"), "no active mutators shows no ascension line at all") + game.ascension = { "famine", "nightblind" } + ok(contains(Render.panel(game), "ascension famine,nightblind"), "active mutators are listed by key in the panel") +end + +-- durability.lua: weapon/armor/bow wear and repair. + +function tests.durabilityFreshWeaponStartsAtMaxAndIsNotBroken() + local sword = Items.weapon("sword", 4) + eq(sword.durability, Durability.MAX, "a freshly forged weapon starts at max durability") + eq(sword.maxDurability, Durability.MAX, "a freshly forged weapon's max durability matches the catalog default") + ok(not Durability.isBroken(sword), "a freshly forged weapon is not broken") +end + +function tests.durabilityWearWeaponDecrementsAndBreaksAtZeroSubtractingItsPower() + local hero = { attack = 5, inventory = {}, kind = "player", name = "you" } + Items.equipWeapon(Items.weapon("sword", 4), hero) + eq(hero.attack, 9, "the sword's power is baked into attack on equip") + local broke + for _ = 1, Durability.MAX - 1 do + broke = Durability.wearWeapon(hero) + ok(broke == nil, "the sword survives every hit before its last") + end + eq(hero.weapon.durability, 1, "one point of durability remains before the final hit") + broke = Durability.wearWeapon(hero) + ok(broke ~= nil, "the hit that empties durability reports a break") + ok(contains(broke, "breaks apart"), "the break line names what happened") + eq(hero.weapon.durability, 0, "durability floors at exactly zero") + eq(hero.attack, 5, "the sword's power is pulled back out of attack the instant it breaks") +end + +function tests.durabilityWearWeaponIsANoOpOnceAlreadyBroken() end local order = { From 117119b11e325fe8fa8eef456dd363a6b98f4ea8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:49:15 +0200 Subject: [PATCH 134/172] test: cover tests.durabilityWearArmorBreaksAndSubtractsItsPowerFromDe... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 8ccd6d9..61a848f 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8952,6 +8952,179 @@ function tests.durabilityWearWeaponDecrementsAndBreaksAtZeroSubtractingItsPower( end function tests.durabilityWearWeaponIsANoOpOnceAlreadyBroken() + local item = Items.weapon("dagger", 2) + item.durability = 0 + local hero = { weapon = item, attack = 10, kind = "player", name = "you" } + eq(Durability.wearWeapon(hero), nil, "wearing an already broken weapon reports no further break") + eq(item.durability, 0, "durability never drops below zero") + eq(hero.attack, 10, "wearing an already-broken weapon never touches attack again") +end + +function tests.durabilityWearArmorBreaksAndSubtractsItsPowerFromDefense() + local hero = { defense = 1, inventory = {}, kind = "player", name = "you" } + Items.equipArmor(Items.armor("plate", 3), hero) + eq(hero.defense, 4, "the plate's power is baked into defense on equip") + hero.armor.durability = 1 + local broke = Durability.wearArmor(hero) + ok(contains(broke, "battered"), "the break line reports the armor giving out") + eq(hero.defense, 1, "the armor's power is pulled back out of defense the instant it breaks") +end + +function tests.durabilityWearBowBreaksWithoutTouchingAnyStatDirectly() + local hero = { bow = Items.bow("shortbow", 3, 6), attack = 5, kind = "player", name = "you" } + hero.bow.durability = 1 + local broke = Durability.wearBow(hero) + ok(contains(broke, "snaps"), "the break line reports the bow giving out") + eq(hero.bow.durability, 0, "durability floors at zero") + eq(hero.attack, 5, "a bow's power was never baked into attack, so breaking it leaves attack untouched") +end + +function tests.durabilityEffectivePowerIsZeroForABrokenItemAndFullOtherwise() + local bow = Items.bow("shortbow", 3, 6) + eq(Durability.effectivePower(bow), 3, "a pristine bow contributes its full power") + bow.durability = 0 + eq(Durability.effectivePower(bow), 0, "a broken bow contributes nothing") + eq(Durability.effectivePower(nil), 0, "no bow equipped contributes nothing") +end + +function tests.durabilityRepairFailsWithoutEnoughGoldOrOnAPristineItem() + local hero = { weapon = Items.weapon("sword", 4), gold = 0 } + local success, reason = Durability.repair(hero, "weapon") + eq(success, false, "a pristine weapon refuses repair") + eq(reason, "already in perfect condition", "the refusal explains why") + hero.weapon.durability = 10 + success, reason = Durability.repair(hero, "weapon") + eq(success, false, "an empty purse refuses to cover the repair bill") + eq(reason, "not enough gold", "the refusal explains why") + success, reason = Durability.repair(hero, "bow") + eq(success, false, "repairing an empty slot fails") + eq(reason, "nothing equipped there", "the refusal explains why") +end + +function tests.durabilityRepairRestoresFullDurabilityAndFoldsPowerBackInAfterABreak() + local hero = { attack = 5, gold = 100, inventory = {}, kind = "player", name = "you" } + Items.equipWeapon(Items.weapon("sword", 4), hero) + hero.weapon.durability = 0 + hero.attack = hero.attack - 4 + local success, cost = Durability.repair(hero, "weapon") + ok(success, "a full purse and a broken weapon repair successfully") + eq(hero.weapon.durability, Durability.MAX, "repairing restores full durability") + eq(cost, Durability.MAX * Durability.REPAIR_COST_PER_POINT, "repairing a fully broken item costs its full bill") + eq(hero.gold, 100 - cost, "the repair bill is deducted from gold") + eq(hero.attack, 9, "the weapon's power is folded back into attack once repaired") +end + +function tests.durabilityRepairOnANotYetBrokenItemJustTopsUpDurabilityWithoutTouchingStats() + local hero = { attack = 9, gold = 100 } + hero.weapon = Items.weapon("sword", 4) + hero.weapon.durability = 20 + local success, cost = Durability.repair(hero, "weapon") + ok(success, "a merely dinged weapon still repairs") + eq(hero.weapon.durability, Durability.MAX, "durability tops back up to max") + eq(cost, (Durability.MAX - 20) * Durability.REPAIR_COST_PER_POINT, "the bill only covers the missing points") + eq(hero.attack, 9, "attack is untouched since the weapon was never broken in the first place") +end + +function tests.equipWeaponAddsOnlyEffectivePowerForAnAlreadyBrokenWeapon() + local hero = { attack = 5, inventory = {} } + local sword = Items.weapon("sword", 4) + sword.durability = 0 + Items.equipWeapon(sword, hero) + eq(hero.attack, 5, "equipping an already-broken weapon adds none of its power") +end + +function tests.rangedFireDealsNoBowBonusOnceTheBowIsBroken() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.bow = Items.bow("shortbow", 5, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 10) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + orc.hp, orc.maxHp = 999, 999 + + game:fireAt(orc) + local pristineDamage = 999 - orc.hp + + orc.hp = 999 + game.player.bow.durability = 0 + game:fireAt(orc) + local brokenDamage = 999 - orc.hp + + ok(brokenDamage < pristineDamage, "a broken bow deals strictly less damage than a working one") + eq(brokenDamage, pristineDamage - 5, "the broken bow's missing power matches its catalog power exactly") +end + +function tests.gamePlayerAttackWearsTheEquippedWeaponAndLogsWhenItBreaks() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("dagger", 2), game.player) + game.player.weapon.durability = 1 + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game:playerAttack(orc) + eq(game.player.weapon.durability, 0, "the weapon's last durability point is spent on this hit") + local broke = false + for _, line in ipairs(game.log:recent(6)) do + if contains(line, "breaks apart") then broke = true end + end + ok(broke, "the weapon breaking is reported in the message log") +end + +function tests.gameMonsterMeleeWearsThePlayersArmorAndLogsWhenItBreaks() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipArmor(Items.armor("plate", 3), game.player) + game.player.armor.durability = 1 + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + game:monsterMelee(orc) + eq(game.player.armor.durability, 0, "the armor's last durability point is spent on this hit") + local broke = false + for _, line in ipairs(game.log:recent(6)) do + if contains(line, "battered") then broke = true end + end + ok(broke, "the armor breaking is reported in the message log") +end + +function tests.gameFireAtWearsTheEquippedBowAndLogsWhenItBreaks() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.bow = Items.bow("shortbow", 3, 6) + game.player.bow.durability = 1 + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game:fireAt(orc) + eq(game.player.bow.durability, 0, "the bow's last durability point is spent on this shot") + ok(contains(game.log:last(), "snaps"), "the bow snapping is the shot's own last log line") +end + +function tests.gameBrokenEquippedSlotFindsTheFirstBrokenPieceInPriorityOrder() + local game = stagedGame(1, arena(21, 11), 3, 6) + eq(game:brokenEquippedSlot(), nil, "a fresh player with no gear has nothing broken") + Items.equipArmor(Items.armor("plate", 3), game.player) + game.player.armor.durability = 0 + eq(game:brokenEquippedSlot(), "armor", "the only broken piece is found") + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + eq(game:brokenEquippedSlot(), "weapon", "weapon takes priority over armor once both are broken") +end + +function tests.gameRepairEquippedSpendsGoldAndLogsSuccess() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + game.player.gold = 1000 + local before = game.player.gold + local success = game:repairEquipped("weapon") + ok(success, "a full purse repairs the broken weapon") + eq(game.player.weapon.durability, Durability.MAX, "the weapon comes back to full durability") + ok(game.player.gold < before, "gold is spent on the repair") + ok(contains(game.log:last(), "repair"), "the repair is logged") +end + +function tests.gameRepairEquippedFailsAndLogsWhenGoldIsShort() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + game.player.gold = 0 + local success = game:repairEquipped("weapon") + eq(success, false, "an empty purse cannot repair") + eq(game.player.weapon.durability, 0, "the weapon stays broken") end local order = { From d16250b6f91ae8f49de2488c55566a8c116c49d7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 19:18:42 +0200 Subject: [PATCH 135/172] test: cover tests.autopilotRepairsBrokenGearAutomaticallyWhenAffordable --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 61a848f..f36d937 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9125,6 +9125,179 @@ function tests.gameRepairEquippedFailsAndLogsWhenGoldIsShort() local success = game:repairEquipped("weapon") eq(success, false, "an empty purse cannot repair") eq(game.player.weapon.durability, 0, "the weapon stays broken") + ok(contains(game.log:last(), "cannot repair"), "the failure is logged") +end + +function tests.autopilotRepairsBrokenGearAutomaticallyWhenAffordable() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + game.player.gold = 1000 + game.level.entities = {} + game:choosePlayerAction() + eq(game.player.weapon.durability, Durability.MAX, + "the autopilot repairs a broken weapon on sight once it can afford to") +end + +function tests.durabilityFieldsSurviveSerialization() + local game = Game.new(3) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 7 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.weapon.durability, 7, "a weapon's own durability survives a save/load round trip") + eq(restored.player.weapon.maxDurability, Durability.MAX, "a weapon's max durability survives a save/load round trip") +end + +function tests.renderPanelShowsWornGearOnlyOnceItHasTakenDamage() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + ok(not contains(Render.panel(game), "sword ("), "a pristine weapon's label carries no wear indicator") + game.player.weapon.durability = 10 + ok(contains(Render.panel(game), "sword (10/30)"), "a dinged weapon's label reports its remaining durability") + game.player.weapon.durability = 0 + ok(contains(Render.panel(game), "sword (broken)"), "a broken weapon's label says so plainly") +end + +-- disarm.lua: passive trap sense and deliberate disarming. + +function tests.disarmSweepDetectsAHiddenTrapWithinOneStepEventuallyAcrossAHandfulOfSeeds() + local detected = false + for seed = 1, 100 do + local level = { traps = { { x = 5, y = 5, effect = "dart", revealed = false } } } + if #Disarm.sweep(level, Rng.new(seed), 5, 6) > 0 then detected = true break end + end + ok(detected, "an adjacent hidden trap is eventually detected across a handful of seeds") +end + +function tests.disarmSweepNeverDetectsATrapTwoStepsAway() + for seed = 1, 200 do + local level = { traps = { { x = 5, y = 5, effect = "dart", revealed = false } } } + eq(#Disarm.sweep(level, Rng.new(seed), 7, 7), 0, + "a trap two steps away is never detected regardless of the roll") + end +end + +function tests.disarmSweepNeverRedetectsAnAlreadyDetectedOrRevealedTrap() + local level = { traps = { + { x = 5, y = 5, effect = "dart", revealed = false, detected = true }, + { x = 5, y = 6, effect = "dart", revealed = true }, + } } + for seed = 1, 50 do + eq(#Disarm.sweep(level, Rng.new(seed), 5, 5), 0, + "neither an already-detected nor an already-sprung trap is swept again") + end +end + +function tests.disarmOddsForClimbWithLevelButNeverReachTheDenominator() + local num1, den1 = Disarm.oddsFor({ level = 1 }) + eq(num1, Disarm.DISARM_BASE_NUMERATOR, "a fresh level-one player rolls the catalog's base odds") + local num2, den2 = Disarm.oddsFor({ level = 100 }) + eq(den2, den1, "the denominator never changes with level") + ok(num2 < den2, "even an extremely high level never reaches a guaranteed disarm") + eq(num2, Disarm.DISARM_BASE_DENOMINATOR - 1, "the numerator caps a single point shy of the denominator") +end + +function tests.disarmAttemptOnSuccessRemovesTheTrapAndPaysSalvage() + local succeeded = false + for seed = 1, 300 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } + game.level.traps = { trap } + local essenceBefore = game.player.essence + local success, line = Disarm.attempt(game, trap) + if success then + eq(#game.level.traps, 0, "a successfully disarmed trap is removed from the level") + ok(game.player.essence > essenceBefore, "a successful disarm pays out salvage essence") + ok(contains(line, "disarm"), "the success is reported") + succeeded = true + break + end + end + ok(succeeded, "disarming eventually succeeds across a handful of seeds") +end + +function tests.disarmAttemptOnFailureSpringsTheTrapOnThePlayer() + local failed = false + for seed = 1, 300 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } + game.level.traps = { trap } + local hpBefore = game.player.hp + local success = Disarm.attempt(game, trap) + if not success then + eq(trap.revealed, true, "a failed disarm springs the trap, revealing it") + ok(game.player.hp < hpBefore, "a failed disarm's spring wounds the player exactly like a footstep would") + failed = true + break + end + end + ok(failed, "disarming eventually fails across a handful of seeds") +end + +function tests.gameDisarmTrapRefusesAnUndetectedOrAlreadySprungTrap() + local game = stagedGame(1, arena(21, 11), 3, 6) + local undetected = { x = 4, y = 6, effect = "dart", revealed = false } + game.level.traps = { undetected } + eq(game:disarmTrap(undetected), false, "an undetected trap refuses disarming outright") + eq(#game.level.traps, 1, "the untouched trap is still there") + + local sprung = { x = 5, y = 6, effect = "dart", revealed = true, detected = true } + eq(game:disarmTrap(sprung), false, "an already-sprung trap refuses disarming outright") +end + +function tests.gameDisarmTrapSucceedsOrSpringsExactlyLikeDisarmAttempt() + local sawSuccess, sawFailure = false, false + for seed = 1, 300 do + if sawSuccess and sawFailure then break end + local game = stagedGame(seed, arena(21, 11), 3, 6) + local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } + game.level.traps = { trap } + local hpBefore = game.player.hp + if game:disarmTrap(trap) then + eq(#game.level.traps, 0, "a successful disarm through the game wrapper removes the trap") + sawSuccess = true + else + eq(trap.revealed, true, "a failed disarm through the game wrapper springs the trap") + ok(game.player.hp <= hpBefore, "a failed disarm through the game wrapper can wound the player") + sawFailure = true + end + end + ok(sawSuccess, "the game wrapper eventually succeeds across a handful of seeds") + ok(sawFailure, "the game wrapper eventually fails across a handful of seeds") +end + +function tests.gameEndPlayerTurnEventuallyDetectsAnAdjacentTrapAcrossAHandfulOfSeeds() + local noticed = false + for seed = 1, 100 do + local game = stagedGame(1, arena(21, 11), 3, 6) + game.rng = Rng.new(seed) + game.level.traps = { { x = 4, y = 6, effect = "dart", revealed = false } } + game:endPlayerTurn() + if game.level.traps[1].detected then + local reported = false + for _, line in ipairs(game.log:recent(6)) do + if contains(line, "notice") then reported = true end + end + ok(reported, "a freshly detected trap is reported somewhere in the turn's log lines") + noticed = true + break + end + end + ok(noticed, "an adjacent hidden trap is eventually detected during a normal turn across a handful of seeds") +end + +function tests.trapDetectedFieldSurvivesSerialization() + local game = Game.new(3) + game.level.traps = { { x = 1, y = 1, effect = "dart", revealed = false, detected = true } } + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.level.traps[1].detected, true, "a trap's detected flag survives a save/load round trip") +end end local order = { From 4e48ff6c6b57cafd439de3c97e60b0d350b22838 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 19:46:25 +0200 Subject: [PATCH 136/172] test: cover tests.secretsSweepDiscoversAnAdjacentHiddenDoorEventually... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index f36d937..6581fc3 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9298,6 +9298,179 @@ function tests.trapDetectedFieldSurvivesSerialization() os.remove(path) eq(restored.level.traps[1].detected, true, "a trap's detected flag survives a save/load round trip") end + +function tests.secretsSweepDiscoversAnAdjacentHiddenDoorEventuallyAcrossAHandfulOfSeeds() + local discovered = false + for seed = 1, 100 do + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + if #Secrets.sweep(level, Rng.new(seed), 5, 6) > 0 then discovered = true break end + end + ok(discovered, "an adjacent secret door is eventually discovered across a handful of seeds") +end + +function tests.secretsSweepNeverDiscoversADoorTwoStepsAway() + for seed = 1, 200 do + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + eq(#Secrets.sweep(level, Rng.new(seed), 7, 7), 0, + "a secret door two steps away is never discovered regardless of the roll") + end +end + +function tests.secretsSweepNeverRediscoversAnAlreadyDiscoveredDoor() + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = true } } + for seed = 1, 50 do + eq(#Secrets.sweep(level, Rng.new(seed), 5, 5), 0, "an already-discovered door is never swept again") + end +end + +function tests.secretsSweepOpensTheWallIntoAPlainClosedDoorOnDiscovery() + local level = arena(21, 11) + level.map:set(5, 5, Tiles.WALL) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + local found + for seed = 1, 100 do + local door = Secrets.sweep(level, Rng.new(seed), 5, 6)[1] + if door then found = door break end + end + ok(found ~= nil, "the door is eventually discovered") + eq(found.discovered, true, "the discovered door's own flag is flipped") + eq(level.map:get(5, 5), Tiles.DOOR_CLOSED, "the wall tile becomes a plain closed door once discovered") +end + +local function orthogonalNeighbours(x, y) + return { { x + 1, y }, { x - 1, y }, { x, y + 1 }, { x, y - 1 } } +end + +local function reachableFrom(map, fromX, fromY, x, y) + return Astar.find(map, fromX, fromY, x, y, function(cx, cy) + return not map:isPassable(cx, cy, false) + end) ~= nil +end + +function tests.builderOccasionallyPlacesASecretDoorGuardingAGenuinelyUnreachableAlcove() + local checked = 0 + for seed = 1, 200 do + local level = Builder.build(3, Rng.new(seed), {}) + if #level.secretDoors == 1 then + checked = checked + 1 + local door = level.secretDoors[1] + eq(door.discovered, false, "a freshly built floor's secret door always starts undiscovered") + eq(level.map:get(door.x, door.y), Tiles.WALL, "an undiscovered secret door still reads as an ordinary wall") + + -- Exactly one of the door's own orthogonal neighbours is the alcove + -- itself: real floor, yet unreachable from the stairs while the door + -- between them is still a plain, impassable wall. + local alcoveX, alcoveY + for _, n in ipairs(orthogonalNeighbours(door.x, door.y)) do + local nx, ny = n[1], n[2] + if level.map:isWalkable(nx, ny) and not reachableFrom(level.map, level.upX, level.upY, nx, ny) then + alcoveX, alcoveY = nx, ny + end + end + ok(alcoveX ~= nil, "seed " .. seed .. ": the secret door hides a genuinely unreachable alcove cell") + if alcoveX then + level.map:set(door.x, door.y, Tiles.DOOR_CLOSED) + ok(reachableFrom(level.map, level.upX, level.upY, alcoveX, alcoveY), + "seed " .. seed .. ": the same alcove opens up the instant its door is discovered") + end + if checked >= 10 then break end + end + end + ok(checked > 0, "at least one of the sampled seeds actually places a secret door") +end + +function tests.builderNeverPlacesASecretDoorAboveDepthTwo() + for seed = 1, 10 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.secretDoors, 0, "no secret door appears on the first floor") + end +end + +function tests.gameEndPlayerTurnEventuallyDiscoversAnAdjacentSecretDoorAcrossAHandfulOfSeeds() + local discovered = false + for seed = 1, 100 do + local game = stagedGame(1, arena(21, 11), 3, 6) + game.rng = Rng.new(seed) + game.level.map:set(4, 6, Tiles.WALL) + game.level.secretDoors = { { x = 4, y = 6, discovered = false } } + game:endPlayerTurn() + if game.level.secretDoors[1].discovered then + local reported = false + for _, line in ipairs(game.log:recent(6)) do + if contains(line, "hidden door") then reported = true end + end + ok(reported, "a freshly discovered secret door is reported somewhere in the turn's log lines") + eq(game.level.map:get(4, 6), Tiles.DOOR_CLOSED, "the discovered door's tile is a plain closed door") + discovered = true + break + end + end + ok(discovered, "an adjacent secret door is eventually discovered over a handful of seeded turns") +end + +function tests.secretDoorFieldsSurviveSerialization() + local game = Game.new(5) + game.level.secretDoors = { { x = 3, y = 3, discovered = false } } + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(#restored.level.secretDoors, 1, "the secret door list survives a save/load round trip") + eq(restored.level.secretDoors[1].discovered, false, "a secret door's discovered flag survives a round trip") +end + +function tests.chestsNewLootAlwaysIncludesGoldAndExactlyOneGearPiece() + for seed = 1, 30 do + local chest = Chests.new(Rng.new(seed), 3) + local goldCount, gearCount = 0, 0 + for _, item in ipairs(chest.loot) do + if item.kind == Items.GOLD then goldCount = goldCount + 1 end + if item.kind == Items.WEAPON or item.kind == Items.ARMOR then gearCount = gearCount + 1 end + end + eq(goldCount, 1, "every chest carries exactly one pile of gold") + eq(gearCount, 1, "every chest carries exactly one weapon or armor piece") + end +end + +function tests.chestsNewNeverRollsTrappedOnAnUnlockedChest() + local sawLockedTrapped, sawLockedUntrapped = false, false + for seed = 1, 100 do + local chest = Chests.new(Rng.new(seed), 3) + ok(chest.locked or not chest.trapped, "an unlocked chest is never also trapped") + if chest.locked and chest.trapped then sawLockedTrapped = true end + if chest.locked and not chest.trapped then sawLockedUntrapped = true end + end + ok(sawLockedTrapped, "a locked chest eventually rolls trapped across a handful of seeds") + ok(sawLockedUntrapped, "a locked chest eventually rolls untrapped across a handful of seeds") +end + +function tests.chestsIsLockedIsTrueForALockedUnopenedChestAndFalseOnceOpened() + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + eq(Chests.isLocked(chest), true, "a locked, unopened chest reports locked") + chest.opened = true + eq(Chests.isLocked(chest), false, "an opened chest never reports locked, whatever its own flag says") +end + +function tests.chestsOpenGrantsEveryLootItemAndEmptiesTheChest() + local player = { inventory = {} } + local chest = { locked = false, trapped = false, opened = false, loot = { Items.gold(5), Items.potionOf("heal") } } + local success, line = Chests.open(player, chest) + ok(success, "opening an unlocked chest succeeds") + eq(#player.inventory, 2, "every loot item lands in the inventory") + eq(chest.opened, true, "the chest is marked opened") + eq(#chest.loot, 0, "the chest's own loot list is emptied") + ok(contains(line, "open"), "the opening is reported") +end + +function tests.chestsOpenRefusesAnAlreadyEmptiedChest() + local player = { inventory = {} } + local chest = { locked = false, trapped = false, opened = true, loot = {} } + local success, line = Chests.open(player, chest) + eq(success, false, "opening an already-emptied chest fails") + eq(#player.inventory, 0, "nothing is granted from an already-emptied chest") end local order = { From 2498a1129bd88817950c4664230ab810620b58cb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:05:24 +0200 Subject: [PATCH 137/172] test: cover tests.chestsOpenRefusesALockedChestAndLeavesItsLootUntouc... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 6581fc3..d76d7fa 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9471,6 +9471,179 @@ function tests.chestsOpenRefusesAnAlreadyEmptiedChest() local success, line = Chests.open(player, chest) eq(success, false, "opening an already-emptied chest fails") eq(#player.inventory, 0, "nothing is granted from an already-emptied chest") + ok(contains(line, "empty"), "the refusal explains the chest is already empty") +end + +function tests.chestsOpenRefusesALockedChestAndLeavesItsLootUntouched() + local player = { inventory = {} } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local success, line = Chests.open(player, chest) + eq(success, false, "opening a locked chest outright fails") + eq(#player.inventory, 0, "a refused open grants nothing") + eq(#chest.loot, 1, "a refused open leaves the chest's loot untouched") + ok(contains(line, "locked"), "the refusal explains the chest is locked") +end + +function tests.chestsUnlockWithKeySpendsExactlyOneKeyAndGrantsTheHoard() + local player = { inventory = { Items.key(), Items.key() } } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local success, line = Chests.unlockWithKey(player, chest) + ok(success, "unlocking with a carried key succeeds") + eq(#player.inventory, 2, "exactly one key is spent, leaving the spare behind") + eq(player.inventory[1].kind, Items.KEY, "the surviving inventory item is still a key") + eq(chest.locked, false, "the chest's own lock flag is cleared") + eq(chest.opened, true, "the chest is opened in the same stroke") + ok(contains(line, "unlock"), "the unlock is reported") +end + +function tests.chestsUnlockWithKeyFailsWithoutAKeyAndLeavesTheChestLocked() + local player = { inventory = {} } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local success, line = Chests.unlockWithKey(player, chest) + eq(success, false, "unlocking without a key fails") + eq(chest.locked, true, "the chest stays locked") + eq(#chest.loot, 1, "nothing is granted without a key") + ok(contains(line, "no key"), "the refusal explains there is no key") +end + +function tests.chestsUnlockWithKeyRefusesAChestThatIsNotLocked() + local player = { inventory = { Items.key() } } + local chest = { locked = false, trapped = false, opened = false, loot = { Items.gold(5) } } + local success = Chests.unlockWithKey(player, chest) + eq(success, false, "a key is never spent forcing a lock that was never there") + eq(#player.inventory, 1, "the key is not spent") +end + +function tests.chestsPickFailsImmediatelyWithoutALockpickAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local success, line = Chests.pick(game, chest) + eq(success, false, "picking without a lockpick fails") + eq(chest.locked, true, "the lock is untouched without a lockpick") + eq(#chest.loot, 1, "nothing is granted without a lockpick") + ok(contains(line, "no lockpicks"), "the refusal explains there are no lockpicks") +end + +function tests.chestsPickOnSuccessOpensTheChestAndGrantsTheHoard() + local succeeded = false + for seed = 1, 100 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + game.player.inventory = { Items.lockpick() } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local success, line = Chests.pick(game, chest) + if success then + eq(chest.opened, true, "a successful pick opens the chest") + eq(#game.player.inventory, 1, "the hoard lands in the inventory alongside the spent pick's empty slot") + ok(contains(line, "click"), "the success is reported") + succeeded = true + break + end + end + ok(succeeded, "picking eventually succeeds across a handful of seeds") +end + +function tests.chestsPickAlwaysSpendsTheLockpickWhetherItWinsOrLoses() + for seed = 1, 20 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + game.player.inventory = { Items.lockpick() } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + Chests.pick(game, chest) + local stillHasPick = false + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.LOCKPICK then stillHasPick = true end + end + ok(not stillHasPick, "a pick attempt spends the lockpick regardless of outcome") + end +end + +function tests.chestsPickOnFailureAgainstATrappedChestSpringsItAndLeavesTheLockStanding() + local failed = false + for seed = 1, 100 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + game.player.inventory = { Items.lockpick() } + local chest = { locked = true, trapped = true, opened = false, loot = { Items.gold(5) } } + local hpBefore, poisonBefore = game.player.hp, #game.player.statuses + local success, line = Chests.pick(game, chest) + if not success then + eq(chest.locked, true, "a failed pick leaves the lock standing") + eq(chest.trapped, false, "a sprung trap is spent for good") + ok(game.player.hp < hpBefore or #game.player.statuses > poisonBefore, + "a trapped chest's failed pick actually harms the player") + ok(contains(line, "trap") or contains(line, "poison") or contains(line, "needle"), + "the trap's bite is reported") + failed = true + break + end + end + ok(failed, "picking a trapped chest eventually fails across a handful of seeds") +end + +function tests.chestsPickOnFailureAgainstAnUntrappedChestJustWastesThePick() + local failed = false + for seed = 1, 100 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + game.player.inventory = { Items.lockpick() } + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + local hpBefore = game.player.hp + local success, line = Chests.pick(game, chest) + if not success then + eq(chest.locked, true, "a failed pick leaves the lock standing") + eq(game.player.hp, hpBefore, "an untrapped chest's failed pick costs nothing but the pick itself") + ok(contains(line, "slips"), "the failure is reported") + failed = true + break + end + end + ok(failed, "picking an untrapped chest eventually fails across a handful of seeds") +end + +function tests.chestsOddsForClimbsWithLevelButNeverReachesTheDenominator() + local num1, den1 = Chests.oddsFor({ level = 1 }) + eq(num1, Chests.PICK_BASE_NUMERATOR, "a fresh level-one player rolls the catalog's base odds") + local num2, den2 = Chests.oddsFor({ level = 100 }) + eq(den2, den1, "the denominator never changes with level") + ok(num2 < den2, "even an extremely high level never reaches a guaranteed pick") + eq(num2, Chests.PICK_BASE_DENOMINATOR - 1, "the numerator caps a single point shy of the denominator") +end + +function tests.gameOpenChestOpensAnUnlockedChestOnSightAndCountsIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { x = 3, y = 6, locked = false, trapped = false, opened = false, loot = { Items.gold(5) } } + game.level.chests = { chest } + local before = game.stats.chestsOpened + local success = game:openChest() + ok(success, "opening an unlocked chest under the player succeeds") + eq(chest.opened, true, "the chest is marked opened") + eq(game.stats.chestsOpened, before + 1, "a successful open is tallied for meta scoring") +end + +function tests.gameOpenChestUnlocksWithACarriedKeyBeforeEverTryingALockpick() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { x = 3, y = 6, locked = true, trapped = true, opened = false, loot = { Items.gold(5) } } + game.level.chests = { chest } + game.player.inventory = { Items.key(), Items.lockpick() } + local success = game:openChest() + ok(success, "opening a locked chest with a key on hand succeeds") + eq(chest.opened, true, "the chest opens") + local stillHasPick, stillHasKey = false, false + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.LOCKPICK then stillHasPick = true end + if item.kind == Items.KEY then stillHasKey = true end + end + ok(stillHasPick, "the lockpick is never touched once a key is on hand") + ok(not stillHasKey, "the key is the one actually spent") +end + +function tests.gameOpenChestFallsBackToALockpickWhenNoKeyIsCarried() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { x = 3, y = 6, locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + game.level.chests = { chest } + game.player.inventory = { Items.lockpick() } + game:openChest() + local stillHasPick = false + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.LOCKPICK then stillHasPick = true end + end end local order = { From b0648df4e042f03546e5dfcd345a1d2032dcd6cf Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:32:13 +0200 Subject: [PATCH 138/172] test: cover tests.gameOpenChestRefusesWhenNothingIsHereOrItIsAlreadyE... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index d76d7fa..a10690c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9644,6 +9644,179 @@ function tests.gameOpenChestFallsBackToALockpickWhenNoKeyIsCarried() for _, item in ipairs(game.player.inventory) do if item.kind == Items.LOCKPICK then stillHasPick = true end end + ok(not stillHasPick, "the lockpick is spent gambling against the lock with no key on hand") +end + +function tests.gameOpenChestRefusesWhenNothingIsHereOrItIsAlreadyEmpty() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.chests = {} + eq(game:openChest(), false, "opening with no chest underfoot fails") + local chest = { x = 3, y = 6, locked = false, trapped = false, opened = true, loot = {} } + game.level.chests = { chest } + eq(game:openChest(), false, "opening an already-emptied chest fails") +end + +function tests.chestsFieldsSurviveSerialization() + local level = arena(21, 11) + local chest = Chests.new(Rng.new(4), 3) + chest.x, chest.y = 5, 5 + chest.locked = true + chest.trapped = true + level.chests = { chest } + local restored = Level.fromData(level:toData()) + eq(#restored.chests, 1, "the chest list survives a round trip") + eq(restored.chests[1].locked, true, "a chest's locked flag survives a round trip") + eq(restored.chests[1].trapped, true, "a chest's trapped flag survives a round trip") + eq(restored.chests[1].opened, false, "a chest's opened flag survives a round trip") + eq(#restored.chests[1].loot, #chest.loot, "a chest's loot list survives a round trip intact") + eq(restored.chests[1].loot[1].kind, chest.loot[1].kind, "a chest's loot items keep their own kind") +end + +function tests.renderShowsAClosedChestGlyphAndAnOpenedOneOnceEmptied() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { x = 4, y = 6, locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + game.level.chests = { chest } + game:computeVisible() + local frame = game:render() + ok(contains(frame, "B"), "a closed chest renders as its own glyph") + chest.opened = true + chest.loot = {} + frame = game:render() + ok(contains(frame, "b"), "an emptied chest renders as its own distinct glyph") +end + +function tests.autopilotOpensAnUnlockedChestOnSightButSkipsALockedOneWithNoKeyOrPick() + local game = stagedGame(1, arena(21, 11), 3, 6) + local chest = { x = 3, y = 6, locked = false, trapped = false, opened = false, loot = { Items.gold(5) } } + game.level.chests = { chest } + game:choosePlayerAction() + eq(chest.opened, true, "the autopilot opens a free chest the instant it stands on one") + + local lockedGame = stagedGame(1, arena(21, 11), 3, 6) + local locked = { x = 3, y = 6, locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + lockedGame.level.chests = { locked } + local beforeX, beforeY = lockedGame.player.x, lockedGame.player.y + lockedGame:choosePlayerAction() + eq(locked.opened, false, "the autopilot never gambles a lock it has neither a key nor a lockpick for") + ok(lockedGame.player.x ~= beforeX or lockedGame.player.y ~= beforeY, + "the autopilot still makes progress toward the stairs instead of parking on an unopenable chest") +end + +function tests.morgueScoreWeighsEveryStatByItsOwnFixedWeight() + local w = Morgue.SCORE_WEIGHTS + local report = { + deepestDepth = 4, kills = 12, bossKills = 1, questsCompleted = 2, artifactsFound = 1, + vaultsOpened = 1, setPiecesFound = 3, chestsOpened = 5, playerLevel = 6, gold = 40, won = false, + } + local expected = 4 * w.depth + 12 * w.kill + 1 * w.bossKill + 2 * w.quest + 1 * w.artifact + + 1 * w.vault + 3 * w.setPiece + 5 * w.chest + 6 * w.playerLevel + 40 + eq(Morgue.score(report), expected, "score sums every weighted stat plus flat gold") +end + +function tests.morgueScoreAddsTheWinBonusOnlyWhenWon() + local base = { + deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, + vaultsOpened = 0, setPiecesFound = 0, chestsOpened = 0, playerLevel = 1, gold = 0, won = false, + } + local lost = Morgue.score(base) + base.won = true + local won = Morgue.score(base) + eq(won - lost, Morgue.SCORE_WEIGHTS.win, "winning adds exactly the win bonus and nothing else") +end + +function tests.morgueEquipmentListsOnlyOccupiedSlotsInFixedOrder() + local game = Game.new(9) + eq(#Morgue.equipment(game.player), 0, "a fresh player with nothing worn has no equipment lines") + game.player.armor = Items.armor("plate", 4) + game.player.weapon = Items.weapon("sword", 3) + local lines = Morgue.equipment(game.player) + eq(#lines, 2, "only the two worn slots produce a line") + eq(lines[1], "weapon: sword", "weapon is listed before armor regardless of assignment order") + eq(lines[2], "armor: plate", "armor keeps its own slot label") +end + +function tests.morgueEquipmentPrefixesARevealedCurseButNotAHiddenOne() + local game = Game.new(9) + local ring = Items.ring("ring of woe", 0, 0) + Curses.afflict(ring, Curses.pool.ring[1]) + game.player.ring = ring + eq(Morgue.equipment(game.player)[1], "ring: ring of woe", "an unrevealed curse reads like an ordinary item") + Curses.reveal(ring) + eq(Morgue.equipment(game.player)[1], "ring: cursed ring of woe", "a revealed curse is prefixed") +end + +function tests.morgueKillTallySortsByCountThenByCatalogOrderOnTies() + local compendium = Compendium.new() + Compendium.noteKill(compendium, "goblin") + Compendium.noteKill(compendium, "rat") + Compendium.noteKill(compendium, "rat") + Compendium.noteKill(compendium, "bat") + local rows = Morgue.killTally(compendium) + eq(#rows, 3, "only species with at least one kill appear") + eq(rows[1].key, "rat", "the heaviest kill count sorts first") + eq(rows[1].kills, 2, "rat's tally is exactly two") + eq(rows[2].key, "bat", "a tie between bat and goblin breaks toward bat's earlier catalog slot") + eq(rows[3].key, "goblin", "goblin follows bat on the same tied count") +end + +function tests.morgueBuildMirrorsGameStatsPlayerAndCompendium() + local game = Game.new(21) + game.stats.kills = 3 + game.stats.bossKills = 1 + game.deepestDepth = 5 + game.turn = 120 + game.player.gold = 77 + Compendium.noteKill(game.compendium, "orc") + local report = Morgue.build(game) + eq(report.kills, 3, "build copies the live kill tally") + eq(report.bossKills, 1, "build copies the live boss kill tally") + eq(report.deepestDepth, 5, "build copies the deepest depth reached") + eq(report.turn, 120, "build copies the current turn") + eq(report.gold, 77, "build copies the player's gold") + eq(report.alive, true, "a living player's report says so") + eq(#report.killTally, 1, "the compendium's own kill feeds straight into the tally") + eq(report.killTally[1].key, "orc", "the tallied species matches what was actually killed") +end + +function tests.morgueFormatRendersOutcomeGearKillsAndScoreInOrder() + local game = Game.new(3) + game.player.weapon = Items.weapon("axe", 2) + Compendium.noteKill(game.compendium, "kobold") + game.player.hp = 0 + local text = Morgue.format(Morgue.build(game)) + ok(contains(text, "died in the dark"), "a dead player's obituary names the outcome") + ok(contains(text, "weapon: axe"), "the report lists the worn weapon") + ok(contains(text, "kobold"), "the report lists a slain species") + local scoreLine = "score: " .. Morgue.score(Morgue.build(game)) + ok(contains(text, scoreLine), "the report ends with the matching numeric score") + local scorePos = text:find("score:", 1, true) + local killsPos = text:find("kills:", 1, true) + ok(killsPos < scorePos, "the kill tally is printed before the score line") +end + +function tests.morgueFormatNamesTheWinOutcomeWhenWon() + local game = Game.new(3) + game.won = true + local text = Morgue.format(Morgue.build(game)) + ok(contains(text, "Victorious the wanderer"), "a won run's heading calls the player victorious") + ok(contains(text, "escaped the warren with the Amulet"), "a won run's outcome line names the escape") +end + +function tests.morgueFormatOmitsEquipmentAndKillsSectionsWhenEmpty() + local game = Game.new(3) + local text = Morgue.format(Morgue.build(game)) + ok(not contains(text, "equipment:"), "an unarmed player's report has no equipment section") + ok(not contains(text, "kills:"), "a report with no recorded kills has no kill section") + ok(contains(text, "still drawing breath"), "a living player's report says so") +end + +function tests.leaderboardRecordOnAnEmptyBoardLandsAtRankOne() + local board = Leaderboard.new() + local rank = Leaderboard.record(board, { score = 100 }) + eq(rank, 1, "the first entry ever recorded ranks first") + eq(#board, 1, "the board now holds exactly one entry") +end + end local order = { From f68d0299e32f66ef12729a00813e25fabc193a19 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:34:09 +0200 Subject: [PATCH 139/172] test: cover tests.leaderboardRecordKeepsTheBoardSortedBestScoreFirst --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index a10690c..0941769 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9817,6 +9817,179 @@ function tests.leaderboardRecordOnAnEmptyBoardLandsAtRankOne() eq(#board, 1, "the board now holds exactly one entry") end +function tests.leaderboardRecordKeepsTheBoardSortedBestScoreFirst() + local board = Leaderboard.new() + Leaderboard.record(board, { score = 50, tag = "a" }) + Leaderboard.record(board, { score = 90, tag = "b" }) + Leaderboard.record(board, { score = 70, tag = "c" }) + eq(board[1].tag, "b", "the highest score sorts to the very top") + eq(board[2].tag, "c", "the middling score lands second") + eq(board[3].tag, "a", "the lowest score lands last") + local rank = Leaderboard.record(board, { score = 80, tag = "d" }) + eq(rank, 2, "a fresh entry ranks by where it actually lands, not where it was inserted") + eq(board[2].tag, "d", "the new entry displaces the weaker one down a slot") + eq(board[3].tag, "c", "everything below the insertion point shifts down by one") +end + +function tests.leaderboardRecordDropsAnEntryThatDoesNotCrackTheCap() + local board = Leaderboard.new() + for i = 1, Leaderboard.CAPACITY do + Leaderboard.record(board, { score = 1000 - i }) + end + eq(#board, Leaderboard.CAPACITY, "the board fills to exactly its capacity") + local rank = Leaderboard.record(board, { score = 1 }) + eq(rank, nil, "a score worse than everything already on the board does not crack it") + eq(#board, Leaderboard.CAPACITY, "the board stays at capacity rather than growing") + eq(board[Leaderboard.CAPACITY].score, 1000 - Leaderboard.CAPACITY, "the weakest surviving entry is unchanged") +end + +function tests.leaderboardRecordBumpsTheWeakestEntryOffOnceFull() + local board = Leaderboard.new() + for i = 1, Leaderboard.CAPACITY do + Leaderboard.record(board, { score = 1000 - i }) + end + local worst = board[Leaderboard.CAPACITY].score + local rank = Leaderboard.record(board, { score = 1500 }) + ok(rank ~= nil, "a score that beats the current worst entry cracks the board") + eq(#board, Leaderboard.CAPACITY, "the board stays at capacity after bumping someone off") + for _, entry in ipairs(board) do + ok(entry.score ~= worst, "the previous weakest entry no longer appears on the board") + end +end + +function tests.leaderboardTopReturnsAtMostCountEntriesInOrder() + local board = Leaderboard.new() + Leaderboard.record(board, { score = 30 }) + Leaderboard.record(board, { score = 10 }) + Leaderboard.record(board, { score = 20 }) + local top2 = Leaderboard.top(board, 2) + eq(#top2, 2, "top(2) returns exactly two entries") + eq(top2[1].score, 30, "the first slot is the best score") + eq(top2[2].score, 20, "the second slot is the next best score") +end + +function tests.leaderboardTopDefaultsToTheFullCapacityWhenNoCountIsGiven() + local board = Leaderboard.new() + for i = 1, Leaderboard.CAPACITY + 3 do + Leaderboard.record(board, { score = i }) + end + local top = Leaderboard.top(board) + eq(#top, Leaderboard.CAPACITY, "top() with no count returns the board's full capacity, not everything ever recorded") +end + +function tests.leaderboardBestScoreReadsTheTopEntryOrZeroWhenEmpty() + eq(Leaderboard.bestScore(Leaderboard.new()), 0, "an empty board's best score is zero") + local board = Leaderboard.new() + Leaderboard.record(board, { score = 42 }) + eq(Leaderboard.bestScore(board), 42, "the best score matches the single recorded entry") +end + +function tests.metaLeaderboardFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip() + local fresh = Meta.load("/tmp/warren_meta_does_not_exist_55511.sav") + eq(#fresh.leaderboard, 0, "a freshly defaulted meta state has an empty leaderboard") + local meta = Meta.new() + Leaderboard.record(meta.leaderboard, { score = 250, className = "mage", depth = 4, turn = 88, won = false, seed = 7 }) + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) + os.remove(path) + eq(#restored.leaderboard, 1, "a recorded leaderboard entry survives a save/load round trip") + eq(restored.leaderboard[1].score, 250, "a restored entry keeps its own score") + eq(restored.leaderboard[1].className, "mage", "a restored entry keeps its own class") +end + +function tests.replayRunIsDeterministicAndMatchesAFreshGamesOwnReport() + local seed, steps = 55, 60 + local game = Game.new(seed) + for _ = 1, steps do + if not game.player:isAlive() then break end + game:autoStep() + end + local expected = Morgue.build(game) + local replayed = Replay.run(seed, nil, steps) + local matches, mismatchField = Replay.matches(expected, replayed) + ok(matches, "replaying the same seed and step count reproduces every compared field (first mismatch: " + .. tostring(mismatchField) .. ")") + eq(replayed.turn, expected.turn, "the replay reaches the exact same turn") + eq(replayed.deepestDepth, expected.deepestDepth, "the replay reaches the exact same depth") +end + +function tests.replayVerifyConfirmsAGenuineReportAndReturnsItsScore() + local seed, steps = 55, 60 + local game = Game.new(seed) + for _ = 1, steps do + if not game.player:isAlive() then break end + game:autoStep() + end + local report = Morgue.build(game) + local matches, mismatchField, score = Replay.verify(seed, nil, steps, report) + ok(matches, "verify confirms a report that genuinely came from this seed and step count") + eq(mismatchField, nil, "a matching verify names no mismatched field") + eq(score, Morgue.score(report), "verify's returned score matches the report's own score") +end + +function tests.replayVerifyFlagsATamperedReportAndStillReturnsTheRealScore() + local seed, steps = 55, 60 + local game = Game.new(seed) + for _ = 1, steps do + if not game.player:isAlive() then break end + game:autoStep() + end + local report = Morgue.build(game) + local tampered = {} + for k, v in pairs(report) do tampered[k] = v end + tampered.gold = report.gold + 9999 + local matches, mismatchField, score = Replay.verify(seed, nil, steps, tampered) + eq(matches, false, "a tampered gold total fails verification") + eq(mismatchField, "gold", "verify names gold as the field that diverged") + eq(score, Morgue.score(report), "the replay still reports the real, untampered score") +end + +function tests.replayComparedFieldsCoversEveryFieldMorgueScoreActuallyReads() + local weighed = { + "deepestDepth", "kills", "bossKills", "questsCompleted", "artifactsFound", + "vaultsOpened", "setPiecesFound", "chestsOpened", "playerLevel", "gold", "won", + } + for _, field in ipairs(weighed) do + local found = false + for _, compared in ipairs(Replay.COMPARED_FIELDS) do + if compared == field then found = true end + end + ok(found, "Replay.COMPARED_FIELDS includes '" .. field .. "', every field Morgue.score weighs") + end +end + +function tests.replayMatchesReportsTheFirstDivergingFieldInComparedFieldsOrder() + local base = { + won = false, alive = true, deepestDepth = 3, turn = 100, gold = 10, essence = 2, + kills = 1, bossKills = 0, questsCompleted = 0, artifactsFound = 0, + vaultsOpened = 0, setPiecesFound = 0, chestsOpened = 0, playerLevel = 1, + } + local altered = {} + for k, v in pairs(base) do altered[k] = v end + altered.turn = 999 + local matches, field = Replay.matches(base, altered) + eq(matches, false, "a divergent turn count fails the match") + eq(field, "turn", "the mismatch is reported against the turn field specifically") + eq(Replay.matches(base, base), true, "an identical report matches itself") +end + +function tests.challengesForDaySameDayAlwaysReturnsTheSamePresetAndSeed() + local presetA, seedA = Challenges.forDay(7) + local presetB, seedB = Challenges.forDay(7) + eq(presetA, presetB, "the same day index always names the identical preset table") + eq(seedA, seedB, "the same day index always yields the identical seed") +end + +function tests.challengesForDayCyclesThroughTheCatalogInOrder() + for i = 0, #Challenges.CATALOG - 1 do + local preset = Challenges.forDay(i) + eq(preset, Challenges.CATALOG[i + 1], "day " .. i .. " maps to catalog slot " .. (i + 1)) + end + local wrapped = Challenges.forDay(#Challenges.CATALOG) + eq(wrapped, Challenges.CATALOG[1], "the cycle wraps back to the first preset after one full lap") +end + end local order = { From fac05c737f5af95ce3b7e860b670e3cacf2a3827 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 20:36:41 +0200 Subject: [PATCH 140/172] test: cover tests.challengesSeedForStaysWithinTheDefaultSeedRange --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 0941769..e85c777 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9990,6 +9990,179 @@ function tests.challengesForDayCyclesThroughTheCatalogInOrder() eq(wrapped, Challenges.CATALOG[1], "the cycle wraps back to the first preset after one full lap") end +function tests.challengesSeedForStaysWithinTheDefaultSeedRange() + for _, day in ipairs({ 0, 1, 42, 365, 999999 }) do + local seed = Challenges.seedFor(day) + ok(seed >= 0 and seed < 65536, "day " .. day .. " hashes into the same 16-bit range as the default seed") + end +end + +function tests.challengesCatalogPresetsReferenceOnlyRealClassesAndMutators() + for _, preset in ipairs(Challenges.CATALOG) do + ok(Classes.isValid(preset.class), preset.key .. " names a real class") + for _, key in ipairs(preset.mutators) do + ok(Ascension.byKey[key] ~= nil, preset.key .. "'s mutator '" .. key .. "' is a real ascension mutator") + end + end +end + +function tests.challengesDescribeNamesTheClassAndEveryRequiredMutator() + local described = Challenges.describe(Challenges.byKey.wild_hunt) + eq(described, "Wild Hunt (ranger + nightblind)", "a single-mutator preset lists its class and that mutator") +end + +function tests.challengesDescribeOmitsTheMutatorJoinWhenNoneAreRequired() + local described = Challenges.describe(Challenges.byKey.shadow_creed) + eq(described, "Shadow Creed (rogue)", "a mutator-free preset reads as just its name and class") +end + +function tests.challengesSatisfiedByFailsOnAClassMismatch() + local game = Game.new(9, { class = "rogue" }) + local satisfied, reason = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game) + eq(satisfied, false, "a rogue never satisfies a fighter-only challenge") + ok(contains(reason, "fighter"), "the failure names the class the challenge actually required") +end + +function tests.challengesSatisfiedBySucceedsWithTheRightClassAndNoMutatorsRequired() + local game = Game.new(9, { class = "rogue" }) + local satisfied = Challenges.satisfiedBy(Challenges.byKey.shadow_creed, game) + eq(satisfied, true, "matching the class is enough when the preset demands no mutators") +end + +function tests.challengesSatisfiedByFailsWhenARequiredMutatorNeverActivated() + local game = Game.new(9, { class = "fighter" }) + local satisfied, reason = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game) + eq(satisfied, false, "a run with no active mutators cannot satisfy one that requires bloodbound") + eq(reason, "missing mutator bloodbound", "the failure names the exact missing mutator") +end + +function tests.challengesSatisfiedBySucceedsOnceItsRequiredMutatorIsActuallyActive() + local unlocked = Meta.new() + unlocked.bestDepth = 3 + local game = Game.new(9, { class = "fighter", meta = unlocked, mutators = { "bloodbound" } }) + local satisfied = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game) + eq(satisfied, true, "the challenge is honored once its required mutator actually resolved active") +end + +function tests.analyticsWinRateIsZeroWithNoRecordedRunsAndFractionalOtherwise() + eq(Analytics.winRate(Meta.new()), 0, "a state with no recorded runs has a zero win rate") + local meta = Meta.new() + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) + eq(Analytics.winRate(meta), 0.5, "one win out of two runs is a 50% win rate") +end + +function tests.analyticsAverageScoreIsZeroOnAnEmptyBoardAndTheMeanOtherwise() + eq(Analytics.averageScore(Leaderboard.new()), 0, "an empty leaderboard has a zero average score") + local board = Leaderboard.new() + Leaderboard.record(board, { score = 100 }) + Leaderboard.record(board, { score = 200 }) + Leaderboard.record(board, { score = 300 }) + eq(Analytics.averageScore(board), 200, "the average of 100, 200 and 300 is exactly 200") +end + +function tests.analyticsFavoriteClassPicksTheMostFrequentTiesBrokenByFirstAppearance() + eq(Analytics.favoriteClass(Leaderboard.new()), nil, "an empty leaderboard has no favorite class") + local board = Leaderboard.new() + Leaderboard.record(board, { score = 90, className = "rogue" }) + Leaderboard.record(board, { score = 80, className = "mage" }) + Leaderboard.record(board, { score = 70, className = "mage" }) + eq(Analytics.favoriteClass(board), "mage", "mage appears twice, more than any other class") + + local tied = Leaderboard.new() + Leaderboard.record(tied, { score = 50, className = "fighter" }) + Leaderboard.record(tied, { score = 40, className = "ranger" }) + eq(Analytics.favoriteClass(tied), "fighter", + "a tie between equally-frequent classes breaks toward whichever appeared first on the board") +end + +function tests.analyticsPercentileRanksAScoreAgainstTheBoardItWouldJoin() + eq(Analytics.percentile(Leaderboard.new(), 999), 1, "an empty board can never hold anything back") + local board = Leaderboard.new() + Leaderboard.record(board, { score = 100 }) + Leaderboard.record(board, { score = 200 }) + Leaderboard.record(board, { score = 300 }) + Leaderboard.record(board, { score = 400 }) + eq(Analytics.percentile(board, 999), 1, "a score above everything beats the entire board") + eq(Analytics.percentile(board, 1), 0, "a score below everything beats nothing") + eq(Analytics.percentile(board, 250), 0.5, "a score beating exactly half the board reads as 50%") + eq(Analytics.percentile(board, 200), 0.25, "a tied score counts as beaten, not beating, its equal") +end + +function tests.titlesForRenownNeverReturnsNilForAnyNonNegativeRenown() + for _, renown in ipairs({ 0, 1, 49, 50, 350, 1499, 1500, 100000 }) do + ok(Titles.forRenown(renown) ~= nil, "renown " .. renown .. " always resolves to some title") + end +end + +function tests.titlesForRenownWalksTheLadderFromTheBottomUp() + eq(Titles.forRenown(0), "Whelp", "zero renown starts at the very bottom of the ladder") + eq(Titles.forRenown(49), "Whelp", "one short of a threshold stays on the lower title") + eq(Titles.forRenown(50), "Delver", "landing exactly on a threshold claims that title") + eq(Titles.forRenown(349), "Warden", "just under the next threshold keeps the current title") + eq(Titles.forRenown(1500), "Undying", "the exact top threshold claims the top title") + eq(Titles.forRenown(999999), "Undying", "renown far past the ladder still caps at the top title") +end + +function tests.titlesToNextCountsDownToTheNextThresholdAndNilsOutAtTheTop() + eq(Titles.toNext(0), 50, "starting from zero needs exactly the first threshold's renown") + eq(Titles.toNext(45), 5, "five renown short of the first threshold reports exactly five") + eq(Titles.toNext(50), 100, "landing on a threshold reports the gap to the next one, not zero") + eq(Titles.toNext(1500), nil, "sitting at the top threshold has no further tier to climb toward") + eq(Titles.toNext(50000), nil, "renown past the top of the ladder also has nothing left to climb toward") +end + +function tests.morgueBuildOmitsTitleWithoutMetaAndIncludesItWithMeta() + local game = Game.new(3) + local bare = Morgue.build(game) + eq(bare.title, nil, "a meta-free build carries no title") + local meta = Meta.new() + meta.renown = 60 + local withMeta = Morgue.build(game, meta) + eq(withMeta.title, "Delver", "a build given meta carries the title that renown actually earns") + local text = Morgue.format(withMeta) + ok(contains(text, "the Delver"), "the formatted report names the earned title in its heading") + ok(not contains(Morgue.format(bare), "the Delver"), "a title-less report never mentions any title") +end + +function tests.exportLeaderboardCsvOnAnEmptyBoardIsJustTheHeaderRow() + local csv = Export.leaderboardCsv(Leaderboard.new()) + eq(csv, table.concat(Export.COLUMNS, ",") .. "\n", "an empty board exports to a single header line") +end + +function tests.exportLeaderboardCsvEmitsOneRowPerEntryInRankOrder() + local board = Leaderboard.new() + Leaderboard.record(board, { score = 300, className = "fighter", depth = 5, turn = 200, won = true, seed = 11 }) + Leaderboard.record(board, { score = 200, className = "mage", depth = 3, turn = 150, won = false, seed = 22 }) + local csv = Export.leaderboardCsv(board) + local lines = {} + for line in csv:gmatch("[^\n]+") do lines[#lines + 1] = line end + eq(#lines, 3, "the header plus one row per entry gives three lines total") + eq(lines[1], "rank,score,className,depth,turn,won,seed", "the header names every column in a fixed order") + eq(lines[2], "1,300,fighter,5,200,true,11", "the top-ranked entry becomes the first data row") + eq(lines[3], "2,200,mage,3,150,false,22", "the second-ranked entry keeps that same rank order") +end + +function tests.exportLeaderboardCsvRendersBooleansAsLowercaseWords() + local board = Leaderboard.new() + Leaderboard.record(board, { score = 50, className = "rogue", depth = 1, turn = 10, won = true, seed = 1 }) + ok(contains(Export.leaderboardCsv(board), ",true,"), "a won run's CSV row spells out 'true', not '1' or 'TRUE'") +end + +function tests.exportCompendiumCsvListsEverySpeciesSeenOrNot() + local compendium = Compendium.new() + Compendium.noteSighting(compendium, "rat") + Compendium.noteKill(compendium, "rat") + Compendium.noteKill(compendium, "rat") + local csv = Export.compendiumCsv(compendium) + local lines = {} + for line in csv:gmatch("[^\n]+") do lines[#lines + 1] = line end + eq(lines[1], "key,name,seen,kills", "the header names every compendium export column") + eq(#lines, #Compendium.entries(compendium) + 1, "every catalogued species gets a row, sighted or not") + ok(contains(csv, "rat,rat,true,2"), "a sighted, twice-slain species reports its own seen flag and tally") + ok(contains(csv, "false,0"), "an undiscovered species still reports as unseen with a zero tally") +end + end local order = { From 8e7c57dcbfffd352a2ff3985a5b0d7b50c44a7ef Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 23:12:01 +0200 Subject: [PATCH 141/172] test: cover tests.streaksCurrentIsZeroAndNilOnAnEmptyHistory --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index e85c777..4204c47 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10163,6 +10163,179 @@ function tests.exportCompendiumCsvListsEverySpeciesSeenOrNot() ok(contains(csv, "false,0"), "an undiscovered species still reports as unseen with a zero tally") end +function tests.streaksCurrentIsZeroAndNilOnAnEmptyHistory() + local length, outcome = Streaks.current(Streaks.new()) + eq(length, 0, "an empty history has a streak length of zero") + eq(outcome, nil, "an empty history has no outcome to report") +end + +function tests.streaksCurrentCountsOnlyTheUnbrokenRunAtTheEnd() + local history = Streaks.new() + Streaks.record(history, true) + Streaks.record(history, true) + Streaks.record(history, false) + Streaks.record(history, false) + Streaks.record(history, false) + local length, outcome = Streaks.current(history) + eq(length, 3, "three losses in a row at the tail end give a streak of three") + eq(outcome, false, "the current streak's outcome is a loss") + + Streaks.record(history, true) + length, outcome = Streaks.current(history) + eq(length, 1, "a single win right after a loss streak is itself a streak of exactly one") + eq(outcome, true, "the fresh win flips the current outcome to a win") +end + +function tests.streaksRecordTrimsHistoryPastItsCap() + local history = Streaks.new() + for i = 1, Streaks.HISTORY_CAP + 10 do + Streaks.record(history, i % 2 == 0) + end + eq(#history, Streaks.HISTORY_CAP, "history never grows past its fixed cap") +end + +function tests.streaksRecordDropsTheOldestEntryNotTheNewestOnceOverCap() + local history = Streaks.new() + for i = 1, Streaks.HISTORY_CAP do + Streaks.record(history, false) + end + eq(#history, Streaks.HISTORY_CAP, "filling exactly to the cap keeps every entry") + Streaks.record(history, true) + eq(#history, Streaks.HISTORY_CAP, "one more push still respects the same cap") + eq(history[#history], true, "the newest push is the one that survives at the tail") + eq(history[1], false, "the oldest entry was the one dropped to make room") +end + +function tests.streaksBestWinStreakFindsTheLongestWinRunAnywhereNotJustTheActiveOne() + local history = Streaks.new() + for _, won in ipairs({ true, true, true, false, true, false, true, true }) do + Streaks.record(history, won) + end + eq(Streaks.bestWinStreak(history), 3, "the best win streak is the earlier run of three, not the trailing two") + local currentLength, currentOutcome = Streaks.current(history) + eq(currentLength, 2, "the currently active streak is only two long") + eq(currentOutcome, true, "the currently active streak is a winning one") +end + +function tests.streaksBestWinStreakIsZeroWithNoWinsAtAll() + local history = Streaks.new() + Streaks.record(history, false) + Streaks.record(history, false) + eq(Streaks.bestWinStreak(history), 0, "a history of nothing but losses has a best win streak of zero") +end + +function tests.metaRecordRunFeedsStreaksAndRunHistorySurvivesASaveLoadRoundTrip() + local fresh = Meta.load("/tmp/warren_meta_does_not_exist_71234.sav") + eq(#fresh.runHistory, 0, "a freshly defaulted meta state has an empty run history") + local meta = Meta.new() + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) + eq(#meta.runHistory, 2, "each recorded run appends exactly one entry to the history") + local currentLength, currentOutcome = Streaks.current(meta.runHistory) + eq(currentLength, 1, "the most recent recorded run is a fresh streak of one") + eq(currentOutcome, false, "the most recent recorded run was a loss") + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) + os.remove(path) + eq(#restored.runHistory, 2, "run history survives a save/load round trip") + eq(restored.runHistory[1], true, "the oldest entry keeps its own outcome") + eq(restored.runHistory[2], false, "the newest entry keeps its own outcome") +end + +function tests.metaRecordRunUnlocksComebackOnlyAfterALossThenAWin() + local meta = Meta.new() + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) + eq(meta.achievements.comeback, nil, "a lone loss cannot unlock a comeback") + Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + eq(meta.achievements.comeback, true, "winning right after a loss unlocks the comeback") +end + +function tests.metaRecordRunDoesNotUnlockComebackOnAFirstEverWinOrAWinAfterAWin() + local isolated = Meta.new() + Meta.recordRun(isolated, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + eq(isolated.achievements.comeback, nil, "a first-ever run that wins has no prior loss to come back from") + + local streak = Meta.new() + Meta.recordRun(streak, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + Meta.recordRun(streak, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + eq(streak.achievements.comeback, nil, "a win right after another win is not a comeback") +end + +-- Nemesis (nemesis.lua): a cross-floor hunter born mid-run from a plain +-- monster that was actually hunting the player at the stairs, escalating +-- every time it follows without being killed first. + +function tests.nemesisIsEligibleRequiresAHuntingHostileAndExcludesEverySpecialCase() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 1) + ok(not Nemesis.isEligible(orc), "a monster that has not noticed the player yet is not eligible") + orc.awareness = Stealth.HUNTING + ok(Nemesis.isEligible(orc), "a hunting monster under a self-directed ai is eligible") + + local wretch = Monsters.create(Monsters.byKey.wretch, 2, 5, 5, 1) + wretch.awareness = Stealth.HUNTING + ok(not Nemesis.isEligible(wretch), "a permanent flee species never gives chase down a stairwell") + + local shopkeeper = Monsters.create(Monsters.byKey.shopkeeper, 3, 5, 5, 4) + shopkeeper.awareness = Stealth.HUNTING + ok(not Nemesis.isEligible(shopkeeper), "the shopkeeper's vend ai is never eligible") + + local boss = Bosses.create(Bosses.byKey.lich, 4, 5, 5) + boss.awareness = Stealth.HUNTING + ok(not Nemesis.isEligible(boss), "a boss already has its own escalation path") + + local elite = Monsters.create(Monsters.byKey.orc, 5, 5, 5, 3) + elite.awareness, elite.elite = Stealth.HUNTING, true + ok(not Nemesis.isEligible(elite), "an elite already has its own escalation path") + + local mimic = Mimics.reveal(6, 5, 5, 3) + mimic.awareness = Stealth.HUNTING + ok(not Nemesis.isEligible(mimic), "a revealed mimic already has its own escalation path") + + local shade = Graves.reveal(7, 5, 5, {}) + shade.awareness = Stealth.HUNTING + ok(not Nemesis.isEligible(shade), "a grave shade already has its own escalation path") + + local dead = Monsters.create(Monsters.byKey.rat, 8, 5, 5, 1) + dead.awareness, dead.hp = Stealth.HUNTING, 0 + ok(not Nemesis.isEligible(dead), "a dead monster cannot give chase") + + local already = Monsters.create(Monsters.byKey.rat, 9, 5, 5, 1) + already.awareness, already.nemesis = Stealth.HUNTING, true + ok(not Nemesis.isEligible(already), "an existing nemesis cannot spawn a second one") +end + +function tests.nemesisPickHunterReturnsTheNearestEligibleHunterBreakingTiesByListOrder() + local player = Entity.new({ x = 5, y = 5, glyph = "@", name = "you", kind = "player", hp = 20, attack = 5, defense = 1 }) + local level = arena(21, 11) + local far = Monsters.create(Monsters.byKey.orc, 1, 15, 5, 1) + far.awareness = Stealth.HUNTING + local dormant = Monsters.create(Monsters.byKey.orc, 2, 6, 5, 1) + local near = Monsters.create(Monsters.byKey.orc, 3, 7, 5, 1) + near.awareness = Stealth.HUNTING + level.entities = { far, dormant, near } + eq(Nemesis.pickHunter(level, player), near, "the nearest eligible hunter wins over a dormant closer monster and a farther one") + + local tie = Monsters.create(Monsters.byKey.orc, 4, 3, 5, 1) + tie.awareness = Stealth.HUNTING + level.entities = { near, tie } + eq(Nemesis.pickHunter(level, player), near, "an equidistant tie is broken by earlier list order") + + level.entities = { dormant } + eq(Nemesis.pickHunter(level, player), nil, "no eligible hunter on the floor returns nil") +end + +function tests.nemesisBeginFromCapturesSpeciesAndHpFractionAtGrudgeOne() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 1) + orc.hp = orc.maxHp // 2 + local pursuit = Nemesis.beginFrom(orc) + eq(pursuit.species, "orc", "the pursuit remembers which species is following") + eq(pursuit.grudge, 1, "a freshly begun pursuit starts at grudge one") + eq(pursuit.hpFraction, 0.5, "the pursuit captures the hunter's hp fraction at the moment it gave chase") +end + +function tests.nemesisEscalateRaisesGrudgeAndHealsTowardFullBothCapped() + local pursuit = { species = "orc", grudge = 1, hpFraction = 0.3 } end local order = { From 216365838135602238725dfbae78118d2895db45 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 12:25:21 +0200 Subject: [PATCH 142/172] test: cover tests.nemesisSpawnScalesHpAndAttackWithGrudgeAndAppliesTh... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 4204c47..27847f0 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10336,6 +10336,179 @@ end function tests.nemesisEscalateRaisesGrudgeAndHealsTowardFullBothCapped() local pursuit = { species = "orc", grudge = 1, hpFraction = 0.3 } + Nemesis.escalate(pursuit) + eq(pursuit.grudge, 2, "escalating raises the grudge by one") + eq(pursuit.hpFraction, 0.5, "escalating heals the pursuit's carried hp fraction") + for _ = 1, 10 do Nemesis.escalate(pursuit) end + eq(pursuit.grudge, Nemesis.MAX_GRUDGE, "grudge never climbs past its cap") + eq(pursuit.hpFraction, 1, "hp fraction never heals past full") +end + +function tests.nemesisSpawnScalesHpAndAttackWithGrudgeAndAppliesTheCarriedHpFraction() + local base = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 1) + local weak = Nemesis.spawn({ species = "orc", grudge = 1, hpFraction = 0.5 }, 2, 5, 5, 1) + eq(weak.maxHp, base.maxHp + Nemesis.bonusHp(1), "a grudge-one nemesis carries a small hp bonus over the plain species") + eq(weak.hp, math.floor(weak.maxHp * 0.5), "the spawned nemesis's hp reflects its carried hp fraction") + eq(weak.attack, base.attack + Nemesis.bonusAttack(1), "a grudge-one nemesis carries a small attack bonus") + ok(weak.nemesis, "the spawned entity is flagged as a nemesis") + eq(weak.nemesisGrudge, 1, "the spawned entity remembers its own grudge tier") + eq(weak.species, "orc", "the spawned nemesis keeps the species key its xp/faction lookups depend on") + + local fierce = Nemesis.spawn({ species = "orc", grudge = 4, hpFraction = 1 }, 3, 5, 5, 1) + ok(fierce.maxHp > weak.maxHp, "a higher grudge nemesis carries a larger hp bonus") + ok(fierce.attack > weak.attack, "a higher grudge nemesis carries a larger attack bonus") + + local sliver = Nemesis.spawn({ species = "orc", grudge = 1, hpFraction = 0.001 }, 4, 5, 5, 1) + ok(sliver.hp >= 1, "a nemesis spawned from a nearly-dead fraction never rounds down to zero hp") +end + +function tests.nemesisNameForEscalatesThroughEachEpithetAndClampsAtTheLast() + eq(Nemesis.nameFor("orc", 1), "orc, " .. Nemesis.EPITHETS[1], "grudge one uses the first epithet") + eq(Nemesis.nameFor("orc", #Nemesis.EPITHETS), "orc, " .. Nemesis.EPITHETS[#Nemesis.EPITHETS], + "grudge at the epithet count uses the last epithet") + eq(Nemesis.nameFor("orc", Nemesis.MAX_GRUDGE + 5), "orc, " .. Nemesis.EPITHETS[#Nemesis.EPITHETS], + "grudge beyond the epithet list clamps to the last epithet rather than indexing past it") +end + +function tests.nemesisXpBonusAndBountyGoldScaleWithGrudge() + ok(Nemesis.xpBonus(2) > Nemesis.xpBonus(1), "a higher grudge pays a bigger xp bonus") + ok(Nemesis.bountyGold(2) > Nemesis.bountyGold(1), "a higher grudge pays a bigger bounty") + eq(Nemesis.bountyGold(1), 10, "grudge one's bounty matches the catalog base") +end + +-- Seed 3, not 5: adding a tenth potion type (levitate) grew identify.lua's +-- potionLooks pool by one, which costs Identify.new one extra shuffle draw +-- at world creation - every seed's subsequent rng stream shifts by exactly +-- that much, including this test's nemesis-emergence chance roll. Reseeded +-- by actually running Game:descend() across a range and picking one where +-- the roll still succeeds, not guessed. +function tests.gameDescendCanRollANewNemesisFromAHuntingMonsterLeftOnTheDepartingFloor() + local game = stagedGame(3, arena(21, 11), 2, 2) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + orc.awareness = Stealth.HUNTING + game.level.entities = { orc } + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + ok(game.nemesis ~= nil, "this seed's departure roll gives the hunting orc a chance to follow") + eq(game.nemesis.species, "orc", "the nemesis descriptor remembers which species followed") + local found + for _, entity in ipairs(game.level.entities) do + if entity.nemesis then found = entity end + end + ok(found ~= nil, "the nemesis is actually placed onto the floor just reached") + eq(found.nemesisGrudge, 1, "a freshly emerged nemesis starts at grudge one") +end + +function tests.gameDescendNeverRollsANewNemesisFromAMonsterThatIsNotActuallyHunting() + for seed = 1, 30 do + local game = stagedGame(seed, arena(21, 11), 2, 2) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + game.level.entities = { orc } + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + ok(game.nemesis == nil, "a dormant monster never gives chase no matter the seed") + end +end + +function tests.gameNemesisFollowsAcrossMultipleDescentsEscalatingItsGrudgeEachTime() + local game = stagedGame(1, arena(21, 11), 2, 2) + game.nemesis = { species = "orc", grudge = 1, hpFraction = 1 } + local hunter = Nemesis.spawn(game.nemesis, game:nextMonsterId(), 10, 6, 1) + game.level.entities = { hunter } + game.player.x, game.player.y = game.level.downX, game.level.downY + + game:descend() + eq(game.nemesis.grudge, 2, "following once past the first stairwell raises the grudge to two") + local first + for _, entity in ipairs(game.level.entities) do if entity.nemesis then first = entity end end + ok(first ~= nil, "the escalated nemesis is placed on the second floor") + eq(first.nemesisGrudge, 2, "the respawned nemesis carries the escalated grudge") + + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.nemesis.grudge, 3, "following a second time raises the grudge again") + local second + for _, entity in ipairs(game.level.entities) do if entity.nemesis then second = entity end end + ok(second ~= nil, "the nemesis follows onto the third floor too") + local nemesisCount = 0 + for _, entity in ipairs(game.level.entities) do + if entity.nemesis then nemesisCount = nemesisCount + 1 end + end + eq(nemesisCount, 1, "exactly one nemesis is ever on a floor at a time, never a duplicate") +end + +function tests.gameAscendCarriesAnActiveNemesisAlongButNeverRollsANewOne() + local game = stagedGame(1, arena(21, 11), 2, 2) + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.depth, 2, "the game is now on the second floor") + eq(game.nemesis, nil, "no nemesis was following yet") + + for seed = 1, 20 do + local ascendGame = stagedGame(seed, arena(21, 11), 2, 2) + ascendGame.player.x, ascendGame.player.y = ascendGame.level.downX, ascendGame.level.downY + ascendGame:descend() + local orc = Monsters.create(Monsters.byKey.orc, 99, 10, 6, ascendGame.depth) + orc.awareness = Stealth.HUNTING + ascendGame.level.entities = { orc } + ascendGame.player.x, ascendGame.player.y = ascendGame.level.upX, ascendGame.level.upY + ascendGame:ascend() + ok(ascendGame.nemesis == nil, "climbing back up never manufactures a brand new nemesis") + end + + local followGame = stagedGame(1, arena(21, 11), 2, 2) + followGame.player.x, followGame.player.y = followGame.level.downX, followGame.level.downY + followGame:descend() + followGame.nemesis = { species = "orc", grudge = 2, hpFraction = 1 } + local hunter = Nemesis.spawn(followGame.nemesis, followGame:nextMonsterId(), 10, 6, followGame.depth) + followGame.level.entities = { hunter } + followGame.player.x, followGame.player.y = followGame.level.upX, followGame.level.upY + followGame:ascend() + eq(followGame.nemesis.grudge, 3, "an already-active nemesis still follows the player back up the stairs") + local found + for _, entity in ipairs(followGame.level.entities) do if entity.nemesis then found = entity end end + ok(found ~= nil, "the nemesis is placed on the floor just climbed back onto") +end + +-- companions.lua/mercenaries.lua/reanimation.lua's own module comments each +-- describe a bonded ally as sticking with the player for the run (a +-- companion "persists across the whole run once bonded", a hireling demands +-- its wage "for as long as it lives") - before Game:detachCarriedAllies/ +-- Game:plantCarriedAllies existed, Game:descend simply swapped self.level to +-- the next floor's own dungeon:get(depth) result, so every one of the three +-- was silently left behind, alive and well, on the departing floor forever. +function tests.gameDescendCarriesEveryLivingAllyOntoTheNextFloorInsteadOfAbandoningThem() + local game = stagedGame(1, arena(21, 11), 3, 6) + local oldLevel = game.level + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 99, 8, 6, 1) + Corpses.spawn(game.level, rat) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + ok(golem ~= nil and hireling ~= nil and thrall ~= nil, "all three allies bond before the descent") + + game.player.x, game.player.y = game.level.downX, game.level.downY + ok(game:descend(), "descending succeeds") + eq(game.depth, 2, "the game is now on the second floor") + ok(game:hasCompanion(), "the companion is with the player on the new floor") + ok(game:hasHireling(), "the hireling is with the player on the new floor") + local thrallFound + for _, entity in ipairs(game.level.entities) do + if entity.kind == "thrall" then thrallFound = entity end + end + eq(thrallFound, thrall, "the very same thrall instance rises with the player, not a rebuilt one") + + local leftBehind = 0 + for _, entity in ipairs(oldLevel.entities) do + if entity.kind == "companion" or entity.kind == "hireling" or entity.kind == "thrall" then + leftBehind = leftBehind + 1 + end + end + eq(leftBehind, 0, "none of the three allies are stranded on the departing floor") +end + +function tests.gameAscendCarriesEveryLivingAllyBackOntoThePreviousFloorToo() + local game = stagedGame(1, arena(21, 11), 3, 6) end local order = { From c2c0a4c9622802de1ead3e802cd22b07702be680 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:11:04 +0200 Subject: [PATCH 143/172] test: cover tests.gameAwardKillOnANemesisGrantsBonusXpAndBountyGoldAn... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 27847f0..868d41d 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10509,6 +10509,179 @@ end function tests.gameAscendCarriesEveryLivingAllyBackOntoThePreviousFloorToo() local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.depth, 2, "now on the second floor") + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + ok(golem ~= nil and hireling ~= nil, "both allies bond on the second floor") + local descendLevel = game.level + + game.player.x, game.player.y = game.level.upX, game.level.upY + ok(game:ascend(), "ascending succeeds") + eq(game.depth, 1, "back on the first floor") + ok(game:hasCompanion(), "the companion follows back up the stairs") + ok(game:hasHireling(), "the hireling follows back up the stairs too") + + local leftBehind = 0 + for _, entity in ipairs(descendLevel.entities) do + if entity.kind == "companion" or entity.kind == "hireling" then leftBehind = leftBehind + 1 end + end + eq(leftBehind, 0, "neither ally is stranded on the floor just climbed away from") +end + +function tests.gameAwardKillOnANemesisGrantsBonusXpAndBountyGoldAndClearsTheHunt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.nemesis = { species = "orc", grudge = 3, hpFraction = 1 } + local hunter = Nemesis.spawn(game.nemesis, 1, 4, 6, 1) + hunter.hp = 1 + game.level.entities = { hunter } + local xpBefore, goldBefore = game.player.xp, game.player.gold + game:playerAttack(hunter) + ok(game.player.xp > xpBefore or game.player.level > 1, "felling the nemesis grants xp on top of its species reward") + eq(game.player.gold, goldBefore + Nemesis.bountyGold(3), "felling the nemesis pays out its grudge-scaled bounty gold") + eq(game.nemesis, nil, "the hunt is over once the nemesis is actually killed") + ok(contains(table.concat(game.log:recent(5), " "), "hunt is over"), "the kill is logged as ending the hunt") +end + +function tests.nemesisFieldsSurviveEntityAndGameSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.nemesis = { species = "orc", grudge = 3, hpFraction = 0.75 } + local hunter = Nemesis.spawn(game.nemesis, 1, 6, 5, 1) + game.level.entities = { hunter } + + local restoredLevel = Level.fromData(game.level:toData()) + eq(restoredLevel.entities[1].nemesis, true, "the nemesis flag survives a level round trip") + eq(restoredLevel.entities[1].nemesisGrudge, 3, "the nemesis's grudge survives a level round trip") + + local restoredGame = Game.fromData(game:toData()) + eq(restoredGame.nemesis.species, "orc", "the pursuing nemesis descriptor survives a game round trip") + eq(restoredGame.nemesis.grudge, 3, "the descriptor's grudge survives a game round trip") + eq(restoredGame.nemesis.hpFraction, 0.75, "the descriptor's carried hp fraction survives a game round trip") +end + +function tests.factionsAreRivalsChecksBothDirectionsAndExcludesSameFactionAndNil() + ok(Factions.areRivals(Factions.GOBLINOID, Factions.ORCISH), "goblinoid and orcish are a listed rivalry") + ok(Factions.areRivals(Factions.ORCISH, Factions.GOBLINOID), "the rivalry check reads either direction") + ok(not Factions.areRivals(Factions.GOBLINOID, Factions.GOBLINOID), "a faction is never its own rival") + ok(not Factions.areRivals(Factions.MERCHANT, Factions.DRAKE), "an unlisted pair is not a rivalry") + ok(not Factions.areRivals(nil, Factions.ORCISH), "a nil faction on either side never counts as a rival") + ok(not Factions.areRivals(Factions.GOBLINOID, nil), "a nil faction on either side never counts as a rival") +end + +function tests.infightingTargetPicksTheNearestVisibleRivalOverACloserNonRival() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 12, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 3, 9, 6, 1) + game.level.entities = { goblin, orc, rat } + local target = Infighting.target(game, goblin) + eq(target.id, orc.id, "the goblin's rival target is the orc, not the nearer but unrelated rat") +end + +function tests.infightingTargetIgnoresARivalBeyondSightRange() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 3, 6, 1) + local farOrc = Monsters.create(Monsters.byKey.orc, 2, 19, 6, 1) + game.level.entities = { goblin, farOrc } + ok(Infighting.target(game, goblin) == nil, "a rival far beyond sight range is not a target") +end + +function tests.infightingTargetIgnoresARivalBehindAWallEvenWithinSightRange() + local map = Map.new(21, 11) + for y = 2, 9 do + for x = 2, 9 do map:set(x, y, Map.FLOOR) end + for x = 12, 19 do map:set(x, y, Map.FLOOR) end + end + local level = Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 2, downX = 19, downY = 9, + }) + local game = stagedGame(1, level, 2, 2) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 9, 5, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 12, 5, 1) + game.level.entities = { goblin, orc } + ok(Infighting.target(game, goblin) == nil, + "a rival on the far side of a solid wall is not a target despite being close in raw distance") +end + +function tests.infightingTargetIgnoresNonRivalsWithoutDiscord() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 2, 11, 6, 1) + game.level.entities = { goblin, rat } + ok(Infighting.target(game, goblin) == nil, "an unrelated species is never a target on its own") +end + +function tests.infightingTargetUnderDiscordPicksNearestCreatureOfAnyKindIncludingACompanion() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + Status.apply(orc, Status.DISCORD, 5, 1) + local companion = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + local rat = Monsters.create(Monsters.byKey.rat, 3, 15, 6, 1) + game.level.entities = { orc, companion, rat } + local target = Infighting.target(game, orc) + eq(target.id, companion.id, + "a discorded monster attacks the nearest creature of any kind, even a companion, over a farther non-rival") +end + +function tests.infightingTargetReturnsNilOnceDiscordHasExpired() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 2, 11, 6, 1) + game.level.entities = { orc, rat } + ok(Infighting.target(game, orc) == nil, "without discord an orc has no quarrel with a nearby rat") +end + +function tests.infightingStrikeOnAPlainMonsterSpawnsACorpseButAwardsThePlayerNothing() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + orc.hp, orc.maxHp = 1, 1 + game.level.entities = { goblin, orc } + local xpBefore = game.player.xp + local killsBefore = game.stats.kills + Infighting.strike(game, goblin, orc) + ok(not orc:isAlive(), "the orc dies to the goblin's blow") + eq(game.player.xp, xpBefore, "the player earns no xp for a kill it did not land") + eq(game.stats.kills, killsBefore, "the run's kill counter only tracks the player's own kills") + eq(#game.level.corpses, 1, "the fallen orc still leaves a corpse behind for scavenging or reanimation") + ok(contains(game.log:last(), "dies"), "the death is still logged") +end + +function tests.infightingStrikeThatKillsACompanionRoutesThroughTheNormalAllyDeathMessage() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + local wolf = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 1, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { orc, wolf } + Infighting.strike(game, orc, wolf) + ok(contains(game.log:last(), "falls"), "a companion killed by infighting dies with the same message as any other death") +end + +-- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise... and its +-- hireling/thrall siblings, but here on neither side of the fight is a +-- player-aligned ally at all: two monsters brawling among themselves (a +-- rivalry, or one of them discorded) were exactly as silent as a companion's +-- own melee, despite Infighting.strike running the very same Combat.attack +-- every other fight in the engine does. A dormant third monster standing +-- nearby but uninvolved never had a chance to overhear either kind of fight. +function tests.infightingStrikeEmitsNoiseThatCanStirADormantBystanderNotInvolvedInTheFight() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + local bystander = Monsters.create(Monsters.byKey.rat, 3, 14, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { goblin, orc, bystander } + Infighting.strike(game, goblin, orc) end local order = { From b40d8482e4be0e62371cebab84f1cb7ae60d0b38 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:20:37 +0200 Subject: [PATCH 144/172] test: cover tests.infightingStrikeAppliesTheAttackersEliteLifestealAg... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 868d41d..d5fbcb4 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10682,6 +10682,179 @@ function tests.infightingStrikeEmitsNoiseThatCanStirADormantBystanderNotInvolved bystander.awareness = Stealth.DORMANT game.level.entities = { goblin, orc, bystander } Infighting.strike(game, goblin, orc) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "two monsters brawling among themselves are exactly as loud as any other fight") +end + +-- Same shape of gap as the Noise fix just above, but for a different +-- Combat.attack side effect: Game:applyEliteOnHit (a vampiric elite's +-- lifesteal, a chilling elite's status rider) was only ever wired into +-- Game:monsterMelee/monsterShoot, both of which assume the victim is the +-- player - its log lines even hardcoded the word "you". An elite fighting a +-- rival monster or the player's own companion/hireling/thrall through +-- Infighting.strike got neither the heal nor the rider at all. +function tests.infightingStrikeAppliesTheAttackersEliteLifestealAgainstAnyDefenderNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + orc.elite, orc.eliteAffixes = true, { "vampiric" } + orc.hp = orc.maxHp - 5 + local wolf = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 20, maxHp = 20, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { orc, wolf } + local before = orc.hp + Infighting.strike(game, orc, wolf) + ok(orc.hp > before, "a vampiric elite heals on an infighting hit exactly as it does against the player") + ok(contains(game.log:last(), "wolf"), "the drain message names the actual victim, not the player") + ok(not contains(game.log:last(), "you"), "a wolf being drained is never described as draining \"you\"") +end + +function tests.infightingStrikeAppliesTheAttackersEliteOnHitRiderAgainstAnyDefenderNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + orc.elite, orc.eliteAffixes = true, { "chilling" } + local wolf = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "hireling", + hp = 20, maxHp = 20, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { orc, wolf } + ok(not Status.has(wolf, Status.SLOW), "the hireling starts unslowed") + Infighting.strike(game, orc, wolf) + ok(Status.has(wolf, Status.SLOW), "a chilling elite's infighting hit slows its actual victim, not just the player") + ok(contains(game.log:last(), "wolf"), "the rider message names the actual victim, not the player") +end + +-- Game:monsterMelee has always rolled a bite-carrying species' disease.lua +-- infection against the player; Infighting.strike shares every other one of +-- monsterMelee's hooks (Combat.attack, applyEliteOnHit, emitNoise) but never +-- this one, so a rat fighting a rival monster or the player's own +-- companion/hireling/thrall through infighting could never actually spread +-- its parasites - the same shape of gap the elite-hook tests above already +-- closed for lifesteal and on-hit riders. +function tests.infightingStrikeCanTransmitTheAttackersBiteDiseaseToAnyDefenderNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 3) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + eq(rat.biteDisease, Disease.PARASITES, "a rat carries parasites to begin with") + local wolf = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 10000, maxHp = 10000, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { rat, wolf } + local infected = false + for _ = 1, 400 do + Infighting.strike(game, rat, wolf) + if Disease.isInfected(wolf) then infected = true break end + end + ok(infected, "enough infighting bites from a parasite-carrying rival eventually infect a companion, not just the player") + eq(wolf.disease.key, Disease.PARASITES, "the companion caught exactly the rat's own carried affliction") + ok(contains(game.log:last(), "wolf"), "the infection message names the actual victim, not the player") +end + +function tests.infightingTryActAttacksAnAdjacentRivalAndReturnsTrue() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + game.level.entities = { goblin, orc } + local hpBefore = orc.hp + local acted = Infighting.tryAct(game, goblin) + ok(acted, "infighting consumes the goblin's turn when an adjacent rival is present") + ok(orc.hp < hpBefore, "the adjacent rival takes damage") +end + +function tests.infightingTryActStepsTowardADistantRivalAndReturnsTrue() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 14, 6, 1) + game.level.entities = { goblin, orc } + local acted = Infighting.tryAct(game, goblin) + ok(acted, "infighting consumes the goblin's turn even while the rival is still distant") + eq(goblin.x, 11, "the goblin steps one cell toward the distant rival instead of standing still") +end + +function tests.infightingTryActReturnsFalseWithNoQualifyingTarget() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 2, 11, 6, 1) + game.level.entities = { goblin, rat } + ok(not Infighting.tryAct(game, goblin), "infighting yields the turn back to Ai.act when nothing qualifies") +end + +function tests.gameTakeMonsterTurnLetsARivalFightInsteadOfChasingADistantPlayer() + local game = stagedGame(1, arena(21, 11), 19, 9) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + game.level.entities = { goblin, orc } + local hpBefore = orc.hp + game:takeMonsterTurn(goblin) + ok(orc.hp < hpBefore, "a goblin that could chase a distant player instead fights the adjacent rival orc") + eq(goblin.x, 10, "fighting the rival in place keeps the goblin from stepping toward the player") +end + +function tests.spellsDiscordAppliesTheDiscordStatusAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "discord") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local manaBefore = game.player.mana + local success = game:castSpell("discord", orc) + ok(success, "casting a learned, in-range discord succeeds") + ok(Status.has(orc, Status.DISCORD), "the target now carries the discord status") + eq(game.player.mana, manaBefore - Spells.catalog.discord.cost, "casting discord spends exactly its listed mana cost") +end + +function tests.spellsDiscordFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "discord") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("discord", farOrc) + ok(not success, "discord cast beyond its range fails") + ok(not Status.has(farOrc, Status.DISCORD), "a failed cast never applies the status") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfTheDiscordSpellSomewhereInARangeOfSeeds() + local found = false + for seed = 1, 100 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "discord" then found = true end + end + if found then break end + end + ok(found, "the discord tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.spellsConfuseAppliesTheConfuseStatusAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "confuse") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local manaBefore = game.player.mana + local success = game:castSpell("confuse", orc) + ok(success, "casting a learned, in-range confuse succeeds") + ok(Status.has(orc, Status.CONFUSE), "the target now carries the confuse status") + eq(game.player.mana, manaBefore - Spells.catalog.confuse.cost, "casting confuse spends exactly its listed mana cost") +end + +function tests.spellsConfuseFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "confuse") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("confuse", farOrc) + ok(not success, "confuse cast beyond its range fails") + ok(not Status.has(farOrc, Status.CONFUSE), "a failed cast never applies the status") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfTheConfuseSpellSomewhereInARangeOfSeeds() + local found = false + for seed = 1, 100 do end local order = { From 5e5103cecb0cf768093bd4b4feb670e9192b3ce5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:21:06 +0200 Subject: [PATCH 145/172] test: cover tests.builderCanRollAConfuseTrapOrTrapKitSomewhereInARang... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index d5fbcb4..26bfc25 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10855,6 +10855,179 @@ end function tests.builderCanRollATomeOfTheConfuseSpellSomewhereInARangeOfSeeds() local found = false for seed = 1, 100 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "confuse" then found = true end + end + if found then break end + end + ok(found, "the confuse tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.builderCanRollAConfuseTrapOrTrapKitSomewhereInARangeOfSeeds() + local sawTrap, sawKit = false, false + for seed = 1, 100 do + local level = Builder.build(3, Rng.new(seed), {}) + for _, trap in ipairs(level.traps) do + if trap.effect == "confuse" then sawTrap = true end + end + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.KIT and ground.item.effect == "confuse" then sawKit = true end + end + if sawTrap and sawKit then break end + end + ok(sawTrap, "a builder-placed confuse trap is reachable within a reasonable seed range") + ok(sawKit, "a confuse trap kit is reachable dungeon loot within a reasonable seed range") +end + +function tests.builderCanRollABlindTrapOrTrapKitSomewhereInARangeOfSeeds() + local sawTrap, sawKit = false, false + for seed = 1, 100 do + local level = Builder.build(3, Rng.new(seed), {}) + for _, trap in ipairs(level.traps) do + if trap.effect == "blind" then sawTrap = true end + end + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.KIT and ground.item.effect == "blind" then sawKit = true end + end + if sawTrap and sawKit then break end + end + ok(sawTrap, "a builder-placed blind trap is reachable within a reasonable seed range") + ok(sawKit, "a blind trap kit is reachable dungeon loot within a reasonable seed range") +end + +function tests.builderCanRollAStunTrapOrTrapKitSomewhereInARangeOfSeeds() + local sawTrap, sawKit = false, false + for seed = 1, 100 do + local level = Builder.build(3, Rng.new(seed), {}) + for _, trap in ipairs(level.traps) do + if trap.effect == "stun" then sawTrap = true end + end + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.KIT and ground.item.effect == "stun" then sawKit = true end + end + if sawTrap and sawKit then break end + end + ok(sawTrap, "a builder-placed stun trap is reachable within a reasonable seed range") + ok(sawKit, "a stun trap kit is reachable dungeon loot within a reasonable seed range") +end + +function tests.spellsBlindAppliesTheBlindStatusAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "blind") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local manaBefore = game.player.mana + local success = game:castSpell("blind", orc) + ok(success, "casting a learned, in-range blind succeeds") + ok(Status.has(orc, Status.BLIND), "the target now carries the blind status") + eq(game.player.mana, manaBefore - Spells.catalog.blind.cost, "casting blind spends exactly its listed mana cost") +end + +function tests.spellsBlindFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "blind") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("blind", farOrc) + ok(not success, "blind cast beyond its range fails") + ok(not Status.has(farOrc, Status.BLIND), "a failed cast never applies the status") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfTheBlindSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "cleanse" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed, and the nearest blind tome under the new list happens to sit at + -- seed 111 - still well within "a reasonable seed range". + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "blind" then found = true end + end + if found then break end + end + ok(found, "the blind tome is reachable dungeon loot within a reasonable seed range") +end + +-- WEAKEN previously only ever flowed toward the player (shaman's tryCurse, +-- a cursed shrine) - see spells.lua's weaken entry for the missing other +-- direction this closes. +function tests.spellsWeakenAppliesTheWeakenStatusAndReducesAttackAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "weaken") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local effectiveAttackBefore = orc:effectiveAttack() + local manaBefore = game.player.mana + local success = game:castSpell("weaken", orc) + ok(success, "casting a learned, in-range weaken succeeds") + ok(Status.has(orc, Status.WEAKEN), "the target now carries the weaken status") + eq(orc:effectiveAttack() + 3, effectiveAttackBefore, "weaken's -3 attack modifier is folded into the target's effective attack") + eq(game.player.mana, manaBefore - Spells.catalog.weaken.cost, "casting weaken spends exactly its listed mana cost") +end + +function tests.spellsWeakenFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "weaken") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("weaken", farOrc) + ok(not success, "weaken cast beyond its range fails") + ok(not Status.has(farOrc, Status.WEAKEN), "a failed cast never applies the status") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfTheWeakenSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "cleanse" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed, and the nearest weaken tome under the new list happens to sit at + -- seed 127 - still well within "a reasonable seed range". + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "weaken" then found = true end + end + if found then break end + end + ok(found, "the weaken tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.spellsCleanseLiftsHostileStatusesAndDiseaseOnTheCasterAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "cleanse") + Status.apply(game.player, Status.POISON, 5, 2) + Status.apply(game.player, Status.CONFUSE, 6, 1) + Disease.infect(game.player, Disease.PARASITES) + local manaBefore = game.player.mana + local success = game:castSpell("cleanse") + ok(success, "casting a learned, self-targeted cleanse succeeds") + ok(not Status.has(game.player, Status.POISON), "cleanse lifts poison from the caster") + ok(not Status.has(game.player, Status.CONFUSE), "cleanse lifts confusion from the caster") + ok(not Disease.isInfected(game.player), "cleanse also lifts an active infection, like the cure potion does") + eq(game.player.mana, manaBefore - Spells.catalog.cleanse.cost, "casting cleanse spends exactly its listed mana cost") + ok(contains(game.log:last(), "affliction lifts") and contains(game.log:last(), "fades"), + "the single logged line credits both the disease and the status effects it lifted") +end + +function tests.spellsCleanseWithNothingHostileStillSucceedsAndSpendsMana() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "cleanse") + local manaBefore = game.player.mana + local success = game:castSpell("cleanse") + ok(success, "casting cleanse with nothing to lift still succeeds, exactly like heal or shield always do") + eq(game.player.mana, manaBefore - Spells.catalog.cleanse.cost, "mana is still spent even when there was nothing to cleanse") + ok(contains(game.log:last(), "nothing to cleanse"), "the logged line is honest that nothing was actually lifted") +end + +function tests.builderCanRollATomeOfTheCleanseSpellSomewhereInARangeOfSeeds() + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) end local order = { From aaa221f6bf83a6f517cfdcbb9b4f577975ebdbff Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:27:04 +0200 Subject: [PATCH 146/172] test: cover tests.spellsLevitateAppliesTheLevitateStatusToTheCasterAn... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 26bfc25..d5fd8bf 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11028,6 +11028,179 @@ function tests.builderCanRollATomeOfTheCleanseSpellSomewhereInARangeOfSeeds() local found = false for seed = 1, 200 do local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "cleanse" then found = true end + end + if found then break end + end + ok(found, "the cleanse tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.spellsLevitateAppliesTheLevitateStatusToTheCasterAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "levitate") + local manaBefore = game.player.mana + local success = game:castSpell("levitate") + ok(success, "casting a learned, self-targeted levitate succeeds") + ok(Status.has(game.player, Status.LEVITATE), "the caster now carries the levitate status") + eq(game.player.mana, manaBefore - Spells.catalog.levitate.cost, "casting levitate spends exactly its listed mana cost") +end + +function tests.builderCanRollATomeOfTheLevitateSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "weaken" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed (a longer list moves the modulo, not just adds a new bucket at the + -- end), and the nearest levitate tome under the new list happens to sit + -- at seed 173 - still well within "a reasonable seed range", just a + -- different one than before this list grew by one entry. + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "levitate" then found = true end + end + if found then break end + end + ok(found, "the levitate tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.polymorphPoolExcludesTheShopkeeperAndRespectsDepth() + local shallow = Polymorph.pool(1) + local shallowKeys = {} + for _, spec in ipairs(shallow) do shallowKeys[spec.key] = true end + ok(shallowKeys.rat and shallowKeys.bat and shallowKeys.kobold, "a depth-one pool offers every depth-one species") + ok(not shallowKeys.orc, "a depth-one pool excludes a species that first appears deeper") + + local deep = Polymorph.pool(4) + local deepKeys = {} + for _, spec in ipairs(deep) do deepKeys[spec.key] = true end + ok(deepKeys.zombie and deepKeys.necromancer, "a depth-four pool offers species that first appear at depth four") + ok(not deepKeys.shopkeeper, "the shopkeeper is never offered by a polymorph, at any depth") +end + +function tests.polymorphTransformPicksADifferentSpeciesAndPreservesTheHpFraction() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + rat.hp = 2 + local pool = Polymorph.pool(1) + local spec = Polymorph.transform(rat, Rng.new(7), pool) + ok(spec ~= nil, "a pool with other species on offer always yields a transform") + ok(rat.species ~= "rat", "the entity is no longer its original species") + eq(rat.species, spec.key, "the entity's species now matches whatever transform returned") + eq(rat.maxHp, spec.hp, "max hp is replaced wholesale from the new species") + local expectedHp = math.max(1, math.floor(spec.hp * 0.5 + 0.5)) + eq(rat.hp, expectedHp, "current hp is rescaled to the same fraction the old body carried") + eq(rat.attack, spec.attack, "attack is replaced wholesale from the new species") + eq(rat.ai, spec.ai, "ai behaviour is replaced wholesale from the new species") +end + +function tests.polymorphTransformReturnsNilWhenThePoolOffersNothingButTheCurrentSpecies() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local spec = Polymorph.transform(rat, Rng.new(3), { Monsters.byKey.rat }) + ok(spec == nil, "a pool with nothing but the entity's own species transforms nothing") + eq(rat.species, "rat", "the entity is left untouched when there is nowhere else to go") +end + +function tests.spellsPolymorphTransformsTheTargetAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "polymorph") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local manaBefore = game.player.mana + local success = game:castSpell("polymorph", orc) + ok(success, "casting a learned, in-range polymorph succeeds") + ok(orc.species ~= "orc", "the target's species actually changed") + eq(game.player.mana, manaBefore - Spells.catalog.polymorph.cost, "casting polymorph spends exactly its listed mana cost") +end + +function tests.spellsPolymorphFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "polymorph") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("polymorph", farOrc) + ok(not success, "polymorph cast beyond its range fails") + eq(farOrc.species, "orc", "a failed cast never transforms the target") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfThePolymorphSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "cleanse" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed, and the nearest polymorph tome under the new list happens to sit + -- at seed 183 - still well within "a reasonable seed range". + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "polymorph" then found = true end + end + if found then break end + end + ok(found, "the polymorph tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.blocksCanReceiveRefusesWallsDoorsVaultsGatesBlocksAndEntities() + local level = arena(9, 9) + level.map:set(4, 4, Tiles.WALL) + eq(Blocks.canReceive(level, 4, 4), false, "a wall cannot receive a shoved block") + level.map:set(4, 5, Tiles.DOOR_CLOSED) + eq(Blocks.canReceive(level, 4, 5), false, "a closed door cannot receive a shoved block") + level.map:set(4, 6, Tiles.VAULT_DOOR) + eq(Blocks.canReceive(level, 4, 6), false, "a sealed vault door cannot receive a shoved block") + level.map:set(4, 7, Tiles.GATE_CLOSED) + eq(Blocks.canReceive(level, 4, 7), false, "a closed gate cannot receive a shoved block") + level.blocks = { { x = 3, y = 3 } } + eq(Blocks.canReceive(level, 3, 3), false, "a cell already holding another block cannot receive a shove") + level.entities = { Entity.new({ x = 3, y = 4, glyph = "r", name = "rat", kind = "monster", hp = 5, attack = 1, defense = 0 }) } + eq(Blocks.canReceive(level, 3, 4), false, "a cell occupied by a living entity cannot receive a shove") + eq(Blocks.canReceive(level, 5, 5), true, "plain open floor can receive a shoved block") + eq(Blocks.canReceive(level, 20, 20), false, "an out-of-bounds cell cannot receive a shove") +end + +function tests.blocksPushRelocatesABlockOntoPlainFloor() + local level = arena(9, 9) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove onto open floor succeeds") + eq(block.x, 4, "the block's own x relocates by the shove") + eq(block.y, 3, "the block's own y is unchanged by a purely horizontal shove") + ok(contains(line, "shove"), "a plain shove is logged as a shove") +end + +function tests.blocksPushIntoLavaSolidifiesTheCellAndConsumesTheBlock() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.LAVA) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove into lava still succeeds") + eq(level.map:get(4, 3), Tiles.RUBBLE, "the lava cell solidifies into rubble") + eq(#level.blocks, 0, "the block is consumed by the lava it just quenched") + ok(contains(line, "lava"), "quenching lava is logged") +end + +function tests.blocksPushIntoAChasmFillsTheGapAndConsumesTheBlock() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.CHASM) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove into a chasm still succeeds") + eq(level.map:get(4, 3), Tiles.FLOOR, "the chasm cell fills into plain floor") + eq(#level.blocks, 0, "the block is consumed filling in the chasm") + ok(contains(line, "chasm"), "filling a chasm is logged") +end + +function tests.blocksPushFailsAgainstAWallOrAnotherBlockOrAnEntity() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.WALL) + local block = { x = 3, y = 3 } + level.blocks = { block } + eq(Blocks.push(level, block, 1, 0), false, "a shove into a wall fails outright") + eq(block.x, 3, "a failed shove leaves the block exactly where it was") + end local order = { From a1cacb21bb16527791ccb23f07f896ef917f6b05 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 13:46:09 +0200 Subject: [PATCH 147/172] test: cover tests.blocksUpdateGatesOpensOnlyWhileAPlatedBlockHoldsItA... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index d5fd8bf..f3e02bd 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11201,6 +11201,179 @@ function tests.blocksPushFailsAgainstAWallOrAnotherBlockOrAnEntity() eq(Blocks.push(level, block, 1, 0), false, "a shove into a wall fails outright") eq(block.x, 3, "a failed shove leaves the block exactly where it was") + level.map:set(4, 3, Tiles.FLOOR) + level.blocks = { block, { x = 4, y = 3 } } + eq(Blocks.push(level, block, 1, 0), false, "a shove into another block fails outright") + + level.blocks = { block } + level.entities = { Entity.new({ x = 4, y = 3, glyph = "r", name = "rat", kind = "monster", hp = 5, attack = 1, defense = 0 }) } + eq(Blocks.push(level, block, 1, 0), false, "a shove into a living entity fails outright") +end + +function tests.blocksUpdateGatesOpensOnlyWhileAPlatedBlockHoldsItAndReseals() + local level = arena(9, 9) + level.map:set(6, 3, Tiles.GATE_CLOSED) + level.gates = { { x = 6, y = 3, open = false } } + level.plates = { { x = 4, y = 3, gateX = 6, gateY = 3 } } + local block = { x = 3, y = 3 } + level.blocks = { block } + Blocks.updateGates(level) + eq(level.map:get(6, 3), Tiles.GATE_CLOSED, "the gate stays sealed with no block on its plate") + eq(level.gates[1].open, false, "the gate's own open field matches its sealed tile") + + Blocks.push(level, block, 1, 0) + Blocks.updateGates(level) + eq(level.map:get(6, 3), Tiles.GATE_OPEN, "the gate swings open the instant its plate takes the block's weight") + eq(level.gates[1].open, true, "the gate's own open field matches its opened tile") + + Blocks.push(level, block, 1, 0) + Blocks.updateGates(level) + eq(level.map:get(6, 3), Tiles.GATE_CLOSED, "the gate reseals the instant the block is shoved off its plate") + eq(level.gates[1].open, false, "the gate's own open field matches its resealed tile") +end + +function tests.movePlayerShovesABlockAheadAndTheGateItHoldsOpenReactsImmediately() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.GATE_CLOSED) + game.level.gates = { { x = 6, y = 6, open = false } } + game.level.plates = { { x = 5, y = 6, gateX = 6, gateY = 6 } } + game.level.blocks = { { x = 4, y = 6 } } + game:movePlayer(1, 0) + eq(game.player.x, 4, "the player steps into the cell the block just vacated") + eq(game.level.blocks[1].x, 5, "the shove relocates the block by exactly one cell") + eq(game.level.blocks[1].y, 6, "the shove does not drift off the push axis") + eq(game.level.map:get(6, 6), Tiles.GATE_OPEN, "the linked gate opens the instant the block lands on its plate") + ok(contains(game.log:last(), "shove"), "shoving the block is logged") +end + +function tests.movePlayerCannotShoveABlockIntoAWallAndDoesNotMoveEither() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(5, 6, Tiles.WALL) + game.level.blocks = { { x = 4, y = 6 } } + local moved = game:movePlayer(1, 0) + eq(moved, false, "the player's own step fails along with the blocked shove") + eq(game.player.x, 3, "the player does not move when the shove it would trigger fails") + eq(game.level.blocks[1].x, 4, "the block stays put when its shove fails") +end + +function tests.movePlayerCannotShoveOneBlockIntoAnother() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.blocks = { { x = 4, y = 6 }, { x = 5, y = 6 } } + local moved = game:movePlayer(1, 0) + eq(moved, false, "shoving a block into another block fails the whole step") + eq(game.player.x, 3, "the player does not move when the chained shove fails") +end + +function tests.movePlayerSwapsPlacesWithItsOwnCompanionInsteadOfAttackingIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local hpBefore = hound.hp + game:movePlayer(1, 0) + eq(game.player.x, 6, "the player steps onto the cell its companion just vacated") + eq(game.player.y, 5, "the player's row is unchanged by the swap") + eq(hound.x, 5, "the companion is displaced onto the player's old cell") + eq(hound.y, 5, "the companion's row is unchanged by the swap") + eq(hound.hp, hpBefore, "swapping past a companion never damages it") + ok(contains(game.log:last(), "swap"), "swapping past an ally is logged") +end + +function tests.movePlayerSwapsPlacesWithAHiredHirelingToo() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local hpBefore = sellsword.hp + game:movePlayer(1, 0) + eq(sellsword.x, 5, "a hireling is displaced by a swap exactly like a companion, never attacked") + eq(sellsword.hp, hpBefore, "swapping past a hireling never damages it") +end + +function tests.adjacentMonsterSkipsTheBondedCompanionAndFindsARealHostileInstead() + local game = stagedGame(1, arena(21, 11), 5, 5) + Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, 5, 6, 1) + game.level.entities[#game.level.entities + 1] = rat + eq(game:adjacentMonster(), rat, "the autopilot's melee target skips its own companion and finds the hostile rat") +end + +function tests.adjacentMonsterReturnsNilWhenOnlyAnAllyStandsNextToThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + Companions.summon(game, "hound") + eq(game:adjacentMonster(), nil, "a bonded companion alone never counts as a melee target") +end + +function tests.adjacentMonsterAlsoSkipsAThrallStandingNextToThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + local thrall = Entity.new({ id = 99, x = 6, y = 5, glyph = "z", name = "raised thrall", kind = "thrall", + hp = 5, attack = 2, defense = 0 }) + game.level.entities[#game.level.entities + 1] = thrall + eq(game:adjacentMonster(), nil, "a raised thrall standing next to the player is never a melee target either") +end + +function tests.choosePlayerActionNeverAttacksItsOwnBondedCompanionEvenWithNothingElseToDo() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local hpBefore = hound.hp + game:choosePlayerAction() + eq(hound.hp, hpBefore, "the autopilot never bumps its own companion into a melee attack") +end + +function tests.aStarNeverRoutesAMonsterThroughABlockedCell() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + game.level.blocks = { { x = 11, y = 6 } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 13, 6, 1) + game.level.entities = { orc } + Ai.act(game, orc) + ok(not (orc.x == 11 and orc.y == 6), "a chasing monster never steps onto a cell a block occupies") + eq(orc.x, 13, "with the single gap blocked, the chaser has no route and holds its ground") + eq(orc.y, 6, "the chaser's y is likewise unchanged with no route available") +end + +function tests.playerPathToNeverRoutesThroughABlockedCell() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + game.level.blocks = { { x = 11, y = 6 } } + local path = game:playerPathTo(15, 6) + eq(path, nil, "no path is found when the only corridor cell is occupied by a block") +end + +function tests.gatesAndBlocksStayOffFloorsAboveDepthTwo() + for seed = 1, 4 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.gates, 0, "no gate appears above depth three") + eq(#level.blocks, 0, "no pushable block appears above depth three") + eq(#level.plates, 0, "no pressure plate appears above depth three") + end + end +end + +-- Depth 5 cycles to the rooms generator (see gen/init.lua's Gen.order), +-- the one that actually carves the doorway pinch points this puzzle needs; +-- depths 3-4 cycle to cellular/drunkard caves that essentially never have +-- one, so the gate's own depth >= 3 gate is exercised here rather than at +-- depth three itself, exactly like vaults.lua's own test picks depth five. +function tests.builderOccasionallyPlacesAGateBlockPuzzleOnceADoorwayIsAvailable() + local found = false + for seed = 1, 200 do + local level = Builder.build(5, Rng.new(seed), {}) + if #level.gates == 1 then + found = true + eq(#level.plates, 1, "a placed gate always comes with exactly one linked plate") + eq(#level.blocks, 1, "a placed gate always comes with exactly one pushable block") + local gate, plate, block = level.gates[1], level.plates[1], level.blocks[1] + eq(plate.gateX, gate.x, "the plate's gate link matches the placed gate's x") + eq(plate.gateY, gate.y, "the plate's gate link matches the placed gate's y") + eq(level.map:get(gate.x, gate.y), Tiles.GATE_CLOSED, "the gate starts sealed on its own tile") + local dx, dy = plate.x - block.x, plate.y - block.y + ok((dx == 0 and math.abs(dy) == 1) or (dy == 0 and math.abs(dx) == 1), + "the block always starts exactly one cardinal step from its plate") + local treasureFound + for _, ground in ipairs(level.floorItems) do + if ground.item.name and ground.item.name:find("gate%-forged", 1) then treasureFound = ground.item end + end + ok(treasureFound ~= nil, "the puzzle's guaranteed treasure is placed somewhere on the floor") end local order = { From 9293cb24dcdced373a5d86e50578d1f834d98922 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 14:29:38 +0200 Subject: [PATCH 148/172] test: cover tests.builderOnlyEverPlacesASolvableGatePuzzleThatNeverSe... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index f3e02bd..ae3faa8 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11374,6 +11374,179 @@ function tests.builderOccasionallyPlacesAGateBlockPuzzleOnceADoorwayIsAvailable( if ground.item.name and ground.item.name:find("gate%-forged", 1) then treasureFound = ground.item end end ok(treasureFound ~= nil, "the puzzle's guaranteed treasure is placed somewhere on the floor") + break + end + end + ok(found, "at least one of the sampled seeds places a gate/block/plate puzzle") +end + +-- Every gate the builder actually places must be genuinely solvable with a +-- single shove and must never seal the only route to the stairs down: the +-- block always starts one further cardinal step out from the plate, so +-- shoving it from that exact approach cell always lands it on the plate, +-- and the reachability check in builder.lua guarantees an alternate route +-- to the stairs exists whenever a gate is actually placed. +function tests.builderOnlyEverPlacesASolvableGatePuzzleThatNeverSealsTheOnlyRoute() + local checked = 0 + for seed = 1, 200 do + local level = Builder.build(5, Rng.new(seed), {}) + if #level.gates == 1 then + checked = checked + 1 + local gate, plate, block = level.gates[1], level.plates[1], level.blocks[1] + local dx, dy = plate.x - block.x, plate.y - block.y + local approachX, approachY = block.x - dx, block.y - dy + local path = Astar.find(level.map, level.upX, level.upY, approachX, approachY, function(x, y) + if x == approachX and y == approachY then return false end + if level:blockAt(x, y) then return true end + return not level.map:isPassable(x, y, false) + end) + ok(path ~= nil, "seed " .. seed .. ": the block's approach cell is reachable without crossing the gate") + local pushed = Blocks.push(level, block, dx, dy) + ok(pushed, "seed " .. seed .. ": a single shove from the approach cell always succeeds") + eq(block.x, plate.x, "seed " .. seed .. ": the shoved block lands exactly on its plate's x") + eq(block.y, plate.y, "seed " .. seed .. ": the shoved block lands exactly on its plate's y") + -- The gate tile itself is still sealed at this point (pushing the + -- block only just happened, above, and nothing re-ran Blocks. + -- updateGates), so this walks the actual map exactly as generated - + -- the direct proof builder.lua's own safety check is real, not just + -- asserted by this test's own reasoning about it. + ok(reachableFrom(level.map, level.upX, level.upY, level.downX, level.downY), + "seed " .. seed .. ": the stairs down stay reachable even treating the gate as a wall") + end + end + ok(checked > 0, "at least one of the sampled seeds actually placed a gate to check") +end + +function tests.blockPlateAndGateFieldsSurviveSerialization() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.GATE_OPEN) + level.blocks = { { x = 5, y = 5 } } + level.plates = { { x = 4, y = 6, gateX = 6, gateY = 6 } } + level.gates = { { x = 6, y = 6, open = true } } + local restored = Level.fromData(level:toData()) + eq(#restored.blocks, 1, "the block list survives a round trip") + eq(restored.blocks[1].x, 5, "a block's position survives a round trip") + eq(#restored.plates, 1, "the plate list survives a round trip") + eq(restored.plates[1].gateX, 6, "a plate's gate link survives a round trip") + eq(#restored.gates, 1, "the gate list survives a round trip") + eq(restored.gates[1].open, true, "a gate's open state survives a round trip") +end + +function tests.gameBlockPuzzleStateSurvivesSaveLoad() + local game = Game.new(1) + local bx, by = game.player.x + 2, game.player.y + game.level.map:set(bx + 1, by, Tiles.GATE_OPEN) + game.level.blocks = { { x = bx, y = by } } + game.level.plates = { { x = bx + 1, y = by, gateX = bx + 1, gateY = by } } + game.level.gates = { { x = bx + 1, y = by, open = true } } + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(#restored.level.blocks, 1, "the block list survives a save/load round trip") + eq(restored.level.blocks[1].x, bx, "a block's position survives a save/load round trip") + eq(restored.level.gates[1].open, true, "a gate's open state survives a save/load round trip") +end + +function tests.prefabVaultsNeverAppearBeforeDepthThree() + for seed = 1, 40 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.prefabVaults, 0, "no hand-authored vault appears above the first two floors") + end + end +end + +-- Each of the three hand-authored vaults (see gen/prefab.lua) actually turns +-- up in the wild on a fixed, known seed, and the shape the builder actually +-- stamps onto the floor's map is byte-identical to calling Prefab.stamp +-- directly on a fresh scratch map with the same template - proof the +-- builder's own placement pass never mutates or desyncs the template it drew. +function tests.builderStampsEachHandAuthoredVaultMatchingAFreshPrefabStampOfTheSameTemplate() + local cases = { + { seed = 5, name = "treasureCell" }, + { seed = 12, name = "guardPost" }, + { seed = 50, name = "flooded" }, + } + for _, case in ipairs(cases) do + local level = Builder.build(4, Rng.new(case.seed), {}) + eq(#level.prefabVaults, 1, "seed " .. case.seed .. "'s depth-4 floor places exactly one hand-authored vault") + local placed = level.prefabVaults[1] + eq(placed.name, case.name, "seed " .. case.seed .. " places the expected named vault") + + local template + for _, candidate in ipairs(Prefab.vaults) do + if candidate.name == case.name then template = candidate end + end + local scratch = Map.new(placed.w + 4, placed.h + 4) + Prefab.stamp(scratch, template, 2, 2) + local same = true + for row = 0, placed.h - 1 do + for col = 0, placed.w - 1 do + if scratch:get(2 + col, 2 + row) ~= level.map:get(placed.x + col, placed.y + row) then + same = false + end + end + end + ok(same, case.name .. ": the floor's stamped footprint matches a fresh Prefab.stamp of the same template exactly") + end +end + +-- A hand-authored vault is walled off behind solid, untouched rock with no +-- carved corridor leading in (see builder.lua's own placement comment): the +-- one way in is digging.lua's pick. This proves that concretely rather than +-- just trusting builder.lua's own reasoning about it - the vault's interior +-- is unreachable by ordinary movement, some cell on its outer wall genuinely +-- borders the already-reachable dungeon, and clearing that one cell down to +-- floor (the same two-swing progression digging.lua's own Digging.swing +-- drives) opens a real route all the way in. +function tests.builderDiggingThroughAPrefabVaultsOuterWallOpensARouteToItsSealedInterior() + local level = Builder.build(4, Rng.new(49), {}) + eq(#level.prefabVaults, 1, "seed 49's depth-4 floor places exactly one hand-authored vault") + local vault = level.prefabVaults[1] + eq(vault.name, "guardPost", "this fixed seed places the guard post vault") + + local wallX, wallY, extX, extY + for y = vault.y, vault.y + vault.h - 1 do + for x = vault.x, vault.x + vault.w - 1 do + -- A corner cell sits on both edges at once, so stepping "inward" from + -- whichever single exterior neighbour found it would walk straight + -- into a different wall row/column instead of the interior - only a + -- pure edge cell (on exactly one side) keeps the inward step on the + -- template's own single wall-thickness axis. + local verticalEdge = (x == vault.x or x == vault.x + vault.w - 1) and y > vault.y and y < vault.y + vault.h - 1 + local horizontalEdge = (y == vault.y or y == vault.y + vault.h - 1) and x > vault.x and x < vault.x + vault.w - 1 + local onBoundary = verticalEdge or horizontalEdge + if onBoundary and not wallX then + for _, n in ipairs(orthogonalNeighbours(x, y)) do + local nx, ny = n[1], n[2] + local outside = nx < vault.x or nx > vault.x + vault.w - 1 or ny < vault.y or ny > vault.y + vault.h - 1 + if outside and level.map:inBounds(nx, ny) and reachableFrom(level.map, level.upX, level.upY, nx, ny) then + wallX, wallY, extX, extY = x, y, nx, ny + end + end + end + end + end + ok(wallX ~= nil, "at least one of the vault's outer wall cells borders the already-reachable dungeon") + eq(level.map:get(wallX, wallY), Tiles.WALL, "the bordering cell reads as a plain wall before any digging") + ok(not reachableFrom(level.map, level.upX, level.upY, wallX, wallY), + "the wall cell itself is not reachable by ordinary movement while it is still solid rock") + + local inX, inY = 2 * wallX - extX, 2 * wallY - extY + ok(not reachableFrom(level.map, level.upX, level.upY, inX, inY), + "the vault's interior stays fully sealed off before the wall is dug through") + + ok(Digging.canDig(level.map, wallX, wallY), "the bordering wall cell is a valid digging target") + level.map:set(wallX, wallY, Tiles.RUBBLE) + level.map:set(wallX, wallY, Tiles.FLOOR) + ok(reachableFrom(level.map, level.upX, level.upY, wallX, wallY), + "after two swings clear the wall down to floor, the dug cell joins the reachable dungeon") + ok(level.map:isWalkable(inX, inY), "the cell just inside the dug wall is real, walkable vault interior") + ok(reachableFrom(level.map, level.upX, level.upY, inX, inY), + "digging through the outer wall opens a genuine route into the vault's previously sealed interior") +end + end local order = { From 8250eacdede7efdf5d832221c861f2308e36ba38 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 15:25:05 +0200 Subject: [PATCH 149/172] test: cover tests.prefabVaultFieldsSurviveSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index ae3faa8..6542389 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11547,6 +11547,179 @@ function tests.builderDiggingThroughAPrefabVaultsOuterWallOpensARouteToItsSealed "digging through the outer wall opens a genuine route into the vault's previously sealed interior") end +function tests.prefabVaultFieldsSurviveSerialization() + local level = arena(21, 11) + level.prefabVaults = { { name = "guardPost", x = 5, y = 4, w = 7, h = 5 } } + local restored = Level.fromData(level:toData()) + eq(#restored.prefabVaults, 1, "the prefab vault list survives a round trip") + eq(restored.prefabVaults[1].name, "guardPost", "a placed vault's template name survives a round trip") + eq(restored.prefabVaults[1].x, 5, "a placed vault's anchor x survives a round trip") + eq(restored.prefabVaults[1].w, 7, "a placed vault's footprint width survives a round trip") +end + +function tests.saveGameFnv1a32MatchesThePublishedTestVectors() + eq(SaveGame.fnv1a32(""), 2166136261, "the empty string hashes to the FNV-1a32 offset basis") + eq(SaveGame.fnv1a32("a"), 3826002220, "a single byte matches the published FNV-1a32 test vector") + eq(SaveGame.fnv1a32("warren"), SaveGame.fnv1a32("warren"), "hashing the same string twice is deterministic") + ok(SaveGame.fnv1a32("warren") ~= SaveGame.fnv1a32("warreo"), "a one-character change changes the hash") +end + +function tests.saveGameEncodeDecodeRoundTripsAValueUnchanged() + local codec = SaveGame.new({}) + local data = { hp = 12, name = "vault-keeper", tags = { "brave", "unlucky" } } + local decoded = codec.decode(codec.encode(data)) + eq(Serialize.encode(decoded), Serialize.encode(data), "a value survives an encode/decode round trip byte for byte") +end + +function tests.saveGameSaveThenLoadRoundTripsThroughARealFile() + local codec = SaveGame.new({}) + local path = os.tmpname() + codec.save(path, { hp = 12, tags = { "brave", "unlucky" } }) + local data = codec.load(path) + os.remove(path) + eq(data.hp, 12, "a saved number survives a real file round trip") + eq(data.tags[1], "brave", "a saved array survives a real file round trip") + eq(data.tags[2], "unlucky", "array order survives a real file round trip") +end + +function tests.saveGameLoadReportsMissingRatherThanErroringOnANonexistentFile() + local codec = SaveGame.new({}) + local data, err = codec.load("/tmp/warren_savegame_does_not_exist_998877.sav") + eq(data, nil, "a missing file yields no data") + eq(err, "missing", "the reason is reported as missing, distinct from corruption") +end + +function tests.saveGameDecodeRejectsTextThatIsNotValidLuaAtAll() + local codec = SaveGame.new({}) + local data, err = codec.decode("{ this is not } valid ( lua at all") + eq(data, nil, "unparseable text yields no data") + ok(contains(err or "", "corrupt"), "the failure is reported as corruption") +end + +function tests.saveGameDecodeDetectsATamperedPayloadViaChecksumMismatch() + local codec = SaveGame.new({}) + local text = codec.encode({ marker = "TAMPERMARKER", count = 3 }) + local tampered = text:gsub("TAMPERMARKER", "TAMPERMARKED", 1) + ok(tampered ~= text, "the substitution actually changed the encoded text") + local data, err = codec.decode(tampered) + eq(data, nil, "a payload that no longer matches its checksum is rejected") + ok(contains(err or "", "checksum mismatch"), "the rejection names the checksum as the reason") +end + +function tests.saveGameDecodeRefusesASaveFromAVersionNewerThanTheCodecSupports() + -- One throwaway migration just to make CURRENT_VERSION 2 for this check. + local codec = SaveGame.new({ function(d) return d end }) + local payload = Serialize.encode({ x = 1 }) + local envelope = { + magic = "WARRENSAVE", version = codec.CURRENT_VERSION + 1, + checksum = SaveGame.fnv1a32(payload), payload = payload, + } + local data, err = codec.decode(Serialize.encode(envelope)) + eq(data, nil, "a save newer than this codec understands is refused rather than guessed at") + ok(contains(err or "", "future version"), "the refusal names the version mismatch as the reason") +end + +function tests.saveGameWithNoMigrationHistoryPassesLegacyDataThroughUnchanged() + local codec = SaveGame.new({}) + local path = os.tmpname() + Serialize.saveFile(path, { greeting = "hi", count = 7 }) + local data, err = codec.load(path) + os.remove(path) + eq(err, nil, "a magic-less legacy file loads without an error") + eq(data.greeting, "hi", "legacy string fields survive untouched") + eq(data.count, 7, "legacy numeric fields survive untouched") +end + +function tests.saveGameMigrationChainAppliesEachStepInOrderStartingFromVersionOne() + local codec = SaveGame.new({ + [1] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "one"); return d end, + [2] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "two"); return d end, + }) + eq(codec.CURRENT_VERSION, 3, "two migrations put the current version one past the last step") + local path = os.tmpname() + Serialize.saveFile(path, { trail = {} }) + local data = codec.load(path) + os.remove(path) + eq(#data.trail, 2, "both migration steps ran") + eq(data.trail[1], "one", "step one ran before step two") + eq(data.trail[2], "two", "step two ran after step one") +end + +function tests.saveGameMigrationChainSkipsStepsAlreadyPastTheStoredVersion() + local codec = SaveGame.new({ + [1] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "one"); return d end, + [2] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "two"); return d end, + }) + local payload = Serialize.encode({ trail = {} }) + local envelope = { + magic = "WARRENSAVE", version = 2, + checksum = SaveGame.fnv1a32(payload), payload = payload, + } + local data = codec.decode(Serialize.encode(envelope)) + eq(#data.trail, 1, "a save already at version 2 only runs the one remaining step") + eq(data.trail[1], "two", "the remaining step is the one from version 2 onward, not version 1's") +end + +function tests.metaLoadUpgradesALegacyPreEnvelopeFileWithoutLosingItsRenown() + local path = os.tmpname() + Serialize.saveFile(path, { + renown = 77, totalRuns = 3, wins = 1, bestDepth = 4, + achievements = { boss_slayer = true }, perks = { vigor = true }, + }) + local meta = Meta.load(path) + os.remove(path) + eq(meta.renown, 77, "renown from a pre-envelope legacy file is not discarded") + eq(meta.achievements.boss_slayer, true, "achievements from a legacy file survive the upgrade") + eq(#meta.graves, 0, "graves defaults in for a file predating the graves feature") + eq(#meta.leaderboard, 0, "leaderboard defaults in for a file predating the leaderboard feature") + eq(#meta.runHistory, 0, "runHistory defaults in for a file predating streaks") +end + +function tests.metaLoadStartsFreshWithoutCrashingOnACorruptedFile() + local path = os.tmpname() + local meta = Meta.new() + meta.renown = 500 + Meta.save(meta, path) + local file = assert(io.open(path, "r")) + local text = file:read("a") + file:close() + local tampered = text:gsub("500", "999", 1) + local out = assert(io.open(path, "w")) + out:write(tampered) + out:close() + local restored = Meta.load(path) + os.remove(path) + eq(restored.renown, 0, "a checksum-broken meta file starts fresh rather than trusting tampered renown") +end + +function tests.gameLoadUpgradesALegacyPreEnvelopeSaveMissingNewerPlayerFields() + local game = Game.new(1, { class = "fighter" }) + local data = game:toData() + data.playerExtra.essence = nil + data.playerExtra.skills = nil + data.playerExtra.skillPoints = nil + data.playerExtra.skillCooldowns = nil + data.playerExtra.reputation = nil + data.playerExtra.sanity = nil + data.stats.vaultsOpened = nil + data.stats.setPiecesFound = nil + local path = os.tmpname() + Serialize.saveFile(path, data) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.essence, 0, "essence defaults in for a save predating it") + eq(restored.stats.vaultsOpened, 0, "vaultsOpened defaults in for a save predating it") + eq(restored.stats.setPiecesFound, 0, "setPiecesFound defaults in for a save predating it") + eq(type(restored.player.skills), "table", "skills defaults to an empty table for a save predating the skill tree") + eq(restored.player.skillPoints, 0, "skillPoints defaults in for a save predating the skill tree") + eq(type(restored.player.skillCooldowns), "table", + "skillCooldowns defaults to an empty table for a save predating the skill tree") + eq(type(restored.player.reputation), "table", "reputation defaults to an empty table for a save predating it") + eq(restored.player.sanity, Sanity.MAX, "sanity defaults to full for a save predating the sanity system") +end + +function tests.gameLoadRaisesAClearErrorOnATruncatedSaveFileInsteadOfCrashingObscurely() + local game = Game.new(1) end local order = { From f47cfd379f92ddb3f5bd6eb9bfad9170467fe118 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 15:55:05 +0200 Subject: [PATCH 150/172] test: cover tests.gameLoadRaisesAClearErrorForASaveFromAFutureVersion --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 6542389..0139363 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11720,6 +11720,179 @@ end function tests.gameLoadRaisesAClearErrorOnATruncatedSaveFileInsteadOfCrashingObscurely() local game = Game.new(1) + local path = os.tmpname() + game:save(path) + local file = assert(io.open(path, "r")) + local text = file:read("a") + file:close() + local out = assert(io.open(path, "w")) + out:write(text:sub(1, 20)) + out:close() + local success, err = pcall(Game.load, path) + os.remove(path) + eq(success, false, "loading a truncated save fails rather than silently misloading") + ok(contains(err or "", "corrupt"), "the failure names corruption instead of an obscure nil-call error") +end + +function tests.gameLoadRaisesAClearErrorForASaveFromAFutureVersion() + local payload = Serialize.encode({ marker = 1 }) + local envelope = { + magic = "WARRENSAVE", version = 999, + checksum = SaveGame.fnv1a32(payload), payload = payload, + } + local path = os.tmpname() + local file = assert(io.open(path, "w")) + file:write(Serialize.encode(envelope)) + file:close() + local success, err = pcall(Game.load, path) + os.remove(path) + eq(success, false, "loading a save from a newer format fails rather than misinterpreting it") + ok(contains(err or "", "future version"), "the failure names the version mismatch") +end + +-- A dead-end room reachable from the player's own room only by turning a +-- corner, so a straight sightline between the two ends always crosses solid +-- rock while a four-directional flood (or a walker) can still reach it. +local function noiseCornerArena() + local map = Map.new(15, 9) + for x = 2, 5 do map:set(x, 3, Map.FLOOR) end + for y = 3, 5 do map:set(5, y, Map.FLOOR) end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 3, downX = 5, downY = 5, + }) +end + +function tests.noisePropagateDecaysByExactlyOneLoudnessPerStepThroughOpenFloor() + local level = arena(21, 11) + local field = Noise.propagate(level.map, 5, 5, 4) + eq(Noise.intensityAt(field, level.map, 5, 5), 4, "the origin itself keeps the full starting loudness") + eq(Noise.intensityAt(field, level.map, 6, 5), 3, "one step away has decayed by exactly one") + eq(Noise.intensityAt(field, level.map, 7, 5), 2, "two steps away has decayed by exactly two") + eq(Noise.intensityAt(field, level.map, 8, 5), 1, "three steps away has decayed by exactly three") + eq(Noise.intensityAt(field, level.map, 9, 5), 0, "four steps away has decayed to nothing") + eq(Noise.intensityAt(field, level.map, 10, 5), 0, "five steps away never even carried a trailing zero forward") +end + +function tests.noisePropagateNeverCrossesASolidWallEvenWhenTheOtherSideIsCloseByEuclideanDistance() + local level = wallSplitArena() + local field = Noise.propagate(level.map, 5, 6, 20) + ok(Noise.intensityAt(field, level.map, 10, 6) > 0, "loudness reaches all the way up to the dividing wall") + eq(Noise.intensityAt(field, level.map, 12, 6), 0, "nothing at all gets through to the far side of the wall") +end + +function tests.noisePropagateBendsAroundACornerToReachACellWithNoStraightLineAtAll() + local level = noiseCornerArena() + ok(not Los.clear(level.map, 2, 3, 5, 5), "no straight sightline exists between the two ends of the bend") + local field = Noise.propagate(level.map, 2, 3, 10) + ok(Noise.intensityAt(field, level.map, 5, 5) > 0, "the flood still reaches the far end by turning the corner") + eq(Noise.intensityAt(field, level.map, 5, 5), 10 - 5, "the intensity there reflects the corridor's actual walked distance, five steps") +end + +function tests.noisePropagateFromAWallOriginStillReachesItsWalkableNeighbours() + local level = arena(21, 11) + level.map:set(10, 6, Map.WALL) + local field = Noise.propagate(level.map, 10, 6, 5) + eq(Noise.intensityAt(field, level.map, 10, 6), 5, "the unwalkable origin itself still records the starting loudness") + eq(Noise.intensityAt(field, level.map, 11, 6), 4, "a walkable neighbour of the struck wall still hears it one step quieter") + eq(Noise.intensityAt(field, level.map, 9, 6), 4, "every walkable side of the wall hears it, not just one") +end + +function tests.noiseIntensityAtIsZeroOutOfBoundsAndWhereverTheFloodNeverReached() + local level = arena(21, 11) + local field = Noise.propagate(level.map, 5, 5, 3) + eq(Noise.intensityAt(field, level.map, -3, 5), 0, "a cell outside the map bounds reads as silent") + eq(Noise.intensityAt(field, level.map, 5, 5000), 0, "a wildly out of range cell reads as silent rather than erroring") + eq(Noise.intensityAt(field, level.map, 19, 9), 0, "a cell the flood never reached reads as silent") +end + +function tests.noisePropagateWithZeroOrNegativeLoudnessReachesNothing() + local level = arena(21, 11) + eq(next(Noise.propagate(level.map, 5, 5, 0)), nil, "zero loudness produces an entirely empty field") + eq(next(Noise.propagate(level.map, 5, 5, -3)), nil, "negative loudness produces an entirely empty field too") +end + +function tests.gameEmitNoiseEventuallyStirsADormantMonsterWithinRangeIntoSearchingAtTheNoisesOrigin() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + local stirred = false + for _ = 1, 200 do + game:emitNoise(3, 6, Noise.MELEE) + if orc.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a dormant monster within the flood's reach eventually stirs across enough noisy bursts") + eq(orc.awareness, Stealth.SEARCHING, "hearing a noise only starts a search, unlike Stealth.alert's outright hunting") + eq(orc.lastKnownX, 3, "the search heads for the noise's own origin") + eq(orc.lastKnownY, 6, "the search heads for the noise's own origin") +end + +function tests.gameEmitNoiseNeverStirsADormantMonsterTheFloodCannotReach() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + for _ = 1, 200 do + game:emitNoise(3, 6, Noise.MELEE) + end + eq(orc.awareness, Stealth.DORMANT, "a monster far outside even the loudest reach used here never stirs") +end + +function tests.gameEmitNoiseNeverAffectsAMonsterThatIsAlreadyAware() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.awareness = Stealth.HUNTING + orc.lastKnownX, orc.lastKnownY = 99, 99 + game.level.entities = { orc } + game:emitNoise(3, 6, Noise.MELEE) + eq(orc.awareness, Stealth.HUNTING, "an already-aware monster is untouched by a noise burst") + eq(orc.lastKnownX, 99, "its existing last-known position is left exactly as it was") +end + +function tests.gamePlayerAttackCanStirADormantBystanderThatIsNotEvenTheTarget() + local game = stagedGame(1, arena(21, 11), 3, 6) + local target = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 2, 8, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { target, bystander } + local stirred = false + for _ = 1, 200 do + target.hp = target.maxHp + game:playerAttack(target) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a sustained melee fight eventually stirs a wild bystander who was never the target") + eq(bystander.awareness, Stealth.SEARCHING, "the bystander only starts searching, having merely heard the clash") +end + +function tests.gameMonsterMeleeCanStirADormantBystanderTooNotJustPlayerActions() + local game = stagedGame(1, arena(21, 11), 3, 6) + local attacker = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 2, 8, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { attacker, bystander } + local stirred = false + for _ = 1, 200 do + game.player.hp = game.player.maxHp + game:monsterMelee(attacker) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a monster hitting the player is exactly as loud as the player hitting back") +end + +function tests.gameCastSpellOnlyEmitsNoiseOnASuccessfulCastNeverOnAFailedOne() + local game = stagedGame(5, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 2, 9, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { rat, bystander } + game.player.mana = 0 + for _ = 1, 200 do + game:castSpell("missile", rat) + end + eq(bystander.awareness, Stealth.DORMANT, "two hundred failed casts for lack of mana never make a sound") end local order = { From 89e97d7b3a75527746f50777b25e8b3cac59864c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:38:08 +0200 Subject: [PATCH 151/172] test: cover tests.gameDigEmitsNoiseFromTheStruckCellEvenThoughItStart... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 0139363..007b45d 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11893,6 +11893,179 @@ function tests.gameCastSpellOnlyEmitsNoiseOnASuccessfulCastNeverOnAFailedOne() game:castSpell("missile", rat) end eq(bystander.awareness, Stealth.DORMANT, "two hundred failed casts for lack of mana never make a sound") + + game.player.mana = 999 + local stirred = false + for _ = 1, 200 do + rat.hp = rat.maxHp + game:castSpell("missile", rat) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a spell that actually goes off is loud enough to eventually stir a nearby bystander") +end + +function tests.gameDigEmitsNoiseFromTheStruckCellEvenThoughItStartsUnwalkable() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.WALL) + game.player.pick = Items.pick("miner's pick", 20) + local bystander = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { bystander } + local stirred = false + for _ = 1, 200 do + game:dig(1, 0) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + if not game.player.pick then game.player.pick = Items.pick("miner's pick", 20) end + if game.level.map:get(4, 6) == Tiles.FLOOR then game.level.map:set(4, 6, Tiles.WALL) end + end + ok(stirred, "the racket of a pick striking solid rock still carries even though the struck cell starts unwalkable") +end + +function tests.diseaseInfectSeedsIncubationAndRefusesToStackASecondInfection() + local host = { name = "dummy", hp = 20 } + ok(Disease.infect(host, Disease.PARASITES), "infecting a clean host succeeds") + ok(Disease.isInfected(host), "the host now carries an infection") + eq(host.disease.incubation, Disease.defs[Disease.PARASITES].incubation, "incubation starts at the affliction's own listed value") + eq(host.disease.stage, 0, "a freshly seeded infection has not erupted yet") + ok(not Disease.infect(host, Disease.ROT), "a second affliction cannot take hold while one is already active") + eq(host.disease.key, Disease.PARASITES, "the original infection is left completely untouched by the refused attempt") +end + +function tests.diseaseUnknownKeyNeverInfects() + local host = { name = "dummy", hp = 20 } + ok(not Disease.infect(host, "swamp fever"), "an unrecognised affliction key never takes hold") + ok(not Disease.isInfected(host), "the host stays clean") +end + +function tests.diseaseIsInfectedAndHasEruptedTrackIncubationVsActiveStageSeparately() + local host = { name = "dummy", hp = 20 } + Disease.infect(host, Disease.ROT) + ok(Disease.isInfected(host), "an incubating host already counts as infected") + ok(not Disease.hasErupted(host), "but has not yet erupted while incubation is still counting down") + host.disease.stage = 1 + ok(Disease.hasErupted(host), "once staged past zero the infection counts as erupted") +end + +function tests.diseaseTickCountsIncubationDownInSilenceThenErupts() + local host = { name = "dummy", hp = 20 } + Disease.infect(host, Disease.PARASITES) + local incubation = Disease.defs[Disease.PARASITES].incubation + for i = 1, incubation - 1 do + local line = Disease.tick(host) + eq(line, nil, "each turn still incubating produces no visible symptom") + eq(host.disease.stage, 0, "the infection has not erupted yet at turn " .. i) + end + local eruptionLine = Disease.tick(host) + ok(contains(eruptionLine, "breaks out"), "the turn incubation runs out, the eruption is reported") + eq(host.disease.stage, 1, "the infection is now active at stage one") +end + +function tests.diseaseTickDealsStagePotencyThroughResistancesAndEscalatesOnItsFixedSchedule() + local host = { name = "dummy", hp = 1000 } + Disease.infect(host, Disease.ROT) + host.disease.stage = 1 + host.disease.sinceStage = 0 + local def = Disease.defs[Disease.ROT] + local hpBefore = host.hp + Disease.tick(host) + eq(host.hp, hpBefore - def.stagePotency[1], "a stage-one tick deals exactly that stage's listed potency") + for _ = 2, def.stageTurns - 1 do + Disease.tick(host) + end + eq(host.disease.stage, 1, "the stage holds steady right up until its full window has elapsed") + local escalationLine = Disease.tick(host) + ok(contains(escalationLine, "worsens"), "the tick that completes the window reports the escalation") + eq(host.disease.stage, 2, "the infection has moved on to stage two") + eq(host.disease.sinceStage, 0, "the new stage starts its own window fresh") +end + +function tests.diseaseTickNeverEscalatesPastTheTopStage() + local host = { name = "dummy", hp = 1000 } + Disease.infect(host, Disease.PLAGUE) + host.disease.stage = Disease.MAX_STAGE + host.disease.sinceStage = 0 + local def = Disease.defs[Disease.PLAGUE] + for _ = 1, def.stageTurns * 3 do + Disease.tick(host) + end + eq(host.disease.stage, Disease.MAX_STAGE, "the top stage never escalates any further, however long it lingers") +end + +function tests.diseaseAnImmuneHostTakesNoDamageButStillEscalatesOnSchedule() + local host = { name = "dummy", hp = 1000, resist = { poison = Resistances.IMMUNE } } + Disease.infect(host, Disease.ROT) + host.disease.stage = 1 + host.disease.sinceStage = 0 + local def = Disease.defs[Disease.ROT] + local hpBefore = host.hp + for _ = 1, def.stageTurns - 1 do + local line = Disease.tick(host) + eq(line, nil, "an immune host takes no damage, so a plain tick has nothing to report") + end + eq(host.hp, hpBefore, "an immune host never loses hp to the infection at all") + local escalationLine = Disease.tick(host) + ok(contains(escalationLine, "worsens"), "immunity blocks the damage but not the clock - the stage still worsens on schedule") +end + +function tests.diseaseCureLiftsAnInfectionAtAnyStageAndReportsFalseWhenThereWasNothingToLift() + local host = { name = "dummy", hp = 20 } + eq(Disease.cure(host), false, "curing a clean host reports nothing was lifted") + Disease.infect(host, Disease.PARASITES) + ok(Disease.cure(host), "curing an incubating infection succeeds") + ok(not Disease.isInfected(host), "the host is clean again") + Disease.infect(host, Disease.PLAGUE) + host.disease.stage = 3 + ok(Disease.cure(host), "curing works exactly the same on a fully erupted top-stage infection") + ok(not Disease.isInfected(host), "the host is clean again regardless of how far the infection had progressed") +end + +function tests.diseaseStagePotencyIsZeroWhileIncubatingOrUninfected() + local host = { name = "dummy", hp = 20 } + eq(Disease.stagePotency(host), 0, "an uninfected host has no stage potency at all") + Disease.infect(host, Disease.ROT) + eq(Disease.stagePotency(host), 0, "a still-incubating infection has not erupted into any potency yet") + host.disease.stage = 2 + eq(Disease.stagePotency(host), Disease.defs[Disease.ROT].stagePotency[2], "an erupted stage reports its own listed potency") +end + +function tests.gameMonsterMeleeCanInfectThePlayerViaABiteCarryingSpeciesButNeverStacksASecondInfection() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + game.level.entities = { rat } + game.player.hp = 10000 + game.player.maxHp = 10000 + local infected = false + for _ = 1, 400 do + game:monsterMelee(rat) + if Disease.isInfected(game.player) then infected = true; break end + end + ok(infected, "enough bites from a parasite-carrying species eventually infect the player") + eq(game.player.disease.key, Disease.PARASITES, "the player caught exactly the species' own carried affliction") + local firstIncubation = game.player.disease.incubation + for _ = 1, 50 do + game:monsterMelee(rat) + end + ok(game.player.disease.incubation <= firstIncubation, "further bites while already sick never restart or stack a second infection") +end + +function tests.gameMonsterMeleeNeverInfectsFromASpeciesWithNoBiteDisease() + local game = stagedGame(1, arena(21, 11), 3, 6) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 4, 6, 1) + eq(goblin.biteDisease, nil, "a goblin carries no disease of its own to begin with") + game.level.entities = { goblin } + game.player.hp = 10000 + game.player.maxHp = 10000 + for _ = 1, 400 do + game:monsterMelee(goblin) + end + ok(not Disease.isInfected(game.player), "four hundred goblin bites never infect anyone, since goblins carry nothing to catch") +end + +function tests.gameEndPlayerTurnTicksThePlayersDiseaseAndLogsItsEruption() + local game = stagedGame(1, arena(21, 11), 3, 6) + Disease.infect(game.player, Disease.PARASITES) + local incubation = Disease.defs[Disease.PARASITES].incubation + for _ = 1, incubation - 1 do end local order = { From fce4742c188c0a431c74c85e4fbc5606b588b8fa Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:23:02 +0200 Subject: [PATCH 152/172] test: cover tests.gameUseItemDrinkingACurePotionLiftsTheInfectionAndI... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 007b45d..df1d081 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12066,6 +12066,179 @@ function tests.gameEndPlayerTurnTicksThePlayersDiseaseAndLogsItsEruption() Disease.infect(game.player, Disease.PARASITES) local incubation = Disease.defs[Disease.PARASITES].incubation for _ = 1, incubation - 1 do + game:endPlayerTurn() + eq(game.player.disease.stage, 0, "the infection keeps quietly incubating turn by turn") + end + game:endPlayerTurn() + eq(game.player.disease.stage, 1, "the infection erupts on schedule as the player's own turns tick past") + eq(game.log:last(), "you break out in gut parasites", + "the eruption is reported in the message log with correct you-are-the-subject grammar, not the third-person 'breaks out' a named monster would get") +end + +function tests.gameUseItemDrinkingACurePotionLiftsTheInfectionAndIdentifiesThePotion() + local game = stagedGame(1, arena(21, 11), 3, 6) + Disease.infect(game.player, Disease.ROT) + game.player.inventory = { Items.potionOf("cure") } + local used = game:useItem(1) + eq(used, true, "drinking the potion succeeds") + ok(not Disease.isInfected(game.player), "the cure potion lifts the infection entirely") + ok(contains(game.log:last(), "cleansed"), "the log confirms the cure took effect") + eq(#game.player.inventory, 0, "the spent potion leaves the inventory like any other") +end + +function tests.gameUseItemDrinkingACurePotionAlsoLiftsHostileStatusesNotJustDisease() + local game = stagedGame(1, arena(21, 11), 3, 6) + Status.apply(game.player, Status.POISON, 5, 2) + Status.apply(game.player, Status.CONFUSE, 6, 1) + Status.apply(game.player, Status.STRENGTH, 30, 3) + game.player.inventory = { Items.potionOf("cure") } + local used = game:useItem(1) + eq(used, true, "drinking the potion succeeds") + ok(not Status.has(game.player, Status.POISON), "the cure potion lifts poison even with no disease present") + ok(not Status.has(game.player, Status.CONFUSE), "the cure potion lifts confusion too") + ok(Status.has(game.player, Status.STRENGTH), "the cure potion never touches a buff like strength") + ok(contains(game.log:last(), "fades"), "the log reports the lifted statuses when there was no disease to mention") +end + +function tests.gameUseItemDrinkingACurePotionReportsBothDiseaseAndStatusesWhenBothArePresent() + local game = stagedGame(1, arena(21, 11), 3, 6) + Disease.infect(game.player, Disease.ROT) + Status.apply(game.player, Status.POISON, 5, 2) + game.player.inventory = { Items.potionOf("cure") } + game:useItem(1) + ok(not Disease.isInfected(game.player), "the infection is lifted alongside the status effect") + ok(not Status.has(game.player, Status.POISON), "the status effect is lifted alongside the infection") + ok(contains(game.log:last(), "cleansed away") and contains(game.log:last(), "fades"), + "the single log line credits both the disease cure and the status cure") +end + +function tests.gameUseItemDrinkingACurePotionWithNothingToCureIsHonestInTheLog() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.inventory = { Items.potionOf("cure") } + local used = game:useItem(1) + eq(used, true, "the potion is still consumed even when there is nothing to cleanse") + ok(contains(game.log:last(), "nothing to cleanse"), "the log admits nothing was actually cured") +end + +function tests.diseaseAndBiteDiseaseFieldsSurviveALevelSerializationRoundTrip() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + Disease.infect(rat, Disease.PLAGUE) + rat.disease.stage = 2 + game.level.entities = { rat } + + local restored = Level.fromData(game.level:toData()) + eq(restored.entities[1].biteDisease, Disease.PARASITES, "the species' own carried affliction survives a level round trip") + eq(restored.entities[1].disease.key, Disease.PLAGUE, "the active infection's key survives a level round trip") + eq(restored.entities[1].disease.stage, 2, "the active infection's progressed stage survives a level round trip") +end + +-- captives.lua: netting a battered monster alive instead of finishing it +-- off, and cashing the resulting captive in for goodwill, gold or intel. + +function tests.captivesEligibleAcceptsABatteredWildHostileAndRejectsATooHealthyOne() + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + rat.hp = 1 + ok(Captives.eligible(rat), "a wild hostile battered down to a sliver of hp is eligible for a net") + rat.hp = rat.maxHp + ok(not Captives.eligible(rat), "a target still at full health is too fierce to net") +end + +function tests.captivesEligibleExcludesBossesMimicsGravesNemesesAndTheVendingShopkeeper() + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 3) + orc.hp = 1 + orc.isBoss = true + ok(not Captives.eligible(orc), "a boss is excluded from netting even when battered") + orc.isBoss = false + orc.mimic = true + ok(not Captives.eligible(orc), "a mimic is excluded from netting even when battered") + orc.mimic = false + orc.grave = true + ok(not Captives.eligible(orc), "a grave shade is excluded from netting even when battered") + orc.grave = false + orc.nemesis = true + ok(not Captives.eligible(orc), "a nemesis is excluded from netting even when battered") + orc.nemesis = false + ok(Captives.eligible(orc), "the same battered orc becomes eligible again once none of the exclusions apply") + local shopkeeper = Monsters.create(Monsters.byKey.shopkeeper, 2, 6, 6, 4) + shopkeeper.hp = 1 + ok(not Captives.eligible(shopkeeper), "the vending shopkeeper is never eligible for a net even when battered") +end + +function tests.captivesCanReachRespectsRangeAndLineOfSight() + local level = arena(21, 11) + ok(Captives.canReach(level.map, 3, 6, 3 + Captives.RANGE, 6), "a target at exactly max range with a clear line is reachable") + ok(not Captives.canReach(level.map, 3, 6, 3 + Captives.RANGE + 1, 6), "a target one cell beyond max range is unreachable") + level.map:set(5, 6, Tiles.WALL) + ok(not Captives.canReach(level.map, 3, 6, 7, 6), "a wall blocking the line of sight makes an in-range target unreachable") +end + +function tests.captivesValueOfMatchesKillXpAndAddsAnEliteBonus() + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 3) + eq(Captives.valueOf(orc), Monsters.xpFor(orc), "a plain monster's capture value equals its kill xp") + orc.elite = true + orc.eliteTier = "champion" + local baseXp = Monsters.xpFor(orc) + eq(Captives.valueOf(orc), baseXp + Elites.xpBonus(orc, baseXp), + "an elite's capture value adds the same bonus a kill's xp would") + ok(Captives.valueOf(orc) > baseXp, "the elite bonus actually raises the capture value above the plain kill xp") +end + +function tests.captivesResolveRefusesWithNoClearThrowWithoutSpendingTheNet() + local game = stagedGame(4, arena(21, 11), 2, 2) + local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) + rat.hp = 1 + game.level.entities = { rat } + local net = Items.net() + local success, lines = Captives.resolve(game, game.player, net, rat) + eq(success, false, "a net thrown beyond range never leaves the hand") + ok(contains(lines[1], "clear throw"), "the reason given is the lack of a clear throw") +end + +function tests.captivesResolveWastesTheNetOnATargetStillTooHealthyToSubdue() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + game.level.entities = { rat } + local net = Items.net() + local success, lines = Captives.resolve(game, game.player, net, rat) + eq(success, true, "a net thrown at a still-healthy target still leaves the hand") + eq(#game.level.captives, 0, "no captive is created from a target too healthy to subdue") + eq(rat.hp, rat.maxHp, "the target takes no damage from a wasted net") + ok(contains(lines[1], "fierce"), "the log explains the target was too fierce to net") +end + +function tests.captivesResolveCanThrashFreeAndStillSpendTheNet() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.rng = Rng.new(1) + local net = Items.net() + local success, lines = Captives.resolve(game, game.player, net, rat) + eq(success, true, "a lost catch roll still leaves the net thrown") + eq(#game.level.captives, 0, "no captive is created when the target thrashes free") + eq(rat.hp, 1, "a thrashing target takes no further damage") + ok(rat:isAlive(), "a target that thrashes free is still alive") + ok(contains(lines[1], "thrashes free"), "the log reports the target thrashing free") +end + +function tests.captivesResolveCatchesABatteredTargetIntoACaptiveAndZeroesItsHp() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.rng = Rng.new(2) + local net = Items.net() + local success, lines = Captives.resolve(game, game.player, net, rat) + eq(success, true, "a successful catch leaves the net thrown") + eq(#game.level.captives, 1, "a successful catch adds exactly one captive") + local captive = game.level.captives[1] + eq(captive.name, rat.name, "the captive keeps the caught monster's name") + eq(captive.species, rat.species, "the captive keeps the caught monster's species") + eq(captive.faction, rat.faction, "the captive keeps the caught monster's faction") + eq(captive.value, Monsters.xpFor(rat), "the captive's value matches Captives.valueOf") + eq(rat.hp, 0, "the caught monster's hp is zeroed") + ok(not rat:isAlive(), "the caught monster is no longer alive") end local order = { From 6f260eb25f17b1c01ed687bfce4244161ce84a51 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 18:42:28 +0200 Subject: [PATCH 153/172] test: cover tests.gameCaptureAtRemovesTheCaughtMonsterFromTheLevelWit... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index df1d081..48d5c9e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12239,6 +12239,179 @@ function tests.captivesResolveCatchesABatteredTargetIntoACaptiveAndZeroesItsHp() eq(captive.value, Monsters.xpFor(rat), "the captive's value matches Captives.valueOf") eq(rat.hp, 0, "the caught monster's hp is zeroed") ok(not rat:isAlive(), "the caught monster is no longer alive") + ok(contains(lines[1], "bound"), "the log reports the net dropping true") +end + +function tests.gameCaptureAtRemovesTheCaughtMonsterFromTheLevelWithoutAwardingAKill() + local game = stagedGame(4, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + local before = #game.player.inventory + game.player.inventory[#game.player.inventory + 1] = Items.net() + local index = #game.player.inventory + game.rng = Rng.new(2) + local xpBefore = game.player.xp + local killsBefore = game.stats.kills + local success = game:captureAt(index, rat) + eq(success, true, "a successful net capture reports the item as thrown") + eq(#game.player.inventory, before, "the net leaves the inventory once thrown") + eq(#game.level.entities, 0, "the captured monster is swept from the level's entities") + eq(#game.level.captives, 1, "the captured monster becomes exactly one level captive") + eq(game.player.xp, xpBefore, "a capture grants no xp") + eq(game.stats.kills, killsBefore, "a capture is never counted as a kill") + eq(#game.level.corpses, 0, "a captured monster leaves no corpse behind") +end + +function tests.gameCaptureAtLeavesTheTargetAliveAndTheNetUnspentWhenOutOfRange() + local game = stagedGame(4, arena(21, 11), 2, 2) + local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) + rat.hp = 1 + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.net() + local index = #game.player.inventory + local before = #game.player.inventory + local success = game:captureAt(index, rat) + eq(success, false, "a capture attempt beyond range fails") + eq(#game.player.inventory, before, "a failed capture attempt does not consume the net") + ok(rat:isAlive(), "a failed capture attempt leaves the target alive") +end + +function tests.gameRansomCaptiveWarmsFactionStandingWithNoGoldChange() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.level.captives = { { x = 5, y = 5, species = "rat", faction = Factions.VERMIN, name = "rat", value = 3 } } + local goldBefore = game.player.gold + local repBefore = Factions.reputationFor(game.player, Factions.VERMIN) + local success = game:ransomCaptive() + eq(success, true, "ransoming a held captive succeeds") + eq(#game.level.captives, 0, "ransoming removes the captive from the level") + eq(game.player.gold, goldBefore, "ransoming changes no gold") + eq(Factions.reputationFor(game.player, Factions.VERMIN), repBefore + Captives.RANSOM_REP_GAIN, + "ransoming warms the captive's own faction standing") +end + +function tests.gameSellCaptivePaysGoldAndCoolsFactionStanding() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.level.captives = { { x = 5, y = 5, species = "rat", faction = Factions.VERMIN, name = "rat", value = 7 } } + local goldBefore = game.player.gold + local repBefore = Factions.reputationFor(game.player, Factions.VERMIN) + local success = game:sellCaptive() + eq(success, true, "selling a held captive succeeds") + eq(#game.level.captives, 0, "selling removes the captive from the level") + eq(game.player.gold, goldBefore + 7, "selling pays exactly the captive's own value in gold") + eq(Factions.reputationFor(game.player, Factions.VERMIN), repBefore - Captives.SELL_REP_LOSS, + "selling cools the captive's own faction standing") +end + +function tests.gameInterrogateCaptiveRevealsEveryHiddenDoorAndGrantsEssence() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.level.secretDoors = { + { x = 8, y = 8, discovered = false }, + { x = 9, y = 9, discovered = false }, + { x = 10, y = 10, discovered = true }, + } + game.level.captives = { { x = 5, y = 5, species = "rat", faction = Factions.VERMIN, name = "rat", value = 8 } } + local essenceBefore = game.player.essence + local success = game:interrogateCaptive() + eq(success, true, "interrogating a held captive succeeds") + eq(#game.level.captives, 0, "interrogating removes the captive from the level") + ok(game.level.secretDoors[1].discovered, "interrogating reveals a previously hidden door") + ok(game.level.secretDoors[2].discovered, "interrogating reveals every previously hidden door, not just one") + eq(game.player.essence, essenceBefore + 4, "interrogating grants essence equal to half the captive's value") +end + +function tests.captiveFieldsSurviveALevelSerializationRoundTrip() + local level = arena(21, 11) + level.captives = { { x = 5, y = 5, species = "rat", faction = Factions.VERMIN, name = "rat", value = 3 } } + local restored = Level.fromData(level:toData()) + eq(#restored.captives, 1, "a held captive survives a level round trip") + eq(restored.captives[1].name, "rat", "the captive's name survives a round trip") + eq(restored.captives[1].faction, Factions.VERMIN, "the captive's faction survives a round trip") + eq(restored.captives[1].value, 3, "the captive's value survives a round trip") + eq(restored:captiveAt(5, 5), restored.captives[1], "Level:captiveAt finds the restored captive by position") +end + +function tests.autopilotNetsABatteredAdjacentMonsterInsteadOfFinishingItInMeleeWhenCarryingANet() + local game = stagedGame(4, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + rat.hp = 1 + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.net() + game.rng = Rng.new(2) + game:choosePlayerAction() + eq(#game.level.captives, 1, "the autopilot nets the battered adjacent monster instead of finishing it off") + eq(#game.level.entities, 0, "the netted monster is removed from the level's entities") +end + +function tests.autopilotSellsAHeldCaptiveOnSight() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.level.captives = { { x = 5, y = 5, species = "rat", faction = Factions.VERMIN, name = "rat", value = 5 } } + local goldBefore = game.player.gold + game:choosePlayerAction() + eq(#game.level.captives, 0, "the autopilot cashes in a held captive on sight") + eq(game.player.gold, goldBefore + 5, "the autopilot sells rather than ransoms or interrogates on its own") +end + +function tests.builderOccasionallyPlacesANetFromDepthFive() + local level = Builder.build(5, Rng.new(10), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.NET then found = ground.item end + end + ok(found ~= nil, "this seed's depth-5 floor places a net") +end + +function tests.netsStayOffFloorsAboveDepthFive() + for seed = 1, 8 do + for depth = 1, 4 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.NET, "no net appears above depth 5") + end + end + end +end + +function tests.hordeBuildArenaIsFullyEnclosedWithAClearCentreReachableFromEveryCorner() + local map, cx, cy = Horde.buildArena(Rng.new(1), Horde.WIDTH, Horde.HEIGHT) + for x = 1, Horde.WIDTH do + eq(map:get(x, 1), Tiles.WALL, "the top border is solid wall") + eq(map:get(x, Horde.HEIGHT), Tiles.WALL, "the bottom border is solid wall") + end + for y = 1, Horde.HEIGHT do + eq(map:get(1, y), Tiles.WALL, "the left border is solid wall") + eq(map:get(Horde.WIDTH, y), Tiles.WALL, "the right border is solid wall") + end + ok(map:isWalkable(cx, cy), "the centre cell the player starts on is always clear") + for _, anchor in ipairs(Horde.spawnAnchors(Horde.WIDTH, Horde.HEIGHT)) do + ok(Astar.find(map, cx, cy, anchor.x, anchor.y) ~= nil, + "every corner anchor is reachable from the centre") + end +end + +function tests.hordeBuildArenaIsDeterministicForAFixedSeedAndDiffersAcrossSeeds() + local mapA = Horde.buildArena(Rng.new(5), Horde.WIDTH, Horde.HEIGHT) + local mapB = Horde.buildArena(Rng.new(5), Horde.WIDTH, Horde.HEIGHT) + eq(table.concat(mapA.tiles), table.concat(mapB.tiles), "the same seed always carves the same pillars") + local mapC = Horde.buildArena(Rng.new(6), Horde.WIDTH, Horde.HEIGHT) + ok(table.concat(mapA.tiles) ~= table.concat(mapC.tiles), "a different seed carves a different arena") +end + +function tests.hordeSpawnPointsAreUniqueWalkableCellsAndAlwaysCoverAMaxWave() + local map = Horde.buildArena(Rng.new(3), Horde.WIDTH, Horde.HEIGHT) + local points = Horde.spawnPoints(map) + ok(#points >= Horde.MAX_COUNT, "the spawn queue always has room for a maximum-size wave") + local seen = {} + for _, point in ipairs(points) do + local key = point.x .. "," .. point.y + ok(not seen[key], "no spawn cell is ever offered twice") + seen[key] = true + ok(map:isWalkable(point.x, point.y), "every offered spawn cell is walkable") + end +end + +function tests.hordeCountForClimbsWithWaveThenCapsAtMaxCount() + eq(Horde.countFor(1), Horde.BASE_COUNT, "wave one spawns the base count") end local order = { From f1dc06a5fca77cc174f5ca140cf6e4a487e6d493 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 19:09:32 +0200 Subject: [PATCH 154/172] test: cover tests.hordeEssenceForIsZeroBeforeTheFirstWaveAndProportio... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 48d5c9e..2cc3e7f 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12412,6 +12412,179 @@ end function tests.hordeCountForClimbsWithWaveThenCapsAtMaxCount() eq(Horde.countFor(1), Horde.BASE_COUNT, "wave one spawns the base count") + eq(Horde.countFor(4), Horde.BASE_COUNT + 3, "count climbs by one per wave") + eq(Horde.countFor(50), Horde.MAX_COUNT, "count never exceeds the maximum") +end + +function tests.hordeEssenceForIsZeroBeforeTheFirstWaveAndProportionalAfter() + eq(Horde.essenceFor(0), 0, "no reward is owed before any wave has been cleared") + eq(Horde.essenceFor(3), 3 * Horde.ESSENCE_PER_WAVE, "reward scales linearly with the wave cleared") +end + +function tests.hordePoolExcludesThePassiveVendingShopkeeperEvenAtAQualifyingDepth() + local pool = Horde.pool(Monsters.byKey.shopkeeper.minDepth) + for _, spec in ipairs(pool) do + ok(spec.key ~= "shopkeeper", "the arena never draws the passive shopkeeper into a wave") + end + ok(#pool > 0, "other species at the same depth still populate the pool") +end + +function tests.hordeClearedIsVacuouslyTrueOnAnEmptyArenaAndFalseWhileAnyMonsterLives() + local level = arena(Horde.WIDTH, Horde.HEIGHT) + ok(Horde.cleared(level), "an arena that never had a monster placed counts as cleared") + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + level.entities = { rat } + ok(not Horde.cleared(level), "a living monster keeps the arena from counting as cleared") + rat.hp = 0 + ok(Horde.cleared(level), "the arena counts as cleared again once its last monster dies") +end + +function tests.hordeClearedIgnoresCarriedAlliesAndOnlyCountsHostileMonsters() + local level = arena(Horde.WIDTH, Horde.HEIGHT) + local golem = Entity.new({ kind = "companion", name = "clay golem", x = 3, y = 3, hp = 26, maxHp = 26 }) + local hireling = Entity.new({ kind = "hireling", name = "sellsword", x = 4, y = 3, hp = 20, maxHp = 20 }) + local thrall = Entity.new({ kind = "thrall", name = "rat thrall", x = 5, y = 3, hp = 4, maxHp = 4 }) + level.entities = { golem, hireling, thrall } + ok(Horde.cleared(level), "three living allies with no hostile monster among them still count as cleared") + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + level.entities[#level.entities + 1] = rat + ok(not Horde.cleared(level), "a living hostile monster still blocks clearance even alongside living allies") + rat.hp = 0 + ok(Horde.cleared(level), "clearance returns once the monster dies, regardless of the allies still standing") +end + +function tests.hordeSpawnWaveNeverStacksTwoMonstersOnOneCellAndEverySpawnStartsHunting() + local game = Game.new(11) + game:startHorde() + local level = game.level + local seen = {} + eq(#level.entities, Horde.countFor(1), "the first wave spawns exactly its own count") + for _, entity in ipairs(level.entities) do + local key = entity.x .. "," .. entity.y + ok(not seen[key], "no two monsters ever spawn on the same cell") + seen[key] = true + eq(entity.awareness, Stealth.HUNTING, "an arena spawn starts already hunting, never asleep") + end +end + +function tests.gameStartHordeBeginsAtWaveOneCenteredAndWithoutAStartingReward() + local game = Game.new(11) + local essenceBefore, hpBefore = game.player.essence, game.player.hp + game:startHorde() + eq(game.horde.wave, 1, "starting a horde run begins at wave one") + eq(game.level.generator, "horde", "the arena level is tagged as a horde level") + eq(game.player.essence, essenceBefore, "bootstrapping wave one owes no essence reward") + eq(game.player.hp, hpBefore, "a player already at full health is not over-healed on entry") + ok(game.level.map:isWalkable(game.player.x, game.player.y), "the player starts on a walkable cell") +end + +function tests.gameEndPlayerTurnAdvancesTheHordeWaveBanksEssenceAndHealsOnceEveryMonsterDies() + local game = Game.new(11) + game:startHorde() + game.player.hp = game.player.maxHp - 1 + local essenceBefore = game.player.essence + for _, entity in ipairs(game.level.entities) do entity.hp = 0 end + game:cleanupDead() + game:endPlayerTurn() + eq(game.horde.wave, 2, "clearing wave one advances to wave two") + eq(game.player.essence, essenceBefore + Horde.essenceFor(1), "clearing a wave banks its essence reward") + eq(game.player.hp, game.player.maxHp, "clearing a wave heals a rest slice, capped at max hp") + eq(#game.level.entities, Horde.countFor(2), "wave two spawns its own larger count") +end + +function tests.gameStartHordeCarriesEveryLivingAllyIntoTheArenaInsteadOfAbandoningThem() + local game = Game.new(11) + local oldLevel = game.level + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 99, game.player.x + 1, game.player.y, 1) + Corpses.spawn(game.level, rat) + local thrall = Reanimation.raise(game, game.level.corpses[1]) + ok(golem ~= nil and hireling ~= nil and thrall ~= nil, "all three allies bond before entering the arena") + + game:startHorde() + ok(game:hasCompanion(), "the companion is with the player in the arena") + ok(game:hasHireling(), "the hireling is with the player in the arena") + local thrallFound + for _, entity in ipairs(game.level.entities) do + if entity.kind == "thrall" then thrallFound = entity end + end + eq(thrallFound, thrall, "the very same thrall instance enters the arena, not a rebuilt one") + + local leftBehind = 0 + for _, entity in ipairs(oldLevel.entities) do + if entity.kind == "companion" or entity.kind == "hireling" or entity.kind == "thrall" then + leftBehind = leftBehind + 1 + end + end + eq(leftBehind, 0, "none of the three allies are stranded on the abandoned floor") +end + +function tests.gameEndPlayerTurnStillAdvancesHordeWavesWithACarriedCompanionPresent() + local game = Game.new(11) + Companions.summon(game, "golem") + game:startHorde() + ok(game:hasCompanion(), "the companion made it into wave one's arena") + for _, entity in ipairs(game.level.entities) do + if entity.kind == "monster" then entity.hp = 0 end + end + game:cleanupDead() + game:endPlayerTurn() + eq(game.horde.wave, 2, "a living companion in the arena never blocks wave clearance from advancing") + ok(game:hasCompanion(), "the companion survives into wave two alongside the player") +end + +function tests.gameEndPlayerTurnLeavesAnUnclearedHordeWaveUntouched() + local game = Game.new(11) + game:startHorde() + local countBefore = #game.level.entities + game:endPlayerTurn() + eq(game.horde.wave, 1, "a wave still holding a living monster never advances") + eq(#game.level.entities, countBefore, "an uncleared wave's monster count is untouched") +end + +function tests.hordeNeverPromotesAnEliteInEitherOfTheFirstTwoWavesRegardlessOfSeed() + for seed = 1, 5 do + local game = Game.new(seed) + game:startHorde() + for _, entity in ipairs(game.level.entities) do + ok(not entity.elite, "no wave-one monster is ever promoted to elite, depth is below the minimum") + end + for _, e in ipairs(game.level.entities) do e.hp = 0 end + game:cleanupDead() + game:endPlayerTurn() + for _, entity in ipairs(game.level.entities) do + ok(not entity.elite, "no wave-two monster is ever promoted to elite either") + end + end +end + +function tests.hordeEventuallyPromotesElitesOnceWavesCrossTheMinimumDepth() + local game = Game.new(7) + game:startHorde() + local sawElite = false + for _ = 2, 6 do + for _, e in ipairs(game.level.entities) do e.hp = 0 end + game:cleanupDead() + game:endPlayerTurn() + for _, entity in ipairs(game.level.entities) do + if entity.elite then sawElite = true end + end + end + ok(sawElite, "this fixed seed rolls at least one elite once waves reach the minimum depth") +end + +function tests.branchesSeedForIsDeterministicAndVariesWithEitherInput() + eq(Branches.seedFor(42, 6), Branches.seedFor(42, 6), "the same world seed and depth always derive the same rift seed") + ok(Branches.seedFor(42, 6) ~= Branches.seedFor(42, 7), "a different origin depth derives a different rift seed") + ok(Branches.seedFor(42, 6) ~= Branches.seedFor(43, 6), "a different world seed derives a different rift seed") +end + +function tests.branchesBuildIsDeterministicAndGeneratesDeeperThanItsOrigin() + local a = Branches.build(11, 6, {}) + local b = Branches.build(11, 6, {}) + eq(table.concat(a.map.tiles), table.concat(b.map.tiles), + "the same world seed and origin depth always build the same pocket floor") end local order = { From bb27e6ef22937ad4ca3123d14a596466cc8ce83d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 20:14:49 +0200 Subject: [PATCH 155/172] test: cover tests.branchesBuildNeverPlacesAChasmOrTheWinningAmulet --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 2cc3e7f..d5f1041 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12585,6 +12585,179 @@ function tests.branchesBuildIsDeterministicAndGeneratesDeeperThanItsOrigin() local b = Branches.build(11, 6, {}) eq(table.concat(a.map.tiles), table.concat(b.map.tiles), "the same world seed and origin depth always build the same pocket floor") + eq(a.depth, 6 + Branches.DEPTH_BONUS, "a pocket floor is generated at its origin depth plus the fixed bonus") + local c = Branches.build(11, 7, {}) + ok(table.concat(a.map.tiles) ~= table.concat(c.map.tiles), "a different origin depth builds a different pocket floor") +end + +function tests.branchesBuildNeverPlacesAChasmOrTheWinningAmulet() + for seed = 1, 6 do + for depth = 6, 10 do + local level = Branches.build(seed, depth, {}) + for _, glyph in ipairs(level.map.tiles) do + ok(glyph ~= Tiles.CHASM, "a pocket floor never generates a chasm with nowhere lower to fall") + end + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.AMULET, "a pocket floor never generates the winning amulet") + end + end + end +end + +function tests.builderPlacesARiftShardFromDepthFourAndARiftFromDepthSix() + local shardLevel = Builder.build(4, Rng.new(1), {}) + local foundShard = false + for _, ground in ipairs(shardLevel.floorItems) do + if ground.item.kind == Items.RIFTSTONE then foundShard = true end + end + ok(foundShard, "this seed's depth-4 floor places a rift shard") + + local riftLevel = Builder.build(6, Rng.new(1), {}) + ok(#riftLevel.rifts >= 1, "this seed's depth-6 floor tears open a rift") +end + +function tests.riftsAndShardsStayOffFloorsAboveTheirOwnThreshold() + for seed = 1, 6 do + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.RIFTSTONE, "no rift shard appears above depth 4") + end + end + for depth = 1, 5 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.rifts, 0, "no rift appears above depth 6") + end + end +end + +function tests.riftFieldsSurviveALevelSerializationRoundTrip() + local level = arena(21, 11) + level.rifts = { { x = 6, y = 5 } } + eq(level:riftAt(6, 5), level.rifts[1], "Level:riftAt finds the rift by position") + ok(level:riftAt(1, 1) == nil, "Level:riftAt finds nothing off-position") + local restored = Level.fromData(level:toData()) + eq(#restored.rifts, 1, "a rift survives a level round trip") + eq(restored.rifts[1].x, 6, "the rift's position survives a round trip") + eq(restored:riftAt(6, 5), restored.rifts[1], "Level:riftAt finds the restored rift by position") +end + +function tests.gameEnterRiftFailsWithoutARiftUnderfootOrWithoutAShard() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:enterRift(), "entering fails with no rift on the current cell") + game.level.rifts = { { x = 5, y = 5 } } + ok(not game:enterRift(), "a first crossing fails without a carried shard") + eq(game.rift or false, false, "a failed crossing never sets the rift flag") +end + +function tests.gameEnterRiftSpendsTheShardAndSwapsToTheCachedPocketFloor() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + local invBefore = #game.player.inventory + local mainLevel = game.level + ok(game:enterRift(), "entering a rift underfoot with a shard in hand succeeds") + eq(#game.player.inventory, invBefore - 1, "the shard is spent on the first crossing") + eq(game.rift, true, "the rift flag is set while across a crossing") + eq(game.level.depth, game.depth + Branches.DEPTH_BONUS, "the pocket floor is generated deeper than the origin") + eq(game.player.x, game.level.upX, "the player lands on the pocket floor's own entry point") + eq(game.player.y, game.level.upY, "the player lands on the pocket floor's own entry point") + ok(game.riftReturn ~= nil, "a return point is remembered") + eq(game.riftReturn.level, mainLevel, "the return point remembers the exact main-track level object") +end + +function tests.gameDescendAndAscendAreBothBlockedWhileAcrossARift() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + game:enterRift() + local depthBefore, levelBefore = game.depth, game.level + game.player.x, game.player.y = game.level.downX, game.level.downY + ok(not game:descend(), "descend refuses to fire while the player is across a rift") + game.player.x, game.player.y = game.level.upX, game.level.upY + ok(not game:ascend(), "ascend refuses to fire while the player is across a rift") + eq(game.depth, depthBefore, "depth is untouched by either refusal") + eq(game.level, levelBefore, "the active level is untouched by either refusal") +end + +function tests.gameExitRiftRestoresTheExactMainTrackLevelDepthAndPosition() + local game = stagedGame(1, arena(21, 11), 5, 5) + local mainLevel = game.level + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + game:enterRift() + ok(not game:exitRift(), "exiting fails before the player reaches the pocket floor's own exit") + game.player.x, game.player.y = game.level.downX, game.level.downY + ok(game:exitRift(), "exiting at the pocket floor's own exit point succeeds") + eq(game.rift, false, "the rift flag clears on exit") + eq(game.riftReturn, nil, "the return point is cleared once used") + eq(game.level, mainLevel, "the exact original level object is restored, not a rebuilt copy") + eq(game.depth, 1, "the origin depth is restored") + eq(game.player.x, 5, "the player's exact departure position is restored") + eq(game.player.y, 5, "the player's exact departure position is restored") +end + +function tests.gameReenteringAStabilizedRiftNeverSpendsASecondShard() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + game:enterRift() + local pocketLevel = game.level + game.player.x, game.player.y = game.level.downX, game.level.downY + game:exitRift() + local invBefore = #game.player.inventory + game:enterRift() + eq(game.level, pocketLevel, "re-entering the same rift restores the exact cached pocket floor") + eq(#game.player.inventory, invBefore, "re-entering an already-stabilized rift spends no second shard") +end + +function tests.gameRiftPocketFloorPersistsStateAcrossALeaveAndReturn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + game:enterRift() + local orc = Monsters.create(Monsters.byKey.orc, 1, game.level.upX, game.level.upY, game.level.depth) + orc.hp = 0 + game.level.entities = { orc } + game.player.x, game.player.y = game.level.downX, game.level.downY + game:exitRift() + game.player.x, game.player.y = 5, 5 + game:enterRift() + eq(game.level.entities[1].hp, 0, "the pocket floor remembers a monster killed on an earlier crossing") +end + +-- Same abandonment bug Game:descend/Game:ascend had (see the ally-carrying +-- tests near gameAscendCarriesAnActiveNemesisAlongButNeverRollsANewOne), +-- just at the two other call sites that swap self.level: Game:enterRift and +-- Game:exitRift share the exact same Game:detachCarriedAllies/ +-- Game:plantCarriedAllies pair. +function tests.gameEnterRiftAndExitRiftBothCarryALivingCompanionAcrossTheCrossing() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + local mainLevel = game.level + local golem = Companions.summon(game, "golem") + ok(golem ~= nil, "the companion bonds before crossing") + + ok(game:enterRift(), "entering the rift succeeds with a shard in hand") + eq(game.rift, true, "the game is now across the rift") + ok(game:hasCompanion(), "the companion crosses into the rift's pocket floor too") + local strandedOnMain = 0 + for _, entity in ipairs(mainLevel.entities) do + if entity.kind == "companion" then strandedOnMain = strandedOnMain + 1 end + end + eq(strandedOnMain, 0, "the companion is not left stranded on the main-track floor") + + game.player.x, game.player.y = game.level.downX, game.level.downY + local pocketLevel = game.level + ok(game:exitRift(), "exiting the rift succeeds at its own downstairs") + eq(game.rift, false, "back on the main track") + ok(game:hasCompanion(), "the companion follows back out of the rift as well") + local strandedInPocket = 0 + for _, entity in ipairs(pocketLevel.entities) do + if entity.kind == "companion" then strandedInPocket = strandedInPocket + 1 end + end + eq(strandedInPocket, 0, "the companion is not left stranded on the pocket floor either") end local order = { From f710e182dd34c5725280abd2952f88aef93bc31f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 21:13:48 +0200 Subject: [PATCH 156/172] test: cover tests.choosePlayerActionEntersAWellHealedRiftUnderfootBut... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index d5f1041..56a432c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12760,6 +12760,179 @@ function tests.gameEnterRiftAndExitRiftBothCarryALivingCompanionAcrossTheCrossin eq(strandedInPocket, 0, "the companion is not left stranded on the pocket floor either") end +function tests.choosePlayerActionEntersAWellHealedRiftUnderfootButSkipsItWhenBadlyHurt() + local hurt = stagedGame(1, arena(21, 11), 5, 5) + hurt.level.rifts = { { x = 5, y = 5 } } + hurt.player.inventory[#hurt.player.inventory + 1] = Items.riftstone() + hurt.player.hp = math.floor(hurt.player.maxHp * 0.5) + hurt:choosePlayerAction() + eq(hurt.rift or false, false, "the autopilot never spends a shard while badly hurt") + + local healthy = stagedGame(1, arena(21, 11), 5, 5) + healthy.level.rifts = { { x = 5, y = 5 } } + healthy.player.inventory[#healthy.player.inventory + 1] = Items.riftstone() + healthy:choosePlayerAction() + eq(healthy.rift, true, "the autopilot crosses a rift underfoot on sight while at good health") +end + +function tests.choosePlayerActionStepsThroughAndThenExitsARiftUnderAutopilot() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.rifts = { { x = 5, y = 5 } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + game:choosePlayerAction() + ok(game.rift, "the autopilot has crossed into the pocket floor") + game.player.x, game.player.y = game.level.downX, game.level.downY + game:choosePlayerAction() + eq(game.rift, false, "standing at the pocket floor's own exit, the autopilot immediately steps back out") +end + +function tests.renderDrawsARiftGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + rendered.level.rifts = { { x = 5, y = 6 } } + rendered:computeVisible() + ok(contains(rendered:render(), "Q"), "a rift on a visible cell renders as its glyph") +end + +function tests.gameSaveLoadRoundTripsAnActiveRiftCrossing() + local game = Game.new(9) + game.level.rifts = { { x = game.player.x, y = game.player.y } } + game.player.inventory[#game.player.inventory + 1] = Items.riftstone() + ok(game:enterRift(), "the staged rift crossing succeeds") + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.rift, true, "the rift flag survives a save/load round trip") + eq(restored.depth, game.depth, "depth survives the round trip") + eq(table.concat(restored.level.map.tiles), table.concat(game.level.map.tiles), + "the active pocket floor's map survives the round trip") + eq(restored.riftReturn.x, game.riftReturn.x, "the remembered return position survives the round trip") + eq(restored.riftReturn.y, game.riftReturn.y, "the remembered return position survives the round trip") + eq(restored.riftReturn.depth, game.riftReturn.depth, "the remembered return depth survives the round trip") + restored.player.x, restored.player.y = restored.level.downX, restored.level.downY + ok(restored:exitRift(), "exiting still works correctly after a save/load round trip") + eq(restored.rift, false, "the rift flag clears after exiting post-load") +end + +function tests.gameSaveLoadRoundTripsAnActiveHordeArena() + local game = Game.new(11) + game:startHorde() + -- Clear wave one so the round trip has to prove more than just "wave one + -- survives" - the wave counter must actually be the one Horde.tick + -- advanced to, not always reset back to its Horde.newState() default. + for _, entity in ipairs(game.level.entities) do entity.hp = 0 end + game:cleanupDead() + game:endPlayerTurn() + eq(game.horde.wave, 2, "the staged run has advanced to wave two before saving") + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + ok(restored.horde, "an active horde run survives a save/load round trip at all") + eq(restored.horde.wave, 2, "the exact wave in progress survives the round trip") + eq(restored.level.generator, "horde", "the restored level is still tagged as the arena") + eq(table.concat(restored.level.map.tiles), table.concat(game.level.map.tiles), + "the arena's own map survives the round trip rather than falling back to the abandoned floor") + eq(#restored.level.entities, #game.level.entities, "wave two's monster count survives the round trip") + eq(restored.player.x, game.player.x, "the player's arena position survives the round trip") + eq(restored.player.y, game.player.y, "the player's arena position survives the round trip") + for _, entity in ipairs(restored.level.entities) do entity.hp = 0 end + restored:cleanupDead() + restored:endPlayerTurn() + eq(restored.horde.wave, 3, "the restored run's Horde.tick still advances waves normally after loading") +end + +function tests.gameSaveLoadOfAnOrdinaryRunNeverGainsAHordeField() + local game = Game.new(3) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.horde, nil, "a run that never entered the arena has no horde field after a round trip") + eq(restored.level.generator, game.level.generator, + "an ordinary floor's own generator survives the round trip unaffected by the horde snapshot fields") +end + +-- commands.lua: the typed-command parser/dispatcher layered on top of the +-- same granular Game: methods choosePlayerAction already drives, and +-- Game:manualStep, its integration into the turn cycle (see game.lua's +-- Game:beginTurn/autoStep for the shared pre-turn head both share). + +function tests.commandsParseSplitsAVerbAndArgsLowercasedAndTrimmed() + local parsed = Commands.parse(" ThRoW 3 ") + eq(parsed.verb, "throw", "the verb is lowercased") + eq(parsed.args[1], "3", "the remaining token becomes an arg") + eq(Commands.parse(""), nil, "an empty line parses to nil") + eq(Commands.parse(" "), nil, "an all-whitespace line parses to nil") +end + +function tests.commandsApplyRejectsAnUnknownVerbWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "dance") + eq(consumesTurn, false, "an unrecognized verb spends no turn") + ok(contains(message, "unknown command"), "the message names the bad verb") +end + +function tests.commandsApplyMovingIntoAWallFailsWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 2, 2) + local consumesTurn, message = Commands.apply(game, "north") + eq(consumesTurn, false, "walking into the arena's own boundary wall spends no turn") + eq(message, "you cannot go that way", "the failure explains itself") + eq(game.player.x, 2, "the player never actually moves on a rejected step") + eq(game.player.y, 2, "the player never actually moves on a rejected step") +end + +function tests.commandsApplyMovementBumpsIntoAnAdjacentHostileAsAnAttack() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + rat.hp = 20 + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "east") + eq(consumesTurn, true, "bumping into a hostile spends the turn") + eq(game.player.x, 5, "the player does not step onto the monster's own tile") + ok(rat.hp < 20, "the bump attack actually lands through Game:movePlayer") +end + +function tests.commandsApplyAttackAliasesMovementIntoAnExplicitDirection() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + rat.hp = 20 + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "attack east") + eq(consumesTurn, true, "attack spends the turn") + ok(rat.hp < 20, "attack lands the same bump attack a plain direction would") + local rejected, message = Commands.apply(game, "attack sideways") + eq(rejected, false, "attack with an unrecognized direction word spends no turn") + eq(message, "attack which direction?", "the failure names what is missing") +end + +function tests.commandsApplyWaitAlwaysSucceedsAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "wait") + eq(consumesTurn, true, "waiting always spends the turn") + eq(message, "you wait", "waiting reports itself") +end + +function tests.commandsApplyPickupSweepsFloorItemsAtThePlayersFeetAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.floorItems = { { x = 5, y = 5, item = Items.gold(15) } } + local goldBefore = game.player.gold + local consumesTurn = Commands.apply(game, "pickup") + eq(consumesTurn, true, "pickup always spends the turn") + eq(game.player.gold, goldBefore + 15, "gold sitting on the player's own tile is swept up") + eq(#game.level.floorItems, 0, "the picked-up item leaves the floor") +end + +function tests.commandsApplyUseDrinksAPotionByInventoryIndexAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local before = #game.player.inventory + local consumesTurn = Commands.apply(game, "use 1") + eq(consumesTurn, true, "using a valid inventory item spends the turn") + eq(#game.player.inventory, before - 1, "the consumed potion leaves the inventory") +end + +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From ef064dfe9d988733431316bd9ff51596b68e7cbb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 22:06:40 +0200 Subject: [PATCH 157/172] test: cover tests.commandsApplyItemCommandsRejectAMissingOrNonNumeric... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 56a432c..05f29ed 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -12931,6 +12931,179 @@ function tests.commandsApplyUseDrinksAPotionByInventoryIndexAndSpendsTheTurn() eq(#game.player.inventory, before - 1, "the consumed potion leaves the inventory") end +function tests.commandsApplyItemCommandsRejectAMissingOrNonNumericIndexWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + for _, verb in ipairs({ "use", "drop", "sell", "throw", "capture" }) do + local consumesTurn, message = Commands.apply(game, verb) + eq(consumesTurn, false, verb .. " with no inventory number spends no turn") + ok(message ~= nil and #message > 0, verb .. " explains what index it needs") + end + local consumesTurn, message = Commands.apply(game, "use ninety") + eq(consumesTurn, false, "a non-numeric inventory argument spends no turn") + ok(message ~= nil, "the non-numeric argument still gets a clear reason") +end + +function tests.commandsApplyCastAutoTargetsTheNearestVisibleHostile() + local game = stagedGame(1, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 5, 8, 5, 1) + local far = Monsters.create(Monsters.byKey.rat, 9, 15, 5, 1) + near.hp, far.hp = 30, 30 + game.level.entities = { near, far } + local consumesTurn = Commands.apply(game, "cast missile") + eq(consumesTurn, true, "casting a known targeted spell at a visible hostile spends the turn") + ok(near.hp < 30, "the nearer visible hostile takes the hit") + eq(far.hp, 30, "the farther hostile is left untouched") +end + +function tests.commandsApplyCastBreaksATiedDistanceByAscendingMonsterId() + local game = stagedGame(1, arena(21, 11), 5, 5) + local higherId = Monsters.create(Monsters.byKey.rat, 9, 8, 5, 1) + local lowerId = Monsters.create(Monsters.byKey.rat, 3, 5, 8, 1) + higherId.hp, lowerId.hp = 30, 30 + game.level.entities = { higherId, lowerId } + Commands.apply(game, "cast missile") + ok(lowerId.hp < 30, "a tied Chebyshev distance is broken toward the lower monster id") + eq(higherId.hp, 30, "the higher-id tied hostile is left untouched") +end + +function tests.commandsApplyCastFailsWithoutSpendingATurnWhenNoHostileIsVisible() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "cast missile") + eq(consumesTurn, false, "a targeted spell with nothing to target spends no turn") + eq(message, "there is nothing in sight to cast that at", "the failure explains itself") +end + +function tests.commandsApplyCastRejectsAnUnknownOrUnlearnedSpellWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "cast nonsense") + eq(consumesTurn, false, "an unknown spell key spends no turn") + eq(message, "you know no spell by that name", "the failure names the problem") +end + +function tests.commandsApplyCastConfuseAppliesTheStatusThroughTheFullCommandPipeline() + local game = stagedGame(1, arena(21, 11), 5, 5) + Spells.learn(game.player, "confuse") + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "cast confuse") + eq(consumesTurn, true, "casting confuse on a visible hostile spends the turn") + eq(Status.has(rat, Status.CONFUSE), true, "the nearest visible hostile carries the confuse status afterward") +end + +function tests.commandsApplyCastBlindAppliesTheStatusThroughTheFullCommandPipeline() + local game = stagedGame(1, arena(21, 11), 5, 5) + Spells.learn(game.player, "blind") + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "cast blind") + eq(consumesTurn, true, "casting blind on a visible hostile spends the turn") + eq(Status.has(rat, Status.BLIND), true, "the nearest visible hostile carries the blind status afterward") +end + +function tests.commandsApplyCastWeakenAppliesTheStatusThroughTheFullCommandPipeline() + local game = stagedGame(1, arena(21, 11), 5, 5) + Spells.learn(game.player, "weaken") + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "cast weaken") + eq(consumesTurn, true, "casting weaken on a visible hostile spends the turn") + eq(Status.has(rat, Status.WEAKEN), true, "the nearest visible hostile carries the weaken status afterward") +end + +-- levitate is self-targeted (Spells.catalog.levitate.targeted == false), so +-- unlike cast confuse/blind above there is no hostile to resolve at all - +-- this is the pipeline's only self-buff cast covered end to end. +function tests.commandsApplyCastLevitateAppliesTheStatusToTheCasterThroughTheFullCommandPipeline() + local game = stagedGame(1, arena(21, 11), 5, 5) + Spells.learn(game.player, "levitate") + local consumesTurn = Commands.apply(game, "cast levitate") + eq(consumesTurn, true, "casting levitate spends the turn") + eq(Status.has(game.player, Status.LEVITATE), true, "the caster carries the levitate status afterward") +end + +-- cleanse is self-targeted like levitate, so no hostile needs to be in sight +-- at all - the pipeline still finds and lifts the caster's own status. +function tests.commandsApplyCastCleanseLiftsTheCastersOwnStatusThroughTheFullCommandPipeline() + local game = stagedGame(1, arena(21, 11), 5, 5) + Spells.learn(game.player, "cleanse") + Status.apply(game.player, Status.POISON, 5, 2) + local consumesTurn = Commands.apply(game, "cast cleanse") + eq(consumesTurn, true, "casting cleanse spends the turn") + eq(Status.has(game.player, Status.POISON), false, "the caster's own poison is gone afterward") +end + +function tests.commandsApplyThrowAndFireFailWithoutSpendingATurnWhenNoHostileIsVisible() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn1, message1 = Commands.apply(game, "throw 1") + eq(consumesTurn1, false, "throwing with nothing in sight spends no turn") + eq(message1, "there is nothing in sight to throw at", "the failure explains itself") + local consumesTurn2, message2 = Commands.apply(game, "fire") + eq(consumesTurn2, false, "firing with nothing in sight spends no turn") + eq(message2, "there is nothing in sight to fire at", "the failure explains itself") +end + +function tests.commandsApplyUseskillRejectsAnUnknownOrUnlearnedSkillWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local unknown = Commands.apply(game, "useskill nonsense") + eq(unknown, false, "an unknown skill key spends no turn") + local unlearned = Commands.apply(game, "useskill cleave") + eq(unlearned, false, "a skill the player never learned spends no turn") +end + +function tests.commandsApplyUseskillCleaveAutoTargetsAnAdjacentHostile() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + Skills.learn(game.player, "cleave") + local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + rat.hp = 20 + game.level.entities = { rat } + local consumesTurn = Commands.apply(game, "useskill cleave") + eq(consumesTurn, true, "cleave against an adjacent hostile spends the turn") + ok(rat.hp < 20, "cleave's own attack lands on the auto-targeted adjacent hostile") +end + +-- Every targeted handler (throw/capture/fire/cast/useskill) accepts an +-- optional trailing target number, resolved against the exact same 1-based +-- listing Handlers.look/examine already print. These tests all set up a +-- nearer hostile the OLD nearest-only auto-target would have picked, plus a +-- farther one at a later position in game.level.entities (the same order +-- look numbers its list in), and confirm the explicit number reaches past +-- the nearer default to the one actually named. +function tests.commandsApplyThrowWithAnExplicitTargetNumberHitsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 20, 20 + game.level.entities = { near, far } + game.player.inventory[#game.player.inventory + 1] = Items.weapon("dagger", 2) + local index = #game.player.inventory + local consumesTurn = Commands.apply(game, "throw " .. index .. " 2") + eq(consumesTurn, true, "throwing at an explicit in-sight target number spends the turn") + ok(far.hp < 20, "the explicitly named second hostile takes the hit") + eq(near.hp, 20, "the nearer, non-targeted hostile is left untouched") +end + +function tests.commandsApplyThrowRejectsAnExplicitTargetNumberThatIsNotInSightWithoutSpendingATurn() + local game = stagedGame(4, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + game.level.entities = { rat } + game.player.inventory[#game.player.inventory + 1] = Items.weapon("dagger", 2) + local index = #game.player.inventory + local before = #game.player.inventory + local consumesTurn, message = Commands.apply(game, "throw " .. index .. " 5") + eq(consumesTurn, false, "naming a target number nothing is visible at spends no turn") + eq(message, "no monster in sight at that number", "the failure names the specific problem") + eq(#game.player.inventory, before, "the item is never spent on a rejected target number") +end + +function tests.commandsApplyFireWithAnExplicitTargetNumberShootsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 20, 20 + game.level.entities = { near, far } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) end local order = { From 64d37536f19747cd2bef501f0ddddd286ba3d27f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 22:12:21 +0200 Subject: [PATCH 158/172] test: cover tests.commandsApplyCastWithAnExplicitTargetNumberHitsThat... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 05f29ed..295de40 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13104,6 +13104,179 @@ function tests.commandsApplyFireWithAnExplicitTargetNumberShootsThatHostileInste game.level.entities = { near, far } game.player.bow = Items.bow("short bow", 2, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local consumesTurn = Commands.apply(game, "fire 2") + eq(consumesTurn, true, "firing at an explicit in-sight target number spends the turn") + ok(far.hp < 20, "the explicitly named second hostile takes the shot") + eq(near.hp, 20, "the nearer, non-targeted hostile is left untouched") +end + +function tests.commandsApplyCastWithAnExplicitTargetNumberHitsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 20, 20 + game.level.entities = { near, far } + local consumesTurn = Commands.apply(game, "cast missile 2") + eq(consumesTurn, true, "casting at an explicit in-sight target number spends the turn") + ok(far.hp < 20, "the explicitly named second hostile takes the hit") + eq(near.hp, 20, "the nearer, non-targeted hostile is left untouched") +end + +function tests.commandsApplyCaptureWithAnExplicitTargetNumberTargetsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 1, 1 + game.level.entities = { near, far } + game.player.inventory[#game.player.inventory + 1] = Items.net() + local index = #game.player.inventory + game.rng = Rng.new(2) + local consumesTurn = Commands.apply(game, "capture " .. index .. " 2") + eq(consumesTurn, true, "capturing at an explicit in-sight target number spends the turn") + eq(#game.level.captives, 1, "exactly one captive results from the attempt") + eq(game.level.captives[1].x, far.x, "the explicitly named second hostile is the one caught") + eq(game.level.captives[1].y, far.y, "the explicitly named second hostile is the one caught") + ok(near:isAlive(), "the nearer, non-targeted hostile is left alive and free") +end + +-- Cleave hits its named target through the full attack path and every other +-- monster standing next to the player for a halved splash (see skills.lua's +-- Skills.useCleave) - so with two rats both adjacent to the player, an +-- explicit target number cannot be proven by "the other one is untouched" +-- (the splash still reaches it), only by which one takes the bigger, full +-- hit. Combat.damage is a plain, RNG-free formula (attack minus defense, +-- floored, minimum 1 - see combat.lua), so two identical rats make that +-- comparison deterministic. +function tests.commandsApplyUseskillCleaveWithAnExplicitTargetNumberHitsThatAdjacentHostileInsteadOfTheDefaultNearestOne() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + Skills.learn(game.player, "cleave") + local ratA = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + local ratB = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + ratA.hp, ratB.hp = 20, 20 + game.level.entities = { ratA, ratB } + local consumesTurn = Commands.apply(game, "useskill cleave 2") + eq(consumesTurn, true, "cleave against an explicit in-sight target number spends the turn") + local dealtToB, dealtToA = 20 - ratB.hp, 20 - ratA.hp + ok(dealtToB > dealtToA, "the explicitly named second hostile takes cleave's full hit, not just its splash") +end + +function tests.commandsApplyUseskillMultishotWithExplicitTargetNumbersHitsExactlyThoseTwoHostiles() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "ranger" + Skills.learn(game.player, "multishot") + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local first = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local middle = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + local third = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + first.hp, middle.hp, third.hp = 20, 20, 20 + game.level.entities = { first, middle, third } + local consumesTurn = Commands.apply(game, "useskill multishot 1 3") + eq(consumesTurn, true, "multishot against two explicit in-sight target numbers spends the turn") + ok(first.hp < 20, "the first explicitly named hostile takes a shot") + ok(third.hp < 20, "the second explicitly named hostile takes a shot") + eq(middle.hp, 20, "the un-named, nearer-by-distance hostile in between is left untouched") +end + +function tests.commandsApplyUseskillMultishotWithOnlyOneExplicitTargetNumberFallsBackToAutoTargetingBoth() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "ranger" + Skills.learn(game.player, "multishot") + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + near.hp = 20 + game.level.entities = { near } + local consumesTurn = Commands.apply(game, "useskill multishot 1") + eq(consumesTurn, true, "an ambiguous single explicit target still falls back to auto-targeting") + ok(near.hp < 20, "the only visible hostile still takes a shot via the auto-target fallback") +end + +function tests.commandsApplyContextActionsFailCleanlyWithNothingHereWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + for _, verb in ipairs({ + "open", "pray", "quest", "butcher", "raise", "scavenge", "harvest", + "ransom", "sellcaptive", "interrogate", "descend", "ascend", "enterrift", "exitrift", + }) do + local consumesTurn, message = Commands.apply(game, verb) + eq(consumesTurn, false, verb .. " with nothing here spends no turn") + ok(message ~= nil and #message > 0, verb .. " gives a reason") + end +end + +function tests.commandsApplyDigRejectsAnUnknownDirectionAndAnUnreadiedPickWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local badDirection, message1 = Commands.apply(game, "dig sideways") + eq(badDirection, false, "an unrecognized direction word spends no turn") + eq(message1, "dig which direction?", "the failure names what is missing") + local noPick, message2 = Commands.apply(game, "dig north") + eq(noPick, false, "digging without a readied pick spends no turn") + ok(message2 ~= nil, "the failure explains itself") +end + +function tests.commandsApplyDisarmRejectsAMissingDirectionOrAnUndetectedTrapWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local badDirection, message1 = Commands.apply(game, "disarm sideways") + eq(badDirection, false, "an unrecognized direction word spends no turn") + eq(message1, "disarm which direction?", "the failure names what is missing") + + local undetected = { x = 6, y = 5, effect = "dart", revealed = false } + game.level.traps = { undetected } + local noTarget, message2 = Commands.apply(game, "disarm east") + eq(noTarget, false, "a trap never yet noticed spends no turn") + eq(message2, "there is nothing here you have noticed to disarm", "the failure explains itself") + eq(#game.level.traps, 1, "the untouched trap is still there") + + local sprung = { x = 4, y = 5, effect = "dart", revealed = true, detected = true } + game.level.traps = { sprung } + local alreadySprung = Commands.apply(game, "disarm west") + eq(alreadySprung, false, "an already-sprung trap spends no turn either") +end + +function tests.commandsApplyDisarmAlwaysSpendsTheTurnAgainstADetectedTrapWhetherItSucceedsOrSprings() + local sawSuccess, sawFailure = false, false + for seed = 1, 300 do + if sawSuccess and sawFailure then break end + local game = stagedGame(seed, arena(21, 11), 3, 6) + local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } + game.level.traps = { trap } + local consumesTurn = Commands.apply(game, "disarm east") + eq(consumesTurn, true, "a disarm attempt against a real detected trap always spends the turn") + if #game.level.traps == 0 then + sawSuccess = true + else + eq(trap.revealed, true, "a failed disarm still springs and reveals the trap") + sawFailure = true + end + end + ok(sawSuccess, "disarming through the command eventually succeeds across a handful of seeds") + ok(sawFailure, "disarming through the command eventually springs across a handful of seeds") +end + +function tests.commandsApplyAppeaseBuysFactionStandingWithGoldAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.gold = 100 + local repBefore = Factions.reputationFor(game.player, Factions.MERCHANT) + local costBefore = Factions.appeaseCost(game.player, Factions.MERCHANT) + local consumesTurn = Commands.apply(game, "appease merchant") + eq(consumesTurn, true, "affording tribute to a real faction spends the turn") + eq(game.player.gold, 100 - costBefore, "the tribute's cost is charged in gold") + ok(Factions.reputationFor(game.player, Factions.MERCHANT) > repBefore, "standing actually improves") +end + +function tests.commandsApplyAppeaseRejectsAMissingOrUnknownFactionWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local missing, message1 = Commands.apply(game, "appease") + eq(missing, false, "appease with no faction name spends no turn") + ok(contains(message1, "merchant"), "the failure lists the valid faction names") + + local unknown, message2 = Commands.apply(game, "appease atlanteans") + eq(unknown, false, "an unrecognized faction name spends no turn") + ok(contains(message2, "merchant"), "the failure lists the valid faction names") +end + +function tests.commandsApplyAppeaseFailsWithoutSpendingATurnWhenGoldIsShort() end local order = { From 9fc53e4ef47ead2c64eaac5b0b74e20d81947620 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 22:19:45 +0200 Subject: [PATCH 159/172] test: cover tests.commandsApplyHordeEntersTheArenaAtWaveOneAndSpendsT... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 295de40..f9938ef 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13277,6 +13277,179 @@ function tests.commandsApplyAppeaseRejectsAMissingOrUnknownFactionWithoutSpendin end function tests.commandsApplyAppeaseFailsWithoutSpendingATurnWhenGoldIsShort() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.gold = 0 + local repBefore = Factions.reputationFor(game.player, Factions.MERCHANT) + local consumesTurn = Commands.apply(game, "appease merchant") + eq(consumesTurn, false, "an unaffordable tribute spends no turn") + eq(game.player.gold, 0, "no gold is spent on a failed tribute") + eq(Factions.reputationFor(game.player, Factions.MERCHANT), repBefore, "standing is untouched") +end + +function tests.commandsApplyHordeEntersTheArenaAtWaveOneAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn = Commands.apply(game, "horde") + eq(consumesTurn, true, "entering the horde arena spends the turn") + eq(game.horde.wave, 1, "the arena begins at wave one") + eq(game.level.generator, "horde", "the current level is swapped for the arena") +end + +function tests.commandsApplyHordeRefusesToRestartAnArenaAlreadyInProgressWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Commands.apply(game, "horde") + for _, entity in ipairs(game.level.entities) do entity.hp = 0 end + game:cleanupDead() + game:endPlayerTurn() + local waveBefore = game.horde.wave + local consumesTurn, message = Commands.apply(game, "horde") + eq(consumesTurn, false, "re-entering an arena already in progress spends no turn") + eq(game.horde.wave, waveBefore, "wave progress already made is left untouched") + ok(message ~= nil, "the refusal explains itself") +end + +function tests.commandsApplyInformationalVerbsNeverSpendATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local turnBefore = game.turn + for _, verb in ipairs({ "inventory", "status", "look", "help" }) do + local consumesTurn, message = Commands.apply(game, verb) + eq(consumesTurn, false, verb .. " never spends a turn") + ok(message ~= nil and #message > 0, verb .. " always answers with something") + end + eq(game.turn, turnBefore, "none of the informational commands advanced the turn counter") +end + +function tests.commandsApplyLookListsOnlyVisibleHostilesByName() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + game.level.entities = { rat } + local consumesTurn, message = Commands.apply(game, "look") + eq(consumesTurn, false, "look never spends a turn") + ok(contains(message, rat.name), "look names a visible hostile") +end + +-- examine.lua: detailed, read-only inspection of a single inventory item or +-- a visible monster. Every function is pure (identify state and the item/ +-- monster passed in are never mutated), so these are exercised directly +-- rather than only through Commands.apply. + +function tests.examineItemDescribesAPlainWeaponsPowerAndDurability() + local game = stagedGame(1, arena(21, 11), 5, 5) + local weapon = Items.weapon("dagger", 3) + local description = Examine.item(game.identify, weapon) + ok(contains(description, "dagger"), "the report names the item") + ok(contains(description, "power 3"), "the report states the weapon's power") + ok(contains(description, "durability 30/30"), "a pristine weapon reports full durability") + ok(contains(description, "worth " .. Shop.priceOf(weapon) .. " gold"), "the report prices the item") +end + +function tests.examineItemFlagsABrokenWeaponAndAFullyTemperedBonus() + local game = stagedGame(1, arena(21, 11), 5, 5) + local weapon = Items.weapon("sword", 4) + weapon.durability = 0 + weapon.temper = 2 + local description = Examine.item(game.identify, weapon) + ok(contains(description, "(broken)"), "a broken weapon is flagged as broken") + ok(contains(description, "tempered +2"), "the report states the temper level") +end + +function tests.examineItemDescribesARingsFlatAttackAndDefenseBonus() + local game = stagedGame(1, arena(21, 11), 5, 5) + local ring = Items.ring("ring of the tide", 1, 2) + ok(contains(Examine.item(game.identify, ring), "attack +1, defense +2"), "the report states the ring's flat bonuses") +end + +function tests.examineItemDescribesAnAffixSocketedGemRunewordAndRevealedCurse() + local game = stagedGame(1, arena(21, 11), 5, 5) + local weapon = Items.weapon("sword", 4) + Affixes.decorate(weapon, Affixes.weapon[3]) + Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FROST, 1)) + Runewords.socket(weapon, Runewords.rune("sol")) + Runewords.socket(weapon, Runewords.rune("ith")) + Curses.afflict(weapon, Curses.pool.weapon[1]) + Curses.reveal(weapon) + local description = Examine.item(game.identify, weapon) + ok(contains(description, "affix: flaming"), "the report names the rolled affix") + ok(contains(description, "on hit: burning, 3 turns, potency 2"), "the report states the affix's on-hit rider") + ok(contains(description, "socketed: tier 1 frost gem"), "the report states the socketed gem") + ok(contains(description, "runeword Zenith: attack +3"), "the report names the completed runeword and its bonus") + ok(contains(description, "cursed (weakness)"), "a revealed curse is called out by name") +end + +function tests.examineItemNamesAnArtifactsUniquePowerAndASetPiecesSetName() + local game = stagedGame(1, arena(21, 11), 5, 5) + local artifact = Artifacts.create("bloodfang") + ok(contains(Examine.item(game.identify, artifact), "artifact power: lifesteal"), "the report names the artifact's unique power") + local setPiece = Sets.create("ironclad", "weapon") + ok(contains(Examine.item(game.identify, setPiece), "set piece: Ironclad"), "the report names the set the piece belongs to") +end + +function tests.examineItemListsTheElementalResistanceAWornArtifactGrants() + local game = stagedGame(1, arena(21, 11), 5, 5) + local cinderplate = Artifacts.create("cinderplate") + ok(contains(Examine.item(game.identify, cinderplate), "grants: fire resist"), "the report states the resistance the artifact grants") +end + +function tests.examineItemShowsAPotionsDisguisedLabelWithoutLeakingItsIdentity() + local game = stagedGame(1, arena(21, 11), 5, 5) + local potion = Items.potionOf("poison") + local description = Examine.item(game.identify, potion) + ok(not contains(description, "potion of poison"), "an unidentified potion's true name stays hidden") + ok(contains(description, "worth 6 gold"), "the report still prices the item off its real, hidden effect") +end + +function tests.examineMonsterReportsHpAttackDefenseAndDefaultsToHuntingAwareness() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + local description = Examine.monster(game.player, rat) + ok(contains(description, rat.name), "the report names the monster") + ok(contains(description, string.format("hp %d/%d", rat.hp, rat.maxHp)), "the report states current and max hp") + ok(contains(description, "hunting you"), "a hand-built monster with no awareness field defaults to hunting") +end + +function tests.examineMonsterReportsDormantAwarenessForAnUnwokenWildMonster() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + rat.awareness = Stealth.DORMANT + ok(contains(Examine.monster(game.player, rat), "asleep"), "a dormant monster reports as asleep") +end + +function tests.examineMonsterReportsItsFactionAndTheCurrentReputationTier() + local game = stagedGame(1, arena(21, 11), 5, 5) + local kobold = Monsters.create(Monsters.byKey.kobold, 1, 8, 5, 1) + Factions.adjust(game.player, Factions.GOBLINOID, 30) + ok(contains(Examine.monster(game.player, kobold), "faction: goblinoid (friendly)"), + "the report states the faction and its current reputation tier") +end + +function tests.examineMonsterListsAnElitesTierAndEachAffixItRolled() + local game = stagedGame(1, arena(21, 11), 5, 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 8, 5, 1) + orc.elite, orc.eliteTier, orc.eliteAffixes = true, "champion", { "brutal", "swift" } + ok(contains(Examine.monster(game.player, orc), "champion: brutal, swift"), + "the report names the elite tier and each affix it rolled") +end + +function tests.examineMonsterFlagsAnEnragedAndDesperateBossTogether() + local game = stagedGame(1, arena(21, 11), 5, 5) + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 8, 5) + warlord.enraged, warlord.desperate = true, true + ok(contains(Examine.monster(game.player, warlord), "boss, enraged, desperate"), + "the report flags both boss phase tags together") +end + +function tests.examineMonsterReportsANonNormalResistanceFromItsBaseProfile() + local game = stagedGame(1, arena(21, 11), 5, 5) + local troll = Monsters.create(Monsters.byKey.troll, 1, 8, 5, 1) + ok(contains(Examine.monster(game.player, troll), "resistances: fire weak"), + "the report states the troll's innate fire weakness") +end + +function tests.examineMonsterListsActiveStatusEffectsWithRemainingDuration() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + Status.apply(rat, Status.POISON, 4, 2) + ok(contains(Examine.monster(game.player, rat), "afflicted: poison (4)"), + "the report lists the active status and its remaining duration") end local order = { From 69dd449825d3caa113e549a272cf841b813b690f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 13:50:00 +0200 Subject: [PATCH 160/172] test: cover tests.examineMonsterReportsADiseaseCaughtThroughInfighting --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index f9938ef..24692ec 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13452,6 +13452,179 @@ function tests.examineMonsterListsActiveStatusEffectsWithRemainingDuration() "the report lists the active status and its remaining duration") end +-- Only reachable through infighting.lua's own bite chance (a rival monster +-- or the player's own companion/hireling/thrall can now catch a disease +-- from a carrier's bite, see infightingStrikeCanTransmitTheAttackersBite +-- DiseaseToAnyDefenderNotJustThePlayer above) - shown the same way the +-- afflicted-statuses line just above already surfaces a fact invisible on +-- the bare map glyph. +function tests.examineMonsterReportsADiseaseCaughtThroughInfighting() + local game = stagedGame(1, arena(21, 11), 5, 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 8, 5, 1) + ok(not contains(Examine.monster(game.player, orc), "diseased"), "a healthy monster's report mentions no disease") + Disease.infect(orc, Disease.PLAGUE) + ok(contains(Examine.monster(game.player, orc), "diseased: black plague (incubating)"), + "an incubating infection shows up in a monster's examine report") + orc.disease.stage = 1 + ok(contains(Examine.monster(game.player, orc), "diseased: black plague (stage 1)"), + "an erupted infection reports its current stage in a monster's examine report") +end + +-- commands.lua: `examine `/`examine monster `, the interface half of +-- examine.lua a typed session reaches through. + +function tests.commandsApplyExamineDescribesAnInventoryItemWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.weapon("dagger", 3) } + local turnBefore = game.turn + local consumesTurn, message = Commands.apply(game, "examine 1") + eq(consumesTurn, false, "examine never spends a turn") + eq(game.turn, turnBefore, "the turn counter stays put") + ok(contains(message, "dagger") and contains(message, "power 3"), "the report describes the inventory item") + local aliased = Commands.apply(game, "x 1") + eq(aliased, false, "the x alias behaves identically and never spends a turn") +end + +function tests.commandsApplyExamineMonsterDescribesTheSameEntryLookJustNumbered() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 5, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 9, 6, 1) + game.level.entities = { rat, orc } + local _, lookMessage = Commands.apply(game, "look") + ok(contains(lookMessage, "1) " .. rat.name), "look numbers the rat first") + ok(contains(lookMessage, "2) " .. orc.name), "look numbers the orc second") + local consumesTurn, examineMessage = Commands.apply(game, "examine monster 2") + eq(consumesTurn, false, "examining a monster never spends a turn") + ok(contains(examineMessage, orc.name), "examine monster 2 describes the same monster look numbered 2") +end + +function tests.commandsApplyExamineFailsCleanlyOnBadOrMissingArgumentsWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Commands.apply(game, "examine"), false, "examine with no argument spends no turn") + eq(Commands.apply(game, "examine 1"), false, "examining an empty inventory slot spends no turn") + eq(Commands.apply(game, "examine monster"), false, "examine monster with no index spends no turn") + eq(Commands.apply(game, "examine monster 1"), false, "examining with nothing in sight spends no turn") +end + +function tests.commandsApplyHelpMentionsExamine() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "examine"), "help mentions examine") +end + +-- commands.lua: crafting/economy verbs (forge/merge/socket/socketrune/ +-- temper/enchant/disenchant/repair/steal/dismiss/learn/brew), the interface +-- half of the API a typed session could not reach before this session. + +function tests.commandsApplyForgeAndMergeDriveTheGemPipelineAndSpendTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.essence = Crafting.FORGE_COST * 2 + ok(Commands.apply(game, "forge fire"), "forging a known element spends the turn") + ok(Commands.apply(game, "forge fire"), "forging a second matching gem spends the turn") + local a, b + for i, item in ipairs(game.player.inventory) do + if item.kind == Items.GEM then + if not a then a = i else b = i end + end + end + local consumesTurn = Commands.apply(game, "merge " .. a .. " " .. b) + eq(consumesTurn, true, "merging two matching gems spends the turn") +end + +function tests.commandsApplyForgeRejectsAMissingOrUnknownElementWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local missing, message1 = Commands.apply(game, "forge") + eq(missing, false, "forge with no element spends no turn") + ok(message1 ~= nil, "the failure names what is missing") + game.player.essence = Crafting.FORGE_COST + local unknown = Commands.apply(game, "forge lava") + eq(unknown, false, "an unrecognized element spends no turn") +end + +function tests.commandsApplyMergeRejectsAMissingOrMismatchedPairWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local missing, message = Commands.apply(game, "merge") + eq(missing, false, "merge with no indices spends no turn") + ok(message ~= nil, "the failure names what is missing") + game.player.inventory = { Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FROST, 1) } + local mismatched = Commands.apply(game, "merge 1 2") + eq(mismatched, false, "mismatched gems spend no turn") + eq(#game.player.inventory, 2, "a rejected merge leaves both gems untouched") +end + +function tests.commandsApplySocketTemperEnchantDisenchantDriveTheEquippedWeaponAndSpendTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + game.player.inventory = { Crafting.gem(Crafting.FIRE, 1) } + + local socketed = Commands.apply(game, "socket weapon 1") + eq(socketed, true, "socketing a gem into the equipped weapon spends the turn") + eq(weapon.socket.element, Crafting.FIRE, "the socketed gem's element lands on the weapon") + + game.player.essence = Crafting.temperCost(weapon) + local tempered = Commands.apply(game, "temper weapon") + eq(tempered, true, "tempering the equipped weapon spends the turn") + + game.player.essence = Crafting.ENCHANT_COST + local enchanted = Commands.apply(game, "enchant weapon vicious") + eq(enchanted, true, "enchanting the equipped weapon spends the turn") + eq(weapon.affix.key, "vicious", "the chosen affix lands on the weapon") + + local disenchanted = Commands.apply(game, "disenchant weapon") + eq(disenchanted, true, "disenchanting the equipped weapon spends the turn") + eq(weapon.affix, nil, "the affix is gone once disenchanted") +end + +function tests.commandsApplySocketRuneCompletesARunewordAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.inventory = { Runewords.rune("sol"), Runewords.rune("ith") } + ok(Commands.apply(game, "socketrune weapon 1"), "socketing the first rune spends the turn") + local consumesTurn, message = Commands.apply(game, "socketrune weapon 1") + eq(consumesTurn, true, "completing the runeword spends the turn") + ok(contains(game.log:last(), "Zenith"), "completing a runeword is announced by name") +end + +function tests.commandsApplyCraftingVerbsFailCleanlyWithBadTargetsWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + for _, verb in ipairs({ "socket", "socketrune", "temper", "enchant", "disenchant" }) do + local consumesTurn, message = Commands.apply(game, verb) + eq(consumesTurn, false, verb .. " with no target spends no turn") + ok(message ~= nil and #message > 0, verb .. " explains what it needs") + end + Items.equipWeapon(Items.weapon("dagger", 2), game.player) + local badSocket = Commands.apply(game, "socket weapon 1") + eq(badSocket, false, "socketing with an empty inventory slot spends no turn") +end + +function tests.commandsApplyRepairFixesTheEquippedWeaponAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + game.player.gold = 1000 + local consumesTurn = Commands.apply(game, "repair weapon") + eq(consumesTurn, true, "repairing an affordable broken weapon spends the turn") + eq(game.player.weapon.durability, Durability.MAX, "the weapon comes back to full durability") +end + +function tests.commandsApplyRepairRejectsAnUnknownSlotOrAnEmptyPurseWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local badSlot, message = Commands.apply(game, "repair shield") + eq(badSlot, false, "an unrecognized slot spends no turn") + eq(message, "repair what? weapon, armor or bow", "the failure names the valid slots") + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.weapon.durability = 0 + game.player.gold = 0 + local shortPurse = Commands.apply(game, "repair weapon") + eq(shortPurse, false, "an empty purse cannot repair, and spends no turn") +end + +function tests.commandsApplyStealTakesTheWardedWareUnderfootAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) +end + local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", From 808016f9258d66d23a42322415314faca837ac3a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 13:53:13 +0200 Subject: [PATCH 161/172] test: cover tests.commandsApplyStealFailsWithNothingWardedUnderfootWi... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 24692ec..fc3d5f2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13623,6 +13623,179 @@ end function tests.commandsApplyStealTakesTheWardedWareUnderfootAndSpendsTheTurn() local game = stagedGame(1, arena(21, 11), 5, 5) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.level.floorItems = { { x = 5, y = 5, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } + local invBefore = #game.player.inventory + local consumesTurn = Commands.apply(game, "steal") + eq(consumesTurn, true, "stealing a warded ware underfoot spends the turn") + eq(#game.player.inventory, invBefore + 1, "the stolen item joins the inventory") + eq(keeper.hostile, true, "the keeper turns hostile after the theft") +end + +function tests.commandsApplyStealFailsWithNothingWardedUnderfootWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "steal") + eq(consumesTurn, false, "stealing with nothing underfoot spends no turn") + eq(message, "there is nothing here worth stealing", "the failure explains itself") +end + +function tests.commandsApplyDismissReleasesTheHirelingAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Mercenaries.hire(game, "sellsword") + ok(game:hasHireling(), "the hire itself succeeded") + local consumesTurn = Commands.apply(game, "dismiss") + eq(consumesTurn, true, "dismissing an under-contract hireling spends the turn") + eq(game:hasHireling(), false, "the hireling is gone") +end + +function tests.commandsApplyDismissFailsWithNoHirelingWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn = Commands.apply(game, "dismiss") + eq(consumesTurn, false, "dismissing with nobody under contract spends no turn") +end + +function tests.commandsApplyLearnSpendsAPointNeverATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.skillPoints = 1 + local turnBefore = game.turn + local success = Commands.apply(game, "learn cleave") + eq(success, false, "learning a skill never spends a turn") + eq(game.turn, turnBefore, "the turn counter is untouched by learning") + eq(Skills.has(game.player, "cleave"), true, "the skill is actually learned") + eq(game.player.skillPoints, 0, "the skill point is spent") + ok(contains(game.log:last(), "learn"), "learning is logged") + + local missing, message = Commands.apply(game, "learn") + eq(missing, false, "learn with no skill name spends no turn") + ok(message ~= nil, "the failure names what is missing") + + local alreadyKnown = Commands.apply(game, "learn cleave") + eq(alreadyKnown, false, "re-learning an already-known skill spends no turn") + ok(contains(game.log:last(), "already"), "the failure to re-learn is logged") +end + +-- Skills.active/Skills.passive keys are camelCase (secondWind, poisonBlade, +-- manaShield), but Commands.parse lowercases every token including a typed +-- skill name before any handler sees it - a player typing the skill's own +-- printed name would otherwise never match it. Typed exactly as a human +-- would type it (mixed case), routed through the real Commands.apply/parse +-- pipeline, not a pre-lowercased literal. +function tests.commandsApplyLearnAcceptsTheSkillsOwnCamelCaseSpellingDespiteTheParserLowercasingIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.level = 5 + game.player.skillPoints = 2 + local success = Commands.apply(game, "learn secondWind") + eq(success, false, "learning a skill never spends a turn") + eq(Skills.has(game.player, "secondWind"), true, "the camelCase skill is actually learned under its real key") + ok(contains(game.log:last(), "second wind"), "learning is logged under the skill's real name") + + local rogue = stagedGame(2, arena(21, 11), 5, 5) + rogue.player.class = "rogue" + rogue.player.level = 5 + rogue.player.skillPoints = 2 + Commands.apply(rogue, "learn poisonBlade") + eq(Skills.has(rogue.player, "poisonBlade"), true, "a differently-classed camelCase skill also resolves correctly") + + local mage = stagedGame(3, arena(21, 11), 5, 5) + mage.player.class = "mage" + mage.player.level = 5 + mage.player.skillPoints = 2 + Commands.apply(mage, "learn manaShield") + eq(Skills.has(mage.player, "manaShield"), true, "a camelCase PASSIVE skill resolves too, not only active ones") +end + +function tests.commandsApplyUseskillAcceptsACamelCaseSkillNameDespiteTheParserLowercasingIt() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.level = 5 + Skills.learn(game.player, "secondWind") + game.player.hp = 1 + local consumesTurn = Commands.apply(game, "useskill secondWind") + eq(consumesTurn, true, "using a camelCase-named skill by its real spelling spends the turn") + ok(game.player.hp > 1, "second wind actually healed the player, proving the real skill ran") + eq(game.player.skillCooldowns.secondWind, Skills.active.secondWind.cooldown, + "the cooldown lands on the real camelCase key, not a lowercased one") + + local rogue = stagedGame(5, arena(21, 11), 5, 5) + rogue.player.class = "rogue" + rogue.player.level = 5 + Skills.learn(rogue.player, "poisonBlade") + local consumesTurn2 = Commands.apply(rogue, "useskill poisonBlade") + eq(consumesTurn2, true, "using poisonBlade by its real camelCase spelling spends the turn") + eq(rogue.player.pendingPoisonBlade, true, "the venom is actually readied, proving the real skill ran") +end + +function tests.commandsApplyBrewCombinesTwoHerbsAtACauldronAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + local consumesTurn = Commands.apply(game, "brew 1 2") + eq(consumesTurn, true, "brewing two herbs at a cauldron underfoot spends the turn") + eq(game.player.inventory[1].kind, Items.POTION, "the brewed potion replaces the spent herbs") +end + +function tests.commandsApplyBrewFailsAwayFromACauldronWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + local consumesTurn = Commands.apply(game, "brew 1 2") + eq(consumesTurn, false, "brewing away from any cauldron spends no turn") + eq(#game.player.inventory, 2, "a failed brew for lack of a cauldron spends nothing") + local missing, message = Commands.apply(game, "brew") + eq(missing, false, "brew with no indices spends no turn") + ok(message ~= nil, "the failure names what is missing") +end + +-- Real bug found while manually verifying the new moonbell/sunpetal curdle +-- pairing through the full command pipeline: Game:brewAt returns false both +-- when nothing was ever attempted (no cauldron, bad indices) and when a real +-- attempt curdled (both herbs already spent, gas already vented) - the old +-- handler read either shape of false as "give the turn back", so a player +-- who curdled a pairing got a free retry exactly like a typo would. Fixed by +-- duplicating the preconditions in the handler (same shape as disarm's own +-- fix earlier today) so anything that reaches Game:brewAt always spends the +-- turn regardless of which of its two outcomes it lands on. +function tests.commandsApplyBrewSpendsTheTurnEvenWhenThePairingCurdles() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.NIGHTCAP) } + local consumesTurn, message = Commands.apply(game, "brew 1 2") + eq(consumesTurn, true, "a curdled brew still consumed a real attempt and must spend the turn") + eq(message, nil, "the curdle's own log line speaks for it, like a successful brew's message does") + eq(#game.player.inventory, 0, "the curdle already spent both herbs before the handler ever saw the outcome") + ok(contains(game.log:recent(1)[1] or "", "curdle"), "the curdle is still logged through the command pipeline") +end + +function tests.commandsApplyBrewRejectsTheSameIndexTwiceOrANonHerbItemWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Items.potion(10) } + local sameIndex = Commands.apply(game, "brew 1 1") + eq(sameIndex, false, "naming the same inventory slot twice spends no turn") + local nonHerb = Commands.apply(game, "brew 1 2") + eq(nonHerb, false, "pairing a herb with a non-herb item spends no turn") + eq(#game.player.inventory, 2, "neither rejected attempt spends any item") +end + +function tests.commandsApplyHelpMentionsTheCraftingAndEconomyVerbs() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + for _, word in ipairs({ "steal", "dismiss", "learn", "forge", "merge", "brew", "socket", "temper", "enchant", "repair" }) do + ok(contains(message, word), "help mentions " .. word) + end +end + +function tests.commandsApplyHelpMentionsAppeaseDisarmAndHorde() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + for _, word in ipairs({ "appease", "disarm", "horde" }) do + ok(contains(message, word), "help mentions " .. word) + end +end + +-- Game:manualStep - the turn-cycle integration around commands.lua. + end local order = { From 851e57ec97fa4a7492cb62911f873b871b167bf4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 14:46:50 +0200 Subject: [PATCH 162/172] test: cover tests.gameManualStepEndsTheTurnOnlyWhenTheCommandActually... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index fc3d5f2..7892f31 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13796,6 +13796,179 @@ end -- Game:manualStep - the turn-cycle integration around commands.lua. +function tests.gameManualStepEndsTheTurnOnlyWhenTheCommandActuallySucceeds() + local game = stagedGame(1, arena(21, 11), 5, 5) + local turnBefore = game.turn + local consumesTurn = game:manualStep("north") + eq(consumesTurn, true, "a valid move spends the turn") + eq(game.turn, turnBefore + 1, "Game:manualStep advances the turn counter on a successful command") +end + +function tests.gameManualStepLeavesTheTurnCounterUntouchedOnARejectedCommand() + local game = stagedGame(1, arena(21, 11), 5, 5) + local turnBefore = game.turn + local consumesTurn, message = game:manualStep("nonsense") + eq(consumesTurn, false, "an unrecognized command reports failure") + ok(message ~= nil, "the rejection carries a reason") + eq(game.turn, turnBefore, "the turn counter never advances on a rejected command") +end + +function tests.gameManualStepIsIdempotentAcrossRepeatedRejectedCommandsWithinOneUnspentTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Status.apply(game.player, Status.POISON, 5, 5) + game:manualStep("nonsense") + local hpAfterFirst = game.player.hp + local durationAfterFirst = game.player.statuses[1].duration + game:manualStep("gibberish") + eq(game.player.hp, hpAfterFirst, + "a second rejected command within the same unspent turn does not re-tick poison damage") + eq(game.player.statuses[1].duration, durationAfterFirst, + "...nor does it re-decrement the poison's own remaining duration") +end + +function tests.gameManualStepStunnedPlayerIgnoresTheTypedCommandButStillSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + -- Duration 2, not 1: Status.tick (run inside beginTurn, before the skip + -- check) decrements duration first, so a duration-1 stun would already + -- have expired by the time Status.skipsTurn looks for it this same turn. + Status.apply(game.player, Status.STUN, 2, 0) + local turnBefore = game.turn + local px, py = game.player.x, game.player.y + local consumesTurn = game:manualStep("north") + eq(consumesTurn, true, "a stunned turn is spent regardless of the typed command") + eq(game.player.x, px, "the player never actually moves while stunned") + eq(game.player.y, py, "the player never actually moves while stunned") + eq(game.turn, turnBefore + 1, "the stunned turn still advances the turn counter") +end + +function tests.gameManualStepHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.won = true + local turnBefore = game.turn + local consumesTurn = game:manualStep("north") + eq(consumesTurn, false, "a manual command after victory spends no turn") + eq(game.turn, turnBefore, "the turn counter never advances once the run is already won") +end + +-- Game:autoExplore / commands.lua's "explore" - autoexplore, repeating +-- Game:exploreStep turn after turn inside a single call instead of forcing +-- the player to retype "explore" once per step, and stopping itself the +-- instant something worth their attention shows up. + +function tests.gameAutoExploreWalksTowardUnexploredTerrainUntilTheWholeReachableAreaIsSeen() + local level = arena(25, 15) + local game = stagedGame(1, level, 3, 3) + local turnBefore = game.turn + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, true, "a run with real ground left to cover spends turns") + eq(message, "you have finished exploring", "a completed run reports it finished") + ok(game.turn > turnBefore, "autoExplore actually advances the turn counter") + local map = level.map + local allSeen = true + for y = 2, 14 do + for x = 2, 24 do + if not level.explored[map:index(x, y)] then allSeen = false end + end + end + ok(allSeen, "every walkable cell in the reachable room ends up explored") +end + +function tests.gameAutoExploreReportsNothingLeftWithoutSpendingATurnWhenAlreadyFullyExplored() + local game = stagedGame(1, arena(11, 9), 5, 5) + local turnBefore = game.turn + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, false, "a room already fully visible from the start spends no turn") + eq(message, "there is nothing left to explore", "the refusal explains why") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameAutoExploreHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.won = true + local turnBefore = game.turn + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, false, "an autoexplore attempt after victory spends no turn") + eq(message, "there is nothing left to do", "the refusal matches Game:manualStep's own halted message") + eq(game.turn, turnBefore, "the turn counter never advances once the run is already won") +end + +function tests.gameAutoExploreStopsTheInstantThePlayerTakesDamage() + local game = stagedGame(1, arena(25, 15), 3, 3) + Status.apply(game.player, Status.POISON, 5, 4) + local turnBefore = game.turn + local hpBefore = game.player.hp + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, true, "the interrupted turn itself still spends") + eq(message, "you stop exploring - you have taken damage", "the interrupt explains itself") + eq(game.turn, turnBefore + 1, "exactly the one turn that took the damage was spent") + eq(game.player.hp, hpBefore - 4, "the poison tick's own damage lands normally before the interrupt fires") +end + +function tests.gameAutoExploreRefusesToStartWhileAHostileIsAlreadyInSight() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 5, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + game:computeVisible() + local turnBefore = game.turn + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, false, "a hostile already visible refuses to even start") + eq(message, "a monster is already in sight", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +-- The room is wide open (not a corridor) on purpose: Game:exploreStep's own +-- BFS treats an entity's tile as unreachable (see game.lua), so a monster +-- sitting in a one-wide corridor would simply wall off the far end and +-- "nothing left to explore" would fire before the player ever got close +-- enough to see it - a real, separately-confirmed property of exploreStep, +-- not the thing this test means to exercise. A big room lets the BFS route +-- around the orc's single tile instead, so sight is what actually stops +-- this run. Both the room's height and the orc's row sit well within the +-- player's initial vision radius (8), so the whole approach plays out along +-- a single row and Stealth.WAKE_RADIUS (6, smaller than that same vision +-- radius) guarantees the orc is seen before it is ever close enough to roll +-- a wake of its own (see stealth.lua's canSpot) - the interrupt below is +-- proven to fire on sight alone, never on contact, without relying on RNG. +function tests.gameAutoExploreStopsTheMomentAHostileComesIntoSightWithoutEngagingIt() + local level = arena(30, 11) + local game = stagedGame(2, level, 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 25, 6, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + local turnBefore = game.turn + local hpBefore = game.player.hp + local orcHpBefore = orc.hp + local consumesTurn, message = game:autoExplore() + eq(consumesTurn, true, "real ground was covered before the interrupt fired") + eq(message, "you stop exploring - a monster is in sight", "the interrupt names a sighted hostile") + eq(game.turn, turnBefore + 14, "exactly the steps needed to bring the orc within vision range 8 were taken") + eq(game.player.hp, hpBefore, "no damage was taken - explore stops on sight, not on contact") + eq(orc.hp, orcHpBefore, "the orc is only spotted, never engaged") + ok(game.visible[level.map:index(orc.x, orc.y)], "the orc really is the thing that got seen") + eq(orc.x, 25, "the still-dormant orc never took a single step of its own") + eq(orc.awareness, Stealth.DORMANT, "and never woke up at all") +end + +function tests.gameManualStepDrivenExploreSpendsExactlyAsManyTurnsAsCallingGameAutoExploreDirectly() + local direct = stagedGame(9, arena(25, 15), 3, 3) + direct:autoExplore() + local viaManualStep = stagedGame(9, arena(25, 15), 3, 3) + viaManualStep:manualStep("explore") + eq(viaManualStep.turn, direct.turn, + "routing explore through Game:manualStep must not end the turn a second time on top of what " .. + "autoExplore already closed itself") +end + +function tests.commandsApplyExploreDrivesGameAutoExploreAndReturnsItsResult() + local game = stagedGame(1, arena(11, 9), 5, 5) + local consumesTurn, message = Commands.apply(game, "explore") + eq(consumesTurn, false, "a room already fully visible from the start spends no turn") + eq(message, "there is nothing left to explore", "the command surfaces Game:autoExplore's own message verbatim") +end + +function tests.commandsApplyHelpMentionsExplore() end local order = { From 0129c399f568ee393287a518dd5f568f8fb21603 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 15:21:10 +0200 Subject: [PATCH 163/172] test: cover tests.commandsApplyHelpMentionsTheOptionalTargetNumberOnE... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 7892f31..164fc30 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13969,6 +13969,179 @@ function tests.commandsApplyExploreDrivesGameAutoExploreAndReturnsItsResult() end function tests.commandsApplyHelpMentionsExplore() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "explore"), "help mentions explore") +end + +function tests.commandsApplyHelpMentionsTheOptionalTargetNumberOnEveryTargetedVerb() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "throw [target]"), "help spells out throw's optional target number") + ok(contains(message, "fire [target]"), "help spells out fire's optional target number") + ok(contains(message, "capture [target]"), "help spells out capture's optional target number") + ok(contains(message, "cast [target]"), "help spells out cast's optional target number") + ok(contains(message, "useskill [target]"), "help spells out useskill's optional target number") +end + +function tests.gameRestUntilHealedRefusesWithoutSpendingATurnWhenAlreadyAtFullHealth() + local game = stagedGame(1, arena(11, 9), 5, 5) + local turnBefore = game.turn + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, false, "resting at full health spends no turn") + eq(message, "you are already at full health", "the refusal explains why") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameRestUntilHealedRefusesWithoutSpendingATurnWhileAHostileIsAlreadyInSight() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + game.player.hp = game.player.maxHp - 5 + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 5, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + game:computeVisible() + local turnBefore = game.turn + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, false, "a hostile already visible refuses to even start") + eq(message, "a monster is already in sight", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameRestUntilHealedHealsTurnByTurnUntilFullThenStops() + local game = stagedGame(1, arena(11, 9), 5, 5) + game.player.hp = game.player.maxHp - 6 + Status.apply(game.player, Status.REGEN, 20, 2) + local turnBefore = game.turn + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, true, "a rest that actually heals spends turns") + eq(message, "you rest until your wounds close", "a completed rest reports it finished") + eq(game.player.hp, game.player.maxHp, "hp is capped exactly at maxHp, never over it") + eq(game.turn, turnBefore + 3, "exactly ceil(6/2) turns of the regen tick were needed") +end + +-- Deliberately the same "nothing left to explore" shape as autoExplore's own +-- steps==0 refusal, but rest's equivalent needs a *second* unchanged turn +-- before it gives up rather than judging the first one alone - see +-- Game:restUntilHealed's own comment for why: a real player's typed "rest" +-- always runs through Game:manualStep, which already primes one turn of +-- ticking before this function's loop ever gets to take its own first +-- hpBefore snapshot, so that first reading alone cannot be trusted. +function tests.gameRestUntilHealedStopsAfterTwoUnchangedTurnsWhenNothingIsHealingThePlayer() + local game = stagedGame(1, arena(11, 9), 5, 5) + game.player.hp = game.player.maxHp - 5 + local turnBefore = game.turn + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, true, "the turns spent checking still count as spent") + eq(message, "you stop resting - nothing here is healing you", "the interrupt explains itself") + eq(game.turn, turnBefore + 2, "exactly two turns were spent before giving up") + eq(game.player.hp, game.player.maxHp - 5, "hp is unchanged - nothing was healing it in the first place") +end + +function tests.gameRestUntilHealedStopsTheInstantThePlayerTakesDamage() + local game = stagedGame(1, arena(11, 9), 5, 5) + game.player.hp = game.player.maxHp - 5 + Status.apply(game.player, Status.POISON, 5, 4) + local turnBefore = game.turn + local hpBefore = game.player.hp + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, true, "the interrupted turn itself still spends") + eq(message, "you stop resting - you have taken damage", "the interrupt explains itself") + eq(game.turn, turnBefore + 1, "exactly the one turn that took the damage was spent") + eq(game.player.hp, hpBefore - 4, "the poison tick's own damage lands normally before the interrupt fires") +end + +-- Same row placement trick as autoExplore's own sight-interrupt test, but +-- mirrored: here the *player* stands still (resting) and the orc is the one +-- that has to close the distance on its own, so it starts HUNTING (already +-- aware, not DORMANT) rather than relying on stealth.lua's wake roll - the +-- point of this test is Game:canSeePlayer's own chase range (10) reaching +-- further than the player's own vision radius (8), not the wake mechanic. +-- Status.REGEN is applied so the run has real healing to interrupt (see +-- Game:restUntilHealed's own comment - two turns are needed before "nothing +-- is healing you" could even fire, and this scenario resolves before that). +function tests.gameRestUntilHealedStopsTheMomentAHostileComesIntoSightWithoutEngagingIt() + local level = arena(30, 11) + local game = stagedGame(2, level, 20, 6) + game.player.hp = game.player.maxHp - 20 + Status.apply(game.player, Status.REGEN, 50, 1) + local orc = Monsters.create(Monsters.byKey.orc, 1, 11, 6, 1) + orc.awareness = Stealth.HUNTING + level.entities = { orc } + game:computeVisible() + ok(not game.visible[level.map:index(orc.x, orc.y)], "the orc starts out of sight") + local turnBefore = game.turn + local hpBefore = game.player.hp + local orcHpBefore = orc.hp + local consumesTurn, message = game:restUntilHealed() + eq(consumesTurn, true, "real turns were spent healing before the interrupt fired") + eq(message, "you stop resting - a monster is in sight", "the interrupt names a sighted hostile") + eq(game.turn, turnBefore + 2, "exactly the steps needed to bring the orc within vision range 8 were taken") + ok(game.player.hp > hpBefore, "the turns spent before the interrupt actually healed the player") + eq(orc.hp, orcHpBefore, "the orc is only spotted, never engaged") + ok(game.visible[level.map:index(orc.x, orc.y)], "the orc really is the thing that got seen") + eq(orc.awareness, Stealth.HUNTING, "the orc's own awareness is unaffected by simply being spotted") +end + +function tests.gameManualStepDrivenRestSpendsExactlyAsManyTurnsAsCallingGameRestUntilHealedDirectly() + local direct = stagedGame(9, arena(11, 9), 5, 5) + direct.player.hp = direct.player.maxHp - 6 + Status.apply(direct.player, Status.REGEN, 20, 2) + direct:restUntilHealed() + local viaManualStep = stagedGame(9, arena(11, 9), 5, 5) + viaManualStep.player.hp = viaManualStep.player.maxHp - 6 + Status.apply(viaManualStep.player, Status.REGEN, 20, 2) + viaManualStep:manualStep("rest") + eq(viaManualStep.turn, direct.turn, + "routing rest through Game:manualStep must not end the turn a second time on top of what " .. + "restUntilHealed already closed itself, and must credit the turn manualStep primes before " .. + "Commands.apply ever runs to the same run rather than losing it") + eq(viaManualStep.player.hp, direct.player.hp, "both paths heal the player by the same total amount") +end + +function tests.commandsApplyRestDrivesGameRestUntilHealedAndReturnsItsResult() + local game = stagedGame(1, arena(11, 9), 5, 5) + local consumesTurn, message = Commands.apply(game, "rest") + eq(consumesTurn, false, "a player already at full health spends no turn") + eq(message, "you are already at full health", "the command surfaces Game:restUntilHealed's own message verbatim") +end + +function tests.commandsApplyHelpMentionsRest() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "rest"), "help mentions rest") +end + +-- Splits an otherwise-open arena into two floor regions with a solid wall +-- column at x == gapX between them - used only to build a destination that is +-- provably unreachable from the player's own side, for Game:travelTo's "no +-- route exists" refusal below. +local function splitArena(w, h, gapX) + local map = Map.new(w, h) + for y = 2, h - 1 do + for x = 2, gapX - 1 do map:set(x, y, Map.FLOOR) end + for x = gapX + 1, w - 1 do map:set(x, y, Map.FLOOR) end + end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 2, downX = w - 1, downY = h - 1, + }) +end + +function tests.gameTravelToWalksTheShortestPathAndArrivesAtTheDestination() + local level = arena(21, 11) + local game = stagedGame(1, level, 3, 6) + local expectedSteps = #game:playerPathTo(level.downX, level.downY) - 1 + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, true, "a real trip spends turns") + eq(message, "you arrive", "arriving reports so") + eq(game.player.x, level.downX, "the player actually reaches the destination x") + eq(game.player.y, level.downY, "the player actually reaches the destination y") + eq(game.turn, turnBefore + expectedSteps, "exactly the shortest path's own number of steps were spent") +end + end local order = { From 9627ab970ab8971dea5a712c86d9f1327982e623 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 18:19:35 +0200 Subject: [PATCH 164/172] test: cover tests.gameTravelToRefusesWithoutSpendingATurnWhenAlreadyT... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 164fc30..c0b3c98 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14142,6 +14142,179 @@ function tests.gameTravelToWalksTheShortestPathAndArrivesAtTheDestination() eq(game.turn, turnBefore + expectedSteps, "exactly the shortest path's own number of steps were spent") end +function tests.gameTravelToRefusesWithoutSpendingATurnWhenAlreadyThere() + local level = arena(11, 9) + local game = stagedGame(1, level, level.downX, level.downY) + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "already standing on the destination spends no turn") + eq(message, "you are already there", "the refusal explains why") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToRefusesWithoutSpendingATurnWhenNoRouteExists() + local level = splitArena(21, 9, 11) + local game = stagedGame(1, level, 3, 5) + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "an unreachable destination refuses to even start") + eq(message, "you cannot find a path there", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToRefusesWithoutSpendingATurnWhileAHostileIsAlreadyInSight() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 5, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + game:computeVisible() + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "a hostile already visible refuses to even start") + eq(message, "a monster is already in sight", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + game.won = true + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "a travel attempt after victory spends no turn") + eq(message, "there is nothing left to do", "the refusal matches Game:manualStep's own halted message") + eq(game.turn, turnBefore, "the turn counter never advances once the run is already won") +end + +function tests.gameTravelToStopsTheInstantThePlayerTakesDamage() + local level = arena(25, 15) + local game = stagedGame(1, level, 3, 3) + Status.apply(game.player, Status.POISON, 5, 4) + local turnBefore = game.turn + local hpBefore = game.player.hp + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, true, "the interrupted turn itself still spends") + eq(message, "you stop traveling - you have taken damage", "the interrupt explains itself") + eq(game.turn, turnBefore + 1, "exactly the one turn that took the damage was spent") + eq(game.player.hp, hpBefore - 4, "the poison tick's own damage lands normally before the interrupt fires") +end + +-- Same row-placement trick as Game:autoExplore's own sight-interrupt test +-- (see its comment for the exact reasoning: both the room's height and the +-- orc's row sit well within the player's initial vision radius (8), and +-- Stealth.WAKE_RADIUS (6) is smaller than that, so the orc is always seen +-- before it could ever wake on its own). The destination (28, 6) sits well +-- past the orc's own cell (25, 6) on the same row, but the interrupt fires +-- purely from Game:anyHostileVisible at the top of the loop - before that +-- turn's own move (and any path recomputation to route around the now- +-- occupied cell) is ever attempted - so the two extra columns behind the orc +-- never come into play; empirically confirmed to land on the exact same +-- turn count as the analogous autoExplore test. +function tests.gameTravelToStopsTheMomentAHostileComesIntoSightWithoutEngagingIt() + local level = arena(30, 11) + local game = stagedGame(2, level, 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 25, 6, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + local turnBefore = game.turn + local hpBefore = game.player.hp + local orcHpBefore = orc.hp + local consumesTurn, message = game:travelTo(28, 6) + eq(consumesTurn, true, "real ground was covered before the interrupt fired") + eq(message, "you stop traveling - a monster is in sight", "the interrupt names a sighted hostile") + eq(game.turn, turnBefore + 14, "exactly the steps needed to bring the orc within vision range 8 were taken") + eq(game.player.hp, hpBefore, "no damage was taken - travel stops on sight, not on contact") + eq(orc.hp, orcHpBefore, "the orc is only spotted, never engaged") + ok(game.visible[level.map:index(orc.x, orc.y)], "the orc really is the thing that got seen") + eq(orc.x, 25, "the still-dormant orc never took a single step of its own") + eq(orc.awareness, Stealth.DORMANT, "and never woke up at all") +end + +function tests.gameManualStepDrivenTravelSpendsExactlyAsManyTurnsAsCallingGameTravelToDirectly() + local level1 = arena(21, 11) + local direct = stagedGame(9, level1, 3, 6) + direct:travelTo(level1.downX, level1.downY) + local level2 = arena(21, 11) + local viaManualStep = stagedGame(9, level2, 3, 6) + viaManualStep:manualStep("travel down") + eq(viaManualStep.turn, direct.turn, + "routing travel through Game:manualStep must not end the turn a second time on top of what " .. + "travelTo already closed itself") + eq(viaManualStep.player.x, direct.player.x, "both paths reach the same destination x") + eq(viaManualStep.player.y, direct.player.y, "both paths reach the same destination y") +end + +function tests.commandsApplyTravelDownDrivesGameTravelToTowardTheDownStairs() + local level = arena(21, 11) + local game = stagedGame(1, level, level.downX, level.downY) + local consumesTurn, message = Commands.apply(game, "travel down") + eq(consumesTurn, false, "already standing on the down stairs spends no turn") + eq(message, "you are already there", "the command surfaces Game:travelTo's own message verbatim") +end + +function tests.commandsApplyTravelUpDrivesGameTravelToTowardTheUpStairs() + local level = arena(21, 11) + local game = stagedGame(1, level, level.upX, level.upY) + local consumesTurn, message = Commands.apply(game, "travel up") + eq(consumesTurn, false, "already standing on the up stairs spends no turn") + eq(message, "you are already there", "the command surfaces Game:travelTo's own message verbatim") +end + +function tests.commandsApplyTravelWithoutAnArgumentFailsWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "travel") + eq(consumesTurn, false, "an unrecognized destination spends no turn") + eq(message, "travel where? down or up", "the failure explains the two valid destinations") +end + +function tests.commandsApplyHelpMentionsTravel() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "travel down/up"), "help mentions travel down/up") +end + +-- Every test above exercises the engine in-process, by requiring src/ modules +-- directly - never the actual `lua warren.lua ...` entry point a real player +-- (or the --play REPL) invokes. That leaves argument parsing, the playLoop +-- stdin/stdout loop, and the CLI-level wiring between flags and Game/Meta +-- entirely unverified by automation (three prior sessions flagged this and +-- checked it by hand instead). This section closes that gap: it spawns the +-- real interpreter against the real warren.lua as a subprocess, the same way +-- a player or a shell script would, and asserts on what actually comes back. +local warrenEntry = scriptDir .. "/../warren.lua" +local luaExe = arg[-1] or "lua" + +local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +-- Runs `lua warren.lua `, optionally piping stdinText in (for +-- --play), and returns (combinedOutput, exitCode). stderr is folded into +-- stdout so a crash's traceback shows up in the captured text instead of +-- vanishing into the terminal. +local function runWarrenCli(argv, stdinText) + local parts = { shellQuote(luaExe), shellQuote(warrenEntry) } + for _, a in ipairs(argv) do parts[#parts + 1] = shellQuote(a) end + local inPath + if stdinText then + inPath = os.tmpname() + local input = io.open(inPath, "w") + input:write(stdinText) + input:close() + parts[#parts + 1] = "<" + parts[#parts + 1] = shellQuote(inPath) + end + parts[#parts + 1] = "2>&1" + local proc = io.popen(table.concat(parts, " "), "r") + local output = proc:read("a") + local success, exitType, code = proc:close() + if inPath then os.remove(inPath) end + local exitCode = success and 0 or (exitType == "exit" and code or -1) + return output, exitCode +end + +function tests.cliHeadlessAutoRunPrintsTheStatusBarAndExitsZero() end local order = { From 281fe36e73a99106abaedac708016ce68c8bbe44 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 19:22:33 +0200 Subject: [PATCH 165/172] test: cover tests.cliPlayReplDrivesRealCommandsThroughRealStdin --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index c0b3c98..07fc422 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14315,6 +14315,179 @@ local function runWarrenCli(argv, stdinText) end function tests.cliHeadlessAutoRunPrintsTheStatusBarAndExitsZero() + local output, code = runWarrenCli({ "--seed", "42", "--auto", "20" }) + eq(code, 0, "a plain headless auto-run exits zero") + ok(contains(output, "HP "), "the status bar's HP field reaches real stdout") + ok(contains(output, "depth "), "the status bar's depth field reaches real stdout") + ok(not contains(output, "traceback"), "a routine auto-run never prints a Lua traceback") +end + +function tests.cliPlayReplDrivesRealCommandsThroughRealStdin() + local script = "status\nlook\nlearn cleave\nlearn cleave\nforge fire\nhelp\nquit\n" + local output, code = runWarrenCli({ "--seed", "5", "--class", "fighter", "--play" }, script) + eq(code, 0, "a scripted --play session exits zero") + ok(contains(output, "you learn cleave"), "learn is wired through the real stdin loop") + ok(contains(output, "you already know that"), "a repeated learn is rejected through the real stdin loop") + ok(contains(output, "you see no threats nearby"), "look reaches the real command dispatcher") + ok(contains(output, "cannot forge: not enough essence"), + "forge's failure message reaches real stdout with a fresh fighter's zero essence") + ok(contains(output, "commands: north/south/east/west"), "help lists the command vocabulary over real stdout") +end + +function tests.cliPlayLoopChargesATurnOnlyForCommandsThatActuallySucceed() + local script = "wait\nasdf\nstatus\nquit\n" + local output, code = runWarrenCli({ "--seed", "1", "--auto", "0", "--play" }, script) + eq(code, 0, "a mixed valid/invalid --play script still exits zero") + ok(contains(output, "unknown command: asdf"), "an unrecognized verb is reported over real stdout") + ok(contains(output, "turn 1"), "one successful wait (and one rejected typo) leaves the turn counter at exactly 1") + ok(not contains(output, "turn 2"), "the rejected typo never advances the turn counter a second time") +end + +function tests.cliSaveThenLoadRoundTripsThroughTheRealEntryPoint() + local savePath = os.tmpname() + local saveOut, saveCode = runWarrenCli({ "--seed", "9", "--auto", "15", "--save", savePath }) + eq(saveCode, 0, "--save exits zero") + ok(contains(saveOut, "saved to"), "--save reports where it wrote the file") + local saved = io.open(savePath, "r") + ok(saved ~= nil, "the save file actually exists on disk after --save") + if saved then + ok(#saved:read("a") > 0, "the save file is not empty") + saved:close() + end + local loadOut, loadCode = runWarrenCli({ "--load", savePath, "--auto", "0" }) + os.remove(savePath) + eq(loadCode, 0, "--load exits zero") + ok(contains(loadOut, "turn 15"), "the loaded run resumes at exactly the turn it was saved on") +end + +function tests.cliVerifyConfirmsADeterministicReplayThroughTheRealEntryPoint() + local output, code = runWarrenCli({ "--seed", "7", "--auto", "30", "--verify" }) + eq(code, 0, "--verify exits zero") + ok(contains(output, "replay verified: seed 7 reproduces this run"), + "a meta-free run reports a verified replay over real stdout") +end + +function tests.cliChallengePresetPrintsAChallengeLineAndAVerdict() + local output, code = runWarrenCli({ "--challenge", "3", "--auto", "40" }) + eq(code, 0, "--challenge exits zero") + ok(contains(output, "challenge:"), "the challenge preset announces itself over real stdout") + ok(contains(output, "challenge honored") or contains(output, "challenge broken"), + "the challenge preset reaches a pass/fail verdict by the end of the run") +end + +function tests.cliReportShowsFreshProgressionStateForANewMetaFile() + local metaPath = os.tmpname() + os.remove(metaPath) + local output, code = runWarrenCli({ "--meta", metaPath, "--report" }) + os.remove(metaPath) + eq(code, 0, "--report against a brand-new --meta file exits zero") + ok(contains(output, "renown 0"), "a fresh meta file reports zero renown") + ok(contains(output, "streak:"), "the report includes the streak line") + ok(contains(output, "compendium:"), "the report includes the compendium section") + ok(contains(output, "leaderboard:"), "the report includes the leaderboard section") + ok(contains(output, "perk vigor"), "the report lists purchasable perks") +end + +function tests.cliExportsRealLeaderboardAndCompendiumCsvFiles() + local metaPath = os.tmpname() + os.remove(metaPath) + local csvPath = os.tmpname() + local bestiaryPath = os.tmpname() + local output, code = runWarrenCli({ + "--meta", metaPath, "--seed", "5", "--auto", "30", + "--export-csv", csvPath, "--export-compendium", bestiaryPath, + }) + eq(code, 0, "a run with both export flags exits zero") + ok(contains(output, "leaderboard exported to"), "the CLI reports the leaderboard export path") + ok(contains(output, "compendium exported to"), "the CLI reports the compendium export path") + local csv = io.open(csvPath, "r") + ok(csv ~= nil, "the leaderboard CSV file exists on disk") + if csv then + local header = csv:read("l") + eq(header, "rank,score,className,depth,turn,won,seed", "the leaderboard CSV has the expected header row") + csv:close() + end + local bestiary = io.open(bestiaryPath, "r") + ok(bestiary ~= nil, "the compendium CSV file exists on disk") + if bestiary then + ok(#bestiary:read("a") > 0, "the compendium CSV is not empty") + bestiary:close() + end + os.remove(metaPath) + os.remove(csvPath) + os.remove(bestiaryPath) +end + +function tests.cliLoadingAMissingSaveFileFailsLoudlyRatherThanSilently() + local output, code = runWarrenCli({ "--load", "/tmp/warren-tests-nonexistent-save-xyz.sav" }) + eq(code, 1, "loading a save file that does not exist exits nonzero") + ok(contains(output, "cannot load save"), "the failure names the save-loading step, not a generic crash") + ok(contains(output, "missing"), "the failure explains that the file itself is what is missing") +end + +-- Deliberately loose on exact numbers (unlike the in-process Game:autoExplore +-- tests above, this drives a real builder-generated level rather than a +-- hand-built arena) - it only asserts the one invariant a real player relying +-- on this over actual stdin needs to hold: a single typed "explore" advances +-- the turn counter by more than the one turn any other command would. +function tests.cliPlayReplExploreAdvancesSeveralTurnsInASingleTypedCommand() + local script = "status\nexplore\nstatus\nquit\n" + local output, code = runWarrenCli({ "--seed", "5", "--class", "fighter", "--play" }, script) + eq(code, 0, "a scripted explore through --play exits zero") + ok(not contains(output, "traceback"), "explore over real stdin never crashes") + -- Every command re-renders the status bar (its own "turn %d" field) on top + -- of whatever reply message it printed, so "turn %d" shows up several + -- times per command rather than once per typed "status" - the turn number + -- only ever increases, so comparing the very first occurrence (the + -- opening render, before any command) against the very last (after + -- everything, including "explore") is robust to exactly how many of those + -- redundant re-renders there are. + local turns = {} + for turn in output:gmatch("turn (%d+)") do turns[#turns + 1] = tonumber(turn) end + ok(#turns >= 2, "the turn counter appears in the transcript") + ok(turns[#turns] > turns[1], "explore actually advances the turn counter through the real entry point") +end + +-- A freshly created character always starts at full hp (see entity.lua), so +-- this exercises rest's refusal path rather than a multi-turn heal - the +-- healing/interrupt logic itself is already covered turn-by-turn by the +-- gameRestUntilHealed* tests above; what this proves instead is that the +-- real --play REPL actually reaches Commands.apply's "rest" handler and +-- reports Game:restUntilHealed's own refusal without spending a turn, +-- through the real entry point rather than by requiring src/ modules. +function tests.cliPlayReplRestRefusesWithoutSpendingATurnAtFullHealth() + local script = "status\nrest\nstatus\nquit\n" + local output, code = runWarrenCli({ "--seed", "5", "--class", "fighter", "--play" }, script) + eq(code, 0, "a scripted rest through --play exits zero") + ok(not contains(output, "traceback"), "rest over real stdin never crashes") + ok(contains(output, "you are already at full health"), "the real entry point surfaces the refusal verbatim") + ok(not contains(output, "turn 1"), "the refused rest never advances the turn counter past the opening render") +end + +-- Seed 1 with the fighter class was picked by hand (see how gameAutoExplore's +-- own CLI test picked seed 5): "travel down" on this particular generated +-- floor covers real ground before a monster comes into sight and interrupts +-- it, exactly the multi-turn-then-interrupt shape gameTravelToStopsTheMoment +-- AHostileComesIntoSightWithoutEngagingIt already proves turn-by-turn in +-- isolation - this only proves the real --play entry point reaches it too. +function tests.cliPlayReplTravelDownAdvancesSeveralTurnsInASingleTypedCommand() + local script = "status\ntravel down\nstatus\nquit\n" + local output, code = runWarrenCli({ "--seed", "1", "--class", "fighter", "--play" }, script) + eq(code, 0, "a scripted travel through --play exits zero") + ok(not contains(output, "traceback"), "travel over real stdin never crashes") + ok(contains(output, "you stop traveling - a monster is in sight"), + "the real entry point surfaces Game:travelTo's own interrupt verbatim") + local turns = {} + for turn in output:gmatch("turn (%d+)") do turns[#turns + 1] = tonumber(turn) end + ok(#turns >= 2, "the turn counter appears in the transcript") + ok(turns[#turns] > turns[1], "travel actually advances the turn counter through the real entry point") +end + +-- Moonbell is the sixth herb (see alchemy.lua), added purely to make +-- confuse/blind/levitate brewable - all three were only ever obtainable as +-- loot, a trap, or a spell before this, a gap flagged (and deliberately not +-- closed, since the existing five-herb grid was already fully assigned) +-- across several earlier sessions today. Its own pair concentrates its end local order = { From 6f581d988ed06dc19445a2944e31b399452eb488 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 20:55:11 +0200 Subject: [PATCH 166/172] test: cover tests.alchemyMoonbellPairedWithItselfConcentratesIntoConf... --- tests/run.lua | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 07fc422..e4682d9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14488,6 +14488,87 @@ end -- loot, a trap, or a spell before this, a gap flagged (and deliberately not -- closed, since the existing five-herb grid was already fully assigned) -- across several earlier sessions today. Its own pair concentrates its +-- dream-fog aspect into confusion, the same "own aspect turned inward" shape +-- as every other same-herb recipe. +function tests.alchemyMoonbellPairedWithItselfConcentratesIntoConfusion() + local potion = Alchemy.brew(Alchemy.MOONBELL, Alchemy.MOONBELL) + eq(potion.effect, "confuse", "a doubled moonbell brews its own dream-fog aspect") +end + +-- Sunpetal (wakeful daylight) is the one aspect moonbell (dreaming night) +-- outright opposes, the same shape as the pre-existing sunpetal/nightcap +-- (light versus shade) and emberleaf/frostmoss (fire versus frost) curdles. +function tests.alchemyMoonbellPairedWithSunpetalCurdlesOnTheDayVersusDreamOpposition() + local potion, reason = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.MOONBELL) + eq(potion, nil, "day and dream herbs curdle rather than brewing a potion") + eq(reason, "curdle", "a curdled moonbell brew names its failure like any other opposed pair") +end + +function tests.alchemyMoonbellPairedWithBloodrootBrewsClarity() + local potion = Alchemy.brew(Alchemy.BLOODROOT, Alchemy.MOONBELL) + eq(potion.effect, "clarity", "blood grounds the dreaming mind into clarity instead of confusion") + eq(potion.power, 30, "an unspecified recipe power falls back to the catalog default for its effect") +end + +function tests.alchemyMoonbellPairedWithEmberleafBrewsBlindness() + local potion = Alchemy.brew(Alchemy.EMBERLEAF, Alchemy.MOONBELL) + eq(potion.effect, "blind", "fire's smoke thickened by dream-fog brews blindness") +end + +function tests.alchemyMoonbellPairedWithFrostmossBrewsLevitation() + local potion = Alchemy.brew(Alchemy.FROSTMOSS, Alchemy.MOONBELL) + eq(potion.effect, "levitate", "frost's lightness carried by dream-fog brews levitation") +end + +function tests.alchemyMoonbellPairedWithNightcapBrewsAStrongerPoisonThanEitherHerbAlone() + local blended = Alchemy.brew(Alchemy.NIGHTCAP, Alchemy.MOONBELL) + eq(blended.effect, "poison", "two shade-aspected herbs still blend into poison, like nightcap's other cross pairs") + ok(blended.power > Alchemy.recipes[Alchemy.pairKey(Alchemy.BLOODROOT, Alchemy.NIGHTCAP)].power, + "moonbell's nightcap blend is stronger than nightcap's weakest existing poison blend") + ok(blended.power < Alchemy.recipes[Alchemy.pairKey(Alchemy.NIGHTCAP, Alchemy.NIGHTCAP)].power, + "moonbell's nightcap blend still falls short of concentrated nightcap's own poison") +end + +-- The five pre-existing herbs' own 15 recipes are untouched data (see +-- alchemy.lua's own comment on why moonbell appends rather than inserts) - +-- this pins the two still-standing curdles so a future change to the new +-- pairings above could never silently perturb them. +function tests.alchemyAddingMoonbellLeftEveryPreExistingRecipeUntouched() + eq(Alchemy.brew(Alchemy.SUNPETAL, Alchemy.SUNPETAL).power, 18, "concentrated sunpetal still heals for its original power") + local potion, reason = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.NIGHTCAP) + eq(potion, nil, "sunpetal and nightcap still curdle") + eq(reason, "curdle", "the pre-existing curdle still names its failure the same way") + eq(Alchemy.brew(Alchemy.EMBERLEAF, Alchemy.FROSTMOSS), nil, "emberleaf and frostmoss still curdle too") + eq(Alchemy.brew(Alchemy.NIGHTCAP, Alchemy.NIGHTCAP).power, 7, "concentrated nightcap still brews its original poison power") +end + +-- Exercises the full Game:brewAt pipeline (cauldron gating, inventory +-- spending, Identify.reveal) with the new herb, not just Alchemy.brew in +-- isolation - the unit-level recipe tests above already proved the fixed +-- outcomes; this proves the interactive path a player actually uses reaches +-- them unchanged. +function tests.gameBrewAtCanBrewAMoonbellPairingAtACauldron() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.MOONBELL), Alchemy.herb(Alchemy.FROSTMOSS) } + ok(game:brewAt(1, 2), "brewing moonbell with frostmoss at a cauldron underfoot succeeds") + eq(#game.player.inventory, 1, "both source herbs are spent and one potion is added") + local brewed = game.player.inventory[1] + eq(brewed.effect, "levitate", "the brewed potion matches moonbell/frostmoss's fixed recipe") + ok(Identify.isKnown(game.identify, brewed), "brewing a moonbell potion yourself identifies its type for free, like any other brew") +end + +function tests.buildersOccasionallyRollAMoonbellHerbAsForagingLoot() + local found + for seed = 1, 200 do + local level = Builder.build(1, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.HERB and ground.item.herb == Alchemy.MOONBELL then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a moonbell herb eventually turns up in the entrance-floor foraging loot across a handful of seeds") + eq(found.name, "moonbell herb", "a foraged moonbell herb carries its plain, unmasked name like any other herb") end local order = { From 8d0fb81ba992060c533d77b0294f698e28ef9e4b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 21:43:48 +0200 Subject: [PATCH 167/172] test: cover run --- tests/run.lua | 177 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 4 deletions(-) diff --git a/tests/run.lua b/tests/run.lua index e4682d9..39d85e0 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14574,10 +14574,179 @@ end local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", - "scheduler", "generators", "connectivityRepair", "dungeonPersistence", "monsterAiChase", "monsterAiRanged", - "monsterAiFlee", "monsterAiDeterministic", "doorsAndKeys", "traps", "lightAndDark", - "identification", "effects", "messageLog", "weaponAffixOnHit", "saveLoadRoundTrip", - "deterministicPlaythrough", "autoplayDescends", + "scheduler", "generators", + "mazeGeneratorIsDeterministicDiffersAcrossSeedsAndLeavesEveryFloorCellReachable", + "mazeGeneratorIsRegisteredButExcludedFromTheDepthCycle", + "mazeGeneratorProducesOneWideCorridorsRatherThanOpenRooms", + "mazeGeneratorBraidingOpensSomeDeadEndsWithoutTouchingConnectivity", + "prefabVaultSizeMatchesItsWidestRowAndItsRowCount", + "prefabVaultFitsAcceptsAnAnchorWithAWallGapToEveryBorderAndRejectsOneWithout", + "prefabVaultStampWritesEveryGlyphAndCollectsTreasureMonsterAndLootMarkersInMapOrder", + "prefabVaultStampIsDeterministicForAFixedAnchor", + "builderCanBeExplicitlySteeredToAMazeFloorAndStillYieldsAPlayableLevel", + "connectivityRepair", "dungeonPersistence", "monsterAiChase", "monsterAiRanged", + "monsterAiFlee", "monsterAiDeterministic", + "aiActConfusedMonsterSometimesStumblesInsteadOfChasing", + "aiActWithoutConfusionAlwaysRunsTheNormalBehaviour", + "aiActBlindedArcherNeitherShootsNorChasesEvenWithAClearShot", + "gameAdjacentAllyFindsACompanionStandingRightNextToAMonster", + "gameAdjacentAllyIgnoresAHostileMonsterAndOnlyCountsCardinalAdjacency", + "gameAdjacentAllyBreaksTiesByTheLowerEntityIdAndIgnoresADeadAlly", + "aiChaseAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever", + "aiChasePrefersAnAdjacentPlayerOverAnAdjacentAllyWhenBothAreInMeleeRange", + "aiPackAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever", + "aiTunnelAttacksAnAllyBlockingItsOnlyGapInsteadOfIdling", + "aiRangedAttacksABlockingAllyWhenItHasNoLineOfSightToThePlayerAtAll", + "gameMoveMonsterAttacksAnAllyItBumpsIntoInsteadOfDoingNothing", + "aiActConfusedMonsterCanStumbleIntoAttackingABlockingAllyInsteadOfIdling", + "doorsAndKeys", "traps", + "gameSpringTrapConfuseEffectAppliesToAnyVictimNotJustThePlayer", + "gameSpringTrapBlindEffectAppliesToAnyVictimNotJustThePlayer", + "gameSpringTrapStunEffectAppliesToAnyVictimNotJustThePlayer", + "gameMovePlayerConfusionEitherFollowsTheChosenDirectionOrStumblesElsewhere", + "gameMovePlayerConfusionStumbleIntoAWallStillSpendsTheTurn", + "gameMovePlayerWithoutConfusionNeverStumbles", + "gameAllyActsConfusedReturnsFalseImmediatelyWithoutTheStatus", + "lightAndDark", + "identification", "identifyGivesEveryPotionTypeItsOwnDistinctUnidentifiedLook", "effects", + "effectsMessagesAddressThePlayerInSecondPersonNotThirdPersonSingular", + "effectsClarityMessageAddressesThePlayerPossessively", + "effectsCureMessagesAddressThePlayerPossessivelyForAllFourOutcomes", "hazards", + "shrinePrayGrantsWarBlessingWhenFavorHolds", "shrinePrayGrantsVitalityBlessing", + "shrinePrayGrantsWisdomBlessing", "shrinePrayGrantsFortuneBlessingAndCleansesAilments", + "shrinePrayCanCurseInsteadOfBless", "shrinePrayFailsWithoutEnoughGold", + "shrineCostAndCurseOddsRiseWithUse", "shrineFieldsSurviveSerialization", + "builderOccasionallyPlacesAShrineFromDepthFour", "shrinesStayOffTheShallowFloors", + "autopilotPraysAtAnUnspentShrineWhenAffordable", "autopilotSkipsAnAlreadyUsedShrine", + "shrineRendersItsGlyphWhenVisible", + "messageLog", "weaponAffixOnHit", + "progressionLeveling", "progressionMonsterKillGrantsXp", + "spellCastMissileKillsAndGrantsXp", "spellCastFailsWithoutSpendingState", + "spellLearnFromTome", "spellHealAndShield", "spellBlinkMovesWithinRadius", + "manaRegeneratesEachTurn", + "throwWeaponHitsAndLands", "throwWeaponKillGrantsXp", "throwPotionAppliesHostileEffect", + "throwConfusePotionAppliesTheStatusToTheTargetNotTheThrower", + "throwBlindPotionAppliesTheStatusToTheTargetNotTheThrower", + "throwLevitatePotionAppliesTheStatusToTheTargetNotTheThrower", + "throwOutOfRangeFails", + "rangedShotHitsAtRangeAndConsumesAmmo", "rangedShotKillGrantsXp", "rangedFailsWithoutAmmo", + "rangedFailsOutOfRange", "ammoStacksOnPickup", + "trollRegenerateUnlessBurning", "ratHasNoRegen", "wretchDeathBurstsPoisonSpores", + "dragonBreathIgnitesPlayer", + "necromancerSummonsZombie", "summonRespectsPopulationCap", + "curseAtRangeHurtsAndWeakens", "curseDamageIsMitigatedByThePlayersDefenseLikeAnyOtherAttack", + "curseBreaksThePlayersMeleeMomentumStack", "curseWearsThePlayersEquippedArmor", + "curseWithheldWhenAdjacent", "abilityCooldownBlocksImmediateReuse", + "shriekAtRangeBlindsThePlayer", "shriekWithheldWhenAdjacent", "shriekIsUnavailableOutOfRange", + "shriekBlindnessReducesTheVictimsEffectiveSightRadius", + "healerMendsNeediestAlly", "healerSkipsWhenAlliesAreHealthy", + "healerNeverMendsThePlayersOwnCompanionHirelingOrThrall", + "rallyBuffsAdjacentAllies", "rallySkipsWhenNoUnbuffedAlliesAdjacent", + "rallyNeverBuffsThePlayersOwnCompanionHirelingOrThrall", + "footpadStealsThenFlees", "footpadWithNothingToStealStillFlees", + "slimeSplitsOnSurvivedHit", "splitCapsAtTwoGenerations", + "takeMonsterTurnPrefersAbilityOverMovement", "takeMonsterTurnFallsBackToAiWhenOnCooldown", + "abilityFieldsSurviveSerialization", + "statusEffectKillGrantsXpAndLogsDeath", + "hazardKillDuringMonsterTurnGrantsXp", "hazardStepRunsOncePerPlayerRound", + "saveLoadRoundTrip", "deterministicPlaythrough", "autoplayDescends", + "amuletSpawnsOnFinalFloorOnly", "descendIsBlockedAtTheFinalFloor", + "ascendWithoutAmuletDoesNotWin", "ascendWithAmuletWinsTheGame", "wonGameHaltsTheAutopilot", + "autopilotFetchesTheAmuletAndWins", + "shopPriceCatalogScalesWithPower", "shopStockRollsAffordableWares", + "goldPickupAddsToWalletNotInventory", + "affordableWareIsBoughtAutomaticallyOnPickup", "unaffordableWareStaysOnTheGround", + "deadKeeperMakesWaresFreeToLoot", "stealingFromShopTurnsTheKeeperHostile", + "hostileShopkeeperChasesLikeAnyMonster", "passiveShopkeeperIgnoresTheAdjacentPlayer", + "sellItemGrantsHalfPriceAndRemovesIt", "builderOccasionallySeedsAShopWithMatchingWares", + "shopAndLooseGoldStayOffTheShallowFloors", + "classApplyGrantsFighterStatBonuses", "classApplyGrantsRogueGoldAndAttack", + "classApplyGrantsMageManaAndAttack", "classApplyGrantsRangerBowAndAmmo", "invalidClassIsIgnored", + "skillLearnRespectsClassLevelAndPoints", "skillCooldownBlocksImmediateReuseAndTicksDown", + "fighterCleaveHitsPrimaryAndAdjacentMonsters", + "fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes", + "fighterSecondWindRestoresPortionOfMaxHp", + "fighterBerserkBoostsAttackAtLowHp", "rogueBackstabDoublesDamageOnFullHpTarget", + "rogueEvasionCanFullyNegateMeleeHit", "poisonBladeAppliesPoisonOnNextMeleeHitOnly", + "pendingPoisonBladeReadinessSurvivesAGameSaveLoadRoundTrip", + "mageOverloadBoostsMissileDamage", "mageMeditateRestoresManaWithinCap", + "mageManaShieldRaisesDefenseWhileManaHeld", "rangerMultishotHitsTwoTargetsForOneArrow", + "rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows", + "rangerCamouflageShrinksDetectionOnDarkFloors", "rangerTrueshotAddsFlatRangedDamage", + "effectiveRadiusBlindStatusOverridesLightAndWeatherToAMinimumOfOne", + "canSeePlayerBlindedMonsterOnlyDetectsThePlayerAtMeleeRange", + "autopilotLearnsSkillsAutomaticallyAsPointsAllow", "skillFieldsSurviveSerialization", + "craftingForgeSpendsEssenceForATierOneGem", "craftingMergeGemsCombinesMatchingPairsIntoTheNextTier", + "craftingSocketAddsPowerAndSwapsRiderOnResocket", "craftingTemperRaisesPowerAtRisingCostUpToACap", + "craftingTemperRefusesWithoutEnoughEssence", "craftingEnchantGrantsAChosenAffixOnce", + "craftingDisenchantStripsAffixAndRefundsEssence", "craftingSocketedGemRidersStackWithARolledAffixOnHit", + "craftingShockRiderActuallyStunsTheVictimOnItsNextTurnNotJustOnPaper", + "gameCraftingWrappersDriveTheFullPipeline", "monsterKillsGrantEssenceScaledToTheirXpReward", + "craftingFieldsSurviveSerialization", + "alchemyBrewResolvesAKnownPairToItsFixedPotion", "alchemyBrewIsOrderIndependent", + "alchemyBrewCurdlesOnAnOpposedPairing", "alchemyLookupRejectsAnUnknownHerb", + "gameBrewAtCombinesTwoHerbsIntoAPotionAtACauldron", "gameBrewAtFailsWithoutACauldronUnderfoot", + "gameBrewAtRequiresTwoHerbItems", "gameBrewAtCurdlesIntoGasInsteadOfAPotion", + "buildersScatterHerbsFromTheFirstFloor", "cauldronsStayOffTheEntranceFloor", + "buildersOccasionallyPlaceACauldronFromDepthTwo", "cauldronFieldsSurviveSerialization", + "cauldronRendersItsGlyphWhenVisible", + "nodesHarvestingAnOreVeinPaysEssenceAndSpendsACharge", + "nodesHarvestingAnOreVeinDoublesItsYieldDuringRichVeins", + "nodesHarvestingAGemSeamYieldsARawTierOneGemOfItsElement", + "nodesGoingFallowRefusesFurtherHarvestsUntilItReopens", + "nodesStepCountsDownAndReopensAFallowNode", "nodesFieldsSurviveSerialization", + "nodeRendersItsGlyphWhenVisible", + "gameHarvestNodeAtAnOreVeinAddsEssenceAndSpendsACharge", + "gameHarvestNodeFailsWithoutANodeUnderfoot", "gameHarvestNodeOnAFallowVeinFails", + "autopilotHarvestsAnOreVeinWhenIdleAndStandingOnIt", "autopilotSkipsAFallowNode", + "buildersNeverPlaceANodeOnTheEntranceFloor", "buildersAlwaysPlaceAtLeastOneNodeFromDepthTwoOn", + "corpsesSpawnsForAWildMonsterButNotForUndeadMimicOrBoss", + "corpsesNutritionScalesWithTheFelledSpeciesMaxHpClampedToAFixedBand", + "corpsesStepCountsDownAndRotsAwayEntirely", + "corpsesGoesRottenPastItsThresholdButStaysButcherableUntilThen", + "corpsesButcheringFreshMeatAddsFoodToInventoryAndConsumesTheCorpse", + "corpsesButcheringRottenMeatPoisonsThePlayerInsteadAndStillConsumesTheCorpse", + "corpseFieldsSurviveSerialization", "corpseRendersItsGlyphWhenVisible", + "gameAwardKillLeavesACorpseForAWildMonsterButNotForUndeadOrMimic", + "gameButcherCorpseAtFreshMeatFeedsThePlayerAndClearsTheCorpse", + "gameButcherCorpseFailsWithoutACorpseUnderfoot", + "autopilotButchersAFreshCorpseWhenIdleAndStandingOnIt", + "autopilotLeavesARottenCorpseAloneRatherThanRiskingPoison", + "hungerCurrentDefaultsToCapForAFreshEntity", + "hungerTierOfClassifiesEachBandAtItsBoundaries", + "hungerAttackAndDefensePenaltiesEscalateByTier", + "entityEffectiveAttackAndDefenseFoldInTheHungerPenalty", + "hungerSuppressesRegenOnlyAtWeakOrWorse", + "hungerTickDrainsByOneAndLogsOnlyOnATierCrossing", + "hungerTickBitesForOneHpEveryRoundWhileStarving", + "hungerEatCapsAtTheCeilingAndWastesAnOverfullMealIntoBloat", + "hungerEatBelowTheCeilingBanksNutritionWithNoWaste", + "hungerEatClampsAMealThatWouldOverflowPastTheCapWithoutBloating", + "gameHungerTorchTickSuppressesRingRegenOnceThePlayerIsWeak", + "gameUseItemEatingFoodRestoresSatiationAndLogsTheMeal", + "gameUseItemEatingFoodWhileAlreadyFullAppliesSlowAndLogsBloat", + "reanimationRaiseCreatesAThrallAdjacentToThePlayerAndSpendsTheCorpseAndSanity", + "reanimationRaiseRefusesARottenCorpseWithoutSpendingAnything", + "reanimationRaiseRefusesWithoutEnoughSanityAndLeavesTheCorpseAlone", + "reanimationRaiseScalesThrallStatsDownFromTheOriginSpecies", + "reanimationRaiseFallsBackToAPlainSkeletonForAnUnrecognisedSpecies", + "reanimationRaiseRetiresAnEarlierThrallWhenRaisingAnother", + "reanimationActAttacksAnAdjacentHostileMonsterAndCanKillIt", + "reanimationActAdvancesOnAVisibleMonsterItCannotYetReach", + "reanimationActIgnoresAMonsterBeyondItsSightRadius", + "reanimationActBlindedThrallOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius", + "reanimationActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander", + "reanimationActFollowsThePlayerWhenNoMonsterIsVisible", + "reanimationActConfusedThrallSometimesStumblesInsteadOfFightingAnAdjacentHostile", + "reanimationActCollapsesOnceItsBorrowedAnimationRunsOut", + "takeMonsterTurnRoutesAThrallThroughReanimationAct", + "gameRaiseCorpseSucceedsAndLogsTheCallAnswered", + "gameRaiseCorpseFailsWithoutACorpseUnderfoot", + "scavengingHerbForMapsEachWildFactionToItsOwnFixedHerb", + "scavengingHerbForReturnsNilForACorpseWithNoBestiaryEntry", + "gameScavengeCorpseCarvesTheMatchingHerbAndClearsTheCorpse", + "gameScavengeCorpseFailsWithoutACorpseUnderfoot", + "gameScavengeCorpseFailsOnARottenCorpse", } for _, name in ipairs(order) do From 6127e3c377db5b781b7e0950b9a1d4f14116e1ab Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 22:32:24 +0200 Subject: [PATCH 168/172] test: extend coverage of run --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 39d85e0..a682cc7 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14747,6 +14747,179 @@ local order = { "gameScavengeCorpseCarvesTheMatchingHerbAndClearsTheCorpse", "gameScavengeCorpseFailsWithoutACorpseUnderfoot", "gameScavengeCorpseFailsOnARottenCorpse", + "gameScavengeCorpseFailsForASpeciesWithNoKnownHerb", + "reanimationFieldsSurviveSerialization", + "herbItemsSurviveSerialization", + "potionOfFortificationAppliesAShield", "usingAFortifyPotionAppliesShieldAndConsumesIt", + "potionOfLevitationAppliesTheLevitateStatus", "usingAPotionOfLevitationAppliesLevitateAndConsumesIt", + "companionSummonPlacesItAdjacentToThePlayer", "companionSummonRejectsAnUnknownSpecies", + "companionSummonReplacesAnEarlierBond", "companionAttacksAdjacentHostileMonsterAndCanKillIt", + "companionAttacksADiagonallyAdjacentHostileMonsterJustLikeACardinalOne", + "companionAdvancesOnAVisibleMonsterItCannotYetReach", "companionIgnoresAMonsterBeyondItsSightRadius", + "companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius", + "companionActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander", + "companionFollowsThePlayerWhenNoMonsterIsVisible", + "companionActConfusedCompanionSometimesStumblesInsteadOfFightingAnAdjacentHostile", + "companionActConfusedCompanionNeverAttacksThePlayerEvenIfTheStumbleAimsAtItsCell", + "hearthSpriteMendsThePlayerWhenIdle", + "useItemOnACharmBondsACompanionAndConsumesIt", "takeMonsterTurnRoutesACompanionThroughCompanionsAct", + "gameTakeMonsterTurnTicksAnInfectedCompanionsDiseaseExactlyLikeThePlayersOwnEndOfTurnDoes", + "aFallenCompanionIsLoggedAndSweptWithoutAwardingXp", "builderOccasionallyPlacesACharmFromDepthThree", + "charmsStayOffTheShallowestFloors", "companionFieldsSurviveSerialization", + "companionGainXpBelowFirstThresholdBanksXpWithoutEvolving", + "companionGainXpAtFirstThresholdEvolvesRenamesAndGrowsStats", + "companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumulatively", + "companionGainXpNeverReappliesAnAlreadyCrossedStage", + "companionGainXpAtMaxLevelJustBanksFurtherXpWithNoFurtherEvolution", + "companionGainXpIgnoresAZeroOrNegativeAmount", + "companionActCreditsItsOwnKillWithXpOnTopOfThePlayers", + "companionEvolutionBoostsAHearthSpriteHealingOnTopOfItsStaticRate", + "companionLevelXpAndHealBonusSurviveSerialization", + "artifactsCreateBuildsTheCatalogedItemShape", "lifestealWeaponHealsThePlayerOnAHit", + "thornedArmorReflectsDamageBackAtAMeleeAttacker", "thornedArmorCanKillAWeakAttacker", + "thornedArmorRespectsAFireImmuneAttacker", "thornedArmorIsHalvedAgainstAFireResistantAttacker", + "ringOfTheTideRegeneratesHpEachTurn", "piercingBowIgnoresPartOfTargetDefense", + "builderOccasionallyPlacesAnArtifactFromDepthFive", "artifactsStayOffFloorsAboveDepthFive", + "artifactFieldsSurviveSerialization", + "useItemOnAKitPlantsAHiddenTrapOnThePlayersTile", "plantingATrapWhereOneAlreadyExistsFails", + "aMonsterSteppingOntoAPlayerSetTrapSpringsIt", + "checkTrapForSkipsAndLeavesUndetectedForALevitatingMonster", + "movePlayerSkipsTheInlineTrapTriggerForALevitatingPlayer", + "aLethalTrapKillsAWeakMonsterAndCreditsThePlayer", + "aTeleportTrapRelocatesTheMonsterThatSpringsIt", "builderOccasionallyPlacesATrapKitFromDepthTwo", + "kitsStayOffTheEntranceFloor", "plantedTrapFieldsSurviveSerialization", + "bossCreateBuildsAFreshUnenragedBoss", "bossAtFullHealthDoesNotEnrage", + "bossEnragesOncePastItsHalfHpThresholdAndOnlyOnce", "bossLootMatchesItsCatalogEntry", + "bossOnlyArtifactsAreExcludedFromTheRandomBuilderRoll", + "builderOccasionallyPlacesABossFromDepthSix", "bossesStayOffFloorsAboveDepthSix", + "takeMonsterTurnEnragesABloodiedBossBeforeItsAiActs", + "defeatingABossGrantsBossXpAndDropsItsGuaranteedArtifact", "bossFieldsSurviveSerialization", + "questGenerateProducesACullBounty", "questGenerateProducesAHoardBounty", + "questGenerateProducesADepthBountyClampedToMaxDepth", + "questAcceptBindsThePlayerAndClaimsTheBoard", "questAcceptFailsWhenAlreadyHoldingOne", + "questAcceptFailsOnAnAlreadyClaimedBoard", + "questRegisterKillIncrementsOnlyMatchingSpeciesWhileActive", + "questResolvedDetectsEachKindsCompletion", + "gameResolveQuestPaysOutAndClearsAFulfilledBounty", "gameResolveQuestIsANoOpOnAnUnmetBounty", + "gameTryAcceptQuestBindsAndLogsWhenStandingOnAnUnclaimedBoard", + "autopilotAcceptsAnUnclaimedBountyForFreeWithoutSkippingItsTurn", + "autopilotSkipsAnAlreadyClaimedBoard", "autopilotDoesNotStackASecondBountyWhileOneIsActive", + "questBoardRendersItsGlyphAndFlipsOnceClaimed", "questFieldsSurviveSerialization", + "builderOccasionallyPlacesAQuestBoardFromDepthTwo", "questBoardsStayOffTheEntranceFloor", + "metaFreshStateHasNoRenownRunsOrAchievements", + "metaRenownForScalesWithDepthBossesAndQuestsAndAddsAWinBonus", + "metaRecordRunBanksRenownAndAdvancesTallies", + "metaRecordRunUnlocksMatchingAchievementsOnlyOnce", + "metaPurchaseSpendsRenownAndMarksThePerkOwned", "metaPurchaseFailsWithoutEnoughRenown", + "metaPurchaseFailsForAnAlreadyOwnedPerk", "metaPurchaseFailsForAnUnknownPerk", + "metaApplyAllAppliesEveryOwnedPerkToAFreshPlayer", "metaAppliedPerksLandBeneathAClassDelta", + "metaLoadReturnsFreshStateForAMissingFile", "metaSaveLoadRoundTrip", + "gameAwardKillTracksKillsAndBossKillsForMetaStats", + "gameResolveQuestTracksQuestsCompletedForMetaStats", + "gamePickupTracksArtifactsFoundForMetaStats", "gameDescendTracksDeepestDepthEvenAfterAscending", + "gameMetaStatsFieldsSurviveSerialization", + "resistancesDefaultTierPassesDamageThroughUnchanged", "resistancesResistHalvesAndFloors", + "resistancesWeakAmplifies", "resistancesImmuneZeroesEvenAHugeHit", + "resistancesEquipmentGrantsATierOnTopOfABaseProfile", + "resistancesInnateWeaknessIsMaskedByAResistantItem", + "combatDefenderResistanceHalvesTheFinalBlow", + "combatWeaponElementOverridesPhysicalAndCanBeFullyImmune", + "statusPoisonAndBurningTicksRespectResistance", + "statusPoisonShrugAndRegenKnitMessagesAddressThePlayerInSecondPerson", + "hazardsAfflictAppliesTargetResistance", + "spellMissileAndFireballRespectTargetResistance", "throwingAndRangedDamageRespectResistance", + "equippingCinderplateAndFrostbandGrantsTheirResist", + "disenchantingAnElementalAffixClearsItsDamageType", + "bossBreathDamageTypeMatchesTheMonsterAndCanBeShruggedOff", + "resistFieldsSurviveEntitySerialization", + "factionsTierBoundariesReflectReputationScore", + "factionsAdjustAccumulatesAndClampsToTheHundredBounds", + "factionsRegisterKillSoursOnlyTheFallensFaction", + "factionsRegisterKillPenalizesABossFarMoreThanARegularKill", + "factionsRegisterKillIgnoresAFactionlessTarget", + "factionsPriceMultiplierScalesFromHostileToHonored", + "factionsIsWaryTrueForAFreshGoblinoidAndFalseOnceProvoked", + "factionsIsWaryIsFalseForNonTribalFactionsAndOnceTheTribeGoesHostile", + "factionsAppeaseSpendsGoldAndRaisesReputationOrFailsWithoutFunds", + "factionsAppeaseCostsMoreTheDeeperTheGrudge", + "factionsAppeaseFailsOnceAFactionIsAlreadyMaxedOut", + "factionAndProvokedFieldsSurviveEntitySerialization", + "gameTakeMonsterTurnLeavesAWaryGoblinoidAloneUntilProvoked", + "gamePlayerAttackProvokesAWaryGoblinoidSoItFightsBackNextTurn", + "gameAwardKillSoursFactionReputationWithTheFallensFaction", + "shopBuyIsMarkedUpWhenMerchantsAreHostile", "shopBuyIsDiscountedWhenMerchantsAreHonored", + "shopSellValueRisesWithMerchantStanding", "stealingFromShopAlsoSoursMerchantReputation", + "resolveQuestNudgesMerchantReputationUpward", "gameAppeaseFactionSpendsGoldAndLogsTheOutcome", + "playerReputationSurvivesGameSaveLoadRoundTrip", + "encumbranceItemWeightScalesWithGearPower", "encumbranceAmmoWeightScalesWithStackCount", + "encumbranceCapacityGrowsWithLevel", "encumbranceEquippedGearCountsTowardCarriedWeight", + "encumbranceLightLoadHasNoPenalty", "encumbranceBurdenedSlowsMovementButNotDefense", + "encumbranceOverloadedSlowsMovementAndWeakensDefense", + "gameDropItemReturnsItemToTheFloorAndFreesCapacity", + "gamePickupWarnsWhenNewWeightTipsIntoOverloaded", + "gamePickupWarnsWithALighterMessageWhenOnlyTippingIntoBurdened", + "autopilotDropsCheapestJunkWhenOverloadedAndSafe", + "encumbranceTierIsDerivedAcrossASaveLoadRoundTrip", + "renderPanelShowsCarriedLoad", + "tilesClassifyVolatileTerrain", + "terrainAfflictLavaDoesNothingOffLava", "terrainLavaRespectsFireResistanceAndImmunity", + "terrainAfflictLavaUnharmedMessageAddressesThePlayerInSecondPerson", + "terrainLavaBurnsAndIgnitesAPlayerWhoStepsOnIt", "terrainLavaCanKillAWeakMonsterAndCreditsThePlayer", + "terrainChasmDropsThePlayerToTheFloorBelowWithFallDamage", + "terrainChasmSwallowsAMonsterAndCreditsTheKill", + "terrainIceSlidesATravellerUntilSomethingStopsIt", "terrainIceSlideStopsBeforeAWall", + "terrainIceSlideStopsAtAnOccupiedCell", + "checkTerrainForSkipsLavaDamageAndIgnitionForALevitatingPlayer", + "checkTerrainForSkipsTheChasmFallForALevitatingPlayer", + "checkTerrainForSkipsTheIceSlideForALevitatingPlayer", + "checkTerrainForSkipsLavaForALevitatingMonsterToo", + "terrainGlyphsSurviveMapSerialization", + "chasmFallFieldsSurviveGameSaveLoad", "lavaChasmAndIceStayOffTheShallowFloors", + "builderOccasionallyPlacesLavaChasmAndIceFromDepthFour", "chasmNeverAppearsOnTheFinalFloor", + "eliteNeverPromotesBelowMinDepth", "eliteEligibleExcludesBossesAndTheShopkeeper", + "eliteArmoredAffixAddsFlatDefenseAndPrefixesTheName", + "eliteChampionTierAtDeepDepthRollsTwoDistinctAffixes", "eliteFieldsSurviveSerialization", + "monstersCreateNeverAliasesTheSpeciesCatalogsInnateResistTable", + "builderOccasionallyPromotesAMonsterToEliteFromDepthThree", + "elitesNeverAppearOnTheShallowestTwoFloors", + "eliteVampiricMonsterDrainsHpFromThePlayerOnAMeleeHit", + "eliteChillingMonsterSlowsThePlayerOnAMeleeHit", + "eliteVolatileMonsterExplodesOnDeathDamagingAnAdjacentPlayer", + "eliteVolatileMonsterDoesNotBurstAnythingBeyondItsBlastRadius", + "eliteKillGrantsBonusXpOverThePlainSpeciesReward", + "vaultsPullThrowsALeverAndOpensASingleLeverVaultImmediately", + "vaultsPullOpensTheDoorOnlyOnceEveryLeverIsThrown", + "vaultsTreasureRollsAGuaranteedAffixOnABaseStrongerThanAnyFloorDrop", + "gamePullLeverOpensASingleLeverVaultAndTracksTheStat", + "gamePullLeverOnATwoLeverVaultWaitsForTheSecondPull", + "pullingAnAlreadyThrownLeverAgainDoesNotInflateTheVaultTally", + "movePlayerAutoPullsALeverItStepsOnto", + "vaultDoorBlocksMovementUntilItsLeversOpenIt", + "builderOccasionallyPlacesAVaultFromDepthFive", + "vaultsStayOffFloorsAboveDepthFive", + "builderOnlyEverPlacesASolvableVaultPuzzle", + "vaultFieldsSurviveSerialization", + "gameVaultsOpenedStatSurvivesSaveLoad", + "metaRenownForScalesWithVaultsOpened", + "metaRecordRunUnlocksVaultBreakerOnceAVaultOpens", + "runewordsSocketAppendsToTheChainInOrderAndCapsAtMaxRunes", + "runewordsSocketRefusesAnythingThatIsNotWeaponOrArmor", + "runewordsActiveWordMatchesOnlyAnExactChain", + "runewordsAttackAndDefenseBonusReadTheWielderSGearLive", + "gameSocketRuneAppliesItsRunewordBonusInActualCombat", + "gameSocketRuneFailsOnAnUnknownTargetOrANonRuneItem", + "builderOccasionallyPlacesARuneFromDepthThree", + "runesStayOffTheShallowestTwoFloors", + "runeChainFieldsSurviveSerialization", + "atmosphereHooksAreZeroForAnyConditionTheyDoNotGovern", + "atmosphereFoggyShrinksSightRadiusButNeverBelowOne", + "atmosphereConsecratedRegeneratesThePlayerEachTurn", + "atmosphereConsecratedNeverRegeneratesPastMaxHp", + "builderOccasionallyDrawsEachWeatherConditionFromDepthTwo", + "weatherNeverAppearsOnTheEntranceFloor", + "weatherFieldSurvivesSerialization", + "renderPanelReportsTheFloorSWeatherWhenPresent", + "biomesForDepthCyclesThroughTheCatalogInOrder", "biomesDescribeNamesTheBiomeAndItsAmbientText", } for _, name in ipairs(order) do From e6fc26f2533fd4c2d78d52bb14644535ac4fcfc1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:49:43 +0200 Subject: [PATCH 169/172] test: add more cases for run --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index a682cc7..7af4285 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14920,6 +14920,179 @@ local order = { "weatherFieldSurvivesSerialization", "renderPanelReportsTheFloorSWeatherWhenPresent", "biomesForDepthCyclesThroughTheCatalogInOrder", "biomesDescribeNamesTheBiomeAndItsAmbientText", + "biomesCatalogEveryEntryNamesARealFactionAndHazardKind", + "biomesNativesAmongFiltersToTheFavoredFactionAndExcludesRangedSpecs", + "builderLevelsCarryTheBiomeMatchingTheirDepthRegardlessOfSeed", + "builderOccasionallyReinforcesAFloorWithADormantNativeMonster", + "builderOccasionallyBloomsAHazardMatchingTheFloorSBiome", + "biomeFieldSurvivesSerialization", + "stealthHandBuiltMonsterDefaultsToAlertWhenAwarenessIsUnset", + "builderPlacesWildDepthOneMonstersAsleepByDefault", + "stealthBossesStartFullyAlertRatherThanDormant", + "stealthSpawnedMinionsStartHuntingRatherThanDormant", + "stealthDormantMonsterWakesImmediatelyWhenThePlayerStandsNextToIt", + "stealthDormantMonsterBeyondItsWakeRadiusNeverStirs", + "stealthDormantMonsterWithinRangeEventuallyWakesOnASuccessfulRoll", + "stealthMeleeAttackOnADormantMonsterDealsATripledSneakBonus", + "stealthHuntingMonsterLosesTrackAndSwitchesToSearching", + "stealthSearchingMonsterReacquiresThePlayerAndReturnsToHunting", + "stealthSearchingMonsterGivesUpAndReturnsToDormant", + "stealthAwarenessFieldsSurviveASaveLoadRoundTrip", + "renderPanelFlagsSleepingFoesSeparatelyFromAwakeOnes", + "compendiumNoteSightingReturnsTrueOnlyOnTheFirstSighting", + "compendiumNoteKillAccumulatesAcrossMultipleKills", + "compendiumEntriesListsEveryWildSpeciesAndEveryBoss", + "gameRecordsASightingTheFirstTimeAMonsterBecomesVisible", + "gameRecordsAKillIntoItsCompendiumOnAwardKill", + "compendiumFieldsSurviveAGameSaveLoadRoundTrip", + "gameReusesThePassedMetaSCompendiumByReference", + "setsCreateBuildsTheCatalogedItemShape", + "setsAttackAndDefenseBonusEscalateAsMorePiecesAreEquipped", + "setsBonusDropsBackWhenAPieceIsUnequipped", + "setsDoNotCrossTalkBetweenDifferentSets", + "gameEquippingASetPieceAppliesItsBonusInActualCombat", + "gameEquippingTheSecondSetPieceLogsTheSetAwakening", + "setsRegenerationAppliesEachTurnAtTheFullIroncladSet", + "builderOccasionallyPlacesASetPieceFromDepthThree", + "setPiecesStayOffTheShallowestTwoFloors", + "setFieldsSurviveSerialization", + "gamePickupTracksSetPiecesFoundForMetaStats", + "metaRenownForScalesWithSetPiecesFound", + "metaRecordRunUnlocksSetCollectorOnceThreePiecesFound", + "renderPanelShowsTheActiveSetBonusWhenOneApplies", + "mimicsFloorItemBuildsADisguisedLootShape", + "mimicsRevealBuildsAHostileMonsterScaledByDepth", + "gamePickupHereRevealsAMimicAndAmbushesThePlayer", + "gameKillingARevealedMimicGrantsItsFixedEssenceReward", + "builderOccasionallyPlacesAMimicFromDepthTwo", + "mimicsNeverAppearOnTheEntranceFloor", + "mimicFieldSurvivesSerialization", + "cursesRollRespectsItsOneInEightChanceAcrossSeedsAndKnownSlots", + "cursesAfflictFoldsThePenaltyIntoTheItemAndKeepsItHidden", + "cursesAfflictNeverDropsPowerOrRangeBelowItsFloor", + "cursesIsCursedAndIsRevealedReadEmptyOrCleanGearSafely", + "cursesIsBoundInChecksASpecificEquippedSlot", + "cursesTryReleaseRevealsAndRefusesABoundSlotButPassesACleanOne", + "cursesCleanseUndoesThePenaltyAndClearsEveryField", + "cursesCleanseWornLiftsEveryCursedSlotAndCountsThem", + "cursesRevealFirstUnknownFindsEarliestSlotAndAdvances", + "cursesDrainAmountOnlyReadsACursedRing", + "cursesLabelHidesUntilRevealedThenPrefixes", + "gameEquippingOverABoundCursedWeaponFailsAndRevealsTheCurse", + "gameEquippingAFreshCurseHidesItUntilYouTryToTakeItOff", + "gameRemoveCurseScrollLiftsEveryCurseCurrentlyWorn", + "gameHungerTorchTickBleedsHpFromACursedRingEachTurn", + "shrinePrayFortuneBlessingAlsoLiftsAnyCurseCurrentlyWorn", + "renderPanelPrefixesCursedGearOnceRevealedButNotBefore", + "builderOccasionallyPlacesACursedPieceOfGear", + "builderNeverCursesAnItemThatAlreadyHasABeneficialAffix", + "cursedFieldsSurviveSerialization", + "momentumStacksReadsZeroForAFreshEntity", + "momentumOnMeleeHitBuildsAndCapsAtItsMaxStacks", + "momentumOnDamagedSnapsTheStackBackToZero", + "momentumAttackBonusScalesLinearlyWithStacks", + "entityEffectiveAttackFoldsInMomentumAlongsideStatusModifiers", + "gamePlayerAttackBuildsMomentumAndItReadsLiveInEffectiveAttack", + "gameMonsterMeleeSnapsMomentumBackToZero", + "gameEvadedMonsterAttackNeverBreaksMomentum", + "gameMimicAmbushAlsoBreaksMomentum", + "renderPanelShowsComboOnlyOnceItIsBuilt", + "momentumFieldSurvivesSerialization", + "conductivityIsWetReadsTheEntitysOwnWetField", + "conductivityTickSoaksOnWaterAndDecaysOneStepAtATimeOffIt", + "conductivityTickReSoaksImmediatelyOnSteppingBackOntoWater", + "conductivityApplyFireDampensDamageOnlyOnceSoaked", + "conductivityApplyLightningBonusesDamageOnlyOnceSoaked", + "conductivityNeverConjuresDamageOutOfAnImmuneResistance", + "conductivityResistsIgnitionOnlyWhileWet", + "statusApplyRefusesBurningOnlyWhileSoakedButOtherEffectsStillApply", + "statusRemoveStripsANamedStatusRegardlessOfItsRemainingDuration", + "statusCureHostileLiftsEveryDebuffButLeavesBuffsUntouched", + "hazardsAfflictDampensFireAndBlocksIgnitionForASoakedVictim", + "gameConductivityTicksAMonsterEachTurnBasedOnTheTileUnderfoot", + "gameConductivityTicksThePlayerEachAutoStepBasedOnTheTileUnderfoot", + "spellShockBoltDealsMoreDamageToASoakedTargetThanADryOne", + "renderPanelShowsWetOnlyWhileSoaked", + "builderCanRollATomeOfTheShockBoltSpell", + "entityWetFieldSurvivesSerialization", + "diggingCanDigAllowsOnlyWallsAndRubble", + "diggingSwingClearsAWallToRubbleOnAnOrdinarySwing", + "diggingSwingClearsRubbleToFloorOnASecondSwing", + "diggingSwingCaveInHurtsTheDiggerAndLeavesTheTileUntouched", + "diggingSwingOreStrikeClearsTheWallOutrightAndPaysEssenceToATrackingDigger", + "diggingSwingOreStrikeSkipsTheEssenceRewardForADiggerThatDoesNotTrackIt", + "diggingWearPickDepletesDurabilityAndBreaksAtZero", + "diggingWearPickIsANoOpWithoutAPickEquipped", + "diggingDirectionsTowardPrefersTheLongerAxisFirst", + "diggingDirectionsTowardOmitsAZeroDelta", + "itemsEquipPickSwapsAnyPriorPickBackIntoInventory", + "shopPicksHaveAPositiveCatalogPrice", + "gameDigFailsWithoutAPickEquipped", + "gameDigFailsWhenNothingAheadIsDiggable", + "gameDigClearsAWallAndWearsThePickDown", + "gameDigBreaksThePickOnItsLastUse", + "gameMonsterTunnelsThroughAWallToReachAndAttackThePlayer", + "pickFieldSurvivesSerialization", + "builderNeverPlacesAPickBeforeDepthTwo", + "builderOccasionallyPlacesAPickFromDepthTwo", + "sanityCurrentDefaultsToMaxForAFreshEntity", + "sanityTierOfClassifiesEachBandAtItsBoundaries", + "sanityAdjustClampsToTheZeroAndMaxBounds", + "sanityAttackAndDefensePenaltiesEscalateByTier", + "entityEffectiveAttackAndDefenseFoldInTheSanityPenalty", + "sanityDarknessDreadFiresOnlyOnADarkFloorWithASpentTorch", + "sanityCorpseDreadCountsOnlyCorpsesAdjacentToThePlayer", + "sanityCorpseDreadCapsAtItsFixedCeiling", + "sanityUndeadDreadCountsOnlyVisibleUndeadAndCapsAtItsCeiling", + "sanityCurseDreadFiresOnlyForARevealedCurse", + "sanityWeatherDreadFiresOnlyForInfestedWeather", + "sanityTotalDreadSumsEverySourceAtOnce", + "sanityTickAppliesDreadAndLogsOnlyOnATierCrossing", + "sanityTickAnnouncesReachingTerrifiedAndBroken", + "sanityTickRecoversAndAnnouncesClimbingBackOutOfBroken", + "sanityTickAnnouncesFullyRegainingComposure", + "sanityTickNeverClimbsPastTheMaxOrReportsAPhantomCrossing", + "sanityRollPanicNeverFiresAboveBroken", + "sanityRollPanicRollsAgainstItsFixedChanceOnceBroken", + "sanityRollScreamNeverFiresAboveTerrified", + "sanityRollScreamAlertsEveryDormantMonsterWithinItsRadius", + "sanityFieldSurvivesSerialization", + "gameEndPlayerTurnTicksSanityAndTheLogReportsATierCrossing", + "gameAutoStepSkipsThePlayerTurnWhenPanicFires", + "gameUseItemPotionOfClarityRestoresSanity", + "renderPanelShowsSanityOnlyOnceItDropsBelowComposed", + "renderPanelShowsTheDiseaseStageOnceThePlayerIsInfectedAndNothingBeforeInfection", + "builderOccasionallyRollsAPotionOfClarityAsLoot", + "builderOccasionallyRollsAPotionOfConfusionAsLoot", + "builderOccasionallyRollsAPotionOfBlindnessAsLoot", + "builderOccasionallyRollsAPotionOfLevitationAsLoot", + "eventsCatalogEntriesEachHaveDistinctStartAndEndFlavorText", + "eventsMonsterDamageMultiplierOnlyFiresDuringBloodmoon", + "eventsEssenceMultiplierOnlyFiresDuringRichVeins", + "eventsSightPenaltyOnlyFiresDuringHush", + "eventsStepNeverStartsOnTheFirstFloorRegardlessOfTheRoll", + "eventsStepEventuallyStartsFromDepthTwoAcrossAHandfulOfSeeds", + "eventsStepCountsDownAndExpiresWithItsEndLine", + "gameMonsterMeleeDamageScalesDuringBloodmoon", + "gameMonsterShootDamageScalesDuringBloodmoon", + "gameKillEssenceIsDoubledDuringRichVeins", + "gameEffectiveRadiusShrinksDuringHush", + "gameEndPlayerTurnEventuallyRollsAFreshEventFromDepthTwo", + "gameEndPlayerTurnAnnouncesAnEventEndingInTheLog", + "renderPanelShowsTheActiveEventAndItsRemainingTurns", + "eventFieldSurvivesSerialization", + "gravesRecordAppendsNewestFirstAndTrimsToMaxRecorded", + "gravesEligibleForOnlyIncludesGravesAtOrDeeperThanTheQueriedDepth", + "gravesFloorItemBuildsAnInertGraveMarkerShape", + "gravesRevealScalesHpAndAttackWithCarriedWealthAndCaps", + "gravesLootForReturnsHalfGoldAndAThirdEssenceRoundedDown", + "graveFieldsSurviveSerialization", + "gamePickupHereWakesAGraveShadeAndAmbushesThePlayer", + "gameKillingARevealedShadeGrantsItsFixedXpAndPaysOutGraveLoot", + "gravesShadeLeavesNoCorpseLikeAnyOtherUndead", + "gameGraveEntryCapturesDepthGoldEssenceAndClassAtTheMomentOfDeath", + "gameGraveEntryDefaultsToWandererForAClasslessRun", + "builderNeverPlacesAGraveWhenOptionsCarriesNone", } for _, name in ipairs(order) do From a22754c83ad06f4c8bb6e3cba33c18bd3a9a92b9 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:50:10 +0200 Subject: [PATCH 170/172] test: strengthen tests for run --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 7af4285..88a5c92 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -15093,6 +15093,179 @@ local order = { "gameGraveEntryCapturesDepthGoldEssenceAndClassAtTheMomentOfDeath", "gameGraveEntryDefaultsToWandererForAClasslessRun", "builderNeverPlacesAGraveWhenOptionsCarriesNone", + "buildersOnlyRollAGraveOntoAFloorAtOrShallowerThanWhereItWasDug", + "buildersOccasionallyPlaceAnEligibleGraveFromSuppliedOptions", + "buildersInsertAGraveWithoutDisturbingAnyOtherPlacement", + "metaGravesFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip", + "proficiencyKeyForReadsFistsWhenUnarmedAndTheWeaponsNameOnceEquipped", + "proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing", + "proficiencyAttackBonusReadsTheHighestRankCrossedForTheCurrentKeyAlone", + "entityEffectiveAttackFoldsInProficiencyAlongsideMomentumAndSanity", + "gamePlayerAttackAccumulatesProficiencyAndLogsOnlyOnARankCrossing", + "renderPanelShowsMasteryOnlyOnceARankIsCrossed", + "proficiencyFieldSurvivesSerialization", + "proficiencyOnHitWithAndBonusForTrackAnExplicitKeyIndependentOfKeyFor", + "gameFireAtAccumulatesBowSpecificProficiencySeparateFromMelee", + "ascensionCatalogEntriesHaveUniqueKeysAndAtLeastOneEffectHook", + "ascensionIsUnlockedRequiresMeetingBothDepthAndWinRequirements", + "ascensionIsUnlockedWithNoMetaLocksEveryCatalogedMutator", + "ascensionResolveDropsUnknownAndStillLockedKeys", + "ascensionResolveDeduplicatesAndReordersToCatalogOrder", + "ascensionResolveWithNoRequestReturnsEmpty", + "ascensionIsActiveTrueOnlyForKeysInTheActiveList", + "ascensionDamageMultiplierMultipliesAcrossActiveMutators", + "ascensionEssenceMultiplierAppliesFamineAlone", + "ascensionSightPenaltySumsAcrossActiveMutators", + "ascensionRenownBonusPercentSumsRatherThanCompounds", + "ascensionApplyToPlayerHalvesMaxHpAndDoublesAttackForGlassCannon", + "ascensionApplyToPlayerHalvesDefenseForFrail", + "gameNewResolvesRequestedMutatorsAgainstMetaAndAppliesTheirDeltas", + "gameMonsterMeleeDamageStacksBloodboundWithBloodmoonMultiplicatively", + "gameKillEssenceStacksFamineWithRichVeinsMultiplicatively", + "gameEffectiveRadiusStacksNightblindWithHush", + "metaRenownForAddsTheAscensionBonusOnTopOfTheBaseScore", + "ascensionFieldSurvivesASaveLoadRoundTrip", + "renderPanelShowsActiveAscensionMutatorsByKey", + "durabilityFreshWeaponStartsAtMaxAndIsNotBroken", + "durabilityWearWeaponDecrementsAndBreaksAtZeroSubtractingItsPower", + "durabilityWearWeaponIsANoOpOnceAlreadyBroken", + "durabilityWearArmorBreaksAndSubtractsItsPowerFromDefense", + "durabilityWearBowBreaksWithoutTouchingAnyStatDirectly", + "durabilityEffectivePowerIsZeroForABrokenItemAndFullOtherwise", + "durabilityRepairFailsWithoutEnoughGoldOrOnAPristineItem", + "durabilityRepairRestoresFullDurabilityAndFoldsPowerBackInAfterABreak", + "durabilityRepairOnANotYetBrokenItemJustTopsUpDurabilityWithoutTouchingStats", + "equipWeaponAddsOnlyEffectivePowerForAnAlreadyBrokenWeapon", + "rangedFireDealsNoBowBonusOnceTheBowIsBroken", + "gamePlayerAttackWearsTheEquippedWeaponAndLogsWhenItBreaks", + "gameMonsterMeleeWearsThePlayersArmorAndLogsWhenItBreaks", + "gameFireAtWearsTheEquippedBowAndLogsWhenItBreaks", + "gameBrokenEquippedSlotFindsTheFirstBrokenPieceInPriorityOrder", + "gameRepairEquippedSpendsGoldAndLogsSuccess", + "gameRepairEquippedFailsAndLogsWhenGoldIsShort", + "autopilotRepairsBrokenGearAutomaticallyWhenAffordable", + "durabilityFieldsSurviveSerialization", + "renderPanelShowsWornGearOnlyOnceItHasTakenDamage", + "disarmSweepDetectsAHiddenTrapWithinOneStepEventuallyAcrossAHandfulOfSeeds", + "disarmSweepNeverDetectsATrapTwoStepsAway", + "disarmSweepNeverRedetectsAnAlreadyDetectedOrRevealedTrap", + "disarmOddsForClimbWithLevelButNeverReachTheDenominator", + "disarmAttemptOnSuccessRemovesTheTrapAndPaysSalvage", + "disarmAttemptOnFailureSpringsTheTrapOnThePlayer", + "gameDisarmTrapRefusesAnUndetectedOrAlreadySprungTrap", + "gameDisarmTrapSucceedsOrSpringsExactlyLikeDisarmAttempt", + "gameEndPlayerTurnEventuallyDetectsAnAdjacentTrapAcrossAHandfulOfSeeds", + "trapDetectedFieldSurvivesSerialization", + "secretsSweepDiscoversAnAdjacentHiddenDoorEventuallyAcrossAHandfulOfSeeds", + "secretsSweepNeverDiscoversADoorTwoStepsAway", + "secretsSweepNeverRediscoversAnAlreadyDiscoveredDoor", + "secretsSweepOpensTheWallIntoAPlainClosedDoorOnDiscovery", + "builderOccasionallyPlacesASecretDoorGuardingAGenuinelyUnreachableAlcove", + "builderNeverPlacesASecretDoorAboveDepthTwo", + "gameEndPlayerTurnEventuallyDiscoversAnAdjacentSecretDoorAcrossAHandfulOfSeeds", + "secretDoorFieldsSurviveSerialization", + "chestsNewLootAlwaysIncludesGoldAndExactlyOneGearPiece", + "chestsNewNeverRollsTrappedOnAnUnlockedChest", + "chestsIsLockedIsTrueForALockedUnopenedChestAndFalseOnceOpened", + "chestsOpenGrantsEveryLootItemAndEmptiesTheChest", + "chestsOpenRefusesAnAlreadyEmptiedChest", + "chestsOpenRefusesALockedChestAndLeavesItsLootUntouched", + "chestsUnlockWithKeySpendsExactlyOneKeyAndGrantsTheHoard", + "chestsUnlockWithKeyFailsWithoutAKeyAndLeavesTheChestLocked", + "chestsUnlockWithKeyRefusesAChestThatIsNotLocked", + "chestsPickFailsImmediatelyWithoutALockpickAndSpendsNothing", + "chestsPickOnSuccessOpensTheChestAndGrantsTheHoard", + "chestsPickAlwaysSpendsTheLockpickWhetherItWinsOrLoses", + "chestsPickOnFailureAgainstATrappedChestSpringsItAndLeavesTheLockStanding", + "chestsPickOnFailureAgainstAnUntrappedChestJustWastesThePick", + "chestsOddsForClimbsWithLevelButNeverReachesTheDenominator", + "gameOpenChestOpensAnUnlockedChestOnSightAndCountsIt", + "gameOpenChestUnlocksWithACarriedKeyBeforeEverTryingALockpick", + "gameOpenChestFallsBackToALockpickWhenNoKeyIsCarried", + "gameOpenChestRefusesWhenNothingIsHereOrItIsAlreadyEmpty", + "chestsFieldsSurviveSerialization", + "renderShowsAClosedChestGlyphAndAnOpenedOneOnceEmptied", + "autopilotOpensAnUnlockedChestOnSightButSkipsALockedOneWithNoKeyOrPick", + "mercenaryHireTakesOnAKnownRosterEntryAdjacentToThePlayer", "mercenaryHireRejectsAnUnknownRosterKey", + "mercenaryHireReplacesAnEarlierHireling", "mercenaryAttacksAdjacentHostileMonsterAndCanKillIt", + "mercenaryAdvancesOnAVisibleMonsterItCannotYetReach", "mercenaryIgnoresAMonsterBeyondItsSightRadius", + "mercenaryActBlindedHirelingOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius", + "mercenaryActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander", + "mercenaryFollowsThePlayerWhenNoMonsterIsVisible", + "mercenaryActConfusedHirelingSometimesStumblesInsteadOfFightingAnAdjacentHostile", + "fieldMedicMendsThePlayerWhenIdle", + "mercenaryStopsChasingAndFollowingOnceSulking", "mercenaryStillFightsAnAdjacentHostileEvenWhileSulking", + "mercenaryPayWagePaysWhenAffordableAndRestoresLoyalty", "mercenaryPayWageShortsAndDropsLoyaltyWhenGoldIsShort", + "mercenaryPayWageDesertsOnceLoyaltyBottomsOut", "mercenaryPayWageIsANoOpWithNoHirelingOnTheLevel", + "mercenaryDismissReleasesTheHirelingWithNoPenalty", "mercenaryDismissFailsWithNoOneUnderContract", + "useItemOnAContractHiresTheNamedMercenaryAndConsumesIt", + "useItemOnAContractWithAnUnknownRosterKeyFailsWithoutConsumingIt", + "takeMonsterTurnRoutesAHirelingThroughMercenariesAct", "aFallenHirelingIsLoggedAndSweptWithoutAwardingXp", + "gameEndPlayerTurnPaysTheHirelingWageOnceTheIntervalElapses", "mercenaryFieldsSurviveSerialization", + "shopPriceOfAContractMatchesItsRosterHireCost", "shopStockCanRollAContractWithAPositiveCatalogPrice", + "autopilotHiresACarriedContractWhenNoHirelingIsYetUnderContract", + "morgueScoreWeighsEveryStatByItsOwnFixedWeight", "morgueScoreAddsTheWinBonusOnlyWhenWon", + "morgueEquipmentListsOnlyOccupiedSlotsInFixedOrder", "morgueEquipmentPrefixesARevealedCurseButNotAHiddenOne", + "morgueKillTallySortsByCountThenByCatalogOrderOnTies", "morgueBuildMirrorsGameStatsPlayerAndCompendium", + "morgueFormatRendersOutcomeGearKillsAndScoreInOrder", "morgueFormatNamesTheWinOutcomeWhenWon", + "morgueFormatOmitsEquipmentAndKillsSectionsWhenEmpty", + "leaderboardRecordOnAnEmptyBoardLandsAtRankOne", "leaderboardRecordKeepsTheBoardSortedBestScoreFirst", + "leaderboardRecordDropsAnEntryThatDoesNotCrackTheCap", "leaderboardRecordBumpsTheWeakestEntryOffOnceFull", + "leaderboardTopReturnsAtMostCountEntriesInOrder", "leaderboardTopDefaultsToTheFullCapacityWhenNoCountIsGiven", + "leaderboardBestScoreReadsTheTopEntryOrZeroWhenEmpty", + "metaLeaderboardFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip", + "replayRunIsDeterministicAndMatchesAFreshGamesOwnReport", "replayVerifyConfirmsAGenuineReportAndReturnsItsScore", + "replayVerifyFlagsATamperedReportAndStillReturnsTheRealScore", + "replayComparedFieldsCoversEveryFieldMorgueScoreActuallyReads", + "replayMatchesReportsTheFirstDivergingFieldInComparedFieldsOrder", + "challengesForDaySameDayAlwaysReturnsTheSamePresetAndSeed", "challengesForDayCyclesThroughTheCatalogInOrder", + "challengesSeedForStaysWithinTheDefaultSeedRange", "challengesCatalogPresetsReferenceOnlyRealClassesAndMutators", + "challengesDescribeNamesTheClassAndEveryRequiredMutator", "challengesDescribeOmitsTheMutatorJoinWhenNoneAreRequired", + "challengesSatisfiedByFailsOnAClassMismatch", "challengesSatisfiedBySucceedsWithTheRightClassAndNoMutatorsRequired", + "challengesSatisfiedByFailsWhenARequiredMutatorNeverActivated", + "challengesSatisfiedBySucceedsOnceItsRequiredMutatorIsActuallyActive", + "analyticsWinRateIsZeroWithNoRecordedRunsAndFractionalOtherwise", + "analyticsAverageScoreIsZeroOnAnEmptyBoardAndTheMeanOtherwise", + "analyticsFavoriteClassPicksTheMostFrequentTiesBrokenByFirstAppearance", + "analyticsPercentileRanksAScoreAgainstTheBoardItWouldJoin", + "titlesForRenownNeverReturnsNilForAnyNonNegativeRenown", + "titlesForRenownWalksTheLadderFromTheBottomUp", "titlesToNextCountsDownToTheNextThresholdAndNilsOutAtTheTop", + "morgueBuildOmitsTitleWithoutMetaAndIncludesItWithMeta", + "exportLeaderboardCsvOnAnEmptyBoardIsJustTheHeaderRow", "exportLeaderboardCsvEmitsOneRowPerEntryInRankOrder", + "exportLeaderboardCsvRendersBooleansAsLowercaseWords", "exportCompendiumCsvListsEverySpeciesSeenOrNot", + "streaksCurrentIsZeroAndNilOnAnEmptyHistory", "streaksCurrentCountsOnlyTheUnbrokenRunAtTheEnd", + "streaksRecordTrimsHistoryPastItsCap", "streaksRecordDropsTheOldestEntryNotTheNewestOnceOverCap", + "streaksBestWinStreakFindsTheLongestWinRunAnywhereNotJustTheActiveOne", + "streaksBestWinStreakIsZeroWithNoWinsAtAll", "metaRecordRunFeedsStreaksAndRunHistorySurvivesASaveLoadRoundTrip", + "metaRecordRunUnlocksComebackOnlyAfterALossThenAWin", + "metaRecordRunDoesNotUnlockComebackOnAFirstEverWinOrAWinAfterAWin", + "nemesisIsEligibleRequiresAHuntingHostileAndExcludesEverySpecialCase", + "nemesisPickHunterReturnsTheNearestEligibleHunterBreakingTiesByListOrder", + "nemesisBeginFromCapturesSpeciesAndHpFractionAtGrudgeOne", + "nemesisEscalateRaisesGrudgeAndHealsTowardFullBothCapped", + "nemesisSpawnScalesHpAndAttackWithGrudgeAndAppliesTheCarriedHpFraction", + "nemesisNameForEscalatesThroughEachEpithetAndClampsAtTheLast", + "nemesisXpBonusAndBountyGoldScaleWithGrudge", + "gameDescendCanRollANewNemesisFromAHuntingMonsterLeftOnTheDepartingFloor", + "gameDescendNeverRollsANewNemesisFromAMonsterThatIsNotActuallyHunting", + "gameNemesisFollowsAcrossMultipleDescentsEscalatingItsGrudgeEachTime", + "gameAscendCarriesAnActiveNemesisAlongButNeverRollsANewOne", + "gameDescendCarriesEveryLivingAllyOntoTheNextFloorInsteadOfAbandoningThem", + "gameAscendCarriesEveryLivingAllyBackOntoThePreviousFloorToo", + "gameAwardKillOnANemesisGrantsBonusXpAndBountyGoldAndClearsTheHunt", + "nemesisFieldsSurviveEntityAndGameSerialization", + "factionsAreRivalsChecksBothDirectionsAndExcludesSameFactionAndNil", + "infightingTargetPicksTheNearestVisibleRivalOverACloserNonRival", + "infightingTargetIgnoresARivalBeyondSightRange", + "infightingTargetIgnoresARivalBehindAWallEvenWithinSightRange", + "infightingTargetIgnoresNonRivalsWithoutDiscord", + "infightingTargetUnderDiscordPicksNearestCreatureOfAnyKindIncludingACompanion", + "infightingTargetReturnsNilOnceDiscordHasExpired", + "infightingStrikeOnAPlainMonsterSpawnsACorpseButAwardsThePlayerNothing", + "infightingStrikeThatKillsACompanionRoutesThroughTheNormalAllyDeathMessage", + "infightingStrikeEmitsNoiseThatCanStirADormantBystanderNotInvolvedInTheFight", + "infightingStrikeAppliesTheAttackersEliteLifestealAgainstAnyDefenderNotJustThePlayer", + "infightingStrikeAppliesTheAttackersEliteOnHitRiderAgainstAnyDefenderNotJustThePlayer", } for _, name in ipairs(order) do From 06f2f07e001bdb8f727c84d28f85e0864ab17fd8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:51:17 +0200 Subject: [PATCH 171/172] test: test more of run --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 88a5c92..57b341b 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -15266,6 +15266,179 @@ local order = { "infightingStrikeEmitsNoiseThatCanStirADormantBystanderNotInvolvedInTheFight", "infightingStrikeAppliesTheAttackersEliteLifestealAgainstAnyDefenderNotJustThePlayer", "infightingStrikeAppliesTheAttackersEliteOnHitRiderAgainstAnyDefenderNotJustThePlayer", + "infightingStrikeCanTransmitTheAttackersBiteDiseaseToAnyDefenderNotJustThePlayer", + "infightingTryActAttacksAnAdjacentRivalAndReturnsTrue", + "infightingTryActStepsTowardADistantRivalAndReturnsTrue", + "infightingTryActReturnsFalseWithNoQualifyingTarget", + "gameTakeMonsterTurnLetsARivalFightInsteadOfChasingADistantPlayer", + "spellsDiscordAppliesTheDiscordStatusAndSpendsItsListedManaCost", + "spellsDiscordFailsOutOfRangeAndSpendsNothing", + "builderCanRollATomeOfTheDiscordSpellSomewhereInARangeOfSeeds", + "spellsConfuseAppliesTheConfuseStatusAndSpendsItsListedManaCost", + "spellsConfuseFailsOutOfRangeAndSpendsNothing", + "builderCanRollATomeOfTheConfuseSpellSomewhereInARangeOfSeeds", + "builderCanRollAConfuseTrapOrTrapKitSomewhereInARangeOfSeeds", + "builderCanRollABlindTrapOrTrapKitSomewhereInARangeOfSeeds", + "builderCanRollAStunTrapOrTrapKitSomewhereInARangeOfSeeds", + "spellsBlindAppliesTheBlindStatusAndSpendsItsListedManaCost", + "spellsBlindFailsOutOfRangeAndSpendsNothing", + "builderCanRollATomeOfTheBlindSpellSomewhereInARangeOfSeeds", + "spellsWeakenAppliesTheWeakenStatusAndReducesAttackAndSpendsItsListedManaCost", + "spellsWeakenFailsOutOfRangeAndSpendsNothing", + "builderCanRollATomeOfTheWeakenSpellSomewhereInARangeOfSeeds", + "spellsCleanseLiftsHostileStatusesAndDiseaseOnTheCasterAndSpendsItsListedManaCost", + "spellsCleanseWithNothingHostileStillSucceedsAndSpendsMana", + "builderCanRollATomeOfTheCleanseSpellSomewhereInARangeOfSeeds", + "spellsLevitateAppliesTheLevitateStatusToTheCasterAndSpendsItsListedManaCost", + "builderCanRollATomeOfTheLevitateSpellSomewhereInARangeOfSeeds", + "polymorphPoolExcludesTheShopkeeperAndRespectsDepth", + "polymorphTransformPicksADifferentSpeciesAndPreservesTheHpFraction", + "polymorphTransformReturnsNilWhenThePoolOffersNothingButTheCurrentSpecies", + "spellsPolymorphTransformsTheTargetAndSpendsItsListedManaCost", + "spellsPolymorphFailsOutOfRangeAndSpendsNothing", + "builderCanRollATomeOfThePolymorphSpellSomewhereInARangeOfSeeds", + "blocksCanReceiveRefusesWallsDoorsVaultsGatesBlocksAndEntities", + "blocksPushRelocatesABlockOntoPlainFloor", + "blocksPushIntoLavaSolidifiesTheCellAndConsumesTheBlock", + "blocksPushIntoAChasmFillsTheGapAndConsumesTheBlock", + "blocksPushFailsAgainstAWallOrAnotherBlockOrAnEntity", + "blocksUpdateGatesOpensOnlyWhileAPlatedBlockHoldsItAndReseals", + "movePlayerShovesABlockAheadAndTheGateItHoldsOpenReactsImmediately", + "movePlayerCannotShoveABlockIntoAWallAndDoesNotMoveEither", + "movePlayerCannotShoveOneBlockIntoAnother", + "movePlayerSwapsPlacesWithItsOwnCompanionInsteadOfAttackingIt", + "movePlayerSwapsPlacesWithAHiredHirelingToo", + "adjacentMonsterSkipsTheBondedCompanionAndFindsARealHostileInstead", + "adjacentMonsterReturnsNilWhenOnlyAnAllyStandsNextToThePlayer", + "adjacentMonsterAlsoSkipsAThrallStandingNextToThePlayer", + "choosePlayerActionNeverAttacksItsOwnBondedCompanionEvenWithNothingElseToDo", + "aStarNeverRoutesAMonsterThroughABlockedCell", + "playerPathToNeverRoutesThroughABlockedCell", + "gatesAndBlocksStayOffFloorsAboveDepthTwo", + "builderOccasionallyPlacesAGateBlockPuzzleOnceADoorwayIsAvailable", + "builderOnlyEverPlacesASolvableGatePuzzleThatNeverSealsTheOnlyRoute", + "blockPlateAndGateFieldsSurviveSerialization", + "gameBlockPuzzleStateSurvivesSaveLoad", + "prefabVaultsNeverAppearBeforeDepthThree", + "builderStampsEachHandAuthoredVaultMatchingAFreshPrefabStampOfTheSameTemplate", + "builderDiggingThroughAPrefabVaultsOuterWallOpensARouteToItsSealedInterior", + "prefabVaultFieldsSurviveSerialization", + "saveGameFnv1a32MatchesThePublishedTestVectors", + "saveGameEncodeDecodeRoundTripsAValueUnchanged", + "saveGameSaveThenLoadRoundTripsThroughARealFile", + "saveGameLoadReportsMissingRatherThanErroringOnANonexistentFile", + "saveGameDecodeRejectsTextThatIsNotValidLuaAtAll", + "saveGameDecodeDetectsATamperedPayloadViaChecksumMismatch", + "saveGameDecodeRefusesASaveFromAVersionNewerThanTheCodecSupports", + "saveGameWithNoMigrationHistoryPassesLegacyDataThroughUnchanged", + "saveGameMigrationChainAppliesEachStepInOrderStartingFromVersionOne", + "saveGameMigrationChainSkipsStepsAlreadyPastTheStoredVersion", + "metaLoadUpgradesALegacyPreEnvelopeFileWithoutLosingItsRenown", + "metaLoadStartsFreshWithoutCrashingOnACorruptedFile", + "gameLoadUpgradesALegacyPreEnvelopeSaveMissingNewerPlayerFields", + "gameLoadRaisesAClearErrorOnATruncatedSaveFileInsteadOfCrashingObscurely", + "gameLoadRaisesAClearErrorForASaveFromAFutureVersion", + "noisePropagateDecaysByExactlyOneLoudnessPerStepThroughOpenFloor", + "noisePropagateNeverCrossesASolidWallEvenWhenTheOtherSideIsCloseByEuclideanDistance", + "noisePropagateBendsAroundACornerToReachACellWithNoStraightLineAtAll", + "noisePropagateFromAWallOriginStillReachesItsWalkableNeighbours", + "noiseIntensityAtIsZeroOutOfBoundsAndWhereverTheFloodNeverReached", + "noisePropagateWithZeroOrNegativeLoudnessReachesNothing", + "gameEmitNoiseEventuallyStirsADormantMonsterWithinRangeIntoSearchingAtTheNoisesOrigin", + "gameEmitNoiseNeverStirsADormantMonsterTheFloodCannotReach", + "gameEmitNoiseNeverAffectsAMonsterThatIsAlreadyAware", + "gamePlayerAttackCanStirADormantBystanderThatIsNotEvenTheTarget", + "gameMonsterMeleeCanStirADormantBystanderTooNotJustPlayerActions", + "gameCastSpellOnlyEmitsNoiseOnASuccessfulCastNeverOnAFailedOne", + "gameDigEmitsNoiseFromTheStruckCellEvenThoughItStartsUnwalkable", + "diseaseInfectSeedsIncubationAndRefusesToStackASecondInfection", + "diseaseUnknownKeyNeverInfects", + "diseaseIsInfectedAndHasEruptedTrackIncubationVsActiveStageSeparately", + "diseaseTickCountsIncubationDownInSilenceThenErupts", + "diseaseTickMessagesAddressThePlayerInSecondPersonForEruptionDamageAndEscalation", + "diseaseTickDealsStagePotencyThroughResistancesAndEscalatesOnItsFixedSchedule", + "diseaseTickNeverEscalatesPastTheTopStage", + "diseaseAnImmuneHostTakesNoDamageButStillEscalatesOnSchedule", + "diseaseCureLiftsAnInfectionAtAnyStageAndReportsFalseWhenThereWasNothingToLift", + "diseaseStagePotencyIsZeroWhileIncubatingOrUninfected", + "gameMonsterMeleeCanInfectThePlayerViaABiteCarryingSpeciesButNeverStacksASecondInfection", + "gameMonsterMeleeNeverInfectsFromASpeciesWithNoBiteDisease", + "gameEndPlayerTurnTicksThePlayersDiseaseAndLogsItsEruption", + "gameUseItemDrinkingACurePotionLiftsTheInfectionAndIdentifiesThePotion", + "gameUseItemDrinkingACurePotionAlsoLiftsHostileStatusesNotJustDisease", + "gameUseItemDrinkingACurePotionReportsBothDiseaseAndStatusesWhenBothArePresent", + "gameUseItemDrinkingACurePotionWithNothingToCureIsHonestInTheLog", + "diseaseAndBiteDiseaseFieldsSurviveALevelSerializationRoundTrip", + "captivesEligibleAcceptsABatteredWildHostileAndRejectsATooHealthyOne", + "captivesEligibleExcludesBossesMimicsGravesNemesesAndTheVendingShopkeeper", + "captivesCanReachRespectsRangeAndLineOfSight", + "captivesValueOfMatchesKillXpAndAddsAnEliteBonus", + "captivesResolveRefusesWithNoClearThrowWithoutSpendingTheNet", + "captivesResolveWastesTheNetOnATargetStillTooHealthyToSubdue", + "captivesResolveCanThrashFreeAndStillSpendTheNet", + "captivesResolveCatchesABatteredTargetIntoACaptiveAndZeroesItsHp", + "gameCaptureAtRemovesTheCaughtMonsterFromTheLevelWithoutAwardingAKill", + "gameCaptureAtLeavesTheTargetAliveAndTheNetUnspentWhenOutOfRange", + "gameRansomCaptiveWarmsFactionStandingWithNoGoldChange", + "gameSellCaptivePaysGoldAndCoolsFactionStanding", + "gameInterrogateCaptiveRevealsEveryHiddenDoorAndGrantsEssence", + "captiveFieldsSurviveALevelSerializationRoundTrip", + "autopilotNetsABatteredAdjacentMonsterInsteadOfFinishingItInMeleeWhenCarryingANet", + "autopilotSellsAHeldCaptiveOnSight", + "builderOccasionallyPlacesANetFromDepthFive", + "netsStayOffFloorsAboveDepthFive", + "hordeBuildArenaIsFullyEnclosedWithAClearCentreReachableFromEveryCorner", + "hordeBuildArenaIsDeterministicForAFixedSeedAndDiffersAcrossSeeds", + "hordeSpawnPointsAreUniqueWalkableCellsAndAlwaysCoverAMaxWave", + "hordeCountForClimbsWithWaveThenCapsAtMaxCount", + "hordeEssenceForIsZeroBeforeTheFirstWaveAndProportionalAfter", + "hordePoolExcludesThePassiveVendingShopkeeperEvenAtAQualifyingDepth", + "hordeClearedIsVacuouslyTrueOnAnEmptyArenaAndFalseWhileAnyMonsterLives", + "hordeClearedIgnoresCarriedAlliesAndOnlyCountsHostileMonsters", + "hordeSpawnWaveNeverStacksTwoMonstersOnOneCellAndEverySpawnStartsHunting", + "gameStartHordeBeginsAtWaveOneCenteredAndWithoutAStartingReward", + "gameStartHordeCarriesEveryLivingAllyIntoTheArenaInsteadOfAbandoningThem", + "gameEndPlayerTurnStillAdvancesHordeWavesWithACarriedCompanionPresent", + "gameEndPlayerTurnAdvancesTheHordeWaveBanksEssenceAndHealsOnceEveryMonsterDies", + "gameEndPlayerTurnLeavesAnUnclearedHordeWaveUntouched", + "hordeNeverPromotesAnEliteInEitherOfTheFirstTwoWavesRegardlessOfSeed", + "hordeEventuallyPromotesElitesOnceWavesCrossTheMinimumDepth", + "branchesSeedForIsDeterministicAndVariesWithEitherInput", + "branchesBuildIsDeterministicAndGeneratesDeeperThanItsOrigin", + "branchesBuildNeverPlacesAChasmOrTheWinningAmulet", + "builderPlacesARiftShardFromDepthFourAndARiftFromDepthSix", + "riftsAndShardsStayOffFloorsAboveTheirOwnThreshold", + "riftFieldsSurviveALevelSerializationRoundTrip", + "gameEnterRiftFailsWithoutARiftUnderfootOrWithoutAShard", + "gameEnterRiftSpendsTheShardAndSwapsToTheCachedPocketFloor", + "gameDescendAndAscendAreBothBlockedWhileAcrossARift", + "gameExitRiftRestoresTheExactMainTrackLevelDepthAndPosition", + "gameReenteringAStabilizedRiftNeverSpendsASecondShard", + "gameRiftPocketFloorPersistsStateAcrossALeaveAndReturn", + "gameEnterRiftAndExitRiftBothCarryALivingCompanionAcrossTheCrossing", + "choosePlayerActionEntersAWellHealedRiftUnderfootButSkipsItWhenBadlyHurt", + "choosePlayerActionStepsThroughAndThenExitsARiftUnderAutopilot", + "renderDrawsARiftGlyphWhenVisible", + "gameSaveLoadRoundTripsAnActiveRiftCrossing", + "gameSaveLoadRoundTripsAnActiveHordeArena", + "gameSaveLoadOfAnOrdinaryRunNeverGainsAHordeField", + "phasesTickIsANoOpForAPlainMonsterOrABossStillAboveItsDesperateThreshold", + "phasesTickBracesOnceABloodiedBossCrossesDesperateAtSpikingDefenseAndConsumingTheTurn", + "phasesTickUnleashesOnTheTurnAfterBracingHittingThePlayerAndRevertingDefense", + "phasesUnleashHitsCompanionsAndHirelingsInRadiusButLeavesAWildMonsterUntouched", + "phasesUnleashHitsAReanimatedThrallInRadiusJustLikeACompanionOrHireling", + "phasesNovaFiresAtMostOncePerBossThenFallsBackToItsOrdinaryAbility", + "takeMonsterTurnSkipsAbilityAndAiWhileABossBracesOrUnleashesItsCataclysm", + "bossPhaseFieldsSurviveALevelSerializationRoundTrip", + "commandsParseSplitsAVerbAndArgsLowercasedAndTrimmed", + "commandsApplyRejectsAnUnknownVerbWithoutSpendingATurn", + "commandsApplyMovingIntoAWallFailsWithoutSpendingATurn", + "commandsApplyMovementBumpsIntoAnAdjacentHostileAsAnAttack", + "commandsApplyAttackAliasesMovementIntoAnExplicitDirection", + "commandsApplyWaitAlwaysSucceedsAndSpendsTheTurn", + "commandsApplyPickupSweepsFloorItemsAtThePlayersFeetAndSpendsTheTurn", + "commandsApplyUseDrinksAPotionByInventoryIndexAndSpendsTheTurn", + "commandsApplyItemCommandsRejectAMissingOrNonNumericIndexWithoutSpendingATurn", } for _, name in ipairs(order) do From c7b1c2ef39f6f1c094d354be2a1e5c1e2c202420 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:55:06 +0200 Subject: [PATCH 172/172] test: cover run --- tests/run.lua | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 57b341b..19c03b2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -15439,6 +15439,134 @@ local order = { "commandsApplyPickupSweepsFloorItemsAtThePlayersFeetAndSpendsTheTurn", "commandsApplyUseDrinksAPotionByInventoryIndexAndSpendsTheTurn", "commandsApplyItemCommandsRejectAMissingOrNonNumericIndexWithoutSpendingATurn", + "commandsApplyCastAutoTargetsTheNearestVisibleHostile", + "commandsApplyCastBreaksATiedDistanceByAscendingMonsterId", + "commandsApplyCastFailsWithoutSpendingATurnWhenNoHostileIsVisible", + "commandsApplyCastRejectsAnUnknownOrUnlearnedSpellWithoutSpendingATurn", + "commandsApplyCastConfuseAppliesTheStatusThroughTheFullCommandPipeline", + "commandsApplyCastBlindAppliesTheStatusThroughTheFullCommandPipeline", + "commandsApplyCastWeakenAppliesTheStatusThroughTheFullCommandPipeline", + "commandsApplyCastLevitateAppliesTheStatusToTheCasterThroughTheFullCommandPipeline", + "commandsApplyCastCleanseLiftsTheCastersOwnStatusThroughTheFullCommandPipeline", + "commandsApplyThrowAndFireFailWithoutSpendingATurnWhenNoHostileIsVisible", + "commandsApplyUseskillRejectsAnUnknownOrUnlearnedSkillWithoutSpendingATurn", + "commandsApplyUseskillCleaveAutoTargetsAnAdjacentHostile", + "commandsApplyThrowWithAnExplicitTargetNumberHitsThatHostileInsteadOfTheNearestOne", + "commandsApplyThrowRejectsAnExplicitTargetNumberThatIsNotInSightWithoutSpendingATurn", + "commandsApplyFireWithAnExplicitTargetNumberShootsThatHostileInsteadOfTheNearestOne", + "commandsApplyCastWithAnExplicitTargetNumberHitsThatHostileInsteadOfTheNearestOne", + "commandsApplyCaptureWithAnExplicitTargetNumberTargetsThatHostileInsteadOfTheNearestOne", + "commandsApplyUseskillCleaveWithAnExplicitTargetNumberHitsThatAdjacentHostileInsteadOfTheDefaultNearestOne", + "commandsApplyUseskillMultishotWithExplicitTargetNumbersHitsExactlyThoseTwoHostiles", + "commandsApplyUseskillMultishotWithOnlyOneExplicitTargetNumberFallsBackToAutoTargetingBoth", + "commandsApplyContextActionsFailCleanlyWithNothingHereWithoutSpendingATurn", + "commandsApplyDigRejectsAnUnknownDirectionAndAnUnreadiedPickWithoutSpendingATurn", + "commandsApplyDisarmRejectsAMissingDirectionOrAnUndetectedTrapWithoutSpendingATurn", + "commandsApplyDisarmAlwaysSpendsTheTurnAgainstADetectedTrapWhetherItSucceedsOrSprings", + "commandsApplyAppeaseBuysFactionStandingWithGoldAndSpendsTheTurn", + "commandsApplyAppeaseRejectsAMissingOrUnknownFactionWithoutSpendingATurn", + "commandsApplyAppeaseFailsWithoutSpendingATurnWhenGoldIsShort", + "commandsApplyHordeEntersTheArenaAtWaveOneAndSpendsTheTurn", + "commandsApplyHordeRefusesToRestartAnArenaAlreadyInProgressWithoutSpendingATurn", + "commandsApplyInformationalVerbsNeverSpendATurn", + "commandsApplyLookListsOnlyVisibleHostilesByName", + "examineItemDescribesAPlainWeaponsPowerAndDurability", + "examineItemFlagsABrokenWeaponAndAFullyTemperedBonus", + "examineItemDescribesARingsFlatAttackAndDefenseBonus", + "examineItemDescribesAnAffixSocketedGemRunewordAndRevealedCurse", + "examineItemNamesAnArtifactsUniquePowerAndASetPiecesSetName", + "examineItemListsTheElementalResistanceAWornArtifactGrants", + "examineItemShowsAPotionsDisguisedLabelWithoutLeakingItsIdentity", + "examineMonsterReportsHpAttackDefenseAndDefaultsToHuntingAwareness", + "examineMonsterReportsDormantAwarenessForAnUnwokenWildMonster", + "examineMonsterReportsItsFactionAndTheCurrentReputationTier", + "examineMonsterListsAnElitesTierAndEachAffixItRolled", + "examineMonsterFlagsAnEnragedAndDesperateBossTogether", + "examineMonsterReportsANonNormalResistanceFromItsBaseProfile", + "examineMonsterListsActiveStatusEffectsWithRemainingDuration", + "examineMonsterReportsADiseaseCaughtThroughInfighting", + "commandsApplyExamineDescribesAnInventoryItemWithoutSpendingATurn", + "commandsApplyExamineMonsterDescribesTheSameEntryLookJustNumbered", + "commandsApplyExamineFailsCleanlyOnBadOrMissingArgumentsWithoutSpendingATurn", + "commandsApplyHelpMentionsExamine", + "commandsApplyForgeAndMergeDriveTheGemPipelineAndSpendTheTurn", + "commandsApplyForgeRejectsAMissingOrUnknownElementWithoutSpendingATurn", + "commandsApplyMergeRejectsAMissingOrMismatchedPairWithoutSpendingATurn", + "commandsApplySocketTemperEnchantDisenchantDriveTheEquippedWeaponAndSpendTheTurn", + "commandsApplySocketRuneCompletesARunewordAndSpendsTheTurn", + "commandsApplyCraftingVerbsFailCleanlyWithBadTargetsWithoutSpendingATurn", + "commandsApplyRepairFixesTheEquippedWeaponAndSpendsTheTurn", + "commandsApplyRepairRejectsAnUnknownSlotOrAnEmptyPurseWithoutSpendingATurn", + "commandsApplyStealTakesTheWardedWareUnderfootAndSpendsTheTurn", + "commandsApplyStealFailsWithNothingWardedUnderfootWithoutSpendingATurn", + "commandsApplyDismissReleasesTheHirelingAndSpendsTheTurn", + "commandsApplyDismissFailsWithNoHirelingWithoutSpendingATurn", + "commandsApplyLearnSpendsAPointNeverATurn", + "commandsApplyLearnAcceptsTheSkillsOwnCamelCaseSpellingDespiteTheParserLowercasingIt", + "commandsApplyUseskillAcceptsACamelCaseSkillNameDespiteTheParserLowercasingIt", + "commandsApplyBrewCombinesTwoHerbsAtACauldronAndSpendsTheTurn", + "commandsApplyBrewFailsAwayFromACauldronWithoutSpendingATurn", + "commandsApplyBrewSpendsTheTurnEvenWhenThePairingCurdles", + "commandsApplyBrewRejectsTheSameIndexTwiceOrANonHerbItemWithoutSpendingATurn", + "commandsApplyHelpMentionsTheCraftingAndEconomyVerbs", + "commandsApplyHelpMentionsAppeaseDisarmAndHorde", + "gameManualStepEndsTheTurnOnlyWhenTheCommandActuallySucceeds", + "gameManualStepLeavesTheTurnCounterUntouchedOnARejectedCommand", + "gameManualStepIsIdempotentAcrossRepeatedRejectedCommandsWithinOneUnspentTurn", + "gameManualStepStunnedPlayerIgnoresTheTypedCommandButStillSpendsTheTurn", + "gameManualStepHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon", + "gameAutoExploreWalksTowardUnexploredTerrainUntilTheWholeReachableAreaIsSeen", + "gameAutoExploreReportsNothingLeftWithoutSpendingATurnWhenAlreadyFullyExplored", + "gameAutoExploreHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon", + "gameAutoExploreStopsTheInstantThePlayerTakesDamage", + "gameAutoExploreRefusesToStartWhileAHostileIsAlreadyInSight", + "gameAutoExploreStopsTheMomentAHostileComesIntoSightWithoutEngagingIt", + "gameManualStepDrivenExploreSpendsExactlyAsManyTurnsAsCallingGameAutoExploreDirectly", + "commandsApplyExploreDrivesGameAutoExploreAndReturnsItsResult", + "commandsApplyHelpMentionsExplore", + "commandsApplyHelpMentionsTheOptionalTargetNumberOnEveryTargetedVerb", + "gameRestUntilHealedRefusesWithoutSpendingATurnWhenAlreadyAtFullHealth", + "gameRestUntilHealedRefusesWithoutSpendingATurnWhileAHostileIsAlreadyInSight", + "gameRestUntilHealedHealsTurnByTurnUntilFullThenStops", + "gameRestUntilHealedStopsAfterTwoUnchangedTurnsWhenNothingIsHealingThePlayer", + "gameRestUntilHealedStopsTheInstantThePlayerTakesDamage", + "gameRestUntilHealedStopsTheMomentAHostileComesIntoSightWithoutEngagingIt", + "gameManualStepDrivenRestSpendsExactlyAsManyTurnsAsCallingGameRestUntilHealedDirectly", + "commandsApplyRestDrivesGameRestUntilHealedAndReturnsItsResult", + "commandsApplyHelpMentionsRest", + "gameTravelToWalksTheShortestPathAndArrivesAtTheDestination", + "gameTravelToRefusesWithoutSpendingATurnWhenAlreadyThere", + "gameTravelToRefusesWithoutSpendingATurnWhenNoRouteExists", + "gameTravelToRefusesWithoutSpendingATurnWhileAHostileIsAlreadyInSight", + "gameTravelToHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon", + "gameTravelToStopsTheInstantThePlayerTakesDamage", + "gameTravelToStopsTheMomentAHostileComesIntoSightWithoutEngagingIt", + "gameManualStepDrivenTravelSpendsExactlyAsManyTurnsAsCallingGameTravelToDirectly", + "commandsApplyTravelDownDrivesGameTravelToTowardTheDownStairs", + "commandsApplyTravelUpDrivesGameTravelToTowardTheUpStairs", + "commandsApplyTravelWithoutAnArgumentFailsWithoutSpendingATurn", + "commandsApplyHelpMentionsTravel", + "cliHeadlessAutoRunPrintsTheStatusBarAndExitsZero", + "cliPlayReplDrivesRealCommandsThroughRealStdin", + "cliPlayLoopChargesATurnOnlyForCommandsThatActuallySucceed", + "cliSaveThenLoadRoundTripsThroughTheRealEntryPoint", + "cliVerifyConfirmsADeterministicReplayThroughTheRealEntryPoint", + "cliChallengePresetPrintsAChallengeLineAndAVerdict", + "cliReportShowsFreshProgressionStateForANewMetaFile", + "cliExportsRealLeaderboardAndCompendiumCsvFiles", + "cliLoadingAMissingSaveFileFailsLoudlyRatherThanSilently", + "cliPlayReplExploreAdvancesSeveralTurnsInASingleTypedCommand", + "cliPlayReplRestRefusesWithoutSpendingATurnAtFullHealth", + "cliPlayReplTravelDownAdvancesSeveralTurnsInASingleTypedCommand", + "alchemyMoonbellPairedWithItselfConcentratesIntoConfusion", + "alchemyMoonbellPairedWithSunpetalCurdlesOnTheDayVersusDreamOpposition", + "alchemyMoonbellPairedWithBloodrootBrewsClarity", + "alchemyMoonbellPairedWithEmberleafBrewsBlindness", + "alchemyMoonbellPairedWithFrostmossBrewsLevitation", + "alchemyMoonbellPairedWithNightcapBrewsAStrongerPoisonThanEitherHerbAlone", + "alchemyAddingMoonbellLeftEveryPreExistingRecipeUntouched", + "gameBrewAtCanBrewAMoonbellPairingAtACauldron", + "buildersOccasionallyRollAMoonbellHerbAsForagingLoot", } for _, name in ipairs(order) do