diff --git a/src/horde.lua b/src/horde.lua index 2861e8f..cde5a2d 100644 --- a/src/horde.lua +++ b/src/horde.lua @@ -157,3 +157,88 @@ function Horde.countFor(wave) return count end +function Horde.essenceFor(wave) + return wave * Horde.ESSENCE_PER_WAVE +end + +-- Species eligible for wave `wave`, drawn from Monsters.forDepth at +-- Horde.depthFor(wave) exactly like a real floor's population, minus the +-- passive vend-behaviour shopkeeper - a wave the player can never provoke +-- into fighting back would leave Horde.cleared permanently false. +function Horde.pool(wave) + local eligible = {} + for _, spec in ipairs(Monsters.forDepth(Horde.depthFor(wave))) do + if spec.ai ~= "vend" then eligible[#eligible + 1] = spec end + end + return eligible +end + +-- True once every hostile monster the arena has ever placed is dead - +-- vacuously true against a still-empty arena too, which is exactly what +-- lets Horde.tick reuse the same check to bootstrap wave 1 as it does to +-- clear every wave after it (see Horde.tick's own comment). Filtered to +-- entity.kind == "monster", the same "hostile" tag every other module +-- (companions.lua/mercenaries.lua/reanimation.lua/abilities.lua...) uses: +-- Game:startHorde can now carry a bonded companion/hireling/thrall into the +-- arena, and an ally that never dies to the wave itself would otherwise +-- keep this permanently false, freezing the arena on wave 0 forever. +function Horde.cleared(level) + for _, entity in ipairs(level.entities) do + if entity.kind == "monster" and entity:isAlive() then return false end + end + return true +end + +-- Populates `game.level` with wave `wave`'s monsters, drawn from +-- Horde.pool and placed one to a cell along Horde.spawnPoints's queue - +-- never doubling two monsters onto the same cell, the one-entity-per-cell +-- invariant every other spawn path in game.lua already relies on. Every +-- spawn is already hunting, exactly like Game:spawnMinion's own summons - +-- an arena has no stealth approach to sneak past. Returns the spawned +-- entities. +function Horde.spawnWave(game, wave) + local level = game.level + local points = Horde.spawnPoints(level.map) + local pool = Horde.pool(wave) + local count = math.min(Horde.countFor(wave), #points) + local depth = Horde.depthFor(wave) + local spawned = {} + for i = 1, count do + local point = points[i] + local spec = game.rng:pick(pool) + local entity = Monsters.create(spec, game:nextMonsterId(), point.x, point.y, depth) + entity.awareness = Stealth.HUNTING + Elites.maybePromote(game.rng, entity, depth) + level.entities[#level.entities + 1] = entity + spawned[#spawned + 1] = entity + end + return spawned +end + +-- Advances horde state by one player turn, called from Game:endPlayerTurn +-- behind a `game.horde` check. A no-op while the current wave still has a +-- living monster in it; the instant it does not - including the very first +-- call, against the still-empty arena Game:startHorde just built, since +-- Horde.cleared reads that the same way as a genuinely finished wave - it +-- banks Horde.essenceFor(wave) essence (zero the first time, wave still +-- being 0), heals Horde.REST_HEAL, advances the wave counter, and spawns +-- the new wave outright, so the arena is never left standing empty for +-- even a single turn. Returns a log line, or nil when nothing happened. +function Horde.tick(game) + local horde = game.horde + if not horde then return nil end + if not Horde.cleared(game.level) then return nil end + local reward = Horde.essenceFor(horde.wave) + if reward > 0 then + game.player.essence = game.player.essence + reward + end + game.player.hp = math.min(game.player.maxHp, game.player.hp + Horde.REST_HEAL) + horde.wave = horde.wave + 1 + Horde.spawnWave(game, horde.wave) + if horde.wave == 1 then + return "the horde arena falls silent - wave 1 begins" + end + return string.format("the arena falls silent - wave %d begins (+%d essence)", horde.wave, reward) +end + +return Horde diff --git a/src/hunger.lua b/src/hunger.lua new file mode 100644 index 0000000..b1ed56e --- /dev/null +++ b/src/hunger.lua @@ -0,0 +1,112 @@ +-- Named bands layered over the player's raw satiation counter, the same +-- shape sanity.lua already gives its own slow-moving meter: a plain number +-- that Game:hungerTorchTick drains by one every round (untouched here), +-- classified into FED/HUNGRY/WEAK/STARVING tiers that a caller reads back +-- instead of re-deriving the cutoffs itself. Falling into WEAK or worse +-- costs real attack and defense - wired into entity.lua's effectiveAttack/ +-- effectiveDefense alongside Sanity's own penalty, so a gaunt fighter hits +-- and blocks softer without combat.lua needing to know hunger exists at +-- all - and blocks the passive mending hungerTorchTick already grants from +-- a worn ring, an ironclad set or consecrated ground: a starving adventurer +-- can still down a potion, just not coast on a ring of regeneration while +-- fading. STARVING alone keeps hungerTorchTick's own flat hp drain, exactly +-- as before. +-- +-- Eating is the mirror case: Hunger.eat folds fresh nutrition into the same +-- counter, capped at Hunger.CAP, but food eaten while already at that cap +-- is entirely wasted and leaves the diner Status.SLOW for a few turns - the +-- same overfull stumble a real glutton earns, reusing a status the engine +-- already ticks, serializes and renders with no special handling of its own. + +local Status = require("status") + +local Hunger = {} + +Hunger.FED = "fed" +Hunger.HUNGRY = "hungry" +Hunger.WEAK = "weak" +Hunger.STARVING = "starving" + +-- Starting ration for a fresh game (Game.new) and the ceiling Hunger.eat +-- refuses to bank past. Comfortably above START so a couple of found +-- rations can stack up before a long dive, without letting the pool grow +-- without bound. +Hunger.START = 1000 +Hunger.CAP = 1200 + +-- Tier ceilings: a value at or below HUNGRY_MAX but above WEAK_MAX reads as +-- HUNGRY, and so on downward. 0 itself is always STARVING regardless of +-- where these ceilings sit. +Hunger.HUNGRY_MAX = 300 +Hunger.WEAK_MAX = 100 + +Hunger.BLOAT_DURATION = 4 + +local ATTACK_PENALTY = { + [Hunger.FED] = 0, [Hunger.HUNGRY] = 0, [Hunger.WEAK] = -2, [Hunger.STARVING] = -4, +} +local DEFENSE_PENALTY = { + [Hunger.FED] = 0, [Hunger.HUNGRY] = 0, [Hunger.WEAK] = -1, [Hunger.STARVING] = -3, +} + +-- A fresh entity - or a monster, which never accrues hunger of its own +-- since nothing ever calls Hunger.tick for one - reads as fully fed. +function Hunger.current(entity) + return entity.satiation or Hunger.CAP +end + +function Hunger.tierOf(entity) + local value = Hunger.current(entity) + if value <= 0 then return Hunger.STARVING end + if value <= Hunger.WEAK_MAX then return Hunger.WEAK end + if value <= Hunger.HUNGRY_MAX then return Hunger.HUNGRY end + return Hunger.FED +end + +function Hunger.attackPenalty(entity) + return ATTACK_PENALTY[Hunger.tierOf(entity)] +end + +function Hunger.defensePenalty(entity) + return DEFENSE_PENALTY[Hunger.tierOf(entity)] +end + +-- WEAK or worse blocks the passive regen hungerTorchTick otherwise grants +-- from a ring, a set bonus or friendly weather - a fading body has nothing +-- left over to spend on mending. +function Hunger.suppressesRegen(entity) + local tier = Hunger.tierOf(entity) + return tier == Hunger.WEAK or tier == Hunger.STARVING +end + +-- One round of drain. Returns a log line only when the tier actually +-- changes, except STARVING which - matching the flat drain this replaces - +-- reports and bites for 1 hp every single round it holds, not just on the +-- turn it is first reached. +function Hunger.tick(entity) + local before = Hunger.tierOf(entity) + entity.satiation = math.max(0, Hunger.current(entity) - 1) + local after = Hunger.tierOf(entity) + if after == Hunger.STARVING then + entity.hp = entity.hp - 1 + return "you are starving" + end + if after ~= before then + return "you feel " .. after + end + return nil +end + +-- Feeds `nutrition` in, clamped at Hunger.CAP. Returns true when the diner +-- was already at the cap - all of it wasted, and left Status.SLOW for +-- Hunger.BLOAT_DURATION turns - or false for an ordinary meal that landed. +function Hunger.eat(entity, nutrition) + local wasFull = Hunger.current(entity) >= Hunger.CAP + entity.satiation = math.min(Hunger.CAP, Hunger.current(entity) + nutrition) + if wasFull then + Status.apply(entity, Status.SLOW, Hunger.BLOAT_DURATION, 1) + end + return wasFull +end + +return Hunger diff --git a/src/identify.lua b/src/identify.lua index e6b2b55..1778b9b 100644 --- a/src/identify.lua +++ b/src/identify.lua @@ -8,7 +8,7 @@ local Items = require("items") local Identify = {} local potionLooks = { - "ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous", + "ruby", "azure", "cloudy", "fizzy", "smoky", "golden", "murky", "viscous", "inky", "frothy", } local scrollLooks = { "XYZZY", "ELBERETH", "ZELGO KAI", "JUYED AWK", "VE FORBRYDERNE",