diff --git a/src/captives.lua b/src/captives.lua new file mode 100644 index 0000000..2d34de5 --- /dev/null +++ b/src/captives.lua @@ -0,0 +1,160 @@ +-- 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