Files
warren/src/commands.lua
T

626 lines
27 KiB
Lua

-- Text command parser and dispatcher for a human player, layered entirely on
-- top of the granular Game:<action> methods choosePlayerAction (ai.lua's own
-- autopilot) already drives - nothing here duplicates combat, targeting or
-- turn bookkeeping, it only translates a typed word into the same call the
-- autopilot would have made. Every other player-facing system in this engine
-- is a mechanic; this is the one module whose whole job is the interface
-- between a line of text and those mechanics, which is why it takes `game`
-- as a plain argument rather than requiring game.lua itself (that require
-- runs the other way, from game.lua's Game:manualStep).
--
-- Commands.apply(game, text) always returns (consumesTurn, message): a
-- movement/action word that actually did something returns true and leaves
-- Game:manualStep to spend the turn, exactly like choosePlayerAction would
-- have; an unrecognized word, a bad argument, or an action with nothing to
-- act on returns false and spends nothing, so a typo never costs a turn the
-- way a wasted keystroke would in any other roguelike. Purely informational
-- commands (inventory, look, status, help) always return false for the same
-- reason - reading your own inventory is free.
local Spells = require("spells")
local Identify = require("identify")
local Factions = require("factions")
local Examine = require("examine")
local Skills = require("skills")
local Commands = {}
local DIRS = {
north = { 0, -1 }, n = { 0, -1 },
south = { 0, 1 }, s = { 0, 1 },
east = { 1, 0 }, e = { 1, 0 },
west = { -1, 0 }, w = { -1, 0 },
northeast = { 1, -1 }, ne = { 1, -1 },
northwest = { -1, -1 }, nw = { -1, -1 },
southeast = { 1, 1 }, se = { 1, 1 },
southwest = { -1, 1 }, sw = { -1, 1 },
}
-- Splits `text` on whitespace into a lowercase verb plus its remaining
-- tokens as args. Returns nil for anything with no verb at all (an empty or
-- all-whitespace line), which Commands.apply treats the same as an unknown
-- command - no turn spent either way.
function Commands.parse(text)
if type(text) ~= "string" then return nil end
local tokens = {}
for token in text:gmatch("%S+") do
tokens[#tokens + 1] = token:lower()
end
if #tokens == 0 then return nil end
local args = {}
for i = 2, #tokens do args[#args + 1] = tokens[i] end
return { verb = tokens[1], args = args }
end
-- A monster the player can actually see right now: alive, kind "monster"
-- (excludes the player's own companions/hirelings/thralls, which never
-- carry that kind - see game.lua's isPlayerAlly), and standing on a tile
-- game.visible already marks lit for the player this turn.
local function isVisibleHostile(game, entity)
return entity.kind == "monster" and entity:isAlive()
and game.visible[game.level.map:index(entity.x, entity.y)] == true
end
-- Up to `count` nearest visible hostiles, Chebyshev distance, ties broken by
-- ascending id - the same tie rule Game:nearestAlly and companions.lua's own
-- nearestHostile already use, so auto-targeting a ranged command picks
-- exactly the monster the autopilot would have picked in the same spot.
local function nearestHostiles(game, count)
local player = game.player
local results, excluded = {}, {}
for _ = 1, count do
local best, bestDist
for _, entity in ipairs(game.level.entities) do
if not excluded[entity] and isVisibleHostile(game, entity) then
local dist = math.max(math.abs(entity.x - player.x), math.abs(entity.y - player.y))
if not best or dist < bestDist or (dist == bestDist and entity.id < best.id) then
best, bestDist = entity, dist
end
end
end
if not best then break end
results[#results + 1] = best
excluded[best] = true
end
return results
end
local function nearestHostile(game)
return nearestHostiles(game, 1)[1]
end
-- Every visible hostile in game.level.entities's own order, unlike
-- nearestHostiles above which sorts by distance - this is the order
-- Handlers.look numbers its list in, so "examine monster <n>" always means
-- the same monster the player just saw look print at that number.
local function visibleHostilesInOrder(game)
local list = {}
for _, entity in ipairs(game.level.entities) do
if isVisibleHostile(game, entity) then list[#list + 1] = entity end
end
return list
end
-- Resolves an optional trailing target number - the exact 1-based index
-- Handlers.look/examine already print next to each visible hostile - to an
-- entity, falling back to the nearest visible hostile (the old, only
-- behaviour) when the caller omitted it. Every targeted handler below stays
-- correct for a player who never bothers naming a target, while letting a
-- player who cares (a dangerous archer standing behind an expendable rat)
-- reach past the default without any change to game.lua's own range/line-of-
-- sight checks, which still reject an out-of-reach explicit target exactly
-- like they already reject a too-far auto-picked one.
-- Returns (target, explicit): explicit is true only when the caller actually
-- passed a token for the slot, so callers can tell "you named a target that
-- is not there" apart from "there is no target in sight at all" and keep
-- their own existing wording for the latter.
local function resolveTarget(game, indexText)
if indexText == nil then return nearestHostile(game), false end
local index = tonumber(indexText)
return index and visibleHostilesInOrder(game)[index], true
end
-- Commands.parse lowercases every token before a handler ever sees it, which
-- is harmless for directions/spells/factions/slots (already all-lowercase
-- keys) but not for Skills.active/Skills.passive, whose keys are camelCase
-- (secondWind, poisonBlade, manaShield) - a player typing the skill's own
-- name would otherwise never match it. Maps a lowercased word back to its
-- real key by case-insensitive scan; returns the word unchanged when nothing
-- matches, so an actually unknown skill still reaches Skills.use/Skills.learn
-- and gets their own "no such skill" wording instead of being swallowed here.
local function resolveSkillKey(word)
if not word then return word end
for key in pairs(Skills.active) do
if key:lower() == word then return key end
end
for key in pairs(Skills.passive) do
if key:lower() == word then return key end
end
return word
end
local Handlers = {}
local function moveHandler(dx, dy)
return function(game)
if game:movePlayer(dx, dy) then return true, nil end
return false, "you cannot go that way"
end
end
for name, dir in pairs(DIRS) do
Handlers[name] = moveHandler(dir[1], dir[2])
end
Handlers.attack = function(game, args)
local dir = DIRS[args[1]]
if not dir then return false, "attack which direction?" end
if game:movePlayer(dir[1], dir[2]) then return true, nil end
return false, "there is nothing to attack there"
end
Handlers.wait = function() return true, "you wait" end
Handlers.z = Handlers.wait
Handlers["."] = Handlers.wait
Handlers.pickup = function(game)
game:pickupHere()
return true, nil
end
Handlers.g = Handlers.pickup
-- Game:autoExplore already returns the exact (consumesTurn, message) shape
-- every handler here does - it just may have spent several turns getting
-- there instead of one, stopping itself the moment a hostile comes into
-- sight or the player takes damage rather than walking on regardless.
Handlers.explore = function(game)
return game:autoExplore()
end
-- Game:restUntilHealed shares the same shape and the same "may spend several
-- turns before returning" behaviour as Game:autoExplore just above.
Handlers.rest = function(game)
return game:restUntilHealed()
end
-- Game:travelTo shares that same "may spend several turns" shape too. "down"/
-- "up" are the level's own stairs (self.level.downX/Y, upX/Y) - always a
-- valid, already-walkable destination regardless of what the player has
-- explored yet, exactly like choosePlayerAction's own goal-seeking already
-- assumes; a raw x/y command is deliberately not offered since nothing in
-- this interface ever shows the player a raw coordinate to aim one at.
Handlers.travel = function(game, args)
if args[1] == "down" then return game:travelTo(game.level.downX, game.level.downY) end
if args[1] == "up" then return game:travelTo(game.level.upX, game.level.upY) end
return false, "travel where? down or up"
end
-- Every handler below that takes an inventory index shares the same shape:
-- a missing or non-numeric first arg fails without spending a turn, exactly
-- like an unknown verb, rather than crashing into the underlying Game
-- method with a nil index.
local function indexArg(args)
return tonumber(args[1])
end
Handlers.use = function(game, args)
local index = indexArg(args)
if not index then return false, "use what? give an inventory number" end
if game:useItem(index) then return true, nil end
return false, "you cannot use that"
end
Handlers.u = Handlers.use
Handlers.drop = function(game, args)
local index = indexArg(args)
if not index then return false, "drop what? give an inventory number" end
if game:dropItem(index) then return true, nil end
return false, "you have nothing there to drop"
end
Handlers.sell = function(game, args)
local index = indexArg(args)
if not index then return false, "sell what? give an inventory number" end
if game:sellItem(index) then return true, nil end
return false, "that cannot be sold here"
end
-- Trailing target number is always the argument slot right after whatever
-- an individual handler needed for itself (an inventory index, a spell/skill
-- key) - see resolveTarget above for exactly what "explicit" changes.
Handlers.throw = function(game, args)
local index = indexArg(args)
if not index then return false, "throw what? give an inventory number" end
local target, explicit = resolveTarget(game, args[2])
if not target then
if explicit then return false, "no monster in sight at that number" end
return false, "there is nothing in sight to throw at"
end
if game:throwAt(index, target) then return true, nil end
return false, "you cannot throw that"
end
Handlers.capture = function(game, args)
local index = indexArg(args)
if not index then return false, "capture with what? give an inventory number" end
local target, explicit = resolveTarget(game, args[2])
if not target then
if explicit then return false, "no monster in sight at that number" end
return false, "there is nothing in sight to capture"
end
if game:captureAt(index, target) then return true, nil end
return false, "the capture attempt fails"
end
Handlers.fire = function(game, args)
local target, explicit = resolveTarget(game, args[1])
if not target then
if explicit then return false, "no monster in sight at that number" end
return false, "there is nothing in sight to fire at"
end
if game:fireAt(target) then return true, nil end
return false, "you cannot fire right now"
end
Handlers.cast = function(game, args)
local key = args[1]
if not key then return false, "cast what spell?" end
local def = Spells.catalog[key]
if not def then return false, "you know no spell by that name" end
local target = nil
if def.targeted then
local explicit
target, explicit = resolveTarget(game, args[2])
if not target then
if explicit then return false, "no monster in sight at that number" end
return false, "there is nothing in sight to cast that at"
end
end
if game:castSpell(key, target) then return true, nil end
return false, "the spell fails"
end
-- multishot's two targets are named together or not at all: giving only one
-- explicit number is ambiguous about which of the (up to) two shots it picks
-- out, so that case falls back to the same auto-pick-both-nearest behaviour
-- as giving none, rather than guessing which slot the player meant.
Handlers.useskill = function(game, args)
local key = resolveSkillKey(args[1])
if not key then return false, "use which skill?" end
local success
if key == "cleave" then
local target, explicit = resolveTarget(game, args[2])
if not target and explicit then return false, "no monster in sight at that number" end
success = game:useSkill(key, target)
elseif key == "multishot" then
if args[2] and args[3] then
local list = visibleHostilesInOrder(game)
local t1, t2 = list[tonumber(args[2]) or -1], list[tonumber(args[3]) or -1]
if not t1 or not t2 then return false, "no monster in sight at that number" end
success = game:useSkill(key, t1, t2)
else
local targets = nearestHostiles(game, 2)
success = game:useSkill(key, targets[1], targets[2])
end
else
success = game:useSkill(key)
end
if success then return true, nil end
return false, "that skill fails"
end
-- Zero-argument actions that always resolve against whatever stands on the
-- player's own tile (a chest, a corpse, a captive, a node, a stairwell...),
-- the same way every one of these Game methods already looks itself up by
-- self.player.x/y rather than taking a target. `nothingMessage` only ever
-- surfaces when the underlying method itself failed silently (no log line
-- of its own) - most branches already explain themselves through game.log.
local function hereHandler(methodName, nothingMessage)
return function(game)
if game[methodName](game) then return true, nil end
return false, nothingMessage
end
end
Handlers.open = hereHandler("openChest", "there is nothing here to open")
Handlers.butcher = hereHandler("butcherCorpse", "there is nothing here to butcher")
Handlers.raise = hereHandler("raiseCorpse", "there is nothing here to raise")
Handlers.scavenge = hereHandler("scavengeCorpse", "there is nothing here to scavenge")
Handlers.harvest = hereHandler("harvestNode", "there is nothing here to harvest")
Handlers.ransom = hereHandler("ransomCaptive", "there is nothing here to ransom")
Handlers.sellcaptive = hereHandler("sellCaptive", "there is nothing here to sell")
Handlers.interrogate = hereHandler("interrogateCaptive", "there is nothing here to interrogate")
Handlers.descend = hereHandler("descend", "there are no stairs down here")
Handlers[">"] = Handlers.descend
Handlers.ascend = hereHandler("ascend", "there are no stairs up here")
Handlers["<"] = Handlers.ascend
Handlers.enterrift = hereHandler("enterRift", "there is no rift here to cross")
Handlers.exitrift = hereHandler("exitRift", "you are not standing across a rift")
-- Game:startHorde is a deliberate, one-way trial (see its own header: the
-- dungeon floor left behind is simply abandoned, there is no exit back) that
-- always succeeds and, unlike every hereHandler above, carries no location
-- requirement of its own - so this guards against the one real footgun a
-- typed command adds that the method itself does not: retyping "horde"
-- mid-run would otherwise silently discard the current wave and its
-- progress by building a brand new arena on top of it.
Handlers.horde = function(game)
if game.horde then return false, "you are already fighting in the arena" end
game:startHorde()
return true, nil
end
Handlers.pray = function(game)
local shrine = game.level:shrineAt(game.player.x, game.player.y)
if not shrine then return false, "there is no shrine here" end
if game:prayAt(shrine) then return true, nil end
return false, "your prayer goes unanswered"
end
Handlers.quest = function(game)
local board = game.level:questBoardAt(game.player.x, game.player.y)
if not board then return false, "there is no quest board here" end
if game:tryAcceptQuest(board) then return true, nil end
return false, "you cannot take up that bounty"
end
-- Every faction key factions.lua defines, spelled out here (rather than
-- discovered from the module at runtime) purely so a bad argument's error
-- message can list them by name; the set itself is built from the same
-- constants Game:appeaseFaction already trusts, so a typo can never buy
-- standing with a faction that does not exist.
local FACTION_NAMES = {
Factions.MERCHANT, Factions.GOBLINOID, Factions.VERMIN, Factions.ORCISH,
Factions.UNDEAD, Factions.BEAST, Factions.CULT, Factions.DRAKE, Factions.BANDIT,
}
local FACTION_SET = {}
for _, name in ipairs(FACTION_NAMES) do FACTION_SET[name] = true end
Handlers.appease = function(game, args)
local faction = args[1]
if not faction or not FACTION_SET[faction] then
return false, "appease which faction? " .. table.concat(FACTION_NAMES, ", ")
end
if game:appeaseFaction(faction) then return true, nil end
return false, nil
end
Handlers.dig = function(game, args)
local dir = DIRS[args[1]]
if not dir then return false, "dig which direction?" end
if game:dig(dir[1], dir[2]) then return true, nil end
return false, "you cannot dig there"
end
-- Disarms a detected-but-unsprung trap one step away in `dir` - the same
-- Chebyshev-1 reach Disarm.sweep already uses to notice it in the first
-- place (see Game:endPlayerTurn's "you notice a hidden trap nearby"). The
-- validity check below duplicates Game:disarmTrap's own guard on purpose:
-- unlike every other handler here, a disarm attempt against a real trap
-- must still spend the turn even when Disarm.attempt fails and springs it,
-- but Game:disarmTrap's own boolean return conflates that spring with "no
-- trap here" into the same false - so this handler tells the two apart
-- itself instead of trusting the method's return value for turn cost.
Handlers.disarm = function(game, args)
local dir = DIRS[args[1]]
if not dir then return false, "disarm which direction?" end
local trap = game.level:trapAt(game.player.x + dir[1], game.player.y + dir[2])
if not trap or not trap.detected or trap.revealed then
return false, "there is nothing here you have noticed to disarm"
end
game:disarmTrap(trap)
return true, nil
end
-- Crafting/economy verbs (game.lua's forgeGem/mergeGemsAt/socketItem/
-- socketRune/temperItem/enchantItem/disenchantItem/repairEquipped/
-- stealFromShop/dismissHireling/learnSkill/brewAt), the other half of the
-- API choosePlayerAction can already reach that a typed session could not
-- until now. Every one of these Game methods already logs its own success
-- or failure reason, so a handler only needs its own message for an
-- argument so malformed the underlying method can never even be called.
-- Shared by socket/socketrune/temper/enchant/disenchant: their `target`
-- resolves through Game:resolveGear exactly like this, either the literal
-- word for currently equipped gear or an inventory slot number for
-- anything else (a spare ring, an unequipped bow).
local function targetArg(token)
if token == "weapon" or token == "armor" then return token end
return tonumber(token)
end
Handlers.forge = function(game, args)
local element = args[1]
if not element then return false, "forge which element? fire, frost, shock or venom" end
if game:forgeGem(element) then return true, nil end
return false, nil
end
Handlers.merge = function(game, args)
local a, b = tonumber(args[1]), tonumber(args[2])
if not a or not b then return false, "merge which two gems? give two inventory numbers" end
if game:mergeGemsAt(a, b) then return true, nil end
return false, "those cannot be merged"
end
Handlers.socket = function(game, args)
local target, gemIndex = targetArg(args[1]), tonumber(args[2])
if not target or not gemIndex then
return false, "socket what into what? give a target (weapon/armor/item number) and a gem number"
end
if game:socketItem(target, gemIndex) then return true, nil end
return false, "that cannot be socketed"
end
Handlers.socketrune = function(game, args)
local target, runeIndex = targetArg(args[1]), tonumber(args[2])
if not target or not runeIndex then
return false, "socket which rune into what? give a target (weapon/armor/item number) and a rune number"
end
if game:socketRune(target, runeIndex) then return true, nil end
return false, "that cannot be socketed"
end
Handlers.temper = function(game, args)
local target = targetArg(args[1])
if not target then return false, "temper what? weapon, armor or an item number" end
if game:temperItem(target) then return true, nil end
return false, "that cannot be tempered"
end
Handlers.enchant = function(game, args)
local target, affixKey = targetArg(args[1]), args[2]
if not target or not affixKey then
return false, "enchant what with what? give a target (weapon/armor/item number) and an affix name"
end
if game:enchantItem(target, affixKey) then return true, nil end
return false, "that cannot be enchanted"
end
Handlers.disenchant = function(game, args)
local target = targetArg(args[1])
if not target then return false, "disenchant what? weapon, armor or an item number" end
if game:disenchantItem(target) then return true, nil end
return false, "that cannot be disenchanted"
end
Handlers.repair = function(game, args)
local slot = args[1]
if slot ~= "weapon" and slot ~= "armor" and slot ~= "bow" then
return false, "repair what? weapon, armor or bow"
end
if game:repairEquipped(slot) then return true, nil end
return false, nil
end
-- Steals whichever warded ware sits underfoot, the same tile Game:pickupHere
-- already resolves against - unlike Game:stealFromShop's own raw floorItems
-- index, a typed command can only ever mean "the ware I am standing on".
local function groundIndexHere(game)
for i, ground in ipairs(game.level.floorItems) do
if ground.x == game.player.x and ground.y == game.player.y then return i, ground end
end
return nil
end
Handlers.steal = function(game)
local index = groundIndexHere(game)
if index and game:stealFromShop(index) then return true, nil end
return false, "there is nothing here worth stealing"
end
Handlers.dismiss = function(game)
if game:dismissHireling() then return true, nil end
return false, nil
end
Handlers.learn = function(game, args)
local key = resolveSkillKey(args[1])
if not key then return false, "learn which skill?" end
game:learnSkill(key)
return false, nil
end
-- Duplicates Game:brewAt's own precondition checks (cauldron underfoot, two
-- distinct herb items) before ever calling it, the same shape as disarm
-- above and for the same reason: past those preconditions, brewing always
-- spends both herbs, whether the pairing brews a potion or curdles into gas
-- (see Alchemy.brew), so this handler must not read a curdle's own `false`
-- as "nothing happened" and hand the player a free turn back.
Handlers.brew = function(game, args)
local a, b = tonumber(args[1]), tonumber(args[2])
if not a or not b then return false, "brew which two herbs? give two inventory numbers" end
if not game.level:cauldronAt(game.player.x, game.player.y) then
return false, "there is no cauldron here"
end
if a == b then return false, "brew two different herbs, not the same one twice" end
local herbA, herbB = game.player.inventory[a], game.player.inventory[b]
if not herbA or not herbB or herbA.kind ~= "herb" or herbB.kind ~= "herb" then
return false, "you need two herbs to brew"
end
game:brewAt(a, b)
return true, nil
end
-- Purely informational commands. Every one of these returns false (spends no
-- turn) and carries its whole answer in the message, which is the one place
-- a caller should print something the game log itself would never contain.
Handlers.inventory = function(game)
if #game.player.inventory == 0 then return false, "you are carrying nothing" end
local lines = {}
for i, item in ipairs(game.player.inventory) do
lines[#lines + 1] = string.format(" %d) %s", i, Identify.label(game.identify, item))
end
return false, table.concat(lines, "\n")
end
Handlers.i = Handlers.inventory
Handlers.status = function(game)
local p = game.player
return false, string.format(
"hp %d/%d mana %d/%d gold %d essence %d level %d xp %d/%d depth %d turn %d",
p.hp, p.maxHp, p.mana or 0, p.maxMana or 0, p.gold, p.essence,
p.level, p.xp, p.xpNext, game.depth, game.turn)
end
Handlers.look = function(game)
local targets = visibleHostilesInOrder(game)
if #targets == 0 then return false, "you see no threats nearby" end
local names = {}
for i, entity in ipairs(targets) do
names[#names + 1] = string.format("%d) %s", i, entity.name)
end
return false, "in sight: " .. table.concat(names, ", ")
end
Handlers.l = Handlers.look
-- Detail views (examine.lua): `examine <n>` inspects inventory item n, the
-- same index use/drop/sell already key off; `examine monster <n>` inspects
-- the nth entry of the very list Handlers.look just numbered. Both branches
-- are free, like every other informational command here.
Handlers.examine = function(game, args)
if args[1] == "monster" then
local index = tonumber(args[2])
if not index then return false, "examine which monster? give the number look showed you" end
local target = visibleHostilesInOrder(game)[index]
if not target then return false, "no monster in sight at that number" end
return false, Examine.monster(game.player, target)
end
local index = indexArg(args)
if not index then return false, "examine what? give an inventory number, or 'monster <n>'" end
local item = game.player.inventory[index]
if not item then return false, "you are not carrying anything at that number" end
return false, Examine.item(game.identify, item)
end
Handlers.x = Handlers.examine
Handlers.help = function()
return false, "commands: north/south/east/west/ne/nw/se/sw (or n/s/e/w/...), attack <dir>, " ..
"wait, pickup, explore, rest, travel down/up, inventory, use <n>, drop <n>, sell <n>, throw <n> [target], " ..
"fire [target], capture <n> [target], cast <spell> [target], useskill <skill> [target], " ..
"open, pray, quest, appease <faction>, butcher, raise, " ..
"scavenge, harvest, ransom, sellcaptive, interrogate, dig <dir>, disarm <dir>, " ..
"descend, ascend, enterrift, exitrift, horde, " ..
"steal, dismiss, learn <skill>, forge <element>, merge <a> <b>, brew <a> <b>, " ..
"socket <target> <gem>, socketrune <target> <rune>, temper <target>, " ..
"enchant <target> <affix>, disenchant <target>, repair <slot>, " ..
"examine <n>, examine monster <n>, look, status, help " ..
"([target] is the number look/examine gives a monster; omit it to auto-target the nearest one)"
end
-- Parses `text` and dispatches it against `game`. Returns (consumesTurn,
-- message): Game:manualStep spends the player's turn iff the first value is
-- true, and either surfaces `message` itself (a failure reason, or an
-- informational answer) or leaves the game log to speak for a successful
-- action, exactly the way every Game:<action> method already logs its own
-- outcome.
function Commands.apply(game, text)
local parsed = Commands.parse(text)
if not parsed then return false, "say something" end
local handler = Handlers[parsed.verb]
if not handler then return false, "unknown command: " .. parsed.verb end
return handler(game, parsed.args)
end
return Commands