feat: extend game with Game:anyHostileVisible

This commit is contained in:
dualfroz committed 2026-09-06 19:07:12 +02:00
1 parent 7e20884d3d
commit b4e2c7a431
1 file changed
+197 -7
+197 -7
View File
@@ -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