From 0e8655e87181d406c54b4a0c981e149fe6395ede Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 17:33:25 +0200 Subject: [PATCH] feat: add Challenges.seedFor and Challenges.forDay --- src/challenges.lua | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/challenges.lua diff --git a/src/challenges.lua b/src/challenges.lua new file mode 100644 index 0000000..9d99912 --- /dev/null +++ b/src/challenges.lua @@ -0,0 +1,69 @@ +-- Named challenge presets: a fixed class, a fixed mutator set and a +-- deterministic seed, bound together so "today's challenge" means the same +-- run for everyone who plays it - the daily-seed ritual familiar +-- roguelikes build around, reproduced here without this module ever +-- touching the wall clock itself (see Challenges.seedFor's own +-- caller-supplied day index), so a test can pin any "day" it likes and get +-- the exact same answer back every time. + +local Ascension = require("ascension") + +local Challenges = {} + +Challenges.CATALOG = { + { key = "iron_gauntlet", name = "Iron Gauntlet", class = "fighter", mutators = { "bloodbound" } }, + { key = "arcane_sprint", name = "Arcane Sprint", class = "mage", mutators = { "famine" } }, + { key = "shadow_creed", name = "Shadow Creed", class = "rogue", mutators = {} }, + { key = "wild_hunt", name = "Wild Hunt", class = "ranger", mutators = { "nightblind" } }, +} + +Challenges.byKey = {} +for _, def in ipairs(Challenges.CATALOG) do + Challenges.byKey[def.key] = def +end + +-- A Knuth multiplicative hash of `dayIndex`, folded into the same 16-bit +-- range warren.lua's own default seed (os.time() & 0xFFFF) already uses, so +-- a challenge seed is never distinguishable from an ordinary one downstream. +function Challenges.seedFor(dayIndex) + return (dayIndex * 2654435761) % 65536 +end + +-- The preset and seed "day `dayIndex`" always resolves to - a pure function +-- of dayIndex alone, so the same day always hands out the same challenge to +-- everyone who asks it, and a test can walk arbitrary days without waiting +-- on a clock. +function Challenges.forDay(dayIndex) + local preset = Challenges.CATALOG[(dayIndex % #Challenges.CATALOG) + 1] + return preset, Challenges.seedFor(dayIndex) +end + +-- True iff `game` was actually played under `preset`'s own terms: started +-- as its class, with every mutator it demands active (see +-- Ascension.isActive) - extra mutators beyond the preset's own are fine, +-- only a missing requirement fails it. Returns (false, reason) naming +-- exactly what fell short, so a caller never has to re-derive why. +function Challenges.satisfiedBy(preset, game) + if game.player.class ~= preset.class then + return false, "started as " .. tostring(game.player.class) .. " instead of " .. preset.class + end + for _, key in ipairs(preset.mutators) do + if not Ascension.isActive(game.ascension, key) then + return false, "missing mutator " .. key + end + end + return true, nil +end + +-- One human-readable line describing `preset`: its display name, the class +-- it demands, and every mutator it demands active - exactly the terms +-- Challenges.satisfiedBy itself checks, spelled out for a player deciding +-- whether to attempt it. +function Challenges.describe(preset) + if #preset.mutators == 0 then + return string.format("%s (%s)", preset.name, preset.class) + end + return string.format("%s (%s + %s)", preset.name, preset.class, table.concat(preset.mutators, ", ")) +end + +return Challenges