From 6821564c6758e47e3fa62e3da4afbbd823da619c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 16:19:37 +0200 Subject: [PATCH] feat: add pairKey, Alchemy.herb and Analytics.winRate --- src/alchemy.lua | 102 ++++++++++++++++++++++++++++++++++++++++++++++ src/analytics.lua | 58 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/alchemy.lua create mode 100644 src/analytics.lua diff --git a/src/alchemy.lua b/src/alchemy.lua new file mode 100644 index 0000000..0ed9348 --- /dev/null +++ b/src/alchemy.lua @@ -0,0 +1,102 @@ +-- Herb-based potion brewing, layered on top of items.lua's existing potion +-- catalog rather than inventing a second one: a cauldron turns two foraged +-- herbs into the same potions rollItem already scatters as loot, so a +-- brewed potion drinks, throws (throwing.lua) and identifies exactly like a +-- found one. Recipes are fixed data, the same way crafting.lua's elemental +-- riders are fixed - the uncertainty here is not "what does this pair do" +-- but whether two ill-matched herbs curdle instead of brewing cleanly. See +-- Game:brewAt for how a curdle is resolved against the brewer. + +local Items = require("items") + +local Alchemy = {} + +Alchemy.SUNPETAL = "sunpetal" +Alchemy.BLOODROOT = "bloodroot" +Alchemy.EMBERLEAF = "emberleaf" +Alchemy.FROSTMOSS = "frostmoss" +Alchemy.NIGHTCAP = "nightcap" +Alchemy.MOONBELL = "moonbell" + +-- Fixed order so a given rng draw always names the same herb. New herbs +-- append at the end (see builder.lua's own comment on Alchemy.order) so an +-- existing seed's earlier picks never change which herb they name. +Alchemy.order = { + Alchemy.BLOODROOT, Alchemy.EMBERLEAF, Alchemy.FROSTMOSS, Alchemy.NIGHTCAP, Alchemy.SUNPETAL, + Alchemy.MOONBELL, +} + +Alchemy.names = { + [Alchemy.SUNPETAL] = "sunpetal", + [Alchemy.BLOODROOT] = "bloodroot", + [Alchemy.EMBERLEAF] = "emberleaf", + [Alchemy.FROSTMOSS] = "frostmoss", + [Alchemy.NIGHTCAP] = "nightcap", + [Alchemy.MOONBELL] = "moonbell", +} + +local CURDLE = { curdle = true } + +-- Canonical order-independent key for a herb pair: the two herb keys sorted +-- and joined, so brewing (A, B) and (B, A) always land on the same recipe. +local function pairKey(a, b) + if a > b then a, b = b, a end + return a .. "+" .. b +end +Alchemy.pairKey = pairKey + +-- Same-herb pairs concentrate that herb's own aspect; cross pairs blend two +-- aspects into a shared potion, except the pairings whose aspects outright +-- oppose each other (light versus shade, fire versus frost, day versus +-- dream), which curdle instead of brewing. +Alchemy.recipes = { + [pairKey(Alchemy.SUNPETAL, Alchemy.SUNPETAL)] = { effect = "heal", power = 18 }, + [pairKey(Alchemy.SUNPETAL, Alchemy.BLOODROOT)] = { effect = "heal", power = 10 }, + [pairKey(Alchemy.SUNPETAL, Alchemy.EMBERLEAF)] = { effect = "haste" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.NIGHTCAP)] = CURDLE, + [pairKey(Alchemy.BLOODROOT, Alchemy.BLOODROOT)] = { effect = "strength" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.EMBERLEAF)] = { effect = "strength" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.BLOODROOT, Alchemy.NIGHTCAP)] = { effect = "poison", power = 4 }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.EMBERLEAF)] = { effect = "haste" }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.FROSTMOSS)] = CURDLE, + [pairKey(Alchemy.EMBERLEAF, Alchemy.NIGHTCAP)] = { effect = "poison", power = 5 }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.FROSTMOSS)] = { effect = "fortify" }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.NIGHTCAP)] = { effect = "poison", power = 5 }, + [pairKey(Alchemy.NIGHTCAP, Alchemy.NIGHTCAP)] = { effect = "poison", power = 7 }, + -- Moonbell's own aspect is dream-fog, so concentrating it addles the + -- brewer's own mind rather than anyone else's - the same "own aspect + -- turned inward" shape as every other same-herb pair above. Sunlight is + -- the one aspect it outright opposes (wakeful day versus dreaming night, + -- the same shape as sunpetal/nightcap's light-versus-shade curdle above), + -- so that pairing curdles instead of brewing. + [pairKey(Alchemy.MOONBELL, Alchemy.MOONBELL)] = { effect = "confuse" }, + [pairKey(Alchemy.SUNPETAL, Alchemy.MOONBELL)] = CURDLE, + [pairKey(Alchemy.BLOODROOT, Alchemy.MOONBELL)] = { effect = "clarity" }, + [pairKey(Alchemy.EMBERLEAF, Alchemy.MOONBELL)] = { effect = "blind" }, + [pairKey(Alchemy.FROSTMOSS, Alchemy.MOONBELL)] = { effect = "levitate" }, + [pairKey(Alchemy.NIGHTCAP, Alchemy.MOONBELL)] = { effect = "poison", power = 6 }, +} + +function Alchemy.herb(key) + return { kind = Items.HERB, herb = key, name = Alchemy.names[key] .. " herb", glyph = ";" } +end + +-- The fixed outcome for brewing herbA with herbB together, order +-- independent. Returns nil only when either key names no known herb. +function Alchemy.lookup(herbA, herbB) + if not Alchemy.names[herbA] or not Alchemy.names[herbB] then return nil end + return Alchemy.recipes[pairKey(herbA, herbB)] +end + +-- Resolves a completed brew into a fresh potion item, or (nil, "curdle") for +-- an opposed pairing - the caller decides what a curdle does to the brewer. +function Alchemy.brew(herbA, herbB) + local recipe = Alchemy.lookup(herbA, herbB) + if not recipe then return nil, "unknown herb" end + if recipe.curdle then return nil, "curdle" end + return Items.potionOf(recipe.effect, recipe.power) +end + +return Alchemy diff --git a/src/analytics.lua b/src/analytics.lua new file mode 100644 index 0000000..cdaaff9 --- /dev/null +++ b/src/analytics.lua @@ -0,0 +1,58 @@ +-- Aggregate, read-only statistics over meta.lua's own cross-run history: +-- how often a body of runs actually won, which class shows up most among +-- them, and where a fresh score would land against that history - three +-- questions renown and achievements never answer on their own, since both +-- only ever look at the one run in front of them, never the shape of every +-- run that came before it. + +local Analytics = {} + +-- Fraction of recorded runs that ended in victory. 0 for a state that has +-- not recorded any runs yet rather than dividing by zero. +function Analytics.winRate(meta) + if meta.totalRuns == 0 then return 0 end + return meta.wins / meta.totalRuns +end + +-- Mean score across every leaderboard entry (see leaderboard.lua), 0 for an +-- empty board. +function Analytics.averageScore(board) + if #board == 0 then return 0 end + local total = 0 + for _, entry in ipairs(board) do total = total + entry.score end + return total / #board +end + +-- The className appearing most often on `board`, ties broken toward +-- whichever class was seen first on the board - so the answer only ever +-- depends on the board's own entry order, never on however pairs() happens +-- to walk a count table. nil for an empty board. +function Analytics.favoriteClass(board) + local counts, order = {}, {} + for _, entry in ipairs(board) do + if not counts[entry.className] then order[#order + 1] = entry.className end + counts[entry.className] = (counts[entry.className] or 0) + 1 + end + local best, bestCount = nil, 0 + for _, className in ipairs(order) do + if counts[className] > bestCount then + best, bestCount = className, counts[className] + end + end + return best +end + +-- How many of `board`'s entries `score` would actually outrank, as a +-- fraction from 0 (worse than or tied with everything) to 1 (better than +-- everything). An empty board always answers 1 - there is nothing left to +-- beat, so nothing holds the score back. +function Analytics.percentile(board, score) + if #board == 0 then return 1 end + local beaten = 0 + for _, entry in ipairs(board) do + if score > entry.score then beaten = beaten + 1 end + end + return beaten / #board +end + +return Analytics