feat: add Quests.generate, Quests.describe and Ranged.ammoIndex

This commit is contained in:
dualfroz committed 2026-09-07 18:59:25 +02:00
1 parent 54569d9e04
commit 10488b48e8
2 files changed
+163

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
-- Bounty boards scattered through the dungeon from depth two on. Each board
-- is built with exactly one bounty already written on it (a cull, a hoard or
-- a depth pact), fixed at generation time from the floor's own rng so a given
-- (worldSeed, depth) always offers the identical bounty. Standing on an
-- unclaimed board binds the player to its pact for free (no turn spent, same
-- as learning a skill); a board only ever grants one bounty in its lifetime,
-- so re-rolling by revisiting an already-claimed board is not possible. A
-- bound pact resolves itself the instant its condition is met - no trip back
-- to the board required - paying out gold, xp and essence in one stroke.
local Monsters = require("monsters")
local Quests = {}
Quests.CULL = "cull"
Quests.HOARD = "hoard"
Quests.DEPTH = "depth"
-- Fixed order so a given rng draw always names the same kind.
Quests.order = { Quests.CULL, Quests.HOARD, Quests.DEPTH }
-- Builds the one bounty a freshly placed board offers on `depth`, clamping any
-- depth target to `maxDepth` so a pact can never demand the unreachable.
function Quests.generate(rng, depth, maxDepth)
local kind = Quests.order[rng:range(1, #Quests.order)]
local bounty = {
kind = kind,
rewardGold = 15 + depth * 8,
rewardXp = 10 + depth * 4,
}
if kind == Quests.CULL then
local eligible = Monsters.forDepth(depth)
local spec = eligible[rng:range(1, #eligible)]
bounty.species = spec.key
bounty.speciesName = spec.name
bounty.need = rng:range(2, 4)
bounty.have = 0
elseif kind == Quests.HOARD then
bounty.need = 40 + depth * 20
else
bounty.need = math.min(depth + rng:range(2, 4), maxDepth)
end
return bounty
end
function Quests.describe(bounty)
if bounty.kind == Quests.CULL then
return string.format("cull %d %s (%d/%d)", bounty.need, bounty.speciesName, bounty.have, bounty.need)
elseif bounty.kind == Quests.HOARD then
return string.format("amass %d gold", bounty.need)
end
return string.format("reach depth %d", bounty.need)
end
-- Binds `player` to `board`'s bounty, provided the player is not already
-- bound to one and the board has not already been claimed. Returns (true,
-- line) on success or (false, line) explaining why not.
function Quests.accept(player, board)
if player.quest then
return false, "you already hold a bounty"
end
if board.claimed then
return false, "this board's ledger is already spent"
end
board.claimed = true
player.quest = board.quest
return true, "you bind yourself to a bounty: " .. Quests.describe(board.quest)
end
-- Registers a kill toward the player's active cull bounty if the species
-- matches. A no-op for any other kind, an unrelated species, or no bounty.
function Quests.registerKill(player, species)
local bounty = player.quest
if bounty and bounty.kind == Quests.CULL and species == bounty.species and bounty.have < bounty.need then
bounty.have = bounty.have + 1
end
end
-- True once `player`'s active bounty (if any) is fulfilled by the current
-- state: a cull once its tally is met, a hoard once live gold clears its
-- absolute target, a depth pact once the player has reached it.
function Quests.resolved(player, depth)
local bounty = player.quest
if not bounty then return false end
if bounty.kind == Quests.CULL then return bounty.have >= bounty.need end
if bounty.kind == Quests.HOARD then return player.gold >= bounty.need end
return depth >= bounty.need
end
return Quests
+73
View File
@@ -0,0 +1,73 @@
-- Player ranged combat via an equipped bow and a stack of arrows carried in
-- the inventory. Unlike throwing, firing never costs the launcher itself -
-- only ammunition - so a bow stays equipped shot after shot as long as the
-- quiver holds out. A loosed arrow lands where the target stood either way,
-- recoverable exactly like a thrown weapon.
local Los = require("los")
local Items = require("items")
local Skills = require("skills")
local Artifacts = require("artifacts")
local Resistances = require("resistances")
local Proficiency = require("proficiency")
local Durability = require("durability")
local Ranged = {}
-- Inventory index of the first non-empty arrow stack, or nil if the quiver
-- is dry. Ammo of the same name merges into one stack on pickup, so in
-- practice there is at most one.
function Ranged.ammoIndex(entity)
for i, item in ipairs(entity.inventory) do
if item.kind == Items.AMMO and item.count > 0 then return i end
end
return nil
end
function Ranged.canReach(game, x1, y1, x2, y2, range)
local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2))
return dist <= range and Los.clear(game.level.map, x1, y1, x2, y2)
end
-- Looses one arrow from `shooter`'s equipped bow at `target`. Returns (true,
-- lines) if a shot went out - whatever it did on arrival - or (false, lines)
-- if it never left the string: no bow readied, no arrows, or no clear shot.
-- Damage comes from baseAttack rather than effectiveAttack so a melee
-- weapon's power does not also inflate every arrow; the bow's own power and
-- the target's armour are what separate a longbow from a sling. Mastery of
-- that specific bow (see proficiency.lua) still applies on top, since it is
-- read and recorded explicitly by the bow's own name rather than riding
-- effectiveAttack's blended total.
function Ranged.fire(game, shooter, target)
local bow = shooter.bow
if not bow then return false, { "you have no bow readied" } end
if not target or not target:isAlive() then return false, { "no target" } end
local ammoIdx = Ranged.ammoIndex(shooter)
if not ammoIdx then return false, { "you have no arrows left" } end
if not Ranged.canReach(game, shooter.x, shooter.y, target.x, target.y, bow.range) then
return false, { "you have no clear shot" }
end
local ammo = shooter.inventory[ammoIdx]
ammo.count = ammo.count - 1
if ammo.count <= 0 then table.remove(shooter.inventory, ammoIdx) end
local defense = math.max(0, target:effectiveDefense() - Artifacts.bowPierce(shooter))
local dealt = shooter.baseAttack + Durability.effectivePower(bow) + Skills.rangedDamageBonus(shooter)
+ Proficiency.bonusFor(shooter, bow.name) - defense
if dealt < 1 then dealt = 1 end
dealt = Resistances.apply(target, dealt, Resistances.PHYSICAL)
target.hp = target.hp - dealt
local lines = { string.format("your %s hits %s for %d", ammo.name, target.name, dealt) }
game.level.floorItems[#game.level.floorItems + 1] = {
x = target.x, y = target.y, item = { kind = Items.AMMO, name = ammo.name, glyph = ammo.glyph, count = 1 },
}
local masteredKey, masteredBonus = Proficiency.onHitWith(shooter, bow.name)
if masteredKey then
lines[#lines + 1] = string.format("you grow more skilled with your %s (+%d attack)", masteredKey, masteredBonus)
end
local broke = Durability.wearBow(shooter)
if broke then lines[#lines + 1] = broke end
if not target:isAlive() then game:awardKill(target) end
return true, lines
end
return Ranged