Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { scoreEvokerSet } from "General/Modules/Player/ClassDefaults/PreservationEvoker/PreservationEvokerProfile";

/*
Mastery effectiveness was hardcoded inside scoreEvokerSet. Preservation mastery scales with how injured the
target is, so its real effectiveness varies by content - Resto Shaman already exposed this as a setting.
The settings panel writes number inputs back as strings, which is what the string test below guards.
*/

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)));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,16 @@ export function scoreEvokerSet(stats: Stats, playerData: any, settings: PlayerSe
const healingBreakdown: Record<string, number> = {};
const castBreakdown: Record<string, number> = {};

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;
Expand Down
54 changes: 54 additions & 0 deletions src/General/Modules/Settings/SettingsCategories.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
14 changes: 12 additions & 2 deletions src/General/Modules/Settings/SettingsComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", "consumables", "upgradeFinder", "specSpecific"],
Classic: ["topGear", "enchants", "specSpecific"],
};

const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
Expand All @@ -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))];
/* ---------------------------------------------------------------------------------------------- */
Expand Down Expand Up @@ -73,6 +80,9 @@ export default function SettingsComponent(props) {
return (
<Grid container spacing={1} direction="row">
{categories.map((category) => {
const categoryKeys = mappedKeys[category] || [];
if (categoryKeys.length === 0) return null;

return (
<Grid item xs={12}>
<Grid container spacing={1}>
Expand All @@ -81,7 +91,7 @@ export default function SettingsComponent(props) {
{t("Settings.Retail." + category)}
</Typography>
</Grid>
{mappedKeys[category].map((key, i) => {
{categoryKeys.map((key, i) => {
return (
<Grid item xs={12} sm={4} md={4} lg={3} xl={"auto"}>
<Tooltip
Expand Down
19 changes: 13 additions & 6 deletions src/General/Modules/TopGear/Engine/TopGearEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,16 +772,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 - add more here as values become available.
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;
Expand Down
6 changes: 6 additions & 0 deletions src/Redux/Reducers/RootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ 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"},
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" },

Expand Down
43 changes: 22 additions & 21 deletions src/locale/en/translate.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"5": "5",
"6": "6",
"7": "7",

"Leech": "Leech",
"Burning Crusade": "Burning Crusade",
"Cancel": "Cancel",
Expand Down Expand Up @@ -219,7 +218,6 @@
"Gold": "Gold",
"Sapphire": "Sapphire",
"RollsRoyce": "Rolls Royce"

},
"QeHeader": {
"InsertLogLabel": "Insert Log",
Expand Down Expand Up @@ -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."
Expand All @@ -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."
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand All @@ -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."
Expand All @@ -489,7 +478,6 @@
"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."
Expand Down Expand Up @@ -517,11 +505,24 @@
"druidLevelSixtyTalent": {
"title": "Druid L60 Talent",
"tooltip": "Pick which level 60 talent you'd like to optimize for."
},
"consumables": "Consumables",
"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."
},
"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"
},
Expand Down Expand Up @@ -637,4 +638,4 @@
"exclusive": "Dinar "
}
}
}
}