-- Text command parser and dispatcher for a human player, layered entirely on -- top of the granular Game: methods choosePlayerAction (ai.lua's own -- autopilot) already drives - nothing here duplicates combat, targeting or -- turn bookkeeping, it only translates a typed word into the same call the -- autopilot would have made. Every other player-facing system in this engine -- is a mechanic; this is the one module whose whole job is the interface -- between a line of text and those mechanics, which is why it takes `game` -- as a plain argument rather than requiring game.lua itself (that require -- runs the other way, from game.lua's Game:manualStep). -- -- Commands.apply(game, text) always returns (consumesTurn, message): a -- movement/action word that actually did something returns true and leaves -- Game:manualStep to spend the turn, exactly like choosePlayerAction would -- have; an unrecognized word, a bad argument, or an action with nothing to -- act on returns false and spends nothing, so a typo never costs a turn the -- way a wasted keystroke would in any other roguelike. Purely informational -- commands (inventory, look, status, help) always return false for the same -- reason - reading your own inventory is free. local Spells = require("spells") local Identify = require("identify") local Factions = require("factions") local Examine = require("examine") local Skills = require("skills") local Commands = {} local DIRS = { north = { 0, -1 }, n = { 0, -1 }, south = { 0, 1 }, s = { 0, 1 }, east = { 1, 0 }, e = { 1, 0 }, west = { -1, 0 }, w = { -1, 0 }, northeast = { 1, -1 }, ne = { 1, -1 }, northwest = { -1, -1 }, nw = { -1, -1 }, southeast = { 1, 1 }, se = { 1, 1 }, southwest = { -1, 1 }, sw = { -1, 1 }, } -- Splits `text` on whitespace into a lowercase verb plus its remaining -- tokens as args. Returns nil for anything with no verb at all (an empty or -- all-whitespace line), which Commands.apply treats the same as an unknown -- command - no turn spent either way. function Commands.parse(text) if type(text) ~= "string" then return nil end local tokens = {} for token in text:gmatch("%S+") do tokens[#tokens + 1] = token:lower() end if #tokens == 0 then return nil end local args = {} for i = 2, #tokens do args[#args + 1] = tokens[i] end return { verb = tokens[1], args = args } end -- A monster the player can actually see right now: alive, kind "monster" -- (excludes the player's own companions/hirelings/thralls, which never -- carry that kind - see game.lua's isPlayerAlly), and standing on a tile -- game.visible already marks lit for the player this turn. local function isVisibleHostile(game, entity) return entity.kind == "monster" and entity:isAlive() and game.visible[game.level.map:index(entity.x, entity.y)] == true end -- Up to `count` nearest visible hostiles, Chebyshev distance, ties broken by -- ascending id - the same tie rule Game:nearestAlly and companions.lua's own -- nearestHostile already use, so auto-targeting a ranged command picks -- exactly the monster the autopilot would have picked in the same spot. local function nearestHostiles(game, count) local player = game.player local results, excluded = {}, {} for _ = 1, count do local best, bestDist for _, entity in ipairs(game.level.entities) do if not excluded[entity] and isVisibleHostile(game, entity) then local dist = math.max(math.abs(entity.x - player.x), math.abs(entity.y - player.y)) if not best or dist < bestDist or (dist == bestDist and entity.id < best.id) then best, bestDist = entity, dist end end end if not best then break end results[#results + 1] = best excluded[best] = true end return results end local function nearestHostile(game) return nearestHostiles(game, 1)[1] end -- Every visible hostile in game.level.entities's own order, unlike -- nearestHostiles above which sorts by distance - this is the order -- Handlers.look numbers its list in, so "examine monster " always means -- the same monster the player just saw look print at that number. local function visibleHostilesInOrder(game) local list = {} for _, entity in ipairs(game.level.entities) do if isVisibleHostile(game, entity) then list[#list + 1] = entity end end return list end -- Resolves an optional trailing target number - the exact 1-based index -- Handlers.look/examine already print next to each visible hostile - to an -- entity, falling back to the nearest visible hostile (the old, only -- behaviour) when the caller omitted it. Every targeted handler below stays -- correct for a player who never bothers naming a target, while letting a -- player who cares (a dangerous archer standing behind an expendable rat) -- reach past the default without any change to game.lua's own range/line-of- -- sight checks, which still reject an out-of-reach explicit target exactly -- like they already reject a too-far auto-picked one. -- Returns (target, explicit): explicit is true only when the caller actually -- passed a token for the slot, so callers can tell "you named a target that -- is not there" apart from "there is no target in sight at all" and keep -- their own existing wording for the latter. local function resolveTarget(game, indexText) if indexText == nil then return nearestHostile(game), false end local index = tonumber(indexText) return index and visibleHostilesInOrder(game)[index], true end -- Commands.parse lowercases every token before a handler ever sees it, which -- is harmless for directions/spells/factions/slots (already all-lowercase -- keys) but not for Skills.active/Skills.passive, whose keys are camelCase -- (secondWind, poisonBlade, manaShield) - a player typing the skill's own -- name would otherwise never match it. Maps a lowercased word back to its -- real key by case-insensitive scan; returns the word unchanged when nothing -- matches, so an actually unknown skill still reaches Skills.use/Skills.learn -- and gets their own "no such skill" wording instead of being swallowed here. local function resolveSkillKey(word) if not word then return word end for key in pairs(Skills.active) do if key:lower() == word then return key end end for key in pairs(Skills.passive) do if key:lower() == word then return key end end return word end local Handlers = {} local function moveHandler(dx, dy) return function(game) if game:movePlayer(dx, dy) then return true, nil end return false, "you cannot go that way" end end for name, dir in pairs(DIRS) do Handlers[name] = moveHandler(dir[1], dir[2]) end Handlers.attack = function(game, args) local dir = DIRS[args[1]] if not dir then return false, "attack which direction?" end if game:movePlayer(dir[1], dir[2]) then return true, nil end return false, "there is nothing to attack there" end 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 ` inspects inventory item n, the -- same index use/drop/sell already key off; `examine monster ` inspects -- the nth entry of the very list Handlers.look just numbered. Both branches -- are free, like every other informational command here. Handlers.examine = function(game, args) if args[1] == "monster" then local index = tonumber(args[2]) if not index then return false, "examine which monster? give the number look showed you" end local target = visibleHostilesInOrder(game)[index] if not target then return false, "no monster in sight at that number" end return false, Examine.monster(game.player, target) end local index = indexArg(args) if not index then return false, "examine what? give an inventory number, or 'monster '" end local item = game.player.inventory[index] if not item then return false, "you are not carrying anything at that number" end return false, Examine.item(game.identify, item) end Handlers.x = Handlers.examine Handlers.help = function() return false, "commands: north/south/east/west/ne/nw/se/sw (or n/s/e/w/...), attack , " .. "wait, pickup, explore, rest, travel down/up, inventory, use , drop , sell , throw [target], " .. "fire [target], capture [target], cast [target], useskill [target], " .. "open, pray, quest, appease , butcher, raise, " .. "scavenge, harvest, ransom, sellcaptive, interrogate, dig , disarm , " .. "descend, ascend, enterrift, exitrift, horde, " .. "steal, dismiss, learn , forge , merge , brew , " .. "socket , socketrune , temper , " .. "enchant , disenchant , repair , " .. "examine , examine monster , look, status, help " .. "([target] is the number look/examine gives a monster; omit it to auto-target the nearest one)" end -- Parses `text` and dispatches it against `game`. Returns (consumesTurn, -- message): Game:manualStep spends the player's turn iff the first value is -- true, and either surfaces `message` itself (a failure reason, or an -- informational answer) or leaves the game log to speak for a successful -- action, exactly the way every Game: method already logs its own -- outcome. function Commands.apply(game, text) local parsed = Commands.parse(text) if not parsed then return false, "say something" end local handler = Handlers[parsed.verb] if not handler then return false, "unknown command: " .. parsed.verb end return handler(game, parsed.args) end return Commands