From b4e2c7a43104d92b69288a2b4c95fb37a383c7c4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 19:07:12 +0200 Subject: [PATCH] feat: extend game with Game:anyHostileVisible --- src/game.lua | 204 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/src/game.lua b/src/game.lua index 72c9868..86ab860 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2040,6 +2040,22 @@ function Game:anyMonsterVisible() return false end +-- Only true hostiles (isPlayerAlly's same companion/hireling/thrall +-- allowlist that Game:movePlayer's own bump-attack already respects) - +-- unlike Game:anyMonsterVisible above, which choosePlayerAction uses to gate +-- inventory downtime and deliberately treats ANY visible entity as "not +-- safe to fiddle right now". Game:autoExplore needs the narrower question: +-- a loyal ally standing in plain sight next to the player it always follows +-- must never interrupt an autoexplore run on its own. +function Game:anyHostileVisible() + for _, entity in ipairs(self.level.entities) do + if not isPlayerAlly(entity) and self.visible[self.level.map:index(entity.x, entity.y)] then + return true + end + end + return false +end + -- Inventory index of the single best equipment upgrade to put on this turn, or -- nil. Weapons and armour compare on power, rings on attack plus defence. function Game:bestUpgrade() @@ -2059,11 +2075,135 @@ function Game:bestUpgrade() return nil end +-- The first not-yet-known skill of the player's class whose level requirement +-- is already met, or nil. Learning costs no turn, so the autopilot spends +-- any banked skill point the moment it can, in the tree's fixed order. +function Game:nextLearnableSkill() + local player = self.player + if not player.class or (player.skillPoints or 0) <= 0 then return nil end + for _, key in ipairs(Skills.forClass(player.class)) do + if not Skills.has(player, key) then + local def = Skills.active[key] or Skills.passive[key] + if player.level >= def.reqLevel then return key end + end + end + return nil +end + +function Game:hasCompanion() + for _, entity in ipairs(self.level.entities) do + if entity.kind == "companion" and entity:isAlive() then return true end + end + return false +end + +function Game:hasHireling() + for _, entity in ipairs(self.level.entities) do + if entity.kind == "hireling" and entity:isAlive() then return true end + end + return false +end + +-- Voluntary, penalty-free release of the live hireling, if any - contrast +-- Mercenaries.payWage's desertion, which fires on its own once loyalty +-- bottoms out from missed wages rather than on the player's own say-so. +function Game:dismissHireling() + local name = Mercenaries.dismiss(self) + if not name then + self.log:add("you have no one under contract to release") + return false + end + self.log:add("you release your " .. name .. " from service") + return true +end + function Game:choosePlayerAction() local player = self.player + local learnable = self:nextLearnableSkill() + if learnable then self:learnSkill(learnable) end + if not player.quest then + local board = self.level:questBoardAt(player.x, player.y) + if board and not board.claimed then self:tryAcceptQuest(board) end + end if not self:anyMonsterVisible() then local upgrade = self:bestUpgrade() if upgrade then self:useItem(upgrade); return end + if not self:hasCompanion() then + local charm = self:findInInventory(function(item) return item.kind == Items.CHARM end) + if charm then self:useItem(charm); return end + end + if not self:hasHireling() then + local contract = self:findInInventory(function(item) return item.kind == Items.CONTRACT end) + if contract then self:useItem(contract); return end + end + -- Only ever risks a shrine's first, cheapest, least cursed-prone prayer: + -- the autopilot is not greedy enough to keep re-praying at rising odds. + local shrine = self.level:shrineAt(player.x, player.y) + if shrine and shrine.uses == 0 and player.gold >= Shrines.costFor(shrine) then + self:prayAt(shrine) + return + end + -- Restores whatever gear has actually stopped working the moment the + -- purse can cover it, same "take it as soon as it's affordable" spirit + -- as the shrine check just above. + local brokenSlot = self:brokenEquippedSlot() + if brokenSlot and player.gold >= Durability.repairCost(player[brokenSlot]) then + self:repairEquipped(brokenSlot) + return + end + -- Free income with no downside, so the autopilot takes it on sight + -- rather than weighing it like a shrine's costed, risky prayer. + local node = self.level:nodeAt(player.x, player.y) + if node and not Nodes.isFallow(node) then + self:harvestNode() + return + end + -- Free protein with no gold cost, so the autopilot butchers a corpse on + -- sight same as it strip-mines a node - but only while the meat is + -- still good; a rotten one it leaves alone rather than poisoning itself + -- for nothing. + local corpse = self.level:corpseAt(player.x, player.y) + if corpse and not Corpses.isRotten(corpse) then + self:butcherCorpse() + return + end + -- A chest is worth cracking on sight exactly like the node and corpse + -- above, but only once it can actually be: an unlocked one is free + -- money, and a locked one is only ever tried with a key or lockpick + -- already in hand (see Game:openChest). Skipping a locked chest with + -- neither on hand keeps the autopilot from parking on it and burning + -- every remaining turn on a lock it has no way to open. + local chest = self.level:chestAt(player.x, player.y) + if chest and not chest.opened and (not Chests.isLocked(chest) or self:hasKey() + or self:findInInventory(function(item) return item.kind == Items.LOCKPICK end)) then + self:openChest() + return + end + -- A held captive is worth cashing in on sight exactly like the chest + -- above: the autopilot always takes the plain gold of Game:sellCaptive + -- rather than gambling on Game:ransomCaptive's goodwill or + -- Game:interrogateCaptive's map intel, neither of which it is smart + -- enough here to weigh against the other. + local captive = self.level:captiveAt(player.x, player.y) + if captive then + self:sellCaptive() + return + end + -- A rift crossing is only ever attempted at good health: its own pocket + -- floor is deliberately tuned harder than the main-track floor the rift + -- itself sits on (see branches.lua), so the autopilot never spends its + -- one shard gambling into a fight it is not ready for. + if player.hp >= player.maxHp * 0.8 and self:enterRift() then + return + end + -- Sheds the cheapest thing it is hauling once genuinely overloaded, but + -- only in this same downtime window as the checks above - never instead + -- of fighting or fleeing, since a visible monster always skips this + -- whole block. + if self:encumbranceTier() == Encumbrance.OVERLOADED then + local junk = self:cheapestDroppableItem() + if junk then self:dropItem(junk); return end + end end if player.hp < player.maxHp // 3 then local potion = self:findInInventory(function(item) @@ -2077,14 +2217,60 @@ function Game:choosePlayerAction() end local target = self:adjacentMonster() if target then + -- A carried net beats the killing blow once the target is already + -- battered down to Captives.eligible's own threshold: a capture forgoes + -- this kill's xp and essence, but a live captive can still be cashed in + -- for gold moments later (see the sell-on-sight check above), so this is + -- a trade the autopilot is always willing to make rather than a strict + -- loss. + if Captives.eligible(target) then + local net = self:findInInventory(function(item) return item.kind == Items.NET end) + if net then + self:captureAt(net, target) + return + end + end self:movePlayer(sign(target.x - player.x), sign(target.y - player.y)) return end - if player.x == self.level.downX and player.y == self.level.downY then + + -- Across a rift, the only objective is its own pocket floor's exit: never + -- the main track's descend/ascend goals below, which Game:descend and + -- Game:ascend both refuse outright for as long as self.rift holds anyway. + if self.rift then + if player.x == self.level.downX and player.y == self.level.downY then + self:exitRift() + return + end + local path = self:playerPathTo(self.level.downX, self.level.downY) + if path and #path >= 2 then + self:movePlayer(path[2].x - player.x, path[2].y - player.y) + return + end + local hunt = self:reachableMonsterStep() + if hunt then + self:movePlayer(hunt.x - player.x, hunt.y - player.y) + return + end + local ex, ey = self:exploreStep() + if ex then self:movePlayer(ex, ey) end + return + end + + -- Once the amulet is in hand the objective flips: every remaining step + -- heads for the up stairs instead of the down stairs, floor by floor. + local goalX, goalY = self.level.downX, self.level.downY + if self:hasAmulet() then + goalX, goalY = self.level.upX, self.level.upY + if player.x == goalX and player.y == goalY then + self:ascend() + return + end + elseif self.depth < self.maxDepth and player.x == goalX and player.y == goalY then self:descend() return end - local path = self:playerPathTo(self.level.downX, self.level.downY) + local path = self:playerPathTo(goalX, goalY) if path and #path >= 2 then self:movePlayer(path[2].x - player.x, path[2].y - player.y) return @@ -2099,11 +2285,15 @@ function Game:choosePlayerAction() end function Game:hungerTorchTick() - self.player.satiation = self.player.satiation - 1 - if self.player.satiation <= 0 then - self.player.satiation = 0 - self.player.hp = self.player.hp - 1 - self.log:add("you are starving") + if not Hunger.suppressesRegen(self.player) then + local ringRegen = Artifacts.ringRegen(self.player) + Sets.regenAmount(self.player) + if ringRegen > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + ringRegen) + end + local weatherRegen = Atmosphere.regenBonus(self.level.weather) + if weatherRegen > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + weatherRegen) + end end if self.player.light.fuel > 0 then self.player.light.fuel = self.player.light.fuel - 1