Merge pull request #1 from dualfroz/feat/pending-work-2026-09
feat: land pending September work
This commit is contained in:
88 files changed
+27790
-130
No files matched your search
@@ -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
@@ -1,14 +1,18 @@
|
|||||||
-- Gear affixes. A weapon or armour piece may roll a prefix that bumps its power
|
-- 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
|
-- 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.
|
-- 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 = {}
|
local Affixes = {}
|
||||||
|
|
||||||
Affixes.weapon = {
|
Affixes.weapon = {
|
||||||
{ key = "sharp", name = "sharp", power = 1 },
|
{ key = "sharp", name = "sharp", power = 1 },
|
||||||
{ key = "vicious", name = "vicious", power = 2 },
|
{ key = "vicious", name = "vicious", power = 2 },
|
||||||
{ key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 } },
|
{ 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 } },
|
{ key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 }, damageType = "poison" },
|
||||||
}
|
}
|
||||||
|
|
||||||
Affixes.armor = {
|
Affixes.armor = {
|
||||||
@@ -31,9 +35,10 @@ end
|
|||||||
|
|
||||||
function Affixes.decorate(item, affix)
|
function Affixes.decorate(item, affix)
|
||||||
if not affix then return item end
|
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.power = item.power + affix.power
|
||||||
item.name = affix.name .. " " .. item.name
|
item.name = affix.name .. " " .. item.name
|
||||||
|
item.damageType = affix.damageType
|
||||||
return item
|
return item
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+65
-2
@@ -2,15 +2,38 @@
|
|||||||
-- resolves that monster's turn through game helpers, so all state mutation and
|
-- resolves that monster's turn through game helpers, so all state mutation and
|
||||||
-- rng use stays centralised and deterministic.
|
-- rng use stays centralised and deterministic.
|
||||||
|
|
||||||
|
local Status = require("status")
|
||||||
|
local Infighting = require("infighting")
|
||||||
|
|
||||||
local Ai = {}
|
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 } }
|
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)
|
local function chase(game, monster)
|
||||||
if game:playerAdjacent(monster) then
|
if game:playerAdjacent(monster) then
|
||||||
game:monsterMelee(monster)
|
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)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -20,6 +43,7 @@ local function pack(game, monster)
|
|||||||
game:monsterMelee(monster)
|
game:monsterMelee(monster)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if attackBlockingAlly(game, monster) then return end
|
||||||
if game:canSeePlayer(monster, AGGRO.pack) then
|
if game:canSeePlayer(monster, AGGRO.pack) then
|
||||||
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
return
|
return
|
||||||
@@ -39,6 +63,7 @@ local function ranged(game, monster)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if attackBlockingAlly(game, monster) then return end
|
||||||
if game:canSeePlayer(monster, AGGRO.ranged) then
|
if game:canSeePlayer(monster, AGGRO.ranged) then
|
||||||
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
end
|
end
|
||||||
@@ -50,6 +75,14 @@ local function flee(game, monster)
|
|||||||
end
|
end
|
||||||
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)
|
local function erratic(game, monster)
|
||||||
if game:playerAdjacent(monster) then
|
if game:playerAdjacent(monster) then
|
||||||
game:monsterMelee(monster)
|
game:monsterMelee(monster)
|
||||||
@@ -63,15 +96,45 @@ local function erratic(game, monster)
|
|||||||
end
|
end
|
||||||
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 = {
|
Ai.behaviours = {
|
||||||
chase = chase,
|
chase = chase,
|
||||||
pack = pack,
|
pack = pack,
|
||||||
ranged = ranged,
|
ranged = ranged,
|
||||||
flee = flee,
|
flee = flee,
|
||||||
erratic = erratic,
|
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)
|
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
|
local behaviour = Ai.behaviours[monster.ai] or chase
|
||||||
behaviour(game, monster)
|
behaviour(game, monster)
|
||||||
end
|
end
|
||||||
|
|||||||
+102
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -9,12 +9,51 @@ local Gen = require("gen.init")
|
|||||||
local Monsters = require("monsters")
|
local Monsters = require("monsters")
|
||||||
local Items = require("items")
|
local Items = require("items")
|
||||||
local Affixes = require("affixes")
|
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 Level = require("level")
|
||||||
|
|
||||||
local Builder = {}
|
local Builder = {}
|
||||||
|
|
||||||
local WIDTH, HEIGHT = 60, 24
|
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 = {
|
local weaponBases = {
|
||||||
{ name = "dagger", power = 2 }, { name = "mace", power = 3 },
|
{ name = "dagger", power = 2 }, { name = "mace", power = 3 },
|
||||||
{ name = "sword", power = 4 }, { name = "battle axe", power = 5 },
|
{ 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 = "leather armor", power = 1 }, { name = "chain mail", power = 2 },
|
||||||
{ name = "plate armor", power = 3 },
|
{ 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 } }
|
local NEIGHBOURS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
||||||
|
|
||||||
-- Cells reachable from (sx, sy) through walkable terrain and closed doors, and -
|
-- 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
|
-- 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.
|
-- 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 seen = { [map:index(sx, sy)] = true }
|
||||||
local stack = { { sx, sy } }
|
local stack = { { sx, sy } }
|
||||||
while #stack > 0 do
|
while #stack > 0 do
|
||||||
@@ -37,7 +84,10 @@ local function reachSet(map, sx, sy, allowLocked)
|
|||||||
stack[#stack] = nil
|
stack[#stack] = nil
|
||||||
for _, step in ipairs(NEIGHBOURS) do
|
for _, step in ipairs(NEIGHBOURS) do
|
||||||
local nx, ny = cell[1] + step[1], cell[2] + step[2]
|
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)
|
local idx = map:index(nx, ny)
|
||||||
if not seen[idx] then
|
if not seen[idx] then
|
||||||
seen[idx] = true
|
seen[idx] = true
|
||||||
@@ -46,6 +96,7 @@ local function reachSet(map, sx, sy, allowLocked)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
return seen
|
return seen
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -81,25 +132,42 @@ end
|
|||||||
|
|
||||||
local function rollItem(rng, depth)
|
local function rollItem(rng, depth)
|
||||||
local roll = rng:range(1, 100)
|
local roll = rng:range(1, 100)
|
||||||
if roll <= 24 then
|
if roll <= 22 then
|
||||||
local effects = { "heal", "heal", "strength", "haste", "poison" }
|
local effects = { "heal", "heal", "strength", "haste", "poison", "clarity", "cure", "confuse", "blind", "levitate" }
|
||||||
return Items.potionOf(effects[rng:range(1, #effects)])
|
return Items.potionOf(effects[rng:range(1, #effects)])
|
||||||
elseif roll <= 40 then
|
elseif roll <= 36 then
|
||||||
local effects = { "teleport", "identify", "mapping", "fireball" }
|
local effects = { "teleport", "identify", "mapping", "fireball" }
|
||||||
return Items.scroll(effects[rng:range(1, #effects)])
|
return Items.scroll(effects[rng:range(1, #effects)])
|
||||||
elseif roll <= 60 then
|
elseif roll <= 54 then
|
||||||
local base = weaponBases[rng:range(1, #weaponBases)]
|
local base = weaponBases[rng:range(1, #weaponBases)]
|
||||||
return Affixes.decorate(Items.weapon(base.name, base.power), Affixes.rollWeapon(rng))
|
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)]
|
local base = armorBases[rng:range(1, #armorBases)]
|
||||||
return Affixes.decorate(Items.armor(base.name, base.power), Affixes.rollArmor(rng))
|
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))
|
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
|
else
|
||||||
return Items.food("ration", 400)
|
return Items.food("ration", 400)
|
||||||
end
|
end
|
||||||
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)
|
local function chosenGenerator(depth, options)
|
||||||
if options and options.generator and Gen.has(options.generator) then
|
if options and options.generator and Gen.has(options.generator) then
|
||||||
return options.generator
|
return options.generator
|
||||||
@@ -175,7 +243,11 @@ function Builder.build(depth, rng, options)
|
|||||||
end)
|
end)
|
||||||
if x then
|
if x then
|
||||||
local spec = eligible[rng:range(1, #eligible)]
|
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)
|
mark(x, y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -189,7 +261,56 @@ function Builder.build(depth, rng, options)
|
|||||||
end
|
end
|
||||||
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
|
local trapCount = rng:range(1, 3) + depth // 3
|
||||||
for _ = 1, trapCount do
|
for _ = 1, trapCount do
|
||||||
local x, y = pickFree(reachable, rng, freeFloor)
|
local x, y = pickFree(reachable, rng, freeFloor)
|
||||||
@@ -199,6 +320,14 @@ function Builder.build(depth, rng, options)
|
|||||||
end
|
end
|
||||||
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
|
local dark = depth >= 3
|
||||||
if dark then
|
if dark then
|
||||||
for _ = 1, rng:range(1, 3) do
|
for _ = 1, rng:range(1, 3) do
|
||||||
@@ -207,14 +336,664 @@ function Builder.build(depth, rng, options)
|
|||||||
end
|
end
|
||||||
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({
|
return Level.new({
|
||||||
depth = depth,
|
depth = depth,
|
||||||
generator = generator,
|
generator = generator,
|
||||||
|
weather = weather,
|
||||||
|
biome = biome.key,
|
||||||
map = map,
|
map = map,
|
||||||
entities = entities,
|
entities = entities,
|
||||||
floorItems = floorItems,
|
floorItems = floorItems,
|
||||||
traps = traps,
|
traps = traps,
|
||||||
lights = lights,
|
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,
|
dark = dark,
|
||||||
explored = {},
|
explored = {},
|
||||||
upX = upX, upY = upY,
|
upX = upX, upY = upY,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -1,24 +1,53 @@
|
|||||||
-- Deterministic combat: damage is effective attack minus effective defense,
|
-- 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 Combat = {}
|
||||||
|
|
||||||
local function attackOf(entity)
|
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
|
end
|
||||||
|
|
||||||
local function defenseOf(entity)
|
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
|
end
|
||||||
|
|
||||||
function Combat.damage(attacker, defender)
|
-- The type of an attacker's raw damage: a weapon retagged by an elemental
|
||||||
local dealt = attackOf(attacker) - defenseOf(defender)
|
-- 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
|
if dealt < 1 then dealt = 1 end
|
||||||
return dealt
|
dealt = math.floor(dealt)
|
||||||
|
return Resistances.apply(defender, dealt, damageType or damageTypeOf(attacker))
|
||||||
end
|
end
|
||||||
|
|
||||||
function Combat.attack(attacker, defender)
|
function Combat.attack(attacker, defender, multiplier, damageType)
|
||||||
local dealt = Combat.damage(attacker, defender)
|
local dealt = Combat.damage(attacker, defender, multiplier, damageType)
|
||||||
defender.hp = defender.hp - dealt
|
defender.hp = defender.hp - dealt
|
||||||
return { damage = dealt, killed = defender.hp <= 0 }
|
return { damage = dealt, killed = defender.hp <= 0 }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -4,37 +4,120 @@
|
|||||||
|
|
||||||
local Status = require("status")
|
local Status = require("status")
|
||||||
local Identify = require("identify")
|
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 Effects = {}
|
||||||
|
|
||||||
local handlers = {}
|
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)
|
handlers.heal = function(game, target, item)
|
||||||
local before = target.hp
|
local before = target.hp
|
||||||
target.hp = math.min(target.maxHp, target.hp + item.power)
|
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
|
end
|
||||||
|
|
||||||
handlers.poison = function(game, target, item)
|
handlers.poison = function(game, target, item)
|
||||||
Status.apply(target, Status.POISON, 6, item.power)
|
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
|
end
|
||||||
|
|
||||||
handlers.strength = function(game, target, _)
|
handlers.strength = function(game, target, _)
|
||||||
Status.apply(target, Status.STRENGTH, 30, 3)
|
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
|
end
|
||||||
|
|
||||||
handlers.haste = function(game, target, _)
|
handlers.haste = function(game, target, _)
|
||||||
Status.apply(target, Status.HASTE, 20, 1)
|
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
|
end
|
||||||
|
|
||||||
handlers.teleport = function(game, target, _)
|
handlers.teleport = function(game, target, _)
|
||||||
local x, y = game:randomFloorCell()
|
local x, y = game:randomFloorCell()
|
||||||
if x then
|
if x then
|
||||||
target.x, target.y = x, y
|
target.x, target.y = x, y
|
||||||
return { target.name .. " blinks away" }
|
return { isPlayer(target) and "you blink away" or (target.name .. " blinks away") }
|
||||||
end
|
end
|
||||||
return { "the teleport fizzles" }
|
return { "the teleport fizzles" }
|
||||||
end
|
end
|
||||||
@@ -45,11 +128,28 @@ handlers.mapping = function(game, _, _)
|
|||||||
end
|
end
|
||||||
|
|
||||||
handlers.identify = function(game, target, _)
|
handlers.identify = function(game, target, _)
|
||||||
|
local lines = {}
|
||||||
local revealed = Identify.revealFirstUnknown(game.identify, target.inventory)
|
local revealed = Identify.revealFirstUnknown(game.identify, target.inventory)
|
||||||
if revealed then
|
if revealed then
|
||||||
return { "it is " .. revealed.name }
|
lines[#lines + 1] = "it is " .. revealed.name
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
handlers.fireball = function(game, target, _)
|
handlers.fireball = function(game, target, _)
|
||||||
@@ -59,12 +159,14 @@ handlers.fireball = function(game, target, _)
|
|||||||
if monster:isAlive() then
|
if monster:isAlive() then
|
||||||
local dist = math.max(math.abs(monster.x - target.x), math.abs(monster.y - target.y))
|
local dist = math.max(math.abs(monster.x - target.x), math.abs(monster.y - target.y))
|
||||||
if dist <= radius then
|
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)
|
Status.apply(monster, Status.BURNING, 3, 2)
|
||||||
|
Hazards.ignite(game.level, monster.x, monster.y, 3, 2)
|
||||||
burned = burned + 1
|
burned = burned + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Hazards.ignite(game.level, target.x, target.y, 3, 2)
|
||||||
game:cleanupDead()
|
game:cleanupDead()
|
||||||
return { string.format("fire engulfs %d foes", burned) }
|
return { string.format("fire engulfs %d foes", burned) }
|
||||||
end
|
end
|
||||||
|
|||||||
+162
@@ -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
|
||||||
@@ -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
@@ -3,6 +3,11 @@
|
|||||||
-- and speed drive the turn scheduler; statuses layer temporary modifiers on top.
|
-- and speed drive the turn scheduler; statuses layer temporary modifiers on top.
|
||||||
|
|
||||||
local Status = require("status")
|
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 = {}
|
local Entity = {}
|
||||||
Entity.__index = Entity
|
Entity.__index = Entity
|
||||||
@@ -30,6 +35,8 @@ function Entity.new(spec)
|
|||||||
weapon = nil,
|
weapon = nil,
|
||||||
armor = nil,
|
armor = nil,
|
||||||
ring = nil,
|
ring = nil,
|
||||||
|
bow = nil,
|
||||||
|
pick = nil,
|
||||||
}, Entity)
|
}, Entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,19 +45,25 @@ function Entity:isAlive()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Entity:effectiveAttack()
|
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
|
end
|
||||||
|
|
||||||
function Entity:effectiveDefense()
|
function Entity:effectiveDefense()
|
||||||
return self.defense
|
return self.defense + Status.defenseModifier(self) + Encumbrance.defensePenalty(self) + Sanity.defensePenalty(self)
|
||||||
|
+ Hunger.defensePenalty(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Entity:effectiveSpeed()
|
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
|
if speed < 10 then speed = 10 end
|
||||||
return speed
|
return speed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Entity:encumbranceTier()
|
||||||
|
return Encumbrance.tierFor(self)
|
||||||
|
end
|
||||||
|
|
||||||
function Entity:toData()
|
function Entity:toData()
|
||||||
return {
|
return {
|
||||||
id = self.id,
|
id = self.id,
|
||||||
@@ -66,6 +79,54 @@ function Entity:toData()
|
|||||||
weapon = self.weapon,
|
weapon = self.weapon,
|
||||||
armor = self.armor,
|
armor = self.armor,
|
||||||
ring = self.ring,
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -12,10 +12,15 @@ Gen.generators = {
|
|||||||
bsp = require("gen.bsp").generate,
|
bsp = require("gen.bsp").generate,
|
||||||
cellular = require("gen.cellular").generate,
|
cellular = require("gen.cellular").generate,
|
||||||
drunkard = require("gen.drunkard").generate,
|
drunkard = require("gen.drunkard").generate,
|
||||||
|
maze = require("gen.maze").generate,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Deterministic ordering of names for callers that want to cycle generators by
|
-- 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" }
|
Gen.order = { "rooms", "bsp", "cellular", "drunkard" }
|
||||||
|
|
||||||
function Gen.has(name)
|
function Gen.has(name)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -8,7 +8,7 @@ local Items = require("items")
|
|||||||
local Identify = {}
|
local Identify = {}
|
||||||
|
|
||||||
local potionLooks = {
|
local potionLooks = {
|
||||||
"ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous",
|
"ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous", "inky", "frothy",
|
||||||
}
|
}
|
||||||
local scrollLooks = {
|
local scrollLooks = {
|
||||||
"XYZZY", "ELBERETH", "ZELGO KAI", "JUYED AWK", "VE FORBRYDERNE",
|
"XYZZY", "ELBERETH", "ZELGO KAI", "JUYED AWK", "VE FORBRYDERNE",
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,8 @@
|
|||||||
-- modifies effective stats when equipped and may carry an affix. Potion and
|
-- modifies effective stats when equipped and may carry an affix. Potion and
|
||||||
-- scroll type catalogs drive the identification system.
|
-- scroll type catalogs drive the identification system.
|
||||||
|
|
||||||
|
local Durability = require("durability")
|
||||||
|
|
||||||
local Items = {}
|
local Items = {}
|
||||||
|
|
||||||
Items.POTION = "potion"
|
Items.POTION = "potion"
|
||||||
@@ -12,14 +14,40 @@ Items.ARMOR = "armor"
|
|||||||
Items.RING = "ring"
|
Items.RING = "ring"
|
||||||
Items.FOOD = "food"
|
Items.FOOD = "food"
|
||||||
Items.KEY = "key"
|
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
|
-- Catalogs list every distinct type in a fixed order; identification assigns a
|
||||||
-- random appearance to each entry, so the order here must stay stable.
|
-- 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 = {
|
Items.potionTypes = {
|
||||||
{ effect = "heal", name = "potion of healing", power = 10 },
|
{ effect = "heal", name = "potion of healing", power = 10 },
|
||||||
{ effect = "poison", name = "potion of poison", power = 3 },
|
{ effect = "poison", name = "potion of poison", power = 3 },
|
||||||
{ effect = "strength", name = "potion of strength", power = 0 },
|
{ effect = "strength", name = "potion of strength", power = 0 },
|
||||||
{ effect = "haste", name = "potion of speed", 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 = {
|
Items.scrollTypes = {
|
||||||
@@ -27,6 +55,7 @@ Items.scrollTypes = {
|
|||||||
{ effect = "identify", name = "scroll of identify" },
|
{ effect = "identify", name = "scroll of identify" },
|
||||||
{ effect = "mapping", name = "scroll of magic mapping" },
|
{ effect = "mapping", name = "scroll of magic mapping" },
|
||||||
{ effect = "fireball", name = "scroll of fireball" },
|
{ effect = "fireball", name = "scroll of fireball" },
|
||||||
|
{ effect = "removecurse", name = "scroll of remove curse" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function Items.typeId(item)
|
function Items.typeId(item)
|
||||||
@@ -54,11 +83,13 @@ function Items.scroll(effect)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Items.weapon(name, power)
|
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
|
end
|
||||||
|
|
||||||
function Items.armor(name, power)
|
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
|
end
|
||||||
|
|
||||||
function Items.ring(name, attack, defense)
|
function Items.ring(name, attack, defense)
|
||||||
@@ -73,26 +104,115 @@ function Items.key(name)
|
|||||||
return { kind = Items.KEY, name = name or "iron key", glyph = "*" }
|
return { kind = Items.KEY, name = name or "iron key", glyph = "*" }
|
||||||
end
|
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)
|
function Items.equipWeapon(item, entity)
|
||||||
if entity.weapon then
|
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
|
entity.inventory[#entity.inventory + 1] = entity.weapon
|
||||||
end
|
end
|
||||||
entity.weapon = item
|
entity.weapon = item
|
||||||
entity.attack = entity.attack + item.power
|
local gained = Durability.effectivePower(item)
|
||||||
return item.power
|
entity.attack = entity.attack + gained
|
||||||
|
return gained
|
||||||
end
|
end
|
||||||
|
|
||||||
function Items.equipArmor(item, entity)
|
function Items.equipArmor(item, entity)
|
||||||
if entity.armor then
|
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
|
entity.inventory[#entity.inventory + 1] = entity.armor
|
||||||
end
|
end
|
||||||
entity.armor = item
|
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
|
return item.power
|
||||||
end
|
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)
|
function Items.equipRing(item, entity)
|
||||||
if entity.ring then
|
if entity.ring then
|
||||||
entity.attack = entity.attack - entity.ring.attack
|
entity.attack = entity.attack - entity.ring.attack
|
||||||
@@ -119,6 +239,10 @@ function Items.use(item, entity)
|
|||||||
return Items.equipArmor(item, entity)
|
return Items.equipArmor(item, entity)
|
||||||
elseif item.kind == Items.RING then
|
elseif item.kind == Items.RING then
|
||||||
return Items.equipRing(item, entity)
|
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
|
end
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
@@ -18,6 +18,25 @@ function Level.new(fields)
|
|||||||
floorItems = fields.floorItems or {},
|
floorItems = fields.floorItems or {},
|
||||||
traps = fields.traps or {},
|
traps = fields.traps or {},
|
||||||
lights = fields.lights 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,
|
dark = fields.dark or false,
|
||||||
explored = fields.explored or {},
|
explored = fields.explored or {},
|
||||||
upX = fields.upX, upY = fields.upY,
|
upX = fields.upX, upY = fields.upY,
|
||||||
@@ -34,6 +53,13 @@ function Level:entityAt(x, y)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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)
|
function Level:trapAt(x, y)
|
||||||
for _, trap in ipairs(self.traps) do
|
for _, trap in ipairs(self.traps) do
|
||||||
if trap.x == x and trap.y == y then return trap end
|
if trap.x == x and trap.y == y then return trap end
|
||||||
@@ -41,6 +67,90 @@ function Level:trapAt(x, y)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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)
|
function Level:itemsAt(x, y)
|
||||||
local here = {}
|
local here = {}
|
||||||
for _, ground in ipairs(self.floorItems) do
|
for _, ground in ipairs(self.floorItems) do
|
||||||
@@ -79,6 +189,25 @@ function Level:toData()
|
|||||||
floorItems = self.floorItems,
|
floorItems = self.floorItems,
|
||||||
traps = self.traps,
|
traps = self.traps,
|
||||||
lights = self.lights,
|
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,
|
dark = self.dark,
|
||||||
explored = explored,
|
explored = explored,
|
||||||
upX = self.upX, upY = self.upY,
|
upX = self.upX, upY = self.upY,
|
||||||
@@ -103,6 +232,25 @@ function Level.fromData(snapshot)
|
|||||||
floorItems = snapshot.floorItems,
|
floorItems = snapshot.floorItems,
|
||||||
traps = snapshot.traps,
|
traps = snapshot.traps,
|
||||||
lights = snapshot.lights,
|
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,
|
dark = snapshot.dark,
|
||||||
explored = explored,
|
explored = explored,
|
||||||
upX = snapshot.upX, upY = snapshot.upY,
|
upX = snapshot.upX, upY = snapshot.upY,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -4,20 +4,41 @@
|
|||||||
-- depth so lower floors bite harder.
|
-- depth so lower floors bite harder.
|
||||||
|
|
||||||
local Entity = require("entity")
|
local Entity = require("entity")
|
||||||
|
local Factions = require("factions")
|
||||||
|
local Disease = require("disease")
|
||||||
|
|
||||||
local Monsters = {}
|
local Monsters = {}
|
||||||
|
|
||||||
Monsters.species = {
|
Monsters.species = {
|
||||||
{ key = "rat", glyph = "r", name = "rat", hp = 4, attack = 2, defense = 0, speed = 110, ai = "chase", minDepth = 1 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 = {}
|
Monsters.byKey = {}
|
||||||
@@ -36,9 +57,28 @@ function Monsters.forDepth(depth)
|
|||||||
return eligible
|
return eligible
|
||||||
end
|
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)
|
function Monsters.create(spec, id, x, y, depth)
|
||||||
local bonusHp = depth - 1
|
local bonusHp = depth - 1
|
||||||
return Entity.new({
|
local entity = Entity.new({
|
||||||
id = id,
|
id = id,
|
||||||
x = x, y = y,
|
x = x, y = y,
|
||||||
glyph = spec.glyph, name = spec.name, kind = "monster",
|
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,
|
attack = spec.attack, defense = spec.defense,
|
||||||
speed = spec.speed, range = spec.range or 0,
|
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
|
end
|
||||||
|
|
||||||
return Monsters
|
return Monsters
|
||||||
+132
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -4,9 +4,25 @@
|
|||||||
|
|
||||||
local Status = require("status")
|
local Status = require("status")
|
||||||
local Identify = require("identify")
|
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 Render = {}
|
||||||
|
|
||||||
|
local hazardGlyphs = { [Hazards.FIRE] = "`", [Hazards.GAS] = "," }
|
||||||
|
|
||||||
local function cell(game, x, y)
|
local function cell(game, x, y)
|
||||||
local level = game.level
|
local level = game.level
|
||||||
local map = level.map
|
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
|
if game.player.x == x and game.player.y == y then return "@" end
|
||||||
local monster = level:entityAt(x, y)
|
local monster = level:entityAt(x, y)
|
||||||
if monster then return monster.glyph end
|
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)
|
local here = level:itemsAt(x, y)
|
||||||
if #here > 0 then return here[1].item.glyph end
|
if #here > 0 then return here[1].item.glyph end
|
||||||
end
|
end
|
||||||
return map:get(x, y)
|
return map:get(x, y)
|
||||||
end
|
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 function statusTags(entity)
|
||||||
local tags = {}
|
local tags = {}
|
||||||
for _, active in ipairs(entity.statuses) do
|
for _, active in ipairs(entity.statuses) do
|
||||||
tags[#tags + 1] = active.kind
|
tags[#tags + 1] = active.kind
|
||||||
end
|
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
|
if #tags == 0 then return "none" end
|
||||||
return table.concat(tags, ",")
|
return table.concat(tags, ",")
|
||||||
end
|
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)
|
function Render.panel(game)
|
||||||
local player = game.player
|
local player = game.player
|
||||||
local level = game.level
|
local level = game.level
|
||||||
local weapon = player.weapon and Identify.label(game.identify, player.weapon) or "none"
|
local weapon = player.weapon
|
||||||
local armor = player.armor and Identify.label(game.identify, player.armor) or "none"
|
and gearLabel(player.weapon, Curses.label(player.weapon, Identify.label(game.identify, player.weapon))) or "none"
|
||||||
local ring = player.ring and player.ring.name 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 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 = {
|
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(),
|
player.hp, player.maxHp, player:effectiveAttack(), player:effectiveDefense(),
|
||||||
game.depth, game.turn, level.generator, level.dark and " dark" or ""),
|
player.level, game.depth, game.turn, level.generator, level.dark and " dark" or "",
|
||||||
string.format("food %d torch %s status %s", player.satiation, torch, statusTags(player)),
|
level.weather and (" " .. level.weather) or "", game.won and " WON" or ""),
|
||||||
string.format("weapon %s armor %s ring %s items %d foes %d",
|
string.format("xp %d/%d mana %d/%d food %d torch %s gold %d status %s",
|
||||||
weapon, armor, ring, #player.inventory, #level.entities),
|
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()
|
local last = game.log:last()
|
||||||
if last then lines[#lines + 1] = "- " .. last end
|
if last then lines[#lines + 1] = "- " .. last end
|
||||||
return table.concat(lines, "\n")
|
return table.concat(lines, "\n")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -2,6 +2,12 @@
|
|||||||
-- damage-over-time drains hp, regeneration restores it, stun skips the action,
|
-- damage-over-time drains hp, regeneration restores it, stun skips the action,
|
||||||
-- and haste/slow/strength feed modifiers into speed and combat. Effects are
|
-- and haste/slow/strength feed modifiers into speed and combat. Effects are
|
||||||
-- plain data on entity.statuses so they serialize without special handling.
|
-- 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 = {}
|
local Status = {}
|
||||||
|
|
||||||
@@ -12,20 +18,54 @@ Status.REGEN = "regen"
|
|||||||
Status.HASTE = "haste"
|
Status.HASTE = "haste"
|
||||||
Status.SLOW = "slow"
|
Status.SLOW = "slow"
|
||||||
Status.STRENGTH = "strength"
|
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.defs = {
|
||||||
[Status.POISON] = {
|
[Status.POISON] = {
|
||||||
glyph = "p",
|
glyph = "p",
|
||||||
tick = function(entity, potency)
|
tick = function(entity, potency)
|
||||||
entity.hp = entity.hp - potency
|
local dealt = Resistances.apply(entity, potency, Resistances.POISON)
|
||||||
return string.format("poison saps %s for %d", entity.name, potency)
|
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,
|
end,
|
||||||
},
|
},
|
||||||
[Status.BURNING] = {
|
[Status.BURNING] = {
|
||||||
glyph = "b",
|
glyph = "b",
|
||||||
tick = function(entity, potency)
|
tick = function(entity, potency)
|
||||||
entity.hp = entity.hp - potency
|
local dealt = Conductivity.applyFire(entity, potency)
|
||||||
return string.format("flames sear %s for %d", entity.name, 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,
|
end,
|
||||||
},
|
},
|
||||||
[Status.REGEN] = {
|
[Status.REGEN] = {
|
||||||
@@ -34,7 +74,9 @@ Status.defs = {
|
|||||||
local before = entity.hp
|
local before = entity.hp
|
||||||
entity.hp = math.min(entity.maxHp, entity.hp + potency)
|
entity.hp = math.min(entity.maxHp, entity.hp + potency)
|
||||||
if entity.hp > before then
|
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
|
end
|
||||||
return nil
|
return nil
|
||||||
end,
|
end,
|
||||||
@@ -43,9 +85,38 @@ Status.defs = {
|
|||||||
[Status.HASTE] = { glyph = "h", speed = 60 },
|
[Status.HASTE] = { glyph = "h", speed = 60 },
|
||||||
[Status.SLOW] = { glyph = "l", speed = -50 },
|
[Status.SLOW] = { glyph = "l", speed = -50 },
|
||||||
[Status.STRENGTH] = { glyph = "S", attack = 3 },
|
[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)
|
function Status.apply(entity, kind, duration, potency)
|
||||||
|
if kind == Status.BURNING and Conductivity.resistsIgnition(entity) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
potency = potency or 1
|
potency = potency or 1
|
||||||
for _, active in ipairs(entity.statuses) do
|
for _, active in ipairs(entity.statuses) do
|
||||||
if active.kind == kind then
|
if active.kind == kind then
|
||||||
@@ -66,6 +137,36 @@ function Status.has(entity, kind)
|
|||||||
return false
|
return false
|
||||||
end
|
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.
|
-- Advances every effect one step, applying its tick and dropping expired ones.
|
||||||
-- Returns the log lines produced this step, in effect order.
|
-- Returns the log lines produced this step, in effect order.
|
||||||
function Status.tick(entity)
|
function Status.tick(entity)
|
||||||
@@ -104,6 +205,15 @@ function Status.attackModifier(entity)
|
|||||||
return delta
|
return delta
|
||||||
end
|
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)
|
function Status.skipsTurn(entity)
|
||||||
for _, active in ipairs(entity.statuses) do
|
for _, active in ipairs(entity.statuses) do
|
||||||
if Status.defs[active.kind].skipsTurn then return true end
|
if Status.defs[active.kind].skipsTurn then return true end
|
||||||
|
|||||||
+154
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -14,6 +14,14 @@ Tiles.DOOR_LOCKED = "x"
|
|||||||
Tiles.TRAP = "^"
|
Tiles.TRAP = "^"
|
||||||
Tiles.WATER = "~"
|
Tiles.WATER = "~"
|
||||||
Tiles.RUBBLE = ":"
|
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 = {
|
local props = {
|
||||||
[Tiles.WALL] = { walkable = false, blocksSight = true, door = false },
|
[Tiles.WALL] = { walkable = false, blocksSight = true, door = false },
|
||||||
@@ -26,6 +34,14 @@ local props = {
|
|||||||
[Tiles.TRAP] = { walkable = true, blocksSight = false, door = false },
|
[Tiles.TRAP] = { walkable = true, blocksSight = false, door = false },
|
||||||
[Tiles.WATER] = { walkable = true, blocksSight = false, door = false },
|
[Tiles.WATER] = { walkable = true, blocksSight = false, door = false },
|
||||||
[Tiles.RUBBLE] = { 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]
|
local wallProps = props[Tiles.WALL]
|
||||||
@@ -50,4 +66,24 @@ function Tiles.isClosedDoor(glyph)
|
|||||||
return glyph == Tiles.DOOR_CLOSED or glyph == Tiles.DOOR_LOCKED
|
return glyph == Tiles.DOOR_CLOSED or glyph == Tiles.DOOR_LOCKED
|
||||||
end
|
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
|
return Tiles
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+227
-2
@@ -1,12 +1,44 @@
|
|||||||
-- Headless entry point. Examples:
|
-- Headless entry point. Examples:
|
||||||
-- lua warren.lua --seed 42 --auto 50
|
-- lua warren.lua --seed 42 --auto 50
|
||||||
-- lua warren.lua --seed 7 --auto 30 --save run.sav
|
-- 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 --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 "."
|
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
|
||||||
package.path = scriptDir .. "/src/?.lua;" .. scriptDir .. "/../src/?.lua;" .. package.path
|
package.path = scriptDir .. "/src/?.lua;" .. scriptDir .. "/../src/?.lua;" .. package.path
|
||||||
|
|
||||||
local Game = require("game")
|
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 function parseArgs(argv)
|
||||||
local opts = { seed = os.time() & 0xFFFF, auto = 0 }
|
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 == "--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 == "--save" then opts.save = argv[i + 1]; i = i + 2
|
||||||
elseif flag == "--load" then opts.load = 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
|
else i = i + 1 end
|
||||||
end
|
end
|
||||||
return opts
|
return opts
|
||||||
end
|
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 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
|
for _ = 1, opts.auto do
|
||||||
if not game.player:isAlive() then break end
|
if not game.player:isAlive() then break end
|
||||||
game:autoStep()
|
game:autoStep()
|
||||||
end
|
end
|
||||||
|
|
||||||
io.write(game:render(), "\n")
|
if opts.play then
|
||||||
|
playLoop(game)
|
||||||
|
else
|
||||||
|
io.write(game:render(), "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
if meta and opts.auto > 0 and (not game.player:isAlive() or game.won) then
|
||||||
|
local stats = game:metaStats()
|
||||||
|
local gained = Meta.renownFor(stats)
|
||||||
|
local unlocked = Meta.recordRun(meta, stats)
|
||||||
|
io.write(string.format("run recorded: +%d renown (total %d)\n", gained, meta.renown))
|
||||||
|
for _, name in ipairs(unlocked) do
|
||||||
|
io.write("achievement unlocked: " .. name .. "\n")
|
||||||
|
end
|
||||||
|
local report = Morgue.build(game, meta)
|
||||||
|
local score = Morgue.score(report)
|
||||||
|
local percentile = Analytics.percentile(meta.leaderboard, score)
|
||||||
|
local rank = Leaderboard.record(meta.leaderboard, {
|
||||||
|
score = score, className = report.className, depth = report.deepestDepth,
|
||||||
|
turn = report.turn, won = report.won, seed = game.seed,
|
||||||
|
})
|
||||||
|
if rank then
|
||||||
|
io.write(string.format("leaderboard: rank #%d with score %d (beat %.0f%% of prior runs)\n",
|
||||||
|
rank, score, percentile * 100))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if meta and opts.auto > 0 and not game.player:isAlive() then
|
||||||
|
Graves.record(meta, game:graveEntry())
|
||||||
|
io.write(string.format("a grave is left at depth %d\n", game.deepestDepth))
|
||||||
|
end
|
||||||
|
|
||||||
|
if meta then
|
||||||
|
Meta.save(meta, opts.metaPath)
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.exportPath then
|
||||||
|
local file = io.open(opts.exportPath, "w")
|
||||||
|
file:write(Export.leaderboardCsv(meta and meta.leaderboard or Leaderboard.new()))
|
||||||
|
file:close()
|
||||||
|
io.write("leaderboard exported to " .. opts.exportPath .. "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.exportCompendiumPath then
|
||||||
|
local file = io.open(opts.exportCompendiumPath, "w")
|
||||||
|
file:write(Export.compendiumCsv(meta and meta.compendium or Compendium.new()))
|
||||||
|
file:close()
|
||||||
|
io.write("compendium exported to " .. opts.exportCompendiumPath .. "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.report then
|
||||||
|
reportMeta(meta or Meta.new())
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.verify and not opts.load then
|
||||||
|
if meta then
|
||||||
|
io.write("replay skipped: verification only covers meta-free runs (see replay.lua)\n")
|
||||||
|
else
|
||||||
|
local report = Morgue.build(game)
|
||||||
|
local matches, mismatchField, score =
|
||||||
|
Replay.verify(opts.seed, { class = opts.class, mutators = opts.mutators }, opts.auto, report)
|
||||||
|
if matches then
|
||||||
|
io.write(string.format("replay verified: seed %d reproduces this run (score %d)\n", opts.seed, score))
|
||||||
|
else
|
||||||
|
io.write(string.format("replay mismatch at '%s': seed %d replayed to a different outcome (score %d)\n",
|
||||||
|
mismatchField, opts.seed, score))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if opts.save then
|
if opts.save then
|
||||||
game:save(opts.save)
|
game:save(opts.save)
|
||||||
io.write("saved to " .. opts.save .. "\n")
|
io.write("saved to " .. opts.save .. "\n")
|
||||||
end
|
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
|
||||||
Reference in new issue
Block a user