Files
WoWee/tests/test_spell_classification.cpp
Kelsi 9fb614db34 test(spell): cover range classification and superseded rank resolution
Every spell fix this week was verified by hand against the DBCs, which found the
bugs but did nothing to stop them coming back. The decision logic is now a set of
pure functions in spell_classification.hpp, driven by DBC data rather than spell
ids, and SpellHandler delegates to them so the tested code is the code that runs.

31 assertions across 8 cases, each pinned to a real regression: a physical-school
self-buff must not read as melee (Battle Shout), a physical-school 35-yard shot
must not read as melee (Steady Shot, Taunt, Deadly Throw), and a superseded rank
must resolve to the highest rank actually known while leaving item spells and
other classes' spells alone.
2026-07-14 14:40:23 -07:00

159 lines
6.0 KiB
C++

#include <catch_amalgamated.hpp>
#include "game/spell_classification.hpp"
#include <string>
#include <unordered_map>
#include <unordered_set>
using namespace wowee::game::spellclass;
namespace {
// Real values from Spell.dbc / SpellRange.dbc (WotLK 3.3.5a).
constexpr float kSelfOnly = 0.0f; // "Self Only" — Battle Shout, Hearthstone
constexpr float kCombatRange = 5.0f; // "Combat Range" — every melee ability
constexpr float kDeadlyThrow = 30.0f; // physical, but thrown
constexpr float kSteadyShot = 35.0f; // physical, but shot
constexpr float kFireball = 35.0f;
// Stand-in for the spell name cache.
class FakeSpellDbc {
public:
void add(uint32_t id, std::string name, std::string rank) {
entries_[id] = SpellRankInfo{std::move(name), std::move(rank)};
}
std::function<const SpellRankInfo*(uint32_t)> lookup() const {
return [this](uint32_t id) -> const SpellRankInfo* {
auto it = entries_.find(id);
return it != entries_.end() ? &it->second : nullptr;
};
}
private:
std::unordered_map<uint32_t, SpellRankInfo> entries_;
};
} // namespace
// ---------------------------------------------------------------------------
// Range classification
// ---------------------------------------------------------------------------
TEST_CASE("Self-only spells are cast on the caster", "[spell][range]") {
REQUIRE(isSelfCastRange(kSelfOnly));
// Anything that can reach another unit is not a self-cast.
REQUIRE_FALSE(isSelfCastRange(kCombatRange));
REQUIRE_FALSE(isSelfCastRange(kSteadyShot));
// Unknown range: SpellRange.dbc was missing, so assume nothing.
REQUIRE_FALSE(isSelfCastRange(kUnknownRange));
}
TEST_CASE("Melee is decided by range, not by school", "[spell][range]") {
// Melee abilities all sit at Combat Range.
REQUIRE(isMeleeRange(kCombatRange));
REQUIRE(isMeleeRange(4.0f));
// Regression: Steady Shot, Aimed Shot, Multi-Shot, Taunt and Deadly Throw are all
// physical school. Classifying by school made the client range-check them at 8
// yards and swallow the cast, so Hunters could not shoot past 8 yards.
REQUIRE_FALSE(isMeleeRange(kSteadyShot));
REQUIRE_FALSE(isMeleeRange(kDeadlyThrow));
REQUIRE_FALSE(isMeleeRange(kFireball));
// Regression: Battle Shout is a physical-school self-buff. Read as a melee ability,
// it was blocked by the range check whenever a distant target was selected.
REQUIRE_FALSE(isMeleeRange(kSelfOnly));
// Unknown range must not block a cast: let the server arbitrate.
REQUIRE_FALSE(isMeleeRange(kUnknownRange));
}
TEST_CASE("A spell is never both self-cast and melee", "[spell][range]") {
for (float r : {kUnknownRange, kSelfOnly, 1.0f, kCombatRange, 8.0f, kSteadyShot}) {
const bool both = isSelfCastRange(r) && isMeleeRange(r);
REQUIRE_FALSE(both);
}
}
// ---------------------------------------------------------------------------
// Rank strings
// ---------------------------------------------------------------------------
TEST_CASE("Rank display strings parse to their number", "[spell][rank]") {
REQUIRE(rankValue("Rank 1") == 1);
REQUIRE(rankValue("Rank 9") == 9);
REQUIRE(rankValue("Rank 14") == 14);
// Rankless spells sort below every ranked one.
REQUIRE(rankValue("") == 0);
REQUIRE(rankValue("Racial") == 0);
}
// ---------------------------------------------------------------------------
// Superseded rank resolution
// ---------------------------------------------------------------------------
TEST_CASE("A superseded rank resolves to the highest known rank", "[spell][rank]") {
FakeSpellDbc dbc;
dbc.add(6673, "Battle Shout", "Rank 1");
dbc.add(5242, "Battle Shout", "Rank 2");
dbc.add(6192, "Battle Shout", "Rank 3");
dbc.add(11549, "Battle Shout", "Rank 4");
// The player learned ranks 2 and 3; rank 1 is superseded and no longer active, so
// the server silently discards casts of it. An action bar restored from the server
// can still be holding rank 1.
const std::unordered_set<uint32_t> known{5242, 6192};
REQUIRE(resolveHighestKnownRank(6673, known, dbc.lookup()) == 6192);
// Rank 4 is not known, so it must not be picked.
REQUIRE(resolveHighestKnownRank(11549, known, dbc.lookup()) != 11549);
REQUIRE(resolveHighestKnownRank(11549, known, dbc.lookup()) == 6192);
}
TEST_CASE("A known spell is left alone", "[spell][rank]") {
FakeSpellDbc dbc;
dbc.add(772, "Rend", "Rank 1");
dbc.add(6546, "Rend", "Rank 2");
const std::unordered_set<uint32_t> known{772};
// Rank 1 is still the active rank — casting it must not be rewritten.
REQUIRE(resolveHighestKnownRank(772, known, dbc.lookup()) == 772);
}
TEST_CASE("Spells with no known sibling pass through unchanged", "[spell][rank]") {
FakeSpellDbc dbc;
dbc.add(8690, "Hearthstone", "");
dbc.add(133, "Fireball", "Rank 1");
dbc.add(6673, "Battle Shout", "Rank 1");
dbc.add(5242, "Battle Shout", "Rank 2");
const std::unordered_set<uint32_t> known{5242};
// Item spells are absent from the known-spell list but have no known same-name
// sibling, so they must be left alone rather than rewritten to something else.
REQUIRE(resolveHighestKnownRank(8690, known, dbc.lookup()) == 8690);
// A spell belonging to another class must not be rewritten either.
REQUIRE(resolveHighestKnownRank(133, known, dbc.lookup()) == 133);
}
TEST_CASE("Rank resolution copes with missing data", "[spell][rank]") {
FakeSpellDbc dbc;
dbc.add(5242, "Battle Shout", "Rank 2");
const std::unordered_set<uint32_t> known{5242};
// Spell id 0 is "no spell".
REQUIRE(resolveHighestKnownRank(0, known, dbc.lookup()) == 0);
// A spell absent from Spell.dbc entirely cannot be resolved, so pass it through.
REQUIRE(resolveHighestKnownRank(999999, known, dbc.lookup()) == 999999);
// Nothing known at all: pass through rather than resolving to 0.
REQUIRE(resolveHighestKnownRank(6673, {}, dbc.lookup()) == 6673);
}