Release Healer Man 0.1.15

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

No files matched your search

+31
View File
@@ -1654,3 +1654,34 @@ Vite's existing large-chunk advisory remains informational.
corrected production build completed with 923 modules transformed. KTX2 and corrected production build completed with 923 modules transformed. KTX2 and
environment-performance audits remained green, and the release-candidate environment-performance audits remained green, and the release-candidate
check passed. check passed.
# 2026-09-05 — specialization-aware heirlooms and endgame loot
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
Keeping talents returned to the main menu with the specialization saved.
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
Healer, matching the saved Holy specialization; no Tank or DPS override was
available.
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
player body, staff, and all four AI party bodies/equipment silhouettes were
visible and correctly anchored without obscuring the HUD or playfield.
- Opened Inventory & Equipment in the dungeon. The player had zero earned
items but every applicable slot resolved to a level-10 Priest healer
heirloom and reported average item level 10. Morvane, the Blood Death Knight
companion, showed a complete tank heirloom loadout with the intended
two-handed weapon layout and empty conflicting slots.
- Browser logs contained no uncaught application errors. Only the existing
third-party initialization deprecation warning appeared.
- Automated coverage validates every classic and CoA specialization, Feral
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
availability for every gear archetype, two-hand replacement scoring,
fallback reappearance, percentage-preserving stat refreshes, specialization
persistence and talent reset behavior, and the item-level-255 save limit.
- Verification: the complete Vitest suite passed all 928 cases across 171
files, including all 43 avatar cases. All 15 server persistence/API tests
passed. The production build completed with 925 modules transformed, KTX2
and environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
+17 -6
View File
@@ -103,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
for a future loot-browser screen. for a future loot-browser screen.
Every Wailing Caverns boss rolls one item when it dies. The item instance Every Wailing Caverns boss rolls one item when it dies. The item instance
captures the player's level at the killing blow, remains on the corpse until captures its item level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. The SQL loot source looted, and is then saved in that character's inventory. Drops use character
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage, level through 79; level-80 drops begin the first endgame tier at item level 85.
weapon speed, regeneration, and spell-power values for each item. A scaled The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
drop preserves that original stat distribution instead of replacing it with a block, weapon damage, weapon speed, regeneration, and spell-power values for
generic item-power budget. each item. A scaled drop preserves that original stat distribution instead of
replacing it with a generic item-power budget.
Every WoW and Conquest of Azeroth character has a saved specialization that
locks its activity role and selects a complete specialization-aware heirloom
loadout. Runes of Magic derives the same profile from its active primary class
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
rescale through item level 79 for players and 80 for AI party members, never
enter inventory, and automatically return when earned equipment is removed.
During leveling their score is normalized above compatible same-level catalog
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
the equipment and loot interfaces highlight without equipping automatically.
The equipment runtime uses the Wrath primary attributes and itemizable combat The equipment runtime uses the Wrath primary attributes and itemizable combat
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.14", "version": "0.1.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.14", "version": "0.1.15",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.14", "version": "0.1.15",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.14", "versionName": "0.1.15",
"versionCode": 1014 "versionCode": 1015
} }
+3
View File
@@ -488,6 +488,9 @@ export function openGameDatabase(options = {}) {
id, id,
name, name,
classId, classId,
specializationId: typeof character.specializationId === "string"
? character.specializationId.slice(0, 100)
: null,
categoryId: String(character.categoryId ?? "wow").slice(0, 20), categoryId: String(character.categoryId ?? "wow").slice(0, 20),
raceId: String(character.raceId ?? "human").slice(0, 50), raceId: String(character.raceId ?? "human").slice(0, 50),
gender: character.gender === "female" ? "female" : "male", gender: character.gender === "female" ? "female" : "male",
+3 -2
View File
@@ -169,14 +169,15 @@ test("launches players-only and AI-filled group activities only when every membe
database.respondToOnlineGroupInvite(member.id, invite.id, true); database.respondToOnlineGroupInvite(member.id, invite.id, true);
database.updateOnlineGroupMember(leader.id, { database.updateOnlineGroupMember(leader.id, {
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20, id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
}, "tank"); }, "tank");
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
assert.throws(() => database.startOnlineGroupActivity(leader.id, { assert.throws(() => database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5, type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready"); }), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
database.updateOnlineGroupMember(member.id, { database.updateOnlineGroupMember(member.id, {
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20, id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
}, "healer"); }, "healer");
const playersOnly = database.startOnlineGroupActivity(leader.id, { const playersOnly = database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5, type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
+34 -1
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterProgression, updateCharacterProgression,
updateCharacterSettings, updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings, updateCombatPresentationSettings,
type StorageLike, type StorageLike,
} from "./accountRepository"; } from "./accountRepository";
@@ -29,6 +30,7 @@ import {
recordManastormFailure, recordManastormFailure,
setManastormLoadout, setManastormLoadout,
} from "../game/manastormProgress"; } from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
class MemoryStorage implements StorageLike { class MemoryStorage implements StorageLike {
private values = new Map<string, string>(); private values = new Map<string, string>();
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
} }
describe("local profile repository", () => { 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 () => { it("creates and verifies a local account without storing its raw password", async () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage); const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
})); }));
expect(loadCombatPresentationSettings("offline-roster", storage)) expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); .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(); const legacyV3Storage = new MemoryStorage();
+66 -6
View File
@@ -22,6 +22,11 @@ import {
normalizeManastormProgress, normalizeManastormProgress,
type ManastormProgress, type ManastormProgress,
} from "../game/manastormProgress"; } from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import {
normalizeSpecializationId,
specializationById,
} from "../game/specializationCatalog";
const DATABASE_KEY = "healer-man.profile-database.v1"; const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1"; const SESSION_KEY = "healer-man.session.v1";
@@ -51,7 +56,7 @@ interface LocalAccount {
} }
interface ProfileDatabase { interface ProfileDatabase {
version: 3; version: 4;
accounts: LocalAccount[]; accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>; rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>; presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
} }
function emptyDatabase(): ProfileDatabase { function emptyDatabase(): ProfileDatabase {
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} }; return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
} }
function isCharacterProfile(value: unknown): value is CharacterProfile { function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
settings?: unknown; settings?: unknown;
secondaryClassId?: unknown; secondaryClassId?: unknown;
actionLoadouts?: unknown; actionLoadouts?: unknown;
specializationId?: unknown;
}; };
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level) const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
? Math.max(1, Math.min(80, Math.floor(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])) .map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
: {}; : {};
const categoryId = contentCategoryForClass(value.classId); const categoryId = contentCategoryForClass(value.classId);
const specializationId = categoryId === "rom"
? null
: normalizeSpecializationId(value.classId, legacy.specializationId);
const secondaryClassId = categoryId === "rom" const secondaryClassId = categoryId === "rom"
&& isRomClassId(value.classId) && isRomClassId(value.classId)
&& typeof legacy.secondaryClassId === "string" && typeof legacy.secondaryClassId === "string"
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
talentRanks, talentRanks,
actionBindings, actionBindings,
categoryId, categoryId,
specializationId,
secondaryClassId, secondaryClassId,
actionLoadouts, actionLoadouts,
inventory, inventory,
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
rosters?: unknown; rosters?: unknown;
presentationSettingsByOwner?: unknown; presentationSettingsByOwner?: unknown;
}) | null; }) | 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(); return emptyDatabase();
} }
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean( const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -226,8 +236,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized]; return [ownerId, normalized];
}), }),
); );
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner }; const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage); if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
return database; return database;
} catch { } catch {
return emptyDatabase(); return emptyDatabase();
@@ -421,7 +431,9 @@ export function replaceCharactersForOwner(
} }
export function createCharacter( 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(), storage: StorageLike | null = browserLocalStorage(),
): CharacterResult { ): CharacterResult {
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
@@ -491,6 +503,54 @@ export function updateCharacterProgression(
return updated; 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( export function updateCharacterActionBindings(
ownerId: string, ownerId: string,
characterId: string, characterId: string,
+11 -2
View File
@@ -10,6 +10,7 @@ import {
import { useShellStore } from "./shellStore"; import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge"; import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository"; import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
export function installDungeonShellRuntime(): void { export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon }); registerDungeonShellRuntime({ enterDungeon });
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
useShellStore.setState({ notice: "That character is no longer available." }); useShellStore.setState({ notice: "That character is no longer available." });
return false; 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)) { if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({ useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`, 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; return true;
} }
+11 -1
View File
@@ -30,6 +30,7 @@ import {
type ManastormPartySize, type ManastormPartySize,
} from "../game/manastormProgress"; } from "../game/manastormProgress";
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader"; import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
import { specializationById } from "../game/specializationCatalog";
import { import {
manastormActiveSpellLoadoutModel, manastormActiveSpellLoadoutModel,
validateManastormActiveSpellLoadout, validateManastormActiveSpellLoadout,
@@ -63,7 +64,16 @@ function enterManastorm(
useShellStore.setState({ notice: "That character is no longer available." }); useShellStore.setState({ notice: "That character is no longer available." });
return false; 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)) { if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({ useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`, 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 id: string;
readonly name: string; readonly name: string;
readonly classId: string; readonly classId: string;
readonly specializationId?: string | null;
readonly categoryId: string; readonly categoryId: string;
readonly raceId: string; readonly raceId: string;
readonly gender: "female" | "male"; 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" }, "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", () => { 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", name: "Remedy",
raceId: "human" as const, raceId: "human" as const,
classId: "priest" as const, classId: "priest" as const,
specializationId: "priest:holy",
gender: "female" as const, gender: "female" as const,
appearance: createDefaultAppearance(), appearance: createDefaultAppearance(),
level: 1, level: 1,
@@ -100,6 +101,15 @@ describe("shell store", () => {
expect(useShellStore.getState().notice).toMatch(/select or create/i); 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", () => { it("opens the main menu and dungeon list without resetting gameplay", () => {
prepareSelectedCharacter(); prepareSelectedCharacter();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 }); useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
@@ -124,11 +134,11 @@ describe("shell store", () => {
useShellStore.getState().openDungeons(); useShellStore.getState().openDungeons();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 }); 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({ expect(useShellStore.getState()).toMatchObject({
phase: "game", phase: "game",
activeCharacter: character, activeCharacter: character,
activeDungeonRole: "damage", activeDungeonRole: "healer",
}); });
expect(useGameStore.getState()).toMatchObject({ expect(useGameStore.getState()).toMatchObject({
paused: false, paused: false,
@@ -142,7 +152,7 @@ describe("shell store", () => {
prepareSelectedCharacter(); prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false); expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
expect(useShellStore.getState().phase).toBe("characters"); 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", () => { 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().openManastorms()).toBe(true);
expect(useShellStore.getState().phase).toBe("manastorms"); expect(useShellStore.getState().phase).toBe("manastorms");
expect(useShellStore.getState().enterManastorm("tank")).toBe(false); 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().enterManastorm("healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({ expect(useShellStore.getState()).toMatchObject({
+64 -3
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterProgression, updateCharacterProgression,
updateCharacterSettings, updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings as persistCombatPresentationSettings, updateCombatPresentationSettings as persistCombatPresentationSettings,
swapRomClasses, swapRomClasses,
} from "./accountRepository"; } from "./accountRepository";
@@ -55,6 +56,11 @@ import {
} from "./onlineAccountClient"; } from "./onlineAccountClient";
import { isGameMasterSession } from "../game/manastormAdminConfig"; import { isGameMasterSession } from "../game/manastormAdminConfig";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export interface ShellState { export interface ShellState {
phase: ShellPhase; phase: ShellPhase;
@@ -82,6 +88,7 @@ export interface ShellState {
openManastorms: () => boolean; openManastorms: () => boolean;
openGmAdmin: () => boolean; openGmAdmin: () => boolean;
openClassHall: () => boolean; openClassHall: () => boolean;
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
chooseRomSecondaryClass: (classId: RomClassId) => boolean; chooseRomSecondaryClass: (classId: RomClassId) => boolean;
swapSelectedRomClasses: () => boolean; swapSelectedRomClasses: () => boolean;
returnToMainMenu: () => void; returnToMainMenu: () => void;
@@ -110,11 +117,13 @@ export interface ShellState {
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft { function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human"; const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
return { return {
name: "", name: "",
categoryId, categoryId,
raceId, raceId,
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest", classId,
specializationId: specializationsForClass(classId)[0]?.id ?? null,
gender: "female", gender: "female",
appearance: createDefaultAppearance(), appearance: createDefaultAppearance(),
rotation: -12, rotation: -12,
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
if (!availableClasses.some((definition) => definition.id === next.classId)) { if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id; 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); next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
return { draft: next, notice: "" }; 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." }); set({ notice: "That gender model is not available for the selected race." });
return false; 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({ const result = persistCharacter({
ownerId: session.ownerId, ownerId: session.ownerId,
name: normalizeCharacterName(draft.name), name: normalizeCharacterName(draft.name),
categoryId: draft.categoryId, categoryId: draft.categoryId,
raceId: draft.raceId, raceId: draft.raceId,
classId: draft.classId, classId: draft.classId,
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender, gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance), 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." }); set({ notice: "That character is no longer available." });
return false; 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: "" }); set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true; 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." }); set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false; 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: "" }); set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true; 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." }); set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false; 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: "" }); set({ phase: "manastorms", activeCharacter: null, notice: "" });
return true; return true;
}, },
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
openClassHall: () => { openClassHall: () => {
const { session, selectedCharacterId, characters } = get(); const { session, selectedCharacterId, characters } = get();
const character = characters.find((entry) => entry.id === selectedCharacterId); const character = characters.find((entry) => entry.id === selectedCharacterId);
if (!session || !character || character.categoryId !== "rom") { if (!session || !character) {
set({ notice: "The Class Hall is available to Runes of Magic characters." }); set({ notice: "Select a character before opening the Class Hall." });
return false; return false;
} }
set({ phase: "class-hall", activeCharacter: null, notice: "" }); set({ phase: "class-hall", activeCharacter: null, notice: "" });
return true; 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) => { chooseRomSecondaryClass: (classId) => {
const { session, selectedCharacterId } = get(); const { session, selectedCharacterId } = get();
if (!session || !selectedCharacterId) return false; if (!session || !selectedCharacterId) return false;
+3
View File
@@ -24,6 +24,8 @@ export interface CharacterProfile {
name: string; name: string;
raceId: RaceId; raceId: RaceId;
classId: ClassId; 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. */ /** Always present after repository normalization; optional for v1 callers. */
categoryId?: ContentCategoryId; categoryId?: ContentCategoryId;
/** RoM's inactive class. The active primary remains `classId`. */ /** RoM's inactive class. The active primary remains `classId`. */
@@ -51,6 +53,7 @@ export interface CharacterDraft {
categoryId: ContentCategoryId; categoryId: ContentCategoryId;
raceId: RaceId; raceId: RaceId;
classId: ClassId; classId: ClassId;
specializationId: string | null;
gender: GenderId; gender: GenderId;
appearance: CharacterAppearance; appearance: CharacterAppearance;
rotation: number; rotation: number;
+4 -2
View File
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
export function CombatBridge() { export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const activeRole = useShellStore((state) => state.activeDungeonRole);
const resetRevision = useGameStore((state) => state.resetRevision); const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision); const mountedResetRevision = useRef(resetRevision);
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
useCombatStore.getState().initializeCharacter(character); useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") { if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout( activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout, useManastormStore.getState().activeSpellLoadout,
); );
} }
}, [character?.id, gameMode]); }, [activeRole, character?.id, gameMode]);
useEffect(() => { useEffect(() => {
if (mountedResetRevision.current === resetRevision) return; if (mountedResetRevision.current === resetRevision) return;
@@ -65,6 +66,7 @@ export function CombatBridge() {
// also guarantees a fast return to Character Select cannot outrun a // also guarantees a fast return to Character Select cannot outrun a
// deferred save and lose the last level or talent point. // deferred save and lose the last level or talent point.
shell.saveActiveProgression(state.level, state.xp, state.talentRanks); shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
} }
if (state.actionBindings !== previousState.actionBindings) { if (state.actionBindings !== previousState.actionBindings) {
+11 -3
View File
@@ -11,7 +11,8 @@ import type {
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { PLAYER_AGGRO_ID } from "./aggro"; import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore"; 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 { getMobRuntimeTransform } from "./mobRuntimeRegistry";
import { import {
configureOnlineSessionRuntime, configureOnlineSessionRuntime,
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
selectedTargetId: combat.selectedTargetId, selectedTargetId: combat.selectedTargetId,
activeCast: combat.activeCast, activeCast: combat.activeCast,
animationEvent: combat.playerAnimationEvent, animationEvent: combat.playerAnimationEvent,
equipment: equipmentItemsForOwner( equipment: resolvedEquipmentItems(
combat.equipment, combat.equipment,
combat.inventory, 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() } : {}), ...(publishWorld ? { world: sharedWorld() } : {}),
+5 -1
View File
@@ -40,7 +40,10 @@ export function PartyBridge() {
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]); }, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
useEffect(() => { useEffect(() => {
usePartyStore.getState().syncLevel(level); const party = usePartyStore.getState();
const combat = useCombatStore.getState();
party.syncLevel(level);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
}, [level]); }, [level]);
useEffect(() => { useEffect(() => {
@@ -48,6 +51,7 @@ export function PartyBridge() {
mountedResetRevision.current = resetRevision; mountedResetRevision.current = resetRevision;
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
usePartyStore.getState().resetCombat(combat.level, combat.health); usePartyStore.getState().resetCombat(combat.level, combat.health);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [resetRevision]); }, [resetRevision]);
+78 -9
View File
@@ -131,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
import { normalizeInventory, type InventoryItem } from "./lootTypes"; import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { import {
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
equipmentStats,
activeEquipmentStatsForClass, activeEquipmentStatsForClass,
equipInventoryItem, equipInventoryItem,
normalizeEquipment, normalizeEquipment,
@@ -139,6 +138,12 @@ import {
type EquipmentAssignments, type EquipmentAssignments,
type EquipmentSlot, type EquipmentSlot,
} from "./equipment"; } from "./equipment";
import {
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import { import {
EMPTY_ITEM_STATS, EMPTY_ITEM_STATS,
itemStatSummary, itemStatSummary,
@@ -491,6 +496,8 @@ export interface ActorHealingRequest {
export interface CharacterCombatSeed { export interface CharacterCombatSeed {
readonly classId: ClassId; readonly classId: ClassId;
readonly specializationId?: string | null;
readonly role?: PartyRole;
readonly secondaryClassId?: ClassId | null; readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId; readonly raceId?: RaceId;
readonly level?: number; readonly level?: number;
@@ -593,6 +600,8 @@ export interface PendingGroundTarget {
export interface CombatState { export interface CombatState {
classId: ClassId; classId: ClassId;
specializationId: string | null;
gearRole: PartyRole;
secondaryClassId: ClassId | null; secondaryClassId: ClassId | null;
raceId: RaceId; raceId: RaceId;
level: number; level: number;
@@ -645,6 +654,7 @@ export interface CombatState {
settings: GameplaySettings; settings: GameplaySettings;
playerPosition: CombatPosition; playerPosition: CombatPosition;
initializeCharacter: (profile: CharacterCombatSeed) => void; initializeCharacter: (profile: CharacterCombatSeed) => void;
refreshResolvedGear: () => void;
registerMob: (id: string, registration: MobRegistration) => void; registerMob: (id: string, registration: MobRegistration) => void;
removeMob: (id: string) => void; removeMob: (id: string) => void;
lootMob: (id: string, now?: number) => boolean; lootMob: (id: string, now?: number) => boolean;
@@ -3117,6 +3127,8 @@ function initialState() {
const initialPools = initialResourcePools(classId, abilities, maxResource); const initialPools = initialResourcePools(classId, abilities, maxResource);
return { return {
classId, classId,
specializationId: null,
gearRole: "healer" as PartyRole,
secondaryClassId: null, secondaryClassId: null,
raceId, raceId,
level, level,
@@ -3175,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
initializeCharacter: (profile) => { initializeCharacter: (profile) => {
resetCombatPresentationSession(); resetCombatPresentationSession();
const classId = profile.classId; 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 secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human"; const raceId = profile.raceId ?? "human";
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0); 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 abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory); const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, 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( const maxHealth = talentAdjustedMaxHealth(
classId, classId,
progression.level, 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) => { registerMob: (id, registration) => set((state) => {
if (!id) return state; if (!id) return state;
const existing = state.mobs[id]; const existing = state.mobs[id];
@@ -3471,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You" ? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion"; : usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
const gearStats = activeEquipmentStatsForClass( const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
change.assignments, ownerId: PLAYER_EQUIPMENT_OWNER_ID,
state.inventory, ownerKind: "player",
PLAYER_EQUIPMENT_OWNER_ID, classId: state.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( const maxHealth = talentAdjustedMaxHealth(
state.classId, state.classId,
state.level, state.level,
@@ -3518,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get(); const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot); const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false; 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( const maxHealth = talentAdjustedMaxHealth(
state.classId, state.classId,
state.level, 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"; 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 type DamageSchool = "physical" | "holy" | "fire" | "nature" | "frost" | "shadow" | "arcane";
export interface WeaponStats { export interface WeaponStats {
@@ -109,6 +109,7 @@ export const EMPTY_ITEM_STATS: ItemStats = Object.freeze({
export const HEALTH_PER_STAMINA = 10; export const HEALTH_PER_STAMINA = 10;
export const MANA_PER_INTELLECT = 15; export const MANA_PER_INTELLECT = 15;
export const MAX_PERSISTED_ITEM_LEVEL = 255;
const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze({ const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze({
common: 0.8, common: 0.8,
@@ -116,6 +117,7 @@ const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze(
rare: 1.25, rare: 1.25,
epic: 1.55, epic: 1.55,
legendary: 2, legendary: 2,
heirloom: 1.65,
}); });
const SLOT_WEIGHT: Readonly<Record<string, number>> = Object.freeze({ const SLOT_WEIGHT: Readonly<Record<string, number>> = Object.freeze({
@@ -198,7 +200,7 @@ function fallbackSourceStats(source: ItemStatSource): {
readonly stats: SourceItemStats; readonly stats: SourceItemStats;
readonly weapon: WeaponStats | null; 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 quality = QUALITY_MULTIPLIER[source.quality];
const slotWeight = SLOT_WEIGHT[source.inventorySlot] ?? 0.65; const slotWeight = SLOT_WEIGHT[source.inventorySlot] ?? 0.65;
const budget = Math.max(1, level * quality * slotWeight); const budget = Math.max(1, level * quality * slotWeight);
@@ -232,7 +234,7 @@ export function itemStatsFor(source: ItemStatSource): ItemStats {
1, 1,
Math.trunc(source.sourceItemLevel ?? authored?.sourceItemLevel ?? source.itemLevel), 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 fallback = fallbackSourceStats(source);
const sourceStats = source.sourceStats const sourceStats = source.sourceStats
?? authored?.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", () => { it("clamps generated item levels to the supported character range", () => {
expect(scaledLootItemLevel(-10)).toBe(1); expect(scaledLootItemLevel(-10)).toBe(1);
expect(scaledLootItemLevel(42.9)).toBe(42); expect(scaledLootItemLevel(42.9)).toBe(42);
expect(scaledLootItemLevel(900)).toBe(80); expect(scaledLootItemLevel(900)).toBe(85);
expect(rollBossLoot("not-a-boss", 20)).toEqual([]); 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 { 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 { import {
normalizeItemStats, normalizeItemStats,
MAX_PERSISTED_ITEM_LEVEL,
type ItemQuality, type ItemQuality,
type ItemStats, type ItemStats,
} from "./itemStats"; } from "./itemStats";
@@ -51,7 +52,7 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|| typeof item.sourceBossName !== "string" || typeof item.sourceBossName !== "string"
|| !Number.isFinite(item.acquiredAt) || !Number.isFinite(item.acquiredAt)
) continue; ) 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({ normalized.push({
instanceId: item.instanceId, instanceId: item.instanceId,
itemId: Math.max(1, Math.trunc(item.itemId!)), 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 encounter = useManastormStore.getState().currentEncounter!;
const revision = useGameStore.getState().sessionRevision; const revision = useGameStore.getState().sessionRevision;
const companion = usePartyStore.getState().members[0]; 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, { useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName, name: encounter.bossName,
boss: true, boss: true,
@@ -124,8 +126,11 @@ describe("Manastorm encounter sessions", () => {
it("uses the same shared charge to revive only the requested companion", () => { it("uses the same shared charge to revive only the requested companion", () => {
const companion = usePartyStore.getState().members[0]; const companion = usePartyStore.getState().members[0];
const otherCompanion = usePartyStore.getState().members[1]; const otherCompanion = usePartyStore.getState().members[1];
usePartyStore.getState().damageMember(otherCompanion.id, otherCompanion.maxHealth - 1); usePartyStore.setState((state) => ({
usePartyStore.getState().damageMember(companion.id, companion.maxHealth); 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(usePartyStore.getState().members[0].health).toBe(0);
expect(resurrectManastormSession({ expect(resurrectManastormSession({
@@ -160,9 +165,9 @@ describe("Manastorm encounter sessions", () => {
} }
useCombatStore.setState({ health: 0 }); useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members; const companions = usePartyStore.getState().members;
for (const companion of companions) { usePartyStore.setState((state) => ({
usePartyStore.getState().damageMember(companion.id, companion.maxHealth); members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
} }));
const companion = companions.at(-1)!; const companion = companions.at(-1)!;
expect(manastormPartyIsDefeated()).toBe(true); 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", () => { it("waits for an explicit shared resurrection when the full party is down", () => {
useCombatStore.setState({ health: 0 }); useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members; const companions = usePartyStore.getState().members;
for (const companion of companions) { usePartyStore.setState((state) => ({
usePartyStore.getState().damageMember(companion.id, companion.maxHealth); members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
} }));
const companion = companions.at(-1)!; const companion = companions.at(-1)!;
expect(resolveManastormActorDown({ expect(resolveManastormActorDown({
+1 -1
View File
@@ -182,7 +182,7 @@ describe("party combat runtime", () => {
nextActionAt: 3_000, nextActionAt: 3_000,
}); });
const geared = applyHealerSupport(wounded.map((member) => member.id === healer.id 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); : member), 80, 100, 1_000);
expect(geared.effectiveHealing).toBeGreaterThan(first.effectiveHealing); 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); usePartyStore.getState().initializeParty("damage-member", 10, 100);
const [target, untouched] = usePartyStore.getState().members; 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) 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) expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.health)
.toBe(untouched.maxHealth); .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({ expect(usePartyStore.getState().members.find((member) => member.id === target.id)).toMatchObject({
health: 0, health: 0,
status: "down", status: "down",
@@ -268,8 +270,9 @@ describe("dungeon party composition", () => {
[target.id]: { chest: chest.instanceId }, [target.id]: { chest: chest.instanceId },
}, [chest]); }, [chest]);
const equipped = usePartyStore.getState().members[0]; const equipped = usePartyStore.getState().members[0];
expect(equipped.maxHealth).toBe(beforeMaxHealth + 40); expect(equipped.maxHealth).toBe(beforeMaxHealth + (equipped.gearStats.stamina - target.gearStats.stamina) * 10);
expect(equipped.gearStats).toEqual(chest.stats); 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); expect(usePartyStore.getState().damageMember(target.id, 100)).toBeLessThan(100);
}); });
+27 -9
View File
@@ -11,9 +11,10 @@ import {
type RaceId, type RaceId,
} from "../app/characterCatalog"; } from "../app/characterCatalog";
import { import {
equipmentStats,
type EquipmentAssignments, type EquipmentAssignments,
} from "./equipment"; } from "./equipment";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import { specializationsForClass } from "./specializationCatalog";
import { import {
EMPTY_ITEM_STATS, EMPTY_ITEM_STATS,
healthFromStamina, healthFromStamina,
@@ -52,6 +53,7 @@ export interface PartyMember {
readonly appearance: CharacterAppearance; readonly appearance: CharacterAppearance;
readonly classId: ClassId; readonly classId: ClassId;
readonly specialization: string; readonly specialization: string;
readonly specializationId: string | null;
readonly talentTreeId: string; readonly talentTreeId: string;
readonly talentRanks: TalentRanks; readonly talentRanks: TalentRanks;
readonly role: PartyRole; readonly role: PartyRole;
@@ -227,18 +229,30 @@ function createMember(
const genders = supportedGenders(race.id); const genders = supportedGenders(race.id);
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male"; const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom)); 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 resourceProfile = resourceProfileForClass(template.classId);
const maxResource = resourceProfile.maximum; const maxResource = resourceProfile.maximum;
const resource = resourceProfile.initial; const resource = resourceProfile.initial;
return { return {
id: `party-${role}-${index}-${template.classId}`, id,
name: template.name, name: template.name,
raceId: race.id, raceId: race.id,
gender, gender,
appearance, appearance,
classId: template.classId, classId: template.classId,
specialization: template.specialization, specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId, talentTreeId: template.talentTreeId,
talentRanks: isRomClassId(template.classId) talentRanks: isRomClassId(template.classId)
? Object.freeze({}) ? Object.freeze({})
@@ -259,7 +273,7 @@ function createMember(
activeCastTargetId: null, activeCastTargetId: null,
schoolLockedUntil: Object.freeze({}), schoolLockedUntil: Object.freeze({}),
runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]), runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]),
gearStats: EMPTY_ITEM_STATS, gearStats,
status: "ready", status: "ready",
nextActionAt: 0, nextActionAt: 0,
tauntReadyAt: 0, tauntReadyAt: 0,
@@ -411,11 +425,8 @@ export const usePartyStore = create<PartyState>((set, get) => ({
}), }),
resetCombat: (level, playerHealth) => set((state) => { resetCombat: (level, playerHealth) => set((state) => {
const previousStats = new Map(state.members.map((member) => [member.id, member.gearStats]));
return { return {
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId).map( members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
(member) => withMemberGearStats(member, previousStats.get(member.id) ?? EMPTY_ITEM_STATS),
),
command: "defend", command: "defend",
selectedMemberId: null, selectedMemberId: null,
selfSelected: false, selfSelected: false,
@@ -455,7 +466,14 @@ export const usePartyStore = create<PartyState>((set, get) => ({
syncEquipment: (assignments, inventory) => set((state) => ({ syncEquipment: (assignments, inventory) => set((state) => ({
members: state.members.map((member) => withMemberGearStats( members: state.members.map((member) => withMemberGearStats(
member, 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", name: "Remedy",
raceId: "human" as const, raceId: "human" as const,
classId: "priest" as const, classId: "priest" as const,
specializationId: "priest:holy",
gender: "female" as const, gender: "female" as const,
appearance: createDefaultAppearance(), appearance: createDefaultAppearance(),
level: 5, 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 { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar"; import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { equipmentItemsForOwner } from "../game/equipment"; import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation"; import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets"; import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
@@ -298,12 +298,19 @@ function PartyActors({
const equipment = useCombatStore((state) => state.equipment); const equipment = useCombatStore((state) => state.equipment);
const equipmentItemsByOwner = useMemo( const equipmentItemsByOwner = useMemo(
() => new Map( () => new Map(
Object.keys(equipment).map((ownerId) => [ members.map((member) => [
ownerId, member.id,
equipmentItemsForOwner(equipment, inventory, ownerId), 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 resetRevision = useGameStore((state) => state.resetRevision);
const { rapier, world } = useRapier(); const { rapier, world } = useRapier();
+15 -5
View File
@@ -22,9 +22,9 @@ import {
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation"; import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import { import {
equipmentItemsForOwner,
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
} from "../game/equipment"; } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import { import {
cameraRelativeMovement, cameraRelativeMovement,
@@ -460,13 +460,23 @@ export function PlayerRig() {
const animationEvent = useCombatStore((state) => state.playerAnimationEvent); const animationEvent = useCombatStore((state) => state.playerAnimationEvent);
const inventory = useCombatStore((state) => state.inventory); const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment); 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( const equipmentItems = useMemo(
() => equipmentItemsForOwner( () => activeCharacter ? resolvedEquipmentItems(
equipment, equipment,
inventory, inventory,
PLAYER_EQUIPMENT_OWNER_ID, {
), ownerId: PLAYER_EQUIPMENT_OWNER_ID,
[equipment, inventory], ownerKind: "player",
classId: activeCharacter.classId,
specializationId,
role: gearRole,
level,
},
) : [],
[activeCharacter, equipment, gearRole, inventory, level, specializationId],
); );
const bodyRef = useRef<RapierRigidBody>(null); const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(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--rare { --item-quality: #62a7e8; }
.equipment-quality--epic { --item-quality: #aa76e8; } .equipment-quality--epic { --item-quality: #aa76e8; }
.equipment-quality--legendary { --item-quality: #e9a240; } .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) { @media (max-width: 820px) {
.equipment-panel__owners { grid-template-columns: repeat(5, minmax(70px, 1fr)); overflow-x: auto; } .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 { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store"; 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 { function itemType(inventorySlot: string): string {
return inventorySlot return inventorySlot
@@ -11,8 +17,25 @@ function itemType(inventorySlot: string): string {
export function BossLootWindow() { export function BossLootWindow() {
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null); 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; 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 dismiss = () => useCombatStore.getState().dismissBossLootReward(reward.id);
const viewInventory = () => { const viewInventory = () => {
dismiss(); dismiss();
@@ -39,8 +62,11 @@ export function BossLootWindow() {
<div className="boss-loot-window__body"> <div className="boss-loot-window__body">
<p className="boss-loot-window__eyebrow">Loot acquired automatically</p> <p className="boss-loot-window__eyebrow">Loot acquired automatically</p>
<div className="boss-loot-window__items"> <div className="boss-loot-window__items">
{reward.items.map((item) => ( {reward.items.map((item) => {
<article const comparison = classCanEquipItem(classId, item)
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
: null;
return <article
key={item.instanceId} key={item.instanceId}
className={`boss-loot-window__item equipment-quality--${item.quality}`} className={`boss-loot-window__item equipment-quality--${item.quality}`}
> >
@@ -48,10 +74,11 @@ export function BossLootWindow() {
<p> <p>
<strong>{item.name}</strong> <strong>{item.name}</strong>
<small>{item.quality} · Item level {item.itemLevel} · {itemType(item.inventorySlot)}</small> <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> </p>
{item.quantity > 1 ? <em>×{item.quantity}</em> : null} {item.quantity > 1 ? <em>×{item.quantity}</em> : null}
</article> </article>;
))} })}
{!reward.items.length ? ( {!reward.items.length ? (
<p className="boss-loot-window__empty">No items dropped this time.</p> <p className="boss-loot-window__empty">No items dropped this time.</p>
) : null} ) : null}
+24 -1
View File
@@ -21,6 +21,8 @@ import { CharacterPreview } from "./CharacterPreview";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
import { specializationsForClass } from "../game/specializationCatalog";
export function CharacterCreateScreen() { export function CharacterCreateScreen() {
const draft = useShellStore((state) => state.draft); const draft = useShellStore((state) => state.draft);
@@ -40,6 +42,7 @@ export function CharacterCreateScreen() {
); );
const appearanceLabels = race.labels[draft.gender]; const appearanceLabels = race.labels[draft.gender];
const appearanceCounts = race.counts[draft.gender]; const appearanceCounts = race.counts[draft.gender];
const specializationOptions = useMemo(() => specializationsForClass(draft.classId), [draft.classId]);
const chooseRace = (raceId: RaceId) => { const chooseRace = (raceId: RaceId) => {
const nextClasses = classesForRace(raceId, draft.categoryId); const nextClasses = classesForRace(raceId, draft.categoryId);
@@ -69,6 +72,7 @@ export function CharacterCreateScreen() {
run: () => chooseGender(gender), run: () => chooseGender(gender),
})), })),
...availableClasses.map((definition) => ({ id: `class-${definition.id}`, run: () => updateDraft({ classId: definition.id }) })), ...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-left", run: () => updateDraft({ rotation: draft.rotation - 12 }) },
{ id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) }, { id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) },
{ id: "creator-back", run: cancel }, { id: "creator-back", run: cancel },
@@ -82,7 +86,7 @@ export function CharacterCreateScreen() {
{ id: "randomize", run: randomize }, { id: "randomize", run: randomize },
{ id: "appearance-back", run: () => setStep("identity") }, { id: "appearance-back", run: () => setStep("identity") },
{ id: "create-character", run: createCharacter }, { 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, { const controller = useMenuController(actions, {
columns: draft.step === "identity" ? 5 : 2, columns: draft.step === "identity" ? 5 : 2,
initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name", initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name",
@@ -180,6 +184,25 @@ export function CharacterCreateScreen() {
<span>{characterClass.description}</span> <span>{characterClass.description}</span>
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small> <small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
</div> </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> </section>
<footer className="creator-footer"> <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> <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 { useMemo, useState } from "react";
import { classById, raceById, type RomClassId } from "../app/characterCatalog"; import { classById, raceById, type RomClassId } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore"; import { selectedCharacter, useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import { import {
ROM_ELITE_SKILL_LEVELS, ROM_ELITE_SKILL_LEVELS,
ROM_SECONDARY_UNLOCK_LEVEL, ROM_SECONDARY_UNLOCK_LEVEL,
@@ -13,9 +14,100 @@ import { CharacterPreview } from "./CharacterPreview";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
import {
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export function ClassHallScreen() { export function ClassHallScreen() {
const character = useShellStore(selectedCharacter); 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 notice = useShellStore((state) => state.notice);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu); const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass); const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass);
@@ -38,7 +130,6 @@ export function ClassHallScreen() {
onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu, onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu,
}); });
if (!character) return null;
const race = raceById(character.raceId); const race = raceById(character.raceId);
const primary = classById(character.classId); const primary = classById(character.classId);
const secondary = character.secondaryClassId ? classById(character.secondaryClassId) : null; 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 { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
installDungeonShellRuntime(); installDungeonShellRuntime();
@@ -66,7 +67,10 @@ export function DungeonSelectScreen() {
?? null; ?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : 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) => { const selectDungeon = (dungeonId: DungeonId) => {
setSelectedDungeonId(dungeonId); setSelectedDungeonId(dungeonId);
setRolePickerOpen(false); setRolePickerOpen(false);
+71 -13
View File
@@ -6,7 +6,6 @@ import {
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
classCanEquipItem, classCanEquipItem,
equippedOwnerId, equippedOwnerId,
equipmentItemLevel,
equipmentSlotForItem, equipmentSlotForItem,
type EquipmentSlot, type EquipmentSlot,
} from "../game/equipment"; } from "../game/equipment";
@@ -22,6 +21,14 @@ import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; 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({ const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
head: "Head", head: "Head",
@@ -46,6 +53,10 @@ interface EquipmentOwner {
readonly name: string; readonly name: string;
readonly detail: string; readonly detail: string;
readonly classId: ClassId; readonly classId: ClassId;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
readonly ownerKind: "player" | "party";
} }
type StatPage = "attributes" | "offense" | "defense"; type StatPage = "attributes" | "offense" | "defense";
@@ -181,6 +192,8 @@ function EquipmentDialog() {
const playerLevel = useCombatStore((state) => state.level); const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth); const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats); 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 feedback = useCombatStore((state) => state.feedback);
const members = usePartyStore((state) => state.members); const members = usePartyStore((state) => state.members);
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
@@ -191,14 +204,22 @@ function EquipmentDialog() {
name: character?.name ?? "You", name: character?.name ?? "You",
detail: "Player", detail: "Player",
classId: playerClassId, classId: playerClassId,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
ownerKind: "player" as const,
}, },
...members.map((member) => ({ ...members.map((member) => ({
id: member.id, id: member.id,
name: member.name, name: member.name,
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`, detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId, 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 [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? ""); const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
const [statPage, setStatPage] = useState<StatPage>("attributes"); const [statPage, setStatPage] = useState<StatPage>("attributes");
@@ -214,9 +235,20 @@ function EquipmentDialog() {
const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0]; const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0];
const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null; 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 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) => ( const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt
)), [inventory]); )), [inventory]);
@@ -230,7 +262,7 @@ function EquipmentDialog() {
id: `equipment-owner-${candidate.id}`, id: `equipment-owner-${candidate.id}`,
run: () => setOwnerId(candidate.id), run: () => setOwnerId(candidate.id),
})), })),
...EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [{ ...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
id: `equipment-slot-${slot}`, id: `equipment-slot-${slot}`,
run: () => useCombatStore.getState().unequipItem(owner.id, slot), run: () => useCombatStore.getState().unequipItem(owner.id, slot),
}] : []), }] : []),
@@ -243,7 +275,7 @@ function EquipmentDialog() {
run: () => equip(item), run: () => equip(item),
})), })),
{ id: "equipment-back", run: close }, { id: "equipment-back", run: close },
], [loadout, owner.id, owners, sortedInventory]); ], [earnedLoadout, owner.id, owners, sortedInventory]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
columns: 2, columns: 2,
initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`, initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`,
@@ -253,7 +285,11 @@ function EquipmentDialog() {
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null; const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null; const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false; 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 selectedMember = members.find((member) => member.id === owner.id) ?? null;
const ownerLevel = selectedMember?.level ?? playerLevel; const ownerLevel = selectedMember?.level ?? playerLevel;
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth; const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
@@ -274,7 +310,7 @@ function EquipmentDialog() {
<div className="equipment-panel__summary"> <div className="equipment-panel__summary">
<strong>{inventory.length}</strong><span>Items</span> <strong>{inventory.length}</strong><span>Items</span>
<i aria-hidden="true" /> <i aria-hidden="true" />
<strong>{gearLevel}</strong><span>Gear level</span> <strong>{gearLevel}</strong><span>Average item level</span>
</div> </div>
<ControllerButton <ControllerButton
controlId="equipment-back" controlId="equipment-back"
@@ -304,7 +340,14 @@ function EquipmentDialog() {
> >
<b>{definition.sigil}</b> <b>{definition.sigil}</b>
<span><strong>{candidate.name}</strong><small>{candidate.detail} · {definition.name}</small></span> <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> </ControllerButton>
); );
})} })}
@@ -339,7 +382,8 @@ function EquipmentDialog() {
</div> </div>
<div className="equipment-loadout__slots"> <div className="equipment-loadout__slots">
{EQUIPMENT_SLOTS.map((slot) => { {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 ? ( return item ? (
<ControllerButton <ControllerButton
key={slot} key={slot}
@@ -349,10 +393,11 @@ function EquipmentDialog() {
type="button" type="button"
className={`equipment-slot equipment-quality--${item.quality}`} className={`equipment-slot equipment-quality--${item.quality}`}
onFocus={() => setInspectedId(item.instanceId)} 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> <span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
<em>Unequip</em> <em>{earned ? "Unequip" : `Heirloom ${item.itemLevel}`}</em>
</ControllerButton> </ControllerButton>
) : ( ) : (
<div key={slot} className="equipment-slot is-empty"> <div key={slot} className="equipment-slot is-empty">
@@ -370,6 +415,7 @@ function EquipmentDialog() {
const assignedId = equippedOwnerId(equipment, item.instanceId); const assignedId = equippedOwnerId(equipment, item.instanceId);
const assignedOwner = assignedId ? ownerById.get(assignedId) : null; const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
const canEquip = classCanEquipItem(owner.classId, item); const canEquip = classCanEquipItem(owner.classId, item);
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
return ( return (
<ControllerButton <ControllerButton
key={item.instanceId} key={item.instanceId}
@@ -388,7 +434,7 @@ function EquipmentDialog() {
<small>Level {item.itemLevel} · {itemType(item)}</small> <small>Level {item.itemLevel} · {itemType(item)}</small>
<small className="equipment-item__stats">{itemStatSummary(item.stats)}</small> <small className="equipment-item__stats">{itemStatSummary(item.stats)}</small>
</span> </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> </ControllerButton>
); );
})} })}
@@ -409,6 +455,11 @@ function EquipmentDialog() {
<p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p> <p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p>
<h3>{inspected.name}</h3> <h3>{inspected.name}</h3>
<p>{itemType(inspected)}</p> <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"> <dl className="equipment-details__stats">
{inspectedStatRows.map((row) => ( {inspectedStatRows.map((row) => (
<div key={row.label}> <div key={row.label}>
@@ -417,6 +468,13 @@ function EquipmentDialog() {
</div> </div>
))} ))}
</dl> </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"> <dl className="equipment-details__provenance">
<div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div> <div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div>
<div><dt>Original level</dt><dd>{inspected.sourceItemLevel}</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, run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" }, 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 ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [isGm, isRom, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); ], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
initialId: "mode-dungeons", initialId: "mode-dungeons",
onBack: returnToCharacters, onBack: returnToCharacters,
@@ -173,7 +173,7 @@ export function MainMenuScreen() {
<em>Authorized</em> <em>Authorized</em>
</ControllerButton> </ControllerButton>
)} )}
{isRom && ( {character && (
<ControllerButton <ControllerButton
controlId="mode-class-hall" controlId="mode-class-hall"
selectedId={controller.selectedId} selectedId={controller.selectedId}
@@ -183,8 +183,8 @@ export function MainMenuScreen() {
onClick={openClassHall} onClick={openClassHall}
> >
<b aria-hidden="true">CH</b> <b aria-hidden="true">CH</b>
<span><strong>Class Hall</strong><small>Choose a permanent partner class and swap your active ordering.</small></span> <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>{character && character.level >= 10 ? "Open" : "Unlocks at 10"}</em> <em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
</ControllerButton> </ControllerButton>
)} )}
{GAME_MODES.map((mode) => mode.available ? ( {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 { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
installManastormShellRuntime(); installManastormShellRuntime();
@@ -58,7 +59,10 @@ export function ManastormSelectScreen() {
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity); const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const adminConfig = useManastormAdminStore((state) => state.config); const adminConfig = useManastormAdminStore((state) => state.config);
const character = activeCharacter ?? rosterCharacter; 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 [partySize, setPartySize] = useState<ManastormPartySize>(5);
const groupHumanCount = onlineGroup?.members.length ?? 1; const groupHumanCount = onlineGroup?.members.length ?? 1;
const effectivePartySize = onlineGroup const effectivePartySize = onlineGroup