Files
healer-man/src/game/romPlayerCatalog.test.ts
T
2026-09-27 10:16:42 -04:00

157 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import generated from "./romPlayerCatalog.generated.json";
import baseStatGrowth from "./romBaseStatGrowth.generated.json";
import audit from "../../scripts/runewaker-pipeline/snapshots/runewaker-player-skill-audit.json";
import {
abilitiesForCharacter,
abilityAtLevel,
defaultActionBarForCharacter,
} from "./abilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
const RACE_MATRIX = {
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
"rom-elf": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-warden", "rom-druid"],
"rom-dwarf": ["rom-warrior", "rom-rogue", "rom-mage", "rom-priest", "rom-warlock", "rom-champion"],
};
const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70];
describe("generated RuneWaker player catalog", () => {
it("keeps the small stat-growth pack aligned with the source catalog", () => {
expect(baseStatGrowth).toEqual(generated.baseStatGrowth);
});
it("imports the exact released roster and race eligibility without Duelist", () => {
expect(generated.canonicalClasses.map((entry) => entry.name)).toEqual([
"Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion",
]);
expect(generated.canonicalClasses.some((entry) => entry.name === "Duelist")).toBe(false);
expect(generated.raceMatrix).toEqual(RACE_MATRIX);
expect(generated.eliteLevels).toEqual(ELITE_LEVELS);
expect(audit.coverage).toMatchObject({
releasedClasses: 10,
duelistExcluded: true,
races: 3,
orderedPairs: 66,
eliteMilestones: 660,
ordinaryFamilies: 269,
iconResolved: 929,
uniqueNativeIcons: 918,
unresolvedVisible: 0,
});
expect(audit.hashes.interfaceFdb).toMatch(/^[a-f0-9]{64}$/);
});
it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => {
expect(ROM_ELITE_ABILITIES).toHaveLength(661);
const pairGroups = new Map<string, typeof ROM_ELITE_ABILITIES[number][]>();
for (const ability of ROM_ELITE_ABILITIES) {
const key = `${ability.classId}:${ability.secondaryClassId}`;
pairGroups.set(key, [...(pairGroups.get(key) ?? []), ability]);
}
expect(pairGroups.size).toBe(66);
for (const [pair, elites] of pairGroups) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true);
}
expect(ROM_ALL_ABILITIES).toHaveLength(930);
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
/^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
&& ability.icon === generated.skills.find((skill) => skill.id === ability.id)?.icon
))).toBe(true);
expect(ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-knight-elite-70-devotion-halo")?.icon)
.toBe(generated.skills.find((skill) => skill.sourceSkillId === 499957)?.icon);
expect(generated.skills.every((ability) => (
ability.iconResolution === "native-interface-fdb"
&& /^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
))).toBe(true);
});
it("composes only primary, secondary-general, and active-orientation elite skills", () => {
const warriorScout = abilitiesForCharacter("rom-warrior", "rom-scout");
const scoutWarrior = abilitiesForCharacter("rom-scout", "rom-warrior");
expect(warriorScout.some((ability) => ability.classId === "rom-scout" && ability.romSkillGroup === "primary")).toBe(false);
expect(warriorScout.some((ability) => ability.classId === "rom-scout" && ability.romSkillGroup === "general")).toBe(true);
expect(warriorScout.filter((ability) => ability.secondaryClassId)).toHaveLength(10);
expect(warriorScout.filter((ability) => ability.secondaryClassId).every((ability) => (
ability.classId === "rom-warrior" && ability.secondaryClassId === "rom-scout"
))).toBe(true);
expect(scoutWarrior.filter((ability) => ability.secondaryClassId).every((ability) => (
ability.classId === "rom-scout" && ability.secondaryClassId === "rom-warrior"
))).toBe(true);
expect(defaultActionBarForCharacter("rom-warrior", "rom-scout", 14).some((id) => id.includes("-elite-"))).toBe(false);
expect(defaultActionBarForCharacter("rom-warrior", "rom-scout", 15).filter((id) => id.includes("-elite-"))).toHaveLength(1);
});
it("selects the highest eligible source rank automatically", () => {
const ranked = ROM_ALL_ABILITIES.find((ability) => (ability.ranks?.length ?? 0) > 1)!;
const eligible = ranked.ranks!.filter((rank) => rank.level <= 80).at(-1)!;
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
});
it("preserves every source rank and its full flat or percentage cost", () => {
const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({
resource: cost.resource,
amount: cost.amount,
...(cost.percentage ? { percentage: true } : {}),
}));
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
expect(generatedRanks).toBe(934);
expect(Math.max(...generated.skills.flatMap((ability) => (
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
)))).toBe(400);
for (const source of generated.skills) {
const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!;
expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs));
expect(runtime.ranks).toHaveLength(source.ranks.length);
for (const [index, sourceRank] of source.ranks.entries()) {
expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId);
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs));
}
}
});
it("adds exactly ten percent of the secondary class base stats", () => {
const primary = romNativeClassBaseStats("rom-warrior", 40);
const secondary = romNativeClassBaseStats("rom-scout", 40);
const combined = romDualClassBaseStats("rom-warrior", "rom-scout", 40)!;
for (const stat of Object.keys(primary) as (keyof typeof primary)[]) {
expect(combined[stat]).toBeCloseTo(primary[stat] + secondary[stat] * 0.1, 8);
}
});
it("uses hybrid Priest and native Druid mana curves while retaining source spell costs", () => {
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
(baseResourcesFor("rom-priest", 1).mana + manaFromTotalIntellect(baseAttributesFor("rom-priest", 1, "human").intellect))
* MANA_POOL_MULTIPLIER,
);
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
98 * MANA_POOL_MULTIPLIER,
);
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
{ resource: "mana", amount: 12, percentage: false },
]);
expect(generated.skills.find((ability) => ability.id === "rom-druid-primary-0")?.costs).toEqual([
{ resource: "mana", amount: 12, percentage: false },
]);
});
it("uses player growth tables for every released RoM class without exponential stat explosions", () => {
for (const entry of generated.canonicalClasses) {
const classId = entry.id as keyof typeof generated.baseStatGrowth;
const growth = generated.baseStatGrowth[classId];
expect(growth.strength.percentGrowth, classId).toBe(0.02);
const stats = romNativeClassBaseStats(classId, 70);
expect(stats.health, classId).toBeLessThan(10_000);
expect(stats.strength, classId).toBeLessThan(2_000);
}
expect(generated.baseStatGrowth["rom-champion"].strength).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
expect(generated.baseStatGrowth["rom-warlock"].intellect).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
});
});