feat: extend horde with Horde.essenceFor and Horde.pool

This commit is contained in:
dualfroz committed 2026-09-06 21:49:13 +02:00
1 parent 58994a6132
commit 1f4ea71a1f
3 files changed
+198 -1

No files matched your search

+85
View File
@@ -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
+112
View File
@@ -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
+1 -1
View File
@@ -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",