161 lines
7.0 KiB
Lua
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
|