diff --git a/src/game.lua b/src/game.lua index 17a9b57..dafae90 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1325,6 +1325,179 @@ function Game:pickupHere() 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 function Game:movePlayer(dx, dy) local nx, ny = self.player.x + dx, self.player.y + dy local map = self.level.map