diff --git a/src/game.lua b/src/game.lua index dafae90..7cbca5b 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1498,25 +1498,167 @@ end -- 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 then + 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 then self:triggerTrap(trap) end + 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 false + return stumbled end function Game:useItem(index) @@ -1527,12 +1669,60 @@ function Game:useItem(index) Identify.reveal(self.identify, item) table.remove(self.player.inventory, index) elseif item.kind == Items.FOOD then - self.player.satiation = self.player.satiation + item.nutrition - self.log:add("you eat the " .. item.name) + 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 then + 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 " .. Identify.label(self.identify, item)) + 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