From 54569d9e04c0f476d62ac87c4c5fe565c63bbe2c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:39:28 +0200 Subject: [PATCH] feat: add Proficiency.keyFor, Proficiency.hits and Progression.xpToNext --- src/proficiency.lua | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/progression.lua | 48 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/proficiency.lua create mode 100644 src/progression.lua diff --git a/src/proficiency.lua b/src/proficiency.lua new file mode 100644 index 0000000..09f3783 --- /dev/null +++ b/src/proficiency.lua @@ -0,0 +1,88 @@ +-- Weapon mastery: every melee swing the player lands (Game:playerAttack) +-- nudges their familiarity with whichever weapon delivered it - or their +-- bare fists, wielding nothing - forward by one point, keyed by the +-- weapon's own name so a build's proficiency carries seamlessly from one +-- identical drop to the next with no separate catalog of weapon +-- "categories" to keep in sync with builder.lua's loot tables, artifacts. +-- lua's named weapons, or a set piece's own name. Crossing a rank threshold +-- grants a small, permanent flat attack bonus with that specific weapon (or +-- fists) alone, read live by Entity:effectiveAttack exactly like momentum. +-- lua's combo stacks or sanity.lua's dread penalty - so re-equipping a +-- weapon already mastered picks the bonus straight back up, but switching +-- to something unfamiliar drops it until that one is mastered in turn. +-- +-- Melee reads and writes its own key automatically (Proficiency.keyFor +-- reads the equipped weapon slot, or Proficiency.UNARMED bare-handed), but +-- the underlying Proficiency.onHitWith/bonusFor pair takes any explicit key +-- instead - ranged.lua uses this to track mastery of an equipped bow by its +-- own name, entirely separate from whatever melee weapon (if any) happens +-- to be equipped alongside it, since Ranged.fire deliberately deals damage +-- off baseAttack rather than effectiveAttack (see its own header). + +local Proficiency = {} + +Proficiency.UNARMED = "fists" + +-- Hits needed to cross each mastery rank, and the flat attack bonus that +-- rank grants from then on. Only the highest rank crossed applies (each is +-- already the running total, not a stacking delta), but the gap between +-- ranks widens the deeper it goes, so the later ones are meant to take a +-- genuinely long, dedicated run with one weapon to ever reach. +Proficiency.RANKS = { + { hits = 15, bonus = 1 }, + { hits = 40, bonus = 2 }, + { hits = 80, bonus = 3 }, + { hits = 150, bonus = 4 }, +} + +-- The mastery key `entity` is currently swinging: an equipped weapon's own +-- name, or Proficiency.UNARMED for bare fists. +function Proficiency.keyFor(entity) + return entity.weapon and entity.weapon.name or Proficiency.UNARMED +end + +function Proficiency.hits(entity, key) + return (entity.proficiency and entity.proficiency[key]) or 0 +end + +-- Records one more landed hit under an explicit `key`, and returns that key +-- plus the newly reached rank's bonus the instant a threshold is first +-- crossed - nil otherwise, so a caller only ever logs a genuine milestone +-- rather than every single swing or shot. +function Proficiency.onHitWith(entity, key) + entity.proficiency = entity.proficiency or {} + local before = entity.proficiency[key] or 0 + local after = before + 1 + entity.proficiency[key] = after + for _, rank in ipairs(Proficiency.RANKS) do + if before < rank.hits and after >= rank.hits then + return key, rank.bonus + end + end + return nil +end + +-- The flat attack bonus `entity` currently draws from mastery of `key` +-- alone - never a total across every weapon it has ever swung or fired, so +-- switching weapons genuinely costs something. +function Proficiency.bonusFor(entity, key) + local hits = Proficiency.hits(entity, key) + local bonus = 0 + for _, rank in ipairs(Proficiency.RANKS) do + if hits >= rank.hits then bonus = rank.bonus end + end + return bonus +end + +-- Melee's own auto-keyed shorthand over the two generic functions above: +-- always reads and writes whatever Proficiency.keyFor currently reports for +-- `entity` (its equipped weapon, or bare fists). +function Proficiency.onMeleeHit(entity) + return Proficiency.onHitWith(entity, Proficiency.keyFor(entity)) +end + +function Proficiency.attackBonus(entity) + return Proficiency.bonusFor(entity, Proficiency.keyFor(entity)) +end + +return Proficiency diff --git a/src/progression.lua b/src/progression.lua new file mode 100644 index 0000000..c4bad8e --- /dev/null +++ b/src/progression.lua @@ -0,0 +1,48 @@ +-- Player experience and leveling. Killing a monster grants xp scaled by the +-- species catalog; once xp clears the threshold for the current level the +-- player levels up, gaining permanent hp/attack/defense and a full heal-up on +-- the hp gained. A single large xp grant can trigger several level-ups in a +-- row, so the gain loops rather than checking once. + +local Progression = {} + +-- Threshold to advance from `level` to `level + 1`. Linear on purpose: pure +-- and easy to reason about in tests, unlike an exponential curve. +function Progression.xpToNext(level) + return 20 + (level - 1) * 15 +end + +local function hpGain(level) + return 4 + level +end + +local function levelUp(player) + player.level = player.level + 1 + local gain = hpGain(player.level) + player.maxHp = player.maxHp + gain + player.hp = player.hp + gain + player.attack = player.attack + 1 + player.baseAttack = player.baseAttack + 1 + if player.level % 2 == 0 then + player.defense = player.defense + 1 + player.baseDefense = player.baseDefense + 1 + end + player.skillPoints = (player.skillPoints or 0) + 1 + player.xp = player.xp - player.xpNext + player.xpNext = Progression.xpToNext(player.level) + return string.format("you reach level %d", player.level) +end + +-- Applies an xp gain to the player, running as many level-ups as the gain +-- covers. Returns the log lines produced, in order. +function Progression.grantXp(player, amount) + if amount <= 0 then return {} end + player.xp = player.xp + amount + local lines = {} + while player.xp >= player.xpNext do + lines[#lines + 1] = levelUp(player) + end + return lines +end + +return Progression