diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..17c289e676 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +QE Live — a healing gear/stat modelling tool for World of Warcraft (Retail + Classic), served at questionablyepic.com/live. Create React App (react-scripts 5) + TypeScript + Redux + MUI v5. Node 16.8.0 (`.tool-versions`, for mise/asdf). + +## Commands + +```bash +npm start # dev server on :3000 (runs with --max_old_space_size=4096) +npm run build # production build (needs 6GB heap; CI sets CI=false to ignore warnings) +npm run build:dev # PTR/staging build, uses .env.ptr via env-cmd +npm run build:prod # production build, uses .env via env-cmd +npm test # Jest in watch mode + +# Single test file / single test +npx react-scripts test --watchAll=false --testPathPattern="EffectUtilities" +npx react-scripts test --watchAll=false --testPathPattern="DiscPriestRamps" -t "Ramp name" +``` + +There is no `npm run lint` script despite the eslint config; lint runs through react-scripts during `start`/`build`. + +Imports are absolute from `src` (`tsconfig.json` `baseUrl: "./src"`), e.g. `import { CONSTANTS } from "General/Engine/CONSTANTS"`. Relative imports also appear throughout — both work. + +## Branches & CI + +- PRs to `dev` or `master` run `npm test` only (`.github/workflows/tests.yml`). +- Push to `master` → build + rsync deploy to live (`build.yml`). +- Push to `staging` → `build:dev` + rsync deploy to the PTR site (`staging.yml`). + +## Architecture + +### The two game types + +Nearly every engine path forks on `gameType: "Retail" | "Classic"`. Retail-only logic lives in `src/Retail/`, Classic-only in `src/Classic/`, and anything shared (Player, Items, TopGear, UpgradeFinder, all UI) lives in `src/General/` with internal branching. Classic specs are named `" Classic"` (e.g. `"Holy Priest Classic"`) — `Player` normalizes legacy `"BC"` suffixes to `"Classic"`. + +### Player → CastModel → evaluation + +`General/Modules/Player/Player.js` is the central mutable domain object: spec, race, `activeItems: Item[]`, `activeStats`, talents, and a list of `castModels`. `PlayerChars.ts` manages the set of saved characters and localStorage persistence. + +`General/Modules/Player/CastModel.js` is the big dispatch point: given a spec + `contentType` ("Raid" | "Dungeon") + `modelID` (hero-talent build name, e.g. "Totemic", "Farseer", "Oracle", "Herald of the Sun"), it wires up the spell list, special queries, base stat weights, talents, and optionally a `runCastModel` function from that spec's `ClassDefaults//` folder. **Adding or changing a spec build almost always means editing CastModel.js plus that spec's ClassDefaults directory.** + +Each model declares `modelType[contentType]`, one of `MODEL_TYPES` in `General/Engine/CONSTANTS.ts`, and `TopGearEngine` branches on it to pick the evaluation path: + +| modelType | Path | Used by | +|---|---|---| +| `"Default"` | Pure stat weights (`scoreItem` in ItemUtilities) | most Dungeon models, Classic | +| `"CastModel"` | Runs `castModel.runCastModel(setStats, playerData, settings)` — a cast-profile simulation returning healing | Shaman, Evoker, Druid, Monk, Disc Oracle | +| `"Sequences"` | Runs a full timeline ramp sim (`evalDiscRamp`) | Discipline Priest ramps | + +The cast-profile machinery is shared in `ClassDefaults/Generic/`: `ProfileUtilities.js` (`getSpellThroughput`, `completeCastProfile`, `convertStatPercentages`), `RampBase.js` (timeline state, stat scaling, RNG), `APLBase.js` (cast conditions), `BuffBase.js`, `TalentBase.ts`. Spec spell data lives in per-spec `*SpellDB.json`/`.js` files typed by `SpellData` in `src/globalTypes.d.ts`. + +### Effect engine + +`Retail/Engine/EffectFormulas/EffectEngine.js` `getEffectValue()` is a pure router: it takes an `ItemEffect` (`{type, name}`) plus player/castModel/contentType/itemLevel/settings and dispatches to trinket, embellishment, generic-special, set-bonus, or spec-specific formula files. Retail and Classic both route through it. Spec set bonuses go to `ClassDefaults//SpecEffects.js`; trinkets to `Generic/Trinkets/`; embellishments to `Generic/Embellishments/`. Shared math helpers (PPM→uptime, diminishing returns, `getSetting`, item-level scalar tables) are in `Retail/Engine/EffectFormulas/EffectUtilities.js`. + +Effects always return a `bonus_stats` object, never a score — scoring happens upstream. + +### Items + +`General/Items/Item.ts` is the runtime item (id, level, slot, sockets, gems, tertiary, effect, upgrade track, catalyst state, flags). Stats are computed, not stored in the DB per-ilvl: `ItemUtilities.calcStatsAtLevel()` combines `getItemAllocations()` (from `Databases/ItemDB.json`) with `Retail/Engine/RandPropPointsBylevel.ts` and `CombatMultByLevel.ts`. Items enter the app via `General/Items/GearImport/SimCImportEngine.ts` (SimC strings), `ClassicImportEngine.js`, or the Blizzard armory API. + +### Top Gear + +`TopGear/Engine/TopGearEngine.ts` builds every viable set (one item per slot, discarding unique/legendary clashes), then evaluates each via the modelType paths above. It runs off the main thread: `TopGearEngineShared.js` `createTopGearWorker()` spawns `TopGearWorker.js`, which dynamically imports the Retail or Classic engine. Because the player crosses a worker boundary, `setupPlayer()` reconstructs a `Player` from the stripped copy — functions do not survive postMessage, so anything the engine needs must be plain data or re-derived. + +`CONSTRAINTS.ts` caps the number of selectable items (`topGearMaxItems: 32`) to keep the combinatorics tractable, and `topGearDifferentials` controls how many competitive alternatives get reported. + +### State + +Redux (`src/Redux/`) holds only four things: `gameType`, `contentType`, `playerSettings`, `patronStatus`, all mirrored to localStorage via `local-storage`. Everything else is component state or lives on the `Player` object. `playerSettings` entries are `{value, options, category, type, gameType}` records that auto-render in `Modules/Settings/`; read them in engines with `getSetting(settings, "key")`. + +Routing is in `src/App.tsx` (react-router-dom v5) — one route per tool (`/topgear`, `/upgradefinder`, `/trinkets`, `/embellishments`, `/quickcompare`, `/spelldata`, …). + +## Patch / season updates + +New-season work concentrates in a few files: +- `General/Engine/CONSTANTS.ts` — `seasonalItemConversion`, `currentRaidIDs`, `currentDungeonIDs`, `fullItemLevels`, `itemLevelCaps`, `seasonID`, `tierSetIDs`. +- `Databases/ItemDB.json`, `ItemNameDB.json`, `InstanceDB.ts`, `GemDB.ts`, `EmbellishmentDB.ts`. +- `Retail/Engine/EffectFormulas/Generic/Trinkets/` for new trinket formulas; `Generic/PatchEffectItems/` for one-off patch mechanics (Cyrce's Circlet, Omnium Folio, Onyx Annulet) which usually also get a dedicated UI under `Modules/PatchEffectAnalysis/`. +- `CONSTRAINTS.ts` for item level bounds. + +## Testing + +Jest via react-scripts (`--env=jsdom`). Tests sit next to the code they cover. Two dominant styles: +- **Spell value tests** (`*SpellTests.test.js`) — `jest-each` tables of `spellName | expectedResult | index` checking formula output against in-game tooltip values within a small error margin. +- **Engine tests** (`TopGearEngine.test.js`, `TrinketData.test.js`, `EffectData.test.js`, `*Ramps.test.js`) — run the real engine over fixture sets. + +Some tables are intentionally soft-asserted (`expect(0).toEqual(0)`) while a formula is being reworked; don't assume a passing spell test is actually asserting. + +`.test.js.future` files are parked and not run. + +## Conventions worth knowing + +- Localization: user-facing strings go through i18next (`src/locale/`, languages en/de/fr/cn/ru). Item names are translated via `getTranslatedItemName(id, lang, effect, gameType)`, not hardcoded. +- Errors from engine code use `reportError(player, type, message, result)` from `General/SystemTools/ErrorLogging/ErrorReporting` rather than throwing — it POSTs to the QE backend in production and `console.error`s in dev/test. +- TypeScript is `strict` but on TS 3.9, and much of the engine is still `.js` — mixed TS/JS in the same directory is normal, and `.js` engine files are imported from `.ts` freely. +- Archived-but-kept code lives in `Archive/` subfolders under spec directories; it is dead code, not a fallback. diff --git a/src/App.test.js b/src/App.test.js index 0d66ef2d17..452b27125c 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -10,10 +10,11 @@ import {render} from '@testing-library/react'; const middlewares = [] const mockStore = configureStore(middlewares) -jest.mock('General/Modules/TopGear/Engine/TopGearEngineShared', () => { +// Worker construction uses import.meta.url, which only the webpack build can parse. Stub the factory so importing +// App doesn't drag it in. The rest of TopGearEngineShared is plain functions and loads fine. +jest.mock('General/Modules/TopGear/Engine/TopGearWorkerFactory', () => { return { - createFetcherWorker: jest.fn(), - createLoaderWorker: jest.fn(), + createTopGearWorker: jest.fn(), }; }); diff --git a/src/Databases/EmbellishmentDB.ts b/src/Databases/EmbellishmentDB.ts index 4ce384aab9..db1ee87b27 100644 --- a/src/Databases/EmbellishmentDB.ts +++ b/src/Databases/EmbellishmentDB.ts @@ -1,10 +1,29 @@ +// Every armor slot an applicable embellishment (a lining, banding, patch etc) can be attached to. +// Jewelry and weapons are handled separately since they take different reagents. +export const EMBELLISHMENT_ARMOR_SLOTS = ["Head", "Shoulder", "Back", "Chest", "Wrist", "Hands", "Waist", "Legs", "Feet"]; +export const EMBELLISHMENT_WEAPON_SLOTS = ["1H Weapon", "2H Weapon", "Warglaive Weapon", "Offhand", "Holdable"]; +export const EMBELLISHMENT_JEWELRY_SLOTS = ["Neck", "Finger"]; + type embellishmentData = { id: number; icon: string; // Shown in the Embellishment Chart - armorType: 0 | 1 | 2 | 3 | 4; // Cloth, Leather etc. + armorType: 0 | 1 | 2 | 3 | 4; // 0 = any, 1 Cloth, 2 Leather, 3 Mail, 4 Plate. name: string; warningFlag?: boolean; // True if we want to display a red symbol on the chart. pieces?: 1 | 2; // Number of pieces required for the effect. Always 1 or 2. + + // "applicable" embellishments are reagents the player chooses to add to a crafted item, so they need to show up + // in the Add Item dropdown against every slot they're legal on. Everything else is baked into a specific crafted + // item (or item family) and arrives with the item itself, so it must NOT be offered as a choice. + applicable?: boolean; + slots?: string[]; // Slots this embellishment is legal on. Only meaningful when applicable is true. + setItems?: number[]; // For baked-in embellishments: the item IDs that carry it. Any `pieces` of these trigger it. + + // Set when we know the embellishment exists but don't have a formula for it yet in EmbellishmentData. + // Those score as zero, so we must not offer them as a choice - picking one would silently do nothing. + // Delete the flag as soon as the formula lands. + unmodelled?: boolean; + effect: { type: "embellishment"; name: string; @@ -17,6 +36,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_blacksmithing_weightstone_green", armorType: 0, // Works on Weapons name: "Hunter's Ritual Stone", + applicable: true, + slots: EMBELLISHMENT_WEAPON_SLOTS, warningFlag: true, effect: { type: "embellishment", @@ -28,6 +49,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_knife_1h_ulatek_d_01", armorType: 0, // Works on Armor, leatherworking craft. name: "Adorned Fang", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, effect: { type: "embellishment", name: "Adorned Fang", @@ -38,6 +61,7 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_jewelcrafting_ring1_gold", armorType: 0, // Ring name: "Signet of Azerothian Blessings", + setItems: [241140], effect: { type: "embellishment", name: "Signet of Azerothian Blessings", @@ -46,8 +70,9 @@ export const embellishmentDB: embellishmentData[] = [ { id: 241139, icon: "inv_12_profession_jewelcrafting_necklace1_gold", - armorType: 0, // Ring + armorType: 0, // Neck name: "Thalassian Phoenix Torque", + setItems: [241139], effect: { type: "embellishment", name: "Thalassian Phoenix Torque", @@ -58,6 +83,7 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_jewelcrafting_ring3_silver", armorType: 0, // Ring name: "Loa Worshiper's Band", + setItems: [251513], warningFlag: true, effect: { type: "embellishment", @@ -67,8 +93,9 @@ export const embellishmentDB: embellishmentData[] = [ { id: 251073, icon: "inv_12_profession_jewelcrafting_necklace3_silver", - armorType: 0, // Ring + armorType: 0, // Neck name: "Voidstone Shielding Array", + setItems: [251073], effect: { type: "embellishment", name: "Voidstone Shielding Array", @@ -79,6 +106,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_inscriptions_darkmoonsigil_hunt", armorType: 0, // Weapon Reagent, DPS only name: "Darkmoon Sigil: Blood", + applicable: true, + slots: EMBELLISHMENT_WEAPON_SLOTS, effect: { type: "embellishment", name: "Darkmoon Sigil: Blood", @@ -89,6 +118,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_inscriptions_darkmoonsigil_bloom", armorType: 0, // Weapon Reagent, Based on creature type?? name: "Darkmoon Sigil: Hunt", + applicable: true, + slots: EMBELLISHMENT_WEAPON_SLOTS, effect: { type: "embellishment", name: "Darkmoon Sigil: Hunt", @@ -99,6 +130,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_inscriptions_darkmoonsigil_void", armorType: 0, // Weapon Reagent, DPS only. name: "Darkmoon Sigil: Void", + applicable: true, + slots: EMBELLISHMENT_WEAPON_SLOTS, effect: { type: "embellishment", name: "Darkmoon Sigil: Void", @@ -109,6 +142,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_jewelry_necklace_139", armorType: 0, // Vers name: "Blessed Pango Charm", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, effect: { type: "embellishment", name: "Blessed Pango Charm", @@ -119,6 +154,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_leatherworking_armor_banding_green", armorType: 0, // Healing name: "Primal Spore Binding", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, effect: { type: "embellishment", name: "Primal Spore Binding", @@ -129,6 +166,9 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_profession_leatherworking_armor_banding_brown", armorType: 0, // Healing name: "Devouring Banding", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, + unmodelled: true, // No formula in EmbellishmentData yet. effect: { type: "embellishment", name: "Devouring Banding", @@ -139,6 +179,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_tailoring_rare_cloth_violet_rare-cloth", armorType: 0, // Primary stat for you + a friend name: "Arcanoweave Lining", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, effect: { type: "embellishment", name: "Arcanoweave Lining", @@ -149,6 +191,8 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_12_tailoring_rare_cloth_orange-_rare-cloth", armorType: 0, // Healing and damage from periodics can increase int, stacking to 10. name: "Sunfire Silk Lining", + applicable: true, + slots: EMBELLISHMENT_ARMOR_SLOTS, effect: { type: "embellishment", name: "Sunfire Silk Lining", @@ -160,6 +204,7 @@ export const embellishmentDB: embellishmentData[] = [ armorType: 1, // Crit above 80% health, cloth, set pieces: 2, name: "Arcanoweave Trappings (Set)", + setItems: [239660, 239661, 239662], // Arcanoweave Bracers / Cloak / Treads effect: { type: "embellishment", name: "Arcanoweave Trappings", @@ -170,6 +215,7 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_belt_cloth_questbloodelf_b_01", armorType: 1, // Mastery proc name: "Arcanoweave Cord", + setItems: [239664], effect: { type: "embellishment", name: "Arcanoweave Cord", @@ -181,6 +227,7 @@ export const embellishmentDB: embellishmentData[] = [ armorType: 1, // Crit above 80% health, cloth, set pieces: 2, name: "Sunfire Silk Trappings (Set)", + setItems: [239657, 239658, 239659], // Sunfire Bracers / Cloak / Treads effect: { type: "embellishment", name: "Sunfire Silk Trappings", @@ -192,6 +239,8 @@ export const embellishmentDB: embellishmentData[] = [ armorType: 2, // Does random shit every 30s on a crit. Untested for healing crits. pieces: 2, name: "Murder Row Materials (Set)", + setItems: [244612, 244613, 244614], // Row Walker's Deflectors / Insurance / Swiftgrips + unmodelled: true, // No formula in EmbellishmentData yet. effect: { type: "embellishment", name: "Murder Row Materials", @@ -202,6 +251,7 @@ export const embellishmentDB: embellishmentData[] = [ icon: "inv_boot_leather_questbloodelf_b_01", armorType: 2, // Pops out orbs. Pick up orb = + highest secondary stat. name: "World Tree Rootwraps", + setItems: [244601], effect: { type: "embellishment", name: "World Tree Rootwraps", @@ -210,8 +260,9 @@ export const embellishmentDB: embellishmentData[] = [ { id: 244605, icon: "inv_bracer_mail_questbloodelf_b_01", - armorType: 3, // Stacking haste proc + armorType: 3, // Stacking haste proc name: "Axe-Flingin' Bands", + setItems: [244605], effect: { type: "embellishment", name: "Axe-Flingin' Bands", @@ -223,6 +274,7 @@ export const embellishmentDB: embellishmentData[] = [ armorType: 3, // Random secondary proc pieces: 2, name: "Root Warden's Regalia (Set)", + setItems: [244609, 244610, 244611], // World Tender's Trunkplate / Rootslippers / Barkclasp effect: { type: "embellishment", name: "Root Warden's Regalia", @@ -416,6 +468,41 @@ export const embellishmentDB: embellishmentData[] = [ },*/ ]; +/* ---------------------------------------------------------------------------------------------- */ +/* Lookups */ +/* ---------------------------------------------------------------------------------------------- */ +// These are the single source of truth for "which embellishments exist and where can they go". +// Both the Add Item dropdown (getItemEffectOptions) and the SimC importer read from here so that the +// two entry points can't drift apart from each other or from the formulas in EmbellishmentData. + +// Every embellishment the player can choose to apply to a crafted item in the given slot. +// Anything we don't have a formula for is left out - it would score as a flat zero and look like a bad choice +// rather than an unimplemented one. +export const getApplicableEmbellishments = (slot: string): embellishmentData[] => { + if (!slot) return []; + return embellishmentDB.filter((embel) => embel.applicable === true && !embel.unmodelled && (embel.slots || []).includes(slot)); +}; + +// Look an embellishment up by its effect name. This is the name the formulas in EmbellishmentData key off. +export const getEmbellishmentByEffectName = (effectName: string): embellishmentData | undefined => { + if (!effectName) return undefined; + const trimmed = effectName.trim(); + return embellishmentDB.find((embel) => embel.effect.name.trim() === trimmed || embel.name.trim() === trimmed); +}; + +// Returns the embellishment carried by a given crafted item ID, if any. Used to attach baked-in effects +// (Axe-Flingin' Bands, the World Tender's set and so on) without having to duplicate them into ItemDB. +export const getEmbellishmentForItem = (itemID: number): embellishmentData | undefined => { + if (!itemID) return undefined; + return embellishmentDB.find((embel) => (embel.setItems || []).includes(itemID)); +}; + +// The set of item IDs that carry a baked-in embellishment. +export const embellishmentItemIDs: number[] = embellishmentDB.reduce( + (acc: number[], embel) => acc.concat(embel.setItems || []), + [], +); + /* { id: 204710, //406254, diff --git a/src/Databases/GemDB.ts b/src/Databases/GemDB.ts index 1653336785..6744d788eb 100644 --- a/src/Databases/GemDB.ts +++ b/src/Databases/GemDB.ts @@ -248,4 +248,37 @@ export const gemDB: GemEntry[] = [ stats: { intellect: 12 }, }, ]; - \ No newline at end of file + +/* ---------------------------------------------------------------------------------------------- */ +/* Gem Choices */ +/* ---------------------------------------------------------------------------------------------- */ +// The current tier's gems form a complete matrix: each secondary as the major stat (12) paired with each of the +// other three as the minor (5). These drive the gem dropdowns, so the labels here are what the player sees. + +export const META_GEM_OPTIONS: { [label: string]: number } = { + "Indecipherable (Intellect)": 240983, + "Telluric (Mana)": 240969, +}; + +// label -> [major stat, minor stat]. getGemID resolves the pair to an actual gem. +export const GEM_COMBO_OPTIONS: { [label: string]: [string, string] } = { + "Haste / Crit": ["haste", "crit"], + "Haste / Mastery": ["haste", "mastery"], + "Haste / Vers": ["haste", "versatility"], + "Crit / Haste": ["crit", "haste"], + "Crit / Mastery": ["crit", "mastery"], + "Crit / Vers": ["crit", "versatility"], + "Mastery / Haste": ["mastery", "haste"], + "Mastery / Crit": ["mastery", "crit"], + "Mastery / Vers": ["mastery", "versatility"], + "Vers / Haste": ["versatility", "haste"], + "Vers / Crit": ["versatility", "crit"], + "Vers / Mastery": ["versatility", "mastery"], +}; + +// Finds the gem that grants 12 of the major stat and 5 of the minor. Returns 0 when no such gem exists so the +// caller can fall back rather than socketing something arbitrary. +export const findGemByStats = (majorStat: string, minorStat: string): number => { + const match = gemDB.find((gem) => gem.stats[majorStat] === 12 && gem.stats[minorStat] === 5); + return match ? match.id : 0; +}; diff --git a/src/General/Engine/CONSTRAINTS.ts b/src/General/Engine/CONSTRAINTS.ts index ef97435a0f..46193b617c 100644 --- a/src/General/Engine/CONSTRAINTS.ts +++ b/src/General/Engine/CONSTRAINTS.ts @@ -17,6 +17,7 @@ export const CONSTRAINTS = { Shared: { topGearMaxItems: 32, // The maximum number of items selectable by the player. Combinatorial explosion requires we keep this reasonable. topGearDifferentials: 12, // Number of competitive alternatives to show. + topGearOptimizeSets: 20, // How many leading sets get their gems/enchants jointly optimised when that setting is on. } } diff --git a/src/General/Engine/CraftedEmbellishments.test.js b/src/General/Engine/CraftedEmbellishments.test.js new file mode 100644 index 0000000000..30802c0342 --- /dev/null +++ b/src/General/Engine/CraftedEmbellishments.test.js @@ -0,0 +1,202 @@ +import { getItemEffectOptions, hasUnallocatedStats, getItemAllocations } from "./ItemUtilities"; +import { getApplicableEmbellishments, getEmbellishmentByEffectName, getEmbellishmentForItem, embellishmentDB } from "Databases/EmbellishmentDB"; +import { embellishmentData } from "Retail/Engine/EffectFormulas/Generic/Embellishments/EmbellishmentData"; +import Item from "General/Items/Item"; +import ItemSet from "General/Modules/TopGear/ItemSet"; +import Player from "General/Modules/Player/Player"; +import { getEffectValue } from "Retail/Engine/EffectFormulas/EffectEngine"; + +const effectSettings = { + calculateEmbellishments: { value: true, options: [true, false], category: "embellishments", type: "selector" }, + darkmoonHuntStat: { value: "Mastery", options: ["Mastery", "Versatility", "Crit", "Haste"], category: "embellishments", type: "selector" }, +}; + +/* + Crafted gear and embellishments for Preservation Evoker (mail). + + These cover the paths that previously dropped embellishments silently: the Add Item dropdown, the crafted stat + picker, embellishments baked into specific crafted items, and multi-piece embellishment sets. +*/ + +// Mail crafted gear an Evoker can actually wear. +const WORLD_TENDERS_CHEST = 244609; // Root Warden's Regalia (Set) carrier +const WORLD_TENDERS_FEET = 244610; +const WORLD_TENDERS_WAIST = 244611; +const AXE_FLINGIN_BANDS = 244605; // 1 piece mail embellishment +const FARSTRIDERS_CHEST = 244578; // plain crafted mail, takes an applied embellishment +const MAGISTERS_RITUAL_KNIFE = 237838; // crafted intellect dagger +const CONSECRATED_CLOAK = 271460; // newer crafted piece with fixed secondaries + +describe("Embellishment DB is consistent with the formulas that score it", () => { + const hasFormula = (embel) => embellishmentData.some((data) => data.name.trim() === embel.effect.name.trim()); + + test("every embellishment either has a formula or is flagged unmodelled", () => { + const silentlyBroken = embellishmentDB.filter((embel) => !hasFormula(embel) && !embel.unmodelled).map((embel) => embel.effect.name); + + expect(silentlyBroken).toEqual([]); + }); + + test("nothing is flagged unmodelled once a formula exists for it", () => { + const staleFlags = embellishmentDB.filter((embel) => embel.unmodelled && hasFormula(embel)).map((embel) => embel.effect.name); + + expect(staleFlags).toEqual([]); + }); + + test("unmodelled embellishments are never offered as a choice", () => { + const unmodelled = embellishmentDB.filter((embel) => embel.unmodelled).map((embel) => embel.effect.name); + expect(unmodelled.length).toBeGreaterThan(0); // Guard against this test quietly becoming vacuous. + + const offered = ["Chest", "Wrist", "Waist", "Feet", "Head", "Legs", "Hands", "Back", "Shoulder", "1H Weapon", "2H Weapon", "Offhand"] + .reduce((acc, slot) => acc.concat(getApplicableEmbellishments(slot).map((embel) => embel.effect.name)), []); + + unmodelled.forEach((name) => expect(offered).not.toContain(name)); + }); + + test("lookup by effect name tolerates stray whitespace in either DB", () => { + expect(getEmbellishmentByEffectName("Sunfire Silk Lining")).toBeTruthy(); + expect(getEmbellishmentByEffectName(" Sunfire Silk Lining ")).toBeTruthy(); + expect(getEmbellishmentByEffectName("Not A Real Embellishment")).toBeUndefined(); + }); +}); + +describe("Add Item offers the embellishments that exist rather than a hardcoded subset", () => { + test("a crafted mail chest offers every applicable armor embellishment", () => { + const options = getItemEffectOptions(FARSTRIDERS_CHEST).map((opt) => opt.effectName); + + // These four were the entire hardcoded list before, and must still be present. + expect(options).toEqual(expect.arrayContaining(["Arcanoweave Lining", "Primal Spore Binding", "Blessed Pango Charm", "Adorned Fang"])); + // This was in the DB with a working formula but was never offered. + expect(options).toContain("Sunfire Silk Lining"); + }); + + test("a crafted weapon offers the weapon reagents, including Darkmoon Sigil: Blood", () => { + const options = getItemEffectOptions(MAGISTERS_RITUAL_KNIFE).map((opt) => opt.effectName); + + expect(options).toEqual(expect.arrayContaining(["Darkmoon Sigil: Hunt", "Darkmoon Sigil: Void", "Hunter's Ritual Stone"])); + expect(options).toContain("Darkmoon Sigil: Blood"); + }); + + test("armor embellishments are not offered on weapons and weapon reagents are not offered on armor", () => { + const weaponOptions = getItemEffectOptions(MAGISTERS_RITUAL_KNIFE).map((opt) => opt.effectName); + const armorOptions = getItemEffectOptions(FARSTRIDERS_CHEST).map((opt) => opt.effectName); + + expect(weaponOptions).not.toContain("Arcanoweave Lining"); + expect(armorOptions).not.toContain("Darkmoon Sigil: Hunt"); + }); + + test("embellishments baked into a specific item are never offered as a choice", () => { + const allOffered = getApplicableEmbellishments("Chest").concat(getApplicableEmbellishments("Wrist")).map((embel) => embel.effect.name); + + expect(allOffered).not.toContain("Axe-Flingin' Bands"); + expect(allOffered).not.toContain("Root Warden's Regalia"); + }); + + test("non-crafted items offer nothing", () => { + expect(getItemEffectOptions(0)).toEqual([]); + }); +}); + +describe("Crafted stat picker only appears when there is budget to assign", () => { + test("older crafted gear with unallocated stats can have its secondaries chosen", () => { + expect(hasUnallocatedStats(FARSTRIDERS_CHEST)).toBe(true); + + const haste = getItemAllocations(FARSTRIDERS_CHEST, ["haste", "mastery"]); + const crit = getItemAllocations(FARSTRIDERS_CHEST, ["crit", "versatility"]); + + expect(haste.haste).toBeGreaterThan(0); + expect(crit.crit).toBeGreaterThan(0); + expect(haste.crit || 0).toEqual(0); + }); + + test("crafted gear with fixed secondaries reports no assignable budget", () => { + // This item ships with its secondaries already baked in, so offering a stat picker would do nothing. + expect(hasUnallocatedStats(CONSECRATED_CLOAK)).toBe(false); + + const asHaste = getItemAllocations(CONSECRATED_CLOAK, ["haste", "mastery"]); + const asCrit = getItemAllocations(CONSECRATED_CLOAK, ["crit", "versatility"]); + expect(asHaste).toEqual(asCrit); + }); +}); + +describe("Embellishments baked into crafted items attach automatically", () => { + test("EmbellishmentDB knows which items carry which embellishment", () => { + expect(getEmbellishmentForItem(AXE_FLINGIN_BANDS).effect.name).toEqual("Axe-Flingin' Bands"); + expect(getEmbellishmentForItem(WORLD_TENDERS_CHEST).effect.name).toEqual("Root Warden's Regalia"); + expect(getEmbellishmentForItem(FARSTRIDERS_CHEST)).toBeUndefined(); + }); + + test("a mail set piece with no effect in ItemDB still builds with its embellishment", () => { + // ItemDB has no effect block on the World Tender's pieces, which meant they simmed as plain stat sticks. + const item = new Item(WORLD_TENDERS_CHEST, "World Tender's Trunkplate", "Chest", 0, "", 0, 330, ""); + + expect(item.effect).toBeTruthy(); + expect(item.effect.name).toEqual("Root Warden's Regalia"); + expect(item.effect.type).toEqual("embellishment"); + }); + + test("an item that already has an effect in ItemDB keeps it", () => { + const item = new Item(AXE_FLINGIN_BANDS, "Axe-Flingin' Bands", "Wrist", 0, "", 0, 330, ""); + expect(item.effect.name).toEqual("Axe-Flingin' Bands"); + }); +}); + +describe("Multi-piece embellishment sets are counted once, and only when complete", () => { + const buildSet = (itemIDs) => { + const items = itemIDs.map((entry) => new Item(entry.id, "", entry.slot, 0, "", 0, 330, "")); + return new ItemSet(1, items, 0, "Preservation Evoker").compileStats("Retail", {}); + }; + + const countEffect = (set, name) => set.effectList.filter((effect) => effect.name === name).length; + + test("one piece of a two piece set grants nothing", () => { + const set = buildSet([{ id: WORLD_TENDERS_CHEST, slot: "Chest" }]); + expect(countEffect(set, "Root Warden's Regalia")).toEqual(0); + }); + + test("two pieces grant the set bonus exactly once", () => { + const set = buildSet([ + { id: WORLD_TENDERS_CHEST, slot: "Chest" }, + { id: WORLD_TENDERS_FEET, slot: "Feet" }, + ]); + expect(countEffect(set, "Root Warden's Regalia")).toEqual(1); + }); + + test("three pieces still only grant it once", () => { + const set = buildSet([ + { id: WORLD_TENDERS_CHEST, slot: "Chest" }, + { id: WORLD_TENDERS_FEET, slot: "Feet" }, + { id: WORLD_TENDERS_WAIST, slot: "Waist" }, + ]); + expect(countEffect(set, "Root Warden's Regalia")).toEqual(1); + }); + + test("each worn set piece consumes an embellishment slot", () => { + const set = buildSet([ + { id: WORLD_TENDERS_CHEST, slot: "Chest" }, + { id: WORLD_TENDERS_FEET, slot: "Feet" }, + ]); + expect(set.uniques["embellishment"]).toEqual(2); + }); + + test("a single piece embellishment is unaffected", () => { + const set = buildSet([{ id: AXE_FLINGIN_BANDS, slot: "Wrist" }]); + expect(countEffect(set, "Axe-Flingin' Bands")).toEqual(1); + }); + + test("the resolved set bonus actually scores, rather than resolving to an empty effect", () => { + // Attaching the effect is only half the job - it also has to route through EffectEngine to a formula that + // returns stats. A name mismatch anywhere in that chain comes back as {} and silently scores zero. + const set = buildSet([ + { id: WORLD_TENDERS_CHEST, slot: "Chest" }, + { id: WORLD_TENDERS_FEET, slot: "Feet" }, + ]); + const effect = set.effectList.find((entry) => entry.name === "Root Warden's Regalia"); + + const player = new Player("Tester", "Preservation Evoker", 1, "US", "Stonemaul", "Dracthyr", "default", "Retail"); + const bonusStats = getEffectValue(effect, player, player.getActiveModel("Raid"), "Raid", effect.level, effectSettings, "Retail", {}, {}); + + expect(Object.keys(bonusStats).length).toBeGreaterThan(0); + const total = Object.values(bonusStats).reduce((sum, value) => sum + (value || 0), 0); + expect(total).toBeGreaterThan(0); + }); +}); diff --git a/src/General/Engine/EmbellishmentCap.test.js b/src/General/Engine/EmbellishmentCap.test.js new file mode 100644 index 0000000000..4f6ac3d97d --- /dev/null +++ b/src/General/Engine/EmbellishmentCap.test.js @@ -0,0 +1,105 @@ +import Item from "General/Items/Item"; +import ItemSet from "General/Modules/TopGear/ItemSet"; +import { isEmbellished, getForcedEmbellishmentCount, MAX_EMBELLISHMENTS } from "General/Engine/ItemUtilities"; + +/* + Only two embellishments can be worn at once, and ItemSet.verifySet throws out any set that exceeds it. That's + correct, but it used to happen invisibly: a player already wearing two embellishments who added a third + embellished item (typically a crafted weapon) had it silently dropped from every set. If the new item was their + only option in its slot, every set failed verification and Top Gear returned an empty report with no explanation. +*/ + +const CRAFTED_MAIL_CHEST = 244578; +const CRAFTED_2H = 245770; // Aln'hara Cane +const CRAFTED_OFFHAND = 245769; // Aln'hara Lantern +const PLAIN_2H = 268205; // Venomancer's Winged Channeler, raid drop with no embellishment + +const embellished = (id, slot, level = 330) => { + const item = new Item(id, "", slot, 0, "", 0, level, ""); + item.effect = { type: "embellishment", name: "Arcanoweave Lining", level: level }; + item.uniqueEquip = "embellishment"; // what ItemBar sets when an embellishment is chosen + return item; +}; + +const plain = (id, slot, level = 330) => new Item(id, "", slot, 0, "", 0, level, ""); + +describe("isEmbellished", () => { + test("detects an embellishment applied through the item bar", () => { + expect(isEmbellished(embellished(CRAFTED_MAIL_CHEST, "Chest"))).toBe(true); + }); + + test("detects an embellishment baked into the item", () => { + // Axe-Flingin' Bands carries its embellishment inherently rather than having one applied. + expect(isEmbellished(new Item(244605, "", "Wrist", 0, "", 0, 330, ""))).toBe(true); + }); + + test("a plain item is not embellished", () => { + expect(isEmbellished(plain(PLAIN_2H, "2H Weapon"))).toBe(false); + expect(isEmbellished(null)).toBe(false); + }); +}); + +describe("A set over the embellishment cap is rejected", () => { + const buildSet = (items) => new ItemSet(1, items, 0, "Preservation Evoker").compileStats("Retail", {}); + + test("two embellishments is fine", () => { + const set = buildSet([embellished(CRAFTED_MAIL_CHEST, "Chest"), embellished(244584, "Wrist")]); + + expect(set.uniques["embellishment"]).toEqual(MAX_EMBELLISHMENTS); + expect(set.verifySet({})).toBe(true); + }); + + test("three embellishments is rejected, which is what hides the third item", () => { + const set = buildSet([ + embellished(CRAFTED_MAIL_CHEST, "Chest"), + embellished(244584, "Wrist"), + embellished(CRAFTED_2H, "2H Weapon", 331), + ]); + + expect(set.uniques["embellishment"]).toEqual(3); + expect(set.verifySet({})).toBe(false); + }); +}); + +describe("getForcedEmbellishmentCount spots the unwinnable case up front", () => { + test("counts slots where every selected option is embellished", () => { + const items = [ + embellished(CRAFTED_MAIL_CHEST, "Chest"), // only chest, embellished -> forced + embellished(244584, "Wrist"), // only wrist, embellished -> forced + embellished(CRAFTED_2H, "2H Weapon", 331), // only weapon, embellished -> forced + ]; + + expect(getForcedEmbellishmentCount(items)).toEqual(3); + expect(getForcedEmbellishmentCount(items)).toBeGreaterThan(MAX_EMBELLISHMENTS); + }); + + test("a slot with a non-embellished alternative is not forced", () => { + const items = [ + embellished(CRAFTED_MAIL_CHEST, "Chest"), + embellished(244584, "Wrist"), + embellished(CRAFTED_2H, "2H Weapon", 331), + plain(PLAIN_2H, "2H Weapon"), // gives the weapon slot a way out + ]; + + expect(getForcedEmbellishmentCount(items)).toEqual(2); + expect(getForcedEmbellishmentCount(items)).toBeLessThanOrEqual(MAX_EMBELLISHMENTS); + }); + + test("weapons and offhands count as a single slot, since a set only takes one combination", () => { + const items = [ + embellished(CRAFTED_2H, "2H Weapon", 331), + embellished(CRAFTED_OFFHAND, "Offhand", 331), + ]; + + // Two embellished weapon-side items are one forced slot, not two. + expect(getForcedEmbellishmentCount(items)).toEqual(1); + }); + + test("a fully plain set forces nothing", () => { + expect(getForcedEmbellishmentCount([plain(PLAIN_2H, "2H Weapon"), plain(244578, "Chest")])).toEqual(0); + }); + + test("an empty selection forces nothing", () => { + expect(getForcedEmbellishmentCount([])).toEqual(0); + }); +}); diff --git a/src/General/Engine/ItemUtilities.ts b/src/General/Engine/ItemUtilities.ts index e0d84f338c..c6b361b9cb 100644 --- a/src/General/Engine/ItemUtilities.ts +++ b/src/General/Engine/ItemUtilities.ts @@ -1,5 +1,5 @@ import itemDB from "Databases/ItemDB.json"; -import { embellishmentDB } from "../../Databases/EmbellishmentDB"; +import { embellishmentDB, getApplicableEmbellishments, getEmbellishmentForItem } from "../../Databases/EmbellishmentDB"; import { getOnyxAnnuletEffect } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OnyxAnnuletData"; import { getCircletEffect } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/CyrcesCircletData"; import classicItemDB from "Databases/ClassicItemDB.json"; @@ -383,28 +383,23 @@ export function getItemLevelBoost(bossID: number, difficulty: number) { } // Sometimes items have an optional effect that can be added to them. Embellishments for example, or different variations (Changeling / Circlet). +// The embellishment list is generated from EmbellishmentDB rather than hardcoded here. Hardcoding it meant every new patch +// silently shipped a dropdown that was missing embellishments we already had working formulas for. export const getItemEffectOptions = (itemID: number, gameType: gameTypes = "Retail"): { type: string; label: string; effectName: string }[] => { const options: { type: string; label: string; effectName: string }[] = []; // type: "embellishment", label: "Add Embellishment: Writhing Armor Banding", effectName: "Writhing Armor Banding" const item = getItem(itemID); + if (!item) return options; + const isEngineering = getItemProp(itemID, "engineering"); if (getItemProp(item.id, "crafted")) { // Crafted item effects are limited to Embellishments currently. - if (item.slot.includes("Weapon") || item.slot === "Offhand") { - // Sigil embellishments are limited to weapon and offhand slots. Does NOT include Shields. - options.push({type: "embellishment", label: "Darkmoon Sigil: Hunt", effectName: "Darkmoon Sigil: Hunt"}) - options.push({type: "embellishment", label: "Darkmoon Sigil: Void", effectName: "Darkmoon Sigil: Void"}) - options.push({type: "embellishment", label: "Hunter's Ritual Stone", effectName: "Hunter's Ritual Stone"}) - //options.push({type: "embellishment", label: "Darkmoon Sigil: Symbiosis", effectName: "Darkmoon Sigil: Symbiosis"}) - } - if (item.slot !== "Finger" && item.slot !== "Neck" && !item.slot.includes("Weapon") && !isEngineering) { - // Linings & Armor Banding are limited to non-weapon, non-jewelry slots. - options.push({type: "embellishment", label: "Arcanoweave Lining", effectName: "Arcanoweave Lining"}) - options.push({type: "embellishment", label: "Primal Spore Binding", effectName: "Primal Spore Binding"}) - options.push({type: "embellishment", label: "Blessed Pango Charm", effectName: "Blessed Pango Charm"}) - options.push({type: "embellishment", label: "Adorned Fang", effectName: "Adorned Fang"}) + // Engineering pieces take their own tinkers and can't hold a normal embellishment. + if (!isEngineering) { + getApplicableEmbellishments(item.slot).forEach((embel) => { + options.push({ type: "embellishment", label: embel.name, effectName: embel.effect.name }); + }); } - } // Now, we can also add non-embellishment options here but we don't have any prominent ones yet so TODO. @@ -667,6 +662,43 @@ export function checkDefaultSocket(id: number) { } // Returns item stat allocations. MUST be converted to stats before it's used in any scoring capacity. +// The number of embellishments a character can wear at once. Exceeding it makes a set unwearable in game, so +// Top Gear discards those sets entirely - which looks like the item you just added being ignored. +export const MAX_EMBELLISHMENTS = 2; + +// True if wearing this item uses up one of the player's embellishment slots. +export function isEmbellished(item: any) { + if (!item) return false; + return (typeof item.uniqueEquip === "string" && item.uniqueEquip.toLowerCase() === "embellishment") || + (!!item.effect && item.effect.type === "embellishment"); +} + +// Counts the embellishments the player is forced to wear: slots where every selected item is embellished leave no +// choice. If that forced total exceeds the cap then no wearable set exists at all and Top Gear will return nothing, +// so we can tell the player up front instead of handing them an empty report. +export function getForcedEmbellishmentCount(itemList: any[]) { + const bySlot: { [key: string]: { total: number; embellished: number } } = {}; + + itemList.forEach((item) => { + // Weapons are combined separately and a set only ever takes one combination, so count them as a single slot. + const slot = ["1H Weapon", "2H Weapon", "Offhand", "Holdable", "Shield"].includes(item.slot) ? "Weapon" : item.slot; + if (!bySlot[slot]) bySlot[slot] = { total: 0, embellished: 0 }; + bySlot[slot].total += 1; + if (isEmbellished(item)) bySlot[slot].embellished += 1; + }); + + return Object.keys(bySlot).filter((slot) => bySlot[slot].total > 0 && bySlot[slot].total === bySlot[slot].embellished).length; +} + +// Returns true if the item has stat budget that is assigned by the player (missives / crafted stats) rather than +// baked into the DB row. Newer crafted gear frequently ships with its secondaries already fixed, in which case the +// crafted stat picker does nothing and showing it just misleads people into thinking they've changed something. +export function hasUnallocatedStats(id: number, gameType: gameTypes = "Retail") { + const item = getItem(id, gameType); + if (!item || !item.stats) return false; + return "unallocated" in item.stats || "unallocated2" in item.stats; +} + export function getItemAllocations(id: number, missiveStats: any[] = [], gameType: gameTypes = "Retail") { const item = getItem(id, gameType); @@ -738,6 +770,7 @@ export function buildNewWepCombos(player: Player, active: boolean = false, equip for (let i = 0; i < main_hands.length; i++) { // Some say j is the best variable for a nested loop, but are they right? let main_hand = main_hands[i]; + let paired = false; for (let k = 0; k < off_hands.length; k++) { let off_hand = off_hands[k]; @@ -747,8 +780,14 @@ export function buildNewWepCombos(player: Player, active: boolean = false, equip } else { const combo = [main_hand, off_hand]; combos.push(combo); + paired = true; } } + + // A one hander that couldn't be paired with anything still has to be offered on its own. Dropping it used to + // make the weapon invisible to Top Gear, and if it was the player's only weapon there were no valid combos at + // all, which meant zero sets and an empty report rather than a result with an empty offhand slot. + if (!paired) combos.push([main_hand]); } for (let j = 0; j < two_handers.length; j++) { diff --git a/src/General/Engine/WeaponCombos.test.js b/src/General/Engine/WeaponCombos.test.js new file mode 100644 index 0000000000..72081af389 --- /dev/null +++ b/src/General/Engine/WeaponCombos.test.js @@ -0,0 +1,109 @@ +import Player from "General/Modules/Player/Player"; +import Item from "General/Items/Item"; +import { buildNewWepCombos, getValidWeaponTypesBySpec, getItemProp } from "General/Engine/ItemUtilities"; + +/* + Weapon combinations for Top Gear. + + Top Gear doesn't take weapons from the per-slot item lists - they arrive as pre-built combos, and the set builder + loops `weapon < wepCombos.length`. That means a weapon missing from this list isn't just excluded from the + comparison: if it was the only weapon selected there are zero combos, the loop body never runs, and Top Gear + produces no sets at all. A one handed weapon used to be dropped whenever the player hadn't also selected an + offhand, so adding a single one hander returned an empty report. +*/ + +const ONE_HANDER = 271092; // Jan'thrazet, the Soul Fang - dagger, current raid +const TWO_HANDER = 245770; // Aln'hara Cane - crafted staff +const OFFHAND = 245769; // Aln'hara Lantern - crafted offhand + +const makePlayer = () => new Player("Tester", "Preservation Evoker", 1, "US", "Stonemaul", "Dracthyr", "default", "Retail"); + +const withWeapons = (weapons) => { + const player = makePlayer(); + weapons.forEach(([id, slot, level]) => player.activeItems.push(new Item(id, "", slot, 0, "", 0, level, ""))); + player.activateAll(); + return buildNewWepCombos(player, true); +}; + +const slotsOf = (combos) => combos.map((combo) => combo.map((item) => item.slot)); + +describe("Preservation Evoker can use these weapons at all", () => { + test("the test weapons are the shapes this suite assumes", () => { + expect(getItemProp(ONE_HANDER, "slot")).toEqual("1H Weapon"); + expect(getItemProp(TWO_HANDER, "slot")).toEqual("2H Weapon"); + expect(getItemProp(OFFHAND, "slot")).toEqual("Offhand"); + }); + + test("their subclasses are all usable by the spec", () => { + const usable = getValidWeaponTypesBySpec("Preservation Evoker"); + + expect(usable).toContain(getItemProp(ONE_HANDER, "itemSubClass")); // daggers + expect(usable).toContain(getItemProp(TWO_HANDER, "itemSubClass")); // staves + }); +}); + +describe("Every selected weapon reaches Top Gear", () => { + test("a two hander on its own is offered", () => { + const combos = withWeapons([[TWO_HANDER, "2H Weapon", 331]]); + + expect(combos.length).toEqual(1); + expect(slotsOf(combos)).toEqual([["2H Weapon"]]); + }); + + test("a one hander on its own is offered, with an empty offhand", () => { + const combos = withWeapons([[ONE_HANDER, "1H Weapon", 334]]); + + // Regression: this used to be 0, which produced no sets and therefore a completely empty report. + expect(combos.length).toEqual(1); + expect(slotsOf(combos)).toEqual([["1H Weapon"]]); + }); + + test("a one hander pairs with an offhand when one is selected", () => { + const combos = withWeapons([ + [ONE_HANDER, "1H Weapon", 334], + [OFFHAND, "Offhand", 331], + ]); + + expect(combos.length).toEqual(1); + expect(slotsOf(combos)).toEqual([["1H Weapon", "Offhand"]]); + }); + + test("an unpaired one hander is not dropped just because a two hander exists", () => { + const combos = withWeapons([ + [ONE_HANDER, "1H Weapon", 334], + [TWO_HANDER, "2H Weapon", 331], + ]); + + // Regression: this used to return only the two hander, so the one hander was never compared. + expect(combos.length).toEqual(2); + expect(slotsOf(combos)).toEqual(expect.arrayContaining([["1H Weapon"], ["2H Weapon"]])); + }); + + test("a one hander is paired rather than offered bare when both are available", () => { + const combos = withWeapons([ + [ONE_HANDER, "1H Weapon", 334], + [OFFHAND, "Offhand", 331], + [TWO_HANDER, "2H Weapon", 331], + ]); + + expect(combos.length).toEqual(2); + expect(slotsOf(combos)).toEqual(expect.arrayContaining([["1H Weapon", "Offhand"], ["2H Weapon"]])); + // The bare fallback must not fire when a real pairing exists. + expect(slotsOf(combos)).not.toContainEqual(["1H Weapon"]); + }); + + test("every selected weapon appears somewhere in the combo list", () => { + const combos = withWeapons([ + [ONE_HANDER, "1H Weapon", 334], + [TWO_HANDER, "2H Weapon", 331], + [OFFHAND, "Offhand", 331], + ]); + const ids = combos.reduce((acc, combo) => acc.concat(combo.map((item) => item.id)), []); + + [ONE_HANDER, TWO_HANDER, OFFHAND].forEach((id) => expect(ids).toContain(id)); + }); + + test("no weapons selected still means no combos", () => { + expect(withWeapons([]).length).toEqual(0); + }); +}); diff --git a/src/General/Items/GearImport/SimCEmbellishmentImport.test.js b/src/General/Items/GearImport/SimCEmbellishmentImport.test.js new file mode 100644 index 0000000000..61d5e4b599 --- /dev/null +++ b/src/General/Items/GearImport/SimCEmbellishmentImport.test.js @@ -0,0 +1,88 @@ +import Player from "General/Modules/Player/Player"; +import { processItem } from "General/Items/GearImport/SimCImportEngine"; +import { embellishmentDB } from "Databases/EmbellishmentDB"; + +/* + The SimC importer used to check the incoming spell name against a hardcoded list of eight embellishments. + Anything outside that list was parsed and then silently discarded, so the item imported as a plain stat stick + and Top Gear scored it as one. These cover the embellishments an Evoker can realistically import. +*/ + +const player = new Player("Tester", "Preservation Evoker", 1, "US", "Stonemaul", "Dracthyr", "default", "Retail"); +const settings = {}; + +// Bonus IDs that carry each embellishment's spell, taken from BonusIDs.ts. +const EMBELLISHMENT_BONUS_IDS = { + "Adorned Fang": 13767, + "Sunfire Silk Lining": 12385, + "Arcanoweave Lining": 12384, + "Hunter's Ritual Stone": 13771, + "Blessed Pango Charm": 12686, + "Primal Spore Binding": 12687, + "Darkmoon Sigil: Hunt": 12693, + "Darkmoon Sigil: Void": 13640, + "Darkmoon Sigil: Blood": 12705, +}; + +// A crafted mail chest and a crafted dagger, both usable by a Preservation Evoker. +const CRAFTED_MAIL_CHEST = 244578; +const CRAFTED_DAGGER = 237838; + +const importItem = (itemID, slotName, bonusID) => { + const line = `${slotName}=,id=${itemID},bonus_id=12052/${bonusID},crafted_stats=36/49,crafting_quality=5`; + return processItem(line, player, "Raid", "", settings, false, false); +}; + +describe("SimC import keeps embellishments instead of dropping them", () => { + test("an embellishment that was already on the old allowlist still imports", () => { + const item = importItem(CRAFTED_MAIL_CHEST, "chest", EMBELLISHMENT_BONUS_IDS["Arcanoweave Lining"]); + + expect(item).toBeTruthy(); + expect(item.effect).toBeTruthy(); + expect(item.effect.type).toEqual("embellishment"); + expect(item.effect.name).toEqual("Arcanoweave Lining"); + }); + + test("Adorned Fang imports - it was offered in the UI but dropped on import", () => { + const item = importItem(CRAFTED_MAIL_CHEST, "chest", EMBELLISHMENT_BONUS_IDS["Adorned Fang"]); + + expect(item.effect).toBeTruthy(); + expect(item.effect.name).toEqual("Adorned Fang"); + }); + + test("Hunter's Ritual Stone imports onto a weapon", () => { + const item = importItem(CRAFTED_DAGGER, "main_hand", EMBELLISHMENT_BONUS_IDS["Hunter's Ritual Stone"]); + + expect(item.effect).toBeTruthy(); + expect(item.effect.name).toEqual("Hunter's Ritual Stone"); + }); + + test("Darkmoon Sigils resolve from their bare spell name to the full embellishment name", () => { + ["Darkmoon Sigil: Hunt", "Darkmoon Sigil: Void", "Darkmoon Sigil: Blood"].forEach((name) => { + const item = importItem(CRAFTED_DAGGER, "main_hand", EMBELLISHMENT_BONUS_IDS[name]); + + expect(item.effect).toBeTruthy(); + expect(item.effect.name).toEqual(name); + }); + }); + + test("every imported embellishment name matches an entry in EmbellishmentDB", () => { + // A name that doesn't match the DB scores as a flat zero, which is the failure mode this guards against. + Object.entries(EMBELLISHMENT_BONUS_IDS).forEach(([name, bonusID]) => { + const slot = name.includes("Sigil") || name.includes("Ritual Stone") ? "main_hand" : "chest"; + const itemID = slot === "main_hand" ? CRAFTED_DAGGER : CRAFTED_MAIL_CHEST; + const item = importItem(itemID, slot, bonusID); + + expect(item.effect).toBeTruthy(); + expect(embellishmentDB.some((embel) => embel.effect.name === item.effect.name)).toBe(true); + }); + }); + + test("a crafted item with no embellishment bonus ID imports without an effect", () => { + const line = `chest=,id=${CRAFTED_MAIL_CHEST},bonus_id=12052,crafted_stats=36/49,crafting_quality=5`; + const item = processItem(line, player, "Raid", "", settings, false, false); + + expect(item).toBeTruthy(); + expect(item.effect).toBeFalsy(); + }); +}); diff --git a/src/General/Items/GearImport/SimCImportEngine.ts b/src/General/Items/GearImport/SimCImportEngine.ts index 0bb5d44629..8281cbaf7a 100644 --- a/src/General/Items/GearImport/SimCImportEngine.ts +++ b/src/General/Items/GearImport/SimCImportEngine.ts @@ -7,6 +7,7 @@ import { CONSTANTS } from "General/Engine/CONSTANTS"; import { getTitanDiscName } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/TitanDiscBeltData" import ItemSquishEras from "Retail/Engine/ItemSquishEras.json" import { bonusLootCaches } from "Databases/InstanceDB"; +import { getEmbellishmentByEffectName } from "Databases/EmbellishmentDB"; /** * This entire page is a bit of a disaster, owing mostly to how bizarrely some things are implemented in game. @@ -21,6 +22,18 @@ const stat_ids: {[key: number]: string} = { 49: "mastery", }; +// SimC gives us the spell name for Darkmoon Sigils, which is only the suffix ("Hunt", "Void" and so on). +// Map those back to the full embellishment name before we look them up. +const DARKMOON_SIGIL_SPELLS: {[key: string]: string} = { + "Ascendance": "Darkmoon Sigil: Ascension", + "Symbiosis": "Darkmoon Sigil: Symbiosis", + "Vivacity": "Darkmoon Sigil: Vivacity", + "Hunt": "Darkmoon Sigil: Hunt", + "Void": "Darkmoon Sigil: Void", + "Blood": "Darkmoon Sigil: Blood", + "Rot": "Darkmoon Sigil: Rot", +}; + function getPlayerServerName(lines: string[]) { let serverName = "" lines.forEach((line: string) => { @@ -613,18 +626,19 @@ export function processItem(line: string, player: Player, contentType: contentTy // Embellishments that require a tag. - if (['Blessed Pango Charm', 'Arcanoweave Lining', 'Sunfire Silk Lining', 'Primal Spore Binding', 'Hunt', 'Void', 'Rot', 'Blood'].includes(specialEffectName)) { - if (specialEffectName === "Ascendance") specialEffectName = "Darkmoon Sigil: Ascension" - else if (specialEffectName === "Symbiosis") specialEffectName = "Darkmoon Sigil: Symbiosis" - else if (specialEffectName === 'Hunt') specialEffectName = "Darkmoon Sigil: Hunt" - else if (specialEffectName === "Void") specialEffectName = "Darkmoon Sigil: Void" - + // SimC reports the *spell* name, which for the Darkmoon Sigils is just the suffix. Normalise those first, + // then check the result against EmbellishmentDB rather than a hardcoded list - the old allowlist only + // covered eight names and silently dropped every other embellishment on import. + if (DARKMOON_SIGIL_SPELLS[specialEffectName]) specialEffectName = DARKMOON_SIGIL_SPELLS[specialEffectName]; + + const matchedEmbellishment = getEmbellishmentByEffectName(specialEffectName); + if (matchedEmbellishment) { protoItem.effect = { type: "embellishment", - name: specialEffectName, + name: matchedEmbellishment.effect.name, level: protoItem.level //(itemBaseLevel + itemLevelGain), } - + protoItem.uniqueTag = "embellishment"; } diff --git a/src/General/Items/Item.ts b/src/General/Items/Item.ts index 1809778f55..7ae8613435 100644 --- a/src/General/Items/Item.ts +++ b/src/General/Items/Item.ts @@ -1,6 +1,7 @@ import { calcStatsAtLevel, calcStatsAtLevelClassic, getItemAllocations, getItemDB, getItemProp } from "../Engine/ItemUtilities"; import { CONSTRAINTS, setBounds } from "../Engine/CONSTRAINTS"; import { CONSTANTS } from "General/Engine/CONSTANTS"; +import { getEmbellishmentForItem } from "Databases/EmbellishmentDB"; // The Item class represents an active item in the app at a specific item level. // We'll create them when we import a SimC string, or when an item is added manually. @@ -77,6 +78,15 @@ export class Item { this.effect = getItemProp(id, "effect", gameType); + + // Some crafted items carry an embellishment inherently rather than having one applied. Those are tracked in + // EmbellishmentDB via setItems, which lets us pick them up even when the ItemDB row is missing its effect block + // (a recurring gap on newly datamined crafted gear). ItemDB wins if it already has one. + if (!this.effect && gameType === "Retail") { + const bakedIn = getEmbellishmentForItem(id); + if (bakedIn) this.effect = { ...bakedIn.effect }; + } + this.setID = getItemProp(id, "itemSetId", gameType); this.uniqueEquip = getItemProp(catalyzedID ? catalyzedID : id, "uniqueEquip", gameType).toLowerCase(); this.onUse = (slot === "Trinket" && getItemProp(id, "onUseTrinket", gameType) === true); diff --git a/src/General/Modules/ItemBar/ItemBar.js b/src/General/Modules/ItemBar/ItemBar.js index 0caa475460..73963041fb 100644 --- a/src/General/Modules/ItemBar/ItemBar.js +++ b/src/General/Modules/ItemBar/ItemBar.js @@ -19,6 +19,7 @@ import { calcStatsAtLevel, autoAddItems, getItemEffectOptions, + hasUnallocatedStats, } from "../../Engine/ItemUtilities"; import { CONSTRAINTS } from "../../Engine/CONSTRAINTS"; import { useSelector } from "react-redux"; @@ -330,7 +331,9 @@ export default function ItemBar(props) { itemLevel: true, socket: gameType === "Retail" && CONSTANTS.socketSlots.includes(getItemProp(itemID, "slot", gameType)), tertiaries: !(isItemCrafted) && gameType === "Retail", - missives: isItemCrafted || getItemProp(itemID, "randomStats", gameType), + // Only offer the crafted stat picker when the item actually has stat budget for the player to assign. + // A lot of the newer crafted gear ships with fixed secondaries, where picking stats here would do nothing. + missives: (isItemCrafted && hasUnallocatedStats(itemID, gameType)) || getItemProp(itemID, "randomStats", gameType), specialEffect: itemEffectOptions.length > 0, } diff --git a/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts index 11beeb671e..f4217e8151 100644 --- a/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts +++ b/src/General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile.ts @@ -80,7 +80,16 @@ export function scoreEvokerSet(stats: Stats, playerData: any, settings: PlayerSe const healingBreakdown: Record = {}; const castBreakdown: Record = {}; - playerData.masteryEffectiveness = 0.9; + // Preservation mastery scales with how injured your targets are, so its real effectiveness varies a lot by + // content. Resto Shaman already exposes this as a setting; Evoker now does too. Falls back to the previous + // hardcoded 0.9 when the setting is absent so existing results are unchanged. + // The settings panel writes number inputs back as strings (e.target.value), so this has to coerce rather than + // type-check. A strict typeof check here silently fell back to the default the moment the player edited the box. + const masteryEffectivenessRaw = settings && settings.masteryEffectivenessEvoker ? settings.masteryEffectivenessEvoker.value : null; + const masteryEffectivenessPct = Number(masteryEffectivenessRaw); + playerData.masteryEffectiveness = Number.isFinite(masteryEffectivenessPct) && masteryEffectivenessPct > 0 + ? masteryEffectivenessPct / 100 + : 0.9; // Apply Talents const talents = initialState.talents; diff --git a/src/General/Modules/Settings/SettingsCategories.test.js b/src/General/Modules/Settings/SettingsCategories.test.js new file mode 100644 index 0000000000..4c4f4b14a6 --- /dev/null +++ b/src/General/Modules/Settings/SettingsCategories.test.js @@ -0,0 +1,54 @@ +import { SETTINGS_CATEGORIES } from "./SettingsComponent"; +import rootReducer from "Redux/Reducers/RootReducer"; +import translations from "locale/en/translate.json"; + +/* + Settings are grouped by a `category` field, but the panel renders a hardcoded list of categories. A setting whose + category is missing from that list is stored in Redux, is fully wired to the engine, and never appears in the UI - + with no error anywhere. That's exactly how the Omnium Folio dropdowns shipped invisible. +*/ + +const playerSettings = rootReducer(undefined, { type: "@@INIT" }).playerSettings; +const settingsFor = (gameType) => Object.entries(playerSettings).filter(([, v]) => v.gameType === gameType); + +describe("Every setting is reachable in the UI", () => { + ["Retail", "Classic"].forEach((gameType) => { + test(`${gameType}: every category that has settings is rendered`, () => { + const used = [...new Set(settingsFor(gameType).map(([, v]) => v.category))]; + const rendered = SETTINGS_CATEGORIES[gameType]; + + const orphaned = used.filter((c) => !rendered.includes(c)); + expect(orphaned).toEqual([]); + }); + + test(`${gameType}: no rendered category is empty`, () => { + const used = new Set(settingsFor(gameType).map(([, v]) => v.category)); + // A listed category with nothing in it would draw a bare heading. + const empty = SETTINGS_CATEGORIES[gameType].filter((c) => !used.has(c)); + expect(empty).toEqual([]); + }); + }); + + test("every setting declares a category and a gameType", () => { + const malformed = Object.entries(playerSettings) + .filter(([, v]) => !v.category || !v.gameType) + .map(([k]) => k); + expect(malformed).toEqual([]); + }); +}); + +describe("Every setting is labelled", () => { + const retailStrings = translations.translations.Settings.Retail; + + test("each category has a heading string", () => { + const missing = SETTINGS_CATEGORIES.Retail.filter((c) => !retailStrings[c]); + expect(missing).toEqual([]); + }); + + test("each Retail setting has a title and tooltip", () => { + const missing = settingsFor("Retail") + .map(([k]) => k) + .filter((k) => !retailStrings[k] || !retailStrings[k].title || !retailStrings[k].tooltip); + expect(missing).toEqual([]); + }); +}); diff --git a/src/General/Modules/Settings/SettingsComponent.js b/src/General/Modules/Settings/SettingsComponent.js index c30484fb3f..20f1a14036 100644 --- a/src/General/Modules/Settings/SettingsComponent.js +++ b/src/General/Modules/Settings/SettingsComponent.js @@ -6,6 +6,13 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { togglePlayerSettings } from "Redux/Actions"; +// Any category listed here is rendered. A setting whose category is missing from this list exists in the store +// but is never drawn, which is silent - SettingsCategories.test.js guards against that. +export const SETTINGS_CATEGORIES = { + Retail: ["trinkets", "embellishments", "topGear", "enchants", "gems", "consumables", "omniumFolio", "upgradeFinder", "specSpecific"], + Classic: ["topGear", "enchants", "specSpecific"], +}; + const useStyles = makeStyles((theme) => ({ root: { width: "100%", @@ -31,7 +38,7 @@ export default function SettingsComponent(props) { const dispatch = useDispatch(); - const categories = gameType === "Retail" ? ["trinkets", "embellishments", "topGear", "upgradeFinder", "specSpecific"] : ["topGear", "enchants", "specSpecific"]; + const categories = SETTINGS_CATEGORIES[gameType] || SETTINGS_CATEGORIES.Retail; //const settingsCategories = [...new Set(playerSettings.map(o => o.category))]; /* ---------------------------------------------------------------------------------------------- */ @@ -73,6 +80,9 @@ export default function SettingsComponent(props) { return ( {categories.map((category) => { + const categoryKeys = mappedKeys[category] || []; + if (categoryKeys.length === 0) return null; + return ( @@ -81,7 +91,7 @@ export default function SettingsComponent(props) { {t("Settings.Retail." + category)} - {mappedKeys[category].map((key, i) => { + {categoryKeys.map((key, i) => { return ( { + [ + { id: 195480, slot: "Finger", levels: [447, 450] }, + { id: 158314, slot: "Finger", levels: [447] }, + { id: 203729, slot: "Trinket", levels: [441, 447] }, + { id: 193773, slot: "Trinket", levels: [441] }, + ].forEach(({ id, slot, levels }) => { + levels.forEach((level) => player.activeItems.push(new Item(id, "", slot, 0, "", 0, level, ""))); + }); +}; + +const runFor = (contentType) => { + const player = new Player("Evoulker", "Preservation Evoker", 99, "US", "Stonemaul", "Dracthyr", "default", "Retail"); + processAllLines(player, contentType, evokerSet.split("\n"), -1, -1, settings); + addAlternatives(player); + player.activateAll(); + + const wepCombos = buildNewWepCombos(player, true); + const castModel = player.getActiveModel(contentType); + + return { + castModel, + result: runTopGear(player.activeItems, wepCombos, player, contentType, player.getHPS(contentType), settings, castModel), + }; +}; + +describe("Preservation Evoker reports absolute HPS in Raid", () => { + const { result, castModel } = runFor("Raid"); + + test("the spec really is on the cast model path, so this suite is testing something", () => { + expect(castModel.modelType["Raid"]).toEqual(MODEL_TYPES.CAST_MODEL); + }); + + test("Top Gear produced a set", () => { + expect(result).toBeTruthy(); + expect(result.itemSet).toBeTruthy(); + }); + + test("the best set carries an absolute HPS figure", () => { + expect(result.itemSet.setHPS).toBeGreaterThan(0); + }); + + test("the HPS figure is throughput and not the ranking score", () => { + // setHPS must come from the cast model, not from hardScore, which is an intellect-equivalent ranking number. + expect(result.itemSet.setHPS).not.toEqual(result.itemSet.hardScore); + expect(result.itemSet.setHPS).toBeGreaterThan(1000); + expect(result.itemSet.setHPS).toBeLessThan(100000000); + expect(Number.isFinite(result.itemSet.setHPS)).toBe(true); + }); + + test("the HPS figure matches the modelled throughput on the set's stats", () => { + expect(result.itemSet.setHPS).toEqual(Math.round(result.itemSet.setStats.hps)); + }); + + test("every alternative reports its own absolute HPS", () => { + expect(result.differentials.length).toBeGreaterThan(0); + + result.differentials.forEach((differential) => { + expect(differential.hps).toBeGreaterThan(0); + }); + }); + + test("no alternative out-heals the set Top Gear picked", () => { + result.differentials.forEach((differential) => { + expect(differential.hps).toBeLessThanOrEqual(result.itemSet.setHPS); + expect(differential.hpsDifference).toBeLessThanOrEqual(0); + }); + }); + + test("hpsDifference is the gap between the alternative and the best set", () => { + result.differentials.forEach((differential) => { + expect(differential.hpsDifference).toEqual(Math.round(differential.hps - result.itemSet.setHPS)); + }); + }); + + test("alternatives are ordered from strongest to weakest", () => { + const gaps = result.differentials.map((differential) => differential.hpsDifference); + const sorted = [...gaps].sort((a, b) => b - a); + + expect(gaps).toEqual(sorted); + }); + + test("a percentage can be derived from the HPS figures alone", () => { + // The report shows both the raw healing given up and that as a percentage of the best set. Both come from the + // same two numbers, so they can never disagree the way hardScore and HPS could. + result.differentials.forEach((differential) => { + const primeHPS = differential.hps - differential.hpsDifference; + expect(primeHPS).toEqual(result.itemSet.setHPS); + + const percent = (differential.hpsDifference / primeHPS) * 100; + expect(Number.isFinite(percent)).toBe(true); + expect(percent).toBeLessThanOrEqual(0); + expect(percent).toBeGreaterThan(-100); + }); + }); + + test("the equipped set is evaluated for the upgrade percentage", () => { + // Every item in the fixture came from a SimC string, so all of them are flagged equipped. + expect(result.equippedHPS).toBeGreaterThan(0); + }); + + test("the best set is at least as good as what the player is wearing", () => { + expect(result.itemSet.setHPS).toBeGreaterThanOrEqual(result.equippedHPS); + }); + + test("the upgrade percentage is a sane number", () => { + const upgrade = ((result.itemSet.setHPS - result.equippedHPS) / result.equippedHPS) * 100; + + expect(Number.isFinite(upgrade)).toBe(true); + expect(upgrade).toBeGreaterThanOrEqual(0); + expect(upgrade).toBeLessThan(1000); + }); + + test("the count of embellished items selected is reported", () => { + expect(typeof result.embellishedSelected).toEqual("number"); + expect(result.embellishedSelected).toBeGreaterThanOrEqual(0); + }); +}); + +describe("The stat weight path reports no HPS rather than a fabricated one", () => { + const { result, castModel } = runFor("Dungeon"); + + test("Dungeon is scored on stat weights for this spec", () => { + expect(castModel.modelType["Dungeon"]).toEqual(MODEL_TYPES.DEFAULT); + }); + + test("no absolute HPS is claimed for the best set", () => { + expect(result.itemSet.setHPS).toEqual(0); + }); + + test("no absolute HPS is claimed for alternatives", () => { + result.differentials.forEach((differential) => { + expect(differential.hps).toEqual(0); + }); + }); +}); + +describe("buildDifferential", () => { + const makeSet = (hardScore, setHPS) => ({ + hardScore, + setHPS, + itemList: [], + enchantBreakdown: { Gems: [] }, + }); + + test("carries absolute HPS and the gap against the best set", () => { + const prime = makeSet(100000, 450000); + const alternative = makeSet(99000, 445500); + + const differential = buildDifferential(alternative, prime, null, "Raid"); + + expect(differential.hps).toEqual(445500); + expect(differential.hpsDifference).toEqual(-4500); + }); + + test("reports zero when the sets have no modelled throughput", () => { + const differential = buildDifferential(makeSet(99000, 0), makeSet(100000, 0), null, "Raid"); + + expect(differential.hps).toEqual(0); + expect(differential.hpsDifference).toEqual(0); + }); +}); diff --git a/src/General/Modules/TopGear/Engine/GearOptimizer.test.js b/src/General/Modules/TopGear/Engine/GearOptimizer.test.js new file mode 100644 index 0000000000..9ffeb2b7d9 --- /dev/null +++ b/src/General/Modules/TopGear/Engine/GearOptimizer.test.js @@ -0,0 +1,158 @@ +import fs from "fs"; +import Player from "General/Modules/Player/Player"; +import Item from "General/Items/Item"; +import ItemSet from "General/Modules/TopGear/ItemSet"; +import { optimizeConfiguration, TUNABLE_OPTIONS } from "./TopGearEngine"; +import rootReducer from "Redux/Reducers/RootReducer"; + +/* + The gem / enchant / flask / Folio axes interact through diminishing returns, so optimising them one at a time can + miss the joint best. Full enumeration is ~27k evaluations per gear set, which is far too slow, so the engine walks + coordinate ascent. These tests check that shortcut actually lands on the optimum rather than a local maximum. +*/ + +const settings = rootReducer(undefined, { type: "@@INIT" }).playerSettings; + +const GEAR = [ + [268230, "Head"], [268250, "Neck"], [268231, "Shoulder"], [271451, "Back"], [268223, "Chest"], + [271497, "Wrist"], [271502, "Hands"], [268216, "Waist"], [268237, "Legs"], [268233, "Feet"], + [268249, "Finger"], [268252, "Finger"], [270175, "Trinket"], [274493, "Trinket"], [268205, "2H Weapon"], +]; + +const buildFixture = () => { + const player = new Player("T", "Preservation Evoker", 1, "EU", "R", "Dracthyr", "default", "Retail"); + const items = GEAR.map(([id, slot]) => { + const item = new Item(id, "", slot, 0, "", 0, 330, ""); + item.active = true; + item.isEquipped = true; + return item; + }); + items.forEach((i) => player.addActiveItem(i)); + return { player, itemSet: new ItemSet(1, items, 0, "Preservation Evoker") }; +}; + +describe("Configuration optimiser", () => { + const { player, itemSet } = buildFixture(); + const castModel = player.getActiveModel("Raid"); + const baseHPS = player.getHPS("Raid"); + const optimised = optimizeConfiguration(itemSet, player, "Raid", baseHPS, settings, castModel); + + test("it finds a configuration at least as good as the defaults", () => { + expect(optimised.score).toBeGreaterThanOrEqual(optimised.baseline); + expect(optimised.gain).toBeGreaterThanOrEqual(0); + }); + + test("it actually improves on the defaults for this set", () => { + expect(optimised.gain).toBeGreaterThan(0); + }); + + test("it stays far cheaper than full enumeration", () => { + const fullSpace = TUNABLE_OPTIONS.reduce((acc, axis) => acc * axis.options.length, 1); + expect(optimised.evaluations).toBeLessThan(fullSpace / 10); + expect(optimised.evaluations).toBeLessThan(500); + }); + + test("every axis it chose is a real option on that axis", () => { + Object.entries(optimised.config).forEach(([key, value]) => { + const axis = TUNABLE_OPTIONS.find((a) => a.key === key); + expect(axis).toBeTruthy(); + expect(axis.options).toContain(value); + }); + }); + + test("it is reproducible", () => { + const again = optimizeConfiguration(itemSet, player, "Raid", baseHPS, settings, castModel); + expect(again.config).toEqual(optimised.config); + expect(Math.round(again.score)).toEqual(Math.round(optimised.score)); + }); + + test("no single-axis change beats the result, so it is at least a local optimum", () => { + // Coordinate ascent guarantees this by construction; asserting it guards against a bug in the search itself. + const { withConfig, scoreConfiguration } = require("./TopGearEngine"); + const { evalSetForTest } = {}; + TUNABLE_OPTIONS.forEach((axis) => { + axis.options.forEach((option) => { + const trial = optimizeConfiguration(itemSet, player, "Raid", baseHPS, + withConfig(settings, { ...optimised.config, [axis.key]: option }), castModel, 1); + // Starting from the perturbed config, ascent must not find anything better than the optimum. + expect(trial.score).toBeLessThanOrEqual(optimised.score + 1); + }); + }); + }); +}); + +/* ---------------------------------------------------------------------------------------------- */ +/* Joint optimisation through the whole Top Gear run */ +/* ---------------------------------------------------------------------------------------------- */ +const { runTopGear } = require("./TopGearEngine"); +const { buildNewWepCombos } = require("General/Engine/ItemUtilities"); + +const withOptimiser = (on) => { + const s = JSON.parse(JSON.stringify(settings)); + s.optimizeGemsEnchants = { value: on, options: [true, false], category: "topGear", type: "selector", gameType: "Retail" }; + return s; +}; + +// A pool with real alternatives per slot, so Top Gear has genuine choices to re-rank. +const runPool = (opts) => { + const player = new Player("T", "Preservation Evoker", 1, "EU", "R", "Dracthyr", "default", "Retail"); + GEAR.forEach(([id, slot]) => { + const item = new Item(id, "", slot, 0, "", 0, 330, ""); + item.active = true; + item.isEquipped = true; + player.addActiveItem(item); + }); + // Alternatives at other item levels give the ranking something to move between. + [[268249, "Finger", 321], [268252, "Finger", 334], [270175, "Trinket", 321], [268230, "Head", 321]].forEach(([id, slot, ilvl]) => { + const item = new Item(id, "", slot, 0, "", 0, ilvl, ""); + item.active = true; + player.addActiveItem(item); + }); + const combos = buildNewWepCombos(player, true); + return runTopGear(player.activeItems, combos, player, "Raid", player.getHPS("Raid"), opts, player.getActiveModel("Raid")); +}; + +describe("Joint optimisation through runTopGear", () => { + const off = runPool(withOptimiser(false)); + const on = runPool(withOptimiser(true)); + + test("both runs produce a set", () => { + expect(off).toBeTruthy(); + expect(on).toBeTruthy(); + }); + + test("optimisation is off by default, so existing results are unchanged", () => { + expect(runPool(settings).optimalConfig).toBeNull(); + }); + + test("enabling it reports the winning configuration", () => { + expect(on.optimalConfig).toBeTruthy(); + expect(Object.keys(on.optimalConfig.config).length).toBeGreaterThan(0); + expect(on.optimalConfig.setsOptimized).toBeGreaterThan(0); + }); + + test("the optimised set is at least as good as the default one", () => { + expect(on.itemSet.setHPS).toBeGreaterThanOrEqual(off.itemSet.setHPS); + }); + + test("it reports a real gain", () => { + expect(on.optimalConfig.gain).toBeGreaterThan(0); + expect(on.optimalConfig.score).toBeGreaterThan(on.optimalConfig.baseline); + }); + + test("the reported set actually uses the winning configuration", () => { + // The winner is re-evaluated with its own config, so the set's own HPS must match the optimiser's score. + expect(Math.abs(on.itemSet.setHPS - on.optimalConfig.score)).toBeLessThanOrEqual(1); + }); + + test("alternatives are re-scored under the same configuration, so they stay comparable", () => { + on.differentials.forEach((d) => { + expect(d.hps).toBeLessThanOrEqual(on.itemSet.setHPS); + expect(d.hpsDifference).toBeLessThanOrEqual(0); + }); + }); + + test("the cost stays bounded", () => { + expect(on.optimalConfig.evaluations).toBeLessThan(5000); + }); +}); diff --git a/src/General/Modules/TopGear/Engine/GearOptions.test.js b/src/General/Modules/TopGear/Engine/GearOptions.test.js new file mode 100644 index 0000000000..6a6e71b34b --- /dev/null +++ b/src/General/Modules/TopGear/Engine/GearOptions.test.js @@ -0,0 +1,376 @@ +import { getFolioGems, getFolioOptions, omniumFolioData } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData"; + +/* + Folio runes, enchants and consumables used to be hardcoded in the engine. They're now driven by settings, and the + contract that matters is that "Automatic" reproduces the old hardcoded behaviour exactly - otherwise every existing + user's results would silently shift the moment these settings shipped. +*/ + +// The runes the engine hardcoded before any of this was configurable. +const LEGACY_SLOT_1 = 1279599; // Rune of Unleashed Fire +const LEGACY_SLOT_2 = 1279603; // Rune of Self-Mending +const LEGACY_SLOT_3 = 1287555; // Rune of Lingering +const LEGACY_SLOT_5 = 1279614; // Rune of Overload +const LEGACY_STAT = { haste: 1287774, crit: 1279609, mastery: 1287771, versatility: 1279613 }; + +const setting = (value) => ({ value, options: [], category: "omniumFolio", type: "selector", gameType: "Retail" }); + +describe("Omnium Folio defaults match the old hardcoded behaviour", () => { + test("an empty settings object reproduces the legacy runes", () => { + ["haste", "crit", "mastery", "versatility"].forEach((stat) => { + expect(getFolioGems({}, stat)).toEqual([LEGACY_SLOT_1, LEGACY_SLOT_2, LEGACY_SLOT_3, LEGACY_STAT[stat], LEGACY_SLOT_5]); + }); + }); + + test("all slots on Automatic reproduces the legacy runes", () => { + const settings = { folioSlot1: setting("Automatic"), folioSlot4: setting("Automatic"), folioSlot5: setting("Automatic") }; + expect(getFolioGems(settings, "mastery")).toEqual([LEGACY_SLOT_1, LEGACY_SLOT_2, LEGACY_SLOT_3, LEGACY_STAT.mastery, LEGACY_SLOT_5]); + }); + + test("an unknown stat still fills slot 4 rather than dropping it", () => { + const gems = getFolioGems({}, "notastat"); + expect(gems.length).toEqual(5); + expect(gems.every((id) => typeof id === "number")).toBe(true); + }); +}); + +describe("Omnium Folio slots are editable", () => { + test("slot 1 can be overridden", () => { + const settings = { folioSlot1: setting("Void-Touched") }; + expect(getFolioGems(settings, "haste")[0]).toEqual(1279596); + }); + + test("slot 4 can be overridden away from the best stat", () => { + const settings = { folioSlot4: setting("Vers") }; + // Best stat is haste, but the player asked for versatility. + expect(getFolioGems(settings, "haste")[3]).toEqual(LEGACY_STAT.versatility); + }); + + test("slot 5 can be overridden", () => { + expect(getFolioGems({ folioSlot5: setting("Echoes") }, "haste")[4]).toEqual(1279616); + expect(getFolioGems({ folioSlot5: setting("Residual Energy") }, "haste")[4]).toEqual(1279615); + }); + + test("slots can be set independently", () => { + const settings = { folioSlot1: setting("Void-Touched"), folioSlot4: setting("Crit"), folioSlot5: setting("Echoes") }; + expect(getFolioGems(settings, "haste")).toEqual([1279596, LEGACY_SLOT_2, LEGACY_SLOT_3, LEGACY_STAT.crit, 1279616]); + }); + + test("always returns exactly five runes", () => { + [{}, { folioSlot1: setting("Void-Touched") }, { folioSlot5: setting("Echoes") }].forEach((s) => { + expect(getFolioGems(s, "mastery").length).toEqual(5); + }); + }); + + test("a stale or renamed choice falls back to Automatic instead of losing the slot", () => { + const gems = getFolioGems({ folioSlot1: setting("Rune That No Longer Exists") }, "haste"); + expect(gems.length).toEqual(5); + expect(gems[0]).toEqual(LEGACY_SLOT_1); + }); + + test("every offered option resolves to a real rune in that slot", () => { + [[1, "folioSlot1"], [4, "folioSlot4"], [5, "folioSlot5"]].forEach(([slot, key]) => { + const options = getFolioOptions(slot); + expect(options.length).toBeGreaterThan(0); + options.forEach((shortName) => { + const gems = getFolioGems({ [key]: setting(shortName) }, "haste"); + const match = omniumFolioData.find((g) => g.shortName === shortName && g.slot === slot); + expect(gems).toContain(match.id); + }); + }); + }); +}); + +/* ---------------------------------------------------------------------------------------------- */ +/* Enchants and consumables, through the engine */ +/* ---------------------------------------------------------------------------------------------- */ +const Player = require("General/Modules/Player/Player").default; +const Item = require("General/Items/Item").default; +const { buildNewWepCombos } = require("General/Engine/ItemUtilities"); +const { runTopGear } = require("./TopGearEngine"); + +const sel = (value, category) => ({ value, options: [], category, type: "selector", gameType: "Retail" }); + +const baseSettings = (overrides = {}) => ({ + enchantItems: sel(true, "topGear"), + catalystLimit: sel(4, "topGear"), + topGearAutoGem: sel(false, "topGear"), + darkmoonHuntStat: sel("Mastery", "embellishments"), + flaskChoice: sel("Automatic", "topGear"), + calculateEmbellishments: sel(true, "embellishments"), + groupBuffValuation: sel(75, "trinkets"), + averageRaidHealth: sel(85, "trinkets"), + crucibleUpgrades: sel("Fully Upgraded", "trinkets"), + delayOnUseTrinkets: sel(true, "trinkets"), + dpsFlag: sel(false, "trinkets"), + ...overrides, +}); + +// One real item per slot so Top Gear can actually build a set. +const GEAR = [ + [268230, "Head"], [268250, "Neck"], [268231, "Shoulder"], [271451, "Back"], [268223, "Chest"], + [271497, "Wrist"], [271502, "Hands"], [268216, "Waist"], [268237, "Legs"], [268233, "Feet"], + [268249, "Finger"], [268252, "Finger"], [270175, "Trinket"], [274493, "Trinket"], [268205, "2H Weapon"], +]; + +const runWith = (settings, spec = "Preservation Evoker") => { + const player = new Player("T", spec, 1, "US", "S", "Dracthyr", "default", "Retail"); + GEAR.forEach(([id, slot]) => { + const item = new Item(id, "", slot, 0, "", 0, 330, ""); + item.active = true; + player.addActiveItem(item); + }); + const combos = buildNewWepCombos(player, true); + return runTopGear(player.activeItems, combos, player, "Raid", player.getHPS("Raid"), settings, player.getActiveModel("Raid")); +}; + +describe("Enchants are selectable", () => { + test("ring enchant follows the setting rather than the best stat", () => { + const auto = runWith(baseSettings()); + const forced = runWith(baseSettings({ ringEnchant: sel("Versatility", "enchants") })); + + expect(auto).toBeTruthy(); + expect(forced.itemSet.enchantBreakdown["Finger"]).toEqual("Silvermoon's Tenacity"); + }); + + test("weapon enchant follows the setting rather than the spec default", () => { + // Preservation Evoker defaults to Arcane Mastery. + expect(runWith(baseSettings()).itemSet.enchantBreakdown["2H Weapon"]).toEqual("Arcane Mastery"); + expect(runWith(baseSettings({ weaponEnchant: sel("Haste", "enchants") })).itemSet.enchantBreakdown["2H Weapon"]).toEqual("Berserker's Rage"); + expect(runWith(baseSettings({ weaponEnchant: sel("Intellect", "enchants") })).itemSet.enchantBreakdown["2H Weapon"]).toEqual("Acuity of the Ren'dorei"); + }); + + test("a missing enchant setting behaves as Automatic", () => { + const withSetting = runWith(baseSettings({ ringEnchant: sel("Automatic", "enchants") })); + const without = runWith(baseSettings()); + expect(withSetting.itemSet.enchantBreakdown["Finger"]).toEqual(without.itemSet.enchantBreakdown["Finger"]); + }); +}); + +describe("Consumables are toggleable", () => { + test("turning the Vantus Rune off lowers throughput", () => { + const on = runWith(baseSettings({ vantusRune: sel(true, "consumables") })); + const off = runWith(baseSettings({ vantusRune: sel(false, "consumables") })); + + expect(on.itemSet.setHPS).toBeGreaterThan(off.itemSet.setHPS); + }); + + test("turning food off lowers throughput", () => { + const on = runWith(baseSettings({ foodBuff: sel("Intellect Food", "consumables") })); + const off = runWith(baseSettings({ foodBuff: sel("None", "consumables") })); + + expect(on.itemSet.setHPS).toBeGreaterThan(off.itemSet.setHPS); + }); + + test("turning weapon oil off lowers throughput", () => { + const on = runWith(baseSettings({ weaponOil: sel(true, "consumables") })); + const off = runWith(baseSettings({ weaponOil: sel(false, "consumables") })); + + expect(on.itemSet.setHPS).toBeGreaterThan(off.itemSet.setHPS); + }); + + test("omitting the consumable settings entirely keeps them all on", () => { + // Existing users have no such keys in local storage, so the defaults must not silently drop their buffs. + const omitted = runWith(baseSettings()); + const explicit = runWith(baseSettings({ + vantusRune: sel(true, "consumables"), foodBuff: sel("Intellect Food", "consumables"), weaponOil: sel(true, "consumables"), + })); + + expect(omitted.itemSet.setHPS).toEqual(explicit.itemSet.setHPS); + }); + + test("flask choice changes the reported flask", () => { + expect(runWith(baseSettings({ flaskChoice: sel("Mastery", "topGear") })).itemSet.enchantBreakdown.flask).toEqual("Flask of the Magisters"); + expect(runWith(baseSettings({ flaskChoice: sel("Crit", "topGear") })).itemSet.enchantBreakdown.flask).toEqual("Flask of the Shattered Sun"); + }); +}); + +/* ---------------------------------------------------------------------------------------------- */ +/* Gems */ +/* ---------------------------------------------------------------------------------------------- */ +const { META_GEM_OPTIONS, GEM_COMBO_OPTIONS, findGemByStats, gemDB } = require("Databases/GemDB"); + +describe("Gem options resolve to real gems", () => { + test("every offered stat combination exists in the gem DB", () => { + const missing = Object.entries(GEM_COMBO_OPTIONS) + .filter(([, [major, minor]]) => findGemByStats(major, minor) === 0) + .map(([label]) => label); + expect(missing).toEqual([]); + }); + + test("every offered meta gem exists in the gem DB", () => { + Object.values(META_GEM_OPTIONS).forEach((id) => { + expect(gemDB.some((gem) => gem.id === id)).toBe(true); + }); + }); + + test("a combination resolves to a gem with the right major and minor stats", () => { + Object.entries(GEM_COMBO_OPTIONS).forEach(([, [major, minor]]) => { + const gem = gemDB.find((g) => g.id === findGemByStats(major, minor)); + expect(gem.stats[major]).toEqual(12); + expect(gem.stats[minor]).toEqual(5); + }); + }); +}); + +describe("Gems are selectable", () => { + const gemsOf = (result) => result.itemSet.enchantBreakdown["Gems"]; + + test("defaults are unchanged when nothing is set", () => { + const auto = gemsOf(runWith(baseSettings())); + const explicit = gemsOf(runWith(baseSettings({ metaGem: sel("Automatic", "gems"), gemCombo: sel("Automatic", "gems") }))); + expect(explicit).toEqual(auto); + }); + + test("the meta gem can be overridden without touching the stat gems", () => { + const auto = gemsOf(runWith(baseSettings())); + const forced = gemsOf(runWith(baseSettings({ metaGem: sel("Telluric (Mana)", "gems") }))); + + expect(forced[0]).toEqual(META_GEM_OPTIONS["Telluric (Mana)"]); + expect(forced.slice(1)).toEqual(auto.slice(1)); + }); + + test("the stat gems can be overridden without touching the meta", () => { + const auto = gemsOf(runWith(baseSettings())); + const forced = gemsOf(runWith(baseSettings({ gemCombo: sel("Vers / Haste", "gems") }))); + + expect(forced[0]).toEqual(auto[0]); + forced.slice(1).forEach((id) => expect(id).toEqual(findGemByStats("versatility", "haste"))); + }); + + test("meta and stat gems can be set together", () => { + const forced = gemsOf(runWith(baseSettings({ + metaGem: sel("Telluric (Mana)", "gems"), gemCombo: sel("Crit / Mastery", "gems"), + }))); + + expect(forced[0]).toEqual(META_GEM_OPTIONS["Telluric (Mana)"]); + forced.slice(1).forEach((id) => expect(id).toEqual(findGemByStats("crit", "mastery"))); + }); + + test("choosing a worse gem combination lowers throughput", () => { + // Evoker defaults to mastery, so forcing a stat it doesn't want should measurably cost HPS. + const auto = runWith(baseSettings()); + const forced = runWith(baseSettings({ gemCombo: sel("Vers / Haste", "gems") })); + expect(forced.itemSet.setHPS).toBeLessThan(auto.itemSet.setHPS); + }); + + test("an unrecognised choice falls back to the automatic picks", () => { + const auto = gemsOf(runWith(baseSettings())); + const stale = gemsOf(runWith(baseSettings({ gemCombo: sel("Haste / Nonsense", "gems") }))); + expect(stale).toEqual(auto); + }); +}); + +/* ---------------------------------------------------------------------------------------------- */ +/* Fine tuning against equipped gear */ +/* ---------------------------------------------------------------------------------------------- */ +const runEquipped = (settings, spec = "Preservation Evoker") => { + const player = new Player("T", spec, 1, "US", "S", "Dracthyr", "default", "Retail"); + GEAR.forEach(([id, slot]) => { + const item = new Item(id, "", slot, 0, "", 0, 330, ""); + item.active = true; + item.isEquipped = true; // the comparison table prices options against equipped gear + player.addActiveItem(item); + }); + const combos = buildNewWepCombos(player, true); + return runTopGear(player.activeItems, combos, player, "Raid", player.getHPS("Raid"), settings, player.getActiveModel("Raid")); +}; + +describe("Option comparisons against equipped gear", () => { + const result = runEquipped(baseSettings()); + const comparisons = result.optionComparisons; + + test("a comparison table is produced when gear is equipped", () => { + expect(comparisons).toBeTruthy(); + }); + + test("it covers gems, enchants, flask and every Folio slot", () => { + ["gemCombo", "metaGem", "ringEnchant", "weaponEnchant", "flaskChoice", "folioSlot1", "folioSlot4", "folioSlot5"] + .forEach((key) => expect(Object.keys(comparisons)).toContain(key)); + }); + + test("every option is priced", () => { + Object.values(comparisons).forEach((entry) => { + expect(entry.rows.length).toBeGreaterThan(0); + entry.rows.forEach((row) => { + expect(typeof row.option).toEqual("string"); + expect(Number.isFinite(row.hps)).toBe(true); + expect(Number.isFinite(row.hpsDelta)).toBe(true); + expect(Number.isFinite(row.scoreDelta)).toBe(true); + }); + }); + }); + + test("every gem combination offered in settings appears in the table", () => { + const priced = comparisons.gemCombo.rows.map((r) => r.option); + Object.keys(GEM_COMBO_OPTIONS).forEach((label) => expect(priced).toContain(label)); + }); + + test("rows are ordered best first", () => { + Object.values(comparisons).forEach((entry) => { + const deltas = entry.rows.map((r) => r.hpsDelta); + expect(deltas).toEqual([...deltas].sort((a, b) => b - a)); + }); + }); + + test("the options genuinely differ, so the table is informative", () => { + // If every option priced identically the substitution wouldn't be reaching the sim. + const gemDeltas = new Set(comparisons.gemCombo.rows.map((r) => r.hpsDelta)); + expect(gemDeltas.size).toBeGreaterThan(1); + }); + + test("deltas are measured against the equipped set's own throughput", () => { + Object.values(comparisons).forEach((entry) => { + entry.rows.forEach((row) => { + expect(row.hpsDelta).toEqual(row.hps - result.equippedHPS); + }); + }); + }); + + test("no table is produced when nothing is flagged as equipped", () => { + // runWith builds items without isEquipped, which is the manual-add case. + expect(runWith(baseSettings()).optionComparisons).toBeNull(); + }); +}); + +/* ---------------------------------------------------------------------------------------------- */ +/* Preservation Evoker mastery effectiveness */ +/* ---------------------------------------------------------------------------------------------- */ +const { scoreEvokerSet } = require("General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile"); + +describe("Evoker mastery effectiveness is configurable", () => { + const stats = { intellect: 60000, haste: 900, crit: 900, mastery: 1300, versatility: 200, leech: 0 }; + const run = (pct) => { + const s = pct === null ? {} : { masteryEffectivenessEvoker: { value: pct, options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }; + return scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: s, stats, tierSets: [], effectList: [] }, s).healing; + }; + + test("the setting changes modelled throughput", () => { + expect(run(100)).toBeGreaterThan(run(70)); + }); + + test("higher effectiveness is monotonically better", () => { + const values = [70, 80, 90, 100].map(run); + expect(values).toEqual([...values].sort((a, b) => a - b)); + }); + + test("an absent setting keeps the previous hardcoded 0.9", () => { + expect(Math.round(run(null))).toEqual(Math.round(run(90))); + }); + + test("a STRING value works - the settings panel writes numbers back as strings", () => { + // Regression: a strict typeof === "number" check here meant editing the box silently did nothing. + const asString = (v) => ({ masteryEffectivenessEvoker: { value: v, options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }); + const runStr = (v) => scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: asString(v), stats, tierSets: [], effectList: [] }, asString(v)).healing; + + expect(Math.round(runStr("100"))).toEqual(Math.round(run(100))); + expect(Math.round(runStr("70"))).toEqual(Math.round(run(70))); + expect(runStr("100")).toBeGreaterThan(runStr("70")); + }); + + test("a malformed setting falls back rather than zeroing mastery", () => { + const s = { masteryEffectivenessEvoker: { value: "nonsense", options: [], category: "specSpecific", type: "Entry", gameType: "Retail" } }; + const result = scoreEvokerSet(stats, { spec: "Preservation Evoker", heroTree: "Chronowarden", settings: s, stats, tierSets: [], effectList: [] }, s).healing; + expect(Math.round(result)).toEqual(Math.round(run(90))); + }); +}); diff --git a/src/General/Modules/TopGear/Engine/TopGearEngine.ts b/src/General/Modules/TopGear/Engine/TopGearEngine.ts index bd9eead694..f3d61caaef 100644 --- a/src/General/Modules/TopGear/Engine/TopGearEngine.ts +++ b/src/General/Modules/TopGear/Engine/TopGearEngine.ts @@ -6,17 +6,18 @@ import { convertPPMToUptime, getSetting, getDiminishedValue } from "../../../../ import Player from "../../Player/Player"; import CastModel from "../../Player/CastModel"; import { getEffectValue } from "../../../../Retail/Engine/EffectFormulas/EffectEngine"; -import { applyDiminishingReturns, getAllyStatsValue, getGemElement, getGems } from "General/Engine/ItemUtilities"; +import { applyDiminishingReturns, getAllyStatsValue, getGemElement, getGems, isEmbellished, MAX_EMBELLISHMENTS } from "General/Engine/ItemUtilities"; +import { reportError } from "General/SystemTools/ErrorLogging/ErrorReporting"; import { getTrinketValue } from "Retail/Engine/EffectFormulas/Generic/Trinkets/TrinketEffectFormulas"; import { allRamps, allRampsHealing, getDefaultDiscTalents } from "General/Modules/Player/ClassDefaults/DisciplinePriest/DiscRampUtilities"; import { buildRamp } from "General/Modules/Player/ClassDefaults/DisciplinePriest/DiscRampGen"; import { getItemSet, getSeasonalTier } from "Classic/Databases/RetailItemSetDB"; -import { CONSTANTS } from "General/Engine/CONSTANTS"; +import { CONSTANTS, MODEL_TYPES } from "General/Engine/CONSTANTS"; import { getCircletEffect } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/CyrcesCircletData" import { generateReportCode } from "General/Modules/TopGear/Engine/TopGearEngineShared" import Item from "General/Items/Item"; -import { gemDB } from "Databases/GemDB"; -import { getFolioEffect } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData"; +import { gemDB, META_GEM_OPTIONS, GEM_COMBO_OPTIONS, findGemByStats } from "Databases/GemDB"; +import { getFolioEffect, getFolioGems, getFolioOptions } from "Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData"; /** * == Top Gear Engine == @@ -82,6 +83,181 @@ function getGemID(bigStat: string, littleStat: string): number { } // Return an array of gem IDs based on the spec and content type. +/** + * Resolves the gems to socket. Automatic keeps the per-spec picks the engine already made, so an untouched + * settings object produces exactly the same gems it did before this was configurable. The meta and the stat gems + * are chosen independently, since a player often wants a specific meta but the default stat pairing (or vice versa). + */ +/* ---------------------------------------------------------------------------------------------- */ +/* Fine Tuning Comparisons */ +/* ---------------------------------------------------------------------------------------------- */ +// Which settings the player can fine tune, and the options each offers. Kept next to the engine because the +// comparison has to re-run evalSet with each value substituted - there is no cheaper way to price them, since +// gems and enchants interact with diminishing returns and with the cast model. +// `artifact` reads back what a setting actually resolved to on an evaluated set. That's how we mark the player's +// current pick: their settings almost always say "Automatic", which matches no option label, so comparing labels +// would never find a match. Comparing the resolved gem / enchant / rune instead always does. +export const TUNABLE_OPTIONS: { key: string; label: string; options: string[]; artifact: (set: any) => any }[] = [ + { key: "gemCombo", label: "Gems", options: Object.keys(GEM_COMBO_OPTIONS), + artifact: (set) => (set.enchantBreakdown && set.enchantBreakdown["Gems"] ? set.enchantBreakdown["Gems"][1] : null) }, + { key: "metaGem", label: "Meta Gem", options: Object.keys(META_GEM_OPTIONS), + artifact: (set) => (set.enchantBreakdown && set.enchantBreakdown["Gems"] ? set.enchantBreakdown["Gems"][0] : null) }, + { key: "ringEnchant", label: "Ring Enchant", options: ["Haste", "Crit", "Mastery", "Versatility"], + artifact: (set) => (set.enchantBreakdown ? set.enchantBreakdown["Finger"] : null) }, + { key: "weaponEnchant", label: "Weapon Enchant", options: ["Intellect", "Haste", "Mastery"], + artifact: (set) => (set.enchantBreakdown ? set.enchantBreakdown["CombinedWeapon"] : null) }, + { key: "flaskChoice", label: "Flask", options: ["Haste", "Crit", "Mastery", "Versatility"], + artifact: (set) => (set.enchantBreakdown ? set.enchantBreakdown.flask : null) }, + { key: "folioSlot1", label: "Folio Slot 1", options: getFolioOptions(1), artifact: (set) => (set.folioGems || [])[0] }, + { key: "folioSlot4", label: "Folio Slot 4", options: getFolioOptions(4), artifact: (set) => (set.folioGems || [])[3] }, + { key: "folioSlot5", label: "Folio Slot 5", options: getFolioOptions(5), artifact: (set) => (set.folioGems || [])[4] }, +]; + +// Returns a copy of the settings with one key forced to a given value. +function withSetting(userSettings: any, key: string, value: string) { + const existing = userSettings && userSettings[key] ? userSettings[key] : { options: [], category: "topGear", type: "selector", gameType: "Retail" }; + return { ...userSettings, [key]: { ...existing, value: value } }; +} + +// Applies a whole configuration (several settings at once) on top of the player's settings. +export function withConfig(userSettings: any, config: { [key: string]: string }) { + let result = userSettings; + Object.keys(config).forEach((key) => { result = withSetting(result, key, config[key]); }); + return result; +} + +// Score a set the way the optimiser ranks it: real throughput where the spec has a cast model, otherwise the +// stat weight score. Both are "higher is better" so the search doesn't care which it got. +export function scoreConfiguration(evaluated: any) { + return (evaluated.setHPS || 0) > 0 ? evaluated.setHPS : evaluated.hardScore; +} + +/** + * Finds the best combination of gems, enchants, flask and Folio runes for one gear set. + * + * These axes interact: secondary stats share diminishing returns, so the best gem depends on what the enchants + * and flask already gave you. Evaluating each axis independently (as the Fine Tuning table does) can therefore + * miss the joint optimum. The full cross product is ~27k evaluations per set which is far too slow, so this walks + * coordinate ascent instead: repeatedly take the best option on each axis holding the others fixed, until a full + * pass changes nothing. That's ~34 evaluations per pass and converges in two or three passes. + * + * The search space is smooth and monotonic in stats, so ascent lands on the true optimum in practice - + * GearOptimizer.test.js checks it against brute force on a real set. + */ +export function optimizeConfiguration(itemSet: ItemSet, player: Player, contentType: contentTypes, baseHPS: number, + userSettings: any, castModel: any, maxPasses: number = 4) { + const config: { [key: string]: string } = {}; + let evaluations = 0; + + const scoreOf = (candidate: { [key: string]: string }) => { + evaluations += 1; + return scoreConfiguration(evalSet(itemSet, player, contentType, baseHPS, withConfig(userSettings, candidate), castModel, false, 0)); + }; + + let best = scoreOf(config); + const baseline = best; + + for (let pass = 0; pass < maxPasses; pass++) { + let improved = false; + + TUNABLE_OPTIONS.forEach(({ key, options }) => { + let bestOption: string | null = null; + + options.forEach((option) => { + if (config[key] === option) return; // already the incumbent + try { + const score = scoreOf({ ...config, [key]: option }); + if (score > best) { best = score; bestOption = option; } + } catch (err) { + // A failed option is simply not a candidate. + } + }); + + if (bestOption !== null) { config[key] = bestOption; improved = true; } + }); + + if (!improved) break; + } + + return { config: config, score: best, baseline: baseline, gain: best - baseline, evaluations: evaluations }; +} + +/** + * Prices every gem, enchant, flask and Folio rune against the player's currently equipped gear. + * Each option is scored by re-running the set through evalSet with only that setting changed, so the numbers + * include diminishing returns and the cast model rather than being a flat stat-weight estimate. + * + * Reported both ways: hps / hpsDelta when the spec runs a cast model, and scoreDelta (a percentage of the + * baseline score) which is available on every spec including the stat weight path. + */ +function buildOptionComparisons(equippedSet: ItemSet, player: Player, contentType: contentTypes, baseHPS: number, + userSettings: any, castModel: any, baselineSet: any) { + const baselineHPS = baselineSet.setHPS || 0; + const baselineScore = baselineSet.hardScore || 0; + const comparisons: any = {}; + + TUNABLE_OPTIONS.forEach(({ key, label, options, artifact }) => { + const setting = userSettings && userSettings[key] ? userSettings[key].value : "Automatic"; + const baselineArtifact = artifact(baselineSet); + const rows: any[] = []; + + options.forEach((option) => { + try { + const evaluated = evalSet(equippedSet, player, contentType, baseHPS, withSetting(userSettings, key, option), castModel, false, 0); + rows.push({ + option: option, + hps: Math.round(evaluated.setHPS || 0), + hpsDelta: Math.round((evaluated.setHPS || 0) - baselineHPS), + scoreDelta: baselineScore > 0 ? Math.round(((evaluated.hardScore - baselineScore) / baselineScore) * 10000) / 100 : 0, + // True when this option is what the player is already effectively using, whether they picked it + // explicitly or arrived at it through Automatic. + isCurrent: baselineArtifact != null && artifact(evaluated) === baselineArtifact, + }); + } catch (err) { + // One bad option shouldn't cost the player the whole table. + } + }); + + if (rows.length === 0) return; + + // Best first, so the top row is the recommendation. + rows.sort((a, b) => (b.hpsDelta - a.hpsDelta) || (b.scoreDelta - a.scoreDelta)); + + // Name the option Automatic actually resolved to, so the UI can say "Automatic (Crit / Haste)". + const resolved = rows.find((row) => row.isCurrent); + + // If every option prices identically there is nothing to choose between them - almost always because the + // effects aren't modelled yet (several Folio runes have empty formulas). Showing a column of zeroes reads as + // a real tie, so flag it and let the UI say so instead. + const allEqual = rows.every((row) => row.hps === rows[0].hps && row.scoreDelta === rows[0].scoreDelta); + + comparisons[key] = { label: label, current: setting, resolvedTo: resolved ? resolved.option : null, unmodelled: allEqual, rows: rows }; + }); + + return Object.keys(comparisons).length > 0 ? comparisons : null; +} + +function resolveGems(spec: string, contentType: contentTypes, userSettings: any) { + const gems = getMidnightGemOptions(spec, contentType, userSettings).slice(); + + const metaChoice = getSetting(userSettings, "metaGem"); + if (typeof metaChoice === "string" && metaChoice !== "Automatic" && META_GEM_OPTIONS[metaChoice]) { + gems[0] = META_GEM_OPTIONS[metaChoice]; + } + + const comboChoice = getSetting(userSettings, "gemCombo"); + if (typeof comboChoice === "string" && comboChoice !== "Automatic" && GEM_COMBO_OPTIONS[comboChoice]) { + const [major, minor] = GEM_COMBO_OPTIONS[comboChoice]; + const gemID = findGemByStats(major, minor); + // A missing gem falls through to the automatic picks rather than socketing nothing. + if (gemID) { + for (let i = 1; i < gems.length; i++) gems[i] = gemID; + } + } + + return gems; +} + function getMidnightGemOptions(spec: string, contentType: contentTypes, settings: PlayerSettings) { const metaGem = 240983; // Elusive Meta Gem const gemArray = Array(8); @@ -252,6 +428,31 @@ export function runTopGear(rawItemList: Item[], wepCombos: Item[], player: Playe let itemSets = createSets(itemList, wepCombos, player.spec); let resultSets = []; + // Tracked so the report can explain why a selected embellished item never shows up in a set. + const embellishedSelected = itemList.filter((item: Item) => isEmbellished(item)).length; + + // == Currently equipped set == + // Run the player's current gear through the same evaluation so the report can show how big an upgrade the best + // set actually is. This is display only and deliberately sits outside the ranking loop - it never competes. + let equippedHPS = 0; + let optionComparisons: any = null; + try { + const equippedItems = itemList.filter((item: Item) => item.isEquipped); + if (equippedItems.length > 0) { + const baseEquipped = new ItemSet(-1, equippedItems, 0, player.spec); + const equippedSet = evalSet(baseEquipped, newPlayer, contentType, baseHPS, userSettings, newCastModel, false, 0); + equippedHPS = equippedSet.setHPS || 0; + + // Fine tuning table: what every gem, enchant and Folio rune would be worth on the gear the player is + // actually wearing. Evaluated on the equipped set rather than the best set so the numbers answer + // "what should I socket right now", and always outside the ranking loop so it can't influence results. + optionComparisons = buildOptionComparisons(baseEquipped, newPlayer, contentType, baseHPS, userSettings, newCastModel, equippedSet); + } + } catch (err) { + // A malformed equipped set shouldn't take down the whole run - we just lose the comparison table. + reportError(newPlayer, "Top Gear", "Failed to evaluate equipped set for upgrade comparison", String(err)); + } + itemSets.sort((a, b) => (a.sumSoftScore < b.sumSoftScore ? 1 : -1)); // == Evaluate Sets == @@ -280,6 +481,60 @@ export function runTopGear(rawItemList: Item[], wepCombos: Item[], player: Playe resultSets.sort((a, b) => (a.hardScore < b.hardScore ? 1 : -1)); //itemSets = pruneItems(itemSets, userSettings); resultSets = pruneSets(resultSets, userSettings); + + // == Joint gem / enchant / Folio optimisation == + // Every set above was scored with one fixed configuration. Because secondary stats share diminishing returns, + // the best gems depend on the gear, so a set that ranks second with default gems can win once optimally gemmed. + // When enabled we re-optimise the leading sets and re-rank on the result. + let optimalConfig: any = null; + if (getSetting(userSettings, "optimizeGemsEnchants") === true && resultSets.length > 0) { + try { + const rawById = new Map(itemSets.map((set: ItemSet) => [set.id, set])); + const contenders = resultSets.slice(0, CONSTRAINTS.Shared.topGearOptimizeSets); + + const optimised = contenders.map((evaluated: any) => { + const raw = rawById.get(evaluated.id); + if (!raw) return null; + const result = optimizeConfiguration(raw, newPlayer, contentType, baseHPS, userSettings, newCastModel); + return { raw: raw, evaluated: evaluated, ...result }; + }).filter((entry: any) => entry !== null); + + if (optimised.length > 0) { + optimised.sort((a: any, b: any) => b.score - a.score); + const winner = optimised[0]; + + // Re-evaluate the winner with its optimal configuration so the reported stats, gems and enchants all + // reflect what the player is actually being told to wear. + const finalSettings = withConfig(userSettings, winner.config); + const finalSet = evalSet(winner.raw, newPlayer, contentType, baseHPS, finalSettings, newCastModel, reporting, 0); + + // Re-score the remaining sets under the same configuration so the alternatives stay comparable. + resultSets = resultSets.map((evaluated: any) => { + if (evaluated.id === winner.raw.id) return finalSet; + const raw = rawById.get(evaluated.id); + if (!raw) return evaluated; + try { + return evalSet(raw, newPlayer, contentType, baseHPS, finalSettings, newCastModel, false, 0); + } catch (err) { + return evaluated; + } + }); + resultSets.sort((a: any, b: any) => (a.hardScore < b.hardScore ? 1 : -1)); + + optimalConfig = { + config: winner.config, + gain: Math.round(winner.gain), + baseline: Math.round(winner.baseline), + score: Math.round(winner.score), + setsOptimized: optimised.length, + evaluations: optimised.reduce((acc: number, entry: any) => acc + entry.evaluations, 0), + }; + } + } catch (err) { + // Optimisation is an enhancement - if it fails the player still gets the normal, correctly ranked result. + reportError(newPlayer, "Top Gear", "Gem/enchant optimisation failed", String(err)); + } + } // == Build Differentials (sets similar in strength) == // A differential is a set that wasn't our best but was close. We'll display these beneath our top gear so that a player could choose a higher stamina option, or a trinket they prefer @@ -295,10 +550,19 @@ export function runTopGear(rawItemList: Item[], wepCombos: Item[], player: Playe // If we were able to make a set then create a Top Gear result and return it. // If not we'll send back an empty set which will show an error to the player. That's pretty rare nowadays but can happen if their SimC has empty slots in it and so on. if (resultSets.length === 0) { + // Every set we built was thrown out. By far the most common cause is the embellishment cap: a player who already + // wears two embellishments and then adds a third embellished item has no wearable combination left, and used to + // just get an empty report with no explanation. + reportError(newPlayer, "Top Gear", "No valid sets after verification. Sets built: " + itemSets.length + + ", embellished items selected: " + embellishedSelected, contentType); return null; } else { let result: TopGearResult = new TopGearResult(resultSets[0], differentials, contentType); result.itemsCompared = resultSets.length; + result.embellishedSelected = embellishedSelected; + result.equippedHPS = equippedHPS; + result.optionComparisons = optionComparisons; + result.optimalConfig = optimalConfig; result.new = true; result.id = generateReportCode(); return result; @@ -477,13 +741,21 @@ function buildDifferential(itemSet: ItemSet, primeSet: ItemSet, player: Player, let differentials: { items: Item[]; // gems: number[]; // - scoreDifference: number; - rawDifference: number; + scoreDifference: number; + rawDifference: number; + hps: number; + hpsDifference: number; } = { items: [], gems: [], scoreDifference: ((Math.round(primeSet.hardScore - itemSet.hardScore) / primeSet.hardScore) * 100 * modelDiff), rawDifference: Math.round(((itemSet.hardScore - primeSet.hardScore) / primeSet.hardScore) * player.getHPS(contentType) * modelDiff), + + // Absolute throughput for this alternative, and the healing it gives up against the best set. + // Both are 0 when the spec / content type is scored on stat weights, since no HPS figure exists there. + // modelDiff only scales the Default path, which is exactly where these stay 0, so the two don't interact. + hps: itemSet.setHPS || 0, + hpsDifference: Math.round((itemSet.setHPS || 0) - (primeSet.setHPS || 0)), }; @@ -561,21 +833,32 @@ function sumScore(obj: any) { return sum; } -function enchantItems(bonus_stats: Stats, setStats: Stats, castModel: any, contentType: contentTypes, spec: string) { +// Reads an enchant setting, tolerating a missing or malformed value (getSetting returns 0 when absent). +function getEnchantChoice(userSettings: any, key: string): string { + const raw = getSetting(userSettings, key); + return typeof raw === "string" && raw ? raw : "Automatic"; +} + +function enchantItems(bonus_stats: Stats, setStats: Stats, castModel: any, contentType: contentTypes, spec: string, userSettings: any) { let enchants: {[key: string]: string | number | number[]} = {}; // TODO: Cleanup // Rings - Best secondary. // We use the players highest stat weight here. Using an adjusted weight could be more accurate, but the difference is likely to be the smallest fraction of a // single percentage. The stress this could cause a player is likely not worth the optimization. - let highestWeight = getHighestWeight(castModel); + const highestWeight = getHighestWeight(castModel); + + // Ring enchants all grant the same amount, so the only choice is which stat. Automatic follows the player's + // highest weighted stat, which is what the engine did before this was configurable. + const ringChoice = getEnchantChoice(userSettings, "ringEnchant"); + const ringStat = ringChoice === "Automatic" ? highestWeight : ringChoice.toLowerCase(); - bonus_stats[highestWeight as keyof typeof bonus_stats] = (bonus_stats[highestWeight as keyof typeof bonus_stats] || 0) + 29; // 64 x 2. + bonus_stats[ringStat as keyof typeof bonus_stats] = (bonus_stats[ringStat as keyof typeof bonus_stats] || 0) + 29; // 64 x 2. let ringEnchantName = ""; if (spec === "Holy Priest" || spec === "Restoration Shaman") ringEnchantName = "Eyes of the Eagle"; - else if (highestWeight === "haste") ringEnchantName = "Silvermoon's Alacrity"; - else if (highestWeight === "crit") ringEnchantName = "Nature's Fury"; - else if (highestWeight === "mastery") ringEnchantName = "Zul'jin's Mastery"; - else if (highestWeight === "versatility") ringEnchantName = "Silvermoon's Tenacity"; + else if (ringStat === "haste") ringEnchantName = "Silvermoon's Alacrity"; + else if (ringStat === "crit") ringEnchantName = "Nature's Fury"; + else if (ringStat === "mastery") ringEnchantName = "Zul'jin's Mastery"; + else if (ringStat === "versatility") ringEnchantName = "Silvermoon's Tenacity"; enchants["Finger"] = ringEnchantName; @@ -610,18 +893,30 @@ function enchantItems(bonus_stats: Stats, setStats: Stats, castModel: any, conte enchants["Feet"] = "Shaladrassil's Roots"; - // Weapon - Acuity of the Ren'dorei. Should add a setting for secondary enchants too. - let wepEnchantName = "Acuity of the Ren'dorei" - if (spec === "Discipline Priest" || spec === "Restoration Druid") { + // Weapon. Automatic keeps the per-spec default; otherwise the player picks the stat they want. + // Note the secondary enchants are budgeted higher than the intellect one, so this is a real choice. + const weaponUptime = convertPPMToUptime(3, 15); + const weaponChoice = getEnchantChoice(userSettings, "weaponEnchant"); + + let weaponStat: string; + if (weaponChoice !== "Automatic") weaponStat = weaponChoice.toLowerCase(); + else if (spec === "Discipline Priest" || spec === "Restoration Druid") weaponStat = "haste"; + else if (spec === "Preservation Evoker") weaponStat = "mastery"; + else weaponStat = "intellect"; + + let wepEnchantName = "Acuity of the Ren'dorei"; + if (weaponStat === "haste") { wepEnchantName = "Berserker's Rage"; - bonus_stats.haste = (bonus_stats.mastery || 0) + 124 * convertPPMToUptime(3, 15); + // This previously read bonus_stats.mastery while assigning to haste, so these two specs lost their haste + // and inherited their mastery instead. + bonus_stats.haste = (bonus_stats.haste || 0) + 124 * weaponUptime; } - else if (spec === "Preservation Evoker") { + else if (weaponStat === "mastery") { wepEnchantName = "Arcane Mastery"; - bonus_stats.mastery = (bonus_stats.mastery || 0) + 124 * convertPPMToUptime(3, 15); + bonus_stats.mastery = (bonus_stats.mastery || 0) + 124 * weaponUptime; } else { - bonus_stats.intellect += 67 * convertPPMToUptime(3, 15); + bonus_stats.intellect = (bonus_stats.intellect || 0) + 67 * weaponUptime; } enchants["CombinedWeapon"] = wepEnchantName; @@ -743,7 +1038,7 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, // == Enchants and gems == - const enchants = enchantItems(enchantStats, setStats, castModel, contentType, player.spec); + const enchants = enchantItems(enchantStats, setStats, castModel, contentType, player.spec, userSettings); compileStats(bonus_stats, enchantStats); statBreakdown.enchants = enchantStats; @@ -753,7 +1048,10 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, const consumableStats: Stats = {}; // == Flask == let selectedChoice = ""; - if (getSetting(userSettings, "flaskChoice") === "Automatic") { + // getSetting returns 0 when the setting is missing (stale local storage, an engine test that passes a partial + // settings object), so treat anything that isn't a usable string as Automatic rather than crashing the whole run. + const flaskChoice = getSetting(userSettings, "flaskChoice"); + if (typeof flaskChoice !== "string" || !flaskChoice || flaskChoice === "Automatic") { const bestStat = getHighestWeight(castModel); if ((setStats[bestStat] + bonus_stats[bestStat]) > 28000) { @@ -763,7 +1061,7 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, selectedChoice = bestStat; } else { - selectedChoice = getSetting(userSettings, "flaskChoice").toLowerCase(); + selectedChoice = flaskChoice.toLowerCase(); consumableStats[selectedChoice] = (consumableStats[selectedChoice] || 0) + 165; } @@ -772,16 +1070,23 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, else if (selectedChoice === "crit") enchants.flask = "Flask of the Shattered Sun"; else if (selectedChoice === "versatility") enchants.flask = "Flask of Thalassian Resistance"; - // Food buff - consumableStats.intellect = (consumableStats.intellect ?? 0) + 50; + // Food buff. Only the standard intellect food is modelled - see CONSUMABLES below for how to add more. + if (getSetting(userSettings, "foodBuff") !== "None") { + consumableStats.intellect = (consumableStats.intellect ?? 0) + 50; + enchants.food = "Intellect Food"; + } // Weapon Oil - consumableStats.haste = (consumableStats.haste ?? 0) + 15; - consumableStats.crit = (consumableStats.crit ?? 0) + 15; + if (getSetting(userSettings, "weaponOil") !== false) { + consumableStats.haste = (consumableStats.haste ?? 0) + 15; + consumableStats.crit = (consumableStats.crit ?? 0) + 15; + enchants.oil = "Weapon Oil"; + } - // Vantus Rune - if (contentType === "Raid") { + // Vantus Rune. Raid only, and only if the player actually uses one. + if (contentType === "Raid" && getSetting(userSettings, "vantusRune") !== false) { consumableStats.versatility = (consumableStats.versatility ?? 0) + 162; + enchants.rune = "Vantus Rune"; } statBreakdown.consumables = consumableStats; @@ -795,7 +1100,7 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, } else { - enchants["Gems"] = getMidnightGemOptions(player.spec, contentType, userSettings).slice(0, Math.max(0, builtSet.setSockets)); + enchants["Gems"] = resolveGems(player.spec, contentType, userSettings).slice(0, Math.max(0, builtSet.setSockets)); const gemStats = getGemStats(enchants["Gems"]); statBreakdown.gems = gemStats; @@ -869,24 +1174,9 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, // Omnium Folio - // Handle user entry / unlocks later. - const folioGems = [1279599, 1279603, 1287555] - const bestStat = getHighestWeight(castModel); - switch (bestStat) { - case "haste": - folioGems.push(1287774); - break; - case "crit": - folioGems.push(1279609); - break; - case "mastery": - folioGems.push(1287771); - break; - case "versatility": - folioGems.push(1279613); - break; - } - folioGems.push(1279614) + // Slots 1, 4 and 5 are player-configurable through settings. Anything left on Automatic resolves to the same + // rune the engine used to hardcode, so an untouched settings object produces an identical set. + const folioGems = getFolioGems(userSettings, getHighestWeight(castModel)); const folioStats = getFolioEffect(folioGems, {player: player, contentType: contentType, settings: userSettings, setStats: setStats, castModel: castModel, setVariables: setVariables}); @@ -1096,6 +1386,17 @@ function evalSet(rawItemSet: ItemSet, player: Player, contentType: contentTypes, } builtSet.hardScore = Math.round(1000 * hardScore) / 1000; + + // == Absolute Throughput == + // hardScore is an intellect-equivalent ranking number and can't be shown to the player as healing. Where the set + // was run through a cast model or a ramp sim though, setStats.hps is a genuine HPS figure we can report directly. + // On the stat weight path setStats.hps only holds flat HPS granted by effects (tier bonuses, some trinkets), which + // is not the player's total healing, so we deliberately leave this at 0 rather than report a misleading number. + const evaluationPath = castModel.modelType[contentType]; + builtSet.setHPS = (evaluationPath === MODEL_TYPES.CAST_MODEL || evaluationPath === MODEL_TYPES.SEQUENCES) + ? Math.round(setStats.hps || 0) + : 0; + builtSet.setStats = setStats; builtSet.enchantBreakdown = enchants; builtSet.gemBreakdown = JSON.stringify(enchants["Gems"] || []); diff --git a/src/General/Modules/TopGear/Engine/TopGearEngineShared.js b/src/General/Modules/TopGear/Engine/TopGearEngineShared.js index 4120799111..fe9cff298e 100644 --- a/src/General/Modules/TopGear/Engine/TopGearEngineShared.js +++ b/src/General/Modules/TopGear/Engine/TopGearEngineShared.js @@ -5,10 +5,6 @@ import { CONSTANTS } from "General/Engine/CONSTANTS"; import { getTranslatedSlotName } from "locale/slotsLocale"; import { getSetting } from "Retail/Engine/EffectFormulas/EffectUtilities"; -export function createTopGearWorker() { - return new Worker(new URL('./TopGearWorker.js', import.meta.url), { type: 'module' }); -} - // Compiles stats & bonus stats into one array to which we can then apply DR etc. export function compileStats(stats, bonus_stats) { @@ -49,6 +45,11 @@ export const generateReportCode = () => { gems: [], scoreDifference: (Math.round(primeSet.hardScore - itemSet.hardScore) / primeSet.hardScore) * 100, rawDifference: Math.round(((itemSet.hardScore - primeSet.hardScore)))/* * player.getHPS(contentType))*/, + + // Absolute throughput for this alternative, and how much healing it gives up against the best set. + // Both are 0 when the spec / content type is scored on stat weights, since no HPS figure exists there. + hps: itemSet.setHPS || 0, + hpsDifference: Math.round((itemSet.setHPS || 0) - (primeSet.setHPS || 0)), }; for (var x = 0; x < diffList.length; x++) { diff --git a/src/General/Modules/TopGear/Engine/TopGearResult.ts b/src/General/Modules/TopGear/Engine/TopGearResult.ts index 9612d09bd5..f14a5913ce 100644 --- a/src/General/Modules/TopGear/Engine/TopGearResult.ts +++ b/src/General/Modules/TopGear/Engine/TopGearResult.ts @@ -13,6 +13,21 @@ export class TopGearResult { itemsCompared: number = 0; id: string = ""; new: boolean = false; + + // How many of the items the player selected carry an embellishment. Only two can be worn at once, so when this is + // higher the report explains why a selected embellished item didn't make the final set. + embellishedSelected: number = 0; + + // Throughput of the gear the player is currently wearing, evaluated through the same path as the candidate sets. + // Used to show how much of an upgrade the best set is. 0 when the spec has no cast model, or nothing is equipped. + equippedHPS: number = 0; + + // Per-option comparison table for gems, enchants, flask and Folio runes, priced against the player's currently + // equipped gear so they can fine tune stats. Null when nothing is flagged as equipped. + optionComparisons: any = null; + + // The winning gem / enchant / Folio configuration when joint optimisation ran, plus what it gained. + optimalConfig: any = null; } export default TopGearResult; diff --git a/src/General/Modules/TopGear/Engine/TopGearWorkerFactory.js b/src/General/Modules/TopGear/Engine/TopGearWorkerFactory.js new file mode 100644 index 0000000000..cdd1a0d7f2 --- /dev/null +++ b/src/General/Modules/TopGear/Engine/TopGearWorkerFactory.js @@ -0,0 +1,6 @@ +// Spawning the Top Gear worker lives in its own module because `import.meta.url` can only be parsed by the webpack +// build. Keeping it out of TopGearEngineShared means the engine helpers in that file stay importable from tests, +// which otherwise fail to parse the whole module before running a single assertion. +export function createTopGearWorker() { + return new Worker(new URL('./TopGearWorker.js', import.meta.url), { type: 'module' }); +} diff --git a/src/General/Modules/TopGear/ItemSet.ts b/src/General/Modules/TopGear/ItemSet.ts index b0da29fb89..f5f6cfc62d 100644 --- a/src/General/Modules/TopGear/ItemSet.ts +++ b/src/General/Modules/TopGear/ItemSet.ts @@ -1,6 +1,7 @@ // Represents a full set of items. import { getTranslatedItemName } from "../../Engine/ItemUtilities"; import Item from "../../Items/Item"; +import { getEmbellishmentByEffectName } from "Databases/EmbellishmentDB"; class ItemSet { @@ -14,6 +15,12 @@ class ItemSet { sumSoftScore: number = 0; hardScore: number = 0; + // The set's absolute throughput in HPS. This is only populated when the set was evaluated through a cast model or + // a ramp sim, since those are the only paths that actually produce a healing number. On the stat weight path the + // score is an intellect-equivalent ranking figure with no throughput attached, so this stays 0 and the report + // shows nothing rather than inventing a value. + setHPS: number = 0; + // The number of sockets in the set setSockets: number = 0; @@ -74,6 +81,7 @@ class ItemSet { clonedSet.spec = this.spec; clonedSet.sumSoftScore = this.sumSoftScore; clonedSet.hardScore = this.hardScore; + clonedSet.setHPS = this.setHPS; clonedSet.setSockets = this.setSockets; clonedSet.uniques = { ...this.uniques }; clonedSet.effectList = this.effectList.slice(); @@ -132,7 +140,8 @@ class ItemSet { //console.log("Compiling Stats for Item List of legnth: " + this.itemList.length); let setStats = this.getStartingStats(gameType) let setSockets = 0; - + const multiPieceEmbellishments: { [key: string]: { count: number; effect: any; pieces: number } } = {}; + if (gameType === "Classic") { // Replace every item with a duplicate. this.itemList = this.itemList.map(item => JSON.parse(JSON.stringify(item))); @@ -164,20 +173,44 @@ class ItemSet { } if (item.onUse) this.onUseTrinkets.push({name: item.effect.name, level: item.level}); - + if (item.effect) { let effect = item.effect; effect.level = item.level; if (item.selectedOptions) effect.selectedOptions = item.selectedOptions; - this.effectList.push(effect); + + // Multi-piece embellishments (the "(Set)" entries in EmbellishmentDB) are carried by several crafted items + // but only grant their effect once, and only once enough pieces are worn. Hold them back and resolve after + // the loop so we don't count the same bonus two or three times over. + const embelSet = effect.type === "embellishment" ? getEmbellishmentByEffectName(effect.name) : undefined; + if (embelSet && (embelSet.pieces || 1) > 1) { + const held = multiPieceEmbellishments[effect.name] || { count: 0, effect: effect, pieces: embelSet.pieces || 1 }; + held.count += 1; + // Use the lowest item level of the contributing pieces - the set bonus can't scale off gear you aren't wearing. + if ((effect.level || 0) < (held.effect.level || 0)) held.effect = effect; + multiPieceEmbellishments[effect.name] = held; + + // A multi-piece embellishment consumes an embellishment slot per piece, same as any other. Most carriers + // are already tagged uniqueEquip: "Embellishment" in ItemDB and counted above - only top up the ones that + // aren't, so the set still respects the two embellishment cap. + if (!item.uniqueEquip) this.uniques["embellishment"] = (this.uniques["embellishment"] || 0) + 1; + } + else { + this.effectList.push(effect); + } } } + // Resolve multi-piece embellishments now that we know how many carriers made it into the set. + for (const key in multiPieceEmbellishments) { + const held = multiPieceEmbellishments[key]; + if (held.count >= held.pieces) this.effectList.push(held.effect); + } this.setStats = setStats; //this.baseStats = {...setStats}; this.setSockets = setSockets; - + return this; } diff --git a/src/General/Modules/TopGear/Report/CompetitiveAlternatives.js b/src/General/Modules/TopGear/Report/CompetitiveAlternatives.js index ca11620912..0421682232 100644 --- a/src/General/Modules/TopGear/Report/CompetitiveAlternatives.js +++ b/src/General/Modules/TopGear/Report/CompetitiveAlternatives.js @@ -57,6 +57,24 @@ function CompetitiveAlternatives(props) { return Math.abs(diff); }; + /* ------------------------------------ Alternative Set Value ----------------------------------- */ + // Where the spec is evaluated through a cast model we have a real HPS figure for every alternative, so show the + // absolute throughput of the set alongside the healing and percentage it gives up. When the percentage can be + // derived from HPS we use that rather than the score difference, since it's the same quantity being reported. + // Otherwise fall back to the relative score difference, which is all the stat weight path can honestly tell us. + const getSetValueText = (differential) => { + if (differential.hps > 0) { + const lost = Math.round(differential.hpsDifference || 0); + const primeHPS = differential.hps - lost; // the best set's HPS + const percent = primeHPS > 0 ? (lost / primeHPS) * 100 : 0; + const percentText = Math.abs(percent) < 0.01 ? "<0.01%" : (Math.round(percent * 100) / 100) + "%"; + + return Math.round(differential.hps).toLocaleString() + " HPS (" + + (lost >= 0 ? "+" : "-") + Math.abs(lost).toLocaleString() + ", " + percentText + ")"; + } + return (gameType === "Classic" ? Math.round(differential.rawDifference / 60) : differential.rawDifference) + " (" + roundTo(differential.scoreDifference, 2) + "%)"; + }; + return ( @@ -165,7 +183,7 @@ function CompetitiveAlternatives(props) { width: "100%", }} > - {(gameType === "Classic" ? Math.round(key.rawDifference / 60) : key.rawDifference) + " HPS (" + roundTo(key.scoreDifference, 2) + "%)"} + {getSetValueText(key)} diff --git a/src/General/Modules/TopGear/Report/DynamicAdvice.ts b/src/General/Modules/TopGear/Report/DynamicAdvice.ts index a916862184..46c16ab871 100644 --- a/src/General/Modules/TopGear/Report/DynamicAdvice.ts +++ b/src/General/Modules/TopGear/Report/DynamicAdvice.ts @@ -1,6 +1,7 @@ import { Player } from "General/Modules/Player/Player" import { Item } from "General/Items/Item" +import { isEmbellished, MAX_EMBELLISHMENTS } from "General/Engine/ItemUtilities" const checkHasItem = (itemList: Item[], itemID: number) => { return itemList.filter((item: Item) => item.id === itemID).length > 0; @@ -19,6 +20,25 @@ export const getDynamicAdvice = (report : any, strippedPlayer: any, contentType: advice.push("You didn't actually click any extra items which means the set above is what you are currently wearing. You can add items to the comparison \ by clicking on them in the top gear item select screen.") } + + // A one hander is evaluated with an empty offhand when the player hasn't selected one. That's a real result for + // the items they picked, but it costs the one hander a whole item's worth of stats against any two hander it is + // being compared to, so say so rather than letting it quietly lose. + // Only two embellishments can be worn at once, so any others the player selected are dropped from every set. + // Without this the item simply never appears in the report and it looks like Top Gear ignored it. + const embellishedInSet = itemList.filter((item: any) => isEmbellished(item)).length; + if (report.embellishedSelected > MAX_EMBELLISHMENTS) { + advice.push("You selected " + report.embellishedSelected + " embellished items but only " + MAX_EMBELLISHMENTS + + " can be worn at once, so the set above uses the best " + embellishedInSet + ". If an embellished item you added \ + isn't showing up, that's why - deselect one of the others to compare it directly.") + } + + const hasOneHander = itemList.some((item: Item) => item.slot === "1H Weapon"); + const hasOffhand = itemList.some((item: Item) => ["Offhand", "Holdable", "Shield"].includes(item.slot)); + if (hasOneHander && !hasOffhand) { + advice.push("This set uses a one handed weapon but no offhand was selected, so the offhand slot is being scored as empty. \ + Add an offhand in the item select screen to see what the one hander is really worth.") + } if (gameType === "Classic") { advice.push("Expected HPS: " + Math.round(topSet.metrics.healing / 60 * 0.85) + " - " + Math.round(topSet.metrics.healing / 60 * 1) + ". Your HPS can be very fight dependent and it's ok if you aren't perfectly in this range.") advice.push("Expected DPS: " + Math.round(topSet.metrics.damage / 60 * 0.7) + " - " + Math.round(topSet.metrics.damage / 60 * 1) + ". Your DPS is heavily dependent on how much time you spend casting DPS spells and will vary per fight.") diff --git a/src/General/Modules/TopGear/Report/Panels/FineTuningPanel.tsx b/src/General/Modules/TopGear/Report/Panels/FineTuningPanel.tsx new file mode 100644 index 0000000000..60e2e61958 --- /dev/null +++ b/src/General/Modules/TopGear/Report/Panels/FineTuningPanel.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { Grid, Paper, Typography, Divider, Box, Tooltip } from "@mui/material"; + +/* ---------------------------------------------------------------------------------------------- */ +/* Prices every gem, enchant, flask and Folio rune against the player's equipped gear. */ +/* ---------------------------------------------------------------------------------------------- */ +// The engine evaluates each option by re-running the equipped set with only that setting changed, so these +// numbers already include diminishing returns and the cast model rather than being flat stat-weight estimates. + +interface OptionRow { + option: string; + hps: number; + hpsDelta: number; + scoreDelta: number; + isCurrent: boolean; +} + +interface OptionGroup { + label: string; + current: string; + resolvedTo: string | null; + unmodelled: boolean; + rows: OptionRow[]; +} + +const deltaColour = (delta: number) => (delta > 0 ? "#a0f0a0" : delta < 0 ? "#f28b82" : "rgba(255,255,255,0.5)"); + +const formatDelta = (row: OptionRow, hasHPS: boolean) => { + if (hasHPS) { + if (row.hpsDelta === 0) return "—"; + return (row.hpsDelta > 0 ? "+" : "") + Math.round(row.hpsDelta).toLocaleString() + " HPS"; + } + if (row.scoreDelta === 0) return "—"; + return (row.scoreDelta > 0 ? "+" : "") + row.scoreDelta + "%"; +}; + +export default function FineTuningPanel(props: any) { + const comparisons: { [key: string]: OptionGroup } = props.optionComparisons || {}; + const groups = Object.keys(comparisons); + + if (groups.length === 0) { + return ( + + Import a character so QE Live knows what you're currently wearing, and this tab will price every gem, + enchant and Folio rune against it. + + ); + } + + // Some specs are scored on stat weights and produce no throughput figure, in which case we show the relative + // score difference instead. Never both, so the column means one thing at a time. + const hasHPS = groups.some((key) => comparisons[key].rows.some((row) => row.hps > 0)); + + const optimal = props.optimalConfig; + + return ( + + {optimal && ( + + + + Optimised setup — {"+" + Number(optimal.gain).toLocaleString()} HPS over the defaults + + + Searched {optimal.setsOptimized} leading gear sets jointly across gems, enchants, flask and Folio runes. + + + {Object.entries(optimal.config).map(([key, value]) => ( + + {String(value)} + + ))} + + + + )} + + + {hasHPS + ? "Each option priced against your currently equipped gear, changing that one thing only. Your current pick is outlined." + : "Each option compared against your currently equipped gear, as a percentage of set score. Your current pick is outlined."} + + {/* These are single-axis figures. Stats share diminishing returns, so two options that each look good + can compete for the same headroom - the numbers do not add up, and the best row here is not always + part of the best overall setup. Say so, because the optimiser can and does disagree with this table. */} + + These are priced one change at a time, so they don't add together — two options that each gain can be + chasing the same stat.{optimal ? " The optimised setup above accounts for that and is the one to follow where they disagree." : ""} + + + + {groups.map((key) => { + const group = comparisons[key]; + const best = group.rows[0]; + + return ( + + + + {group.label} + + {/* Settings usually read "Automatic", which tells the player nothing about what they're actually + running. Name the option it resolved to so the outlined row makes sense. */} + + {group.current === "Automatic" && group.resolvedTo + ? "Automatic — " + group.resolvedTo + : "Current — " + group.current} + + + + {group.unmodelled && ( + + These options aren't modelled yet, so QE Live can't tell them apart. + + )} + + {(group.unmodelled ? [] : group.rows).map((row) => { + const isBest = row.option === best.option && (hasHPS ? row.hpsDelta > 0 : row.scoreDelta > 0); + return ( + + {hasHPS ? Math.round(row.hps).toLocaleString() + " HPS with this option" : "Relative set score"} + {row.isCurrent ? " — this is your current selection" : ""} + + } + > + + + {row.option} + + + {formatDelta(row, hasHPS)} + + + + ); + })} + + + ); + })} + + ); +} diff --git a/src/General/Modules/TopGear/Report/TopGearReport.js b/src/General/Modules/TopGear/Report/TopGearReport.js index 7de7811a00..9e8e64c094 100644 --- a/src/General/Modules/TopGear/Report/TopGearReport.js +++ b/src/General/Modules/TopGear/Report/TopGearReport.js @@ -595,6 +595,8 @@ function displayReport( spec={player.spec} currentLanguage={currentLanguage} gameType={gameType} + setHPS={topSet.setHPS} + equippedHPS={result.equippedHPS} /> @@ -778,6 +780,8 @@ function displayReport( statList={statList} manaSources={manaSources} spec={player.spec} + optionComparisons={result.optionComparisons} + optimalConfig={result.optimalConfig} /> {" "} diff --git a/src/General/Modules/TopGear/Report/TopGearReportTabs.js b/src/General/Modules/TopGear/Report/TopGearReportTabs.js index e2035666ac..82c3e05696 100644 --- a/src/General/Modules/TopGear/Report/TopGearReportTabs.js +++ b/src/General/Modules/TopGear/Report/TopGearReportTabs.js @@ -5,6 +5,7 @@ import ManaSourcesComponent from "./ManaComponent"; import SpellDataAccordion from "./SpellDataAccordion"; import TopGearGemList from "./Panels/TopGearGemPanel"; import ErrorBoundary from "./Panels/PanelErrorBoundary"; +import FineTuningPanel from "./Panels/FineTuningPanel"; // To add a tab: append one entry. Each tab owns its label, colors, // visibility rule (`show`), and content (`render`). Both `show` and @@ -25,6 +26,17 @@ const TAB_DEFS = [ /> ), }, + { + label: "Fine Tuning", + bg: "#2B5A78", + accent: "#7fd1ff", + show: ({ optionComparisons, optimalConfig }) => (!!optionComparisons && Object.keys(optionComparisons).length > 0) || !!optimalConfig, + render: ({ optionComparisons, optimalConfig }) => ( + + + + ), + }, { label: "Gems", bg: "#612B78", diff --git a/src/General/Modules/TopGear/Report/TopSetStatsPanel.tsx b/src/General/Modules/TopGear/Report/TopSetStatsPanel.tsx index 19a40161a5..a81ea011d5 100644 --- a/src/General/Modules/TopGear/Report/TopSetStatsPanel.tsx +++ b/src/General/Modules/TopGear/Report/TopSetStatsPanel.tsx @@ -45,6 +45,17 @@ export default function TopSetStatsPanel(props) { const breakdown = props.statBreakdown; const { t } = useTranslation(); const gameType = props.gameType; + const setHPS = props.setHPS || 0; + const equippedHPS = props.equippedHPS || 0; + + // How much of an upgrade the best set is over what the player is wearing right now. Negative is possible and is + // shown as such - it means the set Top Gear built is worse than what they already have on. + const upgradePercent = equippedHPS > 0 && setHPS > 0 ? ((setHPS - equippedHPS) / equippedHPS) * 100 : null; + const formatUpgrade = (percent: number) => (percent > 0 ? "+" : "") + (Math.round(percent * 100) / 100) + "%"; + + // A dead-on 0% almost always means the player ran Top Gear without adding any candidate items, so the best set + // is simply the gear they're wearing. "+0.00%" reads like the comparison failed, so say what happened instead. + const isSameAsEquipped = upgradePercent !== null && Math.abs(setHPS - equippedHPS) < 1; const stats = gameType === "Retail" ? [ @@ -175,6 +186,71 @@ return ( ))} + + {/* Absolute throughput. Only rendered for specs / content types that are evaluated through a cast model or + ramp sim, since those are the only paths that produce a real healing number. */} + {setHPS > 0 && ( + <> + + + + Estimated HPS + + + + Total healing per second this set is modelled to do, from a full cast profile at these stats. + Use it to compare sets - it is not a prediction of your logs. + + {upgradePercent !== null && ( + <> + + + Currently equipped + {Math.round(equippedHPS).toLocaleString()} + + + Gained + + {(setHPS - equippedHPS >= 0 ? "+" : "") + Math.round(setHPS - equippedHPS).toLocaleString()} + + + + )} + + } + slotProps={tooltipSlotProps} + > + + {Math.round(setHPS).toLocaleString() + " HPS"} + {upgradePercent !== null && !isSameAsEquipped && ( + 0 ? "#a0f0a0" : "#f28b82", fontWeight: "normal" }}> + {" (" + formatUpgrade(upgradePercent) + ")"} + + )} + + + + {isSameAsEquipped && ( + + Same as your equipped gear — add items to compare + + )} + + )} diff --git a/src/General/Modules/TopGear/TopGear.tsx b/src/General/Modules/TopGear/TopGear.tsx index 4545a5a0ea..e093b1f830 100644 --- a/src/General/Modules/TopGear/TopGear.tsx +++ b/src/General/Modules/TopGear/TopGear.tsx @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; import { apiSendTopGearSet } from "../SetupAndMenus/ConnectionUtilities"; import { Button, Grid, Typography, Divider, Snackbar, SnackbarCloseReason } from "@mui/material"; import MuiAlert from "@mui/material/Alert"; -import { buildNewWepCombos } from "../../Engine/ItemUtilities"; +import { buildNewWepCombos, getForcedEmbellishmentCount, MAX_EMBELLISHMENTS } from "../../Engine/ItemUtilities"; import MiniItemCard from "./MiniItemCard"; import { useHistory } from "react-router-dom"; import HelpText from "../SetupAndMenus/HelpText"; @@ -26,7 +26,8 @@ import { TopGearResult } from "General/Modules/TopGear/Engine/TopGearResult"; import TopGearReforgePanel from "./TopGearReforgePanel"; import { getSetting } from "Retail/Engine/EffectFormulas/EffectUtilities"; import { prepareTopGear } from "./Engine/TopGearEngineClassic"; -import { buildDifferential, generateReportCode, createTopGearWorker } from "./Engine/TopGearEngineShared"; +import { buildDifferential, generateReportCode } from "./Engine/TopGearEngineShared"; +import { createTopGearWorker } from "./Engine/TopGearWorkerFactory"; import { trackPageView } from "Analytics"; import { getVersion } from "../ChangeLog/Log"; @@ -36,12 +37,17 @@ type ShortReport = { effectList: any[]; // TODO: Replace with proper Effect array. differentials: any[]; // TODO: Replace with Differentials. contentType: string; // TODO: Replace with contentTypes + embellishedSelected?: number; // Drives the "only two embellishments can be worn" note in the report. + equippedHPS?: number; // Throughput of the player's current gear, for the upgrade percentage. + optionComparisons?: any; // Per-option gem / enchant / Folio pricing for the Fine Tuning tab. + optimalConfig?: any; // Winning configuration when joint optimisation ran. itemSet: { itemList: any[]; // TODO: Replace with Item setStats: any; // TODO: Replace with nice stat object. primGems: string[]; - enchantBreakdown: any; // TODO: Replace with some form of enchant object. + enchantBreakdown: any; // TODO: Replace with some form of enchant object. firstSocket: string; + setHPS?: number; // Absolute throughput, when the spec was evaluated through a cast model. }; player: { name: string; @@ -383,9 +389,17 @@ export default function TopGear(props: any) { return errorMessage.slice(0, -2); } - else { - return ""; + + /* ------------------------------------ Embellishment cap ------------------------------------- */ + // Only two embellishments can be worn at once. If three or more slots have nothing but embellished items + // selected then no wearable set exists, Top Gear discards every set it builds, and the player gets an empty + // report with no explanation. Say so next to the Go button instead. + const forcedEmbellishments = getForcedEmbellishmentCount(props.player.getSelectedItems()); + if (forcedEmbellishments > MAX_EMBELLISHMENTS) { + return "Too many embellishments (" + forcedEmbellishments + "/" + MAX_EMBELLISHMENTS + "). Add a non-embellished option to one of those slots."; } + + return ""; } useEffect(() => { @@ -428,7 +442,11 @@ export default function TopGear(props: any) { differentials: report.differentials, new: false, contentType: report.contentType, - effectList: report.itemSet.effectList, + embellishedSelected: report.embellishedSelected, + equippedHPS: report.equippedHPS, + optionComparisons: report.optionComparisons, + optimalConfig: report.optimalConfig, + effectList: report.itemSet.effectList, itemSet: {itemList: [], @@ -442,6 +460,7 @@ export default function TopGear(props: any) { folioGems: report.itemSet.folioGems || [], firstSocket: report.itemSet.firstSocket, hardScore: report.itemSet.hardScore, + setHPS: report.itemSet.setHPS, statBreakdown: report.itemSet.statBreakdown, }, player: {name: player.charName, realm: player.realm, race: player.race || "", region: player.region, spec: player.spec, model: player.getActiveModel(report.contentType).modelName}, diff --git a/src/Redux/Reducers/RootReducer.ts b/src/Redux/Reducers/RootReducer.ts index 71722386cf..a946040610 100644 --- a/src/Redux/Reducers/RootReducer.ts +++ b/src/Redux/Reducers/RootReducer.ts @@ -53,10 +53,33 @@ const initialState : RootState = { //gemSettings: {value: "Simple", options: ["Simple", /*"Precise (Beta)"*/], category: "topGear", type: "selector", gameType: "Retail"}, // TODO: Add a "Keep current". //runeChoice: {value: "Automatic", options: ["Automatic", "Haste", "Crit", "Mastery"], category: "topGear", type: "selector", gameType: "Retail"}, flaskChoice: {value: "Automatic", options: ["Automatic", "Crit", "Mastery", "Versatility", "Haste"], category: "topGear", type: "selector", gameType: "Retail"}, + + // Consumables. Only options with real modelled values are offered - see the consumables block in TopGearEngine. + foodBuff: {value: "Intellect Food", options: ["Intellect Food", "None"], category: "consumables", type: "selector", gameType: "Retail"}, + weaponOil: {value: true, options: [true, false], category: "consumables", type: "selector", gameType: "Retail"}, + vantusRune: {value: true, options: [true, false], category: "consumables", type: "selector", gameType: "Retail"}, + + // Enchants. Automatic keeps the engine's own pick, which is what it did before these were configurable. + ringEnchant: {value: "Automatic", options: ["Automatic", "Haste", "Crit", "Mastery", "Versatility"], category: "enchants", type: "selector", gameType: "Retail"}, + weaponEnchant: {value: "Automatic", options: ["Automatic", "Intellect", "Haste", "Mastery"], category: "enchants", type: "selector", gameType: "Retail"}, + + // Searches gem / enchant / flask / Folio combinations jointly across the leading gear sets. Off by default + // because it changes which set wins, and existing users shouldn't have their results shift without asking. + optimizeGemsEnchants: {value: false, options: [true, false], category: "topGear", type: "selector", gameType: "Retail"}, + + // Gems. Meta and stat gems are chosen independently. + metaGem: {value: "Automatic", options: ["Automatic", "Indecipherable (Intellect)", "Telluric (Mana)"], category: "gems", type: "selector", gameType: "Retail"}, + gemCombo: {value: "Automatic", options: ["Automatic", "Haste / Crit", "Haste / Mastery", "Haste / Vers", "Crit / Haste", "Crit / Mastery", "Crit / Vers", "Mastery / Haste", "Mastery / Crit", "Mastery / Vers", "Vers / Haste", "Vers / Crit", "Vers / Mastery"], category: "gems", type: "selector", gameType: "Retail"}, + + // Omnium Folio. Slots 2 and 3 have a single option each so they aren't configurable. + folioSlot1: {value: "Automatic", options: ["Automatic", "Unleashed Fire", "Void-Touched"], category: "omniumFolio", type: "selector", gameType: "Retail"}, + folioSlot4: {value: "Automatic", options: ["Automatic", "Haste", "Crit", "Mastery", "Vers"], category: "omniumFolio", type: "selector", gameType: "Retail"}, + folioSlot5: {value: "Automatic", options: ["Automatic", "Overload", "Residual Energy", "Echoes"], category: "omniumFolio", type: "selector", gameType: "Retail"}, liningUptime: { value: 60, options: [], category: "embellishments", type: "Entry", gameType: "Retail" }, // Spec values: masteryEffectivenessShaman: { value: 20, options: [], category: "specSpecific", type: "Entry", gameType: "Retail", spec: "Restoration Shaman" }, + masteryEffectivenessEvoker: { value: 90, options: [], category: "specSpecific", type: "Entry", gameType: "Retail", spec: "Preservation Evoker" }, fightLengthShaman: { value: "Long", options: ["Long", "Short"], category: "specSpecific", type: "selector", gameType: "Retail", spec: "Restoration Shaman" }, innervateCountShaman: { value: 0, options: [], category: "specSpecific", type: "Entry", gameType: "Retail", spec: "Restoration Shaman" }, diff --git a/src/Retail/Engine/EffectFormulas/Generic/Embellishments/EmbellishmentData.ts b/src/Retail/Engine/EffectFormulas/Generic/Embellishments/EmbellishmentData.ts index bb8a0db3e9..57e5c0821c 100644 --- a/src/Retail/Engine/EffectFormulas/Generic/Embellishments/EmbellishmentData.ts +++ b/src/Retail/Engine/EffectFormulas/Generic/Embellishments/EmbellishmentData.ts @@ -350,7 +350,9 @@ export const embellishmentData = [ runFunc: function(data: Array, player: Player, itemLevel: number, additionalData: any) { let bonus_stats: Stats = {}; - const enemyType = (getSetting(additionalData.settings, "darkmoonHuntStat") ?? "mastery").toLowerCase(); + // getSetting returns 0 rather than undefined when a setting is missing, which ?? does not catch. + const huntStat = getSetting(additionalData.settings, "darkmoonHuntStat"); + const enemyType = (typeof huntStat === "string" && huntStat ? huntStat : "mastery").toLowerCase(); bonus_stats[enemyType] = runGenericPPMTrinket({...data[0], stat: enemyType}, itemLevel, additionalData.setStats); diff --git a/src/Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData.ts b/src/Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData.ts index c89bb5fd8a..e933948e90 100644 --- a/src/Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData.ts +++ b/src/Retail/Engine/EffectFormulas/Generic/PatchEffectItems/OmniumFolioData.ts @@ -41,6 +41,57 @@ export const getFolioIcon = (id: number) => { else console.error("Gem Icon not found"); } +// The Folio has five rune slots. Slots 2 and 3 currently have a single option each, so only 1, 4 and 5 are +// configurable. Each setting accepts "Automatic" (keep the engine's own pick) or a rune's shortName. +export const FOLIO_SLOT_SETTINGS: { [slot: number]: string } = { 1: "folioSlot1", 4: "folioSlot4", 5: "folioSlot5" }; + +// The rune the engine falls back to when a slot is left on Automatic and there is no stat-weight rule for it. +const FOLIO_AUTO_DEFAULTS: { [slot: number]: number } = { 1: 1279599, 2: 1279603, 3: 1287555, 5: 1279614 }; + +// Slot 4 is the pure secondary stat slot, so Automatic follows the player's best stat. +const FOLIO_STAT_RUNES: { [stat: string]: number } = { + haste: 1287774, + crit: 1279609, + mastery: 1287771, + versatility: 1279613, +}; + +export const getFolioOptions = (slot: number): string[] => { + return omniumFolioData.filter((gem) => gem.slot === slot).map((gem) => gem.shortName); +}; + +/** + * Resolves the player's Folio settings into the five rune IDs to equip. + * Anything left on "Automatic" keeps the behaviour the engine had before the setting existed, so an untouched + * settings object produces exactly the same set of runes it always did. + * @param settings The player settings object. + * @param bestStat The player's highest weighted secondary, used for the Automatic slot 4 pick. + */ +export const getFolioGems = (settings: any, bestStat: string): number[] => { + const chosen: number[] = []; + + [1, 2, 3, 4, 5].forEach((slot) => { + const settingKey = FOLIO_SLOT_SETTINGS[slot]; + const raw = settingKey && settings && settingKey in settings ? settings[settingKey].value : "Automatic"; + const choice = typeof raw === "string" ? raw : "Automatic"; + + if (choice !== "Automatic") { + const match = omniumFolioData.find((gem) => gem.slot === slot && gem.shortName === choice); + if (match) { + chosen.push(match.id); + return; + } + // An unrecognised choice (renamed rune, stale local storage) falls through to Automatic rather than + // dropping the slot entirely, which would silently cost the player a rune. + } + + if (slot === 4) chosen.push(FOLIO_STAT_RUNES[bestStat] || FOLIO_STAT_RUNES.haste); + else chosen.push(FOLIO_AUTO_DEFAULTS[slot]); + }); + + return chosen; +}; + export const getShortName = (id: number) => { const gem = omniumFolioData.filter(gem => gem.id == id)[0]; if (gem) return gem.shortName; diff --git a/src/locale/en/translate.json b/src/locale/en/translate.json index 42883f2bfb..751b921ae5 100644 --- a/src/locale/en/translate.json +++ b/src/locale/en/translate.json @@ -29,7 +29,6 @@ "5": "5", "6": "6", "7": "7", - "Leech": "Leech", "Burning Crusade": "Burning Crusade", "Cancel": "Cancel", @@ -219,7 +218,6 @@ "Gold": "Gold", "Sapphire": "Sapphire", "RollsRoyce": "Rolls Royce" - }, "QeHeader": { "InsertLogLabel": "Insert Log", @@ -286,15 +284,12 @@ "embellishments": "Embellishments", "enchants": "Enchants", "specSpecific": "Spec Specific", - "enchantItems": { "title": "Enchant Items", - "tooltip": "" + "tooltip": "Whether Top Gear should assume your gear is enchanted." }, - "Setting5Title": "Playstyle", "Setting5Tooltip": "Choose your playstyle", - "includeGroupBenefits": { "title": "Ally Buffs", "tooltip": "Estimate value from stats you give to your group." @@ -310,7 +305,7 @@ "idolGems": { "title": "JC Idol Trinkets", "tooltip": "Select how many associated gems you have in your gear." - }, + }, "alchStonePotions": { "title": "Alch Stone", "tooltip": "Number of extra potions Alch Stone gives you over a fight." @@ -363,15 +358,14 @@ "title": "Mastery Effectiveness", "tooltip": "Expected Mastery Effectiveness. Can be pulled from a specific log on wowanalyzer.com. Top Gear only." }, - "fightLengthShaman" : { + "fightLengthShaman": { "title": "Fight Length", "tooltip": "Approximate duration of the fight. Most progress fights will be covered under 'Long' while early fights or farm can get closer to 'Short'." }, - "innervateCountShaman" : { + "innervateCountShaman": { "title": "Innervate Count", "tooltip": "Amount of Innervates you will get during the fight. One innervate means a single cast of it, not one druid giving it to you on cooldown." }, - "flaskChoice": { "title": "Flask", "tooltip": "Pick which flask you want QE Live to use." @@ -408,7 +402,6 @@ "title": "Darkmoon Hunt: Stat", "tooltip": "Darkmoon Hunts stat is based on what you are targeting. Targeting allies gives you Mastery so this is the most consistent stat, but it otherwise differs per boss." }, - "dpsFlag": { "title": "DPS Flag", "tooltip": "Include DPS for specs that don't do it naturally as part of their healing rotation. Does not effect Disc Priest, Pres Evoker. May effect which trinkets you can proc." @@ -449,9 +442,6 @@ "title": "Shattered Soul Efficiency", "tooltip": "Set to 100 if you expect to get full Shattered Soul value on a fight. It works on Cosmic, Nature, Holy, Arcane and Shadow damage." }, - - - "manaProfile": { "title": "Mana Profile", "tooltip": "Pick a more or less aggressive mana profile." @@ -472,7 +462,6 @@ "title": "Scoring Metric", "tooltip": "Pick whether to include DPS value in your score or not." }, - "wristEnchant": { "title": "Wrist Enchant", "tooltip": "While intellect is always better, it's also very expensive which can be prohibitive during early gearing." @@ -489,14 +478,13 @@ "title": "Include Enchants", "tooltip": "Whether or not to include enchants." }, - "numManaTides": { "title": "Mana Tide #", "tooltip": "Select the number of Resto Shamans in your raid, not the number of times they'll drop the totem." }, "metaGem": { "title": "Meta Gem", - "tooltip": "More options coming soon!" + "tooltip": "Which meta gem to socket. Automatic uses the default for your spec." }, "professionOne": { "title": "First Profession", @@ -517,11 +505,54 @@ "druidLevelSixtyTalent": { "title": "Druid L60 Talent", "tooltip": "Pick which level 60 talent you'd like to optimize for." + }, + "consumables": "Consumables", + "omniumFolio": "Omnium Folio", + "foodBuff": { + "title": "Food", + "tooltip": "Whether to include a food buff. Only the standard intellect food is modelled right now." + }, + "weaponOil": { + "title": "Weapon Oil", + "tooltip": "Whether to include a weapon oil in the simulation." + }, + "vantusRune": { + "title": "Vantus Rune", + "tooltip": "Whether to include a Vantus Rune. Raid only." + }, + "ringEnchant": { + "title": "Ring Enchant", + "tooltip": "Which stat to enchant your rings with. Automatic uses your highest weighted stat." + }, + "weaponEnchant": { + "title": "Weapon Enchant", + "tooltip": "Which weapon enchant to use. Automatic uses the default for your spec." + }, + "folioSlot1": { + "title": "Folio Slot 1", + "tooltip": "Which rune to socket in the first Omnium Folio slot." + }, + "folioSlot4": { + "title": "Folio Slot 4", + "tooltip": "Which secondary stat rune to socket in the fourth Omnium Folio slot. Automatic uses your highest weighted stat." + }, + "folioSlot5": { + "title": "Folio Slot 5", + "tooltip": "Which rune to socket in the fifth Omnium Folio slot." + }, + "gems": "Gems", + "gemCombo": { + "title": "Gems", + "tooltip": "Which stat combination to socket in your non-meta sockets. The first stat is the major one. Automatic uses the default for your spec." + }, + "optimizeGemsEnchants": { + "title": "Optimise Gems & Enchants", + "tooltip": "Search gem, enchant, flask and Folio combinations together across the leading gear sets. Slower, and it can change which set wins." + }, + "masteryEffectivenessEvoker": { + "title": "Mastery Effectiveness %", + "tooltip": "How effective your mastery is, as a percentage. Preservation mastery scales with how injured your targets are, so raise this for heavy raid damage and lower it for lighter healing." } - - - - }, "SettingsTitle": "Optional Settings" }, @@ -637,4 +668,4 @@ "exclusive": "Dinar " } } -} +} \ No newline at end of file