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

+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