2673 lines
109 KiB
Lua
2673 lines
109 KiB
Lua
-- Turn orchestration built on an energy scheduler: the world advances actor by
|
|
-- actor until the player is due, the player acts, and the cycle repeats. Owns
|
|
-- the dungeon, the player, identification and the message log, and drives the
|
|
-- headless autopilot that descends the dungeon deterministically.
|
|
|
|
local Rng = require("rng")
|
|
local Tiles = require("tiles")
|
|
local Astar = require("astar")
|
|
local Los = require("los")
|
|
local Light = require("light")
|
|
local Combat = require("combat")
|
|
local Items = require("items")
|
|
local Shop = require("shop")
|
|
local Effects = require("effects")
|
|
local Status = require("status")
|
|
local Identify = require("identify")
|
|
local Builder = require("builder")
|
|
local Dungeon = require("dungeon")
|
|
local Entity = require("entity")
|
|
local MessageLog = require("messagelog")
|
|
local Scheduler = require("scheduler")
|
|
local Ai = require("ai")
|
|
local SaveGame = require("savegame")
|
|
local Render = require("render")
|
|
local Progression = require("progression")
|
|
local Monsters = require("monsters")
|
|
local Spells = require("spells")
|
|
local Throwing = require("throwing")
|
|
local Ranged = require("ranged")
|
|
local Abilities = require("abilities")
|
|
local Hazards = require("hazards")
|
|
local Terrain = require("terrain")
|
|
local Shrines = require("shrines")
|
|
local Classes = require("classes")
|
|
local Skills = require("skills")
|
|
local Crafting = require("crafting")
|
|
local Companions = require("companions")
|
|
local Mercenaries = require("mercenaries")
|
|
local Artifacts = require("artifacts")
|
|
local Sets = require("sets")
|
|
local Hunger = require("hunger")
|
|
local Mimics = require("mimics")
|
|
local Bosses = require("bosses")
|
|
local Phases = require("phases")
|
|
local Quests = require("quests")
|
|
local Meta = require("meta")
|
|
local Alchemy = require("alchemy")
|
|
local Resistances = require("resistances")
|
|
local Factions = require("factions")
|
|
local Encumbrance = require("encumbrance")
|
|
local Elites = require("elites")
|
|
local Vaults = require("vaults")
|
|
local Runewords = require("runewords")
|
|
local Atmosphere = require("atmosphere")
|
|
local Events = require("events")
|
|
local Stealth = require("stealth")
|
|
local Compendium = require("compendium")
|
|
local Nodes = require("nodes")
|
|
local Curses = require("curses")
|
|
local Momentum = require("momentum")
|
|
local Corpses = require("corpses")
|
|
local Digging = require("digging")
|
|
local Sanity = require("sanity")
|
|
local Graves = require("graves")
|
|
local Proficiency = require("proficiency")
|
|
local Ascension = require("ascension")
|
|
local Durability = require("durability")
|
|
local Conductivity = require("conductivity")
|
|
local Disarm = require("disarm")
|
|
local Reanimation = require("reanimation")
|
|
local Secrets = require("secrets")
|
|
local Chests = require("chests")
|
|
local Nemesis = require("nemesis")
|
|
local Scavenging = require("scavenging")
|
|
local Infighting = require("infighting")
|
|
local Blocks = require("blocks")
|
|
local Noise = require("noise")
|
|
local Disease = require("disease")
|
|
local Captives = require("captives")
|
|
local Level = require("level")
|
|
local Horde = require("horde")
|
|
local Branches = require("branches")
|
|
local Commands = require("commands")
|
|
|
|
-- Real feature history, oldest first, matching the fields Game.fromData
|
|
-- below has always had to guard with "or" because they arrived after the
|
|
-- original snapshot shape: essence and the vault/set-piece tallies, then the
|
|
-- skill tree and reputation, then sanity. A run save from before any of
|
|
-- those landed is missing exactly the fields each step below fills in.
|
|
local GameSave = SaveGame.new({
|
|
[1] = function(data)
|
|
data.playerExtra.essence = data.playerExtra.essence or 0
|
|
data.stats = data.stats or { kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0 }
|
|
data.stats.vaultsOpened = data.stats.vaultsOpened or 0
|
|
data.stats.setPiecesFound = data.stats.setPiecesFound or 0
|
|
return data
|
|
end,
|
|
[2] = function(data)
|
|
data.playerExtra.skills = data.playerExtra.skills or {}
|
|
data.playerExtra.skillPoints = data.playerExtra.skillPoints or 0
|
|
data.playerExtra.skillCooldowns = data.playerExtra.skillCooldowns or {}
|
|
data.playerExtra.reputation = data.playerExtra.reputation or {}
|
|
return data
|
|
end,
|
|
[3] = function(data)
|
|
data.playerExtra.sanity = data.playerExtra.sanity or Sanity.MAX
|
|
return data
|
|
end,
|
|
})
|
|
|
|
local Game = {}
|
|
Game.__index = Game
|
|
|
|
local FOV_RADIUS = 8
|
|
|
|
-- Which equip slot each wearable item kind occupies, used to check a slot
|
|
-- for a bound curse before letting a new piece take its place.
|
|
local slotByItemKind = {
|
|
[Items.WEAPON] = "weapon", [Items.ARMOR] = "armor",
|
|
[Items.RING] = "ring", [Items.BOW] = "bow",
|
|
}
|
|
|
|
local function sign(n)
|
|
if n > 0 then return 1 elseif n < 0 then return -1 else return 0 end
|
|
end
|
|
|
|
function Game.new(seed, options)
|
|
local game = setmetatable({}, Game)
|
|
game.seed = seed
|
|
game.rng = Rng.new(seed)
|
|
game.depth = 1
|
|
game.turn = 0
|
|
game.won = false
|
|
game.log = MessageLog.new(32)
|
|
game.identify = Identify.new(game.rng)
|
|
game.dungeon = Dungeon.new(seed, options)
|
|
game.maxDepth = Builder.maxDepth(options)
|
|
game.level = game.dungeon:get(1)
|
|
game.player = Entity.new({
|
|
id = 0, x = game.level.upX, y = game.level.upY,
|
|
glyph = "@", name = "you", kind = "player",
|
|
hp = 30, maxHp = 30, attack = 5, defense = 1, speed = 100,
|
|
})
|
|
game.player.satiation = Hunger.START
|
|
game.player.sanity = Sanity.MAX
|
|
game.player.gold = 20
|
|
game.player.essence = 0
|
|
game.player.light = { fuel = 400, radius = 8 }
|
|
game.player.level = 1
|
|
game.player.xp = 0
|
|
game.player.xpNext = Progression.xpToNext(1)
|
|
game.player.mana = 10
|
|
game.player.maxMana = 10
|
|
game.player.spellsKnown = { "missile" }
|
|
game.player.inventory = {
|
|
Items.potion(10), Items.potion(10), Items.food("ration", 400), Items.scroll("mapping"),
|
|
}
|
|
game.player.skills = {}
|
|
game.player.skillPoints = 1
|
|
game.player.skillCooldowns = {}
|
|
game.player.reputation = {}
|
|
game.deepestDepth = 1
|
|
game.stats = {
|
|
kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 0,
|
|
setPiecesFound = 0, chestsOpened = 0,
|
|
}
|
|
if options and options.meta then
|
|
Meta.applyAll(options.meta, game.player)
|
|
-- Reuses options.meta's own table (rather than copying it) so every
|
|
-- sighting/kill recorded mid-run is already sitting in the table
|
|
-- warren.lua writes back out with Meta.save at the end of the run.
|
|
options.meta.compendium = options.meta.compendium or Compendium.new()
|
|
game.compendium = options.meta.compendium
|
|
else
|
|
game.compendium = Compendium.new()
|
|
end
|
|
if options and options.class and Classes.isValid(options.class) then
|
|
Classes.apply(game.player, options.class)
|
|
end
|
|
game.ascension = Ascension.resolve(options and options.meta, options and options.mutators)
|
|
Ascension.applyToPlayer(game.ascension, game.player)
|
|
game.visible = {}
|
|
game.monsterSeq = 0
|
|
game.nemesis = nil
|
|
game:computeVisible()
|
|
return game
|
|
end
|
|
|
|
-- Snapshot fed to meta.lua at the end of a run (see warren.lua's --meta
|
|
-- flag): everything Meta.renownFor and every achievement condition read,
|
|
-- gathered in one place so neither has to know Game's own field names.
|
|
function Game:metaStats()
|
|
return {
|
|
deepestDepth = self.deepestDepth,
|
|
kills = self.stats.kills,
|
|
bossKills = self.stats.bossKills,
|
|
questsCompleted = self.stats.questsCompleted,
|
|
artifactsFound = self.stats.artifactsFound,
|
|
vaultsOpened = self.stats.vaultsOpened,
|
|
setPiecesFound = self.stats.setPiecesFound,
|
|
chestsOpened = self.stats.chestsOpened,
|
|
won = self.won,
|
|
mutators = self.ascension,
|
|
}
|
|
end
|
|
|
|
-- The grave this run leaves behind for a future one to stumble across (see
|
|
-- graves.lua and Builder's options.graves): only warren.lua's --meta path
|
|
-- ever calls this, and only once a run has actually ended in death -
|
|
-- winning walks out with the Amulet, not a grave. className falls back to
|
|
-- "wanderer" for a run that never picked a class at all.
|
|
function Game:graveEntry()
|
|
return {
|
|
className = self.player.class or "wanderer",
|
|
depth = self.deepestDepth,
|
|
gold = self.player.gold,
|
|
essence = self.player.essence,
|
|
turn = self.turn,
|
|
}
|
|
end
|
|
|
|
-- Fresh id for a monster spawned mid-game (summon, split), disjoint from the
|
|
-- small sequential ids the builder hands out at level generation.
|
|
function Game:nextMonsterId()
|
|
self.monsterSeq = self.monsterSeq + 1
|
|
return 100000 + self.monsterSeq
|
|
end
|
|
|
|
function Game:spawnMinion(speciesKey, x, y)
|
|
local spec = Monsters.byKey[speciesKey]
|
|
local minion = Monsters.create(spec, self:nextMonsterId(), x, y, self.depth)
|
|
-- Summoned or split mid-fight, so it is already aware rather than asleep -
|
|
-- see stealth.lua's header for why builder-placed wild monsters differ.
|
|
minion.awareness = Stealth.HUNTING
|
|
self.level.entities[#self.level.entities + 1] = minion
|
|
return minion
|
|
end
|
|
|
|
function Game:currentEntities()
|
|
return self.level.entities
|
|
end
|
|
|
|
function Game:effectiveRadius()
|
|
local radius = FOV_RADIUS
|
|
if self.level.dark then
|
|
radius = self.player.light.fuel > 0 and self.player.light.radius or 2
|
|
end
|
|
radius = radius - Atmosphere.sightPenalty(self.level.weather) - Events.sightPenalty(self.level.event)
|
|
- Ascension.sightPenalty(self.ascension)
|
|
if radius < 1 then radius = 1 end
|
|
-- Blindness overrides every other light/weather/mutator term rather than
|
|
-- stacking with them: a blinded player run through with a full lantern in
|
|
-- clear weather still can't see past their own hand.
|
|
if Status.has(self.player, Status.BLIND) then radius = 1 end
|
|
return radius
|
|
end
|
|
|
|
-- Folds events.lua's level-scoped monster damage swing together with
|
|
-- whatever ascension.lua mutators the player opted into for this run, read
|
|
-- live at the point a monster actually lands a hit (Game:monsterMelee,
|
|
-- Game:monsterShoot, Game:triggerMimic and Game:triggerGrave's ambush
|
|
-- swings) rather than baked into any monster's own stats.
|
|
function Game:monsterDamageMultiplier()
|
|
return Events.monsterDamageMultiplier(self.level.event) * Ascension.damageMultiplier(self.ascension)
|
|
end
|
|
|
|
-- Folds events.lua's rich-veins swing together with ascension.lua's own
|
|
-- essence mutators, read live wherever essence is actually credited
|
|
-- (Game:awardKill, Nodes.harvest).
|
|
function Game:essenceMultiplier()
|
|
return Events.essenceMultiplier(self.level.event) * Ascension.essenceMultiplier(self.ascension)
|
|
end
|
|
|
|
function Game:computeVisible()
|
|
self.visible = Light.compute(self.level.map, self.player.x, self.player.y,
|
|
self:effectiveRadius(), self.level.lights)
|
|
for idx in pairs(self.visible) do
|
|
self.level:markExplored(idx)
|
|
end
|
|
self:noteSightings()
|
|
end
|
|
|
|
-- Records every currently-visible living monster's species into the
|
|
-- compendium (see compendium.lua) - silent bookkeeping, never touches the
|
|
-- message log, so it cannot perturb anything that reads recent log lines.
|
|
function Game:noteSightings()
|
|
local map = self.level.map
|
|
for _, entity in ipairs(self.level.entities) do
|
|
if entity.kind == "monster" and entity:isAlive() and self.visible[map:index(entity.x, entity.y)] then
|
|
Compendium.noteSighting(self.compendium, entity.species)
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:cleanupDead()
|
|
self.level:removeDead()
|
|
end
|
|
|
|
function Game:hasKey()
|
|
for _, item in ipairs(self.player.inventory) do
|
|
if item.kind == Items.KEY then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Game:hasAmulet()
|
|
for _, item in ipairs(self.player.inventory) do
|
|
if item.kind == Items.AMULET then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Game:randomFloorCell()
|
|
local map = self.level.map
|
|
local cells = {}
|
|
for y = 1, map.height do
|
|
for x = 1, map.width do
|
|
if map:isWalkable(x, y) and not self.level:entityAt(x, y)
|
|
and not (x == self.player.x and y == self.player.y) then
|
|
cells[#cells + 1] = { x = x, y = y }
|
|
end
|
|
end
|
|
end
|
|
if #cells == 0 then return nil end
|
|
local pick = cells[self.rng:range(1, #cells)]
|
|
return pick.x, pick.y
|
|
end
|
|
|
|
function Game:revealWholeLevel()
|
|
local map = self.level.map
|
|
for idx = 1, map.width * map.height do
|
|
self.level:markExplored(idx)
|
|
end
|
|
end
|
|
|
|
-- Monster-turn helpers used by the ai module.
|
|
|
|
function Game:playerAdjacent(monster)
|
|
return math.abs(monster.x - self.player.x) + math.abs(monster.y - self.player.y) == 1
|
|
end
|
|
|
|
-- The player-aligned companion, hireling, or thrall standing right next to
|
|
-- `monster` (Manhattan-1, the same cardinal-only adjacency Game:playerAdjacent
|
|
-- itself uses), or nil if none is that close. A hostile monster whose only
|
|
-- route toward the player runs through a one-wide corridor an ally is
|
|
-- standing in cannot path around it (Game:stepMonsterToward's own
|
|
-- blockedForMonster treats any occupied non-goal cell as a wall), so without
|
|
-- this check every ai.lua pursuit behaviour just holds position forever once
|
|
-- it reaches that ally's doorstep - the ally becomes an unkillable, un-
|
|
-- fightable roadblock rather than the combatant Game:onActorDeath and the
|
|
-- companion/mercenary/thrall noise-emission work already treat it as. Ties
|
|
-- break toward the lower entity id, matching Game:nearestAlly's own rule.
|
|
function Game:adjacentAlly(monster)
|
|
local best
|
|
for _, other in ipairs(self.level.entities) do
|
|
if other ~= monster and other:isAlive()
|
|
and (other.kind == "companion" or other.kind == "hireling" or other.kind == "thrall")
|
|
and math.abs(other.x - monster.x) + math.abs(other.y - monster.y) == 1
|
|
and (not best or other.id < best.id) then
|
|
best = other
|
|
end
|
|
end
|
|
return best
|
|
end
|
|
|
|
function Game:canSeePlayer(monster, range)
|
|
local penalty = Skills.detectionPenalty(self.player, self.level.dark)
|
|
local effectiveRange = math.max(range - penalty, 0)
|
|
-- A blinded monster can still feel the player out at melee range but loses
|
|
-- every longer-ranged aggro/pack/flee/ranged check in ai.lua - the same
|
|
-- clamp Game:effectiveRadius applies to a blinded player's own sight.
|
|
if Status.has(monster, Status.BLIND) then effectiveRange = math.min(effectiveRange, 1) end
|
|
local cheb = math.max(math.abs(monster.x - self.player.x), math.abs(monster.y - self.player.y))
|
|
if cheb > effectiveRange then return false end
|
|
return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y)
|
|
end
|
|
|
|
function Game:hasClearShot(monster)
|
|
return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y)
|
|
end
|
|
|
|
function Game:monsterMelee(monster)
|
|
if Skills.evades(self, self.player) then
|
|
self.log:add(string.format("you evade %s's attack", monster.name))
|
|
return
|
|
end
|
|
local outcome = Combat.attack(monster, self.player, self:monsterDamageMultiplier())
|
|
Momentum.onDamaged(self.player)
|
|
self.log:add(string.format("%s hits you for %d", monster.name, outcome.damage))
|
|
local broke = Durability.wearArmor(self.player)
|
|
if broke then self.log:add(broke) end
|
|
self:applyEliteOnHit(monster, outcome.damage, self.player)
|
|
local thorns = Artifacts.thornsAmount(self.player, outcome.damage)
|
|
if thorns > 0 then
|
|
-- Aegis of Embers "sears" its attacker by name and by log line, so this
|
|
-- reflect is fire damage, not a bare stat subtraction - a fire-immune
|
|
-- dragon (bestiary-real, reachable from depth 6) shrugs it off entirely,
|
|
-- exactly like every other fire source in the engine already does.
|
|
local dealt = Resistances.apply(monster, thorns, Resistances.FIRE)
|
|
if dealt > 0 then
|
|
monster.hp = monster.hp - dealt
|
|
self.log:add(string.format("your armor sears %s for %d", monster.name, dealt))
|
|
if not monster:isAlive() then self:awardKill(monster) end
|
|
else
|
|
self.log:add(string.format("your armor's embers wash harmlessly over %s", monster.name))
|
|
end
|
|
end
|
|
if monster.biteDisease and self.player:isAlive() and self.rng:chance(1, 4) then
|
|
if Disease.infect(self.player, monster.biteDisease) then
|
|
self.log:add(string.format("%s's bite leaves you feeling ill", monster.name))
|
|
end
|
|
end
|
|
self:emitNoise(monster.x, monster.y, Noise.MELEE)
|
|
end
|
|
|
|
-- A vampiric elite heals a portion of the damage it just dealt; a chilling
|
|
-- one leaves its status rider on the victim. Shared by melee and a ranged
|
|
-- shot so either delivery route triggers the same elite hooks - and, since
|
|
-- Infighting.strike resolves an elite fighting a rival monster or the
|
|
-- player's own companion/hireling/thrall through this same method, the log
|
|
-- line names the actual victim instead of always assuming it is the player.
|
|
function Game:applyEliteOnHit(attacker, dealt, victim)
|
|
local lifesteal = Elites.lifestealFraction(attacker)
|
|
if lifesteal > 0 then
|
|
local healed = math.floor(dealt * lifesteal)
|
|
if healed > 0 and attacker:isAlive() then
|
|
attacker.hp = math.min(attacker.maxHp, attacker.hp + healed)
|
|
local from = victim == self.player and "you" or victim.name
|
|
self.log:add(string.format("%s drains %d hp from %s", attacker.name, healed, from))
|
|
end
|
|
end
|
|
local rider = Elites.onHitRider(attacker)
|
|
if rider and victim:isAlive() then
|
|
Status.apply(victim, rider.status, rider.duration, rider.potency)
|
|
local who = victim == self.player and "you" or victim.name
|
|
self.log:add(string.format("%s's touch leaves %s %s", attacker.name, who, rider.status))
|
|
end
|
|
end
|
|
|
|
function Game:monsterShoot(monster)
|
|
local damageType = monster.breath == "fire" and Resistances.FIRE or Resistances.PHYSICAL
|
|
local dealt = Combat.damage(monster, self.player, self:monsterDamageMultiplier(), damageType)
|
|
self.player.hp = self.player.hp - dealt
|
|
Momentum.onDamaged(self.player)
|
|
self.log:add(string.format("%s shoots you for %d", monster.name, dealt))
|
|
local broke = Durability.wearArmor(self.player)
|
|
if broke then self.log:add(broke) end
|
|
self:applyEliteOnHit(monster, dealt, self.player)
|
|
if monster.breath == "fire" then
|
|
if Resistances.tierOf(self.player, Resistances.FIRE) == Resistances.IMMUNE then
|
|
self.log:add("the flames wash over you harmlessly")
|
|
else
|
|
Status.apply(self.player, Status.BURNING, 3, 2)
|
|
self.log:add("the flames catch you alight")
|
|
end
|
|
end
|
|
end
|
|
|
|
local function blockedForMonster(game, self, goalX, goalY)
|
|
return function(x, y)
|
|
if x == goalX and y == goalY then return false end
|
|
if not game.level.map:isWalkable(x, y) then return true end
|
|
if game.level:blockAt(x, y) then return true end
|
|
if x == game.player.x and y == game.player.y then return true end
|
|
local occupant = game.level:entityAt(x, y)
|
|
return occupant ~= nil and occupant ~= self
|
|
end
|
|
end
|
|
|
|
-- Springs whatever untouched trap sits under `entity` after it has just
|
|
-- moved there - a player-planted trap kit harms a monster or companion
|
|
-- exactly as a builder-placed one harms the player, since both live in the
|
|
-- same level.traps list and share springTrap's resolution. A levitating
|
|
-- entity (Status.LEVITATE) drifts clear of the pressure plate entirely -
|
|
-- deliberately silent and without marking the trap revealed, unlike the
|
|
-- lava/chasm skip in checkTerrainFor: a hidden trap is information the
|
|
-- player never earned just by floating over it, so levitation buys safety
|
|
-- without also buying detection.
|
|
function Game:checkTrapFor(entity)
|
|
if Status.has(entity, Status.LEVITATE) then return end
|
|
local trap = self.level:trapAt(entity.x, entity.y)
|
|
if trap and not trap.revealed then
|
|
self:springTrap(trap, entity)
|
|
if not entity:isAlive() then self:onActorDeath(entity) end
|
|
end
|
|
end
|
|
|
|
-- A pit with no floor below it: the player falls to the next depth and
|
|
-- takes fall damage, a monster that blunders in is simply gone. Builder
|
|
-- never seeds a chasm on the final floor, so there is always a level below
|
|
-- to land on.
|
|
function Game:fallIntoChasm(entity)
|
|
if entity == self.player then
|
|
local dealt = 6 + self.rng:range(0, 6)
|
|
self.player.hp = self.player.hp - dealt
|
|
self.log:add(string.format("the floor gives way - you plunge into the dark for %d", dealt))
|
|
if not self.player:isAlive() then return end
|
|
self.depth = self.depth + 1
|
|
self.level = self.dungeon:get(self.depth)
|
|
local x, y = self:randomFloorCell()
|
|
if not x then x, y = self.level.upX, self.level.upY end
|
|
self.player.x, self.player.y = x, y
|
|
if self.depth > self.deepestDepth then self.deepestDepth = self.depth end
|
|
self:computeVisible()
|
|
else
|
|
entity.hp = 0
|
|
self.log:add(string.format("%s plunges into the chasm and is gone", entity.name))
|
|
self:onActorDeath(entity)
|
|
end
|
|
end
|
|
|
|
-- Runs after entity has just come to rest on (dx, dy) from its previous
|
|
-- cell: ice keeps sliding it onward, lava burns it in place, and a chasm
|
|
-- ends the move altogether. Order matters - a slide can carry a traveller
|
|
-- straight off the ice and into whatever waits beyond it. A levitating
|
|
-- entity (Status.LEVITATE) never touches the ground at all, so it short
|
|
-- circuits every branch below - no slide, no burn, no fall - though it
|
|
-- still gets a log line when that skip actually mattered (standing over
|
|
-- lava or a chasm), since both are visible on the map and worth calling out.
|
|
function Game:checkTerrainFor(entity, dx, dy)
|
|
if not entity:isAlive() then return end
|
|
if Status.has(entity, Status.LEVITATE) then
|
|
if self.level.map:get(entity.x, entity.y) == Tiles.LAVA or Terrain.isChasm(self.level, entity) then
|
|
self.log:add(entity == self.player and "you drift safely over the hazard below"
|
|
or string.format("%s drifts safely over the hazard below", entity.name))
|
|
end
|
|
return
|
|
end
|
|
if self.level.map:get(entity.x, entity.y) == Tiles.ICE then
|
|
Terrain.slide(self.level, entity, dx, dy)
|
|
end
|
|
local line = Terrain.afflictLava(self.level, entity)
|
|
if line then
|
|
self.log:add(line)
|
|
if not entity:isAlive() then
|
|
if entity ~= self.player then self:onActorDeath(entity) end
|
|
return
|
|
end
|
|
end
|
|
if Terrain.isChasm(self.level, entity) then
|
|
self:fallIntoChasm(entity)
|
|
end
|
|
end
|
|
|
|
function Game:stepMonsterToward(monster, tx, ty)
|
|
local path = Astar.find(self.level.map, monster.x, monster.y, tx, ty,
|
|
blockedForMonster(self, monster, tx, ty))
|
|
if path and #path >= 2 then
|
|
local step = path[2]
|
|
if self.level.map:isWalkable(step.x, step.y) and not self.level:entityAt(step.x, step.y)
|
|
and not (step.x == self.player.x and step.y == self.player.y) then
|
|
local dx, dy = step.x - monster.x, step.y - monster.y
|
|
monster.x, monster.y = step.x, step.y
|
|
self:checkTrapFor(monster)
|
|
self:checkTerrainFor(monster, dx, dy)
|
|
end
|
|
end
|
|
end
|
|
|
|
local MONSTER_DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
|
|
|
function Game:stepMonsterAway(monster, tx, ty)
|
|
local current = math.abs(monster.x - tx) + math.abs(monster.y - ty)
|
|
local best, bx, by = current, nil, nil
|
|
for _, dir in ipairs(MONSTER_DIRS) do
|
|
local nx, ny = monster.x + dir[1], monster.y + dir[2]
|
|
if self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny)
|
|
and not (nx == self.player.x and ny == self.player.y) then
|
|
local dist = math.abs(nx - tx) + math.abs(ny - ty)
|
|
if dist > best then
|
|
best, bx, by = dist, nx, ny
|
|
end
|
|
end
|
|
end
|
|
if bx then
|
|
local dx, dy = bx - monster.x, by - monster.y
|
|
monster.x, monster.y = bx, by
|
|
self:checkTrapFor(monster)
|
|
self:checkTerrainFor(monster, dx, dy)
|
|
end
|
|
end
|
|
|
|
-- An erratic wanderer's random step or a confused chaser's overridden one
|
|
-- (Ai.act, both routed here) can land on an ally's cell exactly as easily as
|
|
-- the player's - Infighting.strike resolves that the same generic way
|
|
-- Game:adjacentAlly's own callers do, rather than the bump silently doing
|
|
-- nothing the way it would against an impassable wall.
|
|
function Game:moveMonster(monster, dx, dy)
|
|
local nx, ny = monster.x + dx, monster.y + dy
|
|
if nx == self.player.x and ny == self.player.y then
|
|
self:monsterMelee(monster)
|
|
return
|
|
end
|
|
local occupant = self.level:entityAt(nx, ny)
|
|
if occupant and (occupant.kind == "companion" or occupant.kind == "hireling" or occupant.kind == "thrall") then
|
|
Infighting.strike(self, monster, occupant)
|
|
return
|
|
end
|
|
if self.level.map:isWalkable(nx, ny) and not occupant then
|
|
monster.x, monster.y = nx, ny
|
|
self:checkTrapFor(monster)
|
|
self:checkTerrainFor(monster, dx, dy)
|
|
end
|
|
end
|
|
|
|
-- Mirrors ai.lua's own Ai.act confusion stumble for the three player-aligned
|
|
-- actor kinds (companion/hireling/thrall). None of those ever dispatch
|
|
-- through Ai.act, so without this they would leave Status.CONFUSE completely
|
|
-- inert on them despite Game:springTrap and friends already applying it to
|
|
-- "any victim" regardless of kind. Returns true whenever the roll actually
|
|
-- fires (whether or not the stumble itself finds anywhere to go), so each
|
|
-- caller can `if game:allyActsConfused(ally) then return end` first thing,
|
|
-- exactly the precedence Ai.act gives the same roll over chase/pack/etc.
|
|
-- Unlike a confused monster's stumble (moveMonster above, which treats a
|
|
-- step onto the player's cell as an attack), a confused ally's stumble onto
|
|
-- its own master's cell is harmless - an ally never attacks the player it is
|
|
-- bonded or hired to protect, so that one direction is simply blocked like a
|
|
-- wall rather than routed into any melee helper.
|
|
function Game:allyActsConfused(ally)
|
|
if not Status.has(ally, Status.CONFUSE) then return false end
|
|
if not self.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then return false end
|
|
local dir = MONSTER_DIRS[self.rng:range(1, #MONSTER_DIRS)]
|
|
local nx, ny = ally.x + dir[1], ally.y + dir[2]
|
|
if not (nx == self.player.x and ny == self.player.y)
|
|
and self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) then
|
|
ally.x, ally.y = nx, ny
|
|
self:checkTrapFor(ally)
|
|
self:checkTerrainFor(ally, dir[1], dir[2])
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- A burrower's turn when it can neither reach nor see the player through
|
|
-- open floor: senses the player's general direction through solid rock out
|
|
-- to Digging.SENSE_RANGE away and, of the two candidate cells toward them
|
|
-- (longer axis first - see Digging.directionsToward), either claws one
|
|
-- swing into the first one that is still diggable, exactly like Game:dig
|
|
-- but toolless and self-directed, or - if open floor already leads that
|
|
-- way - simply steps there so it closes the distance before it ever needs
|
|
-- to swing at all. Idles once out of range or once neither candidate cell
|
|
-- is diggable nor walkable, the same way "flee" idles once it loses sight.
|
|
function Game:monsterDig(monster, tx, ty)
|
|
local cheb = math.max(math.abs(monster.x - tx), math.abs(monster.y - ty))
|
|
if cheb == 0 or cheb > Digging.SENSE_RANGE then return end
|
|
for _, dir in ipairs(Digging.directionsToward(monster.x, monster.y, tx, ty)) do
|
|
local nx, ny = monster.x + dir[1], monster.y + dir[2]
|
|
if Digging.canDig(self.level.map, nx, ny) then
|
|
self.log:add(Digging.swing(self, monster, nx, ny))
|
|
if not monster:isAlive() then self:onActorDeath(monster) end
|
|
return
|
|
elseif self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny)
|
|
and not (nx == self.player.x and ny == self.player.y) then
|
|
monster.x, monster.y = nx, ny
|
|
self:checkTrapFor(monster)
|
|
self:checkTerrainFor(monster, dir[1], dir[2])
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:nearestAlly(monster)
|
|
local best, ally = math.huge, nil
|
|
for _, other in ipairs(self.level.entities) do
|
|
if other ~= monster and other:isAlive() then
|
|
local dist = math.abs(other.x - monster.x) + math.abs(other.y - monster.y)
|
|
if dist < best or (dist == best and ally and other.id < ally.id) then
|
|
best, ally = dist, other
|
|
end
|
|
end
|
|
end
|
|
return ally
|
|
end
|
|
|
|
-- A monster's death sweeps xp and essence to the player; a companion fights
|
|
-- for the player rather than yielding a reward for dying, so its death is
|
|
-- only logged and swept.
|
|
function Game:onActorDeath(actor)
|
|
if actor.kind == "companion" then
|
|
self.log:add("your " .. actor.name .. " falls")
|
|
self:cleanupDead()
|
|
elseif actor.kind == "hireling" then
|
|
self.log:add("your " .. actor.name .. " falls")
|
|
self:cleanupDead()
|
|
elseif actor.kind == "thrall" then
|
|
self.log:add("your " .. actor.name .. " crumbles back to bones")
|
|
self:cleanupDead()
|
|
else
|
|
self:awardKill(actor)
|
|
end
|
|
end
|
|
|
|
function Game:takeMonsterTurn(monster)
|
|
self.log:addAll(Status.tick(monster))
|
|
if not monster:isAlive() then
|
|
self:onActorDeath(monster)
|
|
return
|
|
end
|
|
Conductivity.tick(self.level, monster)
|
|
local hazardLine = Hazards.afflict(self.level, monster)
|
|
if hazardLine then self.log:add(hazardLine) end
|
|
if not monster:isAlive() then
|
|
self:onActorDeath(monster)
|
|
return
|
|
end
|
|
-- Mirrors Game:hungerTorchTick's own Disease.tick(self.player) call - a
|
|
-- disease is plain generic entity state (see disease.lua), and since
|
|
-- Infighting.strike can now infect a rival monster or the player's own
|
|
-- companion/hireling/thrall (not just the player), every non-player actor
|
|
-- needs its own infection to actually progress on its turn too, or an
|
|
-- infected ally would sit permanently incubating with no way to ever
|
|
-- erupt, escalate, or be noticed.
|
|
local diseaseLine = Disease.tick(monster)
|
|
if diseaseLine then self.log:add(diseaseLine) end
|
|
if not monster:isAlive() then
|
|
self:onActorDeath(monster)
|
|
return
|
|
end
|
|
local regenLine = Abilities.regenTick(monster)
|
|
if regenLine then self.log:add(regenLine) end
|
|
local enrageLine = Bosses.tryEnrage(monster)
|
|
if enrageLine then self.log:add(enrageLine) end
|
|
local phaseLine, phaseConsumedTurn = Phases.tick(self, monster)
|
|
if phaseLine then self.log:add(phaseLine) end
|
|
if not phaseConsumedTurn and not Status.skipsTurn(monster) then
|
|
if monster.kind == "companion" then
|
|
Companions.act(self, monster)
|
|
elseif monster.kind == "hireling" then
|
|
Mercenaries.act(self, monster)
|
|
elseif monster.kind == "thrall" then
|
|
Reanimation.act(self, monster)
|
|
elseif Infighting.tryAct(self, monster) then
|
|
-- A rivalry or a discorded mind has nothing to do with how this
|
|
-- monster currently feels about the player: it fights regardless of
|
|
-- wariness or - unlike Stealth.tick's own gating - whether it has
|
|
-- otherwise noticed the player at all this turn.
|
|
elseif Factions.isWary(self.player, monster) then
|
|
-- A wary tribe member holds its ground: neither provoked nor hated
|
|
-- into open hostility yet, it leaves the player be this turn.
|
|
elseif Stealth.tick(self, monster) then
|
|
Abilities.tickCooldown(monster)
|
|
if not Abilities.tryAct(self, monster) then
|
|
Ai.act(self, monster)
|
|
end
|
|
end
|
|
end
|
|
Scheduler.spend(monster)
|
|
self:cleanupDead()
|
|
end
|
|
|
|
function Game:runToPlayer()
|
|
while true do
|
|
local actor = Scheduler.next(self)
|
|
if actor == nil or actor == self.player then return end
|
|
self:takeMonsterTurn(actor)
|
|
if not self.player:isAlive() then return end
|
|
end
|
|
end
|
|
|
|
-- Player actions.
|
|
|
|
-- Common tail of any player-caused kill: log it, grant the xp it was worth,
|
|
-- and sweep it off the level. Shared by melee and offensive spells so xp
|
|
-- always tracks a felled monster regardless of what felled it.
|
|
function Game:awardKill(target)
|
|
self.log:add(target.name .. " dies")
|
|
local burst = Abilities.onDeath(self, target)
|
|
if burst then self.log:add(burst) end
|
|
local eliteBurst = Elites.onDeath(self, target)
|
|
if eliteBurst then self.log:add(eliteBurst) end
|
|
self.stats.kills = self.stats.kills + 1
|
|
if target.isBoss then self.stats.bossKills = self.stats.bossKills + 1 end
|
|
Factions.registerKill(self.player, target)
|
|
local xp = target.isBoss and Bosses.xpFor(target) or target.mimic and Mimics.xpFor()
|
|
or target.grave and Graves.xpFor() or Monsters.xpFor(target)
|
|
xp = xp + Elites.xpBonus(target, xp)
|
|
if target.nemesis then xp = xp + Nemesis.xpBonus(target.nemesisGrudge or 1) end
|
|
self.log:addAll(Progression.grantXp(self.player, xp))
|
|
self.player.essence = self.player.essence + math.floor(Crafting.essenceFor(xp) * self:essenceMultiplier())
|
|
Quests.registerKill(self.player, target.species)
|
|
Compendium.noteKill(self.compendium, target.species)
|
|
Corpses.spawn(self.level, target)
|
|
self:resolveQuest()
|
|
if target.isBoss then
|
|
local loot = Bosses.lootFor(target)
|
|
if loot then
|
|
self.level.floorItems[#self.level.floorItems + 1] = { x = target.x, y = target.y, item = loot }
|
|
self.log:add(string.format("%s drops %s", target.name, loot.name))
|
|
end
|
|
end
|
|
if target.grave then
|
|
local gold, essence = Graves.lootFor(target)
|
|
if gold > 0 then
|
|
self.player.gold = self.player.gold + gold
|
|
self.log:add(string.format("the shade scatters %d gold as it fades", gold))
|
|
end
|
|
if essence > 0 then
|
|
self.player.essence = self.player.essence + essence
|
|
end
|
|
end
|
|
if target.nemesis then
|
|
local bounty = Nemesis.bountyGold(target.nemesisGrudge or 1)
|
|
self.player.gold = self.player.gold + bounty
|
|
self.log:add(string.format("the hunt is over - you claim %d gold from %s", bounty, target.name))
|
|
self.nemesis = nil
|
|
end
|
|
self:cleanupDead()
|
|
end
|
|
|
|
-- Applies a weapon's on-hit rider - rolled affix or socketed gem, or both -
|
|
-- to a still-living target, logging each status it lands.
|
|
function Game:applyWeaponRiders(weapon, target)
|
|
if not (weapon and target:isAlive()) then return end
|
|
local riders = {}
|
|
if weapon.affix and weapon.affix.onHit then riders[#riders + 1] = weapon.affix.onHit end
|
|
if weapon.socket and weapon.socket.onHit then riders[#riders + 1] = weapon.socket.onHit end
|
|
for _, rider in ipairs(riders) do
|
|
Status.apply(target, rider.status, rider.duration, rider.potency)
|
|
self.log:add(string.format("%s is %s", target.name, rider.status))
|
|
end
|
|
end
|
|
|
|
-- Marks a monster as no longer wary (see factions.lua): once the player has
|
|
-- aimed any offensive action at it, a tribal target that used to hold back
|
|
-- fights (or flees) like any other monster from then on, hit or miss. Any
|
|
-- offensive action also wakes a dormant target outright (see stealth.lua) -
|
|
-- an arrow or a spell that misses still gives away exactly where you are.
|
|
function Game:provoke(target)
|
|
if target and target.kind == "monster" then
|
|
target.provoked = true
|
|
Stealth.alert(target, self.player.x, self.player.y)
|
|
end
|
|
end
|
|
|
|
-- Floods a burst of noise.lua sound out from (x, y) and gives every other
|
|
-- still-dormant monster the flood actually reaches a chance to stir - a
|
|
-- clash of steel three rooms over does not single out whichever target
|
|
-- Game:provoke already woke outright, it startles whatever else is close
|
|
-- enough to hear it too. A monster startled this way only ever starts
|
|
-- searching the noise's origin (see Stealth.hear), never snaps straight to
|
|
-- hunting the way a direct hit does.
|
|
function Game:emitNoise(x, y, loudness)
|
|
local field = Noise.propagate(self.level.map, x, y, loudness)
|
|
for _, entity in ipairs(self.level.entities) do
|
|
if entity.kind == "monster" and Stealth.isUnaware(entity) then
|
|
local intensity = Noise.intensityAt(field, self.level.map, entity.x, entity.y)
|
|
if intensity > 0 and self.rng:chance(intensity, loudness) then
|
|
Stealth.hear(entity, x, y)
|
|
self.log:add(string.format("%s stirs at the noise", entity.name))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:playerAttack(target)
|
|
-- Read before provoke() wakes the target below, so the bonus reflects
|
|
-- whatever it actually was the instant the blow landed.
|
|
local sneak = Stealth.sneakMultiplier(target)
|
|
self:provoke(target)
|
|
self:emitNoise(self.player.x, self.player.y, Noise.MELEE)
|
|
local multiplier = Skills.meleeDamageMultiplier(self.player, target) * sneak
|
|
local outcome = Combat.attack(self.player, target, multiplier)
|
|
Momentum.onMeleeHit(self.player)
|
|
if sneak > 1 then
|
|
self.log:add(string.format("you catch %s completely unaware", target.name))
|
|
end
|
|
self.log:add(string.format("you hit %s for %d", target.name, outcome.damage))
|
|
local broke = Durability.wearWeapon(self.player)
|
|
if broke then self.log:add(broke) end
|
|
local lifesteal = Artifacts.lifestealAmount(self.player, outcome.damage)
|
|
if lifesteal > 0 then
|
|
self.player.hp = math.min(self.player.maxHp, self.player.hp + lifesteal)
|
|
self.log:add(string.format("your %s drinks %d hp from %s", self.player.weapon.name, lifesteal, target.name))
|
|
end
|
|
self:applyWeaponRiders(self.player.weapon, target)
|
|
if Skills.consumePoisonBlade(self.player) and target:isAlive() then
|
|
Status.apply(target, Status.POISON, 5, 2)
|
|
self.log:add(string.format("%s is envenomed", target.name))
|
|
end
|
|
local masteredKey, masteredBonus = Proficiency.onMeleeHit(self.player)
|
|
if masteredKey then
|
|
self.log:add(string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus))
|
|
end
|
|
if outcome.killed then
|
|
self:awardKill(target)
|
|
else
|
|
local splitLine = Abilities.onHit(self, target)
|
|
if splitLine then self.log:add(splitLine) end
|
|
end
|
|
end
|
|
|
|
-- Attempts to cast a known spell, optionally at a target monster. Returns
|
|
-- true and logs its effect on success; on failure logs why and leaves all
|
|
-- state - mana included - untouched.
|
|
function Game:castSpell(key, target)
|
|
self:provoke(target)
|
|
local success, lines = Spells.cast(self, self.player, key, target)
|
|
self.log:addAll(lines)
|
|
if success then self:emitNoise(self.player.x, self.player.y, Noise.SPELL) end
|
|
return success
|
|
end
|
|
|
|
-- Hurls the inventory item at `index` at a monster. The item always leaves
|
|
-- the inventory on a successful throw, whatever it did on arrival.
|
|
function Game:throwAt(index, target)
|
|
local item = self.player.inventory[index]
|
|
if not item then return false end
|
|
self:provoke(target)
|
|
local success, lines = Throwing.resolve(self, self.player, item, target)
|
|
if success then
|
|
table.remove(self.player.inventory, index)
|
|
self:emitNoise(self.player.x, self.player.y, Noise.THROW)
|
|
end
|
|
self.log:addAll(lines)
|
|
return success
|
|
end
|
|
|
|
-- Throws a net from the inventory at `index` at a battered monster instead
|
|
-- of finishing it off in melee (see captives.lua). The net always leaves
|
|
-- the inventory once thrown, whether it actually holds the target or not;
|
|
-- a genuine catch removes the target from level.entities without ever
|
|
-- routing through Game:awardKill, so it earns no xp, essence, quest credit
|
|
-- or faction kill penalty - only a captive left standing on the floor.
|
|
function Game:captureAt(index, target)
|
|
local item = self.player.inventory[index]
|
|
if not item then return false end
|
|
self:provoke(target)
|
|
local success, lines = Captives.resolve(self, self.player, item, target)
|
|
if success then
|
|
table.remove(self.player.inventory, index)
|
|
self:emitNoise(self.player.x, self.player.y, Noise.THROW)
|
|
self:cleanupDead()
|
|
end
|
|
self.log:addAll(lines)
|
|
return success
|
|
end
|
|
|
|
-- Looses an arrow from the player's equipped bow at a monster. The arrow
|
|
-- always leaves the quiver on a successful shot, whatever it did on arrival.
|
|
function Game:fireAt(target)
|
|
self:provoke(target)
|
|
local success, lines = Ranged.fire(self, self.player, target)
|
|
self.log:addAll(lines)
|
|
if success then self:emitNoise(self.player.x, self.player.y, Noise.RANGED) end
|
|
return success
|
|
end
|
|
|
|
-- Spends gold to smooth over the player's standing with `faction` (see
|
|
-- factions.lua): cools a grudge, or pads an existing friendship, by a fixed
|
|
-- amount. The asking price climbs the deeper the relationship has soured.
|
|
function Game:appeaseFaction(faction)
|
|
local success, cost = Factions.appease(self.player, faction)
|
|
if success then
|
|
self.log:add(string.format("your tribute smooths things over with the %s (%d gold)", faction, cost))
|
|
elseif cost == 0 then
|
|
self.log:add(string.format("the %s already think as well of you as they're going to", faction))
|
|
else
|
|
self.log:add(string.format("you need %d gold to make peace with the %s", cost, faction))
|
|
end
|
|
return success
|
|
end
|
|
|
|
-- Prays at the shrine standing under the player, spending its gold price for
|
|
-- a shot at a permanent blessing (or, with rising odds the more this altar
|
|
-- has already been used, a curse instead). Returns false without any state
|
|
-- change when there is no shrine here or its price cannot be met.
|
|
function Game:prayAt(shrine)
|
|
local success, lines = Shrines.pray(self, shrine)
|
|
self.log:addAll(lines)
|
|
return success
|
|
end
|
|
|
|
-- Swings the player's equipped pick at whatever lies one step away in
|
|
-- (dx, dy): a plain wall or the rubble left by an earlier swing. Returns
|
|
-- false without spending the swing when there is no pick readied or the
|
|
-- target cell has nothing left to dig (open floor, a vault door, out of
|
|
-- bounds); otherwise always spends one use of the pick's durability, even on
|
|
-- an unlucky cave-in that costs hp instead of progress (see digging.lua).
|
|
function Game:dig(dx, dy)
|
|
if not self.player.pick then
|
|
self.log:add("you have nothing to dig with")
|
|
return false
|
|
end
|
|
local nx, ny = self.player.x + dx, self.player.y + dy
|
|
if not Digging.canDig(self.level.map, nx, ny) then
|
|
self.log:add("there is nothing here to dig")
|
|
return false
|
|
end
|
|
self.log:add(Digging.swing(self, self.player, nx, ny))
|
|
local brokenLine = Digging.wearPick(self.player)
|
|
if brokenLine then self.log:add(brokenLine) end
|
|
self:emitNoise(nx, ny, Noise.DIG)
|
|
return true
|
|
end
|
|
|
|
-- Harvests whatever resource node stands under the player: an ore vein pays
|
|
-- out essence, a gem seam yields a raw gem, both free of any gold cost -
|
|
-- unlike a shrine's prayer, the only thing rationing this is the node's own
|
|
-- charges (see nodes.lua). Returns false without state change when there is
|
|
-- no node here or it has already been picked dry this trip.
|
|
function Game:harvestNode()
|
|
local node = self.level:nodeAt(self.player.x, self.player.y)
|
|
if not node then
|
|
self.log:add("there is nothing here to harvest")
|
|
return false
|
|
end
|
|
local success, line = Nodes.harvest(self, node)
|
|
self.log:add(line)
|
|
return success
|
|
end
|
|
|
|
-- Butchers whatever corpse lies under the player into raw meat (Items.FOOD),
|
|
-- eaten later exactly like a found ration - unless the corpse has already
|
|
-- gone past its rot threshold (corpses.lua), in which case it poisons the
|
|
-- player instead. Either way the corpse itself is spent in one stroke.
|
|
-- Returns false without state change when there is nothing here to carve.
|
|
function Game:butcherCorpse()
|
|
local corpse = self.level:corpseAt(self.player.x, self.player.y)
|
|
if not corpse then
|
|
self.log:add("there is nothing here to butcher")
|
|
return false
|
|
end
|
|
self.log:add(Corpses.butcher(self, corpse))
|
|
return true
|
|
end
|
|
|
|
-- Raises whatever fresh corpse lies under the player into a temporary
|
|
-- skeletal thrall (see reanimation.lua), spending both the corpse and a
|
|
-- bite of sanity in one stroke - a deliberate trade the autopilot never
|
|
-- makes on its own, unlike Game:butcherCorpse's free meal, so this is only
|
|
-- ever called directly. Returns false without state change when there is
|
|
-- no corpse here or Reanimation.raise itself refuses the attempt.
|
|
function Game:raiseCorpse()
|
|
local corpse = self.level:corpseAt(self.player.x, self.player.y)
|
|
if not corpse then
|
|
self.log:add("there is nothing here to raise")
|
|
return false
|
|
end
|
|
local thrall, failMsg = Reanimation.raise(self, corpse)
|
|
if not thrall then
|
|
self.log:add(failMsg)
|
|
return false
|
|
end
|
|
self.log:add("bones knit and rise - your " .. thrall.name .. " answers the call")
|
|
return true
|
|
end
|
|
|
|
-- Carves whatever fresh corpse lies under the player for the herb its own
|
|
-- kind carries (see scavenging.lua) instead of a meal or a thrall - a third
|
|
-- deliberate trade for the same tile, only ever called directly like
|
|
-- Game:raiseCorpse, never by the autopilot. Returns false without state
|
|
-- change when there is no corpse here or Scavenging.harvest itself refuses.
|
|
function Game:scavengeCorpse()
|
|
local corpse = self.level:corpseAt(self.player.x, self.player.y)
|
|
if not corpse then
|
|
self.log:add("there is nothing here to scavenge")
|
|
return false
|
|
end
|
|
local harvested, message = Scavenging.harvest(self, corpse)
|
|
self.log:add(message)
|
|
return harvested
|
|
end
|
|
|
|
-- Opens whatever chest stands under the player. An unlocked one (or one
|
|
-- already forced open) yields its hoard outright; a locked one first tries
|
|
-- a carried key, exactly like Game:openDoor's own certain trade, and only
|
|
-- falls back to a lockpick gamble (Chests.pick, which risks springing a
|
|
-- trapped lock) once no key can be found. Returns false without state
|
|
-- change when there is nothing here to open, it is already empty, or it is
|
|
-- locked with neither a key nor a lockpick on hand.
|
|
function Game:openChest()
|
|
local chest = self.level:chestAt(self.player.x, self.player.y)
|
|
if not chest then
|
|
self.log:add("there is nothing here to open")
|
|
return false
|
|
end
|
|
if chest.opened then
|
|
self.log:add("this chest is already empty")
|
|
return false
|
|
end
|
|
local success, line
|
|
if not Chests.isLocked(chest) then
|
|
success, line = Chests.open(self.player, chest)
|
|
else
|
|
success, line = Chests.unlockWithKey(self.player, chest)
|
|
if not success then success, line = Chests.pick(self, chest) end
|
|
end
|
|
self.log:add(line)
|
|
if success then self.stats.chestsOpened = self.stats.chestsOpened + 1 end
|
|
return success
|
|
end
|
|
|
|
-- Frees whatever captive stands under the player back to its own kind for
|
|
-- nothing but goodwill (see Captives.ransom) - the one resolution that can
|
|
-- pull a wary tribal faction (factions.lua's neutralFactions) back out of
|
|
-- open hostility rather than deepening it further. Returns false without
|
|
-- state change when there is no captive here.
|
|
function Game:ransomCaptive()
|
|
local captive = self.level:captiveAt(self.player.x, self.player.y)
|
|
if not captive then
|
|
self.log:add("there is nothing here to ransom")
|
|
return false
|
|
end
|
|
Captives.ransom(self.level, self.player, captive)
|
|
self.log:add(string.format("you release %s - the %s think a little better of you for it", captive.name, captive.faction))
|
|
return true
|
|
end
|
|
|
|
-- Sells whatever captive stands under the player for gold at its own
|
|
-- faction's expense (see Captives.sell). Returns false without state change
|
|
-- when there is no captive here.
|
|
function Game:sellCaptive()
|
|
local captive = self.level:captiveAt(self.player.x, self.player.y)
|
|
if not captive then
|
|
self.log:add("there is nothing here to sell")
|
|
return false
|
|
end
|
|
local paid = Captives.sell(self.level, self.player, captive)
|
|
self.log:add(string.format("you trade %s away for %d gold", captive.name, paid))
|
|
return true
|
|
end
|
|
|
|
-- Interrogates whatever captive stands under the player instead of ransoming
|
|
-- or selling it (see Captives.interrogate): no gold, no reputation swing,
|
|
-- just every currently-hidden secret door on this floor found outright plus
|
|
-- a trickle of essence. Returns false without state change when there is no
|
|
-- captive here.
|
|
function Game:interrogateCaptive()
|
|
local captive = self.level:captiveAt(self.player.x, self.player.y)
|
|
if not captive then
|
|
self.log:add("there is nothing here to interrogate")
|
|
return false
|
|
end
|
|
local revealed = Captives.interrogate(self.level, self.player, captive)
|
|
self.log:add(string.format("%s breaks - %d hidden door%s in this floor give themselves away",
|
|
captive.name, #revealed, #revealed == 1 and "" or "s"))
|
|
return true
|
|
end
|
|
|
|
-- Binds the player to `board`'s bounty for free, logging the pact struck or
|
|
-- the reason it could not be. See Quests.accept for the acceptance rules.
|
|
function Game:tryAcceptQuest(board)
|
|
local success, line = Quests.accept(self.player, board)
|
|
self.log:add(line)
|
|
return success
|
|
end
|
|
|
|
-- Pays out the player's active bounty the moment it is fulfilled, granting
|
|
-- gold, xp and essence in one stroke and freeing the player to bind another.
|
|
-- A no-op with no active bounty or one still unmet.
|
|
function Game:resolveQuest()
|
|
local bounty = self.player.quest
|
|
if not bounty or not Quests.resolved(self.player, self.depth) then return false end
|
|
self.player.gold = self.player.gold + bounty.rewardGold
|
|
self.log:addAll(Progression.grantXp(self.player, bounty.rewardXp))
|
|
self.player.essence = self.player.essence + Crafting.essenceFor(bounty.rewardXp)
|
|
self.player.quest = nil
|
|
self.stats.questsCompleted = self.stats.questsCompleted + 1
|
|
Factions.adjust(self.player, Factions.MERCHANT, 3)
|
|
self.log:add(string.format("the bounty resolves: %d gold and %d xp", bounty.rewardGold, bounty.rewardXp))
|
|
return true
|
|
end
|
|
|
|
function Game:openDoor(x, y)
|
|
local map = self.level.map
|
|
if map:get(x, y) == Tiles.DOOR_LOCKED then
|
|
for i, item in ipairs(self.player.inventory) do
|
|
if item.kind == Items.KEY then
|
|
table.remove(self.player.inventory, i)
|
|
map:set(x, y, Tiles.DOOR_OPEN)
|
|
self.log:add("you unlock the door")
|
|
return true
|
|
end
|
|
end
|
|
self.log:add("the door is locked")
|
|
return true
|
|
end
|
|
map:set(x, y, Tiles.DOOR_OPEN)
|
|
self.log:add("you open the door")
|
|
return true
|
|
end
|
|
|
|
-- Springs `trap` on `victim`, whoever that is: reveals it on the map and
|
|
-- resolves its effect against that entity's own stats and position. Kept
|
|
-- generic so a player-planted trap kit (Game:useItem's Items.KIT branch)
|
|
-- harms a monster exactly as a builder-placed one harms the player.
|
|
function Game:springTrap(trap, victim)
|
|
trap.revealed = true
|
|
self.level.map:set(trap.x, trap.y, Tiles.TRAP)
|
|
local isPlayer = victim == self.player
|
|
local name = isPlayer and "you" or victim.name
|
|
if trap.effect == "dart" then
|
|
local dmg = 3 + self.depth // 2
|
|
victim.hp = victim.hp - dmg
|
|
self.log:add(isPlayer and string.format("a dart trap hits you for %d", dmg)
|
|
or string.format("a dart trap hits %s for %d", name, dmg))
|
|
elseif trap.effect == "poison" then
|
|
Status.apply(victim, Status.POISON, 5, 2)
|
|
self.log:add(isPlayer and "you stumble into a poison trap"
|
|
or string.format("%s stumbles into a poison trap", name))
|
|
elseif trap.effect == "confuse" then
|
|
Status.apply(victim, Status.CONFUSE, 6, 1)
|
|
self.log:add(isPlayer and "a cloud of disorienting spores bursts around you"
|
|
or string.format("a cloud of disorienting spores bursts around %s", name))
|
|
elseif trap.effect == "blind" then
|
|
Status.apply(victim, Status.BLIND, 6, 1)
|
|
self.log:add(isPlayer and "a flash of searing light leaves you blind"
|
|
or string.format("a flash of searing light leaves %s blind", name))
|
|
elseif trap.effect == "stun" then
|
|
Status.apply(victim, Status.STUN, 2, 1)
|
|
self.log:add(isPlayer and "a jolt of current locks up your muscles"
|
|
or string.format("a jolt of current locks up %s's muscles", name))
|
|
else
|
|
local x, y = self:randomFloorCell()
|
|
if x then
|
|
victim.x, victim.y = x, y
|
|
self.log:add(isPlayer and "a trap flings you across the level"
|
|
or string.format("a trap flings %s across the level", name))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:triggerTrap(trap)
|
|
self:springTrap(trap, self.player)
|
|
end
|
|
|
|
-- Attempts to disarm `trap`, already noticed (see disarm.lua's Disarm.sweep,
|
|
-- rolled once per turn in Game:endPlayerTurn) but not yet sprung. Refuses
|
|
-- outright, without even risking a spring, against a trap never detected or
|
|
-- one already triggered - there is nothing left here to disarm.
|
|
function Game:disarmTrap(trap)
|
|
if not trap or not trap.detected or trap.revealed then
|
|
self.log:add("there is nothing here you have noticed to disarm")
|
|
return false
|
|
end
|
|
local success, line = Disarm.attempt(self, trap)
|
|
if success then self.log:add(line) end
|
|
return success
|
|
end
|
|
|
|
-- Throws `lever`, a no-op once it has already been thrown. The instant this
|
|
-- pull completes every lever sharing its vault id, the shared door swings
|
|
-- open (its tile flips to a plain open door - see Vaults.pull) and the run's
|
|
-- vault tally advances for meta scoring; otherwise the lever just clunks
|
|
-- into its pulled state.
|
|
function Game:pullLever(lever)
|
|
local pulled, door = Vaults.pull(self.level, lever)
|
|
if not pulled then return false end
|
|
self.level.map:set(lever.x, lever.y, Tiles.LEVER_PULLED)
|
|
if door then
|
|
door.opened = true
|
|
self.level.map:set(door.x, door.y, Tiles.DOOR_OPEN)
|
|
self.stats.vaultsOpened = self.stats.vaultsOpened + 1
|
|
self.log:add("the vault door grinds open")
|
|
else
|
|
self.log:add("you pull the lever - it clunks into place")
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- A priced ware is only free for the taking once its shopkeeper is dead;
|
|
-- otherwise it charges the stamped price and is left on the ground when the
|
|
-- player cannot afford it yet (they simply walk past it).
|
|
function Game:wardedByLiveKeeper(ground)
|
|
if not ground.price then return false end
|
|
local keeper = self.level:entityById(ground.shopId)
|
|
return keeper ~= nil and keeper:isAlive()
|
|
end
|
|
|
|
-- Player-facing wrapper so callers never need to know encumbrance.lua works
|
|
-- entirely off entity.inventory/entity.level rather than any saved field.
|
|
function Game:encumbranceTier()
|
|
return Encumbrance.tierFor(self.player)
|
|
end
|
|
|
|
function Game:pickupHere()
|
|
local tierBefore = self:encumbranceTier()
|
|
for i = #self.level.floorItems, 1, -1 do
|
|
local ground = self.level.floorItems[i]
|
|
if ground.x == self.player.x and ground.y == self.player.y then
|
|
local item = ground.item
|
|
if item.mimic then
|
|
table.remove(self.level.floorItems, i)
|
|
self:triggerMimic(ground)
|
|
elseif item.grave then
|
|
table.remove(self.level.floorItems, i)
|
|
self:triggerGrave(ground)
|
|
elseif item.kind == Items.GOLD then
|
|
self.player.gold = self.player.gold + item.amount
|
|
self.log:add("you find " .. item.amount .. " gold")
|
|
table.remove(self.level.floorItems, i)
|
|
elseif self:wardedByLiveKeeper(ground) then
|
|
local paid = Shop.buy(self, ground)
|
|
if paid then
|
|
self.log:add(string.format("you buy %s for %d gold",
|
|
Identify.label(self.identify, item), paid))
|
|
table.remove(self.level.floorItems, i)
|
|
end
|
|
elseif item.kind == Items.AMMO then
|
|
local stack = self:findInInventory(function(existing)
|
|
return existing.kind == Items.AMMO and existing.name == item.name
|
|
end)
|
|
if stack then
|
|
self.player.inventory[stack].count = self.player.inventory[stack].count + item.count
|
|
else
|
|
self.player.inventory[#self.player.inventory + 1] = item
|
|
end
|
|
self.log:add(string.format("you pick up %d %s", item.count, item.name))
|
|
table.remove(self.level.floorItems, i)
|
|
else
|
|
if item.artifact then self.stats.artifactsFound = self.stats.artifactsFound + 1 end
|
|
if item.set then self.stats.setPiecesFound = self.stats.setPiecesFound + 1 end
|
|
self.player.inventory[#self.player.inventory + 1] = item
|
|
self.log:add("you pick up " .. Identify.label(self.identify, item))
|
|
table.remove(self.level.floorItems, i)
|
|
end
|
|
end
|
|
end
|
|
local tierAfter = self:encumbranceTier()
|
|
if tierAfter ~= tierBefore and tierAfter ~= Encumbrance.LIGHT then
|
|
if tierAfter == Encumbrance.OVERLOADED then
|
|
self.log:add("you are overloaded and moving sluggishly")
|
|
else
|
|
self.log:add("your pack grows heavy")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Unmasks a disguised floorItem the moment the player reaches for it:
|
|
-- drops the real hostile monster in its place (already hunting, never
|
|
-- dormant - see stealth.lua's header for why a builder-placed wild
|
|
-- monster differs) and lands one free ambush swing through combat.lua,
|
|
-- so the surprise still respects the player's defense and resistances
|
|
-- instead of being a flat, unavoidable trap tick.
|
|
function Game:triggerMimic(ground)
|
|
local mimic = Mimics.reveal(self:nextMonsterId(), ground.x, ground.y, self.depth)
|
|
mimic.awareness = Stealth.HUNTING
|
|
self.level.entities[#self.level.entities + 1] = mimic
|
|
self.log:add(ground.item.name .. " was a mimic!")
|
|
local outcome = Combat.attack(mimic, self.player, self:monsterDamageMultiplier())
|
|
Momentum.onDamaged(self.player)
|
|
self.log:add(string.format("it lashes out at you for %d damage", outcome.damage))
|
|
local broke = Durability.wearArmor(self.player)
|
|
if broke then self.log:add(broke) end
|
|
end
|
|
|
|
-- Wakes the vengeful shade sleeping under a disturbed grave marker (see
|
|
-- graves.lua): unlike a mimic's disguise, the marker never hid what it
|
|
-- was, but stepping onto it is still a mistake the same instant Game:
|
|
-- pickupHere makes it - one free ambush swing through combat.lua before
|
|
-- the shade settles into an ordinary chase on its own turns from then on.
|
|
function Game:triggerGrave(ground)
|
|
local shade = Graves.reveal(self:nextMonsterId(), ground.x, ground.y, ground)
|
|
shade.awareness = Stealth.HUNTING
|
|
self.level.entities[#self.level.entities + 1] = shade
|
|
self.log:add("the grave stirs - a vengeful shade rises!")
|
|
local outcome = Combat.attack(shade, self.player, self:monsterDamageMultiplier())
|
|
Momentum.onDamaged(self.player)
|
|
self.log:add(string.format("it lashes out at you for %d damage", outcome.damage))
|
|
local broke = Durability.wearArmor(self.player)
|
|
if broke then self.log:add(broke) end
|
|
end
|
|
|
|
-- Discards an inventory item onto the floor at the player's feet: the
|
|
-- opposite of pickupHere, needed once a hoard grows heavier than is
|
|
-- comfortable to carry (see encumbrance.lua). Equipped gear never reaches
|
|
-- this path - it always trades in place through items.equipWeapon and
|
|
-- friends - so this only ever touches the loose inventory array.
|
|
function Game:dropItem(index)
|
|
local item = self.player.inventory[index]
|
|
if not item then return nil end
|
|
table.remove(self.player.inventory, index)
|
|
self.level.floorItems[#self.level.floorItems + 1] = { x = self.player.x, y = self.player.y, item = item }
|
|
self.log:add("you drop " .. Identify.label(self.identify, item))
|
|
return item
|
|
end
|
|
|
|
-- The cheapest inventory item that is safe to shed first when overloaded:
|
|
-- never the amulet (the entire point of a run) or a key (worthless to a
|
|
-- shop and needed right where it was found). Returns nil if nothing but
|
|
-- those two remains.
|
|
function Game:cheapestDroppableItem()
|
|
local bestIndex, bestPrice
|
|
for i, item in ipairs(self.player.inventory) do
|
|
if item.kind ~= Items.AMULET and item.kind ~= Items.KEY then
|
|
local price = Shop.priceOf(item)
|
|
if not bestPrice or price < bestPrice then
|
|
bestIndex, bestPrice = i, price
|
|
end
|
|
end
|
|
end
|
|
return bestIndex
|
|
end
|
|
|
|
-- Sells an inventory item back to the economy at half its catalog price.
|
|
function Game:sellItem(index)
|
|
local item = self.player.inventory[index]
|
|
local value = Shop.sell(self, index)
|
|
if value then
|
|
self.log:add(string.format("you sell %s for %d gold", Identify.label(self.identify, item), value))
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- Spends gold to restore whichever of the player's weapon, armor or bow sits
|
|
-- in `slotName` back to full durability (see durability.lua), folding its
|
|
-- power back into attack/defense if wear had already broken it.
|
|
function Game:repairEquipped(slotName)
|
|
local item = self.player[slotName]
|
|
local success, result = Durability.repair(self.player, slotName)
|
|
if success then
|
|
self.log:add(string.format("you repair your %s for %d gold", item.name, result))
|
|
else
|
|
self.log:add("cannot repair: " .. result)
|
|
end
|
|
return success
|
|
end
|
|
|
|
-- The first of weapon/armor/bow, in that priority order, that has been worn
|
|
-- clean through to uselessness - never a merely dinged one still pulling its
|
|
-- weight, so the autopilot only ever reaches for its purse over gear that
|
|
-- has actually stopped working.
|
|
function Game:brokenEquippedSlot()
|
|
for _, slot in ipairs({ "weapon", "armor", "bow" }) do
|
|
local item = self.player[slot]
|
|
if item and Durability.isBroken(item) then return slot end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Taking a warded item without paying: the item is stolen outright and its
|
|
-- shopkeeper turns permanently hostile, chasing the player like any monster.
|
|
function Game:stealFromShop(index)
|
|
local ground = self.level.floorItems[index]
|
|
if not ground or not self:wardedByLiveKeeper(ground) then return false end
|
|
self.player.inventory[#self.player.inventory + 1] = ground.item
|
|
local keeper = self.level:entityById(ground.shopId)
|
|
keeper.hostile = true
|
|
Factions.adjust(self.player, Factions.MERCHANT, -20)
|
|
self.log:add("you steal " .. Identify.label(self.identify, ground.item) ..
|
|
"! the shopkeeper turns on you")
|
|
table.remove(self.level.floorItems, index)
|
|
return true
|
|
end
|
|
|
|
-- Resolves a crafting target: "weapon"/"armor" reach the currently equipped
|
|
-- piece (which has left the inventory array entirely - see items.equipWeapon/
|
|
-- equipArmor), anything else is an inventory index.
|
|
function Game:resolveGear(target)
|
|
if target == "weapon" then return self.player.weapon end
|
|
if target == "armor" then return self.player.armor end
|
|
return self.player.inventory[target]
|
|
end
|
|
|
|
function Game:forgeGem(element)
|
|
local ok, result = Crafting.forge(self.player, element)
|
|
if not ok then
|
|
self.log:add("cannot forge: " .. result)
|
|
return false
|
|
end
|
|
self.player.inventory[#self.player.inventory + 1] = result
|
|
self.log:add("you forge a " .. result.name)
|
|
return true
|
|
end
|
|
|
|
function Game:mergeGemsAt(indexA, indexB)
|
|
local gemA, gemB = self.player.inventory[indexA], self.player.inventory[indexB]
|
|
if not gemA or not gemB or gemA.kind ~= Items.GEM or gemB.kind ~= Items.GEM or indexA == indexB then
|
|
return false
|
|
end
|
|
local success, reason, merged = Crafting.mergeGems(gemA, gemB)
|
|
if not success then
|
|
self.log:add("cannot merge gems: " .. reason)
|
|
return false
|
|
end
|
|
local hi, lo = math.max(indexA, indexB), math.min(indexA, indexB)
|
|
table.remove(self.player.inventory, hi)
|
|
table.remove(self.player.inventory, lo)
|
|
self.player.inventory[#self.player.inventory + 1] = merged
|
|
self.log:add("you merge two gems into a " .. merged.name)
|
|
return true
|
|
end
|
|
|
|
function Game:socketItem(target, gemIndex)
|
|
local item = self:resolveGear(target)
|
|
local gem = self.player.inventory[gemIndex]
|
|
if not item or not gem or gem.kind ~= Items.GEM then return false end
|
|
local success, reason = Crafting.socket(self.player, item, gem)
|
|
if not success then
|
|
self.log:add("cannot socket: " .. reason)
|
|
return false
|
|
end
|
|
table.remove(self.player.inventory, gemIndex)
|
|
self.log:add("you socket a " .. gem.name .. " into your " .. item.name)
|
|
return true
|
|
end
|
|
|
|
-- Socketing a rune never removes it from the game the way a failed gem
|
|
-- socket attempt would leave the gem untouched in inventory - the rune is
|
|
-- always spent once it joins the chain, whether or not that completes a
|
|
-- runeword, since there is no "wrong" rune to protect the player from (see
|
|
-- Runewords.socket).
|
|
function Game:socketRune(target, runeIndex)
|
|
local item = self:resolveGear(target)
|
|
local rune = self.player.inventory[runeIndex]
|
|
if not item or not rune or rune.kind ~= Items.RUNE then return false end
|
|
local success, reason = Runewords.socket(item, rune)
|
|
if not success then
|
|
self.log:add("cannot socket rune: " .. reason)
|
|
return false
|
|
end
|
|
table.remove(self.player.inventory, runeIndex)
|
|
local word = Runewords.activeWord(item)
|
|
if word then
|
|
self.log:add("the runes align into " .. word.name .. "! your " .. item.name .. " hums with power")
|
|
else
|
|
self.log:add("you socket a " .. rune.name .. " into your " .. item.name)
|
|
end
|
|
return true
|
|
end
|
|
|
|
function Game:temperItem(target)
|
|
local item = self:resolveGear(target)
|
|
if not item then return false end
|
|
local success, reason = Crafting.temper(self.player, item)
|
|
if not success then
|
|
self.log:add("cannot temper: " .. reason)
|
|
return false
|
|
end
|
|
self.log:add("you temper your " .. item.name)
|
|
return true
|
|
end
|
|
|
|
function Game:enchantItem(target, affixKey)
|
|
local item = self:resolveGear(target)
|
|
if not item then return false end
|
|
local success, reason = Crafting.enchant(self.player, item, affixKey)
|
|
if not success then
|
|
self.log:add("cannot enchant: " .. reason)
|
|
return false
|
|
end
|
|
self.log:add("you enchant your " .. item.name)
|
|
return true
|
|
end
|
|
|
|
function Game:disenchantItem(target)
|
|
local item = self:resolveGear(target)
|
|
if not item then return false end
|
|
local success, refund = Crafting.disenchant(self.player, item)
|
|
if not success then
|
|
self.log:add("cannot disenchant: " .. refund)
|
|
return false
|
|
end
|
|
self.log:add(string.format("you disenchant %s for %d essence", item.name, refund))
|
|
return true
|
|
end
|
|
|
|
-- Combines the two carried herbs at indexA and indexB into whichever potion
|
|
-- Alchemy.brew resolves the pair to. Both herbs are spent regardless of the
|
|
-- outcome - a botched brew that curdles is not a free retry, and vents its
|
|
-- fumes as a gas hazard right underfoot instead of handing back a potion.
|
|
function Game:brewAt(indexA, indexB)
|
|
if not self.level:cauldronAt(self.player.x, self.player.y) then
|
|
self.log:add("there is no cauldron here")
|
|
return false
|
|
end
|
|
if indexA == indexB then return false end
|
|
local herbA, herbB = self.player.inventory[indexA], self.player.inventory[indexB]
|
|
if not herbA or not herbB or herbA.kind ~= Items.HERB or herbB.kind ~= Items.HERB then
|
|
self.log:add("you need two herbs to brew")
|
|
return false
|
|
end
|
|
local hi, lo = math.max(indexA, indexB), math.min(indexA, indexB)
|
|
table.remove(self.player.inventory, hi)
|
|
table.remove(self.player.inventory, lo)
|
|
local potion = Alchemy.brew(herbA.herb, herbB.herb)
|
|
if not potion then
|
|
Hazards.spawnGas(self.level, self.player.x, self.player.y, 3, 2)
|
|
self.log:add(string.format("the %s and %s curdle into choking fumes", herbA.name, herbB.name))
|
|
return false
|
|
end
|
|
Identify.reveal(self.identify, potion)
|
|
self.player.inventory[#self.player.inventory + 1] = potion
|
|
self.log:add("you brew " .. potion.name)
|
|
return true
|
|
end
|
|
|
|
-- Companions, hirelings and thralls are all player-owned allies (see
|
|
-- Game:onActorDeath), never a valid melee target for the player's own bump-
|
|
-- attack or autopilot targeting - both Game:movePlayer and Game:adjacentMonster
|
|
-- below share this one check so neither can turn "walk into your own hound"
|
|
-- into an accidental kill.
|
|
local function isPlayerAlly(entity)
|
|
return entity.kind == "companion" or entity.kind == "hireling" or entity.kind == "thrall"
|
|
end
|
|
|
|
local CONFUSE_DIRS = {
|
|
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 },
|
|
}
|
|
|
|
-- Whether `dx, dy` (the direction the player actually typed, or the one
|
|
-- autopilot's own targeting picked) gets overridden by a random one of the
|
|
-- 8 compass directions this call - the confusion-lands roll only ever fires
|
|
-- once per Game:movePlayer, never re-rolled mid-call. Confusion strikes
|
|
-- before Game:movePlayer even looks at what's in the chosen direction, so a
|
|
-- stumble can just as easily open a door, bump a rival into a fight, shove a
|
|
-- block, or walk the player straight onto a trap as a deliberate move would.
|
|
local function confusionRedirect(self, dx, dy)
|
|
if not Status.has(self.player, Status.CONFUSE) then return dx, dy, false end
|
|
if not self.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then
|
|
return dx, dy, false
|
|
end
|
|
local dir = CONFUSE_DIRS[self.rng:range(1, #CONFUSE_DIRS)]
|
|
return dir[1], dir[2], true
|
|
end
|
|
|
|
-- `stumbled` (below) is what makes a confused player's failed lurch still
|
|
-- cost a turn: an ordinary bump into a wall or an unpushable block returns
|
|
-- false and Game:manualStep spends nothing, exactly as a rejected typed
|
|
-- command should, but a confused player who meant to go one way and staggered
|
|
-- into a wall instead still acted - the turn is spent on the stumble itself,
|
|
-- not on whatever (if anything) it accomplished.
|
|
function Game:movePlayer(dx, dy)
|
|
local stumbled
|
|
dx, dy, stumbled = confusionRedirect(self, dx, dy)
|
|
if stumbled then self.log:add("your senses reel and you stumble sideways") end
|
|
local nx, ny = self.player.x + dx, self.player.y + dy
|
|
local map = self.level.map
|
|
if Tiles.isClosedDoor(map:get(nx, ny)) then
|
|
return self:openDoor(nx, ny)
|
|
end
|
|
local target = self.level:entityAt(nx, ny)
|
|
if target and not isPlayerAlly(target) then
|
|
self:playerAttack(target)
|
|
return true
|
|
end
|
|
if not target then
|
|
local block = self.level:blockAt(nx, ny)
|
|
if block then
|
|
local pushed, line = Blocks.push(self.level, block, dx, dy)
|
|
if not pushed then return stumbled end
|
|
self.log:add(line)
|
|
Blocks.updateGates(self.level)
|
|
end
|
|
end
|
|
if map:isWalkable(nx, ny) then
|
|
if target then
|
|
target.x, target.y = self.player.x, self.player.y
|
|
self.log:add("you swap places with your " .. target.name)
|
|
end
|
|
self.player.x, self.player.y = nx, ny
|
|
local trap = self.level:trapAt(nx, ny)
|
|
if trap and not trap.revealed and not Status.has(self.player, Status.LEVITATE) then
|
|
self:triggerTrap(trap)
|
|
end
|
|
self:checkTerrainFor(self.player, dx, dy)
|
|
local lever = self.level:vaultLeverAt(nx, ny)
|
|
if lever then self:pullLever(lever) end
|
|
self:pickupHere()
|
|
return true
|
|
end
|
|
return stumbled
|
|
end
|
|
|
|
function Game:useItem(index)
|
|
local item = self.player.inventory[index]
|
|
if not item then return false end
|
|
if item.kind == Items.POTION or item.kind == Items.SCROLL then
|
|
self.log:addAll(Effects.apply(item.effect, self, self.player, item))
|
|
Identify.reveal(self.identify, item)
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.FOOD then
|
|
local bloated = Hunger.eat(self.player, item.nutrition)
|
|
if bloated then
|
|
self.log:add("you force down the " .. item.name .. " past comfort and feel bloated")
|
|
else
|
|
self.log:add("you eat the " .. item.name)
|
|
end
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.RING
|
|
or item.kind == Items.BOW then
|
|
local slot = slotByItemKind[item.kind]
|
|
if not Curses.tryRelease(self.player, slot) then
|
|
self.log:add("the " .. Curses.label(self.player[slot], Identify.label(self.identify, self.player[slot])) ..
|
|
" will not come off!")
|
|
return false
|
|
end
|
|
Items.useFromInventory(self.player, index)
|
|
self.log:add("you equip " .. Curses.label(item, Identify.label(self.identify, item)))
|
|
if item.set then
|
|
local count = Sets.equippedCounts(self.player)[item.set]
|
|
local bonus = Sets.bonusFor(item.set, count)
|
|
if bonus then
|
|
self.log:add("the " .. Sets.byKey[item.set].name .. " set (" .. count .. " pieces) awakens")
|
|
end
|
|
end
|
|
elseif item.kind == Items.TOME then
|
|
if Spells.learn(self.player, item.spell) then
|
|
self.log:add("you learn " .. Spells.catalog[item.spell].name)
|
|
else
|
|
self.log:add("you already know that spell")
|
|
end
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.CHARM then
|
|
local companion = Companions.summon(self, item.companion)
|
|
if not companion then return false end
|
|
self.log:add("your " .. companion.name .. " bonds to you")
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.CONTRACT then
|
|
local hireling = Mercenaries.hire(self, item.hireling)
|
|
if not hireling then return false end
|
|
self.log:add("your " .. hireling.name .. " joins you for hire")
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.KIT then
|
|
if self.level:trapAt(self.player.x, self.player.y) then
|
|
self.log:add("there is already a trap here")
|
|
return false
|
|
end
|
|
self.level.traps[#self.level.traps + 1] = {
|
|
x = self.player.x, y = self.player.y, effect = item.effect, revealed = false, placedByPlayer = true,
|
|
}
|
|
self.log:add("you plant a " .. item.name)
|
|
table.remove(self.player.inventory, index)
|
|
elseif item.kind == Items.PICK then
|
|
Items.useFromInventory(self.player, index)
|
|
self.log:add("you ready your " .. item.name)
|
|
else
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Removes the currently-live nemesis (nemesis.lua) from the floor about to
|
|
-- be left and escalates its grudge, so Game:nemesisArrive rebuilds it
|
|
-- stronger on the other side of the stairwell. Unlike every other hostile
|
|
-- (level.lua's own header: a revisited floor keeps its exact state, dead
|
|
-- monsters and all), a nemesis is never left behind - it always follows.
|
|
function Game:nemesisFollow()
|
|
for i, entity in ipairs(self.level.entities) do
|
|
if entity.nemesis then
|
|
table.remove(self.level.entities, i)
|
|
Nemesis.escalate(self.nemesis)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Rolls a brand new nemesis into being from whichever hostile was already
|
|
-- hunting the player on the floor just cleared. Only tried on the way down
|
|
-- (Game:descend) and only while no nemesis is following yet - ordinary
|
|
-- backtracking upstairs never manufactures one.
|
|
function Game:nemesisTryEmerge()
|
|
if not self.rng:chance(Nemesis.CHANCE_NUMERATOR, Nemesis.CHANCE_DENOMINATOR) then return end
|
|
local hunter = Nemesis.pickHunter(self.level, self.player)
|
|
if not hunter then return end
|
|
for i, entity in ipairs(self.level.entities) do
|
|
if entity == hunter then
|
|
table.remove(self.level.entities, i)
|
|
break
|
|
end
|
|
end
|
|
self.nemesis = Nemesis.beginFrom(hunter)
|
|
end
|
|
|
|
-- Drops the pursuing nemesis onto whichever floor Game:descend/ascend has
|
|
-- just switched self.level/self.depth to. A no-op without one following.
|
|
function Game:nemesisArrive()
|
|
if not self.nemesis then return end
|
|
local x, y = self:randomFloorCell()
|
|
if not x then return end
|
|
local entity = Nemesis.spawn(self.nemesis, self:nextMonsterId(), x, y, self.depth)
|
|
entity.awareness = Stealth.HUNTING
|
|
self.level.entities[#self.level.entities + 1] = entity
|
|
self.log:add(entity.name .. " has followed you")
|
|
end
|
|
|
|
local ALLY_CARRY_KINDS = { companion = true, hireling = true, thrall = true }
|
|
local ALLY_CARRY_DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } }
|
|
|
|
-- Pulls every living player-aligned ally (companion/hireling/thrall) off
|
|
-- `fromLevel`'s own entities list, ready to be handed to
|
|
-- Game:plantCarriedAllies once self.level/self.player.x/y already point at
|
|
-- the destination. companions.lua/mercenaries.lua/reanimation.lua's own
|
|
-- module comments describe a bonded ally as persisting "across the whole
|
|
-- run" (or, for a hireling, demanding its wage "for as long as it lives") -
|
|
-- without this pair, every one of them was actually abandoned in place the
|
|
-- instant the player took a single flight of stairs or crossed a rift,
|
|
-- silently contradicting that promise. Mirrors Game:nemesisFollow's own
|
|
-- table.remove-while-scanning shape, just for up to three kinds at once
|
|
-- instead of one flagged entity.
|
|
function Game:detachCarriedAllies(fromLevel)
|
|
local carried = {}
|
|
for i = #fromLevel.entities, 1, -1 do
|
|
local entity = fromLevel.entities[i]
|
|
if ALLY_CARRY_KINDS[entity.kind] and entity:isAlive() then
|
|
table.remove(fromLevel.entities, i)
|
|
carried[#carried + 1] = entity
|
|
end
|
|
end
|
|
return carried
|
|
end
|
|
|
|
-- Re-plants entities Game:detachCarriedAllies pulled off the old floor onto
|
|
-- self.level, each on the first free cell around the player's new position -
|
|
-- the same 8-directional, first-free-cell convention Companions.summon/
|
|
-- Mercenaries.hire/Reanimation.raise already use to place a brand new one -
|
|
-- falling back to Game:randomFloorCell (the same primitive Game:nemesisArrive
|
|
-- uses) only if every adjacent cell is blocked, so an ally is never simply
|
|
-- lost for want of room next to the stairs.
|
|
function Game:plantCarriedAllies(carried)
|
|
for _, ally in ipairs(carried) do
|
|
local placed = false
|
|
for _, dir in ipairs(ALLY_CARRY_DIRS) do
|
|
local x, y = self.player.x + dir[1], self.player.y + dir[2]
|
|
if self.level.map:isWalkable(x, y) and not self.level:entityAt(x, y) then
|
|
ally.x, ally.y = x, y
|
|
placed = true
|
|
break
|
|
end
|
|
end
|
|
if not placed then
|
|
local x, y = self:randomFloorCell()
|
|
if x then ally.x, ally.y = x, y; placed = true end
|
|
end
|
|
if placed then
|
|
self.level.entities[#self.level.entities + 1] = ally
|
|
self.log:add("your " .. ally.name .. " follows you")
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:descend()
|
|
if self.rift then return false end
|
|
if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end
|
|
if self.depth >= self.maxDepth then
|
|
self.log:add("the stairwell ends in solid rock here")
|
|
return false
|
|
end
|
|
if self.nemesis then self:nemesisFollow() else self:nemesisTryEmerge() end
|
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
|
self.depth = self.depth + 1
|
|
self.level = self.dungeon:get(self.depth)
|
|
self.player.x, self.player.y = self.level.upX, self.level.upY
|
|
if self.depth > self.deepestDepth then self.deepestDepth = self.depth end
|
|
self.log:add("you descend to depth " .. self.depth)
|
|
self:nemesisArrive()
|
|
self:plantCarriedAllies(carriedAllies)
|
|
self:computeVisible()
|
|
return true
|
|
end
|
|
|
|
function Game:ascend()
|
|
if self.rift then return false end
|
|
if self.player.x ~= self.level.upX or self.player.y ~= self.level.upY then return false end
|
|
if self.depth <= 1 then
|
|
if self:hasAmulet() then
|
|
self.won = true
|
|
self.log:add("you escape the dungeon with the Amulet of Warren - you win!")
|
|
else
|
|
self.log:add("you climb toward the surface")
|
|
end
|
|
return true
|
|
end
|
|
if self.nemesis then self:nemesisFollow() end
|
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
|
self.depth = self.depth - 1
|
|
self.level = self.dungeon:get(self.depth)
|
|
self.player.x, self.player.y = self.level.downX, self.level.downY
|
|
self.log:add("you climb to depth " .. self.depth)
|
|
self:nemesisArrive()
|
|
self:plantCarriedAllies(carriedAllies)
|
|
self:computeVisible()
|
|
return true
|
|
end
|
|
|
|
-- Crosses the rift the player is currently standing on (see branches.lua),
|
|
-- swapping the run's active level for that rift's own pocket floor. The
|
|
-- pocket floor is generated once per origin depth and cached on
|
|
-- self.riftLevels exactly like self.dungeon caches every main-track floor,
|
|
-- so leaving and coming back always finds it exactly as it was left, dead
|
|
-- monsters and all - and only the very first crossing of a given rift spends
|
|
-- the one carried shard the stabilization costs; every crossing after that,
|
|
-- in either direction, is free. Returns false without side effects when the
|
|
-- player is not standing on a rift, is already across one, or (on a first
|
|
-- crossing only) is not carrying a shard to spend.
|
|
function Game:enterRift()
|
|
if self.rift then return false end
|
|
local rift = self.level:riftAt(self.player.x, self.player.y)
|
|
if not rift then return false end
|
|
self.riftLevels = self.riftLevels or {}
|
|
local firstCrossing = self.riftLevels[self.depth] == nil
|
|
if firstCrossing then
|
|
local shardIndex = self:findInInventory(function(item) return item.kind == Items.RIFTSTONE end)
|
|
if not shardIndex then return false end
|
|
table.remove(self.player.inventory, shardIndex)
|
|
self.riftLevels[self.depth] = Branches.build(self.seed, self.depth, self.dungeon.options)
|
|
end
|
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
|
self.riftReturn = { level = self.level, depth = self.depth, x = self.player.x, y = self.player.y }
|
|
self.level = self.riftLevels[self.depth]
|
|
self.rift = true
|
|
self.player.x, self.player.y = self.level.upX, self.level.upY
|
|
self.log:add(firstCrossing and "the shard crumbles to dust as you step through the rift"
|
|
or "you step through the stabilized rift once more")
|
|
self:plantCarriedAllies(carriedAllies)
|
|
self:computeVisible()
|
|
return true
|
|
end
|
|
|
|
-- Steps back out of a rift crossing at its own pocket floor's downX/downY
|
|
-- (Branches.build reuses an ordinary Builder.build floor's ordinary "stairs
|
|
-- down" pair as the one way back rather than inventing a second tile kind),
|
|
-- restoring the exact main-track level, depth and position Game:enterRift
|
|
-- saved before the crossing. Returns false without effect when the player is
|
|
-- not currently across a rift, or has not yet reached its own exit point.
|
|
function Game:exitRift()
|
|
if not self.rift then return false end
|
|
if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end
|
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
|
self.level = self.riftReturn.level
|
|
self.depth = self.riftReturn.depth
|
|
self.player.x, self.player.y = self.riftReturn.x, self.riftReturn.y
|
|
self.rift = false
|
|
self.riftReturn = nil
|
|
self.log:add("you step back through the rift, blinking in familiar torchlight")
|
|
self:plantCarriedAllies(carriedAllies)
|
|
self:computeVisible()
|
|
return true
|
|
end
|
|
|
|
-- Starts a Horde Arena trial for the run in progress: swaps the player's
|
|
-- current level for a fresh closed arena (see horde.lua's own header for
|
|
-- why this can bypass dungeon.lua/builder.lua entirely and still work with
|
|
-- every existing turn system unmodified) and spawns its first wave through
|
|
-- the very same Horde.tick that clears each wave after it. The dungeon
|
|
-- floor left behind is simply abandoned - descending is not something an
|
|
-- arena run does, only Game:endPlayerTurn's escalating waves. A bonded
|
|
-- companion/hireling/thrall is not abandoned along with it though: the same
|
|
-- detachCarriedAllies/plantCarriedAllies pair Game:descend/ascend/enterRift/
|
|
-- exitRift already use follows the player in here too, planted around the
|
|
-- arena's centre anchor (cx, cy) rather than left to die alone on a floor
|
|
-- the run can never return to - an ally bonded specifically to fight beside
|
|
-- the player has the most reason to stand with them in a survival trial.
|
|
function Game:startHorde()
|
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
|
local map, cx, cy = Horde.buildArena(self.rng, Horde.WIDTH, Horde.HEIGHT)
|
|
self.level = Level.new({ depth = self.depth, generator = "horde", map = map })
|
|
self.player.x, self.player.y = cx, cy
|
|
self.horde = Horde.newState()
|
|
self:plantCarriedAllies(carriedAllies)
|
|
local line = Horde.tick(self)
|
|
if line then self.log:add(line) end
|
|
self:computeVisible()
|
|
end
|
|
|
|
-- Autopilot.
|
|
|
|
function Game:findInInventory(predicate)
|
|
for i, item in ipairs(self.player.inventory) do
|
|
if predicate(item) then return i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Game:adjacentMonster()
|
|
for _, dir in ipairs(MONSTER_DIRS) do
|
|
local monster = self.level:entityAt(self.player.x + dir[1], self.player.y + dir[2])
|
|
if monster and not isPlayerAlly(monster) then return monster end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Game:playerPathTo(tx, ty)
|
|
local hasKey = self:hasKey()
|
|
return Astar.find(self.level.map, self.player.x, self.player.y, tx, ty, function(x, y)
|
|
if x == tx and y == ty then return false end
|
|
if not self.level.map:isPassable(x, y, hasKey) then return true end
|
|
if self.level:blockAt(x, y) then return true end
|
|
return self.level:entityAt(x, y) ~= nil
|
|
end)
|
|
end
|
|
|
|
-- First step of the shortest route to the nearest monster the player can
|
|
-- actually reach, so blockers standing between the player and the stairs get
|
|
-- hunted down. Ties break on ascending monster id for determinism.
|
|
function Game:reachableMonsterStep()
|
|
local bestLen, bestId, step = math.huge, math.huge, nil
|
|
for _, monster in ipairs(self.level.entities) do
|
|
local path = self:playerPathTo(monster.x, monster.y)
|
|
if path and #path >= 2 then
|
|
if #path < bestLen or (#path == bestLen and monster.id < bestId) then
|
|
bestLen, bestId, step = #path, monster.id, path[2]
|
|
end
|
|
end
|
|
end
|
|
return step
|
|
end
|
|
|
|
-- First step toward the nearest not-yet-explored walkable cell via a BFS that
|
|
-- routes around monsters. Lets the autopilot discover a way onward when the
|
|
-- direct path to the stairs is temporarily blocked.
|
|
function Game:exploreStep()
|
|
local map = self.level.map
|
|
local hasKey = self:hasKey()
|
|
local start = map:index(self.player.x, self.player.y)
|
|
local came = { [start] = start }
|
|
local queue = { { self.player.x, self.player.y, start } }
|
|
local head = 1
|
|
while head <= #queue do
|
|
local cell = queue[head]
|
|
head = head + 1
|
|
local cx, cy, idx = cell[1], cell[2], cell[3]
|
|
if idx ~= start and not self.level.explored[idx] and map:isWalkable(cx, cy) then
|
|
local cursor = idx
|
|
while came[cursor] ~= start do
|
|
cursor = came[cursor]
|
|
end
|
|
local stepX = (cursor - 1) % map.width + 1
|
|
local stepY = (cursor - 1) // map.width + 1
|
|
return sign(stepX - self.player.x), sign(stepY - self.player.y)
|
|
end
|
|
for _, dir in ipairs(MONSTER_DIRS) do
|
|
local nx, ny = cx + dir[1], cy + dir[2]
|
|
if map:inBounds(nx, ny) and map:isPassable(nx, ny, hasKey)
|
|
and not self.level:entityAt(nx, ny) then
|
|
local nIdx = map:index(nx, ny)
|
|
if not came[nIdx] then
|
|
came[nIdx] = idx
|
|
queue[#queue + 1] = { nx, ny, nIdx }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Game:anyMonsterVisible()
|
|
for _, monster in ipairs(self.level.entities) do
|
|
if self.visible[self.level.map:index(monster.x, monster.y)] then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Only true hostiles (isPlayerAlly's same companion/hireling/thrall
|
|
-- allowlist that Game:movePlayer's own bump-attack already respects) -
|
|
-- unlike Game:anyMonsterVisible above, which choosePlayerAction uses to gate
|
|
-- inventory downtime and deliberately treats ANY visible entity as "not
|
|
-- safe to fiddle right now". Game:autoExplore needs the narrower question:
|
|
-- a loyal ally standing in plain sight next to the player it always follows
|
|
-- must never interrupt an autoexplore run on its own.
|
|
function Game:anyHostileVisible()
|
|
for _, entity in ipairs(self.level.entities) do
|
|
if not isPlayerAlly(entity) and self.visible[self.level.map:index(entity.x, entity.y)] then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Inventory index of the single best equipment upgrade to put on this turn, or
|
|
-- nil. Weapons and armour compare on power, rings on attack plus defence.
|
|
function Game:bestUpgrade()
|
|
local player = self.player
|
|
local weaponPower = player.weapon and player.weapon.power or 0
|
|
local armorPower = player.armor and player.armor.power or 0
|
|
local ringValue = player.ring and (player.ring.attack + player.ring.defense) or 0
|
|
for i, item in ipairs(player.inventory) do
|
|
if item.kind == Items.WEAPON and item.power > weaponPower then return i end
|
|
end
|
|
for i, item in ipairs(player.inventory) do
|
|
if item.kind == Items.ARMOR and item.power > armorPower then return i end
|
|
end
|
|
for i, item in ipairs(player.inventory) do
|
|
if item.kind == Items.RING and (item.attack + item.defense) > ringValue then return i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- The first not-yet-known skill of the player's class whose level requirement
|
|
-- is already met, or nil. Learning costs no turn, so the autopilot spends
|
|
-- any banked skill point the moment it can, in the tree's fixed order.
|
|
function Game:nextLearnableSkill()
|
|
local player = self.player
|
|
if not player.class or (player.skillPoints or 0) <= 0 then return nil end
|
|
for _, key in ipairs(Skills.forClass(player.class)) do
|
|
if not Skills.has(player, key) then
|
|
local def = Skills.active[key] or Skills.passive[key]
|
|
if player.level >= def.reqLevel then return key end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Game:hasCompanion()
|
|
for _, entity in ipairs(self.level.entities) do
|
|
if entity.kind == "companion" and entity:isAlive() then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Game:hasHireling()
|
|
for _, entity in ipairs(self.level.entities) do
|
|
if entity.kind == "hireling" and entity:isAlive() then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Voluntary, penalty-free release of the live hireling, if any - contrast
|
|
-- Mercenaries.payWage's desertion, which fires on its own once loyalty
|
|
-- bottoms out from missed wages rather than on the player's own say-so.
|
|
function Game:dismissHireling()
|
|
local name = Mercenaries.dismiss(self)
|
|
if not name then
|
|
self.log:add("you have no one under contract to release")
|
|
return false
|
|
end
|
|
self.log:add("you release your " .. name .. " from service")
|
|
return true
|
|
end
|
|
|
|
function Game:choosePlayerAction()
|
|
local player = self.player
|
|
local learnable = self:nextLearnableSkill()
|
|
if learnable then self:learnSkill(learnable) end
|
|
if not player.quest then
|
|
local board = self.level:questBoardAt(player.x, player.y)
|
|
if board and not board.claimed then self:tryAcceptQuest(board) end
|
|
end
|
|
if not self:anyMonsterVisible() then
|
|
local upgrade = self:bestUpgrade()
|
|
if upgrade then self:useItem(upgrade); return end
|
|
if not self:hasCompanion() then
|
|
local charm = self:findInInventory(function(item) return item.kind == Items.CHARM end)
|
|
if charm then self:useItem(charm); return end
|
|
end
|
|
if not self:hasHireling() then
|
|
local contract = self:findInInventory(function(item) return item.kind == Items.CONTRACT end)
|
|
if contract then self:useItem(contract); return end
|
|
end
|
|
-- Only ever risks a shrine's first, cheapest, least cursed-prone prayer:
|
|
-- the autopilot is not greedy enough to keep re-praying at rising odds.
|
|
local shrine = self.level:shrineAt(player.x, player.y)
|
|
if shrine and shrine.uses == 0 and player.gold >= Shrines.costFor(shrine) then
|
|
self:prayAt(shrine)
|
|
return
|
|
end
|
|
-- Restores whatever gear has actually stopped working the moment the
|
|
-- purse can cover it, same "take it as soon as it's affordable" spirit
|
|
-- as the shrine check just above.
|
|
local brokenSlot = self:brokenEquippedSlot()
|
|
if brokenSlot and player.gold >= Durability.repairCost(player[brokenSlot]) then
|
|
self:repairEquipped(brokenSlot)
|
|
return
|
|
end
|
|
-- Free income with no downside, so the autopilot takes it on sight
|
|
-- rather than weighing it like a shrine's costed, risky prayer.
|
|
local node = self.level:nodeAt(player.x, player.y)
|
|
if node and not Nodes.isFallow(node) then
|
|
self:harvestNode()
|
|
return
|
|
end
|
|
-- Free protein with no gold cost, so the autopilot butchers a corpse on
|
|
-- sight same as it strip-mines a node - but only while the meat is
|
|
-- still good; a rotten one it leaves alone rather than poisoning itself
|
|
-- for nothing.
|
|
local corpse = self.level:corpseAt(player.x, player.y)
|
|
if corpse and not Corpses.isRotten(corpse) then
|
|
self:butcherCorpse()
|
|
return
|
|
end
|
|
-- A chest is worth cracking on sight exactly like the node and corpse
|
|
-- above, but only once it can actually be: an unlocked one is free
|
|
-- money, and a locked one is only ever tried with a key or lockpick
|
|
-- already in hand (see Game:openChest). Skipping a locked chest with
|
|
-- neither on hand keeps the autopilot from parking on it and burning
|
|
-- every remaining turn on a lock it has no way to open.
|
|
local chest = self.level:chestAt(player.x, player.y)
|
|
if chest and not chest.opened and (not Chests.isLocked(chest) or self:hasKey()
|
|
or self:findInInventory(function(item) return item.kind == Items.LOCKPICK end)) then
|
|
self:openChest()
|
|
return
|
|
end
|
|
-- A held captive is worth cashing in on sight exactly like the chest
|
|
-- above: the autopilot always takes the plain gold of Game:sellCaptive
|
|
-- rather than gambling on Game:ransomCaptive's goodwill or
|
|
-- Game:interrogateCaptive's map intel, neither of which it is smart
|
|
-- enough here to weigh against the other.
|
|
local captive = self.level:captiveAt(player.x, player.y)
|
|
if captive then
|
|
self:sellCaptive()
|
|
return
|
|
end
|
|
-- A rift crossing is only ever attempted at good health: its own pocket
|
|
-- floor is deliberately tuned harder than the main-track floor the rift
|
|
-- itself sits on (see branches.lua), so the autopilot never spends its
|
|
-- one shard gambling into a fight it is not ready for.
|
|
if player.hp >= player.maxHp * 0.8 and self:enterRift() then
|
|
return
|
|
end
|
|
-- Sheds the cheapest thing it is hauling once genuinely overloaded, but
|
|
-- only in this same downtime window as the checks above - never instead
|
|
-- of fighting or fleeing, since a visible monster always skips this
|
|
-- whole block.
|
|
if self:encumbranceTier() == Encumbrance.OVERLOADED then
|
|
local junk = self:cheapestDroppableItem()
|
|
if junk then self:dropItem(junk); return end
|
|
end
|
|
end
|
|
if player.hp < player.maxHp // 3 then
|
|
local potion = self:findInInventory(function(item)
|
|
return item.kind == Items.POTION and item.effect == "heal"
|
|
end)
|
|
if potion then self:useItem(potion); return end
|
|
end
|
|
if player.satiation < 150 then
|
|
local food = self:findInInventory(function(item) return item.kind == Items.FOOD end)
|
|
if food then self:useItem(food); return end
|
|
end
|
|
local target = self:adjacentMonster()
|
|
if target then
|
|
-- A carried net beats the killing blow once the target is already
|
|
-- battered down to Captives.eligible's own threshold: a capture forgoes
|
|
-- this kill's xp and essence, but a live captive can still be cashed in
|
|
-- for gold moments later (see the sell-on-sight check above), so this is
|
|
-- a trade the autopilot is always willing to make rather than a strict
|
|
-- loss.
|
|
if Captives.eligible(target) then
|
|
local net = self:findInInventory(function(item) return item.kind == Items.NET end)
|
|
if net then
|
|
self:captureAt(net, target)
|
|
return
|
|
end
|
|
end
|
|
self:movePlayer(sign(target.x - player.x), sign(target.y - player.y))
|
|
return
|
|
end
|
|
|
|
-- Across a rift, the only objective is its own pocket floor's exit: never
|
|
-- the main track's descend/ascend goals below, which Game:descend and
|
|
-- Game:ascend both refuse outright for as long as self.rift holds anyway.
|
|
if self.rift then
|
|
if player.x == self.level.downX and player.y == self.level.downY then
|
|
self:exitRift()
|
|
return
|
|
end
|
|
local path = self:playerPathTo(self.level.downX, self.level.downY)
|
|
if path and #path >= 2 then
|
|
self:movePlayer(path[2].x - player.x, path[2].y - player.y)
|
|
return
|
|
end
|
|
local hunt = self:reachableMonsterStep()
|
|
if hunt then
|
|
self:movePlayer(hunt.x - player.x, hunt.y - player.y)
|
|
return
|
|
end
|
|
local ex, ey = self:exploreStep()
|
|
if ex then self:movePlayer(ex, ey) end
|
|
return
|
|
end
|
|
|
|
-- Once the amulet is in hand the objective flips: every remaining step
|
|
-- heads for the up stairs instead of the down stairs, floor by floor.
|
|
local goalX, goalY = self.level.downX, self.level.downY
|
|
if self:hasAmulet() then
|
|
goalX, goalY = self.level.upX, self.level.upY
|
|
if player.x == goalX and player.y == goalY then
|
|
self:ascend()
|
|
return
|
|
end
|
|
elseif self.depth < self.maxDepth and player.x == goalX and player.y == goalY then
|
|
self:descend()
|
|
return
|
|
end
|
|
local path = self:playerPathTo(goalX, goalY)
|
|
if path and #path >= 2 then
|
|
self:movePlayer(path[2].x - player.x, path[2].y - player.y)
|
|
return
|
|
end
|
|
local hunt = self:reachableMonsterStep()
|
|
if hunt then
|
|
self:movePlayer(hunt.x - player.x, hunt.y - player.y)
|
|
return
|
|
end
|
|
local ex, ey = self:exploreStep()
|
|
if ex then self:movePlayer(ex, ey) end
|
|
end
|
|
|
|
function Game:hungerTorchTick()
|
|
if not Hunger.suppressesRegen(self.player) then
|
|
local ringRegen = Artifacts.ringRegen(self.player) + Sets.regenAmount(self.player)
|
|
if ringRegen > 0 then
|
|
self.player.hp = math.min(self.player.maxHp, self.player.hp + ringRegen)
|
|
end
|
|
local weatherRegen = Atmosphere.regenBonus(self.level.weather)
|
|
if weatherRegen > 0 then
|
|
self.player.hp = math.min(self.player.maxHp, self.player.hp + weatherRegen)
|
|
end
|
|
end
|
|
local curseDrain = Curses.drainAmount(self.player)
|
|
if curseDrain > 0 then
|
|
self.player.hp = self.player.hp - curseDrain
|
|
self.log:add("a cursed affliction saps your strength")
|
|
end
|
|
local diseaseLine = Disease.tick(self.player)
|
|
if diseaseLine then self.log:add(diseaseLine) end
|
|
local hungerLine = Hunger.tick(self.player)
|
|
if hungerLine then self.log:add(hungerLine) end
|
|
if self.player.light.fuel > 0 then
|
|
self.player.light.fuel = self.player.light.fuel - 1
|
|
end
|
|
if self.player.mana < self.player.maxMana then
|
|
self.player.mana = self.player.mana + 1
|
|
end
|
|
end
|
|
|
|
function Game:endPlayerTurn()
|
|
self.turnState = nil
|
|
Scheduler.spend(self.player)
|
|
self:hungerTorchTick()
|
|
Hazards.step(self.level, self.rng)
|
|
Nodes.step(self.level)
|
|
Corpses.step(self.level)
|
|
for _, trap in ipairs(Disarm.sweep(self.level, self.rng, self.player.x, self.player.y)) do
|
|
self.log:add("you notice a hidden trap nearby")
|
|
end
|
|
for _, door in ipairs(Secrets.sweep(self.level, self.rng, self.player.x, self.player.y)) do
|
|
self.log:add("you notice a hidden door in the wall")
|
|
end
|
|
local eventLine = Events.step(self.level, self.rng)
|
|
if eventLine then self.log:add(eventLine) end
|
|
Skills.tickCooldowns(self.player)
|
|
self:resolveQuest()
|
|
self.turn = self.turn + 1
|
|
if self.turn % Mercenaries.WAGE_INTERVAL == 0 then
|
|
local wageLine = Mercenaries.payWage(self)
|
|
if wageLine then self.log:add(wageLine) end
|
|
end
|
|
self:computeVisible()
|
|
local sanityLine = Sanity.tick(self)
|
|
if sanityLine then self.log:add(sanityLine) end
|
|
if Sanity.rollScream(self.rng, self) then
|
|
self.log:add("a scream of raw terror tears from your throat")
|
|
end
|
|
if self.horde then
|
|
local hordeLine = Horde.tick(self)
|
|
if hordeLine then self.log:add(hordeLine) end
|
|
end
|
|
end
|
|
|
|
-- Spends a skill point to permanently learn `key`, gated by class, level and
|
|
-- point balance (see skills.lua). Free of any turn cost.
|
|
function Game:learnSkill(key)
|
|
local success, message = Skills.learn(self.player, key)
|
|
if message then self.log:add(message) end
|
|
return success
|
|
end
|
|
|
|
-- Attempts to use the active skill `key`, forwarding any extra arguments
|
|
-- (a target monster, or two for multishot) straight to skills.lua.
|
|
function Game:useSkill(key, ...)
|
|
local success, lines = Skills.use(self, key, ...)
|
|
self.log:addAll(lines)
|
|
return success
|
|
end
|
|
|
|
-- Shared head of any player turn, whether the autopilot or a typed command
|
|
-- is about to decide what happens next: runs every other actor up to the
|
|
-- player, ticks the status/water/hazard afflictions that land on the
|
|
-- player's own turn boundary, and rolls the stun/panic checks that can
|
|
-- consume the turn before any action is even chosen. Returns "halt" once
|
|
-- play cannot continue this call (already won, or dead at any point along
|
|
-- the way - the caller must not act or spend a turn), "skip" once the turn
|
|
-- is already spent on a stun or a panic scream, or "ready" once the caller
|
|
-- is clear to choose and perform an action of its own.
|
|
--
|
|
-- Cached on self.turnState until Game:endPlayerTurn clears it, so calling
|
|
-- this more than once before a turn is actually spent - the one new
|
|
-- situation Game:manualStep introduces, since a rejected typed command
|
|
-- spends no turn and its caller is free to just try again - never re-rolls
|
|
-- the stun/panic check or re-ticks poison, fire or hunger a second time for
|
|
-- a turn the player has not actually finished taking yet. Game:autoStep
|
|
-- never revisits an unspent turn this way, so the cache is invisible to it.
|
|
function Game:beginTurn()
|
|
if self.turnState then return self.turnState end
|
|
if self.won then self.turnState = "halt"; return "halt" end
|
|
self:runToPlayer()
|
|
if not self.player:isAlive() then self.turnState = "halt"; return "halt" end
|
|
self.log:addAll(Status.tick(self.player))
|
|
if not self.player:isAlive() then self.turnState = "halt"; return "halt" end
|
|
Conductivity.tick(self.level, self.player)
|
|
local hazardLine = Hazards.afflict(self.level, self.player)
|
|
if hazardLine then self.log:add(hazardLine) end
|
|
if not self.player:isAlive() then self.turnState = "halt"; return "halt" end
|
|
if Status.skipsTurn(self.player) then
|
|
self.log:add("you are stunned")
|
|
self.turnState = "skip"
|
|
elseif Sanity.rollPanic(self.rng, self.player) then
|
|
self.log:add("terror seizes you and the turn slips away")
|
|
self.turnState = "skip"
|
|
else
|
|
self.turnState = "ready"
|
|
end
|
|
return self.turnState
|
|
end
|
|
|
|
function Game:autoStep()
|
|
local state = self:beginTurn()
|
|
if state == "halt" then return end
|
|
if state == "ready" then self:choosePlayerAction() end
|
|
self:endPlayerTurn()
|
|
end
|
|
|
|
-- Human-driven counterpart to Game:autoStep: runs the exact same pre-turn
|
|
-- checks, then hands `text` to commands.lua instead of letting
|
|
-- choosePlayerAction decide on its own. Returns (consumesTurn, message) from
|
|
-- Commands.apply so a caller (warren.lua's --play REPL, or a test driving a
|
|
-- fixed script of commands) can tell a wasted keystroke from a turn the
|
|
-- world actually answered. A stun or panic rolled during beginTurn still
|
|
-- spends the turn and ends it exactly like autoStep, without ever looking at
|
|
-- `text` - the player was never in control of that turn to begin with.
|
|
function Game:manualStep(text)
|
|
local state = self:beginTurn()
|
|
if state == "halt" then return false, "there is nothing left to do" end
|
|
if state == "skip" then
|
|
self:endPlayerTurn()
|
|
return true, nil
|
|
end
|
|
local consumesTurn, message = Commands.apply(self, text)
|
|
-- "explore" (Game:autoExplore, wired up in commands.lua) can advance and
|
|
-- close several turns entirely on its own inside this one call, unlike
|
|
-- every other handler here which only ever acts within the single turn
|
|
-- already opened above - self.turnState is already nil by the time it
|
|
-- returns, so this must not end an already-ended turn a second time - and
|
|
-- must never end one at all if the very last thing that happened was a
|
|
-- halt (self.turnState == "halt"), exactly like Game:autoStep and this
|
|
-- same function's own "halt" branch above never do either. Every other
|
|
-- command leaves turnState exactly as beginTurn set it above ("ready"),
|
|
-- so the extra check below is a no-op for them.
|
|
if consumesTurn and self.turnState == "ready" then self:endPlayerTurn() end
|
|
return consumesTurn, message
|
|
end
|
|
|
|
local EXPLORE_STEP_CAP = 2000
|
|
|
|
-- Repeats Game:exploreStep/Game:movePlayer turn after turn inside a single
|
|
-- call, the way autoexplore works in most other roguelikes, instead of
|
|
-- making the player retype "explore" once per step. Built on the exact same
|
|
-- beginTurn/endPlayerTurn pair Game:autoStep already drives, so monster AI,
|
|
-- status ticks and hazards all run normally on every turn it takes - the
|
|
-- only difference from a hand-typed run of single moves is that this stops
|
|
-- itself the instant something worth the player's attention shows up,
|
|
-- rather than walking on regardless. Returns (consumesTurn, message) in the
|
|
-- exact same shape every Commands.apply handler does: consumesTurn is true
|
|
-- iff at least one turn was actually spent, so "already interrupted before
|
|
-- moving at all" and "an ordinary rejected command" both read the same way
|
|
-- to a caller that only checks that first value.
|
|
function Game:autoExplore()
|
|
local steps = 0
|
|
while steps < EXPLORE_STEP_CAP do
|
|
if self:anyHostileVisible() then
|
|
if steps == 0 then return false, "a monster is already in sight" end
|
|
return true, "you stop exploring - a monster is in sight"
|
|
end
|
|
local hpBefore = self.player.hp
|
|
local state = self:beginTurn()
|
|
if state == "halt" then
|
|
if steps == 0 then return false, "there is nothing left to do" end
|
|
return true, "you stop exploring"
|
|
end
|
|
if state == "ready" then
|
|
local ex, ey = self:exploreStep()
|
|
if not ex then
|
|
if steps == 0 then return false, "there is nothing left to explore" end
|
|
-- beginTurn above already ran this turn's monster moves and status
|
|
-- ticks for real, even though there turns out to be nothing left to
|
|
-- walk toward - so, unlike the steps == 0 case above (nothing at
|
|
-- all has happened yet, left dangling for a retry within the same
|
|
-- still-open turn, exactly like a rejected command), this turn must
|
|
-- still be closed out properly rather than abandoned half-finished.
|
|
self:endPlayerTurn()
|
|
return true, "you have finished exploring"
|
|
end
|
|
self:movePlayer(ex, ey)
|
|
end
|
|
self:endPlayerTurn()
|
|
steps = steps + 1
|
|
if self.player.hp < hpBefore then
|
|
return true, "you stop exploring - you have taken damage"
|
|
end
|
|
end
|
|
return true, "you stop exploring - that is far enough for now"
|
|
end
|
|
|
|
local REST_STEP_CAP = 500
|
|
|
|
-- Repeats a do-nothing turn (Handlers.wait's own move) inside a single call
|
|
-- until the player's hp is back to full, the way "rest" works in most other
|
|
-- roguelikes, instead of making the player retype "wait" once per turn.
|
|
-- Built on the same beginTurn/endPlayerTurn pair Game:autoExplore already
|
|
-- drives, so status ticks, hazards and the passive hp regen sources that
|
|
-- actually restore hp (a regen ring or artifact, Status.REGEN, favorable
|
|
-- weather - see Game:hungerTorchTick and Status.tick) all run exactly as
|
|
-- they would turn by turn. Stops itself the instant a hostile comes into
|
|
-- sight, the player's hp actually drops, or two full turns in a row pass
|
|
-- with the player's hp completely unchanged - the last case catches the
|
|
-- common situation where none of those regen sources are active at all, so
|
|
-- waiting here would otherwise never do anything but burn turns up to the
|
|
-- safety cap below. Returns (consumesTurn, message) in the exact same shape
|
|
-- every other handler in commands.lua does, exactly like Game:autoExplore.
|
|
--
|
|
-- The "no progress" check deliberately waits for a *second* completed turn
|
|
-- (steps > 1) rather than judging the very first one: Game:manualStep (the
|
|
-- only caller a real player's typed "rest" ever goes through) already calls
|
|
-- Game:beginTurn once of its own, before Commands.apply even runs, to check
|
|
-- whether the turn is allowed to start at all - so by the time this loop's
|
|
-- own first Game:beginTurn call runs, self.turnState may already be primed
|
|
-- from that earlier call, and it is a no-op cache hit rather than a fresh
|
|
-- tick (see Game:beginTurn's own comment). Any regen this call's Status.tick
|
|
-- and Game:hungerTorchTick would otherwise have produced already landed on
|
|
-- self.player.hp *before* this function's very first hpBefore snapshot was
|
|
-- even taken, so a first-iteration hp==hpBefore reading is inconclusive, not
|
|
-- proof that nothing is healing the player - judging it anyway would have
|
|
-- reported a perfectly working regen ring as "nothing here is healing you"
|
|
-- after a single real turn every time rest is typed in the actual game.
|
|
-- Every iteration from the second one on always starts with self.turnState
|
|
-- already nil (this loop's own prior Game:endPlayerTurn call clears it), so
|
|
-- its own Game:beginTurn call is always a fresh tick there - reliable either
|
|
-- way this function is entered, direct or through Game:manualStep.
|
|
function Game:restUntilHealed()
|
|
if self.player.hp >= self.player.maxHp then
|
|
return false, "you are already at full health"
|
|
end
|
|
local steps = 0
|
|
while steps < REST_STEP_CAP do
|
|
if self:anyHostileVisible() then
|
|
if steps == 0 then return false, "a monster is already in sight" end
|
|
return true, "you stop resting - a monster is in sight"
|
|
end
|
|
local hpBefore = self.player.hp
|
|
local state = self:beginTurn()
|
|
if state == "halt" then
|
|
if steps == 0 then return false, "there is nothing left to do" end
|
|
return true, "you stop resting"
|
|
end
|
|
self:endPlayerTurn()
|
|
steps = steps + 1
|
|
if self.player.hp >= self.player.maxHp then
|
|
return true, "you rest until your wounds close"
|
|
end
|
|
if self.player.hp < hpBefore then
|
|
return true, "you stop resting - you have taken damage"
|
|
end
|
|
if steps > 1 and self.player.hp == hpBefore then
|
|
return true, "you stop resting - nothing here is healing you"
|
|
end
|
|
end
|
|
return true, "you stop resting - that is far enough for now"
|
|
end
|
|
|
|
local TRAVEL_STEP_CAP = 500
|
|
|
|
-- Repeats Game:playerPathTo/Game:movePlayer turn after turn toward a fixed
|
|
-- destination inside a single call, the way "travel"/autotravel works in
|
|
-- most other roguelikes (DCSS's `>`, Brogue's travel cursor) - instead of
|
|
-- making the player retype a direction once per step to walk back to a spot
|
|
-- they already know is safe (the stairs they just left behind). Built on the
|
|
-- exact same beginTurn/endPlayerTurn pair Game:autoExplore/Game:restUntilHealed
|
|
-- already drive, so monster AI, status ticks and hazards all run normally on
|
|
-- every turn it takes. Reuses Game:playerPathTo, the same pathfinder
|
|
-- choosePlayerAction already relies on to walk the autopilot toward the
|
|
-- stairs turn by turn - this only exposes that existing primitive to a typed
|
|
-- command instead of teaching a second way to path anywhere. Stops itself
|
|
-- the instant a hostile comes into sight, the player's hp actually drops, the
|
|
-- destination is reached, or the route becomes blocked (a monster or a
|
|
-- spawned block stepped onto the only way through). Returns (consumesTurn,
|
|
-- message) in the exact same shape every other Commands.apply handler does.
|
|
function Game:travelTo(tx, ty)
|
|
if self.player.x == tx and self.player.y == ty then
|
|
return false, "you are already there"
|
|
end
|
|
if not self:playerPathTo(tx, ty) then
|
|
return false, "you cannot find a path there"
|
|
end
|
|
local steps = 0
|
|
while steps < TRAVEL_STEP_CAP do
|
|
if self:anyHostileVisible() then
|
|
if steps == 0 then return false, "a monster is already in sight" end
|
|
return true, "you stop traveling - a monster is in sight"
|
|
end
|
|
local hpBefore = self.player.hp
|
|
local state = self:beginTurn()
|
|
if state == "halt" then
|
|
if steps == 0 then return false, "there is nothing left to do" end
|
|
return true, "you stop traveling"
|
|
end
|
|
if state == "ready" then
|
|
local path = self:playerPathTo(tx, ty)
|
|
if not path or #path < 2 then
|
|
-- beginTurn above already ran this turn's monster moves and status
|
|
-- ticks for real (see Game:autoExplore's own "nothing left to
|
|
-- explore" branch for the identical reasoning), so unlike the
|
|
-- steps == 0 case below - where nothing has happened yet and the
|
|
-- turn is left open for a retry, exactly like a rejected command -
|
|
-- a route that only went dead after steps have already been taken
|
|
-- must close out the turn that already ran rather than abandon it
|
|
-- half-finished.
|
|
if steps == 0 then return false, "you cannot find a path there" end
|
|
self:endPlayerTurn()
|
|
return true, "you stop traveling - the way is blocked"
|
|
end
|
|
self:movePlayer(path[2].x - self.player.x, path[2].y - self.player.y)
|
|
end
|
|
self:endPlayerTurn()
|
|
steps = steps + 1
|
|
if self.player.x == tx and self.player.y == ty then
|
|
return true, "you arrive"
|
|
end
|
|
if self.player.hp < hpBefore then
|
|
return true, "you stop traveling - you have taken damage"
|
|
end
|
|
end
|
|
return true, "you stop traveling - that is far enough for now"
|
|
end
|
|
|
|
function Game:render()
|
|
return Render.frame(self)
|
|
end
|
|
|
|
function Game:toData()
|
|
return {
|
|
seed = self.seed,
|
|
depth = self.depth,
|
|
turn = self.turn,
|
|
won = self.won,
|
|
monsterSeq = self.monsterSeq,
|
|
deepestDepth = self.deepestDepth,
|
|
stats = self.stats,
|
|
rng = self.rng:serialize(),
|
|
identify = self.identify,
|
|
dungeon = self.dungeon:toData(),
|
|
player = self.player:toData(),
|
|
playerExtra = { satiation = self.player.satiation, light = self.player.light },
|
|
log = self.log:toData(),
|
|
}
|
|
end
|
|
|
|
function Game.fromData(snapshot)
|
|
local game = setmetatable({}, Game)
|
|
game.seed = snapshot.seed
|
|
game.depth = snapshot.depth
|
|
game.turn = snapshot.turn
|
|
game.rng = Rng.deserialize(snapshot.rng)
|
|
game.identify = snapshot.identify
|
|
game.dungeon = Dungeon.fromData(snapshot.dungeon)
|
|
game.level = game.dungeon:get(game.depth)
|
|
game.player = Entity.fromData(snapshot.player)
|
|
game.player.satiation = snapshot.playerExtra.satiation
|
|
game.player.light = snapshot.playerExtra.light
|
|
game.log = MessageLog.fromData(snapshot.log)
|
|
game.visible = {}
|
|
game:computeVisible()
|
|
return game
|
|
end
|
|
|
|
function Game:save(path)
|
|
Serialize.saveFile(path, self:toData())
|
|
end
|
|
|
|
function Game.load(path)
|
|
return Game.fromData(Serialize.loadFile(path))
|
|
end
|
|
|
|
return Game
|