Release Healer Man 0.1.15
This commit is contained in:
1 parent
7b41b102fa
commit
f49e54f35f
43 files changed
+1478
-112
No files matched your search
@@ -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,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() } : {}),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!)),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
)),
|
||||
})),
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user