Merge pull request #1 from dualfroz/feat/pending-work-2026-09

feat: land pending September work
This commit is contained in:
dualfroz authored and GitHub committed 2026-09-14 03:28:21 +02:00
commit ae82989fca
88 files changed
+27789 -129

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
-- 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
-- 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
+8 -3
View File
@@ -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
+65 -2
View File
@@ -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
+102
View File
@@ -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
+58
View File
@@ -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
+133
View File
@@ -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
+136
View File
@@ -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
+49
View File
@@ -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
+62
View File
@@ -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
+113
View File
@@ -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
+124
View File
@@ -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
+83
View File
@@ -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
+789 -10
View File
@@ -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,7 +84,10 @@ 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
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
@@ -46,6 +96,7 @@ local function reachSet(map, sx, sy, allowLocked)
end
end
end
end
return seen
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
@@ -207,14 +336,664 @@ 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)
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
-- 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
-- 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,
+160
View File
@@ -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
+69
View File
@@ -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
+182
View File
@@ -0,0 +1,182 @@
-- Lockable loot containers scattered through the dungeon: an unlocked chest
-- yields its hoard the instant it is opened, a locked one demands either a
-- spent key (Items.KEY, the same key a locked door already consumes) or a
-- lockpick gamble (Items.LOCKPICK) that risks springing the chest's own trap
-- on a failed pick - a second, riskier route into the same treasure rather
-- than a strictly worse one, since a lockpick is commoner floor loot than a
-- key. Only a locked chest can be trapped at all: there is no lock to punish
-- fumbling with on one that was never going to resist in the first place.
-- A chest never moves and never decays; once opened it stays on the map as
-- an emptied husk exactly like a corpse past its last butchering, so a
-- second visit finds nothing left to take. Chests are plain data on
-- level.chests, exactly like level.corpses and level.nodes, so they
-- serialize with no special save-format handling.
local Items = require("items")
local Affixes = require("affixes")
local Status = require("status")
local Chests = {}
Chests.LOCK_CHANCE_NUMERATOR = 1
Chests.LOCK_CHANCE_DENOMINATOR = 2
Chests.TRAP_CHANCE_NUMERATOR = 1
Chests.TRAP_CHANCE_DENOMINATOR = 3
Chests.PICK_BASE_NUMERATOR = 1
Chests.PICK_BASE_DENOMINATOR = 2
Chests.PICK_LEVEL_BONUS_DIVISOR = 5
Chests.TRAP_DART_MIN = 2
Chests.TRAP_DART_MAX = 5
Chests.GOLD_MIN_BONUS = 0
Chests.GOLD_MAX_BONUS = 10
Chests.GOLD_BASE = 10
Chests.GOLD_PER_DEPTH = 6
local chestWeapons = {
{ name = "ornate rapier", power = 4 },
{ name = "gilded axe", power = 5 },
}
local chestArmors = {
{ name = "banded mail", power = 3 },
}
-- One chest's hoard: always some gold plus a coin flip between a lightly
-- affixed weapon or armor piece (see affixes.lua) and, half the time on top
-- of that, a spare healing potion. Richer on average than a single floor
-- drop (builder.lua's rollItem), since reaching it costs a key or a
-- lockpick gamble rather than a free step onto its tile.
local function rollLoot(rng, depth)
local loot = { Items.gold(Chests.GOLD_BASE + depth * Chests.GOLD_PER_DEPTH
+ rng:range(Chests.GOLD_MIN_BONUS, Chests.GOLD_MAX_BONUS)) }
if rng:chance(1, 2) then
local base = chestWeapons[rng:range(1, #chestWeapons)]
loot[#loot + 1] = Affixes.decorate(Items.weapon(base.name, base.power + depth // 4), Affixes.rollWeapon(rng))
else
local base = chestArmors[rng:range(1, #chestArmors)]
loot[#loot + 1] = Affixes.decorate(Items.armor(base.name, base.power + depth // 4), Affixes.rollArmor(rng))
end
if rng:chance(1, 2) then
loot[#loot + 1] = Items.potionOf("heal")
end
return loot
end
-- A freshly rolled chest at (unassigned) x/y, exactly like Nodes.new -
-- builder.lua stamps x/y directly onto the returned table once it finds a
-- free cell for it. Trapped is only ever rolled for a locked chest (see
-- above), so the rng stream is never touched by the trap roll at all on an
-- unlocked one.
function Chests.new(rng, depth)
local locked = rng:chance(Chests.LOCK_CHANCE_NUMERATOR, Chests.LOCK_CHANCE_DENOMINATOR)
local trapped = locked and rng:chance(Chests.TRAP_CHANCE_NUMERATOR, Chests.TRAP_CHANCE_DENOMINATOR)
return {
locked = locked,
trapped = trapped,
opened = false,
loot = rollLoot(rng, depth),
}
end
-- A chest still worth forcing: locked and not already emptied. An opened
-- chest reports unlocked regardless of its own `locked` flag, since nothing
-- is left inside to gate.
function Chests.isLocked(chest)
return chest.locked and not chest.opened
end
-- Odds of a lockpick attempt succeeding: a flat coin flip that improves
-- slightly with player level, capped a point shy of the denominator so a
-- pick attempt is never a sure thing no matter how experienced the player
-- gets - mirrors disarm.lua's Disarm.oddsFor.
function Chests.oddsFor(player)
local numerator = Chests.PICK_BASE_NUMERATOR + player.level // Chests.PICK_LEVEL_BONUS_DIVISOR
if numerator > Chests.PICK_BASE_DENOMINATOR - 1 then
numerator = Chests.PICK_BASE_DENOMINATOR - 1
end
return numerator, Chests.PICK_BASE_DENOMINATOR
end
-- Pours every item in `chest`'s hoard into `player`'s inventory and marks
-- the chest spent for good.
local function grant(player, chest)
for _, item in ipairs(chest.loot) do
player.inventory[#player.inventory + 1] = item
end
chest.opened = true
chest.loot = {}
end
-- Opens an already-unlocked (or never-locked) chest outright. Refuses with
-- (false, line) and no state change against one still locked or already
-- emptied - the caller (Game:openChest) routes a locked chest through
-- Chests.unlockWithKey or Chests.pick instead.
function Chests.open(player, chest)
if chest.opened then return false, "this chest is already empty" end
if Chests.isLocked(chest) then return false, "the chest is locked" end
grant(player, chest)
return true, "you open the chest and take its hoard"
end
-- Spends a key from `player`'s inventory to force the lock outright, no
-- roll involved - the same certain trade a locked door already makes for
-- one of Items.KEY. Returns (false, line) with nothing spent if the player
-- is not carrying one, or against a chest that is not actually locked.
function Chests.unlockWithKey(player, chest)
if not Chests.isLocked(chest) then return false, "this chest has no lock to open" end
for i, item in ipairs(player.inventory) do
if item.kind == Items.KEY then
table.remove(player.inventory, i)
chest.locked = false
grant(player, chest)
return true, "you unlock the chest with your key"
end
end
return false, "you have no key"
end
-- A trapped chest's punishment for a failed pick: a dart to the ribs or a
-- dose of poison, the same two effects a floor trap can spring (see
-- game.lua's Game:springTrap) but resolved locally since a chest is not a
-- level.traps entry. Spends the trap the instant it springs - a chest only
-- ever bites back once.
function Chests.springTrap(game, chest)
chest.trapped = false
local player = game.player
if game.rng:chance(1, 2) then
local dealt = game.rng:range(Chests.TRAP_DART_MIN, Chests.TRAP_DART_MAX)
player.hp = player.hp - dealt
return string.format("a needle trap jabs you for %d", dealt)
end
Status.apply(player, Status.POISON, 5, 2)
return "a cloud of poison bursts from the lock"
end
-- Gambles a lockpick against `chest`'s lock. Always spends the pick, win or
-- lose, once one is found. On success the chest opens exactly like
-- Chests.unlockWithKey; on failure a trapped chest springs on the player
-- (Chests.springTrap) while an untrapped one merely wastes the pick, the
-- lock still standing for a later key or another pick.
function Chests.pick(game, chest)
local player = game.player
if not Chests.isLocked(chest) then return false, "this chest has no lock to pick" end
local pickIndex
for i, item in ipairs(player.inventory) do
if item.kind == Items.LOCKPICK then pickIndex = i; break end
end
if not pickIndex then return false, "you have no lockpicks" end
table.remove(player.inventory, pickIndex)
local numerator, denominator = Chests.oddsFor(player)
if not game.rng:chance(numerator, denominator) then
if chest.trapped then return false, Chests.springTrap(game, chest) end
return false, "your lockpick slips and the lock holds"
end
chest.locked = false
grant(player, chest)
return true, "your lockpick clicks the lock open"
end
return Chests
+64
View File
@@ -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
+37 -8
View File
@@ -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
+625
View File
@@ -0,0 +1,625 @@
-- Text command parser and dispatcher for a human player, layered entirely on
-- top of the granular Game:<action> 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 <n>" 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
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
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
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 <n>` inspects inventory item n, the
-- same index use/drop/sell already key off; `examine monster <n>` 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 <n>'" 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 <dir>, " ..
"wait, pickup, explore, rest, travel down/up, inventory, use <n>, drop <n>, sell <n>, throw <n> [target], " ..
"fire [target], capture <n> [target], cast <spell> [target], useskill <skill> [target], " ..
"open, pray, quest, appease <faction>, butcher, raise, " ..
"scavenge, harvest, ransom, sellcaptive, interrogate, dig <dir>, disarm <dir>, " ..
"descend, ascend, enterrift, exitrift, horde, " ..
"steal, dismiss, learn <skill>, forge <element>, merge <a> <b>, brew <a> <b>, " ..
"socket <target> <gem>, socketrune <target> <rune>, temper <target>, " ..
"enchant <target> <affix>, disenchant <target>, repair <slot>, " ..
"examine <n>, examine monster <n>, 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:<action> 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
+188
View File
@@ -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
+93
View File
@@ -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
+80
View File
@@ -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
+103
View File
@@ -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
+181
View File
@@ -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
+161
View File
@@ -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
+113
View File
@@ -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
+82
View File
@@ -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
+131
View File
@@ -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
+127
View File
@@ -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
+110 -8
View File
@@ -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 <third-person-singular-verb>" 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
+162
View File
@@ -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
+123
View File
@@ -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
+64 -3
View File
@@ -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
+89
View File
@@ -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
+206
View File
@@ -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
+48
View File
@@ -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
+130
View File
@@ -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
+2227 -55
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -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)
+90
View File
@@ -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
+106
View File
@@ -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
+114
View File
@@ -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
+244
View File
@@ -0,0 +1,244 @@
-- 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
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
+112
View File
@@ -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
+1 -1
View File
@@ -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",
+110
View File
@@ -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
+131 -7
View File
@@ -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,14 +14,40 @@ 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 = {
@@ -27,6 +55,7 @@ Items.scrollTypes = {
{ 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
+55
View File
@@ -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
+148
View File
@@ -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,
+191
View File
@@ -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
+200
View File
@@ -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
+60
View File
@@ -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
+39
View File
@@ -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
+61 -11
View File
@@ -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
+132
View File
@@ -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
+155
View File
@@ -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
+75
View File
@@ -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
+74
View File
@@ -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
+91
View File
@@ -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
+63
View File
@@ -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
+88
View File
@@ -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
+48
View File
@@ -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
+90
View File
@@ -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
+73
View File
@@ -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
+158
View File
@@ -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
+106 -8
View File
@@ -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")
+62
View File
@@ -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
+67
View File
@@ -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
+77
View File
@@ -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
+201
View File
@@ -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
+112
View File
@@ -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
+64
View File
@@ -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
+59
View File
@@ -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
+159
View File
@@ -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
+115
View File
@@ -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
+111
View File
@@ -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
+281
View File
@@ -0,0 +1,281 @@
-- 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
-- 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
+249
View File
@@ -0,0 +1,249 @@
-- 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
-- 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
+115 -5
View File
@@ -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
+154
View File
@@ -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
+57
View File
@@ -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
+49
View File
@@ -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
+85
View File
@@ -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
+36
View File
@@ -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
+37
View File
@@ -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
+64
View File
@@ -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
+15048 -6
View File
File diff suppressed because it is too large. Load diff
+226 -1
View File
@@ -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,22 +49,215 @@ 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
game:autoStep()
end
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