Release Healer Man 0.1.15

This commit is contained in:
phenom committed 2026-09-05 15:33:06 -04:00
1 parent 7b41b102fa
commit f49e54f35f
43 files changed
+1478 -112

No files matched your search

+4 -2
View File
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const activeRole = useShellStore((state) => state.activeDungeonRole);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter(character);
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout,
);
}
}, [character?.id, gameMode]);
}, [activeRole, character?.id, gameMode]);
useEffect(() => {
if (mountedResetRevision.current === resetRevision) return;
@@ -65,6 +66,7 @@ export function CombatBridge() {
// also guarantees a fast return to Character Select cannot outrun a
// deferred save and lose the last level or talent point.
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
}
if (state.actionBindings !== previousState.actionBindings) {
+11 -3
View File
@@ -11,7 +11,8 @@ import type {
import { useShellStore } from "../app/shellStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { resolvedEquipmentItems } from "./heirloomEquipment";
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
import {
configureOnlineSessionRuntime,
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
selectedTargetId: combat.selectedTargetId,
activeCast: combat.activeCast,
animationEvent: combat.playerAnimationEvent,
equipment: equipmentItemsForOwner(
equipment: resolvedEquipmentItems(
combat.equipment,
combat.inventory,
PLAYER_EQUIPMENT_OWNER_ID,
{
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: combat.classId,
specializationId: combat.specializationId,
role: combat.gearRole,
level: combat.level,
},
),
},
...(publishWorld ? { world: sharedWorld() } : {}),
+5 -1
View File
@@ -40,7 +40,10 @@ export function PartyBridge() {
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
useEffect(() => {
usePartyStore.getState().syncLevel(level);
const party = usePartyStore.getState();
const combat = useCombatStore.getState();
party.syncLevel(level);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
}, [level]);
useEffect(() => {
@@ -48,6 +51,7 @@ export function PartyBridge() {
mountedResetRevision.current = resetRevision;
const combat = useCombatStore.getState();
usePartyStore.getState().resetCombat(combat.level, combat.health);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
resetPartyRuntimeClock();
}, [resetRevision]);
+78 -9
View File
@@ -131,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
equipmentStats,
activeEquipmentStatsForClass,
equipInventoryItem,
normalizeEquipment,
@@ -139,6 +138,12 @@ import {
type EquipmentAssignments,
type EquipmentSlot,
} from "./equipment";
import {
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import {
EMPTY_ITEM_STATS,
itemStatSummary,
@@ -491,6 +496,8 @@ export interface ActorHealingRequest {
export interface CharacterCombatSeed {
readonly classId: ClassId;
readonly specializationId?: string | null;
readonly role?: PartyRole;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly level?: number;
@@ -593,6 +600,8 @@ export interface PendingGroundTarget {
export interface CombatState {
classId: ClassId;
specializationId: string | null;
gearRole: PartyRole;
secondaryClassId: ClassId | null;
raceId: RaceId;
level: number;
@@ -645,6 +654,7 @@ export interface CombatState {
settings: GameplaySettings;
playerPosition: CombatPosition;
initializeCharacter: (profile: CharacterCombatSeed) => void;
refreshResolvedGear: () => void;
registerMob: (id: string, registration: MobRegistration) => void;
removeMob: (id: string) => void;
lootMob: (id: string, now?: number) => boolean;
@@ -3117,6 +3127,8 @@ function initialState() {
const initialPools = initialResourcePools(classId, abilities, maxResource);
return {
classId,
specializationId: null,
gearRole: "healer" as PartyRole,
secondaryClassId: null,
raceId,
level,
@@ -3175,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const specialization = specializationById(profile.specializationId);
const specializationId = specialization?.classId === classId ? specialization.id : null;
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
@@ -3184,7 +3199,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, inventory);
const gearStats = activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
specializationId,
role: gearRole,
level: progression.level,
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
const maxHealth = talentAdjustedMaxHealth(
classId,
progression.level,
@@ -3231,6 +3253,44 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}));
},
refreshResolvedGear: () => set((state) => {
const context: HeirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
};
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
return {
gearStats,
maxHealth,
maxResource,
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
};
}),
registerMob: (id, registration) => set((state) => {
if (!id) return state;
const existing = state.mobs[id];
@@ -3471,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
const gearStats = activeEquipmentStatsForClass(
change.assignments,
state.inventory,
PLAYER_EQUIPMENT_OWNER_ID,
state.classId,
);
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
@@ -3518,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false;
const gearStats = activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { ClassId } from "../app/characterCatalog";
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
import {
compareEquipmentUpgrade,
resolvedAverageItemLevel,
resolvedEquipmentItems,
resolvedEquipmentLoadout,
type HeirloomContext,
} from "./heirloomEquipment";
import { itemStatsFor } from "./itemStats";
import { LOOT_CATALOG, scaledLootItemLevel, type LootCatalogItem } from "./lootCatalog";
import type { InventoryItem } from "./lootTypes";
import {
SPECIALIZATION_DEFINITIONS,
type SpecializationDefinition,
} from "./specializationCatalog";
import { useCombatStore } from "./combatStore";
import { normalizeInventory } from "./lootTypes";
const templates = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
function context(profile: SpecializationDefinition, level: number, ownerKind: "player" | "party" = "player"): HeirloomContext {
return {
ownerId: ownerKind === "player" ? "player" : `party-${profile.id}`,
ownerKind,
classId: profile.classId,
specializationId: profile.id,
role: profile.role,
level,
};
}
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
return {
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
itemId: template.itemId,
itemKey: template.itemKey,
name: template.itemName,
quality: template.quality,
inventorySlot: template.inventorySlot,
armorType: template.armorType,
weaponType: template.weaponType,
itemLevel,
sourceItemLevel: template.sourceItemLevel,
stats: itemStatsFor({ ...template, itemLevel }),
quantity: 1,
sourceBossKey: "test-boss",
sourceBossName: "Test Boss",
acquiredAt: 1,
};
}
function compatibleItems(classId: ClassId, level: number): InventoryItem[] {
return templates
.map((template, index) => catalogItem(template, level, String(index)))
.filter((item) => classCanEquipItem(classId, item));
}
describe("specialization-aware heirlooms", () => {
it("caps player heirlooms at 79 and party heirlooms at 80", () => {
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80)).map((item) => item.itemLevel))).toEqual(new Set([79]));
expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80, "party")).map((item) => item.itemLevel))).toEqual(new Set([80]));
expect(resolvedAverageItemLevel({}, [], context(profile, 40))).toBe(40);
});
it("fills all applicable slots and uses the specialization weapon layout", () => {
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
const loadout = resolvedEquipmentLoadout({}, [], context(protection, 40));
expect(loadout["head"]?.quality).toBe("heirloom");
expect(loadout["finger"]?.quality).toBe("heirloom");
expect(loadout["trinket"]?.quality).toBe("heirloom");
expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand");
expect(loadout["off-hand"]?.armorType).toBe("shield");
});
it("allocates only useful archetype stats", () => {
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
const combat = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "rogue:combat")!;
const protectionStats = resolvedEquipmentItems({}, [], context(protection, 40)).find((item) => item.inventorySlot === "chest")!.stats;
const holyStats = resolvedEquipmentItems({}, [], context(holy, 40)).find((item) => item.inventorySlot === "chest")!.stats;
const combatStats = resolvedEquipmentItems({}, [], context(combat, 40)).find((item) => item.inventorySlot === "chest")!.stats;
expect(protectionStats).toMatchObject({ stamina: expect.any(Number), defenseRating: expect.any(Number), blockRating: expect.any(Number), spellPower: 0 });
expect(holyStats).toMatchObject({ intellect: expect.any(Number), spirit: expect.any(Number), spellPower: expect.any(Number), mp5: expect.any(Number), attackPower: 0 });
expect(combatStats).toMatchObject({ agility: expect.any(Number), attackPower: expect.any(Number), hitRating: expect.any(Number), spellPower: 0 });
expect(protectionStats.blockRating).toBeGreaterThan(0);
expect(holyStats.spellPower).toBeGreaterThan(0);
expect(combatStats.agility).toBeGreaterThan(0);
});
it.each([1, 40, 79])("keeps compatible same-level boss items below heirlooms at level %i", (level) => {
for (const profile of SPECIALIZATION_DEFINITIONS) {
const loadout = resolvedEquipmentLoadout({}, [], context(profile, level));
for (const item of compatibleItems(profile.classId, level)) {
expect(compareEquipmentUpgrade(item, loadout, profile, context(profile, level)).isUpgrade, `${profile.id}: ${item.name}`).toBe(false);
}
}
});
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
expect(scaledLootItemLevel(79)).toBe(79);
expect(scaledLootItemLevel(80)).toBe(85);
for (const profile of SPECIALIZATION_DEFINITIONS) {
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
const upgrades = compatibleItems(profile.classId, 85)
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
expect(upgrades.length, profile.id).toBeGreaterThan(0);
}
});
it("lets earned assignments override a slot and restores its heirloom when removed", () => {
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
const ownerContext = context(profile, 80);
const item = compatibleItems(profile.classId, 85).find((candidate) => equipmentSlotForItem(candidate) === "head")
?? compatibleItems(profile.classId, 85)[0];
const slot = equipmentSlotForItem(item)!;
const equipped = resolvedEquipmentLoadout({ player: { [slot]: item.instanceId } }, [item], ownerContext);
expect(equipped[slot]).toBe(item);
expect(resolvedEquipmentLoadout({}, [item], ownerContext)[slot]?.quality).toBe("heirloom");
});
it("preserves player health and resource percentages across gear maximum changes", () => {
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: profile.id,
role: profile.role,
level: 40,
});
const item = compatibleItems("priest", 85).find((candidate) => equipmentSlotForItem(candidate) === "head")
?? compatibleItems("priest", 85)[0];
const before = useCombatStore.getState();
useCombatStore.setState({
health: before.maxHealth * 0.4,
resource: before.maxResource * 0.25,
inventory: [item],
});
expect(useCombatStore.getState().equipItem(item.instanceId)).toBe(true);
expect(useCombatStore.getState().health / useCombatStore.getState().maxHealth).toBeCloseTo(0.4, 5);
expect(useCombatStore.getState().resource / useCombatStore.getState().maxResource).toBeCloseTo(0.25, 5);
});
it("raises persisted item safety to 255 without admitting virtual heirlooms", () => {
const persisted = { ...compatibleItems("priest", 85)[0], itemLevel: 999 };
expect(normalizeInventory([persisted])[0].itemLevel).toBe(255);
expect(normalizeInventory([{ ...persisted, quality: "heirloom" }])).toEqual([]);
});
});
+378
View File
@@ -0,0 +1,378 @@
import {
classById,
type ClassId,
} from "../app/characterCatalog";
import {
classCanEquipItem,
EQUIPMENT_SLOTS,
equipmentSlotForItem,
type EquipmentAssignments,
type EquipmentLoadout,
type EquipmentSlot,
} from "./equipment";
import {
EMPTY_ITEM_STATS,
ITEM_STAT_KEYS,
itemStatsFor,
type ItemStats,
type NumericItemStatKey,
type SourceItemStats,
type WeaponStats,
} from "./itemStats";
import { LOOT_CATALOG, type LootCatalogItem } from "./lootCatalog";
import type { InventoryItem } from "./lootTypes";
import type { PartyRole } from "./partyRoles";
import {
gearProfileFor,
specializationScore,
type GearArchetype,
type SpecializationDefinition,
type WeaponLayout,
} from "./specializationCatalog";
export const PLAYER_HEIRLOOM_LEVEL_CAP = 79;
export const PARTY_HEIRLOOM_LEVEL_CAP = 80;
export const FIRST_ENDGAME_ITEM_LEVEL = 85;
export type HeirloomOwnerKind = "player" | "party";
export interface HeirloomContext {
readonly ownerId: string;
readonly ownerKind: HeirloomOwnerKind;
readonly classId: ClassId;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
}
export interface EquipmentUpgradeComparison {
readonly isUpgrade: boolean;
readonly scoreDelta: number;
readonly currentScore: number;
readonly candidateScore: number;
readonly displacedSlots: readonly EquipmentSlot[];
readonly usefulStatDeltas: Readonly<Partial<Record<NumericItemStatKey, number>>>;
}
const BASE_SLOTS: readonly EquipmentSlot[] = [
"head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist",
"legs", "feet", "finger", "trinket",
];
const SLOT_WEIGHT: Readonly<Record<EquipmentSlot, number>> = Object.freeze({
head: 1,
neck: 0.55,
shoulder: 0.75,
back: 0.45,
chest: 1.2,
wrist: 0.55,
hands: 0.7,
waist: 0.65,
legs: 1.05,
feet: 0.75,
finger: 0.55,
trinket: 0.75,
"main-hand": 1,
"off-hand": 0.85,
ranged: 0.9,
});
const SLOT_NAMES: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
head: "Crown", neck: "Pendant", shoulder: "Mantle", back: "Cloak", chest: "Vestment",
wrist: "Bindings", hands: "Grips", waist: "Sash", legs: "Legguards", feet: "Treads",
finger: "Signet", trinket: "Charm", "main-hand": "Weapon", "off-hand": "Focus", ranged: "Ranged Arm",
});
const ARMOR_SLOTS = new Set<EquipmentSlot>([
"head", "shoulder", "chest", "wrist", "hands", "waist", "legs", "feet",
]);
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
const itemCache = new Map<string, InventoryItem>();
function effectiveLevel(context: HeirloomContext): number {
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1)));
}
function weaponSlots(layout: WeaponLayout): readonly EquipmentSlot[] {
switch (layout) {
case "dual-wield":
case "weapon-shield":
return ["main-hand", "off-hand"];
case "ranged":
return ["main-hand", "ranged"];
case "caster":
return ["main-hand", "off-hand", "ranged"];
default:
return ["main-hand"];
}
}
function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null {
if (slot === "off-hand" && layout === "weapon-shield") return "shield";
if (ARMOR_SLOTS.has(slot)) return classById(classId).armor;
return null;
}
function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null {
if (slot === "off-hand" && profile.weaponLayout === "dual-wield") return "dagger";
if (slot === "ranged") return profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
if (slot !== "main-hand") return null;
if (profile.weaponLayout === "caster") {
return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId)
? "mace"
: "dagger";
}
if (profile.weaponLayout === "dual-wield") return "dagger";
if (classId === "hunter" || classId === "ranger") return "staff";
return "mace";
}
function inventorySlotFor(slot: EquipmentSlot, layout: WeaponLayout): string {
if (slot === "main-hand") return layout === "two-hand" || layout === "ranged" ? "two-hand" : "one-hand";
return slot;
}
function statDistribution(archetype: GearArchetype, budget: number, armor: number): SourceItemStats {
const common = { stamina: Math.max(1, Math.round(budget * 0.28)), ...(armor > 0 ? { armor } : {}) };
switch (archetype) {
case "strength-dps": return { ...common, strength: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.5), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.2), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
case "agility-dps": return { ...common, agility: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.48), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
case "ranged-dps": return { ...common, agility: Math.round(budget * 0.55), rangedAttackPower: Math.round(budget * 0.52), hitRating: Math.round(budget * 0.21), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.17), armorPenetrationRating: Math.round(budget * 0.13) };
case "spell-dps": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.18), spellPower: Math.round(budget * 0.58), hitRating: Math.round(budget * 0.22), criticalStrikeRating: Math.round(budget * 0.18), hasteRating: Math.round(budget * 0.2) };
case "healer": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.38), spellPower: Math.round(budget * 0.58), mp5: Math.round(budget * 0.16), criticalStrikeRating: Math.round(budget * 0.12), hasteRating: Math.round(budget * 0.2) };
case "block-tank": return { ...common, strength: Math.round(budget * 0.35), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.24), dodgeRating: Math.round(budget * 0.18), parryRating: Math.round(budget * 0.18), blockRating: Math.round(budget * 0.2), blockValue: Math.round(budget * 0.2), expertiseRating: Math.round(budget * 0.1) };
case "non-block-tank": return { ...common, agility: Math.round(budget * 0.34), strength: Math.round(budget * 0.12), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.22), dodgeRating: Math.round(budget * 0.24), parryRating: Math.round(budget * 0.12), expertiseRating: Math.round(budget * 0.1) };
}
}
function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: string | null, budget: number): WeaponStats | null {
if (!weaponType) return null;
const twoHand = inventorySlot === "two-hand";
const multiplier = twoHand ? 1.55 : slot === "ranged" ? 1.15 : 0.9;
return {
damageMin: Math.max(1, Math.round(budget * 0.8 * multiplier * 10) / 10),
damageMax: Math.max(2, Math.round(budget * 1.25 * multiplier * 10) / 10),
damageSchool: "physical",
speedMs: weaponType === "dagger" ? 1800 : weaponType === "staff" ? 2900 : weaponType === "bow" ? 2800 : 2400,
};
}
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
return {
instanceId: `catalog:${template.itemId}`,
itemId: template.itemId,
itemKey: template.itemKey,
name: template.itemName,
quality: template.quality,
inventorySlot: template.inventorySlot,
armorType: template.armorType,
weaponType: template.weaponType,
itemLevel,
sourceItemLevel: template.sourceItemLevel,
stats: itemStatsFor({ ...template, itemLevel }),
quantity: 1,
sourceBossKey: "catalog",
sourceBossName: "Boss catalog",
acquiredAt: 0,
};
}
function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: number, profile: SpecializationDefinition): number {
let highest = 0;
for (const template of CATALOG_ITEMS) {
const candidate = syntheticCatalogItem(template, level);
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
highest = Math.max(highest, specializationScore(candidate.stats, profile));
}
return highest;
}
function scaleStats(stats: ItemStats, factor: number): ItemStats {
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, Math.max(0, Math.round(stats[key] * factor))])) as Record<NumericItemStatKey, number>;
const scaleWeapon = (weapon: WeaponStats | null): WeaponStats | null => weapon ? {
...weapon,
damageMin: Math.max(1, Math.round(weapon.damageMin * factor * 10) / 10),
damageMax: Math.max(2, Math.round(weapon.damageMax * factor * 10) / 10),
} : null;
return Object.freeze({ ...numeric, meleeWeapon: scaleWeapon(stats.meleeWeapon), rangedWeapon: scaleWeapon(stats.rangedWeapon) });
}
function heirloomItem(
context: HeirloomContext,
profile: SpecializationDefinition,
slot: EquipmentSlot,
forcedLayout?: WeaponLayout,
): InventoryItem {
const level = effectiveLevel(context);
const layout = forcedLayout ?? profile.weaponLayout;
const cacheKey = `${context.ownerId}|${context.classId}|${profile.id}|${level}|${slot}|${layout}`;
const cached = itemCache.get(cacheKey);
if (cached) return cached;
const armorType = armorTypeForSlot(context.classId, slot, layout);
const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout });
const inventorySlot = inventorySlotFor(slot, layout);
const weight = SLOT_WEIGHT[slot];
const budget = Math.max(2, level * weight * 1.18);
const armor = armorType && armorType !== "shield"
? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1)))
: armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0;
const sourceStats = statDistribution(profile.gearArchetype, budget, armor);
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
let stats = itemStatsFor({
itemId: 9_000_000,
quality: "heirloom",
inventorySlot,
armorType,
weaponType,
itemLevel: level,
sourceItemLevel: level,
sourceStats,
sourceWeapon,
});
const currentScore = specializationScore(stats, profile);
const catalogScore = highestCatalogScore(context.classId, slot, level, profile)
+ (slot === "main-hand" && ["two-hand", "ranged"].includes(layout)
? highestCatalogScore(context.classId, "off-hand", level, profile)
: 0);
const conflictSafety = slot === "main-hand" && ["two-hand", "ranged"].includes(layout) ? 1.05 : 1.02;
const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore;
if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore);
const normalizedScore = specializationScore(stats, profile);
if (normalizedScore + 0.001 < targetScore) {
const [bestKey, bestWeight] = Object.entries(profile.statWeights)
.sort((left, right) => (right[1] ?? 0) - (left[1] ?? 0))[0] as [NumericItemStatKey, number];
stats = Object.freeze({
...stats,
[bestKey]: stats[bestKey] + Math.max(1, Math.ceil((targetScore - normalizedScore) / Math.max(0.01, bestWeight))),
});
}
const stableNumber = [...`${profile.id}:${slot}`].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 17);
const result: InventoryItem = Object.freeze({
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
itemId: 9_000_000 + stableNumber % 900_000,
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
name: `${profile.displayName} Heirloom ${SLOT_NAMES[slot]}`,
quality: "heirloom",
inventorySlot,
armorType,
weaponType,
itemLevel: level,
sourceItemLevel: level,
stats,
quantity: 1,
sourceBossKey: "heirloom",
sourceBossName: "Heirloom Armory",
acquiredAt: 0,
});
itemCache.set(cacheKey, result);
return result;
}
export function resolvedEquipmentLoadout(
assignments: EquipmentAssignments,
inventory: readonly InventoryItem[],
context: HeirloomContext,
): Readonly<Partial<Record<EquipmentSlot, InventoryItem>>> {
const profile = gearProfileFor(context.classId, context.specializationId, context.role);
const result: Partial<Record<EquipmentSlot, InventoryItem>> = {};
for (const slot of [...BASE_SLOTS, ...weaponSlots(profile.weaponLayout)]) result[slot] = heirloomItem(context, profile, slot);
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
const earned = assignments[context.ownerId] ?? {};
for (const slot of EQUIPMENT_SLOTS) {
const item = earned[slot] ? itemById.get(earned[slot]!) : null;
if (!item || !classCanEquipItem(context.classId, item)) continue;
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"];
if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") {
result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand");
}
result[slot] = item;
}
return Object.freeze(result);
}
export function resolvedEquipmentItems(
assignments: EquipmentAssignments,
inventory: readonly InventoryItem[],
context: HeirloomContext,
): readonly InventoryItem[] {
const loadout = resolvedEquipmentLoadout(assignments, inventory, context);
return EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [loadout[slot]!] : []);
}
export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemStats {
if (!items.length) return EMPTY_ITEM_STATS;
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record<NumericItemStatKey, number>;
let meleeWeapon: WeaponStats | null = null;
let rangedWeapon: WeaponStats | null = null;
for (const item of items) {
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
}
return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon });
}
export function resolvedEquipmentStats(
assignments: EquipmentAssignments,
inventory: readonly InventoryItem[],
context: HeirloomContext,
): ItemStats {
return equipmentStatsForItems(resolvedEquipmentItems(assignments, inventory, context));
}
export function resolvedAverageItemLevel(
assignments: EquipmentAssignments,
inventory: readonly InventoryItem[],
context: HeirloomContext,
): number {
const items = resolvedEquipmentItems(assignments, inventory, context);
if (!items.length) return 0;
return Math.round(items.reduce((total, item) => total + item.itemLevel, 0) / items.length * 10) / 10;
}
function sumStats(items: readonly InventoryItem[]): ItemStats {
return equipmentStatsForItems(items);
}
export function compareEquipmentUpgrade(
candidate: InventoryItem,
currentLoadout: Readonly<Partial<Record<EquipmentSlot, InventoryItem>>>,
profile: SpecializationDefinition,
context?: HeirloomContext,
): EquipmentUpgradeComparison {
const slot = equipmentSlotForItem(candidate);
if (!slot) return { isUpgrade: false, scoreDelta: 0, currentScore: 0, candidateScore: 0, displacedSlots: [], usefulStatDeltas: {} };
const displacedSlots: EquipmentSlot[] = slot === "main-hand" && candidate.inventorySlot === "two-hand"
? ["main-hand", "off-hand"]
: slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand"
? ["main-hand", "off-hand"]
: [slot];
const currentItems = displacedSlots.flatMap((key) => currentLoadout[key] ? [currentLoadout[key]!] : []);
const currentStats = sumStats(currentItems);
const candidateItems = slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand" && context
? [heirloomItem(context, profile, "main-hand", "one-hand"), candidate]
: [candidate];
const candidateStats = sumStats(candidateItems);
const currentScore = specializationScore(currentStats, profile);
const candidateScore = specializationScore(candidateStats, profile);
const scoreDelta = Math.round((candidateScore - currentScore) * 100) / 100;
const usefulStatDeltas = Object.fromEntries(Object.keys(profile.statWeights).flatMap((rawKey) => {
const key = rawKey as NumericItemStatKey;
const delta = candidateStats[key] - currentStats[key];
return delta ? [[key, delta]] : [];
})) as Partial<Record<NumericItemStatKey, number>>;
return Object.freeze({
isUpgrade: scoreDelta > 0,
scoreDelta,
currentScore,
candidateScore,
displacedSlots,
usefulStatDeltas,
});
}
export function emptyResolvedLoadout(): EquipmentLoadout {
return {};
}
+5 -3
View File
@@ -1,6 +1,6 @@
import generatedLootCatalog from "./generated/lootCatalog.json";
export type ItemQuality = "common" | "uncommon" | "rare" | "epic" | "legendary";
export type ItemQuality = "common" | "uncommon" | "rare" | "epic" | "legendary" | "heirloom";
export type DamageSchool = "physical" | "holy" | "fire" | "nature" | "frost" | "shadow" | "arcane";
export interface WeaponStats {
@@ -109,6 +109,7 @@ export const EMPTY_ITEM_STATS: ItemStats = Object.freeze({
export const HEALTH_PER_STAMINA = 10;
export const MANA_PER_INTELLECT = 15;
export const MAX_PERSISTED_ITEM_LEVEL = 255;
const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze({
common: 0.8,
@@ -116,6 +117,7 @@ const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze(
rare: 1.25,
epic: 1.55,
legendary: 2,
heirloom: 1.65,
});
const SLOT_WEIGHT: Readonly<Record<string, number>> = Object.freeze({
@@ -198,7 +200,7 @@ function fallbackSourceStats(source: ItemStatSource): {
readonly stats: SourceItemStats;
readonly weapon: WeaponStats | null;
} {
const level = Math.max(1, Math.min(80, Math.trunc(source.sourceItemLevel ?? source.itemLevel)));
const level = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.sourceItemLevel ?? source.itemLevel)));
const quality = QUALITY_MULTIPLIER[source.quality];
const slotWeight = SLOT_WEIGHT[source.inventorySlot] ?? 0.65;
const budget = Math.max(1, level * quality * slotWeight);
@@ -232,7 +234,7 @@ export function itemStatsFor(source: ItemStatSource): ItemStats {
1,
Math.trunc(source.sourceItemLevel ?? authored?.sourceItemLevel ?? source.itemLevel),
);
const itemLevel = Math.max(1, Math.min(80, Math.trunc(source.itemLevel)));
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.itemLevel)));
const fallback = fallbackSourceStats(source);
const sourceStats = source.sourceStats
?? authored?.sourceStats
+1 -1
View File
@@ -57,7 +57,7 @@ describe("SQL-generated boss loot catalog", () => {
it("clamps generated item levels to the supported character range", () => {
expect(scaledLootItemLevel(-10)).toBe(1);
expect(scaledLootItemLevel(42.9)).toBe(42);
expect(scaledLootItemLevel(900)).toBe(80);
expect(scaledLootItemLevel(900)).toBe(85);
expect(rollBossLoot("not-a-boss", 20)).toEqual([]);
});
+2 -1
View File
@@ -84,7 +84,8 @@ function chooseWeightedItem(items: readonly LootCatalogItem[], random: () => num
}
export function scaledLootItemLevel(playerLevel: number): number {
return Math.max(1, Math.min(80, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1)));
const level = Math.max(1, Math.min(80, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1)));
return level >= 80 ? 85 : level;
}
/**
+2 -1
View File
@@ -1,5 +1,6 @@
import {
normalizeItemStats,
MAX_PERSISTED_ITEM_LEVEL,
type ItemQuality,
type ItemStats,
} from "./itemStats";
@@ -51,7 +52,7 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|| typeof item.sourceBossName !== "string"
|| !Number.isFinite(item.acquiredAt)
) continue;
const itemLevel = Math.max(1, Math.min(80, Math.trunc(item.itemLevel!)));
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
normalized.push({
instanceId: item.instanceId,
itemId: Math.max(1, Math.trunc(item.itemId!)),
+14 -9
View File
@@ -97,7 +97,9 @@ describe("Manastorm encounter sessions", () => {
const encounter = useManastormStore.getState().currentEncounter!;
const revision = useGameStore.getState().sessionRevision;
const companion = usePartyStore.getState().members[0];
usePartyStore.getState().damageMember(companion.id, companion.maxHealth - 1);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === companion.id ? { ...member, health: 1 } : member),
}));
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName,
boss: true,
@@ -124,8 +126,11 @@ describe("Manastorm encounter sessions", () => {
it("uses the same shared charge to revive only the requested companion", () => {
const companion = usePartyStore.getState().members[0];
const otherCompanion = usePartyStore.getState().members[1];
usePartyStore.getState().damageMember(otherCompanion.id, otherCompanion.maxHealth - 1);
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === companion.id
? { ...member, health: 0, status: "down" }
: member.id === otherCompanion.id ? { ...member, health: 1 } : member),
}));
expect(usePartyStore.getState().members[0].health).toBe(0);
expect(resurrectManastormSession({
@@ -160,9 +165,9 @@ describe("Manastorm encounter sessions", () => {
}
useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members;
for (const companion of companions) {
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
}
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
}));
const companion = companions.at(-1)!;
expect(manastormPartyIsDefeated()).toBe(true);
@@ -176,9 +181,9 @@ describe("Manastorm encounter sessions", () => {
it("waits for an explicit shared resurrection when the full party is down", () => {
useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members;
for (const companion of companions) {
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
}
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
}));
const companion = companions.at(-1)!;
expect(resolveManastormActorDown({
+1 -1
View File
@@ -182,7 +182,7 @@ describe("party combat runtime", () => {
nextActionAt: 3_000,
});
const geared = applyHealerSupport(wounded.map((member) => member.id === healer.id
? { ...member, gearStats: { ...member.gearStats, spellPower: 10 } }
? { ...member, gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + 1_000 } }
: member), 80, 100, 1_000);
expect(geared.effectiveHealing).toBeGreaterThan(first.effectiveHealing);
+8 -5
View File
@@ -230,12 +230,14 @@ describe("dungeon party composition", () => {
usePartyStore.getState().initializeParty("damage-member", 10, 100);
const [target, untouched] = usePartyStore.getState().members;
expect(usePartyStore.getState().damageMember(target.id, 25)).toBe(25);
const firstDamage = usePartyStore.getState().damageMember(target.id, 25);
expect(firstDamage).toBeGreaterThan(0);
expect(firstDamage).toBeLessThanOrEqual(25);
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health)
.toBe(target.maxHealth - 25);
.toBe(target.maxHealth - firstDamage);
expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.health)
.toBe(untouched.maxHealth);
expect(usePartyStore.getState().damageMember(target.id, target.maxHealth)).toBe(target.maxHealth - 25);
expect(usePartyStore.getState().damageMember(target.id, target.maxHealth * 100)).toBe(target.maxHealth - firstDamage);
expect(usePartyStore.getState().members.find((member) => member.id === target.id)).toMatchObject({
health: 0,
status: "down",
@@ -268,8 +270,9 @@ describe("dungeon party composition", () => {
[target.id]: { chest: chest.instanceId },
}, [chest]);
const equipped = usePartyStore.getState().members[0];
expect(equipped.maxHealth).toBe(beforeMaxHealth + 40);
expect(equipped.gearStats).toEqual(chest.stats);
expect(equipped.maxHealth).toBe(beforeMaxHealth + (equipped.gearStats.stamina - target.gearStats.stamina) * 10);
expect(equipped.gearStats.armor).toBeGreaterThanOrEqual(chest.stats.armor);
expect(equipped.gearStats.strength).toBeGreaterThanOrEqual(chest.stats.strength);
expect(usePartyStore.getState().damageMember(target.id, 100)).toBeLessThan(100);
});
+27 -9
View File
@@ -11,9 +11,10 @@ import {
type RaceId,
} from "../app/characterCatalog";
import {
equipmentStats,
type EquipmentAssignments,
} from "./equipment";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import { specializationsForClass } from "./specializationCatalog";
import {
EMPTY_ITEM_STATS,
healthFromStamina,
@@ -52,6 +53,7 @@ export interface PartyMember {
readonly appearance: CharacterAppearance;
readonly classId: ClassId;
readonly specialization: string;
readonly specializationId: string | null;
readonly talentTreeId: string;
readonly talentRanks: TalentRanks;
readonly role: PartyRole;
@@ -227,18 +229,30 @@ function createMember(
const genders = supportedGenders(race.id);
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
const maxHealth = memberMaxHealth(level, role, EMPTY_ITEM_STATS, template.classId, race.id);
const id = `party-${role}-${index}-${template.classId}`;
const specializationId = specializationsForClass(template.classId)
.find((definition) => definition.displayName === template.specialization)?.id ?? null;
const gearStats = resolvedEquipmentStats({}, [], {
ownerId: id,
ownerKind: "party",
classId: template.classId,
specializationId,
role,
level,
});
const maxHealth = memberMaxHealth(level, role, gearStats, template.classId, race.id);
const resourceProfile = resourceProfileForClass(template.classId);
const maxResource = resourceProfile.maximum;
const resource = resourceProfile.initial;
return {
id: `party-${role}-${index}-${template.classId}`,
id,
name: template.name,
raceId: race.id,
gender,
appearance,
classId: template.classId,
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
talentRanks: isRomClassId(template.classId)
? Object.freeze({})
@@ -259,7 +273,7 @@ function createMember(
activeCastTargetId: null,
schoolLockedUntil: Object.freeze({}),
runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]),
gearStats: EMPTY_ITEM_STATS,
gearStats,
status: "ready",
nextActionAt: 0,
tauntReadyAt: 0,
@@ -411,11 +425,8 @@ export const usePartyStore = create<PartyState>((set, get) => ({
}),
resetCombat: (level, playerHealth) => set((state) => {
const previousStats = new Map(state.members.map((member) => [member.id, member.gearStats]));
return {
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId).map(
(member) => withMemberGearStats(member, previousStats.get(member.id) ?? EMPTY_ITEM_STATS),
),
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: "defend",
selectedMemberId: null,
selfSelected: false,
@@ -455,7 +466,14 @@ export const usePartyStore = create<PartyState>((set, get) => ({
syncEquipment: (assignments, inventory) => set((state) => ({
members: state.members.map((member) => withMemberGearStats(
member,
equipmentStats(assignments, inventory, member.id),
resolvedEquipmentStats(assignments, inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
}),
)),
})),
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import {
SPECIALIZATION_DEFINITIONS,
specializationById,
specializationsForClass,
} from "./specializationCatalog";
describe("specialization registry", () => {
it("covers every classic and CoA specialization with stable unique IDs", () => {
expect(new Set(SPECIALIZATION_DEFINITIONS.map((definition) => definition.id)).size)
.toBe(SPECIALIZATION_DEFINITIONS.length);
for (const characterClass of CLASSES.filter((definition) => definition.mode !== "rom")) {
const actual = specializationsForClass(characterClass.id).map((definition) => definition.displayName);
const expected = characterClass.id === "druid"
? ["Balance", "Feral (Bear)", "Feral (Cat)", "Restoration"]
: [...characterClass.specializations];
expect(actual, characterClass.name).toEqual(expect.arrayContaining(expected));
expect(actual, characterClass.name).toHaveLength(expected.length);
}
});
it("links both Feral variants to Feral Combat with distinct runtime roles", () => {
const bear = specializationById("druid:feral-bear");
const cat = specializationById("druid:feral-cat");
expect(bear).toMatchObject({ role: "tank", talentTree: "Feral Combat" });
expect(cat).toMatchObject({ role: "damage", talentTree: "Feral Combat" });
});
it("authors CoA support-oriented specializations into the three-role runtime", () => {
const coa = SPECIALIZATION_DEFINITIONS.filter((definition) => (
CLASSES.find((entry) => entry.id === definition.classId)?.mode === "conquest"
));
expect(coa.length).toBeGreaterThan(60);
expect(coa.every((definition) => ["tank", "healer", "damage"].includes(definition.role))).toBe(true);
expect(specializationById("barbarian:ancestry")?.role).toBe("damage");
});
});
+198
View File
@@ -0,0 +1,198 @@
import {
baseClassFor,
classById,
type ClassId,
} from "../app/characterCatalog";
import type { ItemStats, NumericItemStatKey } from "./itemStats";
import type { PartyRole } from "./partyRoles";
export type GearArchetype =
| "strength-dps"
| "agility-dps"
| "ranged-dps"
| "spell-dps"
| "healer"
| "block-tank"
| "non-block-tank";
export type WeaponLayout =
| "two-hand"
| "dual-wield"
| "weapon-shield"
| "one-hand"
| "ranged"
| "caster";
export interface SpecializationDefinition {
readonly id: string;
readonly classId: ClassId;
readonly displayName: string;
readonly role: PartyRole;
readonly talentTree: string;
readonly gearArchetype: GearArchetype;
readonly weaponLayout: WeaponLayout;
readonly statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
}
const WEIGHTS: Readonly<Record<GearArchetype, Readonly<Partial<Record<NumericItemStatKey, number>>>>> = Object.freeze({
"strength-dps": Object.freeze({ strength: 1, stamina: 0.18, attackPower: 0.5, hitRating: 0.82, criticalStrikeRating: 0.72, hasteRating: 0.62, expertiseRating: 0.76, armorPenetrationRating: 0.66 }),
"agility-dps": Object.freeze({ agility: 1, stamina: 0.18, attackPower: 0.45, hitRating: 0.82, criticalStrikeRating: 0.78, hasteRating: 0.64, expertiseRating: 0.72, armorPenetrationRating: 0.66 }),
"ranged-dps": Object.freeze({ agility: 1, stamina: 0.16, rangedAttackPower: 0.5, hitRating: 0.86, criticalStrikeRating: 0.78, hasteRating: 0.66, armorPenetrationRating: 0.6 }),
"spell-dps": Object.freeze({ intellect: 0.8, spirit: 0.28, stamina: 0.14, spellPower: 1, hitRating: 0.86, criticalStrikeRating: 0.68, hasteRating: 0.74 }),
healer: Object.freeze({ intellect: 0.82, spirit: 0.62, stamina: 0.16, spellPower: 1, criticalStrikeRating: 0.46, hasteRating: 0.68, mp5: 0.82 }),
"block-tank": Object.freeze({ strength: 0.64, stamina: 1, armor: 0.12, defenseRating: 0.86, dodgeRating: 0.7, parryRating: 0.68, blockRating: 0.72, blockValue: 0.66, expertiseRating: 0.38 }),
"non-block-tank": Object.freeze({ agility: 0.66, strength: 0.28, stamina: 1, armor: 0.14, defenseRating: 0.78, dodgeRating: 0.86, parryRating: 0.46, expertiseRating: 0.4 }),
});
type SpecTuple = readonly [
classId: ClassId,
displayName: string,
role: PartyRole,
gearArchetype: GearArchetype,
weaponLayout: WeaponLayout,
talentTree?: string,
];
function specId(classId: ClassId, name: string): string {
return `${classId}:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
}
function define(tuple: SpecTuple): SpecializationDefinition {
const [classId, displayName, role, gearArchetype, weaponLayout, talentTree = displayName] = tuple;
return Object.freeze({
id: specId(classId, displayName),
classId,
displayName,
role,
talentTree,
gearArchetype,
weaponLayout,
statWeights: WEIGHTS[gearArchetype],
});
}
const CLASSIC_SPECIALIZATIONS: readonly SpecTuple[] = [
["warrior", "Arms", "damage", "strength-dps", "two-hand"],
["warrior", "Fury", "damage", "strength-dps", "dual-wield"],
["warrior", "Protection", "tank", "block-tank", "weapon-shield"],
["paladin", "Holy", "healer", "healer", "weapon-shield"],
["paladin", "Protection", "tank", "block-tank", "weapon-shield"],
["paladin", "Retribution", "damage", "strength-dps", "two-hand"],
["hunter", "Beast Mastery", "damage", "ranged-dps", "ranged"],
["hunter", "Marksmanship", "damage", "ranged-dps", "ranged"],
["hunter", "Survival", "damage", "ranged-dps", "ranged"],
["rogue", "Assassination", "damage", "agility-dps", "dual-wield"],
["rogue", "Combat", "damage", "agility-dps", "dual-wield"],
["rogue", "Subtlety", "damage", "agility-dps", "dual-wield"],
["priest", "Discipline", "healer", "healer", "caster"],
["priest", "Holy", "healer", "healer", "caster"],
["priest", "Shadow", "damage", "spell-dps", "caster"],
["death-knight", "Blood", "tank", "non-block-tank", "two-hand"],
["death-knight", "Frost", "damage", "strength-dps", "dual-wield"],
["death-knight", "Unholy", "damage", "strength-dps", "two-hand"],
["shaman", "Elemental", "damage", "spell-dps", "weapon-shield"],
["shaman", "Enhancement", "damage", "agility-dps", "dual-wield"],
["shaman", "Restoration", "healer", "healer", "weapon-shield"],
["mage", "Arcane", "damage", "spell-dps", "caster"],
["mage", "Fire", "damage", "spell-dps", "caster"],
["mage", "Frost", "damage", "spell-dps", "caster"],
["warlock", "Affliction", "damage", "spell-dps", "caster"],
["warlock", "Demonology", "damage", "spell-dps", "caster"],
["warlock", "Destruction", "damage", "spell-dps", "caster"],
["druid", "Balance", "damage", "spell-dps", "caster"],
["druid", "Feral (Bear)", "tank", "non-block-tank", "two-hand", "Feral Combat"],
["druid", "Feral (Cat)", "damage", "agility-dps", "two-hand", "Feral Combat"],
["druid", "Restoration", "healer", "healer", "caster"],
] as const;
/** CoA roles are authored explicitly. Support trees map to damage, the closest runtime role. */
const COA_SPECIALIZATIONS: readonly SpecTuple[] = [
["barbarian", "Brutality", "damage", "agility-dps", "two-hand"], ["barbarian", "Headhunting", "damage", "agility-dps", "ranged"], ["barbarian", "Ancestry", "damage", "agility-dps", "two-hand"],
["witch-doctor", "Shadowhunting", "damage", "ranged-dps", "ranged"], ["witch-doctor", "Voodoo", "damage", "spell-dps", "caster"], ["witch-doctor", "Brewing", "healer", "healer", "caster"],
["felsworn", "Slayer", "damage", "agility-dps", "dual-wield"], ["felsworn", "Infernal", "tank", "non-block-tank", "two-hand"], ["felsworn", "Tyrant", "damage", "spell-dps", "caster"],
["witch-hunter", "Boltslinger", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Houndmaster", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Inquisition", "damage", "agility-dps", "dual-wield"], ["witch-hunter", "Black Knight", "tank", "non-block-tank", "two-hand"],
["stormbringer", "Maelstrom", "damage", "spell-dps", "caster"], ["stormbringer", "Lightning", "damage", "spell-dps", "caster"], ["stormbringer", "Wind", "damage", "spell-dps", "caster"],
["knight-of-xoroth", "Hellfire", "damage", "strength-dps", "two-hand"], ["knight-of-xoroth", "Defiance", "tank", "block-tank", "weapon-shield"], ["knight-of-xoroth", "War", "damage", "strength-dps", "two-hand"],
["guardian", "Gladiator", "damage", "strength-dps", "one-hand"], ["guardian", "Inspiration", "damage", "strength-dps", "weapon-shield"], ["guardian", "Vanguard", "tank", "block-tank", "weapon-shield"],
["templar", "Zealot", "damage", "agility-dps", "dual-wield"], ["templar", "Oathkeeper", "tank", "non-block-tank", "two-hand"], ["templar", "Crusader", "damage", "agility-dps", "two-hand"],
["bloodmage", "Sanguine", "damage", "agility-dps", "dual-wield"], ["bloodmage", "Accursed", "damage", "spell-dps", "caster"], ["bloodmage", "Eternal", "tank", "non-block-tank", "two-hand"], ["bloodmage", "Fleshweaver", "healer", "healer", "caster"],
["ranger", "Archery", "damage", "ranged-dps", "ranged"], ["ranger", "Brigand", "damage", "agility-dps", "dual-wield"], ["ranger", "Farstrider", "damage", "ranged-dps", "ranged"],
["chronomancer", "Infinite", "damage", "spell-dps", "caster"], ["chronomancer", "Time", "healer", "healer", "caster"], ["chronomancer", "Artificer", "damage", "spell-dps", "caster"],
["necromancer", "Death", "damage", "spell-dps", "caster"], ["necromancer", "Rime", "damage", "spell-dps", "caster"], ["necromancer", "Animation", "damage", "spell-dps", "caster"],
["pyromancer", "Incineration", "damage", "spell-dps", "caster"], ["pyromancer", "Flameweaving", "damage", "spell-dps", "caster"], ["pyromancer", "Draconic", "damage", "spell-dps", "caster"],
["cultist", "Godblade", "damage", "strength-dps", "two-hand"], ["cultist", "Corruption", "damage", "spell-dps", "caster"], ["cultist", "Heretic", "healer", "healer", "caster"], ["cultist", "Dreadnought", "tank", "block-tank", "weapon-shield"],
["starcaller", "Moon Guard", "tank", "block-tank", "weapon-shield"], ["starcaller", "Sentinel", "damage", "ranged-dps", "ranged"], ["starcaller", "Moon Priest", "healer", "healer", "caster"], ["starcaller", "Warden", "damage", "strength-dps", "two-hand"],
["sun-cleric", "Piety", "damage", "spell-dps", "caster"], ["sun-cleric", "Blessings", "healer", "healer", "caster"], ["sun-cleric", "Valkyrie", "tank", "block-tank", "weapon-shield"], ["sun-cleric", "Seraphim", "damage", "spell-dps", "caster"],
["tinker", "Demolition", "damage", "ranged-dps", "ranged"], ["tinker", "Invention", "healer", "healer", "ranged"], ["tinker", "Mechanics", "damage", "ranged-dps", "ranged"],
["venomancer", "Venom", "damage", "spell-dps", "caster"], ["venomancer", "Stalking", "damage", "agility-dps", "dual-wield"], ["venomancer", "Fortitude", "tank", "non-block-tank", "two-hand"], ["venomancer", "Vizier", "healer", "healer", "caster"],
["reaper", "Harvest", "damage", "agility-dps", "two-hand"], ["reaper", "Soul", "damage", "spell-dps", "caster"], ["reaper", "Domination", "tank", "non-block-tank", "two-hand"],
["primalist", "Primal", "damage", "agility-dps", "two-hand"], ["primalist", "Geomancy", "damage", "spell-dps", "caster"], ["primalist", "Life", "healer", "healer", "caster"], ["primalist", "Mountain King", "tank", "non-block-tank", "two-hand"],
["runemaster", "Runic", "damage", "spell-dps", "caster"], ["runemaster", "Arcane", "damage", "spell-dps", "caster"], ["runemaster", "Riftblade", "tank", "non-block-tank", "two-hand"],
] as const;
export const SPECIALIZATION_DEFINITIONS: readonly SpecializationDefinition[] = Object.freeze(
[...CLASSIC_SPECIALIZATIONS, ...COA_SPECIALIZATIONS].map(define),
);
const BY_ID = new Map(SPECIALIZATION_DEFINITIONS.map((definition) => [definition.id, definition]));
export function specializationsForClass(classId: ClassId): readonly SpecializationDefinition[] {
return SPECIALIZATION_DEFINITIONS.filter((definition) => definition.classId === classId);
}
export function specializationById(id: string | null | undefined): SpecializationDefinition | null {
return id ? BY_ID.get(id) ?? null : null;
}
export function normalizeSpecializationId(classId: ClassId, value: unknown): string | null {
if (typeof value !== "string") return null;
const definition = specializationById(value);
return definition?.classId === classId ? definition.id : null;
}
export function specializationScore(stats: ItemStats, definition: SpecializationDefinition): number {
let score = 0;
for (const [key, weight] of Object.entries(definition.statWeights)) {
const value = stats[key as NumericItemStatKey];
if (typeof value === "number" && Number.isFinite(value)) score += value * (weight ?? 0);
}
const melee = stats.meleeWeapon;
const ranged = stats.rangedWeapon;
const weapon = definition.weaponLayout === "ranged" ? ranged : melee;
if (weapon?.damageMax && weapon.speedMs) score += ((weapon.damageMin ?? 0) + weapon.damageMax) * 0.42;
return Math.round(score * 100) / 100;
}
/** RoM has no saved specialization; derive a complete gear profile from primary class and activity role. */
export function romGearProfile(classId: ClassId, role: PartyRole): SpecializationDefinition {
const definition = classById(classId);
const base = baseClassFor(classId);
const caster = ["priest", "mage", "warlock"].includes(base) || definition.primaryStat === "intellect" || definition.primaryStat === "spirit" || definition.primaryStat === "wisdom";
const gearArchetype: GearArchetype = role === "healer"
? "healer"
: role === "tank"
? (["warrior", "paladin"].includes(base) ? "block-tank" : "non-block-tank")
: caster ? "spell-dps" : base === "hunter" ? "ranged-dps" : definition.primaryStat === "strength" ? "strength-dps" : "agility-dps";
const weaponLayout: WeaponLayout = role === "tank" && gearArchetype === "block-tank"
? "weapon-shield"
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : "two-hand";
return Object.freeze({
id: `${classId}:role-${role}`,
classId,
displayName: `${definition.name} ${role}`,
role,
talentTree: "Primary class",
gearArchetype,
weaponLayout,
statWeights: WEIGHTS[gearArchetype],
});
}
export function gearProfileFor(
classId: ClassId,
specializationId: string | null | undefined,
fallbackRole: PartyRole,
): SpecializationDefinition {
return specializationById(normalizeSpecializationId(classId, specializationId))
?? romGearProfile(classId, fallbackRole);
}