Release Healer Man 0.1.15
This commit is contained in:
1 parent
7b41b102fa
commit
f49e54f35f
43 files changed
+1478
-112
No files matched your search
@@ -1654,3 +1654,34 @@ Vite's existing large-chunk advisory remains informational.
|
||||
corrected production build completed with 923 modules transformed. KTX2 and
|
||||
environment-performance audits remained green, and the release-candidate
|
||||
check passed.
|
||||
|
||||
# 2026-09-05 — specialization-aware heirlooms and endgame loot
|
||||
|
||||
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
|
||||
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
|
||||
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
|
||||
Keeping talents returned to the main menu with the specialization saved.
|
||||
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
|
||||
Healer, matching the saved Holy specialization; no Tank or DPS override was
|
||||
available.
|
||||
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
|
||||
player body, staff, and all four AI party bodies/equipment silhouettes were
|
||||
visible and correctly anchored without obscuring the HUD or playfield.
|
||||
- Opened Inventory & Equipment in the dungeon. The player had zero earned
|
||||
items but every applicable slot resolved to a level-10 Priest healer
|
||||
heirloom and reported average item level 10. Morvane, the Blood Death Knight
|
||||
companion, showed a complete tank heirloom loadout with the intended
|
||||
two-handed weapon layout and empty conflicting slots.
|
||||
- Browser logs contained no uncaught application errors. Only the existing
|
||||
third-party initialization deprecation warning appeared.
|
||||
- Automated coverage validates every classic and CoA specialization, Feral
|
||||
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
|
||||
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
|
||||
availability for every gear archetype, two-hand replacement scoring,
|
||||
fallback reappearance, percentage-preserving stat refreshes, specialization
|
||||
persistence and talent reset behavior, and the item-level-255 save limit.
|
||||
- Verification: the complete Vitest suite passed all 928 cases across 171
|
||||
files, including all 43 avatar cases. All 15 server persistence/API tests
|
||||
passed. The production build completed with 925 modules transformed, KTX2
|
||||
and environment-performance audits remained green, and the release-candidate
|
||||
check passed. The existing large-chunk advisory remains informational.
|
||||
@@ -103,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
|
||||
for a future loot-browser screen.
|
||||
|
||||
Every Wailing Caverns boss rolls one item when it dies. The item instance
|
||||
captures the player's level at the killing blow, remains on the corpse until
|
||||
looted, and is then saved in that character's inventory. The SQL loot source
|
||||
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
|
||||
weapon speed, regeneration, and spell-power values for each item. A scaled
|
||||
drop preserves that original stat distribution instead of replacing it with a
|
||||
generic item-power budget.
|
||||
captures its item level at the killing blow, remains on the corpse until
|
||||
looted, and is then saved in that character's inventory. Drops use character
|
||||
level through 79; level-80 drops begin the first endgame tier at item level 85.
|
||||
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
|
||||
block, weapon damage, weapon speed, regeneration, and spell-power values for
|
||||
each item. A scaled drop preserves that original stat distribution instead of
|
||||
replacing it with a generic item-power budget.
|
||||
|
||||
Every WoW and Conquest of Azeroth character has a saved specialization that
|
||||
locks its activity role and selects a complete specialization-aware heirloom
|
||||
loadout. Runes of Magic derives the same profile from its active primary class
|
||||
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
|
||||
rescale through item level 79 for players and 80 for AI party members, never
|
||||
enter inventory, and automatically return when earned equipment is removed.
|
||||
During leveling their score is normalized above compatible same-level catalog
|
||||
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
|
||||
the equipment and loot interfaces highlight without equipping automatically.
|
||||
|
||||
The equipment runtime uses the Wrath primary attributes and itemizable combat
|
||||
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.14",
|
||||
"versionCode": 1014
|
||||
"versionName": "0.1.15",
|
||||
"versionCode": 1015
|
||||
}
|
||||
@@ -488,6 +488,9 @@ export function openGameDatabase(options = {}) {
|
||||
id,
|
||||
name,
|
||||
classId,
|
||||
specializationId: typeof character.specializationId === "string"
|
||||
? character.specializationId.slice(0, 100)
|
||||
: null,
|
||||
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||
gender: character.gender === "female" ? "female" : "male",
|
||||
|
||||
@@ -169,14 +169,15 @@ test("launches players-only and AI-filled group activities only when every membe
|
||||
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||
|
||||
database.updateOnlineGroupMember(leader.id, {
|
||||
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
||||
id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
|
||||
}, "tank");
|
||||
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
|
||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||
|
||||
database.updateOnlineGroupMember(member.id, {
|
||||
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
||||
id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
|
||||
}, "healer");
|
||||
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
recordManastormFailure,
|
||||
setManastormLoadout,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
private values = new Map<string, string>();
|
||||
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
|
||||
}
|
||||
|
||||
describe("local profile repository", () => {
|
||||
it("persists specializations and supports Class Hall keep/reset choices", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
|
||||
const created = createCharacter({
|
||||
ownerId: "offline-roster",
|
||||
name: "Tempest",
|
||||
categoryId: "coa",
|
||||
raceId: "human",
|
||||
classId: "stormbringer",
|
||||
specializationId: "stormbringer:lightning",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
updateCharacterProgression("offline-roster", created.id, {
|
||||
level: 20,
|
||||
experience: 0,
|
||||
talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 },
|
||||
}, storage);
|
||||
updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage);
|
||||
|
||||
const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!;
|
||||
expect(kept.specializationId).toBe("stormbringer:wind");
|
||||
expect(kept.talentRanks).not.toEqual({});
|
||||
expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id);
|
||||
|
||||
const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!;
|
||||
expect(reset.specializationId).toBe("stormbringer:maelstrom");
|
||||
expect(reset.talentRanks).toEqual({});
|
||||
expect(reset.actionBindings["primary:face-top"]).toBeNull();
|
||||
});
|
||||
|
||||
it("creates and verifies a local account without storing its raw password", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
|
||||
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
normalizeManastormProgress,
|
||||
type ManastormProgress,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
const DATABASE_KEY = "healer-man.profile-database.v1";
|
||||
const SESSION_KEY = "healer-man.session.v1";
|
||||
@@ -51,7 +56,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 3;
|
||||
version: 4;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
actionLoadouts?: unknown;
|
||||
specializationId?: unknown;
|
||||
};
|
||||
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
|
||||
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
|
||||
@@ -138,6 +144,9 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
||||
: {};
|
||||
const categoryId = contentCategoryForClass(value.classId);
|
||||
const specializationId = categoryId === "rom"
|
||||
? null
|
||||
: normalizeSpecializationId(value.classId, legacy.specializationId);
|
||||
const secondaryClassId = categoryId === "rom"
|
||||
&& isRomClassId(value.classId)
|
||||
&& typeof legacy.secondaryClassId === "string"
|
||||
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
talentRanks,
|
||||
actionBindings,
|
||||
categoryId,
|
||||
specializationId,
|
||||
secondaryClassId,
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -226,8 +236,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -421,7 +431,9 @@ export function replaceCharactersForOwner(
|
||||
}
|
||||
|
||||
export function createCharacter(
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
|
||||
specializationId?: string | null;
|
||||
},
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterResult {
|
||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||
@@ -491,6 +503,54 @@ export function updateCharacterProgression(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCharacterSpecialization(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
specializationId: string,
|
||||
resetTalents: boolean,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterResult {
|
||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||
const database = readDatabase(storage);
|
||||
let result: CharacterResult = { ok: false, error: "That character is no longer available." };
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
if (character.categoryId === "rom") {
|
||||
result = { ok: false, error: "RoM gear follows the active primary class and activity role." };
|
||||
return character;
|
||||
}
|
||||
const specialization = specializationById(specializationId);
|
||||
if (!specialization || specialization.classId !== character.classId) {
|
||||
result = { ok: false, error: "That specialization is not available to this class." };
|
||||
return character;
|
||||
}
|
||||
const talentAbilityIds = resetTalents
|
||||
? new Set(abilitiesForClass(character.classId)
|
||||
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
|
||||
.map((ability) => ability.id))
|
||||
: new Set<string>();
|
||||
const actionBindings = resetTalents
|
||||
? Object.fromEntries(Object.entries(character.actionBindings)
|
||||
.map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId]))
|
||||
: character.actionBindings;
|
||||
const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId);
|
||||
const updated = normalizeCharacterProfile({
|
||||
...character,
|
||||
specializationId: specialization.id,
|
||||
talentRanks: resetTalents ? {} : character.talentRanks,
|
||||
actionBindings,
|
||||
actionLoadouts: {
|
||||
...(character.actionLoadouts ?? {}),
|
||||
[loadoutKey]: { ...actionBindings },
|
||||
},
|
||||
});
|
||||
result = { ok: true, character: updated };
|
||||
return updated;
|
||||
});
|
||||
if (result.ok) writeDatabase(database, storage);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function updateCharacterActionBindings(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||
return false;
|
||||
}
|
||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||
useShellStore.setState({
|
||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||
@@ -56,4 +66,3 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ManastormPartySize,
|
||||
} from "../game/manastormProgress";
|
||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import {
|
||||
manastormActiveSpellLoadoutModel,
|
||||
validateManastormActiveSpellLoadout,
|
||||
@@ -63,7 +64,16 @@ function enterManastorm(
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||
return false;
|
||||
}
|
||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||
useShellStore.setState({
|
||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly classId: string;
|
||||
readonly specializationId?: string | null;
|
||||
readonly categoryId: string;
|
||||
readonly raceId: string;
|
||||
readonly gender: "female" | "male";
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ const character = {
|
||||
name: "Remedy",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
specializationId: "priest:holy",
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 1,
|
||||
@@ -100,6 +101,15 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().notice).toMatch(/select or create/i);
|
||||
});
|
||||
|
||||
it("routes a legacy WoW character through the mandatory specialization picker", () => {
|
||||
prepareSelectedCharacter();
|
||||
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
|
||||
expect(useShellStore.getState().openMainMenu()).toBe(true);
|
||||
expect(useShellStore.getState().phase).toBe("class-hall");
|
||||
expect(useShellStore.getState().openDungeons()).toBe(false);
|
||||
expect(useShellStore.getState().notice).toMatch(/choose a specialization/i);
|
||||
});
|
||||
|
||||
it("opens the main menu and dungeon list without resetting gameplay", () => {
|
||||
prepareSelectedCharacter();
|
||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||
@@ -124,11 +134,11 @@ describe("shell store", () => {
|
||||
useShellStore.getState().openDungeons();
|
||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "healer")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({
|
||||
phase: "game",
|
||||
activeCharacter: character,
|
||||
activeDungeonRole: "damage",
|
||||
activeDungeonRole: "healer",
|
||||
});
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
paused: false,
|
||||
@@ -142,7 +152,7 @@ describe("shell store", () => {
|
||||
prepareSelectedCharacter();
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
||||
expect(useShellStore.getState().phase).toBe("characters");
|
||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
||||
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown dungeon without resetting or leaving the list", () => {
|
||||
@@ -234,7 +244,7 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().openManastorms()).toBe(true);
|
||||
expect(useShellStore.getState().phase).toBe("manastorms");
|
||||
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
|
||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
||||
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||
|
||||
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({
|
||||
|
||||
+64
-3
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
||||
swapRomClasses,
|
||||
} from "./accountRepository";
|
||||
@@ -55,6 +56,11 @@ import {
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -82,6 +88,7 @@ export interface ShellState {
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
returnToMainMenu: () => void;
|
||||
@@ -110,11 +117,13 @@ export interface ShellState {
|
||||
|
||||
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
|
||||
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
|
||||
return {
|
||||
name: "",
|
||||
categoryId,
|
||||
raceId,
|
||||
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
||||
classId,
|
||||
specializationId: specializationsForClass(classId)[0]?.id ?? null,
|
||||
gender: "female",
|
||||
appearance: createDefaultAppearance(),
|
||||
rotation: -12,
|
||||
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||
next.classId = availableClasses[0].id;
|
||||
}
|
||||
next.specializationId = next.categoryId === "rom"
|
||||
? null
|
||||
: normalizeSpecializationId(next.classId, next.specializationId)
|
||||
?? specializationsForClass(next.classId)[0]?.id
|
||||
?? null;
|
||||
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
|
||||
return { draft: next, notice: "" };
|
||||
}),
|
||||
@@ -233,12 +247,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ notice: "That gender model is not available for the selected race." });
|
||||
return false;
|
||||
}
|
||||
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
|
||||
set({ notice: "Choose a specialization before creating this character." });
|
||||
return false;
|
||||
}
|
||||
const result = persistCharacter({
|
||||
ownerId: session.ownerId,
|
||||
name: normalizeCharacterName(draft.name),
|
||||
categoryId: draft.categoryId,
|
||||
raceId: draft.raceId,
|
||||
classId: draft.classId,
|
||||
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
|
||||
gender: draft.gender,
|
||||
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
|
||||
});
|
||||
@@ -278,6 +297,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
|
||||
return true;
|
||||
}
|
||||
set({ phase: "main-menu", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -287,6 +311,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "dungeons", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -296,6 +325,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
if (!session || !character || character.categoryId !== "rom") {
|
||||
set({ notice: "The Class Hall is available to Runes of Magic characters." });
|
||||
if (!session || !character) {
|
||||
set({ notice: "Select a character before opening the Class Hall." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
changeSelectedSpecialization: (specializationId, resetTalents) => {
|
||||
const { session, selectedCharacterId, phase } = get();
|
||||
if (!session || !selectedCharacterId || phase !== "class-hall") {
|
||||
set({ notice: "Specializations may only be changed inside the Class Hall." });
|
||||
return false;
|
||||
}
|
||||
const definition = specializationById(specializationId);
|
||||
if (!definition) {
|
||||
set({ notice: "That specialization is unavailable." });
|
||||
return false;
|
||||
}
|
||||
const result = updateCharacterSpecialization(
|
||||
session.ownerId,
|
||||
selectedCharacterId,
|
||||
definition.id,
|
||||
resetTalents,
|
||||
);
|
||||
if (!result.ok || !result.character) {
|
||||
set({ notice: result.error ?? "The specialization could not be changed." });
|
||||
return false;
|
||||
}
|
||||
set((state) => ({
|
||||
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
|
||||
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
chooseRomSecondaryClass: (classId) => {
|
||||
const { session, selectedCharacterId } = get();
|
||||
if (!session || !selectedCharacterId) return false;
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface CharacterProfile {
|
||||
name: string;
|
||||
raceId: RaceId;
|
||||
classId: ClassId;
|
||||
/** Stable specialization registry ID. Normalized profiles always include it; optional for legacy callers. */
|
||||
specializationId?: string | null;
|
||||
/** Always present after repository normalization; optional for v1 callers. */
|
||||
categoryId?: ContentCategoryId;
|
||||
/** RoM's inactive class. The active primary remains `classId`. */
|
||||
@@ -51,6 +53,7 @@ export interface CharacterDraft {
|
||||
categoryId: ContentCategoryId;
|
||||
raceId: RaceId;
|
||||
classId: ClassId;
|
||||
specializationId: string | null;
|
||||
gender: GenderId;
|
||||
appearance: CharacterAppearance;
|
||||
rotation: number;
|
||||
|
||||
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
export function CombatBridge() {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
useManastormStore.getState().activeSpellLoadout,
|
||||
);
|
||||
}
|
||||
}, [character?.id, gameMode]);
|
||||
}, [activeRole, character?.id, gameMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedResetRevision.current === resetRevision) return;
|
||||
@@ -65,6 +66,7 @@ export function CombatBridge() {
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
|
||||
}
|
||||
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import { resolvedEquipmentItems } from "./heirloomEquipment";
|
||||
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
configureOnlineSessionRuntime,
|
||||
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
|
||||
selectedTargetId: combat.selectedTargetId,
|
||||
activeCast: combat.activeCast,
|
||||
animationEvent: combat.playerAnimationEvent,
|
||||
equipment: equipmentItemsForOwner(
|
||||
equipment: resolvedEquipmentItems(
|
||||
combat.equipment,
|
||||
combat.inventory,
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
{
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: combat.classId,
|
||||
specializationId: combat.specializationId,
|
||||
role: combat.gearRole,
|
||||
level: combat.level,
|
||||
},
|
||||
),
|
||||
},
|
||||
...(publishWorld ? { world: sharedWorld() } : {}),
|
||||
|
||||
@@ -40,7 +40,10 @@ export function PartyBridge() {
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||
|
||||
useEffect(() => {
|
||||
usePartyStore.getState().syncLevel(level);
|
||||
const party = usePartyStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
party.syncLevel(level);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
}, [level]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,6 +51,7 @@ export function PartyBridge() {
|
||||
mountedResetRevision.current = resetRevision;
|
||||
const combat = useCombatStore.getState();
|
||||
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
resetPartyRuntimeClock();
|
||||
}, [resetRevision]);
|
||||
|
||||
|
||||
+78
-9
@@ -131,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
equipmentStats,
|
||||
activeEquipmentStatsForClass,
|
||||
equipInventoryItem,
|
||||
normalizeEquipment,
|
||||
@@ -139,6 +138,12 @@ import {
|
||||
type EquipmentAssignments,
|
||||
type EquipmentSlot,
|
||||
} from "./equipment";
|
||||
import {
|
||||
resolvedEquipmentStats,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
itemStatSummary,
|
||||
@@ -491,6 +496,8 @@ export interface ActorHealingRequest {
|
||||
|
||||
export interface CharacterCombatSeed {
|
||||
readonly classId: ClassId;
|
||||
readonly specializationId?: string | null;
|
||||
readonly role?: PartyRole;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly raceId?: RaceId;
|
||||
readonly level?: number;
|
||||
@@ -593,6 +600,8 @@ export interface PendingGroundTarget {
|
||||
|
||||
export interface CombatState {
|
||||
classId: ClassId;
|
||||
specializationId: string | null;
|
||||
gearRole: PartyRole;
|
||||
secondaryClassId: ClassId | null;
|
||||
raceId: RaceId;
|
||||
level: number;
|
||||
@@ -645,6 +654,7 @@ export interface CombatState {
|
||||
settings: GameplaySettings;
|
||||
playerPosition: CombatPosition;
|
||||
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
||||
refreshResolvedGear: () => void;
|
||||
registerMob: (id: string, registration: MobRegistration) => void;
|
||||
removeMob: (id: string) => void;
|
||||
lootMob: (id: string, now?: number) => boolean;
|
||||
@@ -3117,6 +3127,8 @@ function initialState() {
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
||||
return {
|
||||
classId,
|
||||
specializationId: null,
|
||||
gearRole: "healer" as PartyRole,
|
||||
secondaryClassId: null,
|
||||
raceId,
|
||||
level,
|
||||
@@ -3175,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
initializeCharacter: (profile) => {
|
||||
resetCombatPresentationSession();
|
||||
const classId = profile.classId;
|
||||
const specialization = specializationById(profile.specializationId);
|
||||
const specializationId = specialization?.classId === classId ? specialization.id : null;
|
||||
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
|
||||
const secondaryClassId = profile.secondaryClassId ?? null;
|
||||
const raceId = profile.raceId ?? "human";
|
||||
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
||||
@@ -3184,7 +3199,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
||||
const inventory = normalizeInventory(profile.inventory);
|
||||
const equipment = normalizeEquipment(profile.equipment, inventory);
|
||||
const gearStats = activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
||||
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId,
|
||||
specializationId,
|
||||
role: gearRole,
|
||||
level: progression.level,
|
||||
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
classId,
|
||||
progression.level,
|
||||
@@ -3231,6 +3253,44 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
refreshResolvedGear: () => set((state) => {
|
||||
const context: HeirloomContext = {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
};
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
state.talentModifiers,
|
||||
gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
state.level,
|
||||
state.talentModifiers,
|
||||
gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
);
|
||||
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
|
||||
return {
|
||||
gearStats,
|
||||
maxHealth,
|
||||
maxResource,
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
|
||||
};
|
||||
}),
|
||||
|
||||
registerMob: (id, registration) => set((state) => {
|
||||
if (!id) return state;
|
||||
const existing = state.mobs[id];
|
||||
@@ -3471,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? "You"
|
||||
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
||||
const gearStats = activeEquipmentStatsForClass(
|
||||
change.assignments,
|
||||
state.inventory,
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
state.classId,
|
||||
);
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -3518,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
||||
if (next === state.equipment) return false;
|
||||
const gearStats = activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentItems,
|
||||
resolvedEquipmentLoadout,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { itemStatsFor } from "./itemStats";
|
||||
import { LOOT_CATALOG, scaledLootItemLevel, type LootCatalogItem } from "./lootCatalog";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
SPECIALIZATION_DEFINITIONS,
|
||||
type SpecializationDefinition,
|
||||
} from "./specializationCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { normalizeInventory } from "./lootTypes";
|
||||
|
||||
const templates = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
||||
|
||||
function context(profile: SpecializationDefinition, level: number, ownerKind: "player" | "party" = "player"): HeirloomContext {
|
||||
return {
|
||||
ownerId: ownerKind === "player" ? "player" : `party-${profile.id}`,
|
||||
ownerKind,
|
||||
classId: profile.classId,
|
||||
specializationId: profile.id,
|
||||
role: profile.role,
|
||||
level,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
|
||||
return {
|
||||
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
name: template.itemName,
|
||||
quality: template.quality,
|
||||
inventorySlot: template.inventorySlot,
|
||||
armorType: template.armorType,
|
||||
weaponType: template.weaponType,
|
||||
itemLevel,
|
||||
sourceItemLevel: template.sourceItemLevel,
|
||||
stats: itemStatsFor({ ...template, itemLevel }),
|
||||
quantity: 1,
|
||||
sourceBossKey: "test-boss",
|
||||
sourceBossName: "Test Boss",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function compatibleItems(classId: ClassId, level: number): InventoryItem[] {
|
||||
return templates
|
||||
.map((template, index) => catalogItem(template, level, String(index)))
|
||||
.filter((item) => classCanEquipItem(classId, item));
|
||||
}
|
||||
|
||||
describe("specialization-aware heirlooms", () => {
|
||||
it("caps player heirlooms at 79 and party heirlooms at 80", () => {
|
||||
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80)).map((item) => item.itemLevel))).toEqual(new Set([79]));
|
||||
expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80, "party")).map((item) => item.itemLevel))).toEqual(new Set([80]));
|
||||
expect(resolvedAverageItemLevel({}, [], context(profile, 40))).toBe(40);
|
||||
});
|
||||
|
||||
it("fills all applicable slots and uses the specialization weapon layout", () => {
|
||||
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(protection, 40));
|
||||
expect(loadout["head"]?.quality).toBe("heirloom");
|
||||
expect(loadout["finger"]?.quality).toBe("heirloom");
|
||||
expect(loadout["trinket"]?.quality).toBe("heirloom");
|
||||
expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand");
|
||||
expect(loadout["off-hand"]?.armorType).toBe("shield");
|
||||
});
|
||||
|
||||
it("allocates only useful archetype stats", () => {
|
||||
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
|
||||
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
const combat = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "rogue:combat")!;
|
||||
const protectionStats = resolvedEquipmentItems({}, [], context(protection, 40)).find((item) => item.inventorySlot === "chest")!.stats;
|
||||
const holyStats = resolvedEquipmentItems({}, [], context(holy, 40)).find((item) => item.inventorySlot === "chest")!.stats;
|
||||
const combatStats = resolvedEquipmentItems({}, [], context(combat, 40)).find((item) => item.inventorySlot === "chest")!.stats;
|
||||
expect(protectionStats).toMatchObject({ stamina: expect.any(Number), defenseRating: expect.any(Number), blockRating: expect.any(Number), spellPower: 0 });
|
||||
expect(holyStats).toMatchObject({ intellect: expect.any(Number), spirit: expect.any(Number), spellPower: expect.any(Number), mp5: expect.any(Number), attackPower: 0 });
|
||||
expect(combatStats).toMatchObject({ agility: expect.any(Number), attackPower: expect.any(Number), hitRating: expect.any(Number), spellPower: 0 });
|
||||
expect(protectionStats.blockRating).toBeGreaterThan(0);
|
||||
expect(holyStats.spellPower).toBeGreaterThan(0);
|
||||
expect(combatStats.agility).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([1, 40, 79])("keeps compatible same-level boss items below heirlooms at level %i", (level) => {
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(profile, level));
|
||||
for (const item of compatibleItems(profile.classId, level)) {
|
||||
expect(compareEquipmentUpgrade(item, loadout, profile, context(profile, level)).isUpgrade, `${profile.id}: ${item.name}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
|
||||
expect(scaledLootItemLevel(79)).toBe(79);
|
||||
expect(scaledLootItemLevel(80)).toBe(85);
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
|
||||
const upgrades = compatibleItems(profile.classId, 85)
|
||||
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
|
||||
expect(upgrades.length, profile.id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets earned assignments override a slot and restores its heirloom when removed", () => {
|
||||
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
const ownerContext = context(profile, 80);
|
||||
const item = compatibleItems(profile.classId, 85).find((candidate) => equipmentSlotForItem(candidate) === "head")
|
||||
?? compatibleItems(profile.classId, 85)[0];
|
||||
const slot = equipmentSlotForItem(item)!;
|
||||
const equipped = resolvedEquipmentLoadout({ player: { [slot]: item.instanceId } }, [item], ownerContext);
|
||||
expect(equipped[slot]).toBe(item);
|
||||
expect(resolvedEquipmentLoadout({}, [item], ownerContext)[slot]?.quality).toBe("heirloom");
|
||||
});
|
||||
|
||||
it("preserves player health and resource percentages across gear maximum changes", () => {
|
||||
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
specializationId: profile.id,
|
||||
role: profile.role,
|
||||
level: 40,
|
||||
});
|
||||
const item = compatibleItems("priest", 85).find((candidate) => equipmentSlotForItem(candidate) === "head")
|
||||
?? compatibleItems("priest", 85)[0];
|
||||
const before = useCombatStore.getState();
|
||||
useCombatStore.setState({
|
||||
health: before.maxHealth * 0.4,
|
||||
resource: before.maxResource * 0.25,
|
||||
inventory: [item],
|
||||
});
|
||||
expect(useCombatStore.getState().equipItem(item.instanceId)).toBe(true);
|
||||
expect(useCombatStore.getState().health / useCombatStore.getState().maxHealth).toBeCloseTo(0.4, 5);
|
||||
expect(useCombatStore.getState().resource / useCombatStore.getState().maxResource).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
it("raises persisted item safety to 255 without admitting virtual heirlooms", () => {
|
||||
const persisted = { ...compatibleItems("priest", 85)[0], itemLevel: 999 };
|
||||
expect(normalizeInventory([persisted])[0].itemLevel).toBe(255);
|
||||
expect(normalizeInventory([{ ...persisted, quality: "heirloom" }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
classById,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
classCanEquipItem,
|
||||
EQUIPMENT_SLOTS,
|
||||
equipmentSlotForItem,
|
||||
type EquipmentAssignments,
|
||||
type EquipmentLoadout,
|
||||
type EquipmentSlot,
|
||||
} from "./equipment";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
ITEM_STAT_KEYS,
|
||||
itemStatsFor,
|
||||
type ItemStats,
|
||||
type NumericItemStatKey,
|
||||
type SourceItemStats,
|
||||
type WeaponStats,
|
||||
} from "./itemStats";
|
||||
import { LOOT_CATALOG, type LootCatalogItem } from "./lootCatalog";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
import {
|
||||
gearProfileFor,
|
||||
specializationScore,
|
||||
type GearArchetype,
|
||||
type SpecializationDefinition,
|
||||
type WeaponLayout,
|
||||
} from "./specializationCatalog";
|
||||
|
||||
export const PLAYER_HEIRLOOM_LEVEL_CAP = 79;
|
||||
export const PARTY_HEIRLOOM_LEVEL_CAP = 80;
|
||||
export const FIRST_ENDGAME_ITEM_LEVEL = 85;
|
||||
|
||||
export type HeirloomOwnerKind = "player" | "party";
|
||||
|
||||
export interface HeirloomContext {
|
||||
readonly ownerId: string;
|
||||
readonly ownerKind: HeirloomOwnerKind;
|
||||
readonly classId: ClassId;
|
||||
readonly specializationId: string | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
}
|
||||
|
||||
export interface EquipmentUpgradeComparison {
|
||||
readonly isUpgrade: boolean;
|
||||
readonly scoreDelta: number;
|
||||
readonly currentScore: number;
|
||||
readonly candidateScore: number;
|
||||
readonly displacedSlots: readonly EquipmentSlot[];
|
||||
readonly usefulStatDeltas: Readonly<Partial<Record<NumericItemStatKey, number>>>;
|
||||
}
|
||||
|
||||
const BASE_SLOTS: readonly EquipmentSlot[] = [
|
||||
"head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist",
|
||||
"legs", "feet", "finger", "trinket",
|
||||
];
|
||||
|
||||
const SLOT_WEIGHT: Readonly<Record<EquipmentSlot, number>> = Object.freeze({
|
||||
head: 1,
|
||||
neck: 0.55,
|
||||
shoulder: 0.75,
|
||||
back: 0.45,
|
||||
chest: 1.2,
|
||||
wrist: 0.55,
|
||||
hands: 0.7,
|
||||
waist: 0.65,
|
||||
legs: 1.05,
|
||||
feet: 0.75,
|
||||
finger: 0.55,
|
||||
trinket: 0.75,
|
||||
"main-hand": 1,
|
||||
"off-hand": 0.85,
|
||||
ranged: 0.9,
|
||||
});
|
||||
|
||||
const SLOT_NAMES: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
|
||||
head: "Crown", neck: "Pendant", shoulder: "Mantle", back: "Cloak", chest: "Vestment",
|
||||
wrist: "Bindings", hands: "Grips", waist: "Sash", legs: "Legguards", feet: "Treads",
|
||||
finger: "Signet", trinket: "Charm", "main-hand": "Weapon", "off-hand": "Focus", ranged: "Ranged Arm",
|
||||
});
|
||||
|
||||
const ARMOR_SLOTS = new Set<EquipmentSlot>([
|
||||
"head", "shoulder", "chest", "wrist", "hands", "waist", "legs", "feet",
|
||||
]);
|
||||
|
||||
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
||||
const itemCache = new Map<string, InventoryItem>();
|
||||
|
||||
function effectiveLevel(context: HeirloomContext): number {
|
||||
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
|
||||
return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1)));
|
||||
}
|
||||
|
||||
function weaponSlots(layout: WeaponLayout): readonly EquipmentSlot[] {
|
||||
switch (layout) {
|
||||
case "dual-wield":
|
||||
case "weapon-shield":
|
||||
return ["main-hand", "off-hand"];
|
||||
case "ranged":
|
||||
return ["main-hand", "ranged"];
|
||||
case "caster":
|
||||
return ["main-hand", "off-hand", "ranged"];
|
||||
default:
|
||||
return ["main-hand"];
|
||||
}
|
||||
}
|
||||
|
||||
function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null {
|
||||
if (slot === "off-hand" && layout === "weapon-shield") return "shield";
|
||||
if (ARMOR_SLOTS.has(slot)) return classById(classId).armor;
|
||||
return null;
|
||||
}
|
||||
|
||||
function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null {
|
||||
if (slot === "off-hand" && profile.weaponLayout === "dual-wield") return "dagger";
|
||||
if (slot === "ranged") return profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
|
||||
if (slot !== "main-hand") return null;
|
||||
if (profile.weaponLayout === "caster") {
|
||||
return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId)
|
||||
? "mace"
|
||||
: "dagger";
|
||||
}
|
||||
if (profile.weaponLayout === "dual-wield") return "dagger";
|
||||
if (classId === "hunter" || classId === "ranger") return "staff";
|
||||
return "mace";
|
||||
}
|
||||
|
||||
function inventorySlotFor(slot: EquipmentSlot, layout: WeaponLayout): string {
|
||||
if (slot === "main-hand") return layout === "two-hand" || layout === "ranged" ? "two-hand" : "one-hand";
|
||||
return slot;
|
||||
}
|
||||
|
||||
function statDistribution(archetype: GearArchetype, budget: number, armor: number): SourceItemStats {
|
||||
const common = { stamina: Math.max(1, Math.round(budget * 0.28)), ...(armor > 0 ? { armor } : {}) };
|
||||
switch (archetype) {
|
||||
case "strength-dps": return { ...common, strength: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.5), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.2), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
|
||||
case "agility-dps": return { ...common, agility: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.48), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
|
||||
case "ranged-dps": return { ...common, agility: Math.round(budget * 0.55), rangedAttackPower: Math.round(budget * 0.52), hitRating: Math.round(budget * 0.21), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.17), armorPenetrationRating: Math.round(budget * 0.13) };
|
||||
case "spell-dps": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.18), spellPower: Math.round(budget * 0.58), hitRating: Math.round(budget * 0.22), criticalStrikeRating: Math.round(budget * 0.18), hasteRating: Math.round(budget * 0.2) };
|
||||
case "healer": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.38), spellPower: Math.round(budget * 0.58), mp5: Math.round(budget * 0.16), criticalStrikeRating: Math.round(budget * 0.12), hasteRating: Math.round(budget * 0.2) };
|
||||
case "block-tank": return { ...common, strength: Math.round(budget * 0.35), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.24), dodgeRating: Math.round(budget * 0.18), parryRating: Math.round(budget * 0.18), blockRating: Math.round(budget * 0.2), blockValue: Math.round(budget * 0.2), expertiseRating: Math.round(budget * 0.1) };
|
||||
case "non-block-tank": return { ...common, agility: Math.round(budget * 0.34), strength: Math.round(budget * 0.12), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.22), dodgeRating: Math.round(budget * 0.24), parryRating: Math.round(budget * 0.12), expertiseRating: Math.round(budget * 0.1) };
|
||||
}
|
||||
}
|
||||
|
||||
function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: string | null, budget: number): WeaponStats | null {
|
||||
if (!weaponType) return null;
|
||||
const twoHand = inventorySlot === "two-hand";
|
||||
const multiplier = twoHand ? 1.55 : slot === "ranged" ? 1.15 : 0.9;
|
||||
return {
|
||||
damageMin: Math.max(1, Math.round(budget * 0.8 * multiplier * 10) / 10),
|
||||
damageMax: Math.max(2, Math.round(budget * 1.25 * multiplier * 10) / 10),
|
||||
damageSchool: "physical",
|
||||
speedMs: weaponType === "dagger" ? 1800 : weaponType === "staff" ? 2900 : weaponType === "bow" ? 2800 : 2400,
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
|
||||
return {
|
||||
instanceId: `catalog:${template.itemId}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
name: template.itemName,
|
||||
quality: template.quality,
|
||||
inventorySlot: template.inventorySlot,
|
||||
armorType: template.armorType,
|
||||
weaponType: template.weaponType,
|
||||
itemLevel,
|
||||
sourceItemLevel: template.sourceItemLevel,
|
||||
stats: itemStatsFor({ ...template, itemLevel }),
|
||||
quantity: 1,
|
||||
sourceBossKey: "catalog",
|
||||
sourceBossName: "Boss catalog",
|
||||
acquiredAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: number, profile: SpecializationDefinition): number {
|
||||
let highest = 0;
|
||||
for (const template of CATALOG_ITEMS) {
|
||||
const candidate = syntheticCatalogItem(template, level);
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
|
||||
highest = Math.max(highest, specializationScore(candidate.stats, profile));
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
function scaleStats(stats: ItemStats, factor: number): ItemStats {
|
||||
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, Math.max(0, Math.round(stats[key] * factor))])) as Record<NumericItemStatKey, number>;
|
||||
const scaleWeapon = (weapon: WeaponStats | null): WeaponStats | null => weapon ? {
|
||||
...weapon,
|
||||
damageMin: Math.max(1, Math.round(weapon.damageMin * factor * 10) / 10),
|
||||
damageMax: Math.max(2, Math.round(weapon.damageMax * factor * 10) / 10),
|
||||
} : null;
|
||||
return Object.freeze({ ...numeric, meleeWeapon: scaleWeapon(stats.meleeWeapon), rangedWeapon: scaleWeapon(stats.rangedWeapon) });
|
||||
}
|
||||
|
||||
function heirloomItem(
|
||||
context: HeirloomContext,
|
||||
profile: SpecializationDefinition,
|
||||
slot: EquipmentSlot,
|
||||
forcedLayout?: WeaponLayout,
|
||||
): InventoryItem {
|
||||
const level = effectiveLevel(context);
|
||||
const layout = forcedLayout ?? profile.weaponLayout;
|
||||
const cacheKey = `${context.ownerId}|${context.classId}|${profile.id}|${level}|${slot}|${layout}`;
|
||||
const cached = itemCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const armorType = armorTypeForSlot(context.classId, slot, layout);
|
||||
const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout });
|
||||
const inventorySlot = inventorySlotFor(slot, layout);
|
||||
const weight = SLOT_WEIGHT[slot];
|
||||
const budget = Math.max(2, level * weight * 1.18);
|
||||
const armor = armorType && armorType !== "shield"
|
||||
? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1)))
|
||||
: armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0;
|
||||
const sourceStats = statDistribution(profile.gearArchetype, budget, armor);
|
||||
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
|
||||
let stats = itemStatsFor({
|
||||
itemId: 9_000_000,
|
||||
quality: "heirloom",
|
||||
inventorySlot,
|
||||
armorType,
|
||||
weaponType,
|
||||
itemLevel: level,
|
||||
sourceItemLevel: level,
|
||||
sourceStats,
|
||||
sourceWeapon,
|
||||
});
|
||||
const currentScore = specializationScore(stats, profile);
|
||||
const catalogScore = highestCatalogScore(context.classId, slot, level, profile)
|
||||
+ (slot === "main-hand" && ["two-hand", "ranged"].includes(layout)
|
||||
? highestCatalogScore(context.classId, "off-hand", level, profile)
|
||||
: 0);
|
||||
const conflictSafety = slot === "main-hand" && ["two-hand", "ranged"].includes(layout) ? 1.05 : 1.02;
|
||||
const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore;
|
||||
if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore);
|
||||
const normalizedScore = specializationScore(stats, profile);
|
||||
if (normalizedScore + 0.001 < targetScore) {
|
||||
const [bestKey, bestWeight] = Object.entries(profile.statWeights)
|
||||
.sort((left, right) => (right[1] ?? 0) - (left[1] ?? 0))[0] as [NumericItemStatKey, number];
|
||||
stats = Object.freeze({
|
||||
...stats,
|
||||
[bestKey]: stats[bestKey] + Math.max(1, Math.ceil((targetScore - normalizedScore) / Math.max(0.01, bestWeight))),
|
||||
});
|
||||
}
|
||||
const stableNumber = [...`${profile.id}:${slot}`].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 17);
|
||||
const result: InventoryItem = Object.freeze({
|
||||
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
|
||||
itemId: 9_000_000 + stableNumber % 900_000,
|
||||
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
|
||||
name: `${profile.displayName} Heirloom ${SLOT_NAMES[slot]}`,
|
||||
quality: "heirloom",
|
||||
inventorySlot,
|
||||
armorType,
|
||||
weaponType,
|
||||
itemLevel: level,
|
||||
sourceItemLevel: level,
|
||||
stats,
|
||||
quantity: 1,
|
||||
sourceBossKey: "heirloom",
|
||||
sourceBossName: "Heirloom Armory",
|
||||
acquiredAt: 0,
|
||||
});
|
||||
itemCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolvedEquipmentLoadout(
|
||||
assignments: EquipmentAssignments,
|
||||
inventory: readonly InventoryItem[],
|
||||
context: HeirloomContext,
|
||||
): Readonly<Partial<Record<EquipmentSlot, InventoryItem>>> {
|
||||
const profile = gearProfileFor(context.classId, context.specializationId, context.role);
|
||||
const result: Partial<Record<EquipmentSlot, InventoryItem>> = {};
|
||||
for (const slot of [...BASE_SLOTS, ...weaponSlots(profile.weaponLayout)]) result[slot] = heirloomItem(context, profile, slot);
|
||||
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
||||
const earned = assignments[context.ownerId] ?? {};
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
const item = earned[slot] ? itemById.get(earned[slot]!) : null;
|
||||
if (!item || !classCanEquipItem(context.classId, item)) continue;
|
||||
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"];
|
||||
if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") {
|
||||
result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand");
|
||||
}
|
||||
result[slot] = item;
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export function resolvedEquipmentItems(
|
||||
assignments: EquipmentAssignments,
|
||||
inventory: readonly InventoryItem[],
|
||||
context: HeirloomContext,
|
||||
): readonly InventoryItem[] {
|
||||
const loadout = resolvedEquipmentLoadout(assignments, inventory, context);
|
||||
return EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [loadout[slot]!] : []);
|
||||
}
|
||||
|
||||
export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemStats {
|
||||
if (!items.length) return EMPTY_ITEM_STATS;
|
||||
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record<NumericItemStatKey, number>;
|
||||
let meleeWeapon: WeaponStats | null = null;
|
||||
let rangedWeapon: WeaponStats | null = null;
|
||||
for (const item of items) {
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
|
||||
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
|
||||
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
|
||||
}
|
||||
return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon });
|
||||
}
|
||||
|
||||
export function resolvedEquipmentStats(
|
||||
assignments: EquipmentAssignments,
|
||||
inventory: readonly InventoryItem[],
|
||||
context: HeirloomContext,
|
||||
): ItemStats {
|
||||
return equipmentStatsForItems(resolvedEquipmentItems(assignments, inventory, context));
|
||||
}
|
||||
|
||||
export function resolvedAverageItemLevel(
|
||||
assignments: EquipmentAssignments,
|
||||
inventory: readonly InventoryItem[],
|
||||
context: HeirloomContext,
|
||||
): number {
|
||||
const items = resolvedEquipmentItems(assignments, inventory, context);
|
||||
if (!items.length) return 0;
|
||||
return Math.round(items.reduce((total, item) => total + item.itemLevel, 0) / items.length * 10) / 10;
|
||||
}
|
||||
|
||||
function sumStats(items: readonly InventoryItem[]): ItemStats {
|
||||
return equipmentStatsForItems(items);
|
||||
}
|
||||
|
||||
export function compareEquipmentUpgrade(
|
||||
candidate: InventoryItem,
|
||||
currentLoadout: Readonly<Partial<Record<EquipmentSlot, InventoryItem>>>,
|
||||
profile: SpecializationDefinition,
|
||||
context?: HeirloomContext,
|
||||
): EquipmentUpgradeComparison {
|
||||
const slot = equipmentSlotForItem(candidate);
|
||||
if (!slot) return { isUpgrade: false, scoreDelta: 0, currentScore: 0, candidateScore: 0, displacedSlots: [], usefulStatDeltas: {} };
|
||||
const displacedSlots: EquipmentSlot[] = slot === "main-hand" && candidate.inventorySlot === "two-hand"
|
||||
? ["main-hand", "off-hand"]
|
||||
: slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand"
|
||||
? ["main-hand", "off-hand"]
|
||||
: [slot];
|
||||
const currentItems = displacedSlots.flatMap((key) => currentLoadout[key] ? [currentLoadout[key]!] : []);
|
||||
const currentStats = sumStats(currentItems);
|
||||
const candidateItems = slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand" && context
|
||||
? [heirloomItem(context, profile, "main-hand", "one-hand"), candidate]
|
||||
: [candidate];
|
||||
const candidateStats = sumStats(candidateItems);
|
||||
const currentScore = specializationScore(currentStats, profile);
|
||||
const candidateScore = specializationScore(candidateStats, profile);
|
||||
const scoreDelta = Math.round((candidateScore - currentScore) * 100) / 100;
|
||||
const usefulStatDeltas = Object.fromEntries(Object.keys(profile.statWeights).flatMap((rawKey) => {
|
||||
const key = rawKey as NumericItemStatKey;
|
||||
const delta = candidateStats[key] - currentStats[key];
|
||||
return delta ? [[key, delta]] : [];
|
||||
})) as Partial<Record<NumericItemStatKey, number>>;
|
||||
return Object.freeze({
|
||||
isUpgrade: scoreDelta > 0,
|
||||
scoreDelta,
|
||||
currentScore,
|
||||
candidateScore,
|
||||
displacedSlots,
|
||||
usefulStatDeltas,
|
||||
});
|
||||
}
|
||||
|
||||
export function emptyResolvedLoadout(): EquipmentLoadout {
|
||||
return {};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import generatedLootCatalog from "./generated/lootCatalog.json";
|
||||
|
||||
export type ItemQuality = "common" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
export type ItemQuality = "common" | "uncommon" | "rare" | "epic" | "legendary" | "heirloom";
|
||||
export type DamageSchool = "physical" | "holy" | "fire" | "nature" | "frost" | "shadow" | "arcane";
|
||||
|
||||
export interface WeaponStats {
|
||||
@@ -109,6 +109,7 @@ export const EMPTY_ITEM_STATS: ItemStats = Object.freeze({
|
||||
|
||||
export const HEALTH_PER_STAMINA = 10;
|
||||
export const MANA_PER_INTELLECT = 15;
|
||||
export const MAX_PERSISTED_ITEM_LEVEL = 255;
|
||||
|
||||
const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze({
|
||||
common: 0.8,
|
||||
@@ -116,6 +117,7 @@ const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze(
|
||||
rare: 1.25,
|
||||
epic: 1.55,
|
||||
legendary: 2,
|
||||
heirloom: 1.65,
|
||||
});
|
||||
|
||||
const SLOT_WEIGHT: Readonly<Record<string, number>> = Object.freeze({
|
||||
@@ -198,7 +200,7 @@ function fallbackSourceStats(source: ItemStatSource): {
|
||||
readonly stats: SourceItemStats;
|
||||
readonly weapon: WeaponStats | null;
|
||||
} {
|
||||
const level = Math.max(1, Math.min(80, Math.trunc(source.sourceItemLevel ?? source.itemLevel)));
|
||||
const level = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.sourceItemLevel ?? source.itemLevel)));
|
||||
const quality = QUALITY_MULTIPLIER[source.quality];
|
||||
const slotWeight = SLOT_WEIGHT[source.inventorySlot] ?? 0.65;
|
||||
const budget = Math.max(1, level * quality * slotWeight);
|
||||
@@ -232,7 +234,7 @@ export function itemStatsFor(source: ItemStatSource): ItemStats {
|
||||
1,
|
||||
Math.trunc(source.sourceItemLevel ?? authored?.sourceItemLevel ?? source.itemLevel),
|
||||
);
|
||||
const itemLevel = Math.max(1, Math.min(80, Math.trunc(source.itemLevel)));
|
||||
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.itemLevel)));
|
||||
const fallback = fallbackSourceStats(source);
|
||||
const sourceStats = source.sourceStats
|
||||
?? authored?.sourceStats
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("SQL-generated boss loot catalog", () => {
|
||||
it("clamps generated item levels to the supported character range", () => {
|
||||
expect(scaledLootItemLevel(-10)).toBe(1);
|
||||
expect(scaledLootItemLevel(42.9)).toBe(42);
|
||||
expect(scaledLootItemLevel(900)).toBe(80);
|
||||
expect(scaledLootItemLevel(900)).toBe(85);
|
||||
expect(rollBossLoot("not-a-boss", 20)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ function chooseWeightedItem(items: readonly LootCatalogItem[], random: () => num
|
||||
}
|
||||
|
||||
export function scaledLootItemLevel(playerLevel: number): number {
|
||||
return Math.max(1, Math.min(80, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1)));
|
||||
const level = Math.max(1, Math.min(80, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1)));
|
||||
return level >= 80 ? 85 : level;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
normalizeItemStats,
|
||||
MAX_PERSISTED_ITEM_LEVEL,
|
||||
type ItemQuality,
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
@@ -51,7 +52,7 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|
||||
|| typeof item.sourceBossName !== "string"
|
||||
|| !Number.isFinite(item.acquiredAt)
|
||||
) continue;
|
||||
const itemLevel = Math.max(1, Math.min(80, Math.trunc(item.itemLevel!)));
|
||||
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
|
||||
normalized.push({
|
||||
instanceId: item.instanceId,
|
||||
itemId: Math.max(1, Math.trunc(item.itemId!)),
|
||||
|
||||
@@ -97,7 +97,9 @@ describe("Manastorm encounter sessions", () => {
|
||||
const encounter = useManastormStore.getState().currentEncounter!;
|
||||
const revision = useGameStore.getState().sessionRevision;
|
||||
const companion = usePartyStore.getState().members[0];
|
||||
usePartyStore.getState().damageMember(companion.id, companion.maxHealth - 1);
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === companion.id ? { ...member, health: 1 } : member),
|
||||
}));
|
||||
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
|
||||
name: encounter.bossName,
|
||||
boss: true,
|
||||
@@ -124,8 +126,11 @@ describe("Manastorm encounter sessions", () => {
|
||||
it("uses the same shared charge to revive only the requested companion", () => {
|
||||
const companion = usePartyStore.getState().members[0];
|
||||
const otherCompanion = usePartyStore.getState().members[1];
|
||||
usePartyStore.getState().damageMember(otherCompanion.id, otherCompanion.maxHealth - 1);
|
||||
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === companion.id
|
||||
? { ...member, health: 0, status: "down" }
|
||||
: member.id === otherCompanion.id ? { ...member, health: 1 } : member),
|
||||
}));
|
||||
expect(usePartyStore.getState().members[0].health).toBe(0);
|
||||
|
||||
expect(resurrectManastormSession({
|
||||
@@ -160,9 +165,9 @@ describe("Manastorm encounter sessions", () => {
|
||||
}
|
||||
useCombatStore.setState({ health: 0 });
|
||||
const companions = usePartyStore.getState().members;
|
||||
for (const companion of companions) {
|
||||
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
|
||||
}
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
|
||||
}));
|
||||
const companion = companions.at(-1)!;
|
||||
|
||||
expect(manastormPartyIsDefeated()).toBe(true);
|
||||
@@ -176,9 +181,9 @@ describe("Manastorm encounter sessions", () => {
|
||||
it("waits for an explicit shared resurrection when the full party is down", () => {
|
||||
useCombatStore.setState({ health: 0 });
|
||||
const companions = usePartyStore.getState().members;
|
||||
for (const companion of companions) {
|
||||
usePartyStore.getState().damageMember(companion.id, companion.maxHealth);
|
||||
}
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
|
||||
}));
|
||||
const companion = companions.at(-1)!;
|
||||
|
||||
expect(resolveManastormActorDown({
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("party combat runtime", () => {
|
||||
nextActionAt: 3_000,
|
||||
});
|
||||
const geared = applyHealerSupport(wounded.map((member) => member.id === healer.id
|
||||
? { ...member, gearStats: { ...member.gearStats, spellPower: 10 } }
|
||||
? { ...member, gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + 1_000 } }
|
||||
: member), 80, 100, 1_000);
|
||||
expect(geared.effectiveHealing).toBeGreaterThan(first.effectiveHealing);
|
||||
|
||||
|
||||
@@ -230,12 +230,14 @@ describe("dungeon party composition", () => {
|
||||
usePartyStore.getState().initializeParty("damage-member", 10, 100);
|
||||
const [target, untouched] = usePartyStore.getState().members;
|
||||
|
||||
expect(usePartyStore.getState().damageMember(target.id, 25)).toBe(25);
|
||||
const firstDamage = usePartyStore.getState().damageMember(target.id, 25);
|
||||
expect(firstDamage).toBeGreaterThan(0);
|
||||
expect(firstDamage).toBeLessThanOrEqual(25);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health)
|
||||
.toBe(target.maxHealth - 25);
|
||||
.toBe(target.maxHealth - firstDamage);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.health)
|
||||
.toBe(untouched.maxHealth);
|
||||
expect(usePartyStore.getState().damageMember(target.id, target.maxHealth)).toBe(target.maxHealth - 25);
|
||||
expect(usePartyStore.getState().damageMember(target.id, target.maxHealth * 100)).toBe(target.maxHealth - firstDamage);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === target.id)).toMatchObject({
|
||||
health: 0,
|
||||
status: "down",
|
||||
@@ -268,8 +270,9 @@ describe("dungeon party composition", () => {
|
||||
[target.id]: { chest: chest.instanceId },
|
||||
}, [chest]);
|
||||
const equipped = usePartyStore.getState().members[0];
|
||||
expect(equipped.maxHealth).toBe(beforeMaxHealth + 40);
|
||||
expect(equipped.gearStats).toEqual(chest.stats);
|
||||
expect(equipped.maxHealth).toBe(beforeMaxHealth + (equipped.gearStats.stamina - target.gearStats.stamina) * 10);
|
||||
expect(equipped.gearStats.armor).toBeGreaterThanOrEqual(chest.stats.armor);
|
||||
expect(equipped.gearStats.strength).toBeGreaterThanOrEqual(chest.stats.strength);
|
||||
expect(usePartyStore.getState().damageMember(target.id, 100)).toBeLessThan(100);
|
||||
});
|
||||
|
||||
|
||||
+27
-9
@@ -11,9 +11,10 @@ import {
|
||||
type RaceId,
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
equipmentStats,
|
||||
type EquipmentAssignments,
|
||||
} from "./equipment";
|
||||
import { resolvedEquipmentStats } from "./heirloomEquipment";
|
||||
import { specializationsForClass } from "./specializationCatalog";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
healthFromStamina,
|
||||
@@ -52,6 +53,7 @@ export interface PartyMember {
|
||||
readonly appearance: CharacterAppearance;
|
||||
readonly classId: ClassId;
|
||||
readonly specialization: string;
|
||||
readonly specializationId: string | null;
|
||||
readonly talentTreeId: string;
|
||||
readonly talentRanks: TalentRanks;
|
||||
readonly role: PartyRole;
|
||||
@@ -227,18 +229,30 @@ function createMember(
|
||||
const genders = supportedGenders(race.id);
|
||||
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
|
||||
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
|
||||
const maxHealth = memberMaxHealth(level, role, EMPTY_ITEM_STATS, template.classId, race.id);
|
||||
const id = `party-${role}-${index}-${template.classId}`;
|
||||
const specializationId = specializationsForClass(template.classId)
|
||||
.find((definition) => definition.displayName === template.specialization)?.id ?? null;
|
||||
const gearStats = resolvedEquipmentStats({}, [], {
|
||||
ownerId: id,
|
||||
ownerKind: "party",
|
||||
classId: template.classId,
|
||||
specializationId,
|
||||
role,
|
||||
level,
|
||||
});
|
||||
const maxHealth = memberMaxHealth(level, role, gearStats, template.classId, race.id);
|
||||
const resourceProfile = resourceProfileForClass(template.classId);
|
||||
const maxResource = resourceProfile.maximum;
|
||||
const resource = resourceProfile.initial;
|
||||
return {
|
||||
id: `party-${role}-${index}-${template.classId}`,
|
||||
id,
|
||||
name: template.name,
|
||||
raceId: race.id,
|
||||
gender,
|
||||
appearance,
|
||||
classId: template.classId,
|
||||
specialization: template.specialization,
|
||||
specializationId,
|
||||
talentTreeId: template.talentTreeId,
|
||||
talentRanks: isRomClassId(template.classId)
|
||||
? Object.freeze({})
|
||||
@@ -259,7 +273,7 @@ function createMember(
|
||||
activeCastTargetId: null,
|
||||
schoolLockedUntil: Object.freeze({}),
|
||||
runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]),
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
gearStats,
|
||||
status: "ready",
|
||||
nextActionAt: 0,
|
||||
tauntReadyAt: 0,
|
||||
@@ -411,11 +425,8 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
}),
|
||||
|
||||
resetCombat: (level, playerHealth) => set((state) => {
|
||||
const previousStats = new Map(state.members.map((member) => [member.id, member.gearStats]));
|
||||
return {
|
||||
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId).map(
|
||||
(member) => withMemberGearStats(member, previousStats.get(member.id) ?? EMPTY_ITEM_STATS),
|
||||
),
|
||||
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
|
||||
command: "defend",
|
||||
selectedMemberId: null,
|
||||
selfSelected: false,
|
||||
@@ -455,7 +466,14 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
syncEquipment: (assignments, inventory) => set((state) => ({
|
||||
members: state.members.map((member) => withMemberGearStats(
|
||||
member,
|
||||
equipmentStats(assignments, inventory, member.id),
|
||||
resolvedEquipmentStats(assignments, inventory, {
|
||||
ownerId: member.id,
|
||||
ownerKind: "party",
|
||||
classId: member.classId,
|
||||
specializationId: member.specializationId,
|
||||
role: member.role,
|
||||
level: member.level,
|
||||
}),
|
||||
)),
|
||||
})),
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../app/characterCatalog";
|
||||
import {
|
||||
SPECIALIZATION_DEFINITIONS,
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "./specializationCatalog";
|
||||
|
||||
describe("specialization registry", () => {
|
||||
it("covers every classic and CoA specialization with stable unique IDs", () => {
|
||||
expect(new Set(SPECIALIZATION_DEFINITIONS.map((definition) => definition.id)).size)
|
||||
.toBe(SPECIALIZATION_DEFINITIONS.length);
|
||||
for (const characterClass of CLASSES.filter((definition) => definition.mode !== "rom")) {
|
||||
const actual = specializationsForClass(characterClass.id).map((definition) => definition.displayName);
|
||||
const expected = characterClass.id === "druid"
|
||||
? ["Balance", "Feral (Bear)", "Feral (Cat)", "Restoration"]
|
||||
: [...characterClass.specializations];
|
||||
expect(actual, characterClass.name).toEqual(expect.arrayContaining(expected));
|
||||
expect(actual, characterClass.name).toHaveLength(expected.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("links both Feral variants to Feral Combat with distinct runtime roles", () => {
|
||||
const bear = specializationById("druid:feral-bear");
|
||||
const cat = specializationById("druid:feral-cat");
|
||||
expect(bear).toMatchObject({ role: "tank", talentTree: "Feral Combat" });
|
||||
expect(cat).toMatchObject({ role: "damage", talentTree: "Feral Combat" });
|
||||
});
|
||||
|
||||
it("authors CoA support-oriented specializations into the three-role runtime", () => {
|
||||
const coa = SPECIALIZATION_DEFINITIONS.filter((definition) => (
|
||||
CLASSES.find((entry) => entry.id === definition.classId)?.mode === "conquest"
|
||||
));
|
||||
expect(coa.length).toBeGreaterThan(60);
|
||||
expect(coa.every((definition) => ["tank", "healer", "damage"].includes(definition.role))).toBe(true);
|
||||
expect(specializationById("barbarian:ancestry")?.role).toBe("damage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
baseClassFor,
|
||||
classById,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { ItemStats, NumericItemStatKey } from "./itemStats";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
export type GearArchetype =
|
||||
| "strength-dps"
|
||||
| "agility-dps"
|
||||
| "ranged-dps"
|
||||
| "spell-dps"
|
||||
| "healer"
|
||||
| "block-tank"
|
||||
| "non-block-tank";
|
||||
|
||||
export type WeaponLayout =
|
||||
| "two-hand"
|
||||
| "dual-wield"
|
||||
| "weapon-shield"
|
||||
| "one-hand"
|
||||
| "ranged"
|
||||
| "caster";
|
||||
|
||||
export interface SpecializationDefinition {
|
||||
readonly id: string;
|
||||
readonly classId: ClassId;
|
||||
readonly displayName: string;
|
||||
readonly role: PartyRole;
|
||||
readonly talentTree: string;
|
||||
readonly gearArchetype: GearArchetype;
|
||||
readonly weaponLayout: WeaponLayout;
|
||||
readonly statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
|
||||
}
|
||||
|
||||
const WEIGHTS: Readonly<Record<GearArchetype, Readonly<Partial<Record<NumericItemStatKey, number>>>>> = Object.freeze({
|
||||
"strength-dps": Object.freeze({ strength: 1, stamina: 0.18, attackPower: 0.5, hitRating: 0.82, criticalStrikeRating: 0.72, hasteRating: 0.62, expertiseRating: 0.76, armorPenetrationRating: 0.66 }),
|
||||
"agility-dps": Object.freeze({ agility: 1, stamina: 0.18, attackPower: 0.45, hitRating: 0.82, criticalStrikeRating: 0.78, hasteRating: 0.64, expertiseRating: 0.72, armorPenetrationRating: 0.66 }),
|
||||
"ranged-dps": Object.freeze({ agility: 1, stamina: 0.16, rangedAttackPower: 0.5, hitRating: 0.86, criticalStrikeRating: 0.78, hasteRating: 0.66, armorPenetrationRating: 0.6 }),
|
||||
"spell-dps": Object.freeze({ intellect: 0.8, spirit: 0.28, stamina: 0.14, spellPower: 1, hitRating: 0.86, criticalStrikeRating: 0.68, hasteRating: 0.74 }),
|
||||
healer: Object.freeze({ intellect: 0.82, spirit: 0.62, stamina: 0.16, spellPower: 1, criticalStrikeRating: 0.46, hasteRating: 0.68, mp5: 0.82 }),
|
||||
"block-tank": Object.freeze({ strength: 0.64, stamina: 1, armor: 0.12, defenseRating: 0.86, dodgeRating: 0.7, parryRating: 0.68, blockRating: 0.72, blockValue: 0.66, expertiseRating: 0.38 }),
|
||||
"non-block-tank": Object.freeze({ agility: 0.66, strength: 0.28, stamina: 1, armor: 0.14, defenseRating: 0.78, dodgeRating: 0.86, parryRating: 0.46, expertiseRating: 0.4 }),
|
||||
});
|
||||
|
||||
type SpecTuple = readonly [
|
||||
classId: ClassId,
|
||||
displayName: string,
|
||||
role: PartyRole,
|
||||
gearArchetype: GearArchetype,
|
||||
weaponLayout: WeaponLayout,
|
||||
talentTree?: string,
|
||||
];
|
||||
|
||||
function specId(classId: ClassId, name: string): string {
|
||||
return `${classId}:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
|
||||
}
|
||||
|
||||
function define(tuple: SpecTuple): SpecializationDefinition {
|
||||
const [classId, displayName, role, gearArchetype, weaponLayout, talentTree = displayName] = tuple;
|
||||
return Object.freeze({
|
||||
id: specId(classId, displayName),
|
||||
classId,
|
||||
displayName,
|
||||
role,
|
||||
talentTree,
|
||||
gearArchetype,
|
||||
weaponLayout,
|
||||
statWeights: WEIGHTS[gearArchetype],
|
||||
});
|
||||
}
|
||||
|
||||
const CLASSIC_SPECIALIZATIONS: readonly SpecTuple[] = [
|
||||
["warrior", "Arms", "damage", "strength-dps", "two-hand"],
|
||||
["warrior", "Fury", "damage", "strength-dps", "dual-wield"],
|
||||
["warrior", "Protection", "tank", "block-tank", "weapon-shield"],
|
||||
["paladin", "Holy", "healer", "healer", "weapon-shield"],
|
||||
["paladin", "Protection", "tank", "block-tank", "weapon-shield"],
|
||||
["paladin", "Retribution", "damage", "strength-dps", "two-hand"],
|
||||
["hunter", "Beast Mastery", "damage", "ranged-dps", "ranged"],
|
||||
["hunter", "Marksmanship", "damage", "ranged-dps", "ranged"],
|
||||
["hunter", "Survival", "damage", "ranged-dps", "ranged"],
|
||||
["rogue", "Assassination", "damage", "agility-dps", "dual-wield"],
|
||||
["rogue", "Combat", "damage", "agility-dps", "dual-wield"],
|
||||
["rogue", "Subtlety", "damage", "agility-dps", "dual-wield"],
|
||||
["priest", "Discipline", "healer", "healer", "caster"],
|
||||
["priest", "Holy", "healer", "healer", "caster"],
|
||||
["priest", "Shadow", "damage", "spell-dps", "caster"],
|
||||
["death-knight", "Blood", "tank", "non-block-tank", "two-hand"],
|
||||
["death-knight", "Frost", "damage", "strength-dps", "dual-wield"],
|
||||
["death-knight", "Unholy", "damage", "strength-dps", "two-hand"],
|
||||
["shaman", "Elemental", "damage", "spell-dps", "weapon-shield"],
|
||||
["shaman", "Enhancement", "damage", "agility-dps", "dual-wield"],
|
||||
["shaman", "Restoration", "healer", "healer", "weapon-shield"],
|
||||
["mage", "Arcane", "damage", "spell-dps", "caster"],
|
||||
["mage", "Fire", "damage", "spell-dps", "caster"],
|
||||
["mage", "Frost", "damage", "spell-dps", "caster"],
|
||||
["warlock", "Affliction", "damage", "spell-dps", "caster"],
|
||||
["warlock", "Demonology", "damage", "spell-dps", "caster"],
|
||||
["warlock", "Destruction", "damage", "spell-dps", "caster"],
|
||||
["druid", "Balance", "damage", "spell-dps", "caster"],
|
||||
["druid", "Feral (Bear)", "tank", "non-block-tank", "two-hand", "Feral Combat"],
|
||||
["druid", "Feral (Cat)", "damage", "agility-dps", "two-hand", "Feral Combat"],
|
||||
["druid", "Restoration", "healer", "healer", "caster"],
|
||||
] as const;
|
||||
|
||||
/** CoA roles are authored explicitly. Support trees map to damage, the closest runtime role. */
|
||||
const COA_SPECIALIZATIONS: readonly SpecTuple[] = [
|
||||
["barbarian", "Brutality", "damage", "agility-dps", "two-hand"], ["barbarian", "Headhunting", "damage", "agility-dps", "ranged"], ["barbarian", "Ancestry", "damage", "agility-dps", "two-hand"],
|
||||
["witch-doctor", "Shadowhunting", "damage", "ranged-dps", "ranged"], ["witch-doctor", "Voodoo", "damage", "spell-dps", "caster"], ["witch-doctor", "Brewing", "healer", "healer", "caster"],
|
||||
["felsworn", "Slayer", "damage", "agility-dps", "dual-wield"], ["felsworn", "Infernal", "tank", "non-block-tank", "two-hand"], ["felsworn", "Tyrant", "damage", "spell-dps", "caster"],
|
||||
["witch-hunter", "Boltslinger", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Houndmaster", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Inquisition", "damage", "agility-dps", "dual-wield"], ["witch-hunter", "Black Knight", "tank", "non-block-tank", "two-hand"],
|
||||
["stormbringer", "Maelstrom", "damage", "spell-dps", "caster"], ["stormbringer", "Lightning", "damage", "spell-dps", "caster"], ["stormbringer", "Wind", "damage", "spell-dps", "caster"],
|
||||
["knight-of-xoroth", "Hellfire", "damage", "strength-dps", "two-hand"], ["knight-of-xoroth", "Defiance", "tank", "block-tank", "weapon-shield"], ["knight-of-xoroth", "War", "damage", "strength-dps", "two-hand"],
|
||||
["guardian", "Gladiator", "damage", "strength-dps", "one-hand"], ["guardian", "Inspiration", "damage", "strength-dps", "weapon-shield"], ["guardian", "Vanguard", "tank", "block-tank", "weapon-shield"],
|
||||
["templar", "Zealot", "damage", "agility-dps", "dual-wield"], ["templar", "Oathkeeper", "tank", "non-block-tank", "two-hand"], ["templar", "Crusader", "damage", "agility-dps", "two-hand"],
|
||||
["bloodmage", "Sanguine", "damage", "agility-dps", "dual-wield"], ["bloodmage", "Accursed", "damage", "spell-dps", "caster"], ["bloodmage", "Eternal", "tank", "non-block-tank", "two-hand"], ["bloodmage", "Fleshweaver", "healer", "healer", "caster"],
|
||||
["ranger", "Archery", "damage", "ranged-dps", "ranged"], ["ranger", "Brigand", "damage", "agility-dps", "dual-wield"], ["ranger", "Farstrider", "damage", "ranged-dps", "ranged"],
|
||||
["chronomancer", "Infinite", "damage", "spell-dps", "caster"], ["chronomancer", "Time", "healer", "healer", "caster"], ["chronomancer", "Artificer", "damage", "spell-dps", "caster"],
|
||||
["necromancer", "Death", "damage", "spell-dps", "caster"], ["necromancer", "Rime", "damage", "spell-dps", "caster"], ["necromancer", "Animation", "damage", "spell-dps", "caster"],
|
||||
["pyromancer", "Incineration", "damage", "spell-dps", "caster"], ["pyromancer", "Flameweaving", "damage", "spell-dps", "caster"], ["pyromancer", "Draconic", "damage", "spell-dps", "caster"],
|
||||
["cultist", "Godblade", "damage", "strength-dps", "two-hand"], ["cultist", "Corruption", "damage", "spell-dps", "caster"], ["cultist", "Heretic", "healer", "healer", "caster"], ["cultist", "Dreadnought", "tank", "block-tank", "weapon-shield"],
|
||||
["starcaller", "Moon Guard", "tank", "block-tank", "weapon-shield"], ["starcaller", "Sentinel", "damage", "ranged-dps", "ranged"], ["starcaller", "Moon Priest", "healer", "healer", "caster"], ["starcaller", "Warden", "damage", "strength-dps", "two-hand"],
|
||||
["sun-cleric", "Piety", "damage", "spell-dps", "caster"], ["sun-cleric", "Blessings", "healer", "healer", "caster"], ["sun-cleric", "Valkyrie", "tank", "block-tank", "weapon-shield"], ["sun-cleric", "Seraphim", "damage", "spell-dps", "caster"],
|
||||
["tinker", "Demolition", "damage", "ranged-dps", "ranged"], ["tinker", "Invention", "healer", "healer", "ranged"], ["tinker", "Mechanics", "damage", "ranged-dps", "ranged"],
|
||||
["venomancer", "Venom", "damage", "spell-dps", "caster"], ["venomancer", "Stalking", "damage", "agility-dps", "dual-wield"], ["venomancer", "Fortitude", "tank", "non-block-tank", "two-hand"], ["venomancer", "Vizier", "healer", "healer", "caster"],
|
||||
["reaper", "Harvest", "damage", "agility-dps", "two-hand"], ["reaper", "Soul", "damage", "spell-dps", "caster"], ["reaper", "Domination", "tank", "non-block-tank", "two-hand"],
|
||||
["primalist", "Primal", "damage", "agility-dps", "two-hand"], ["primalist", "Geomancy", "damage", "spell-dps", "caster"], ["primalist", "Life", "healer", "healer", "caster"], ["primalist", "Mountain King", "tank", "non-block-tank", "two-hand"],
|
||||
["runemaster", "Runic", "damage", "spell-dps", "caster"], ["runemaster", "Arcane", "damage", "spell-dps", "caster"], ["runemaster", "Riftblade", "tank", "non-block-tank", "two-hand"],
|
||||
] as const;
|
||||
|
||||
export const SPECIALIZATION_DEFINITIONS: readonly SpecializationDefinition[] = Object.freeze(
|
||||
[...CLASSIC_SPECIALIZATIONS, ...COA_SPECIALIZATIONS].map(define),
|
||||
);
|
||||
|
||||
const BY_ID = new Map(SPECIALIZATION_DEFINITIONS.map((definition) => [definition.id, definition]));
|
||||
|
||||
export function specializationsForClass(classId: ClassId): readonly SpecializationDefinition[] {
|
||||
return SPECIALIZATION_DEFINITIONS.filter((definition) => definition.classId === classId);
|
||||
}
|
||||
|
||||
export function specializationById(id: string | null | undefined): SpecializationDefinition | null {
|
||||
return id ? BY_ID.get(id) ?? null : null;
|
||||
}
|
||||
|
||||
export function normalizeSpecializationId(classId: ClassId, value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const definition = specializationById(value);
|
||||
return definition?.classId === classId ? definition.id : null;
|
||||
}
|
||||
|
||||
export function specializationScore(stats: ItemStats, definition: SpecializationDefinition): number {
|
||||
let score = 0;
|
||||
for (const [key, weight] of Object.entries(definition.statWeights)) {
|
||||
const value = stats[key as NumericItemStatKey];
|
||||
if (typeof value === "number" && Number.isFinite(value)) score += value * (weight ?? 0);
|
||||
}
|
||||
const melee = stats.meleeWeapon;
|
||||
const ranged = stats.rangedWeapon;
|
||||
const weapon = definition.weaponLayout === "ranged" ? ranged : melee;
|
||||
if (weapon?.damageMax && weapon.speedMs) score += ((weapon.damageMin ?? 0) + weapon.damageMax) * 0.42;
|
||||
return Math.round(score * 100) / 100;
|
||||
}
|
||||
|
||||
/** RoM has no saved specialization; derive a complete gear profile from primary class and activity role. */
|
||||
export function romGearProfile(classId: ClassId, role: PartyRole): SpecializationDefinition {
|
||||
const definition = classById(classId);
|
||||
const base = baseClassFor(classId);
|
||||
const caster = ["priest", "mage", "warlock"].includes(base) || definition.primaryStat === "intellect" || definition.primaryStat === "spirit" || definition.primaryStat === "wisdom";
|
||||
const gearArchetype: GearArchetype = role === "healer"
|
||||
? "healer"
|
||||
: role === "tank"
|
||||
? (["warrior", "paladin"].includes(base) ? "block-tank" : "non-block-tank")
|
||||
: caster ? "spell-dps" : base === "hunter" ? "ranged-dps" : definition.primaryStat === "strength" ? "strength-dps" : "agility-dps";
|
||||
const weaponLayout: WeaponLayout = role === "tank" && gearArchetype === "block-tank"
|
||||
? "weapon-shield"
|
||||
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : "two-hand";
|
||||
return Object.freeze({
|
||||
id: `${classId}:role-${role}`,
|
||||
classId,
|
||||
displayName: `${definition.name} ${role}`,
|
||||
role,
|
||||
talentTree: "Primary class",
|
||||
gearArchetype,
|
||||
weaponLayout,
|
||||
statWeights: WEIGHTS[gearArchetype],
|
||||
});
|
||||
}
|
||||
|
||||
export function gearProfileFor(
|
||||
classId: ClassId,
|
||||
specializationId: string | null | undefined,
|
||||
fallbackRole: PartyRole,
|
||||
): SpecializationDefinition {
|
||||
return specializationById(normalizeSpecializationId(classId, specializationId))
|
||||
?? romGearProfile(classId, fallbackRole);
|
||||
}
|
||||
@@ -108,6 +108,7 @@ describe("dual-screen party commands", () => {
|
||||
name: "Remedy",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
specializationId: "priest:holy",
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 5,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { classById, raceById, type GenderId, type RaceId } from "../app/characte
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { equipmentItemsForOwner } from "../game/equipment";
|
||||
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
@@ -298,12 +298,19 @@ function PartyActors({
|
||||
const equipment = useCombatStore((state) => state.equipment);
|
||||
const equipmentItemsByOwner = useMemo(
|
||||
() => new Map(
|
||||
Object.keys(equipment).map((ownerId) => [
|
||||
ownerId,
|
||||
equipmentItemsForOwner(equipment, inventory, ownerId),
|
||||
members.map((member) => [
|
||||
member.id,
|
||||
resolvedEquipmentItems(equipment, inventory, {
|
||||
ownerId: member.id,
|
||||
ownerKind: "party",
|
||||
classId: member.classId,
|
||||
specializationId: member.specializationId,
|
||||
role: member.role,
|
||||
level: member.level,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
[equipment, inventory],
|
||||
[equipment, inventory, members],
|
||||
);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const { rapier, world } = useRapier();
|
||||
|
||||
+15
-5
@@ -22,9 +22,9 @@ import {
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
|
||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||
import {
|
||||
equipmentItemsForOwner,
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
} from "../game/equipment";
|
||||
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import {
|
||||
cameraRelativeMovement,
|
||||
@@ -460,13 +460,23 @@ export function PlayerRig() {
|
||||
const animationEvent = useCombatStore((state) => state.playerAnimationEvent);
|
||||
const inventory = useCombatStore((state) => state.inventory);
|
||||
const equipment = useCombatStore((state) => state.equipment);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const specializationId = useCombatStore((state) => state.specializationId);
|
||||
const gearRole = useCombatStore((state) => state.gearRole);
|
||||
const equipmentItems = useMemo(
|
||||
() => equipmentItemsForOwner(
|
||||
() => activeCharacter ? resolvedEquipmentItems(
|
||||
equipment,
|
||||
inventory,
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
),
|
||||
[equipment, inventory],
|
||||
{
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: activeCharacter.classId,
|
||||
specializationId,
|
||||
role: gearRole,
|
||||
level,
|
||||
},
|
||||
) : [],
|
||||
[activeCharacter, equipment, gearRole, inventory, level, specializationId],
|
||||
);
|
||||
const bodyRef = useRef<RapierRigidBody>(null);
|
||||
const avatarRef = useRef<Object3D>(null);
|
||||
|
||||
@@ -2705,6 +2705,37 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.equipment-quality--rare { --item-quality: #62a7e8; }
|
||||
.equipment-quality--epic { --item-quality: #aa76e8; }
|
||||
.equipment-quality--legendary { --item-quality: #e9a240; }
|
||||
.equipment-quality--heirloom { --item-quality: #d9b45b; }
|
||||
|
||||
.creator-specializations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.creator-specializations > strong {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--front-muted);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.creator-specializations button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.creator-specializations button.is-active {
|
||||
border-color: var(--front-accent);
|
||||
background: rgba(218, 179, 92, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.equipment-panel__owners { grid-template-columns: repeat(5, minmax(70px, 1fr)); overflow-x: auto; }
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
resolvedEquipmentLoadout,
|
||||
} from "../game/heirloomEquipment";
|
||||
import { gearProfileFor } from "../game/specializationCatalog";
|
||||
|
||||
function itemType(inventorySlot: string): string {
|
||||
return inventorySlot
|
||||
@@ -11,8 +17,25 @@ function itemType(inventorySlot: string): string {
|
||||
|
||||
export function BossLootWindow() {
|
||||
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null);
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const specializationId = useCombatStore((state) => state.specializationId);
|
||||
const role = useCombatStore((state) => state.gearRole);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const equipment = useCombatStore((state) => state.equipment);
|
||||
const inventory = useCombatStore((state) => state.inventory);
|
||||
if (!reward) return null;
|
||||
|
||||
const profile = gearProfileFor(classId, specializationId, role);
|
||||
const heirloomContext = {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player" as const,
|
||||
classId,
|
||||
specializationId,
|
||||
role,
|
||||
level,
|
||||
};
|
||||
const loadout = resolvedEquipmentLoadout(equipment, inventory, heirloomContext);
|
||||
|
||||
const dismiss = () => useCombatStore.getState().dismissBossLootReward(reward.id);
|
||||
const viewInventory = () => {
|
||||
dismiss();
|
||||
@@ -39,8 +62,11 @@ export function BossLootWindow() {
|
||||
<div className="boss-loot-window__body">
|
||||
<p className="boss-loot-window__eyebrow">Loot acquired automatically</p>
|
||||
<div className="boss-loot-window__items">
|
||||
{reward.items.map((item) => (
|
||||
<article
|
||||
{reward.items.map((item) => {
|
||||
const comparison = classCanEquipItem(classId, item)
|
||||
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
|
||||
: null;
|
||||
return <article
|
||||
key={item.instanceId}
|
||||
className={`boss-loot-window__item equipment-quality--${item.quality}`}
|
||||
>
|
||||
@@ -48,10 +74,11 @@ export function BossLootWindow() {
|
||||
<p>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.quality} · Item level {item.itemLevel} · {itemType(item.inventorySlot)}</small>
|
||||
{comparison && <small>{comparison.isUpgrade ? `Upgrade · +${comparison.scoreDelta.toFixed(1)} score` : `${comparison.scoreDelta.toFixed(1)} score`}</small>}
|
||||
</p>
|
||||
{item.quantity > 1 ? <em>×{item.quantity}</em> : null}
|
||||
</article>
|
||||
))}
|
||||
</article>;
|
||||
})}
|
||||
{!reward.items.length ? (
|
||||
<p className="boss-loot-window__empty">No items dropped this time.</p>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CharacterPreview } from "./CharacterPreview";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
|
||||
import { specializationsForClass } from "../game/specializationCatalog";
|
||||
|
||||
export function CharacterCreateScreen() {
|
||||
const draft = useShellStore((state) => state.draft);
|
||||
@@ -40,6 +42,7 @@ export function CharacterCreateScreen() {
|
||||
);
|
||||
const appearanceLabels = race.labels[draft.gender];
|
||||
const appearanceCounts = race.counts[draft.gender];
|
||||
const specializationOptions = useMemo(() => specializationsForClass(draft.classId), [draft.classId]);
|
||||
|
||||
const chooseRace = (raceId: RaceId) => {
|
||||
const nextClasses = classesForRace(raceId, draft.categoryId);
|
||||
@@ -69,6 +72,7 @@ export function CharacterCreateScreen() {
|
||||
run: () => chooseGender(gender),
|
||||
})),
|
||||
...availableClasses.map((definition) => ({ id: `class-${definition.id}`, run: () => updateDraft({ classId: definition.id }) })),
|
||||
...specializationOptions.map((definition) => ({ id: `spec-${definition.id}`, run: () => updateDraft({ specializationId: definition.id }) })),
|
||||
{ id: "rotate-left", run: () => updateDraft({ rotation: draft.rotation - 12 }) },
|
||||
{ id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) },
|
||||
{ id: "creator-back", run: cancel },
|
||||
@@ -82,7 +86,7 @@ export function CharacterCreateScreen() {
|
||||
{ id: "randomize", run: randomize },
|
||||
{ id: "appearance-back", run: () => setStep("identity") },
|
||||
{ id: "create-character", run: createCharacter },
|
||||
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, updateDraft]);
|
||||
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, specializationOptions, updateDraft]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: draft.step === "identity" ? 5 : 2,
|
||||
initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name",
|
||||
@@ -180,6 +184,25 @@ export function CharacterCreateScreen() {
|
||||
<span>{characterClass.description}</span>
|
||||
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
|
||||
</div>
|
||||
{specializationOptions.length > 0 && (
|
||||
<div className="creator-specializations" aria-label="Specialization">
|
||||
<strong>Specialization</strong>
|
||||
{specializationOptions.map((definition) => (
|
||||
<ControllerButton
|
||||
key={definition.id}
|
||||
controlId={`spec-${definition.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={draft.specializationId === definition.id ? "is-active" : ""}
|
||||
type="button"
|
||||
onClick={() => updateDraft({ specializationId: definition.id })}
|
||||
>
|
||||
<span>{definition.displayName}</span>
|
||||
<small>{PARTY_ROLE_LABELS[definition.role]} / {definition.talentTree}</small>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<footer className="creator-footer">
|
||||
<ControllerButton controlId="creator-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={cancel}>Back to roster</ControllerButton>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { classById, raceById, type RomClassId } from "../app/characterCatalog";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import {
|
||||
ROM_ELITE_SKILL_LEVELS,
|
||||
ROM_SECONDARY_UNLOCK_LEVEL,
|
||||
@@ -13,9 +14,100 @@ import { CharacterPreview } from "./CharacterPreview";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
|
||||
import {
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
export function ClassHallScreen() {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
if (!character) return null;
|
||||
return character.categoryId === "rom"
|
||||
? <RomClassHall character={character} />
|
||||
: <SpecializationClassHall character={character} />;
|
||||
}
|
||||
|
||||
function SpecializationClassHall({ character }: { readonly character: CharacterProfile }) {
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
||||
const changeSpecialization = useShellStore((state) => state.changeSelectedSpecialization);
|
||||
const [pendingSpecializationId, setPendingSpecializationId] = useState<string | null>(null);
|
||||
const options = useMemo(() => specializationsForClass(character.classId), [character.classId]);
|
||||
const current = specializationById(character.specializationId);
|
||||
const forced = current === null;
|
||||
const goBack = forced ? returnToCharacters : returnToMainMenu;
|
||||
const apply = (resetTalents: boolean) => {
|
||||
if (!pendingSpecializationId || !changeSpecialization(pendingSpecializationId, resetTalents)) return;
|
||||
setPendingSpecializationId(null);
|
||||
if (forced) returnToMainMenu();
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => pendingSpecializationId ? [
|
||||
{ id: "specialization-cancel", run: () => setPendingSpecializationId(null) },
|
||||
{ id: "specialization-keep", run: () => apply(false) },
|
||||
{ id: "specialization-reset", run: () => apply(true) },
|
||||
] : [
|
||||
...options.map((definition) => ({ id: `specialization-${definition.id}`, run: () => setPendingSpecializationId(definition.id) })),
|
||||
{ id: "class-hall-back", run: goBack },
|
||||
], [goBack, options, pendingSpecializationId]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: current ? `specialization-${current.id}` : options[0] ? `specialization-${options[0].id}` : "class-hall-back",
|
||||
onBack: pendingSpecializationId ? () => setPendingSpecializationId(null) : goBack,
|
||||
});
|
||||
const race = raceById(character.raceId);
|
||||
const characterClass = classById(character.classId);
|
||||
const pending = specializationById(pendingSpecializationId);
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Specialization details"
|
||||
top={
|
||||
<FrontSurface className="class-hall-screen" ariaLabel="Class Hall specialization selection">
|
||||
<header className="class-hall-header">
|
||||
<BrandMark compact />
|
||||
<div><span>{characterClass.name} / Class Hall</span><strong>{forced ? "Choose your specialization to continue." : "Change your combat path."}</strong></div>
|
||||
<ControllerButton controlId="class-hall-back" selectedId={controller.selectedId} select={controller.select} type="button" onClick={goBack}>{forced ? "Roster" : "Back"}</ControllerButton>
|
||||
</header>
|
||||
<aside className="class-hall-character">
|
||||
<CharacterPreview character={character} />
|
||||
<div><p className="front-kicker">{race.name} / Level {character.level}</p><h1>{character.name}</h1><strong style={{ color: characterClass.color }}>{characterClass.name}</strong>{current && <small>{current.displayName} / {PARTY_ROLE_LABELS[current.role]}</small>}</div>
|
||||
</aside>
|
||||
<main className="class-hall-content">
|
||||
<section className="secondary-class-choice">
|
||||
<p className="front-kicker">Specialization and heirloom profile</p>
|
||||
<h2>{forced ? "A specialization is required" : `Current: ${current.displayName}`}</h2>
|
||||
<p>Your saved specialization locks your activity role and tunes every heirloom stat and weapon slot. Talent points do not change specialization automatically.</p>
|
||||
<div>{options.map((definition) => (
|
||||
<ControllerButton key={definition.id} controlId={`specialization-${definition.id}`} selectedId={controller.selectedId} select={controller.select} className={`front-button secondary-class-option ${current?.id === definition.id ? "is-active" : ""}`} type="button" onClick={() => setPendingSpecializationId(definition.id)}>
|
||||
<b style={{ color: characterClass.color }}>{characterClass.sigil}</b>
|
||||
<span><strong>{definition.displayName}</strong><small>{PARTY_ROLE_LABELS[definition.role]} / {definition.gearArchetype.replaceAll("-", " ")} / {definition.weaponLayout.replaceAll("-", " ")}</small></span>
|
||||
</ControllerButton>
|
||||
))}</div>
|
||||
</section>
|
||||
{notice && <p className="front-notice" role="status">{notice}</p>}
|
||||
</main>
|
||||
{pending && (
|
||||
<div className="front-modal-backdrop"><section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="specialization-confirm-title">
|
||||
<p className="front-kicker">Specialization change</p>
|
||||
<h2 id="specialization-confirm-title">Become {pending.displayName}?</h2>
|
||||
<p>Heirloom stats and your allowed activity role change immediately. Choose whether to preserve the current talent build and action bindings.</p>
|
||||
<div>
|
||||
<ControllerButton controlId="specialization-cancel" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setPendingSpecializationId(null)}>Cancel</ControllerButton>
|
||||
<ControllerButton controlId="specialization-keep" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => apply(false)}>Keep talents</ControllerButton>
|
||||
<ControllerButton controlId="specialization-reset" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => apply(true)}>Reset talents</ControllerButton>
|
||||
</div>
|
||||
</section></div>
|
||||
)}
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={<LazyLocalCompanionPanel />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RomClassHall({ character }: { readonly character: CharacterProfile }) {
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass);
|
||||
@@ -38,7 +130,6 @@ export function ClassHallScreen() {
|
||||
onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu,
|
||||
});
|
||||
|
||||
if (!character) return null;
|
||||
const race = raceById(character.raceId);
|
||||
const primary = classById(character.classId);
|
||||
const secondary = character.secondaryClassId ? classById(character.secondaryClassId) : null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -66,7 +67,10 @@ export function DungeonSelectScreen() {
|
||||
?? null;
|
||||
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
|
||||
|
||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||
const savedSpecialization = specializationById(character?.specializationId);
|
||||
const availableRoles = character
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
const selectDungeon = (dungeonId: DungeonId) => {
|
||||
setSelectedDungeonId(dungeonId);
|
||||
setRolePickerOpen(false);
|
||||
|
||||
+71
-13
@@ -6,7 +6,6 @@ import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
classCanEquipItem,
|
||||
equippedOwnerId,
|
||||
equipmentItemLevel,
|
||||
equipmentSlotForItem,
|
||||
type EquipmentSlot,
|
||||
} from "../game/equipment";
|
||||
@@ -22,6 +21,14 @@ import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentLoadout,
|
||||
type HeirloomContext,
|
||||
} from "../game/heirloomEquipment";
|
||||
import { gearProfileFor } from "../game/specializationCatalog";
|
||||
import type { PartyRole } from "../game/partyRoles";
|
||||
|
||||
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
|
||||
head: "Head",
|
||||
@@ -46,6 +53,10 @@ interface EquipmentOwner {
|
||||
readonly name: string;
|
||||
readonly detail: string;
|
||||
readonly classId: ClassId;
|
||||
readonly specializationId: string | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly ownerKind: "player" | "party";
|
||||
}
|
||||
|
||||
type StatPage = "attributes" | "offense" | "defense";
|
||||
@@ -181,6 +192,8 @@ function EquipmentDialog() {
|
||||
const playerLevel = useCombatStore((state) => state.level);
|
||||
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
|
||||
const playerGearStats = useCombatStore((state) => state.gearStats);
|
||||
const playerSpecializationId = useCombatStore((state) => state.specializationId);
|
||||
const playerGearRole = useCombatStore((state) => state.gearRole);
|
||||
const feedback = useCombatStore((state) => state.feedback);
|
||||
const members = usePartyStore((state) => state.members);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
@@ -191,14 +204,22 @@ function EquipmentDialog() {
|
||||
name: character?.name ?? "You",
|
||||
detail: "Player",
|
||||
classId: playerClassId,
|
||||
specializationId: playerSpecializationId,
|
||||
role: playerGearRole,
|
||||
level: playerLevel,
|
||||
ownerKind: "player" as const,
|
||||
},
|
||||
...members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
|
||||
classId: member.classId,
|
||||
specializationId: member.specializationId,
|
||||
role: member.role,
|
||||
level: member.level,
|
||||
ownerKind: "party" as const,
|
||||
})),
|
||||
], [character?.name, members, playerClassId]);
|
||||
], [character?.name, members, playerClassId, playerGearRole, playerLevel, playerSpecializationId]);
|
||||
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
|
||||
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
|
||||
const [statPage, setStatPage] = useState<StatPage>("attributes");
|
||||
@@ -214,9 +235,20 @@ function EquipmentDialog() {
|
||||
|
||||
const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0];
|
||||
const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null;
|
||||
const itemById = useMemo(() => new Map(inventory.map((item) => [item.instanceId, item])), [inventory]);
|
||||
const ownerById = useMemo(() => new Map(owners.map((candidate) => [candidate.id, candidate])), [owners]);
|
||||
const loadout = equipment[owner.id] ?? {};
|
||||
const heirloomContext: HeirloomContext = {
|
||||
ownerId: owner.id,
|
||||
ownerKind: owner.ownerKind,
|
||||
classId: owner.classId,
|
||||
specializationId: owner.specializationId,
|
||||
role: owner.role,
|
||||
level: owner.level,
|
||||
};
|
||||
const loadout = useMemo(
|
||||
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
|
||||
[equipment, inventory, owner.classId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
|
||||
);
|
||||
const earnedLoadout = equipment[owner.id] ?? {};
|
||||
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
|
||||
right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt
|
||||
)), [inventory]);
|
||||
@@ -230,7 +262,7 @@ function EquipmentDialog() {
|
||||
id: `equipment-owner-${candidate.id}`,
|
||||
run: () => setOwnerId(candidate.id),
|
||||
})),
|
||||
...EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [{
|
||||
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
|
||||
id: `equipment-slot-${slot}`,
|
||||
run: () => useCombatStore.getState().unequipItem(owner.id, slot),
|
||||
}] : []),
|
||||
@@ -243,7 +275,7 @@ function EquipmentDialog() {
|
||||
run: () => equip(item),
|
||||
})),
|
||||
{ id: "equipment-back", run: close },
|
||||
], [loadout, owner.id, owners, sortedInventory]);
|
||||
], [earnedLoadout, owner.id, owners, sortedInventory]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: 2,
|
||||
initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`,
|
||||
@@ -253,7 +285,11 @@ function EquipmentDialog() {
|
||||
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
|
||||
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
|
||||
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false;
|
||||
const gearLevel = equipmentItemLevel(equipment, inventory, owner.id);
|
||||
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
|
||||
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
|
||||
const inspectedComparison = inspected && compatible
|
||||
? compareEquipmentUpgrade(inspected, loadout, gearProfile, heirloomContext)
|
||||
: null;
|
||||
const selectedMember = members.find((member) => member.id === owner.id) ?? null;
|
||||
const ownerLevel = selectedMember?.level ?? playerLevel;
|
||||
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
|
||||
@@ -274,7 +310,7 @@ function EquipmentDialog() {
|
||||
<div className="equipment-panel__summary">
|
||||
<strong>{inventory.length}</strong><span>Items</span>
|
||||
<i aria-hidden="true" />
|
||||
<strong>{gearLevel}</strong><span>Gear level</span>
|
||||
<strong>{gearLevel}</strong><span>Average item level</span>
|
||||
</div>
|
||||
<ControllerButton
|
||||
controlId="equipment-back"
|
||||
@@ -304,7 +340,14 @@ function EquipmentDialog() {
|
||||
>
|
||||
<b>{definition.sigil}</b>
|
||||
<span><strong>{candidate.name}</strong><small>{candidate.detail} · {definition.name}</small></span>
|
||||
<em>{equipmentItemLevel(equipment, inventory, candidate.id)}</em>
|
||||
<em>{resolvedAverageItemLevel(equipment, inventory, {
|
||||
ownerId: candidate.id,
|
||||
ownerKind: candidate.ownerKind,
|
||||
classId: candidate.classId,
|
||||
specializationId: candidate.specializationId,
|
||||
role: candidate.role,
|
||||
level: candidate.level,
|
||||
})}</em>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
@@ -339,7 +382,8 @@ function EquipmentDialog() {
|
||||
</div>
|
||||
<div className="equipment-loadout__slots">
|
||||
{EQUIPMENT_SLOTS.map((slot) => {
|
||||
const item = loadout[slot] ? itemById.get(loadout[slot]!) ?? null : null;
|
||||
const item = loadout[slot] ?? null;
|
||||
const earned = Boolean(earnedLoadout[slot]);
|
||||
return item ? (
|
||||
<ControllerButton
|
||||
key={slot}
|
||||
@@ -349,10 +393,11 @@ function EquipmentDialog() {
|
||||
type="button"
|
||||
className={`equipment-slot equipment-quality--${item.quality}`}
|
||||
onFocus={() => setInspectedId(item.instanceId)}
|
||||
onClick={() => useCombatStore.getState().unequipItem(owner.id, slot)}
|
||||
disabled={!earned}
|
||||
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
|
||||
>
|
||||
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
|
||||
<em>Unequip</em>
|
||||
<em>{earned ? "Unequip" : `Heirloom ${item.itemLevel}`}</em>
|
||||
</ControllerButton>
|
||||
) : (
|
||||
<div key={slot} className="equipment-slot is-empty">
|
||||
@@ -370,6 +415,7 @@ function EquipmentDialog() {
|
||||
const assignedId = equippedOwnerId(equipment, item.instanceId);
|
||||
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
|
||||
const canEquip = classCanEquipItem(owner.classId, item);
|
||||
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
|
||||
return (
|
||||
<ControllerButton
|
||||
key={item.instanceId}
|
||||
@@ -388,7 +434,7 @@ function EquipmentDialog() {
|
||||
<small>Level {item.itemLevel} · {itemType(item)}</small>
|
||||
<small className="equipment-item__stats">{itemStatSummary(item.stats)}</small>
|
||||
</span>
|
||||
<em>{assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
|
||||
<em>{comparison?.isUpgrade ? `Upgrade +${displayNumber(comparison.scoreDelta, 1)}` : assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
@@ -409,6 +455,11 @@ function EquipmentDialog() {
|
||||
<p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p>
|
||||
<h3>{inspected.name}</h3>
|
||||
<p>{itemType(inspected)}</p>
|
||||
{inspectedComparison && (
|
||||
<p className={inspectedComparison.isUpgrade ? "is-beneficial" : undefined}>
|
||||
{inspectedComparison.isUpgrade ? "Upgrade" : "Not an upgrade"} · {inspectedComparison.scoreDelta >= 0 ? "+" : ""}{displayNumber(inspectedComparison.scoreDelta, 2)} specialization score
|
||||
</p>
|
||||
)}
|
||||
<dl className="equipment-details__stats">
|
||||
{inspectedStatRows.map((row) => (
|
||||
<div key={row.label}>
|
||||
@@ -417,6 +468,13 @@ function EquipmentDialog() {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{inspectedComparison && Object.keys(inspectedComparison.usefulStatDeltas).length > 0 && (
|
||||
<dl className="equipment-details__stats">
|
||||
{Object.entries(inspectedComparison.usefulStatDeltas).map(([key, value]) => (
|
||||
<div key={key}><dt>{key.replace(/([A-Z])/g, " $1")}</dt><dd className={(value ?? 0) > 0 ? "is-beneficial" : undefined}>{(value ?? 0) > 0 ? "+" : ""}{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<dl className="equipment-details__provenance">
|
||||
<div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div>
|
||||
<div><dt>Original level</dt><dd>{inspected.sourceItemLevel}</dd></div>
|
||||
|
||||
@@ -99,9 +99,9 @@ export function MainMenuScreen() {
|
||||
run: returnToCharacters,
|
||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||
},
|
||||
...(isRom ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
], [isGm, isRom, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -173,7 +173,7 @@ export function MainMenuScreen() {
|
||||
<em>Authorized</em>
|
||||
</ControllerButton>
|
||||
)}
|
||||
{isRom && (
|
||||
{character && (
|
||||
<ControllerButton
|
||||
controlId="mode-class-hall"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -183,8 +183,8 @@ export function MainMenuScreen() {
|
||||
onClick={openClassHall}
|
||||
>
|
||||
<b aria-hidden="true">CH</b>
|
||||
<span><strong>Class Hall</strong><small>Choose a permanent partner class and swap your active ordering.</small></span>
|
||||
<em>{character && character.level >= 10 ? "Open" : "Unlocks at 10"}</em>
|
||||
<span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span>
|
||||
<em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
|
||||
</ControllerButton>
|
||||
)}
|
||||
{GAME_MODES.map((mode) => mode.available ? (
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
installManastormShellRuntime();
|
||||
|
||||
@@ -58,7 +59,10 @@ export function ManastormSelectScreen() {
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const adminConfig = useManastormAdminStore((state) => state.config);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||
const savedSpecialization = specializationById(character?.specializationId);
|
||||
const availableRoles = character
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||
const groupHumanCount = onlineGroup?.members.length ?? 1;
|
||||
const effectivePartySize = onlineGroup
|
||||
|
||||
Reference in new issue
Block a user