From 10488b48e863bbc2358b931089996a84eda49ca8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 18:59:25 +0200 Subject: [PATCH] feat: add Quests.generate, Quests.describe and Ranged.ammoIndex --- src/quests.lua | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/ranged.lua | 73 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/quests.lua create mode 100644 src/ranged.lua diff --git a/src/quests.lua b/src/quests.lua new file mode 100644 index 0000000..c727c63 --- /dev/null +++ b/src/quests.lua @@ -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 diff --git a/src/ranged.lua b/src/ranged.lua new file mode 100644 index 0000000..73a82ce --- /dev/null +++ b/src/ranged.lua @@ -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