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

+31
View File
@@ -1654,3 +1654,34 @@ Vite's existing large-chunk advisory remains informational.
corrected production build completed with 923 modules transformed. KTX2 and
environment-performance audits remained green, and the release-candidate
check passed.
# 2026-09-05 — specialization-aware heirlooms and endgame loot
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
Keeping talents returned to the main menu with the specialization saved.
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
Healer, matching the saved Holy specialization; no Tank or DPS override was
available.
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
player body, staff, and all four AI party bodies/equipment silhouettes were
visible and correctly anchored without obscuring the HUD or playfield.
- Opened Inventory & Equipment in the dungeon. The player had zero earned
items but every applicable slot resolved to a level-10 Priest healer
heirloom and reported average item level 10. Morvane, the Blood Death Knight
companion, showed a complete tank heirloom loadout with the intended
two-handed weapon layout and empty conflicting slots.
- Browser logs contained no uncaught application errors. Only the existing
third-party initialization deprecation warning appeared.
- Automated coverage validates every classic and CoA specialization, Feral
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
availability for every gear archetype, two-hand replacement scoring,
fallback reappearance, percentage-preserving stat refreshes, specialization
persistence and talent reset behavior, and the item-level-255 save limit.
- Verification: the complete Vitest suite passed all 928 cases across 171
files, including all 43 avatar cases. All 15 server persistence/API tests
passed. The production build completed with 925 modules transformed, KTX2
and environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
+17 -6
View File
@@ -103,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
for a future loot-browser screen.
Every Wailing Caverns boss rolls one item when it dies. The item instance
captures the player's level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. The SQL loot source
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
weapon speed, regeneration, and spell-power values for each item. A scaled
drop preserves that original stat distribution instead of replacing it with a
generic item-power budget.
captures its item level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. Drops use character
level through 79; level-80 drops begin the first endgame tier at item level 85.
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
block, weapon damage, weapon speed, regeneration, and spell-power values for
each item. A scaled drop preserves that original stat distribution instead of
replacing it with a generic item-power budget.
Every WoW and Conquest of Azeroth character has a saved specialization that
locks its activity role and selects a complete specialization-aware heirloom
loadout. Runes of Magic derives the same profile from its active primary class
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
rescale through item level 79 for players and 80 for AI party members, never
enter inventory, and automatically return when earned equipment is removed.
During leveling their score is normalized above compatible same-level catalog
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
the equipment and loot interfaces highlight without equipping automatically.
The equipment runtime uses the Wrath primary attributes and itemizable combat
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.14",
"version": "0.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.14",
"version": "0.1.15",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.14",
"version": "0.1.15",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.14",
"versionCode": 1014
"versionName": "0.1.15",
"versionCode": 1015
}
+3
View File
@@ -488,6 +488,9 @@ export function openGameDatabase(options = {}) {
id,
name,
classId,
specializationId: typeof character.specializationId === "string"
? character.specializationId.slice(0, 100)
: null,
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
raceId: String(character.raceId ?? "human").slice(0, 50),
gender: character.gender === "female" ? "female" : "male",
+3 -2
View File
@@ -169,14 +169,15 @@ test("launches players-only and AI-filled group activities only when every membe
database.respondToOnlineGroupInvite(member.id, invite.id, true);
database.updateOnlineGroupMember(leader.id, {
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
}, "tank");
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
database.updateOnlineGroupMember(member.id, {
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
}, "healer");
const playersOnly = database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
+34 -1
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings,
type StorageLike,
} from "./accountRepository";
@@ -29,6 +30,7 @@ import {
recordManastormFailure,
setManastormLoadout,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
}
describe("local profile repository", () => {
it("persists specializations and supports Class Hall keep/reset choices", () => {
const storage = new MemoryStorage();
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
const created = createCharacter({
ownerId: "offline-roster",
name: "Tempest",
categoryId: "coa",
raceId: "human",
classId: "stormbringer",
specializationId: "stormbringer:lightning",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
updateCharacterProgression("offline-roster", created.id, {
level: 20,
experience: 0,
talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 },
}, storage);
updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage);
const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!;
expect(kept.specializationId).toBe("stormbringer:wind");
expect(kept.talentRanks).not.toEqual({});
expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id);
const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!;
expect(reset.specializationId).toBe("stormbringer:maelstrom");
expect(reset.talentRanks).toEqual({});
expect(reset.actionBindings["primary:face-top"]).toBeNull();
});
it("creates and verifies a local account without storing its raw password", async () => {
const storage = new MemoryStorage();
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
}
const legacyV3Storage = new MemoryStorage();
+66 -6
View File
@@ -22,6 +22,11 @@ import {
normalizeManastormProgress,
type ManastormProgress,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import {
normalizeSpecializationId,
specializationById,
} from "../game/specializationCatalog";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -51,7 +56,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 3;
version: 4;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
settings?: unknown;
secondaryClassId?: unknown;
actionLoadouts?: unknown;
specializationId?: unknown;
};
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
@@ -138,6 +144,9 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
: {};
const categoryId = contentCategoryForClass(value.classId);
const specializationId = categoryId === "rom"
? null
: normalizeSpecializationId(value.classId, legacy.specializationId);
const secondaryClassId = categoryId === "rom"
&& isRomClassId(value.classId)
&& typeof legacy.secondaryClassId === "string"
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
talentRanks,
actionBindings,
categoryId,
specializationId,
secondaryClassId,
actionLoadouts,
inventory,
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
rosters?: unknown;
presentationSettingsByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -226,8 +236,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized];
}),
);
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -421,7 +431,9 @@ export function replaceCharactersForOwner(
}
export function createCharacter(
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
specializationId?: string | null;
},
storage: StorageLike | null = browserLocalStorage(),
): CharacterResult {
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
@@ -491,6 +503,54 @@ export function updateCharacterProgression(
return updated;
}
export function updateCharacterSpecialization(
ownerId: string,
characterId: string,
specializationId: string,
resetTalents: boolean,
storage: StorageLike | null = browserLocalStorage(),
): CharacterResult {
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
const database = readDatabase(storage);
let result: CharacterResult = { ok: false, error: "That character is no longer available." };
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
if (character.categoryId === "rom") {
result = { ok: false, error: "RoM gear follows the active primary class and activity role." };
return character;
}
const specialization = specializationById(specializationId);
if (!specialization || specialization.classId !== character.classId) {
result = { ok: false, error: "That specialization is not available to this class." };
return character;
}
const talentAbilityIds = resetTalents
? new Set(abilitiesForClass(character.classId)
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
.map((ability) => ability.id))
: new Set<string>();
const actionBindings = resetTalents
? Object.fromEntries(Object.entries(character.actionBindings)
.map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId]))
: character.actionBindings;
const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId);
const updated = normalizeCharacterProfile({
...character,
specializationId: specialization.id,
talentRanks: resetTalents ? {} : character.talentRanks,
actionBindings,
actionLoadouts: {
...(character.actionLoadouts ?? {}),
[loadoutKey]: { ...actionBindings },
},
});
result = { ok: true, character: updated };
return updated;
});
if (result.ok) writeDatabase(database, storage);
return result;
}
export function updateCharacterActionBindings(
ownerId: string,
characterId: string,
+11 -2
View File
@@ -10,6 +10,7 @@ import {
import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
useShellStore.setState({ notice: "That character is no longer available." });
return false;
}
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
const savedSpecialization = specializationById(activeCharacter.specializationId);
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
return false;
}
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
return false;
}
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
@@ -56,4 +66,3 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
});
return true;
}
+11 -1
View File
@@ -30,6 +30,7 @@ import {
type ManastormPartySize,
} from "../game/manastormProgress";
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
import { specializationById } from "../game/specializationCatalog";
import {
manastormActiveSpellLoadoutModel,
validateManastormActiveSpellLoadout,
@@ -63,7 +64,16 @@ function enterManastorm(
useShellStore.setState({ notice: "That character is no longer available." });
return false;
}
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
const savedSpecialization = specializationById(activeCharacter.specializationId);
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
return false;
}
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
return false;
}
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
+1
View File
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
readonly id: string;
readonly name: string;
readonly classId: string;
readonly specializationId?: string | null;
readonly categoryId: string;
readonly raceId: string;
readonly gender: "female" | "male";
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+14 -4
View File
@@ -30,6 +30,7 @@ const character = {
name: "Remedy",
raceId: "human" as const,
classId: "priest" as const,
specializationId: "priest:holy",
gender: "female" as const,
appearance: createDefaultAppearance(),
level: 1,
@@ -100,6 +101,15 @@ describe("shell store", () => {
expect(useShellStore.getState().notice).toMatch(/select or create/i);
});
it("routes a legacy WoW character through the mandatory specialization picker", () => {
prepareSelectedCharacter();
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
expect(useShellStore.getState().openMainMenu()).toBe(true);
expect(useShellStore.getState().phase).toBe("class-hall");
expect(useShellStore.getState().openDungeons()).toBe(false);
expect(useShellStore.getState().notice).toMatch(/choose a specialization/i);
});
it("opens the main menu and dungeon list without resetting gameplay", () => {
prepareSelectedCharacter();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
@@ -124,11 +134,11 @@ describe("shell store", () => {
useShellStore.getState().openDungeons();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({
phase: "game",
activeCharacter: character,
activeDungeonRole: "damage",
activeDungeonRole: "healer",
});
expect(useGameStore.getState()).toMatchObject({
paused: false,
@@ -142,7 +152,7 @@ describe("shell store", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
expect(useShellStore.getState().phase).toBe("characters");
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
});
it("rejects an unknown dungeon without resetting or leaving the list", () => {
@@ -234,7 +244,7 @@ describe("shell store", () => {
expect(useShellStore.getState().openManastorms()).toBe(true);
expect(useShellStore.getState().phase).toBe("manastorms");
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({
+64 -3
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings as persistCombatPresentationSettings,
swapRomClasses,
} from "./accountRepository";
@@ -55,6 +56,11 @@ import {
} from "./onlineAccountClient";
import { isGameMasterSession } from "../game/manastormAdminConfig";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export interface ShellState {
phase: ShellPhase;
@@ -82,6 +88,7 @@ export interface ShellState {
openManastorms: () => boolean;
openGmAdmin: () => boolean;
openClassHall: () => boolean;
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
swapSelectedRomClasses: () => boolean;
returnToMainMenu: () => void;
@@ -110,11 +117,13 @@ export interface ShellState {
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
return {
name: "",
categoryId,
raceId,
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
classId,
specializationId: specializationsForClass(classId)[0]?.id ?? null,
gender: "female",
appearance: createDefaultAppearance(),
rotation: -12,
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id;
}
next.specializationId = next.categoryId === "rom"
? null
: normalizeSpecializationId(next.classId, next.specializationId)
?? specializationsForClass(next.classId)[0]?.id
?? null;
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
return { draft: next, notice: "" };
}),
@@ -233,12 +247,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: "That gender model is not available for the selected race." });
return false;
}
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
set({ notice: "Choose a specialization before creating this character." });
return false;
}
const result = persistCharacter({
ownerId: session.ownerId,
name: normalizeCharacterName(draft.name),
categoryId: draft.categoryId,
raceId: draft.raceId,
classId: draft.classId,
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
});
@@ -278,6 +297,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: "That character is no longer available." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true;
}
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
@@ -287,6 +311,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
@@ -296,6 +325,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "manastorms", activeCharacter: null, notice: "" });
return true;
},
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
openClassHall: () => {
const { session, selectedCharacterId, characters } = get();
const character = characters.find((entry) => entry.id === selectedCharacterId);
if (!session || !character || character.categoryId !== "rom") {
set({ notice: "The Class Hall is available to Runes of Magic characters." });
if (!session || !character) {
set({ notice: "Select a character before opening the Class Hall." });
return false;
}
set({ phase: "class-hall", activeCharacter: null, notice: "" });
return true;
},
changeSelectedSpecialization: (specializationId, resetTalents) => {
const { session, selectedCharacterId, phase } = get();
if (!session || !selectedCharacterId || phase !== "class-hall") {
set({ notice: "Specializations may only be changed inside the Class Hall." });
return false;
}
const definition = specializationById(specializationId);
if (!definition) {
set({ notice: "That specialization is unavailable." });
return false;
}
const result = updateCharacterSpecialization(
session.ownerId,
selectedCharacterId,
definition.id,
resetTalents,
);
if (!result.ok || !result.character) {
set({ notice: result.error ?? "The specialization could not be changed." });
return false;
}
set((state) => ({
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
}));
return true;
},
chooseRomSecondaryClass: (classId) => {
const { session, selectedCharacterId } = get();
if (!session || !selectedCharacterId) return false;
+3
View File
@@ -24,6 +24,8 @@ export interface CharacterProfile {
name: string;
raceId: RaceId;
classId: ClassId;
/** Stable specialization registry ID. Normalized profiles always include it; optional for legacy callers. */
specializationId?: string | null;
/** Always present after repository normalization; optional for v1 callers. */
categoryId?: ContentCategoryId;
/** RoM's inactive class. The active primary remains `classId`. */
@@ -51,6 +53,7 @@ export interface CharacterDraft {
categoryId: ContentCategoryId;
raceId: RaceId;
classId: ClassId;
specializationId: string | null;
gender: GenderId;
appearance: CharacterAppearance;
rotation: number;
+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);
}
+1
View File
@@ -108,6 +108,7 @@ describe("dual-screen party commands", () => {
name: "Remedy",
raceId: "human" as const,
classId: "priest" as const,
specializationId: "priest:holy",
gender: "female" as const,
appearance: createDefaultAppearance(),
level: 5,
+12 -5
View File
@@ -19,7 +19,7 @@ import { classById, raceById, type GenderId, type RaceId } from "../app/characte
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { useCombatStore } from "../game/combatStore";
import { equipmentItemsForOwner } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
@@ -298,12 +298,19 @@ function PartyActors({
const equipment = useCombatStore((state) => state.equipment);
const equipmentItemsByOwner = useMemo(
() => new Map(
Object.keys(equipment).map((ownerId) => [
ownerId,
equipmentItemsForOwner(equipment, inventory, ownerId),
members.map((member) => [
member.id,
resolvedEquipmentItems(equipment, inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
}),
]),
),
[equipment, inventory],
[equipment, inventory, members],
);
const resetRevision = useGameStore((state) => state.resetRevision);
const { rapier, world } = useRapier();
+15 -5
View File
@@ -22,9 +22,9 @@ import {
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
equipmentItemsForOwner,
PLAYER_EQUIPMENT_OWNER_ID,
} from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { InventoryItem } from "../game/lootTypes";
import {
cameraRelativeMovement,
@@ -460,13 +460,23 @@ export function PlayerRig() {
const animationEvent = useCombatStore((state) => state.playerAnimationEvent);
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const gearRole = useCombatStore((state) => state.gearRole);
const equipmentItems = useMemo(
() => equipmentItemsForOwner(
() => activeCharacter ? resolvedEquipmentItems(
equipment,
inventory,
PLAYER_EQUIPMENT_OWNER_ID,
),
[equipment, inventory],
{
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
specializationId,
role: gearRole,
level,
},
) : [],
[activeCharacter, equipment, gearRole, inventory, level, specializationId],
);
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
+31
View File
@@ -2705,6 +2705,37 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.equipment-quality--rare { --item-quality: #62a7e8; }
.equipment-quality--epic { --item-quality: #aa76e8; }
.equipment-quality--legendary { --item-quality: #e9a240; }
.equipment-quality--heirloom { --item-quality: #d9b45b; }
.creator-specializations {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
margin-top: 0.65rem;
}
.creator-specializations > strong {
grid-column: 1 / -1;
color: var(--front-muted);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.creator-specializations button {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.45rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: inherit;
}
.creator-specializations button.is-active {
border-color: var(--front-accent);
background: rgba(218, 179, 92, 0.12);
}
@media (max-width: 820px) {
.equipment-panel__owners { grid-template-columns: repeat(5, minmax(70px, 1fr)); overflow-x: auto; }
+31 -4
View File
@@ -1,5 +1,11 @@
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
import {
compareEquipmentUpgrade,
resolvedEquipmentLoadout,
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
function itemType(inventorySlot: string): string {
return inventorySlot
@@ -11,8 +17,25 @@ function itemType(inventorySlot: string): string {
export function BossLootWindow() {
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null);
const classId = useCombatStore((state) => state.classId);
const specializationId = useCombatStore((state) => state.specializationId);
const role = useCombatStore((state) => state.gearRole);
const level = useCombatStore((state) => state.level);
const equipment = useCombatStore((state) => state.equipment);
const inventory = useCombatStore((state) => state.inventory);
if (!reward) return null;
const profile = gearProfileFor(classId, specializationId, role);
const heirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player" as const,
classId,
specializationId,
role,
level,
};
const loadout = resolvedEquipmentLoadout(equipment, inventory, heirloomContext);
const dismiss = () => useCombatStore.getState().dismissBossLootReward(reward.id);
const viewInventory = () => {
dismiss();
@@ -39,8 +62,11 @@ export function BossLootWindow() {
<div className="boss-loot-window__body">
<p className="boss-loot-window__eyebrow">Loot acquired automatically</p>
<div className="boss-loot-window__items">
{reward.items.map((item) => (
<article
{reward.items.map((item) => {
const comparison = classCanEquipItem(classId, item)
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
: null;
return <article
key={item.instanceId}
className={`boss-loot-window__item equipment-quality--${item.quality}`}
>
@@ -48,10 +74,11 @@ export function BossLootWindow() {
<p>
<strong>{item.name}</strong>
<small>{item.quality} · Item level {item.itemLevel} · {itemType(item.inventorySlot)}</small>
{comparison && <small>{comparison.isUpgrade ? `Upgrade · +${comparison.scoreDelta.toFixed(1)} score` : `${comparison.scoreDelta.toFixed(1)} score`}</small>}
</p>
{item.quantity > 1 ? <em>×{item.quantity}</em> : null}
</article>
))}
</article>;
})}
{!reward.items.length ? (
<p className="boss-loot-window__empty">No items dropped this time.</p>
) : null}
+24 -1
View File
@@ -21,6 +21,8 @@ import { CharacterPreview } from "./CharacterPreview";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
import { specializationsForClass } from "../game/specializationCatalog";
export function CharacterCreateScreen() {
const draft = useShellStore((state) => state.draft);
@@ -40,6 +42,7 @@ export function CharacterCreateScreen() {
);
const appearanceLabels = race.labels[draft.gender];
const appearanceCounts = race.counts[draft.gender];
const specializationOptions = useMemo(() => specializationsForClass(draft.classId), [draft.classId]);
const chooseRace = (raceId: RaceId) => {
const nextClasses = classesForRace(raceId, draft.categoryId);
@@ -69,6 +72,7 @@ export function CharacterCreateScreen() {
run: () => chooseGender(gender),
})),
...availableClasses.map((definition) => ({ id: `class-${definition.id}`, run: () => updateDraft({ classId: definition.id }) })),
...specializationOptions.map((definition) => ({ id: `spec-${definition.id}`, run: () => updateDraft({ specializationId: definition.id }) })),
{ id: "rotate-left", run: () => updateDraft({ rotation: draft.rotation - 12 }) },
{ id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) },
{ id: "creator-back", run: cancel },
@@ -82,7 +86,7 @@ export function CharacterCreateScreen() {
{ id: "randomize", run: randomize },
{ id: "appearance-back", run: () => setStep("identity") },
{ id: "create-character", run: createCharacter },
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, updateDraft]);
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, specializationOptions, updateDraft]);
const controller = useMenuController(actions, {
columns: draft.step === "identity" ? 5 : 2,
initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name",
@@ -180,6 +184,25 @@ export function CharacterCreateScreen() {
<span>{characterClass.description}</span>
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
</div>
{specializationOptions.length > 0 && (
<div className="creator-specializations" aria-label="Specialization">
<strong>Specialization</strong>
{specializationOptions.map((definition) => (
<ControllerButton
key={definition.id}
controlId={`spec-${definition.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={draft.specializationId === definition.id ? "is-active" : ""}
type="button"
onClick={() => updateDraft({ specializationId: definition.id })}
>
<span>{definition.displayName}</span>
<small>{PARTY_ROLE_LABELS[definition.role]} / {definition.talentTree}</small>
</ControllerButton>
))}
</div>
)}
</section>
<footer className="creator-footer">
<ControllerButton controlId="creator-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={cancel}>Back to roster</ControllerButton>
+92 -1
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { classById, raceById, type RomClassId } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import {
ROM_ELITE_SKILL_LEVELS,
ROM_SECONDARY_UNLOCK_LEVEL,
@@ -13,9 +14,100 @@ import { CharacterPreview } from "./CharacterPreview";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
import {
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export function ClassHallScreen() {
const character = useShellStore(selectedCharacter);
if (!character) return null;
return character.categoryId === "rom"
? <RomClassHall character={character} />
: <SpecializationClassHall character={character} />;
}
function SpecializationClassHall({ character }: { readonly character: CharacterProfile }) {
const notice = useShellStore((state) => state.notice);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const changeSpecialization = useShellStore((state) => state.changeSelectedSpecialization);
const [pendingSpecializationId, setPendingSpecializationId] = useState<string | null>(null);
const options = useMemo(() => specializationsForClass(character.classId), [character.classId]);
const current = specializationById(character.specializationId);
const forced = current === null;
const goBack = forced ? returnToCharacters : returnToMainMenu;
const apply = (resetTalents: boolean) => {
if (!pendingSpecializationId || !changeSpecialization(pendingSpecializationId, resetTalents)) return;
setPendingSpecializationId(null);
if (forced) returnToMainMenu();
};
const actions = useMemo<MenuAction[]>(() => pendingSpecializationId ? [
{ id: "specialization-cancel", run: () => setPendingSpecializationId(null) },
{ id: "specialization-keep", run: () => apply(false) },
{ id: "specialization-reset", run: () => apply(true) },
] : [
...options.map((definition) => ({ id: `specialization-${definition.id}`, run: () => setPendingSpecializationId(definition.id) })),
{ id: "class-hall-back", run: goBack },
], [goBack, options, pendingSpecializationId]);
const controller = useMenuController(actions, {
initialId: current ? `specialization-${current.id}` : options[0] ? `specialization-${options[0].id}` : "class-hall-back",
onBack: pendingSpecializationId ? () => setPendingSpecializationId(null) : goBack,
});
const race = raceById(character.raceId);
const characterClass = classById(character.classId);
const pending = specializationById(pendingSpecializationId);
return (
<DualDisplayFrame
contextLabel="Specialization details"
top={
<FrontSurface className="class-hall-screen" ariaLabel="Class Hall specialization selection">
<header className="class-hall-header">
<BrandMark compact />
<div><span>{characterClass.name} / Class Hall</span><strong>{forced ? "Choose your specialization to continue." : "Change your combat path."}</strong></div>
<ControllerButton controlId="class-hall-back" selectedId={controller.selectedId} select={controller.select} type="button" onClick={goBack}>{forced ? "Roster" : "Back"}</ControllerButton>
</header>
<aside className="class-hall-character">
<CharacterPreview character={character} />
<div><p className="front-kicker">{race.name} / Level {character.level}</p><h1>{character.name}</h1><strong style={{ color: characterClass.color }}>{characterClass.name}</strong>{current && <small>{current.displayName} / {PARTY_ROLE_LABELS[current.role]}</small>}</div>
</aside>
<main className="class-hall-content">
<section className="secondary-class-choice">
<p className="front-kicker">Specialization and heirloom profile</p>
<h2>{forced ? "A specialization is required" : `Current: ${current.displayName}`}</h2>
<p>Your saved specialization locks your activity role and tunes every heirloom stat and weapon slot. Talent points do not change specialization automatically.</p>
<div>{options.map((definition) => (
<ControllerButton key={definition.id} controlId={`specialization-${definition.id}`} selectedId={controller.selectedId} select={controller.select} className={`front-button secondary-class-option ${current?.id === definition.id ? "is-active" : ""}`} type="button" onClick={() => setPendingSpecializationId(definition.id)}>
<b style={{ color: characterClass.color }}>{characterClass.sigil}</b>
<span><strong>{definition.displayName}</strong><small>{PARTY_ROLE_LABELS[definition.role]} / {definition.gearArchetype.replaceAll("-", " ")} / {definition.weaponLayout.replaceAll("-", " ")}</small></span>
</ControllerButton>
))}</div>
</section>
{notice && <p className="front-notice" role="status">{notice}</p>}
</main>
{pending && (
<div className="front-modal-backdrop"><section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="specialization-confirm-title">
<p className="front-kicker">Specialization change</p>
<h2 id="specialization-confirm-title">Become {pending.displayName}?</h2>
<p>Heirloom stats and your allowed activity role change immediately. Choose whether to preserve the current talent build and action bindings.</p>
<div>
<ControllerButton controlId="specialization-cancel" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setPendingSpecializationId(null)}>Cancel</ControllerButton>
<ControllerButton controlId="specialization-keep" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => apply(false)}>Keep talents</ControllerButton>
<ControllerButton controlId="specialization-reset" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => apply(true)}>Reset talents</ControllerButton>
</div>
</section></div>
)}
<ControllerLegend back />
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
/>
);
}
function RomClassHall({ character }: { readonly character: CharacterProfile }) {
const notice = useShellStore((state) => state.notice);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass);
@@ -38,7 +130,6 @@ export function ClassHallScreen() {
onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu,
});
if (!character) return null;
const race = raceById(character.raceId);
const primary = classById(character.classId);
const secondary = character.secondaryClassId ? classById(character.secondaryClassId) : null;
+5 -1
View File
@@ -25,6 +25,7 @@ import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
installDungeonShellRuntime();
@@ -66,7 +67,10 @@ export function DungeonSelectScreen() {
?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
const availableRoles = character ? partyRolesForClass(character.classId) : [];
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
: [];
const selectDungeon = (dungeonId: DungeonId) => {
setSelectedDungeonId(dungeonId);
setRolePickerOpen(false);
+71 -13
View File
@@ -6,7 +6,6 @@ import {
PLAYER_EQUIPMENT_OWNER_ID,
classCanEquipItem,
equippedOwnerId,
equipmentItemLevel,
equipmentSlotForItem,
type EquipmentSlot,
} from "../game/equipment";
@@ -22,6 +21,14 @@ import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import {
compareEquipmentUpgrade,
resolvedAverageItemLevel,
resolvedEquipmentLoadout,
type HeirloomContext,
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
import type { PartyRole } from "../game/partyRoles";
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
head: "Head",
@@ -46,6 +53,10 @@ interface EquipmentOwner {
readonly name: string;
readonly detail: string;
readonly classId: ClassId;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
readonly ownerKind: "player" | "party";
}
type StatPage = "attributes" | "offense" | "defense";
@@ -181,6 +192,8 @@ function EquipmentDialog() {
const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats);
const playerSpecializationId = useCombatStore((state) => state.specializationId);
const playerGearRole = useCombatStore((state) => state.gearRole);
const feedback = useCombatStore((state) => state.feedback);
const members = usePartyStore((state) => state.members);
const openOverlay = useGameStore((state) => state.openOverlay);
@@ -191,14 +204,22 @@ function EquipmentDialog() {
name: character?.name ?? "You",
detail: "Player",
classId: playerClassId,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
ownerKind: "player" as const,
},
...members.map((member) => ({
id: member.id,
name: member.name,
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
ownerKind: "party" as const,
})),
], [character?.name, members, playerClassId]);
], [character?.name, members, playerClassId, playerGearRole, playerLevel, playerSpecializationId]);
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
const [statPage, setStatPage] = useState<StatPage>("attributes");
@@ -214,9 +235,20 @@ function EquipmentDialog() {
const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0];
const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null;
const itemById = useMemo(() => new Map(inventory.map((item) => [item.instanceId, item])), [inventory]);
const ownerById = useMemo(() => new Map(owners.map((candidate) => [candidate.id, candidate])), [owners]);
const loadout = equipment[owner.id] ?? {};
const heirloomContext: HeirloomContext = {
ownerId: owner.id,
ownerKind: owner.ownerKind,
classId: owner.classId,
specializationId: owner.specializationId,
role: owner.role,
level: owner.level,
};
const loadout = useMemo(
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
[equipment, inventory, owner.classId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
);
const earnedLoadout = equipment[owner.id] ?? {};
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt
)), [inventory]);
@@ -230,7 +262,7 @@ function EquipmentDialog() {
id: `equipment-owner-${candidate.id}`,
run: () => setOwnerId(candidate.id),
})),
...EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [{
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
id: `equipment-slot-${slot}`,
run: () => useCombatStore.getState().unequipItem(owner.id, slot),
}] : []),
@@ -243,7 +275,7 @@ function EquipmentDialog() {
run: () => equip(item),
})),
{ id: "equipment-back", run: close },
], [loadout, owner.id, owners, sortedInventory]);
], [earnedLoadout, owner.id, owners, sortedInventory]);
const controller = useMenuController(actions, {
columns: 2,
initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`,
@@ -253,7 +285,11 @@ function EquipmentDialog() {
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false;
const gearLevel = equipmentItemLevel(equipment, inventory, owner.id);
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
const inspectedComparison = inspected && compatible
? compareEquipmentUpgrade(inspected, loadout, gearProfile, heirloomContext)
: null;
const selectedMember = members.find((member) => member.id === owner.id) ?? null;
const ownerLevel = selectedMember?.level ?? playerLevel;
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
@@ -274,7 +310,7 @@ function EquipmentDialog() {
<div className="equipment-panel__summary">
<strong>{inventory.length}</strong><span>Items</span>
<i aria-hidden="true" />
<strong>{gearLevel}</strong><span>Gear level</span>
<strong>{gearLevel}</strong><span>Average item level</span>
</div>
<ControllerButton
controlId="equipment-back"
@@ -304,7 +340,14 @@ function EquipmentDialog() {
>
<b>{definition.sigil}</b>
<span><strong>{candidate.name}</strong><small>{candidate.detail} · {definition.name}</small></span>
<em>{equipmentItemLevel(equipment, inventory, candidate.id)}</em>
<em>{resolvedAverageItemLevel(equipment, inventory, {
ownerId: candidate.id,
ownerKind: candidate.ownerKind,
classId: candidate.classId,
specializationId: candidate.specializationId,
role: candidate.role,
level: candidate.level,
})}</em>
</ControllerButton>
);
})}
@@ -339,7 +382,8 @@ function EquipmentDialog() {
</div>
<div className="equipment-loadout__slots">
{EQUIPMENT_SLOTS.map((slot) => {
const item = loadout[slot] ? itemById.get(loadout[slot]!) ?? null : null;
const item = loadout[slot] ?? null;
const earned = Boolean(earnedLoadout[slot]);
return item ? (
<ControllerButton
key={slot}
@@ -349,10 +393,11 @@ function EquipmentDialog() {
type="button"
className={`equipment-slot equipment-quality--${item.quality}`}
onFocus={() => setInspectedId(item.instanceId)}
onClick={() => useCombatStore.getState().unequipItem(owner.id, slot)}
disabled={!earned}
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
>
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
<em>Unequip</em>
<em>{earned ? "Unequip" : `Heirloom ${item.itemLevel}`}</em>
</ControllerButton>
) : (
<div key={slot} className="equipment-slot is-empty">
@@ -370,6 +415,7 @@ function EquipmentDialog() {
const assignedId = equippedOwnerId(equipment, item.instanceId);
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
const canEquip = classCanEquipItem(owner.classId, item);
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
return (
<ControllerButton
key={item.instanceId}
@@ -388,7 +434,7 @@ function EquipmentDialog() {
<small>Level {item.itemLevel} · {itemType(item)}</small>
<small className="equipment-item__stats">{itemStatSummary(item.stats)}</small>
</span>
<em>{assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
<em>{comparison?.isUpgrade ? `Upgrade +${displayNumber(comparison.scoreDelta, 1)}` : assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
</ControllerButton>
);
})}
@@ -409,6 +455,11 @@ function EquipmentDialog() {
<p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p>
<h3>{inspected.name}</h3>
<p>{itemType(inspected)}</p>
{inspectedComparison && (
<p className={inspectedComparison.isUpgrade ? "is-beneficial" : undefined}>
{inspectedComparison.isUpgrade ? "Upgrade" : "Not an upgrade"} · {inspectedComparison.scoreDelta >= 0 ? "+" : ""}{displayNumber(inspectedComparison.scoreDelta, 2)} specialization score
</p>
)}
<dl className="equipment-details__stats">
{inspectedStatRows.map((row) => (
<div key={row.label}>
@@ -417,6 +468,13 @@ function EquipmentDialog() {
</div>
))}
</dl>
{inspectedComparison && Object.keys(inspectedComparison.usefulStatDeltas).length > 0 && (
<dl className="equipment-details__stats">
{Object.entries(inspectedComparison.usefulStatDeltas).map(([key, value]) => (
<div key={key}><dt>{key.replace(/([A-Z])/g, " $1")}</dt><dd className={(value ?? 0) > 0 ? "is-beneficial" : undefined}>{(value ?? 0) > 0 ? "+" : ""}{value}</dd></div>
))}
</dl>
)}
<dl className="equipment-details__provenance">
<div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div>
<div><dt>Original level</dt><dd>{inspected.sourceItemLevel}</dd></div>
+5 -5
View File
@@ -99,9 +99,9 @@ export function MainMenuScreen() {
run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
},
...(isRom ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [isGm, isRom, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -173,7 +173,7 @@ export function MainMenuScreen() {
<em>Authorized</em>
</ControllerButton>
)}
{isRom && (
{character && (
<ControllerButton
controlId="mode-class-hall"
selectedId={controller.selectedId}
@@ -183,8 +183,8 @@ export function MainMenuScreen() {
onClick={openClassHall}
>
<b aria-hidden="true">CH</b>
<span><strong>Class Hall</strong><small>Choose a permanent partner class and swap your active ordering.</small></span>
<em>{character && character.level >= 10 ? "Open" : "Unlocks at 10"}</em>
<span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span>
<em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
</ControllerButton>
)}
{GAME_MODES.map((mode) => mode.available ? (
+5 -1
View File
@@ -35,6 +35,7 @@ import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
installManastormShellRuntime();
@@ -58,7 +59,10 @@ export function ManastormSelectScreen() {
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const adminConfig = useManastormAdminStore((state) => state.config);
const character = activeCharacter ?? rosterCharacter;
const availableRoles = character ? partyRolesForClass(character.classId) : [];
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
: [];
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
const groupHumanCount = onlineGroup?.members.length ?? 1;
const effectivePartySize = onlineGroup