feat: add canSee and isAlly

This commit is contained in:
dualfroz committed 2026-09-06 22:53:04 +02:00
1 parent 1f4ea71a1f
commit 2faa8dbe75
1 file changed
+110
+110
View File
@@ -0,0 +1,110 @@
-- Monsters that share no faction bond will occasionally fight each other
-- outright instead of always beelining for the player. Two rival factions
-- (see Factions.RIVALRIES) throw down on sight, and any monster carrying
-- Status.DISCORD - landed by the discord spell, see spells.lua - is
-- compelled to strike whatever creature stands nearest it, friend or foe,
-- for the effect's duration. Either way the fight runs through combat.lua
-- exactly like a player's own attack - same damage formula, same
-- resistances, same Game:applyEliteOnHit hooks (an elite attacker's
-- lifesteal or on-hit status rider fires here too, not just against the
-- player) and the same disease.lua bite chance (a plague-carrier's bite can
-- infect a rival monster or the player's own companion/hireling/thrall,
-- not just the player Game:monsterMelee already covers) - so a discorded
-- troll among goblins is a real threat to both sides, not a scripted
-- distraction. A plain monster felled this way
-- still leaves a corpse (scavenging/reanimation can use it) but pays out no
-- xp, essence, faction standing, or compendium credit - the player didn't
-- land the blow. A companion, hireling, or thrall killed this way (only
-- reachable through discord, since none of them carry a faction) dies
-- exactly as it would to any other monster.
local Combat = require("combat")
local Status = require("status")
local Factions = require("factions")
local Los = require("los")
local Corpses = require("corpses")
local Noise = require("noise")
local Disease = require("disease")
local Infighting = {}
Infighting.SIGHT_RANGE = 8
local function canSee(game, a, b)
local cheb = math.max(math.abs(a.x - b.x), math.abs(a.y - b.y))
if cheb > Infighting.SIGHT_RANGE then return false end
return Los.clear(game.level.map, a.x, a.y, b.x, b.y)
end
local function isAlly(actor)
return actor.kind == "companion" or actor.kind == "hireling" or actor.kind == "thrall"
end
-- The other living, non-player creature `monster` should be fighting this
-- turn instead of following its usual Ai.act behaviour, or nil if none
-- qualifies. A rival-faction monster always qualifies once seen; under
-- Status.DISCORD, the nearest visible creature of any kind - including the
-- player's own companion, hireling, or thrall - qualifies too, since
-- discord scrambles a mind's sense of friend and foe entirely. Ties break
-- toward the lower entity id, matching Game:nearestAlly's own tie rule.
function Infighting.target(game, monster)
local discorded = Status.has(monster, Status.DISCORD)
local best, bestDist = nil, math.huge
for _, other in ipairs(game.level.entities) do
if other ~= monster and other:isAlive() then
local qualifies = discorded or Factions.areRivals(monster.faction, other.faction)
if qualifies and canSee(game, monster, other) then
local dist = math.max(math.abs(other.x - monster.x), math.abs(other.y - monster.y))
if dist < bestDist or (dist == bestDist and other.id < best.id) then
best, bestDist = other, dist
end
end
end
end
return best
end
-- Resolves one monster-vs-monster hit through the same damage formula any
-- other attack uses. A defender that falls is swept immediately: a
-- companion/hireling/thrall dies exactly as game.lua already reports that,
-- while a plain hostile monster just drops - Corpses.spawn still leaves
-- remains behind, but no awardKill means no xp/essence/faction/compendium
-- credit for a kill the player never made.
function Infighting.strike(game, attacker, defender)
local outcome = Combat.attack(attacker, defender)
game.log:add(string.format("%s hits %s for %d", attacker.name, defender.name, outcome.damage))
game:applyEliteOnHit(attacker, outcome.damage, defender)
if attacker.biteDisease and defender:isAlive() and game.rng:chance(1, 4) then
if Disease.infect(defender, attacker.biteDisease) then
game.log:add(string.format("%s's bite leaves %s feeling ill", attacker.name, defender.name))
end
end
game:emitNoise(attacker.x, attacker.y, Noise.MELEE)
if not defender:isAlive() then
if isAlly(defender) then
game:onActorDeath(defender)
else
game.log:add(defender.name .. " dies")
Corpses.spawn(game.level, defender)
end
end
return outcome
end
-- Entry point mirroring Abilities.tryAct's contract: returns true when
-- infighting consumed the monster's turn (attacked an adjacent target or
-- stepped toward a farther one), false to let the caller fall through to
-- Ai.act.
function Infighting.tryAct(game, monster)
local target = Infighting.target(game, monster)
if not target then return false end
local dist = math.max(math.abs(target.x - monster.x), math.abs(target.y - monster.y))
if dist <= 1 then
Infighting.strike(game, monster, target)
else
game:stepMonsterToward(monster, target.x, target.y)
end
return true
end
return Infighting