mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-11 11:33:52 +00:00
Novel replacement for the per-class per-level base-stat scaling
table that vanilla WoW scattered across CharBaseInfo.dbc +
CharStartOutfit.dbc + GtChanceTo*.dbc + the hard-coded HP/mana-
per-level constants in the server's StatSystem. Each WCST entry
binds one (classId, level) pair to base health, mana, armor, and
the five primary stats (Str/Agi/Sta/Int/Spi).
Sparse design: presets emit ~6 sample levels per class with the
runtime stat-interpolator computing intermediate levels.
Three presets:
--gen-cst-warrior Warrior (classId=1) sparse sample at L1/
10/20/30/40/60. baseMana=0 across all
entries (Warrior uses Rage)
--gen-cst-mage Mage (classId=8) same 6 levels with mana
growth tracking Intellect
--gen-cst-starting All 9 vanilla classes at level 1 — shows
per-class flat starting differences
(Warrior/Paladin high Str; Hunter/Rogue
high Agi; Mage/Priest/Warlock high Int;
Shaman/Druid balanced)
Validator catches: id+classId+level required, classId 1..11,
level 1..60, zero baseHealth (player would die instantly),
duplicate statIds, duplicate (classId,level) pairs (runtime
stat-lookup tie). Warns on classId 6/10 (DK/Monk gap unused
in vanilla), Warrior/Rogue baseMana > 0 (these classes use
Rage/Energy not mana), and per-class monotonicity violations
across all 8 stats — sorts by level, walks adjacent pairs,
flags any stat that regresses as level increases (typo guard).
Format count 129 -> 130. CLI flag count 1364 -> 1371.
326 lines
12 KiB
C++
326 lines
12 KiB
C++
#include "cli_combat_stats_catalog.hpp"
|
|
#include "cli_arg_parse.hpp"
|
|
#include "cli_box_emitter.hpp"
|
|
|
|
#include "pipeline/wowee_combat_stats.hpp"
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <set>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace wowee {
|
|
namespace editor {
|
|
namespace cli {
|
|
|
|
namespace {
|
|
|
|
std::string stripWcstExt(std::string base) {
|
|
stripExt(base, ".wcst");
|
|
return base;
|
|
}
|
|
|
|
const char* classIdName(uint8_t c) {
|
|
switch (c) {
|
|
case 1: return "Warrior";
|
|
case 2: return "Paladin";
|
|
case 3: return "Hunter";
|
|
case 4: return "Rogue";
|
|
case 5: return "Priest";
|
|
case 7: return "Shaman";
|
|
case 8: return "Mage";
|
|
case 9: return "Warlock";
|
|
case 11: return "Druid";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
bool saveOrError(const wowee::pipeline::WoweeCombatStats& c,
|
|
const std::string& base, const char* cmd) {
|
|
if (!wowee::pipeline::WoweeCombatStatsLoader::save(c, base)) {
|
|
std::fprintf(stderr, "%s: failed to save %s.wcst\n",
|
|
cmd, base.c_str());
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void printGenSummary(const wowee::pipeline::WoweeCombatStats& c,
|
|
const std::string& base) {
|
|
std::printf("Wrote %s.wcst\n", base.c_str());
|
|
std::printf(" catalog : %s\n", c.name.c_str());
|
|
std::printf(" entries : %zu\n", c.entries.size());
|
|
}
|
|
|
|
int handleGenWarrior(int& i, int argc, char** argv) {
|
|
std::string base = argv[++i];
|
|
std::string name = "WarriorBaseStats";
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
base = stripWcstExt(base);
|
|
auto c = wowee::pipeline::WoweeCombatStatsLoader::
|
|
makeWarriorStats(name);
|
|
if (!saveOrError(c, base, "gen-cst-warrior")) return 1;
|
|
printGenSummary(c, base);
|
|
return 0;
|
|
}
|
|
|
|
int handleGenMage(int& i, int argc, char** argv) {
|
|
std::string base = argv[++i];
|
|
std::string name = "MageBaseStats";
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
base = stripWcstExt(base);
|
|
auto c = wowee::pipeline::WoweeCombatStatsLoader::
|
|
makeMageStats(name);
|
|
if (!saveOrError(c, base, "gen-cst-mage")) return 1;
|
|
printGenSummary(c, base);
|
|
return 0;
|
|
}
|
|
|
|
int handleGenStarting(int& i, int argc, char** argv) {
|
|
std::string base = argv[++i];
|
|
std::string name = "StartingLevelStats";
|
|
if (parseOptArg(i, argc, argv)) name = argv[++i];
|
|
base = stripWcstExt(base);
|
|
auto c = wowee::pipeline::WoweeCombatStatsLoader::
|
|
makeStartingLevels(name);
|
|
if (!saveOrError(c, base, "gen-cst-starting")) return 1;
|
|
printGenSummary(c, base);
|
|
return 0;
|
|
}
|
|
|
|
int handleInfo(int& i, int argc, char** argv) {
|
|
std::string base = argv[++i];
|
|
bool jsonOut = consumeJsonFlag(i, argc, argv);
|
|
base = stripWcstExt(base);
|
|
if (!wowee::pipeline::WoweeCombatStatsLoader::exists(base)) {
|
|
std::fprintf(stderr, "WCST not found: %s.wcst\n",
|
|
base.c_str());
|
|
return 1;
|
|
}
|
|
auto c = wowee::pipeline::WoweeCombatStatsLoader::load(base);
|
|
if (jsonOut) {
|
|
nlohmann::json j;
|
|
j["wcst"] = base + ".wcst";
|
|
j["name"] = c.name;
|
|
j["count"] = c.entries.size();
|
|
nlohmann::json arr = nlohmann::json::array();
|
|
for (const auto& e : c.entries) {
|
|
arr.push_back({
|
|
{"statId", e.statId},
|
|
{"classId", e.classId},
|
|
{"className", classIdName(e.classId)},
|
|
{"level", e.level},
|
|
{"baseHealth", e.baseHealth},
|
|
{"baseMana", e.baseMana},
|
|
{"baseStrength", e.baseStrength},
|
|
{"baseAgility", e.baseAgility},
|
|
{"baseStamina", e.baseStamina},
|
|
{"baseIntellect", e.baseIntellect},
|
|
{"baseSpirit", e.baseSpirit},
|
|
{"baseArmor", e.baseArmor},
|
|
});
|
|
}
|
|
j["entries"] = arr;
|
|
std::printf("%s\n", j.dump(2).c_str());
|
|
return 0;
|
|
}
|
|
std::printf("WCST: %s.wcst\n", base.c_str());
|
|
std::printf(" catalog : %s\n", c.name.c_str());
|
|
std::printf(" entries : %zu\n", c.entries.size());
|
|
if (c.entries.empty()) return 0;
|
|
std::printf(" id class lvl hp mana str agi sta int spi armor\n");
|
|
for (const auto& e : c.entries) {
|
|
std::printf(" %4u %-8s %3u %5u %5u %3u %3u %3u %3u %3u %5u\n",
|
|
e.statId, classIdName(e.classId),
|
|
e.level, e.baseHealth, e.baseMana,
|
|
e.baseStrength, e.baseAgility,
|
|
e.baseStamina, e.baseIntellect,
|
|
e.baseSpirit, e.baseArmor);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int handleValidate(int& i, int argc, char** argv) {
|
|
std::string base = argv[++i];
|
|
bool jsonOut = consumeJsonFlag(i, argc, argv);
|
|
base = stripWcstExt(base);
|
|
if (!wowee::pipeline::WoweeCombatStatsLoader::exists(base)) {
|
|
std::fprintf(stderr,
|
|
"validate-wcst: WCST not found: %s.wcst\n",
|
|
base.c_str());
|
|
return 1;
|
|
}
|
|
auto c = wowee::pipeline::WoweeCombatStatsLoader::load(base);
|
|
std::vector<std::string> errors;
|
|
std::vector<std::string> warnings;
|
|
if (c.entries.empty()) {
|
|
warnings.push_back("catalog has zero entries");
|
|
}
|
|
std::set<uint32_t> idsSeen;
|
|
using Pair = std::pair<uint8_t, uint8_t>;
|
|
std::set<Pair> classLevelPairs;
|
|
for (size_t k = 0; k < c.entries.size(); ++k) {
|
|
const auto& e = c.entries[k];
|
|
std::string ctx = "entry " + std::to_string(k) +
|
|
" (statId=" + std::to_string(e.statId) +
|
|
" " + classIdName(e.classId) +
|
|
" L" + std::to_string(e.level) + ")";
|
|
if (e.statId == 0)
|
|
errors.push_back(ctx + ": statId is 0");
|
|
if (e.classId == 0 || e.classId > 11) {
|
|
errors.push_back(ctx + ": classId " +
|
|
std::to_string(e.classId) +
|
|
" out of vanilla range (1..11)");
|
|
}
|
|
if (e.classId == 6 || e.classId == 10) {
|
|
warnings.push_back(ctx + ": classId " +
|
|
std::to_string(e.classId) +
|
|
" is unused in vanilla (DK/Monk gap)");
|
|
}
|
|
if (e.level == 0 || e.level > 60) {
|
|
errors.push_back(ctx + ": level " +
|
|
std::to_string(e.level) +
|
|
" out of vanilla range (1..60)");
|
|
}
|
|
if (e.baseHealth == 0)
|
|
errors.push_back(ctx +
|
|
": baseHealth is 0 (player would die "
|
|
"instantly)");
|
|
// Warrior(1) and Rogue(4) use Rage/Energy
|
|
// respectively — baseMana > 0 for these
|
|
// classes is wrong.
|
|
if ((e.classId == 1 || e.classId == 4) &&
|
|
e.baseMana > 0) {
|
|
warnings.push_back(ctx +
|
|
": baseMana=" +
|
|
std::to_string(e.baseMana) +
|
|
" on Warrior/Rogue — these classes use "
|
|
"Rage/Energy, not mana. Likely typo");
|
|
}
|
|
// (classId, level) MUST be unique — runtime
|
|
// dispatch by this pair would tie.
|
|
Pair p{e.classId, e.level};
|
|
if (!classLevelPairs.insert(p).second) {
|
|
errors.push_back(ctx +
|
|
": duplicate (classId=" +
|
|
std::to_string(e.classId) +
|
|
", level=" + std::to_string(e.level) +
|
|
") — runtime stat-lookup tie");
|
|
}
|
|
if (!idsSeen.insert(e.statId).second) {
|
|
errors.push_back(ctx + ": duplicate statId");
|
|
}
|
|
}
|
|
// Monotonicity: per-class, when sorted by level,
|
|
// no stat (HP/mana/Str/Agi/Sta/Int/Spi/armor)
|
|
// should regress (decrease) as level increases.
|
|
// Regression suggests a typo in the data table.
|
|
std::map<uint8_t, std::vector<const wowee::pipeline::
|
|
WoweeCombatStats::Entry*>> byClass;
|
|
for (const auto& e : c.entries) byClass[e.classId].push_back(&e);
|
|
for (auto& [classId, vec] : byClass) {
|
|
std::sort(vec.begin(), vec.end(),
|
|
[](const wowee::pipeline::WoweeCombatStats::
|
|
Entry* a,
|
|
const wowee::pipeline::WoweeCombatStats::
|
|
Entry* b) {
|
|
return a->level < b->level;
|
|
});
|
|
for (size_t k = 1; k < vec.size(); ++k) {
|
|
const auto* prev = vec[k-1];
|
|
const auto* cur = vec[k];
|
|
auto chk = [&](const char* statName,
|
|
uint64_t prevV, uint64_t curV) {
|
|
if (curV < prevV) {
|
|
warnings.push_back(
|
|
std::string("monotonicity: ") +
|
|
classIdName(classId) +
|
|
" " + statName +
|
|
" regresses from " +
|
|
std::to_string(prevV) +
|
|
" (L" + std::to_string(prev->level) +
|
|
") to " +
|
|
std::to_string(curV) +
|
|
" (L" + std::to_string(cur->level) +
|
|
") — likely typo");
|
|
}
|
|
};
|
|
chk("baseHealth", prev->baseHealth, cur->baseHealth);
|
|
chk("baseMana", prev->baseMana, cur->baseMana);
|
|
chk("baseStrength", prev->baseStrength, cur->baseStrength);
|
|
chk("baseAgility", prev->baseAgility, cur->baseAgility);
|
|
chk("baseStamina", prev->baseStamina, cur->baseStamina);
|
|
chk("baseIntellect",prev->baseIntellect,cur->baseIntellect);
|
|
chk("baseSpirit", prev->baseSpirit, cur->baseSpirit);
|
|
chk("baseArmor", prev->baseArmor, cur->baseArmor);
|
|
}
|
|
}
|
|
bool ok = errors.empty();
|
|
if (jsonOut) {
|
|
nlohmann::json j;
|
|
j["wcst"] = base + ".wcst";
|
|
j["ok"] = ok;
|
|
j["errors"] = errors;
|
|
j["warnings"] = warnings;
|
|
std::printf("%s\n", j.dump(2).c_str());
|
|
return ok ? 0 : 1;
|
|
}
|
|
std::printf("validate-wcst: %s.wcst\n", base.c_str());
|
|
if (ok && warnings.empty()) {
|
|
std::printf(" OK — %zu entries, all statIds + "
|
|
"(classId,level) unique, classId 1..11, "
|
|
"level 1..60, no zero baseHealth, no "
|
|
"Warrior/Rogue mana, all stats "
|
|
"monotonic over level\n",
|
|
c.entries.size());
|
|
return 0;
|
|
}
|
|
if (!warnings.empty()) {
|
|
std::printf(" warnings (%zu):\n", warnings.size());
|
|
for (const auto& w : warnings)
|
|
std::printf(" - %s\n", w.c_str());
|
|
}
|
|
if (!errors.empty()) {
|
|
std::printf(" ERRORS (%zu):\n", errors.size());
|
|
for (const auto& e : errors)
|
|
std::printf(" - %s\n", e.c_str());
|
|
}
|
|
return ok ? 0 : 1;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool handleCombatStatsCatalog(int& i, int argc, char** argv,
|
|
int& outRc) {
|
|
if (std::strcmp(argv[i], "--gen-cst-warrior") == 0 &&
|
|
i + 1 < argc) {
|
|
outRc = handleGenWarrior(i, argc, argv); return true;
|
|
}
|
|
if (std::strcmp(argv[i], "--gen-cst-mage") == 0 &&
|
|
i + 1 < argc) {
|
|
outRc = handleGenMage(i, argc, argv); return true;
|
|
}
|
|
if (std::strcmp(argv[i], "--gen-cst-starting") == 0 &&
|
|
i + 1 < argc) {
|
|
outRc = handleGenStarting(i, argc, argv); return true;
|
|
}
|
|
if (std::strcmp(argv[i], "--info-wcst") == 0 && i + 1 < argc) {
|
|
outRc = handleInfo(i, argc, argv); return true;
|
|
}
|
|
if (std::strcmp(argv[i], "--validate-wcst") == 0 &&
|
|
i + 1 < argc) {
|
|
outRc = handleValidate(i, argc, argv); return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
} // namespace cli
|
|
} // namespace editor
|
|
} // namespace wowee
|