Files
WoWee/tests/test_spell_classification.cpp
Kelsi d00f20b3e1 fix(gameplay): polish worker and consumable presentation
Parse per-animation M2 color alpha tracks across vanilla and WotLK layouts so hidden lumberjack bundle meshes remain culled until their authored death sequence.

Keep scripted worker emotes and display swaps coherent across movement and async model loads. Rework food and drink presentation around the seated aura lifecycle, repeat consumption audio, preserve real cast animations on auto-stand, and retain potion swigs.

Add server-driven intoxication movement, camera, and post-process effects plus focused regression coverage for M2 alpha tracks and consumable classification.
2026-07-17 00:48:21 -07:00

203 lines
8.1 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);
}
}
TEST_CASE("Ranged weapon auto-attacks ignore placeholder DBC resource costs",
"[spell][ranged-auto]") {
REQUIRE(isRangedWeaponAutoAttack(75)); // Auto Shot
REQUIRE(isRangedWeaponAutoAttack(5019)); // Shoot
REQUIRE(isRangedWeaponAutoAttack(2764)); // Throw
REQUIRE_FALSE(isRangedWeaponAutoAttack(0));
REQUIRE_FALSE(isRangedWeaponAutoAttack(19434)); // Aimed Shot still uses real costs
}
TEST_CASE("Fishing ranks use targetless server-placed casts", "[spell][fishing]") {
for (uint32_t spellId : {7620u, 7731u, 7732u, 18248u, 33095u, 51294u}) {
REQUIRE(isFishingCast(spellId));
}
REQUIRE_FALSE(isFishingCast(0));
REQUIRE_FALSE(isFishingCast(2366)); // Herbalism
REQUIRE_FALSE(isFishingCast(2575)); // Mining
}
TEST_CASE("Only restoration food and drink spells use the seated consumption loop",
"[spell][consumable]") {
REQUIRE(classifyRestChannel("Food") == RestChannelKind::FOOD);
REQUIRE(classifyRestChannel("Drink") == RestChannelKind::DRINK);
// Potions use the same drinking motion, but unrelated quest-item actions do not.
REQUIRE(classifyRestChannel("Drink Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Drink Minor Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Drink Eye Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Healing Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Minor Mana Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Swiftness Potion") == RestChannelKind::POTION);
REQUIRE(classifyRestChannel("Create Fervor Potion") == RestChannelKind::NONE);
REQUIRE(classifyRestChannel("Potion Toss") == RestChannelKind::NONE);
REQUIRE(classifyRestChannel("Drink Disease Bottle") == RestChannelKind::NONE);
REQUIRE(classifyRestChannel("Food (TEST)") == RestChannelKind::NONE);
REQUIRE(classifyRestChannel("") == RestChannelKind::NONE);
const uint32_t alcoholEffects[3] = {0, 100, 0};
const uint32_t ordinaryEffects[3] = {6, 10, 0};
REQUIRE(hasInebriateEffect(alcoholEffects, 3));
REQUIRE_FALSE(hasInebriateEffect(ordinaryEffects, 3));
}
// ---------------------------------------------------------------------------
// 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);
}