test: cover tests.challengesSeedForStaysWithinTheDefaultSeedRange

This commit is contained in:
dualfroz committed 2026-09-11 20:36:41 +02:00
1 parent f68d0299e3
commit fac05c737f
1 file changed
+173
+173
View File
@@ -9990,6 +9990,179 @@ function tests.challengesForDayCyclesThroughTheCatalogInOrder()
eq(wrapped, Challenges.CATALOG[1], "the cycle wraps back to the first preset after one full lap") eq(wrapped, Challenges.CATALOG[1], "the cycle wraps back to the first preset after one full lap")
end end
function tests.challengesSeedForStaysWithinTheDefaultSeedRange()
for _, day in ipairs({ 0, 1, 42, 365, 999999 }) do
local seed = Challenges.seedFor(day)
ok(seed >= 0 and seed < 65536, "day " .. day .. " hashes into the same 16-bit range as the default seed")
end
end
function tests.challengesCatalogPresetsReferenceOnlyRealClassesAndMutators()
for _, preset in ipairs(Challenges.CATALOG) do
ok(Classes.isValid(preset.class), preset.key .. " names a real class")
for _, key in ipairs(preset.mutators) do
ok(Ascension.byKey[key] ~= nil, preset.key .. "'s mutator '" .. key .. "' is a real ascension mutator")
end
end
end
function tests.challengesDescribeNamesTheClassAndEveryRequiredMutator()
local described = Challenges.describe(Challenges.byKey.wild_hunt)
eq(described, "Wild Hunt (ranger + nightblind)", "a single-mutator preset lists its class and that mutator")
end
function tests.challengesDescribeOmitsTheMutatorJoinWhenNoneAreRequired()
local described = Challenges.describe(Challenges.byKey.shadow_creed)
eq(described, "Shadow Creed (rogue)", "a mutator-free preset reads as just its name and class")
end
function tests.challengesSatisfiedByFailsOnAClassMismatch()
local game = Game.new(9, { class = "rogue" })
local satisfied, reason = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game)
eq(satisfied, false, "a rogue never satisfies a fighter-only challenge")
ok(contains(reason, "fighter"), "the failure names the class the challenge actually required")
end
function tests.challengesSatisfiedBySucceedsWithTheRightClassAndNoMutatorsRequired()
local game = Game.new(9, { class = "rogue" })
local satisfied = Challenges.satisfiedBy(Challenges.byKey.shadow_creed, game)
eq(satisfied, true, "matching the class is enough when the preset demands no mutators")
end
function tests.challengesSatisfiedByFailsWhenARequiredMutatorNeverActivated()
local game = Game.new(9, { class = "fighter" })
local satisfied, reason = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game)
eq(satisfied, false, "a run with no active mutators cannot satisfy one that requires bloodbound")
eq(reason, "missing mutator bloodbound", "the failure names the exact missing mutator")
end
function tests.challengesSatisfiedBySucceedsOnceItsRequiredMutatorIsActuallyActive()
local unlocked = Meta.new()
unlocked.bestDepth = 3
local game = Game.new(9, { class = "fighter", meta = unlocked, mutators = { "bloodbound" } })
local satisfied = Challenges.satisfiedBy(Challenges.byKey.iron_gauntlet, game)
eq(satisfied, true, "the challenge is honored once its required mutator actually resolved active")
end
function tests.analyticsWinRateIsZeroWithNoRecordedRunsAndFractionalOtherwise()
eq(Analytics.winRate(Meta.new()), 0, "a state with no recorded runs has a zero win rate")
local meta = Meta.new()
Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true })
Meta.recordRun(meta, { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false })
eq(Analytics.winRate(meta), 0.5, "one win out of two runs is a 50% win rate")
end
function tests.analyticsAverageScoreIsZeroOnAnEmptyBoardAndTheMeanOtherwise()
eq(Analytics.averageScore(Leaderboard.new()), 0, "an empty leaderboard has a zero average score")
local board = Leaderboard.new()
Leaderboard.record(board, { score = 100 })
Leaderboard.record(board, { score = 200 })
Leaderboard.record(board, { score = 300 })
eq(Analytics.averageScore(board), 200, "the average of 100, 200 and 300 is exactly 200")
end
function tests.analyticsFavoriteClassPicksTheMostFrequentTiesBrokenByFirstAppearance()
eq(Analytics.favoriteClass(Leaderboard.new()), nil, "an empty leaderboard has no favorite class")
local board = Leaderboard.new()
Leaderboard.record(board, { score = 90, className = "rogue" })
Leaderboard.record(board, { score = 80, className = "mage" })
Leaderboard.record(board, { score = 70, className = "mage" })
eq(Analytics.favoriteClass(board), "mage", "mage appears twice, more than any other class")
local tied = Leaderboard.new()
Leaderboard.record(tied, { score = 50, className = "fighter" })
Leaderboard.record(tied, { score = 40, className = "ranger" })
eq(Analytics.favoriteClass(tied), "fighter",
"a tie between equally-frequent classes breaks toward whichever appeared first on the board")
end
function tests.analyticsPercentileRanksAScoreAgainstTheBoardItWouldJoin()
eq(Analytics.percentile(Leaderboard.new(), 999), 1, "an empty board can never hold anything back")
local board = Leaderboard.new()
Leaderboard.record(board, { score = 100 })
Leaderboard.record(board, { score = 200 })
Leaderboard.record(board, { score = 300 })
Leaderboard.record(board, { score = 400 })
eq(Analytics.percentile(board, 999), 1, "a score above everything beats the entire board")
eq(Analytics.percentile(board, 1), 0, "a score below everything beats nothing")
eq(Analytics.percentile(board, 250), 0.5, "a score beating exactly half the board reads as 50%")
eq(Analytics.percentile(board, 200), 0.25, "a tied score counts as beaten, not beating, its equal")
end
function tests.titlesForRenownNeverReturnsNilForAnyNonNegativeRenown()
for _, renown in ipairs({ 0, 1, 49, 50, 350, 1499, 1500, 100000 }) do
ok(Titles.forRenown(renown) ~= nil, "renown " .. renown .. " always resolves to some title")
end
end
function tests.titlesForRenownWalksTheLadderFromTheBottomUp()
eq(Titles.forRenown(0), "Whelp", "zero renown starts at the very bottom of the ladder")
eq(Titles.forRenown(49), "Whelp", "one short of a threshold stays on the lower title")
eq(Titles.forRenown(50), "Delver", "landing exactly on a threshold claims that title")
eq(Titles.forRenown(349), "Warden", "just under the next threshold keeps the current title")
eq(Titles.forRenown(1500), "Undying", "the exact top threshold claims the top title")
eq(Titles.forRenown(999999), "Undying", "renown far past the ladder still caps at the top title")
end
function tests.titlesToNextCountsDownToTheNextThresholdAndNilsOutAtTheTop()
eq(Titles.toNext(0), 50, "starting from zero needs exactly the first threshold's renown")
eq(Titles.toNext(45), 5, "five renown short of the first threshold reports exactly five")
eq(Titles.toNext(50), 100, "landing on a threshold reports the gap to the next one, not zero")
eq(Titles.toNext(1500), nil, "sitting at the top threshold has no further tier to climb toward")
eq(Titles.toNext(50000), nil, "renown past the top of the ladder also has nothing left to climb toward")
end
function tests.morgueBuildOmitsTitleWithoutMetaAndIncludesItWithMeta()
local game = Game.new(3)
local bare = Morgue.build(game)
eq(bare.title, nil, "a meta-free build carries no title")
local meta = Meta.new()
meta.renown = 60
local withMeta = Morgue.build(game, meta)
eq(withMeta.title, "Delver", "a build given meta carries the title that renown actually earns")
local text = Morgue.format(withMeta)
ok(contains(text, "the Delver"), "the formatted report names the earned title in its heading")
ok(not contains(Morgue.format(bare), "the Delver"), "a title-less report never mentions any title")
end
function tests.exportLeaderboardCsvOnAnEmptyBoardIsJustTheHeaderRow()
local csv = Export.leaderboardCsv(Leaderboard.new())
eq(csv, table.concat(Export.COLUMNS, ",") .. "\n", "an empty board exports to a single header line")
end
function tests.exportLeaderboardCsvEmitsOneRowPerEntryInRankOrder()
local board = Leaderboard.new()
Leaderboard.record(board, { score = 300, className = "fighter", depth = 5, turn = 200, won = true, seed = 11 })
Leaderboard.record(board, { score = 200, className = "mage", depth = 3, turn = 150, won = false, seed = 22 })
local csv = Export.leaderboardCsv(board)
local lines = {}
for line in csv:gmatch("[^\n]+") do lines[#lines + 1] = line end
eq(#lines, 3, "the header plus one row per entry gives three lines total")
eq(lines[1], "rank,score,className,depth,turn,won,seed", "the header names every column in a fixed order")
eq(lines[2], "1,300,fighter,5,200,true,11", "the top-ranked entry becomes the first data row")
eq(lines[3], "2,200,mage,3,150,false,22", "the second-ranked entry keeps that same rank order")
end
function tests.exportLeaderboardCsvRendersBooleansAsLowercaseWords()
local board = Leaderboard.new()
Leaderboard.record(board, { score = 50, className = "rogue", depth = 1, turn = 10, won = true, seed = 1 })
ok(contains(Export.leaderboardCsv(board), ",true,"), "a won run's CSV row spells out 'true', not '1' or 'TRUE'")
end
function tests.exportCompendiumCsvListsEverySpeciesSeenOrNot()
local compendium = Compendium.new()
Compendium.noteSighting(compendium, "rat")
Compendium.noteKill(compendium, "rat")
Compendium.noteKill(compendium, "rat")
local csv = Export.compendiumCsv(compendium)
local lines = {}
for line in csv:gmatch("[^\n]+") do lines[#lines + 1] = line end
eq(lines[1], "key,name,seen,kills", "the header names every compendium export column")
eq(#lines, #Compendium.entries(compendium) + 1, "every catalogued species gets a row, sighted or not")
ok(contains(csv, "rat,rat,true,2"), "a sighted, twice-slain species reports its own seen flag and tally")
ok(contains(csv, "false,0"), "an undiscovered species still reports as unseen with a zero tally")
end
end end
local order = { local order = {