diff --git a/src/game.lua b/src/game.lua index 39c371c..79cff2f 100644 --- a/src/game.lua +++ b/src/game.lua @@ -695,8 +695,53 @@ function Game:takeMonsterTurn(monster) self:onActorDeath(monster) return end - if not Status.skipsTurn(monster) then - Ai.act(self, monster) + Conductivity.tick(self.level, monster) + local hazardLine = Hazards.afflict(self.level, monster) + if hazardLine then self.log:add(hazardLine) end + if not monster:isAlive() then + self:onActorDeath(monster) + return + end + -- Mirrors Game:hungerTorchTick's own Disease.tick(self.player) call - a + -- disease is plain generic entity state (see disease.lua), and since + -- Infighting.strike can now infect a rival monster or the player's own + -- companion/hireling/thrall (not just the player), every non-player actor + -- needs its own infection to actually progress on its turn too, or an + -- infected ally would sit permanently incubating with no way to ever + -- erupt, escalate, or be noticed. + local diseaseLine = Disease.tick(monster) + if diseaseLine then self.log:add(diseaseLine) end + if not monster:isAlive() then + self:onActorDeath(monster) + return + end + local regenLine = Abilities.regenTick(monster) + if regenLine then self.log:add(regenLine) end + local enrageLine = Bosses.tryEnrage(monster) + if enrageLine then self.log:add(enrageLine) end + local phaseLine, phaseConsumedTurn = Phases.tick(self, monster) + if phaseLine then self.log:add(phaseLine) end + if not phaseConsumedTurn and not Status.skipsTurn(monster) then + if monster.kind == "companion" then + Companions.act(self, monster) + elseif monster.kind == "hireling" then + Mercenaries.act(self, monster) + elseif monster.kind == "thrall" then + Reanimation.act(self, monster) + elseif Infighting.tryAct(self, monster) then + -- A rivalry or a discorded mind has nothing to do with how this + -- monster currently feels about the player: it fights regardless of + -- wariness or - unlike Stealth.tick's own gating - whether it has + -- otherwise noticed the player at all this turn. + elseif Factions.isWary(self.player, monster) then + -- A wary tribe member holds its ground: neither provoked nor hated + -- into open hostility yet, it leaves the player be this turn. + elseif Stealth.tick(self, monster) then + Abilities.tickCooldown(monster) + if not Abilities.tryAct(self, monster) then + Ai.act(self, monster) + end + end end Scheduler.spend(monster) self:cleanupDead() @@ -713,15 +758,128 @@ end -- Player actions. -function Game:playerAttack(target) - local outcome = Combat.attack(self.player, target) - self.log:add(string.format("you hit %s for %d", target.name, outcome.damage)) - local weapon = self.player.weapon - if weapon and weapon.affix and weapon.affix.onHit and target:isAlive() then - local rider = weapon.affix.onHit +-- Common tail of any player-caused kill: log it, grant the xp it was worth, +-- and sweep it off the level. Shared by melee and offensive spells so xp +-- always tracks a felled monster regardless of what felled it. +function Game:awardKill(target) + self.log:add(target.name .. " dies") + local burst = Abilities.onDeath(self, target) + if burst then self.log:add(burst) end + local eliteBurst = Elites.onDeath(self, target) + if eliteBurst then self.log:add(eliteBurst) end + self.stats.kills = self.stats.kills + 1 + if target.isBoss then self.stats.bossKills = self.stats.bossKills + 1 end + Factions.registerKill(self.player, target) + local xp = target.isBoss and Bosses.xpFor(target) or target.mimic and Mimics.xpFor() + or target.grave and Graves.xpFor() or Monsters.xpFor(target) + xp = xp + Elites.xpBonus(target, xp) + if target.nemesis then xp = xp + Nemesis.xpBonus(target.nemesisGrudge or 1) end + self.log:addAll(Progression.grantXp(self.player, xp)) + self.player.essence = self.player.essence + math.floor(Crafting.essenceFor(xp) * self:essenceMultiplier()) + Quests.registerKill(self.player, target.species) + Compendium.noteKill(self.compendium, target.species) + Corpses.spawn(self.level, target) + self:resolveQuest() + if target.isBoss then + local loot = Bosses.lootFor(target) + if loot then + self.level.floorItems[#self.level.floorItems + 1] = { x = target.x, y = target.y, item = loot } + self.log:add(string.format("%s drops %s", target.name, loot.name)) + end + end + if target.grave then + local gold, essence = Graves.lootFor(target) + if gold > 0 then + self.player.gold = self.player.gold + gold + self.log:add(string.format("the shade scatters %d gold as it fades", gold)) + end + if essence > 0 then + self.player.essence = self.player.essence + essence + end + end + if target.nemesis then + local bounty = Nemesis.bountyGold(target.nemesisGrudge or 1) + self.player.gold = self.player.gold + bounty + self.log:add(string.format("the hunt is over - you claim %d gold from %s", bounty, target.name)) + self.nemesis = nil + end + self:cleanupDead() +end + +-- Applies a weapon's on-hit rider - rolled affix or socketed gem, or both - +-- to a still-living target, logging each status it lands. +function Game:applyWeaponRiders(weapon, target) + if not (weapon and target:isAlive()) then return end + local riders = {} + if weapon.affix and weapon.affix.onHit then riders[#riders + 1] = weapon.affix.onHit end + if weapon.socket and weapon.socket.onHit then riders[#riders + 1] = weapon.socket.onHit end + for _, rider in ipairs(riders) do Status.apply(target, rider.status, rider.duration, rider.potency) self.log:add(string.format("%s is %s", target.name, rider.status)) end +end + +-- Marks a monster as no longer wary (see factions.lua): once the player has +-- aimed any offensive action at it, a tribal target that used to hold back +-- fights (or flees) like any other monster from then on, hit or miss. Any +-- offensive action also wakes a dormant target outright (see stealth.lua) - +-- an arrow or a spell that misses still gives away exactly where you are. +function Game:provoke(target) + if target and target.kind == "monster" then + target.provoked = true + Stealth.alert(target, self.player.x, self.player.y) + end +end + +-- Floods a burst of noise.lua sound out from (x, y) and gives every other +-- still-dormant monster the flood actually reaches a chance to stir - a +-- clash of steel three rooms over does not single out whichever target +-- Game:provoke already woke outright, it startles whatever else is close +-- enough to hear it too. A monster startled this way only ever starts +-- searching the noise's origin (see Stealth.hear), never snaps straight to +-- hunting the way a direct hit does. +function Game:emitNoise(x, y, loudness) + local field = Noise.propagate(self.level.map, x, y, loudness) + for _, entity in ipairs(self.level.entities) do + if entity.kind == "monster" and Stealth.isUnaware(entity) then + local intensity = Noise.intensityAt(field, self.level.map, entity.x, entity.y) + if intensity > 0 and self.rng:chance(intensity, loudness) then + Stealth.hear(entity, x, y) + self.log:add(string.format("%s stirs at the noise", entity.name)) + end + end + end +end + +function Game:playerAttack(target) + -- Read before provoke() wakes the target below, so the bonus reflects + -- whatever it actually was the instant the blow landed. + local sneak = Stealth.sneakMultiplier(target) + self:provoke(target) + self:emitNoise(self.player.x, self.player.y, Noise.MELEE) + local multiplier = Skills.meleeDamageMultiplier(self.player, target) * sneak + local outcome = Combat.attack(self.player, target, multiplier) + Momentum.onMeleeHit(self.player) + if sneak > 1 then + self.log:add(string.format("you catch %s completely unaware", target.name)) + end + self.log:add(string.format("you hit %s for %d", target.name, outcome.damage)) + local broke = Durability.wearWeapon(self.player) + if broke then self.log:add(broke) end + local lifesteal = Artifacts.lifestealAmount(self.player, outcome.damage) + if lifesteal > 0 then + self.player.hp = math.min(self.player.maxHp, self.player.hp + lifesteal) + self.log:add(string.format("your %s drinks %d hp from %s", self.player.weapon.name, lifesteal, target.name)) + end + self:applyWeaponRiders(self.player.weapon, target) + if Skills.consumePoisonBlade(self.player) and target:isAlive() then + Status.apply(target, Status.POISON, 5, 2) + self.log:add(string.format("%s is envenomed", target.name)) + end + local masteredKey, masteredBonus = Proficiency.onMeleeHit(self.player) + if masteredKey then + self.log:add(string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus)) + end if outcome.killed then self.log:add(target.name .. " dies") self:cleanupDead()