Files
warren/src/captives.lua
T

161 lines
7.0 KiB
Lua

-- Net-and-ransom: throwing a spare net at a wild monster too battered to
-- keep fighting subdues it alive instead of finishing it off with a killing
-- blow. A captured monster never runs through Game:awardKill at all - no
-- xp, no essence, no quest or compendium credit, no faction kill penalty
-- (see factions.lua's registerKill) and no corpse (corpses.lua) - it is
-- simply dropped from level.entities the instant its hp hits zero here, and
-- reborn as a captive: plain data on level.captives, exactly like a chest
-- or a corpse, so it serializes with no special save-format handling.
--
-- A held captive can then be spent exactly one of three ways, each removing
-- it from level.captives for good: ransomed back to its own kind for
-- nothing but goodwill (Captives.ransom), sold off for gold at that same
-- goodwill's expense (Captives.sell), or interrogated for whatever it knows
-- instead of what it is worth (Captives.interrogate, which cashes in
-- secrets.lua's own passive search early). None of the three is strictly
-- better than the others - ransoming is the one move that can pull a
-- tribal faction back from the brink of open hostility (see factions.lua's
-- isWary), selling is the only one that pays gold, and interrogating is the
-- only one that answers to the floor's own layout rather than to gold or
-- reputation at all.
--
-- Capture is a gamble, not a guarantee: even a target already weak enough
-- to try for can still thrash free, and the net itself is spent from the
-- inventory either way, the same one-shot risk a lockpick's own gamble
-- takes against a locked chest. A net never works at all on anything
-- already spoken for by another escalation or special-case system - a
-- boss, a mimic, a grave shade, a nemesis, or the shopkeeper (ai "vend",
-- effectively unkillable loot rather than wild game to begin with).
local Los = require("los")
local Items = require("items")
local Monsters = require("monsters")
local Elites = require("elites")
local Factions = require("factions")
local Secrets = require("secrets")
local Captives = {}
Captives.RANGE = 4
-- A target must be battered down to this fraction of its own max hp or
-- lower before a net is even worth trying - full-health prey just shrugs a
-- net off before Captives.resolve ever rolls the catch itself.
Captives.HP_FRACTION_NUMERATOR = 3
Captives.HP_FRACTION_DENOMINATOR = 10
Captives.CATCH_NUMERATOR = 1
Captives.CATCH_DENOMINATOR = 2
Captives.RANSOM_REP_GAIN = 12
Captives.SELL_REP_LOSS = 8
Captives.INTERROGATE_ESSENCE_DIVISOR = 2
-- Whether `target` is even worth throwing a net at: alive, an ordinary wild
-- hostile (not a boss, mimic, grave shade or nemesis, and not the vending
-- shopkeeper), and hurt down to HP_FRACTION_NUMERATOR/DENOMINATOR of its own
-- max hp or less.
function Captives.eligible(target)
return target ~= nil
and target:isAlive()
and target.kind == "monster"
and target.ai ~= "vend"
and not target.isBoss
and not target.mimic
and not target.grave
and not target.nemesis
and target.hp * Captives.HP_FRACTION_DENOMINATOR <= target.maxHp * Captives.HP_FRACTION_NUMERATOR
end
-- Same shape as throwing.lua's Throwing.canReach: a net flies no further
-- than RANGE and needs an unbroken line of sight, same as any thrown weapon.
function Captives.canReach(map, x1, y1, x2, y2)
local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2))
return dist <= Captives.RANGE and Los.clear(map, x1, y1, x2, y2)
end
-- Gold/essence baseline shared by every resolution below: the same xp a
-- kill would have paid, elite-boosted the same way Game:awardKill's own xp
-- is (see elites.lua), so a netted champion is worth visibly more than a
-- netted rank-and-file of the same species.
function Captives.valueOf(target)
local xp = Monsters.xpFor(target)
return xp + Elites.xpBonus(target, xp)
end
-- Resolves a net thrown from (thrower) at `target`. Returns (true, lines) if
-- the net left the thrower's hand - whether it actually held anything or
-- not - or (false, lines) if it never left the hand at all: the exact same
-- two-outcome shape Throwing.resolve takes for a thrown weapon or potion.
function Captives.resolve(game, thrower, item, target)
if not item or item.kind ~= Items.NET then
return false, { "that can't be thrown as a net" }
end
if not target or not target:isAlive() then
return false, { "no target" }
end
if not Captives.canReach(game.level.map, thrower.x, thrower.y, target.x, target.y) then
return false, { "you have no clear throw" }
end
if not Captives.eligible(target) then
return true, { target.name .. " is far too fierce to net while it can still fight like that" }
end
if not game.rng:chance(Captives.CATCH_NUMERATOR, Captives.CATCH_DENOMINATOR) then
return true, { target.name .. " thrashes free of the net" }
end
local captive = {
x = target.x, y = target.y,
species = target.species, faction = target.faction, name = target.name,
value = Captives.valueOf(target),
}
game.level.captives[#game.level.captives + 1] = captive
target.hp = 0
return true, { string.format("the net drops true - %s is bound and struggling", target.name) }
end
-- Drops `captive` off level.captives outright - shared by every way of
-- spending one below, so a captive can never be resolved twice over.
function Captives.remove(level, captive)
for i, candidate in ipairs(level.captives) do
if candidate == captive then
table.remove(level.captives, i)
return
end
end
end
-- Frees a held captive back to its own kind for nothing but goodwill: no
-- gold changes hands, but its faction's standing warms by RANSOM_REP_GAIN -
-- the mirror image of Factions.registerKill's penalty for finishing the
-- same creature off outright, and the one resolution that can actually pull
-- a tribal faction back out of Factions.isWary's hostile cutoff.
function Captives.ransom(level, player, captive)
Captives.remove(level, captive)
return Factions.adjust(player, captive.faction, Captives.RANSOM_REP_GAIN)
end
-- Sells a held captive off for its own value in gold: word gets back to its
-- kind either way, so standing sours by SELL_REP_LOSS even though - unlike
-- an outright kill - nothing actually died to earn it.
function Captives.sell(level, player, captive)
Captives.remove(level, captive)
player.gold = player.gold + captive.value
Factions.adjust(player, captive.faction, -Captives.SELL_REP_LOSS)
return captive.value
end
-- Interrogates a held captive instead of ransoming or selling it: no gold,
-- no reputation swing either way, just whatever it gives up before its
-- usefulness runs out - every currently-hidden secret door on this floor,
-- found outright (see Secrets.revealAll), plus a trickle of essence for the
-- trouble. Returns the doors this actually revealed, same as
-- Secrets.revealAll itself.
function Captives.interrogate(level, player, captive)
local revealed = Secrets.revealAll(level)
Captives.remove(level, captive)
player.essence = player.essence + math.max(1, captive.value // Captives.INTERROGATE_ESSENCE_DIVISOR)
return revealed
end
return Captives