From 6f54f4431d26a4b994beaed0ec93d5076f5eb958 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 16:40:52 +0200 Subject: [PATCH] feat: extend game with Game:sellCaptive and Game:interrogateCaptive --- src/game.lua | 191 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 181 insertions(+), 10 deletions(-) diff --git a/src/game.lua b/src/game.lua index 8b116b9..17a9b57 100644 --- a/src/game.lua +++ b/src/game.lua @@ -1105,6 +1105,61 @@ function Game:ransomCaptive() 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 @@ -1124,32 +1179,148 @@ function Game:openDoor(x, y) return true end -function Game:triggerTrap(trap) +-- 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 - self.player.hp = self.player.hp - dmg - self.log:add(string.format("a dart trap hits you for %d", dmg)) + 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(self.player, Status.POISON, 5, 2) - self.log:add("you stumble into a poison trap") + 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 - self.player.x, self.player.y = x, y - self.log:add("a trap flings you across the level") + 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 - self.player.inventory[#self.player.inventory + 1] = ground.item - self.log:add("you pick up " .. Identify.label(self.identify, ground.item)) - table.remove(self.level.floorItems, i) + 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