diff --git a/src/abilities.lua b/src/abilities.lua index df46ff9..56df4db 100644 --- a/src/abilities.lua +++ b/src/abilities.lua @@ -151,3 +151,113 @@ local function tryShriek(game, monster) return true, string.format("%s's wail blinds you", monster.name) end +-- Cleric-style ability: mends the neediest hurt ally within range and line of +-- sight instead of attacking. Ties break on the lowest hp fraction. Filtered +-- to other.kind == "monster" - the same "hostile" tag every other module +-- (companions.lua/mercenaries.lua/reanimation.lua's own nearestHostile, +-- commands.lua's isVisibleHostile) already keys off of - so a dark cleric +-- mends a fellow monster, never the player's own wounded companion, hireling +-- or thrall sharing the same entities list. +local function tryHeal(game, monster) + local best, bestRatio = nil, 1 + for _, other in ipairs(game.level.entities) do + if other ~= monster and other.kind == "monster" and other:isAlive() and other.hp < other.maxHp then + local dist = math.max(math.abs(other.x - monster.x), math.abs(other.y - monster.y)) + if dist <= RANGE.heal and Los.clear(game.level.map, monster.x, monster.y, other.x, other.y) then + local ratio = other.hp / other.maxHp + if ratio < bestRatio then bestRatio, best = ratio, other end + end + end + end + if not best then return false end + local amount = 6 + local before = best.hp + best.hp = math.min(best.maxHp, best.hp + amount) + monster.cooldown = COOLDOWN.heal + return true, string.format("%s mends %s for %d", monster.name, best.name, best.hp - before) +end + +-- Chieftain-style ability: grants every adjacent, not-yet-buffed ally (and +-- itself) a temporary attack boost. No-op once the whole knot is already lit. +-- Filtered to other.kind == "monster" for the same reason tryHeal above is: +-- without it a chieftain standing next to the player's companion, hireling +-- or thrall would rally that ally of the PLAYER's instead of one of its own. +local function tryRally(game, monster) + local buffed = {} + for _, other in ipairs(game.level.entities) do + if other ~= monster and other.kind == "monster" and other:isAlive() and not Status.has(other, Status.STRENGTH) + and math.abs(other.x - monster.x) <= 1 and math.abs(other.y - monster.y) <= 1 then + Status.apply(other, Status.STRENGTH, 10, 2) + buffed[#buffed + 1] = other.name + end + end + if #buffed == 0 then return false end + Status.apply(monster, Status.STRENGTH, 10, 2) + monster.cooldown = COOLDOWN.rally + return true, string.format("%s rallies %s", monster.name, table.concat(buffed, ", ")) +end + +-- Footpad-style ability: not cooldown-gated. Once adjacent it lifts a single +-- non-essential item (never an equipped key or the amulet, since those never +-- sit unequipped in inventory) and flees for the rest of the encounter; a +-- fleeing footpad just keeps running rather than trying again. +local function trySteal(game, monster) + if monster.fleeing then + game:stepMonsterAway(monster, game.player.x, game.player.y) + return true + end + if not game:playerAdjacent(monster) then return false end + local idx = game:findInInventory(function(item) + return item.kind ~= Items.KEY and item.kind ~= Items.AMULET + end) + if not idx then + monster.fleeing = true + return true, string.format("%s finds nothing worth stealing and flees", monster.name) + end + local item = table.remove(game.player.inventory, idx) + monster.stolen = item + monster.fleeing = true + return true, string.format("%s steals your %s and flees", monster.name, item.name) +end + +local dispatch = { summon = trySummon, curse = tryCurse, heal = tryHeal, rally = tryRally, shriek = tryShriek } + +-- Called once per monster turn, after status ticks and regen but before +-- ai.lua. Returns true when the ability consumed the turn. +function Abilities.tryAct(game, monster) + if not monster.ability then return false end + if monster.ability == "steal" then + local acted, line = trySteal(game, monster) + if acted and line then game.log:add(line) end + return acted == true + end + local handler = dispatch[monster.ability] + if not handler or (monster.cooldown and monster.cooldown > 0) then return false end + local acted, line = handler(game, monster) + if not acted then return false end + if line then game.log:add(line) end + return true +end + +-- Slime-style reaction: fired from Game:playerAttack when a hit lands but +-- does not kill. Halves the victim's max hp and spawns a clone at the other +-- half, capped at two splits total (`splitGen`) so a slime cannot be farmed +-- into an unbounded swarm. +function Abilities.onHit(game, target) + if target.ability ~= "split" then return nil end + if not target:isAlive() or target.maxHp < 4 then return nil end + if (target.splitGen or 0) >= 2 then return nil end + local x, y = freeAdjacent(game, target.x, target.y) + if not x then return nil end + local newMax = target.maxHp // 2 + target.maxHp = newMax + target.hp = math.min(target.hp, newMax) + target.splitGen = (target.splitGen or 0) + 1 + local clone = game:spawnMinion(target.species, x, y) + clone.maxHp = newMax + clone.hp = newMax + clone.splitGen = target.splitGen + return string.format("the %s splits in two", target.name) +end + +return Abilities diff --git a/src/affixes.lua b/src/affixes.lua index 8009930..0a57fb3 100644 --- a/src/affixes.lua +++ b/src/affixes.lua @@ -1,14 +1,18 @@ -- Gear affixes. A weapon or armour piece may roll a prefix that bumps its power -- and, for some weapons, attaches an on-hit rider (e.g. flaming sets the target -- alight). Rolls draw from the shared rng so drops stay deterministic per seed. +-- An elemental rider also retags the weapon's own base swing (damageType, +-- read by combat.lua) to match - a flaming sword fights with fire, not steel, +-- so it hits harder against a cold-weak target and can do nothing at all +-- against something fire-immune. local Affixes = {} Affixes.weapon = { { key = "sharp", name = "sharp", power = 1 }, { key = "vicious", name = "vicious", power = 2 }, - { key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 } }, - { key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 } }, + { key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 }, damageType = "fire" }, + { key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 }, damageType = "poison" }, } Affixes.armor = { @@ -31,9 +35,10 @@ end function Affixes.decorate(item, affix) if not affix then return item end - item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit } + item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit, damageType = affix.damageType } item.power = item.power + affix.power item.name = affix.name .. " " .. item.name + item.damageType = affix.damageType return item end diff --git a/src/ai.lua b/src/ai.lua index bfa532d..4d55a53 100644 --- a/src/ai.lua +++ b/src/ai.lua @@ -2,15 +2,38 @@ -- resolves that monster's turn through game helpers, so all state mutation and -- rng use stays centralised and deterministic. +local Status = require("status") +local Infighting = require("infighting") + local Ai = {} -local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8 } +local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8, tunnel = 10 } local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } +-- A player-aligned ally standing adjacent to `monster` occupies its cell +-- exactly the way a wall would for Game:stepMonsterToward's own pathfinding +-- (blockedForMonster treats any occupied non-goal cell as impassable) - so a +-- one-wide corridor an ally is plugging leaves every pursuit behaviour below +-- with no route at all, and without this check they simply idle beside it +-- forever rather than fighting the living combatant in their way. Resolves +-- through Infighting.strike, the same generic monster-vs-any-creature combat +-- infighting.lua already uses for rivalries and discord. Returns true once +-- it has spent the monster's turn this way, so each caller can bail out at +-- the same priority Game:playerAdjacent already has over closing distance. +local function attackBlockingAlly(game, monster) + local ally = game:adjacentAlly(monster) + if not ally then return false end + Infighting.strike(game, monster, ally) + return true +end + local function chase(game, monster) if game:playerAdjacent(monster) then game:monsterMelee(monster) - elseif game:canSeePlayer(monster, AGGRO.chase) then + return + end + if attackBlockingAlly(game, monster) then return end + if game:canSeePlayer(monster, AGGRO.chase) then game:stepMonsterToward(monster, game.player.x, game.player.y) end end @@ -20,6 +43,7 @@ local function pack(game, monster) game:monsterMelee(monster) return end + if attackBlockingAlly(game, monster) then return end if game:canSeePlayer(monster, AGGRO.pack) then game:stepMonsterToward(monster, game.player.x, game.player.y) return @@ -39,6 +63,7 @@ local function ranged(game, monster) end return end + if attackBlockingAlly(game, monster) then return end if game:canSeePlayer(monster, AGGRO.ranged) then game:stepMonsterToward(monster, game.player.x, game.player.y) end @@ -50,6 +75,14 @@ local function flee(game, monster) end end +-- Shopkeepers idle unless game.lua has already flagged them hostile (a theft +-- from their stock), at which point they behave exactly like a chaser. +local function vend(game, monster) + if monster.hostile then + chase(game, monster) + end +end + local function erratic(game, monster) if game:playerAdjacent(monster) then game:monsterMelee(monster) @@ -63,15 +96,45 @@ local function erratic(game, monster) end end +-- A burrower: melee once adjacent, chase like anything else once it can see +-- the player through open floor, otherwise claw straight through whatever +-- rock stands between them (see Game:monsterDig, digging.lua) rather than +-- idling the way a plain chaser out of sight would. +local function tunnel(game, monster) + if game:playerAdjacent(monster) then + game:monsterMelee(monster) + return + end + if attackBlockingAlly(game, monster) then return end + if game:canSeePlayer(monster, AGGRO.tunnel) then + game:stepMonsterToward(monster, game.player.x, game.player.y) + return + end + game:monsterDig(monster, game.player.x, game.player.y) +end + Ai.behaviours = { chase = chase, pack = pack, ranged = ranged, flee = flee, erratic = erratic, + vend = vend, + tunnel = tunnel, } +-- A confused monster gets the exact same chance each turn to have its own +-- chosen direction overridden that Game:movePlayer rolls for the player - see +-- status.lua's Status.CONFUSE_NUMERATOR/DENOMINATOR, shared by both sides so +-- neither one confuses more reliably than the other. On a lucky roll (or once +-- confusion wears off) `behaviour` below runs completely untouched. function Ai.act(game, monster) + if Status.has(monster, Status.CONFUSE) + and game.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then + local dir = DIRS[game.rng:range(1, #DIRS)] + game:moveMonster(monster, dir[1], dir[2]) + return + end local behaviour = Ai.behaviours[monster.ai] or chase behaviour(game, monster) end