From 2c6fae4f44874a2114ea7fce9cb2f829655c0c7a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 19:21:51 +0200 Subject: [PATCH] test: cover tests.runewordsAttackAndDefenseBonusReadTheWielderSGearLive --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 58356b5..2a4cb73 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6530,6 +6530,179 @@ function tests.runewordsActiveWordMatchesOnlyAnExactChain() Runewords.socket(sword, Runewords.rune("sol")) eq(Runewords.activeWord(sword), nil, "a single rune alone matches no cataloged word") Runewords.socket(sword, Runewords.rune("ith")) + local word = Runewords.activeWord(sword) + ok(word ~= nil, "sol then ith completes the Zenith runeword") + eq(word.name, "Zenith", "the matched word is the cataloged Zenith") + Runewords.socket(sword, Runewords.rune("tal")) + eq(Runewords.activeWord(sword), nil, "a third rune growing past Zenith's length breaks its match") +end + +function tests.runewordsAttackAndDefenseBonusReadTheWielderSGearLive() + local player = { weapon = Items.weapon("sword", 4), armor = Items.armor("chain mail", 2) } + eq(Runewords.attackBonus(player), 0, "no bonus applies before any runeword completes") + eq(Runewords.defenseBonus(player), 0, "no defense bonus applies before any runeword completes") + Runewords.socket(player.weapon, Runewords.rune("sol")) + Runewords.socket(player.weapon, Runewords.rune("ith")) + eq(Runewords.attackBonus(player), 3, "a completed Zenith on the weapon grants its flat attack bonus") + Runewords.socket(player.armor, Runewords.rune("tal")) + Runewords.socket(player.armor, Runewords.rune("ral")) + eq(Runewords.defenseBonus(player), 3, "a completed Bastion on the armor grants its flat defense bonus") + eq(Runewords.attackBonus(player), 3, "the armor's runeword does not leak an attack bonus") +end + +function tests.gameSocketRuneAppliesItsRunewordBonusInActualCombat() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 0, 0, 40, 40 + game.level.entities = { orc } + local before = orc.hp + game:playerAttack(orc) + local plainDamage = before - orc.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game2.player) + local orc2 = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc2.defense, orc2.baseDefense, orc2.hp, orc2.maxHp = 0, 0, 40, 40 + game2.level.entities = { orc2 } + game2.player.inventory = { Runewords.rune("sol"), Runewords.rune("ith") } + ok(game2:socketRune("weapon", 1), "socketing the first rune into the equipped weapon succeeds") + ok(game2:socketRune("weapon", 1), "socketing the second rune completes the runeword") + eq(#game2.player.inventory, 0, "both runes leave the inventory once socketed") + ok(contains(game2.log:last(), "Zenith"), "completing a runeword is announced by name") + local before2 = orc2.hp + game2:playerAttack(orc2) + local runewordDamage = before2 - orc2.hp + ok(runewordDamage > plainDamage, "a completed Zenith runeword deals more damage than the bare weapon") + eq(runewordDamage - plainDamage, 3, "the extra damage matches Zenith's exact flat attack bonus") +end + +function tests.gameSocketRuneFailsOnAnUnknownTargetOrANonRuneItem() + local game = stagedGame(1, arena(21, 11), 3, 6) + Items.equipWeapon(Items.weapon("sword", 4), game.player) + game.player.inventory = { Items.potion(10) } + eq(game:socketRune("weapon", 1), false, "socketing a non-rune item fails") + game.player.inventory = { Runewords.rune("sol") } + eq(game:socketRune("armor", 1), false, "socketing into gear the player has not equipped fails") + eq(#game.player.inventory, 1, "a failed socket attempt leaves the rune in inventory") +end + +function tests.builderOccasionallyPlacesARuneFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.RUNE then found = ground.item end + end + ok(found ~= nil, "this seed's depth-3 floor places a rune stone") + ok(found.rune ~= nil, "the placed rune names a real catalog key") +end + +function tests.runesStayOffTheShallowestTwoFloors() + for seed = 1, 6 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.RUNE, "no rune appears on the shallowest two floors") + end + end + end +end + +function tests.runeChainFieldsSurviveSerialization() + local game = Game.new(3) + local sword = Items.weapon("sword", 4) + Items.equipWeapon(sword, game.player) + Runewords.socket(sword, Runewords.rune("sol")) + Runewords.socket(sword, Runewords.rune("ith")) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(table.concat(restored.player.weapon.runes, "-"), "sol-ith", "a weapon's rune chain survives a save/load round trip") + ok(Runewords.activeWord(restored.player.weapon) ~= nil, "the restored chain still forms its runeword") +end + +-- Atmosphere (atmosphere.lua): a single floor-wide weather condition drawn +-- once at generation, distinct from terrain.lua's per-cell hazards - every +-- hook here is a flat modifier applying to the whole floor for its lifetime. + +function tests.atmosphereHooksAreZeroForAnyConditionTheyDoNotGovern() + eq(Atmosphere.sightPenalty(Atmosphere.CONSECRATED), 0, "consecrated ground carries no sight penalty") + eq(Atmosphere.regenBonus(Atmosphere.FOGGY), 0, "fog carries no regen bonus") + eq(Atmosphere.monsterAttackBonus(Atmosphere.FOGGY), 0, "fog carries no monster attack bonus") + eq(Atmosphere.sightPenalty(nil), 0, "a clear floor (no weather) carries no sight penalty") +end + +function tests.atmosphereFoggyShrinksSightRadiusButNeverBelowOne() + eq(Atmosphere.sightPenalty(Atmosphere.FOGGY), 2, "fog carries a fixed sight penalty") + local game = stagedGame(1, arena(21, 11), 3, 6) + local clear = game:effectiveRadius() + game.level.weather = Atmosphere.FOGGY + eq(game:effectiveRadius(), clear - 2, "fog shaves its fixed penalty off the floor's own sight radius") + + local darkGame = stagedGame(1, arenaDark(21, 11), 3, 6) + darkGame.player.light.fuel = 0 + darkGame.level.weather = Atmosphere.FOGGY + eq(darkGame:effectiveRadius(), 1, "fog on an already-dark, torchless floor floors out at a minimum radius of one") +end + +function tests.atmosphereConsecratedRegeneratesThePlayerEachTurn() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.CONSECRATED + game.player.hp = game.player.maxHp - 10 + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before + 1, "consecrated ground regenerates its fixed amount each turn") +end + +function tests.atmosphereConsecratedNeverRegeneratesPastMaxHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.CONSECRATED + game.player.hp = game.player.maxHp + game:hungerTorchTick() + eq(game.player.hp, game.player.maxHp, "consecrated regen never lifts hp past its maximum") +end + +function tests.builderOccasionallyDrawsEachWeatherConditionFromDepthTwo() + local infested = Builder.build(2, Rng.new(1), {}) + eq(infested.weather, Atmosphere.INFESTED, "this seed's depth-2 floor draws an infested reading") + local rat + for _, entity in ipairs(infested.entities) do + if entity.species == "rat" then rat = entity end + end + ok(rat ~= nil, "an infested floor still places its usual monsters") + eq(rat.attack, Monsters.byKey.rat.attack + Atmosphere.monsterAttackBonus(Atmosphere.INFESTED), + "an infested floor bakes its attack bonus into every monster placed on it") + + local consecrated = Builder.build(2, Rng.new(3), {}) + eq(consecrated.weather, Atmosphere.CONSECRATED, "this seed's depth-2 floor draws a consecrated reading") + local foggy = Builder.build(2, Rng.new(6), {}) + eq(foggy.weather, Atmosphere.FOGGY, "this seed's depth-2 floor draws a foggy reading") +end + +function tests.weatherNeverAppearsOnTheEntranceFloor() + for seed = 1, 20 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(level.weather, nil, "no weather condition is ever drawn for the entrance floor") + end +end + +function tests.weatherFieldSurvivesSerialization() + local level = arena(21, 11) + level.weather = Atmosphere.FOGGY + local restored = Level.fromData(level:toData()) + eq(restored.weather, Atmosphere.FOGGY, "a floor's weather survives a round trip") +end + +function tests.renderPanelReportsTheFloorSWeatherWhenPresent() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.weather = Atmosphere.INFESTED + ok(contains(Render.panel(game), "infested"), "the status panel names an active weather condition") +end + +-- Biomes: a fixed, seed-independent theme per depth (contrast with +-- Atmosphere's own dice-rolled weather above), read by the builder's biome +-- pass to occasionally reinforce a floor with one more native monster or end local order = {