From f49e54f35f35cda9be0b00cd6dd410d82b455c34 Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 5 Sep 2026 15:33:06 -0400 Subject: [PATCH] Release Healer Man 0.1.15 --- PLAYTEST_NOTES.md | 31 ++ README.md | 23 +- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- server/database.mjs | 3 + server/database.test.mjs | 5 +- src/app/accountRepository.test.ts | 35 ++- src/app/accountRepository.ts | 72 ++++- src/app/dungeonShellRuntime.ts | 13 +- src/app/manastormShellRuntime.ts | 12 +- src/app/onlineGroupTypes.ts | 1 + src/app/romDualClassPersistence.test.ts | 2 +- src/app/shellStore.test.ts | 18 +- src/app/shellStore.ts | 67 ++++- src/app/types.ts | 3 + src/game/CombatBridge.tsx | 6 +- src/game/OnlineSessionBridge.tsx | 14 +- src/game/PartyBridge.tsx | 6 +- src/game/combatStore.ts | 87 +++++- src/game/heirloomEquipment.test.ts | 150 ++++++++++ src/game/heirloomEquipment.ts | 378 ++++++++++++++++++++++++ src/game/itemStats.ts | 8 +- src/game/lootCatalog.test.ts | 2 +- src/game/lootCatalog.ts | 3 +- src/game/lootTypes.ts | 3 +- src/game/manastormSession.test.ts | 23 +- src/game/partyRuntime.test.ts | 2 +- src/game/partyStore.test.ts | 13 +- src/game/partyStore.ts | 36 ++- src/game/specializationCatalog.test.ts | 38 +++ src/game/specializationCatalog.ts | 198 +++++++++++++ src/platform/dualScreenSync.test.ts | 1 + src/scene/PartyPopulation.tsx | 17 +- src/scene/PlayerRig.tsx | 20 +- src/styles.css | 31 ++ src/ui/BossLootWindow.tsx | 35 ++- src/ui/CharacterCreateScreen.tsx | 25 +- src/ui/ClassHallScreen.tsx | 93 +++++- src/ui/DungeonSelectScreen.tsx | 6 +- src/ui/EquipmentPanel.tsx | 84 +++++- src/ui/MainMenuScreen.tsx | 10 +- src/ui/ManastormSelectScreen.tsx | 6 +- 43 files changed, 1478 insertions(+), 112 deletions(-) create mode 100644 src/game/heirloomEquipment.test.ts create mode 100644 src/game/heirloomEquipment.ts create mode 100644 src/game/specializationCatalog.test.ts create mode 100644 src/game/specializationCatalog.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index e9efeb93..af0d9655 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/README.md b/README.md index 33daa459..417e24ac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 546129af..46fc158c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b5af9339..927f5a2f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-version.json b/release-version.json index a0b20911..e5ceb94e 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.14", - "versionCode": 1014 + "versionName": "0.1.15", + "versionCode": 1015 } diff --git a/server/database.mjs b/server/database.mjs index 1757e284..ef6b4547 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -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", diff --git a/server/database.test.mjs b/server/database.test.mjs index 06a79f66..47928d18 100644 --- a/server/database.test.mjs +++ b/server/database.test.mjs @@ -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, diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts index 1f244998..45fc02e0 100644 --- a/src/app/accountRepository.test.ts +++ b/src/app/accountRepository.test.ts @@ -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(); @@ -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(); diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index b19687d4..d77d6b62 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -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; presentationSettingsByOwner: Record; @@ -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, + profile: Omit & { + 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(); + 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, diff --git a/src/app/dungeonShellRuntime.ts b/src/app/dungeonShellRuntime.ts index a48b4b3e..24c5c981 100644 --- a/src/app/dungeonShellRuntime.ts +++ b/src/app/dungeonShellRuntime.ts @@ -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; } - diff --git a/src/app/manastormShellRuntime.ts b/src/app/manastormShellRuntime.ts index 62f9e95a..cac1860a 100644 --- a/src/app/manastormShellRuntime.ts +++ b/src/app/manastormShellRuntime.ts @@ -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]}.`, diff --git a/src/app/onlineGroupTypes.ts b/src/app/onlineGroupTypes.ts index 901120fa..72667977 100644 --- a/src/app/onlineGroupTypes.ts +++ b/src/app/onlineGroupTypes.ts @@ -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"; diff --git a/src/app/romDualClassPersistence.test.ts b/src/app/romDualClassPersistence.test.ts index e0690ed0..f15782c1 100644 --- a/src/app/romDualClassPersistence.test.ts +++ b/src/app/romDualClassPersistence.test.ts @@ -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", () => { diff --git a/src/app/shellStore.test.ts b/src/app/shellStore.test.ts index 2d847040..5d719680 100644 --- a/src/app/shellStore.test.ts +++ b/src/app/shellStore.test.ts @@ -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({ diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index 67574404..6d80dede 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -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((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((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((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((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((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((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; diff --git a/src/app/types.ts b/src/app/types.ts index 5456c9c7..c801fe68 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -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; diff --git a/src/game/CombatBridge.tsx b/src/game/CombatBridge.tsx index 1ad02dab..f9b890b9 100644 --- a/src/game/CombatBridge.tsx +++ b/src/game/CombatBridge.tsx @@ -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) { diff --git a/src/game/OnlineSessionBridge.tsx b/src/game/OnlineSessionBridge.tsx index 8efd4b0c..42fdf244 100644 --- a/src/game/OnlineSessionBridge.tsx +++ b/src/game/OnlineSessionBridge.tsx @@ -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() } : {}), diff --git a/src/game/PartyBridge.tsx b/src/game/PartyBridge.tsx index c95c62c9..24b63799 100644 --- a/src/game/PartyBridge.tsx +++ b/src/game/PartyBridge.tsx @@ -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]); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 2ee93151..84cc76cb 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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((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((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((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((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((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, diff --git a/src/game/heirloomEquipment.test.ts b/src/game/heirloomEquipment.test.ts new file mode 100644 index 00000000..1d56ddc6 --- /dev/null +++ b/src/game/heirloomEquipment.test.ts @@ -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([]); + }); +}); diff --git a/src/game/heirloomEquipment.ts b/src/game/heirloomEquipment.ts new file mode 100644 index 00000000..f059fb9d --- /dev/null +++ b/src/game/heirloomEquipment.ts @@ -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>>; +} + +const BASE_SLOTS: readonly EquipmentSlot[] = [ + "head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist", + "legs", "feet", "finger", "trinket", +]; + +const SLOT_WEIGHT: Readonly> = 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> = 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([ + "head", "shoulder", "chest", "wrist", "hands", "waist", "legs", "feet", +]); + +const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items); +const itemCache = new Map(); + +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; + 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>> { + const profile = gearProfileFor(context.classId, context.specializationId, context.role); + const result: Partial> = {}; + 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; + 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>>, + 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>; + return Object.freeze({ + isUpgrade: scoreDelta > 0, + scoreDelta, + currentScore, + candidateScore, + displacedSlots, + usefulStatDeltas, + }); +} + +export function emptyResolvedLoadout(): EquipmentLoadout { + return {}; +} diff --git a/src/game/itemStats.ts b/src/game/itemStats.ts index 3a6ed7f9..12e20e47 100644 --- a/src/game/itemStats.ts +++ b/src/game/itemStats.ts @@ -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> = Object.freeze({ common: 0.8, @@ -116,6 +117,7 @@ const QUALITY_MULTIPLIER: Readonly> = Object.freeze( rare: 1.25, epic: 1.55, legendary: 2, + heirloom: 1.65, }); const SLOT_WEIGHT: Readonly> = 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 diff --git a/src/game/lootCatalog.test.ts b/src/game/lootCatalog.test.ts index b76a4861..6220b73b 100644 --- a/src/game/lootCatalog.test.ts +++ b/src/game/lootCatalog.test.ts @@ -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([]); }); diff --git a/src/game/lootCatalog.ts b/src/game/lootCatalog.ts index 98338bae..add9319c 100644 --- a/src/game/lootCatalog.ts +++ b/src/game/lootCatalog.ts @@ -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; } /** diff --git a/src/game/lootTypes.ts b/src/game/lootTypes.ts index 201dfea5..fbf1642c 100644 --- a/src/game/lootTypes.ts +++ b/src/game/lootTypes.ts @@ -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!)), diff --git a/src/game/manastormSession.test.ts b/src/game/manastormSession.test.ts index 1d7c9269..ebeca0f8 100644 --- a/src/game/manastormSession.test.ts +++ b/src/game/manastormSession.test.ts @@ -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({ diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts index a7fd7557..b68b380d 100644 --- a/src/game/partyRuntime.test.ts +++ b/src/game/partyRuntime.test.ts @@ -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); diff --git a/src/game/partyStore.test.ts b/src/game/partyStore.test.ts index 0c174721..0d666dbf 100644 --- a/src/game/partyStore.test.ts +++ b/src/game/partyStore.test.ts @@ -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); }); diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index 7b2564b8..dade883d 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -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((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((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, + }), )), })), diff --git a/src/game/specializationCatalog.test.ts b/src/game/specializationCatalog.test.ts new file mode 100644 index 00000000..6a456999 --- /dev/null +++ b/src/game/specializationCatalog.test.ts @@ -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"); + }); +}); diff --git a/src/game/specializationCatalog.ts b/src/game/specializationCatalog.ts new file mode 100644 index 00000000..3ba75577 --- /dev/null +++ b/src/game/specializationCatalog.ts @@ -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>>; +} + +const WEIGHTS: Readonly>>>> = 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); +} diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index 48b46ee1..bf98a32b 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -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, diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index 15f9e2f7..537b4ddd 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -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(); diff --git a/src/scene/PlayerRig.tsx b/src/scene/PlayerRig.tsx index f487719a..e592074d 100644 --- a/src/scene/PlayerRig.tsx +++ b/src/scene/PlayerRig.tsx @@ -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(null); const avatarRef = useRef(null); diff --git a/src/styles.css b/src/styles.css index 3f2c410a..7d306af3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/BossLootWindow.tsx b/src/ui/BossLootWindow.tsx index 95c668e2..9be0a8f6 100644 --- a/src/ui/BossLootWindow.tsx +++ b/src/ui/BossLootWindow.tsx @@ -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() {

Loot acquired automatically

- {reward.items.map((item) => ( -
{ + const comparison = classCanEquipItem(classId, item) + ? compareEquipmentUpgrade(item, loadout, profile, heirloomContext) + : null; + return
@@ -48,10 +74,11 @@ export function BossLootWindow() {

{item.name} {item.quality} · Item level {item.itemLevel} · {itemType(item.inventorySlot)} + {comparison && {comparison.isUpgrade ? `Upgrade · +${comparison.scoreDelta.toFixed(1)} score` : `${comparison.scoreDelta.toFixed(1)} score`}}

{item.quantity > 1 ? ×{item.quantity} : null} -
- ))} +
; + })} {!reward.items.length ? (

No items dropped this time.

) : null} diff --git a/src/ui/CharacterCreateScreen.tsx b/src/ui/CharacterCreateScreen.tsx index 4af61bbb..5126f938 100644 --- a/src/ui/CharacterCreateScreen.tsx +++ b/src/ui/CharacterCreateScreen.tsx @@ -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() { {characterClass.description} {characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}
+ {specializationOptions.length > 0 && ( +
+ Specialization + {specializationOptions.map((definition) => ( + updateDraft({ specializationId: definition.id })} + > + {definition.displayName} + {PARTY_ROLE_LABELS[definition.role]} / {definition.talentTree} + + ))} +
+ )}
Back to roster diff --git a/src/ui/ClassHallScreen.tsx b/src/ui/ClassHallScreen.tsx index ef60e269..358f97a6 100644 --- a/src/ui/ClassHallScreen.tsx +++ b/src/ui/ClassHallScreen.tsx @@ -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" + ? + : ; +} + +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(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(() => 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 ( + +
+ +
{characterClass.name} / Class Hall{forced ? "Choose your specialization to continue." : "Change your combat path."}
+ {forced ? "Roster" : "Back"} +
+ +
+
+

Specialization and heirloom profile

+

{forced ? "A specialization is required" : `Current: ${current.displayName}`}

+

Your saved specialization locks your activity role and tunes every heirloom stat and weapon slot. Talent points do not change specialization automatically.

+
{options.map((definition) => ( + setPendingSpecializationId(definition.id)}> + {characterClass.sigil} + {definition.displayName}{PARTY_ROLE_LABELS[definition.role]} / {definition.gearArchetype.replaceAll("-", " ")} / {definition.weaponLayout.replaceAll("-", " ")} + + ))}
+
+ {notice &&

{notice}

} +
+ {pending && ( +
+

Specialization change

+

Become {pending.displayName}?

+

Heirloom stats and your allowed activity role change immediately. Choose whether to preserve the current talent build and action bindings.

+
+ setPendingSpecializationId(null)}>Cancel + apply(false)}>Keep talents + apply(true)}>Reset talents +
+
+ )} + + + } + bottom={} + /> + ); +} + +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; diff --git a/src/ui/DungeonSelectScreen.tsx b/src/ui/DungeonSelectScreen.tsx index fffb3f71..d2d454cd 100644 --- a/src/ui/DungeonSelectScreen.tsx +++ b/src/ui/DungeonSelectScreen.tsx @@ -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); diff --git a/src/ui/EquipmentPanel.tsx b/src/ui/EquipmentPanel.tsx index 7ff82afb..b87b5c28 100644 --- a/src/ui/EquipmentPanel.tsx +++ b/src/ui/EquipmentPanel.tsx @@ -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> = 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("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() {
{inventory.length}Items