diff --git a/tests/run.lua b/tests/run.lua index 7fe2d99..dc021d5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8606,6 +8606,179 @@ end function tests.proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing() local entity = {} + for i = 1, Proficiency.RANKS[1].hits - 1 do + local key = Proficiency.onMeleeHit(entity) + eq(key, nil, "no rank crossed yet on hit " .. i) + end + local key, bonus = Proficiency.onMeleeHit(entity) + eq(key, Proficiency.UNARMED, "the rank-crossing hit reports the mastered key") + eq(bonus, Proficiency.RANKS[1].bonus, "the rank-crossing hit reports that rank's own bonus") + local nextKey = Proficiency.onMeleeHit(entity) + eq(nextKey, nil, "the very next hit past a rank reports no further milestone") +end + +function tests.proficiencyAttackBonusReadsTheHighestRankCrossedForTheCurrentKeyAlone() + local entity = { weapon = { name = "sword" } } + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end + eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, "mastery with the equipped weapon applies") + entity.weapon = { name = "mace" } + eq(Proficiency.attackBonus(entity), 0, "switching to an unmastered weapon drops the bonus entirely") + entity.weapon = { name = "sword" } + eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, + "re-equipping the mastered weapon picks its bonus straight back up") +end + +function tests.entityEffectiveAttackFoldsInProficiencyAlongsideMomentumAndSanity() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + eq(entity:effectiveAttack(), 6, "a fresh entity carries no mastery bonus") + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end + eq(entity:effectiveAttack(), 6 + Proficiency.RANKS[1].bonus, "a mastered weapon's flat bonus lands on effective attack") +end + +function tests.gamePlayerAttackAccumulatesProficiencyAndLogsOnlyOnARankCrossing() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game.level.entities = { orc } + for _ = 1, Proficiency.RANKS[1].hits - 1 do + game:playerAttack(orc) + end + ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") + game:playerAttack(orc) + ok(contains(game.log:last(), "skilled"), "crossing a mastery rank is announced as the very last log line") + eq(game.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, + "an unarmed player's landed hits accumulate under the fists key") +end + +function tests.renderPanelShowsMasteryOnlyOnceARankIsCrossed() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "mastery"), "a fresh player with no mastery shows no mastery line") + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end + ok(contains(Render.panel(game), string.format("mastery +%d with %s", Proficiency.RANKS[1].bonus, Proficiency.UNARMED)), + "a crossed mastery rank is reported in the status panel") +end + +function tests.proficiencyFieldSurvivesSerialization() + local game = Game.new(3) + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, + "the player's accumulated mastery survives a save/load round trip") +end + +function tests.proficiencyOnHitWithAndBonusForTrackAnExplicitKeyIndependentOfKeyFor() + local entity = { weapon = { name = "sword" } } + for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onHitWith(entity, "short bow") end + eq(Proficiency.bonusFor(entity, "short bow"), Proficiency.RANKS[1].bonus, + "an explicit key masters independently of whatever keyFor would report") + eq(Proficiency.attackBonus(entity), 0, + "the equipped melee weapon's own mastery is untouched by an explicit-key bow's own hits") +end + +function tests.gameFireAtAccumulatesBowSpecificProficiencySeparateFromMelee() + local game = stagedGame(4, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + orc.hp, orc.maxHp = 999, 999 + game.level.entities = { orc } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", Proficiency.RANKS[1].hits + 1) + for i = 1, Proficiency.RANKS[1].hits - 1 do + game:fireAt(orc) + ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") + end + game:fireAt(orc) + ok(contains(game.log:last(), "skilled"), "crossing a bow mastery rank is announced as the shot's own last line") + eq(game.player.proficiency["short bow"], Proficiency.RANKS[1].hits, "bow hits accumulate under the bow's own name") + eq(game.player.proficiency[Proficiency.UNARMED], nil, "firing a bow never touches the unarmed melee key") +end + +function tests.metaGravesFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip() + local fresh = Meta.load("/tmp/warren_meta_does_not_exist_98765.sav") + eq(#fresh.graves, 0, "a freshly defaulted meta state has no recorded graves") + local meta = Meta.new() + Graves.record(meta, { className = "rogue", depth = 4, gold = 30, essence = 2, turn = 15 }) + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) + os.remove(path) + eq(#restored.graves, 1, "a recorded grave survives a save/load round trip") + eq(restored.graves[1].className, "rogue", "a restored grave keeps its own fallen class") + eq(restored.graves[1].depth, 4, "a restored grave keeps its own depth") +end + +-- ascension.lua: opt-in run mutators traded for extra renown. + +function tests.ascensionCatalogEntriesHaveUniqueKeysAndAtLeastOneEffectHook() + local seen = {} + for _, def in ipairs(Ascension.MUTATORS) do + ok(not seen[def.key], def.key .. " appears only once in the catalog") + seen[def.key] = true + local hasHook = def.damageMultiplier or def.essenceMultiplier or def.sightPenalty or def.applyToPlayer + ok(hasHook ~= nil, def.key .. " carries at least one actual gameplay effect") + end +end + +function tests.ascensionIsUnlockedRequiresMeetingBothDepthAndWinRequirements() + local meta = Meta.new() + meta.bestDepth = 2 + ok(Ascension.isUnlocked(meta, "famine"), "famine unlocks once best depth reaches 2") + ok(not Ascension.isUnlocked(meta, "bloodbound"), "bloodbound still needs depth 3") + ok(not Ascension.isUnlocked(meta, "glasscannon"), "glasscannon still needs a first win") + meta.wins = 1 + ok(Ascension.isUnlocked(meta, "glasscannon"), "glasscannon unlocks once a win is banked") + eq(Ascension.isUnlocked(meta, "madeupkey"), false, "an unknown key is never unlocked") +end + +function tests.ascensionIsUnlockedWithNoMetaLocksEveryCatalogedMutator() + for _, key in ipairs(Ascension.order) do + ok(not Ascension.isUnlocked(nil, key), key .. " stays locked for a run started without a meta file") + end +end + +function tests.ascensionResolveDropsUnknownAndStillLockedKeys() + local meta = Meta.new() + meta.bestDepth = 2 + local active = Ascension.resolve(meta, { "famine", "bloodbound", "madeupkey" }) + eq(#active, 1, "only the one unlocked, known key survives resolution") + eq(active[1], "famine", "famine is the survivor") +end + +function tests.ascensionResolveDeduplicatesAndReordersToCatalogOrder() + local meta = Meta.new() + meta.bestDepth = 4 + local active = Ascension.resolve(meta, { "nightblind", "famine", "nightblind", "famine" }) + eq(#active, 2, "a key repeated in the request is not duplicated in the resolved list") + eq(active[1], "famine", "the resolved order follows the catalog, not the request") + eq(active[2], "nightblind", "the resolved order follows the catalog, not the request") +end + +function tests.ascensionResolveWithNoRequestReturnsEmpty() + eq(#Ascension.resolve(Meta.new(), nil), 0, "a nil request resolves to no active mutators") + eq(#Ascension.resolve(Meta.new(), {}), 0, "an empty request resolves to no active mutators") +end + +function tests.ascensionIsActiveTrueOnlyForKeysInTheActiveList() + local active = { "famine", "frail" } + ok(Ascension.isActive(active, "famine"), "famine is reported active") + ok(not Ascension.isActive(active, "bloodbound"), "bloodbound is reported inactive") + ok(not Ascension.isActive(nil, "famine"), "a nil active list reports every key inactive") +end + +function tests.ascensionDamageMultiplierMultipliesAcrossActiveMutators() + eq(Ascension.damageMultiplier(nil), 1, "no active mutators leaves damage unscaled") + eq(Ascension.damageMultiplier({ "famine" }), 1, "a mutator with no damage hook leaves damage unscaled") + eq(Ascension.damageMultiplier({ "bloodbound" }), 1.5, "bloodbound scales damage by its own fixed multiplier") +end + +function tests.ascensionEssenceMultiplierAppliesFamineAlone() + eq(Ascension.essenceMultiplier(nil), 1, "no active mutators leaves essence unscaled") + eq(Ascension.essenceMultiplier({ "bloodbound" }), 1, "a mutator with no essence hook leaves essence unscaled") + eq(Ascension.essenceMultiplier({ "famine" }), 0.5, "famine halves essence income") +end + end local order = {