Skip to content

Gem, enchant and Omnium Folio selection for Top Gear - #1

Open
DIzosimov wants to merge 73 commits into
devfrom
gem-and-enchant-selection
Open

DIzosimov wants to merge 73 commits into
devfrom
gem-and-enchant-selection

Conversation

@DIzosimov

@DIzosimov DIzosimov commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Gem, enchant, Omnium Folio and consumable selection for Top Gear, a sharded engine that can search all of it, crest spending plans, and an Upgrade Finder that measures against the gear you actually have — plus the SimC import, persistence and report work those sit on.

What changes for a profile that touches nothing

Most of this is opt-in, but not all of it. A reviewer comparing numbers against dev should expect these to move:

  • Ring enchants apply to both rings. One ring's value was being counted for the pair, so absolute HPS was under-counted. Every set rises by the same amount, so rankings barely move.
  • Gem split corrected to 16 / 7 (was 16 / 9).
  • The equipped baseline wears your own gems, and is measured across the same consumables the run searches. Previously a run that changed no gear could still report an upgrade — the gain from re-gemming, or from a potion only the candidates had.
  • Upgrade Finder keeps your own gems, enchants and Folio runes, and runs without a flask. See below.
  • Retail runs are sharded across workers. Results are identical to a single-threaded run (tested); only the speed changes.

Top Gear

Gear options panel

A Detailed toggle beneath the item bar opens per-slot selection. Off, the engine ignores these settings entirely rather than just hiding them. Several picks in a slot expand the run into one variant per combination, ranked alongside every other set.

  • Gems — multi-select plus a separate meta. Includes the four single-stat gems at 17 (Quick Peridot, Masterful Amethyst, Deadly Garnet, Versatile Lapis), which win outright when one stat is far enough ahead.
  • Enchants — per slot, with each ring its own slot, so 29 crit on one and 29 haste on the other can beat 58 of either. Adds Rite of the Hash'ey and Worldsoul Tenacity, and a BEST_SECONDARY marker so an enchant granting "your best secondary" resolves per spec rather than as a fixed stat.
  • Omnium Folio — the secondary stat rune.
  • Consumables — flask, food and potion are search axes like the rest. Adds Amani Cornucopia (71.5 of the best secondary), Light's Potential and Potion of Recklessness (counted at a tenth of the fight, since both are 30 seconds on a five minute cooldown), and a None flask.

Keep my gems, enchants and runes

A top-level switch on the settings header. On, slots that already have a gem, enchant or rune keep it and the engine only decides the empty ones. Those slots also stop being searched, since every combination would score the same.

Optimize Everything

Also on the header. The engine searches every current gem, enchant, rune and consumable itself instead of you pinning each one, always at full depth. It supersedes the pins rather than overwriting them.

Search depth

The old hardcoded cap (12 gem loadouts in 24 variants) kept the first N combinations in build order rather than the best N. Depth is now a setting defaulting to No limit. An untouched profile expands nothing, so this only applies once something is multi-selected. The panel projects the real variant count before you run.

Item tools

  • Catalyze any item in a tier slot, whatever it dropped from.
  • Copy an item with a different stat pair or embellishment from its cogwheel, so both compete.
  • Put a hand-added item on an upgrade track — including Hero Crafted and Myth Crafted for crafted items, read from the item database.

Performance and scale

Large searches used to die mid-run with the progress bar frozen. Both memory limits are gone, and the run scales across cores.

  • Streaming top 3000 (TopSets). Results were all retained and sorted at the end, ~2.5KB each. Live heap growth over 355,806 evaluations: 876MB → 1MB, same winning set and differentials.
  • Streaming set construction (forEachGearSet). Sets were all built before any was scored, ~790B each. Over 3,515,625 sets: 2,633MB → 18MB.
  • Sharding. Sets are dealt round-robin to workers (neighbouring sets are near-identical, so blocks would give one worker all the good ones). Each shard keeps its own top 3000, and the merge is exact — tested against an unsharded run at two, three and four ways. Each worker builds only the sets it owns.
  • Worker count ≈ √(evaluations ÷ startup), from a measured ~10,000 evaluations/s and ~0.6s startup per worker. The saving from the Nth worker shrinks as W/N(N−1) while its startup cost doesn't, so that's where adding one stops paying.
  • Progress shows the stage, an exact evaluation total from the first report, and time left. The bar is its own component updated through a ref, so reports repaint it and nothing else.

Report

  • What changed is outlined in gold — items you aren't wearing, gems you don't have (compared across the whole set, since gem position isn't modelled), enchants that differ from the ones you wear, Folio slots that moved, and embellishments that need crafting.
  • Close alternatives say what kind of decision they are — item, gem (with the socket it would go in), enchant, rune, flask, food or potion — are filtered below 0.01% whatever they change, and are de-duplicated. The percentage was being rounded before division, which collapsed everything to 0.00%.
  • Landing on a report with no code shows an error rather than loading forever.

SimC import and persistence

  • Enchants — every modelled enchant carries its enchantment id, so what a character wears is recognised. Five weapon enchants a healer wouldn't use are named for recognition only and never offered.
  • Omnium Folio — read from omnium_talents. The four stat runes were mapped from one export per rune; the entry ids are a separate numbering from the rune spell ids.
  • Crests — read from upgrade_currencies. Tier ids (3442–3446, Adventurer → Myth) were confirmed against a character's exact in-game totals.
  • Kept between visits — a character's items, Folio runes and crests were saved but never read back, so gear emptied on every load. All three survive a reload now, and every item change saves through one path.

Crest spending

Its own toggle and settings section, off by default.

  • Budget — the five crest amounts are editable settings seeded from the import, so you can plan against crests you don't have yet. The report says what it planned with.
  • Pricing — 20 crests per rank, derived from the app's item level ladder and caps. Crafted items are one payment of 80 to the ceiling: 318 for Hero crests, 331 for Myth.
  • Planning — greedy by healing per crest, each purchase valued through the real set evaluation with earlier purchases applied. Every reachable level of a piece competes on what the whole climb costs, so a piece that needs three ranks to beat the one in its slot can still be bought into. Pieces that lost their slot are considered too.
  • Report — each purchase in order with its cost and gain, grouped by piece underneath when any piece takes more than one rank.

Upgrade Finder

  • Measures against your own gear — your gems, enchants and Folio runes, with the gem and enchant expansion pinned off. It was scoring candidates against a re-gemmed ideal, and with Optimize Everything uncapped it would have run millions of evaluations per candidate on the page thread.
  • Runs without a flask, whatever Top Gear is set to.
  • Compare everything fully upgraded (6/6) — raises equipped gear to the top of its track and offers candidates only at 6/6, so the percentage isn't partly measuring unspent crests. The uploaded report now declares the gear at the levels it was scored at; before, a 6/6 run declared the gear at its real level.
  • Run under WoWAudit conditions — pins the fight to five minutes, forces the 6/6 comparison, and stamps the report with the conditions it ran under, separating the ones enforced from the ones that hold because the engine can't do otherwise.

WoWAudit has accepted reports generated with the no-flask and 6/6 changes. Which of those it actually checks is not known.

Also included

Earlier commits on the branch: crafted item and embellishment import fixes, every setting made reachable in the panel, Evoker mastery effectiveness, and absolute HPS reporting for specs that model it.

Testing

CI=true npx react-scripts test --watchAll=false65 suites / 656 tests passing, up from 309 on dev when this was opened. Production build clean.

New tests were mutation-checked: the code each one guards was broken and the test confirmed to fail, and the ones that didn't fail were fixed.

Worth knowing for anyone working here: react-scripts build does not type-check. An undefined component compiles cleanly and only fails in the browser, which is how a deleted component shipped mid-branch. The pinned TypeScript 3.9 can't parse the repo's own sources, so tsc can't catch it either. UI changes here are covered by tests that render the component.

Known gaps

  • Crafted items from a SimC import get no upgrade track. The importer keys on bonus ids 12052 / 12053, which only appear in pre-squish test data. Hand-added crafted items can be put on a track; imported ones can't until the current bonus id is known.
  • Third meta gem (Powerful Eversong Diamond) isn't in GemDB — only Telluric and Indecipherable are.
  • Folio slot 5 runes are unmodelled and not offered.
  • Worker threading is tested through structured clone and the worker's message contract, not with real threads or timing.
  • Dungeon reports can't have their fight length pinnedPlayer.getFightLength hardcodes 200s for dungeons.
  • The 6/6 comparison drops drop-level candidates from the report entirely.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J

DIzosimov and others added 30 commits August 28, 2026 20:15
Embellishments were driven by three hardcoded lists that had drifted apart from each other and from
EmbellishmentDB, so items were silently dropped rather than scored:

- getItemEffectOptions offered seven embellishments and excluded Finger/Neck outright, so jewellery embellishments
  could never be attached. Now derived from EmbellishmentDB via new applicable / slots metadata.
- The SimC importer matched incoming spell names against an allowlist of eight names and discarded everything else,
  so an imported item lost its embellishment and scored as a plain stat stick with no error. Now resolves against
  the same DB. The two entry points also disagreed with each other: Sunfire Silk Lining imported but could not be
  added manually, and Adorned Fang was the reverse.
- Crafted items that carry an embellishment inherently (Axe-Flingin' Bands, the World Tender's set) have no effect
  block in ItemDB and simmed as plain gear. Item now attaches those from EmbellishmentDB.setItems so a gap in
  ItemDB can't silently drop them again.
- Multi-piece embellishment sets are granted once, only when enough pieces are worn, and consume embellishment
  slots correctly.
- Embellishments with no formula are flagged unmodelled and withheld from the dropdown rather than silently
  scoring zero. A test fails if a formula lands and the flag isn't removed.
- The crafted stat picker is hidden on items with no assignable budget, where choosing stats did nothing.

Weapons: buildNewWepCombos only emitted a one hander when it could pair it with an offhand. With no offhand the
weapon was invisible to Top Gear, and if it was the player's only weapon there were zero combos, zero sets and an
empty report. Unpaired one handers are now offered alone, with report advice noting the offhand is scored as empty.

Embellishment cap: exceeding two embellishments makes every set fail verifySet. That's correct but was silent, so
the item was dropped from all results or the whole report came back empty. The selection screen now warns when the
cap makes a set impossible, the report explains when a selected embellished item couldn't be used, and the engine
reports the error rather than returning a bare null.

Also fixes a latent crash where getSetting returns 0 for a missing setting and the result was used as a string
(darkmoonHuntStat, and flaskChoice which would take out an entire Top Gear run on stale settings), and moves
createTopGearWorker into its own module so TopGearEngineShared can be imported from tests without tripping over
import.meta.

44 suites / 233 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
…er mastery effectiveness

Settings are grouped by a category field, but the panel rendered a hardcoded list of categories. A setting whose
category was missing from that list was stored in Redux, fully wired to the engine, and never drawn, with no error
anywhere. The list now lives in one exported place and SettingsCategories.test.js fails if any category with
settings is missing from it, if a listed category is empty, or if a Retail setting has no title and tooltip. That
last check caught a pre-existing empty tooltip on enchantItems, which is now filled. Categories with nothing in
them for the current game type no longer draw a bare heading, and one with no settings at all can't crash the
panel.

Food, weapon oil and Vantus Rune were applied unconditionally with no way to turn them off. They're toggles now.
Only options with real modelled values are offered - there is no food data beyond a single hardcoded entry, so
that dropdown is deliberately limited rather than invented.

Mastery effectiveness for Preservation Evoker was hardcoded at 0.9 inside scoreEvokerSet, overwriting whatever was
passed in. Preservation mastery scales with how injured the target is so its real effectiveness varies a lot by
content, and Resto Shaman already exposed this as a setting. Absent settings keep 0.9 so existing results are
unchanged. The value is read with Number() rather than a typeof check because the settings panel writes number
inputs back as strings - a strict typeof === "number" check worked on the reducer's initial value and then
silently fell back to the default the moment the player edited the box, which looked exactly like the setting
doing nothing.

42 suites / 200 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
hardScore is an intellect-equivalent ranking number, so the report could only ever show relative differences - and
the competitive alternatives list was printing a hardScore delta labelled "HPS", which it isn't.

Sets evaluated through a cast model or a ramp sim now carry setHPS, a genuine healing figure. It's shown on the
stat panel with an upgrade percentage over currently equipped gear, and per alternative alongside the healing and
percentage given up. The percentage is derived from the HPS figures themselves so the two can never disagree.

The stat weight path deliberately reports nothing rather than a fabricated number. baseHPS there is a hardcoded
placeholder, and that path pre-applies raid buffs which the cast models also apply internally, so anything derived
from it would be wrong. Those specs keep the existing relative display.

The equipped set is evaluated through the same evalSet as the candidates so the two figures are comparable, and
deliberately outside the ranking loop so it can never influence which set wins. It's wrapped so that a malformed
equipped set costs the upgrade percentage rather than the whole run. A dead-on 0% - which happens when the player
runs Top Gear without adding any candidate items, so the best set is simply their current gear - now says so
instead of reading like the comparison failed.

41 suites / 204 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Combines the three PRs (Voulk#1788, Voulk#1789, Voulk#1790) for anyone who wants to run or review all of it together. Each
remains independently mergeable on its own branch; this is the union, not a replacement.

Conflicts were all adjacent additions to the same structures - embellishedSelected next to equippedHPS on the
result object and in the saved report - so both sides were kept. Merging also removed two things that only
existed to keep the HPS work standalone: the stub for TopGearEngineShared is unnecessary once the worker factory
split is present, so the real module is imported again and the shared buildDifferential is unit tested directly,
and the embellishedSelected assertion is restored now that both halves are in the same tree.

47 suites / 261 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Puts these choices where item selection already lives rather than in the global settings panel, since they're
choices about the set being built. Each Top Gear run uses exactly one configuration.

Enchants were previously inlined in TopGearEngine.enchantItems, one hardcoded per slot with its stat values
written into the branch. They're extracted into EnchantDB, which is now the single source of truth: the engine
applies from it and the selection UI offers from it, so the two can't drift apart. Nothing was invented - the
table holds exactly the enchants and values the engine already applied, which is why Eyes of the Eagle appears
(it was already modelled for Holy Priest and Resto Shaman, just not selectable). EnchantDB.test.js pins every
value against what the engine applied before extraction.

Gems can be picked for the meta socket and the stat sockets independently, from the current expansion's gems.

The replace / fill-empty switch is genuinely new behaviour rather than a toggle over the old one. Top Gear has
always ignored the player's socketed gems and assigned its own to every socket, which quietly assumes they will
re-gem the whole set. Unticked, gems already socketed are kept and only empty sockets are filled, so the result is
what the player would get without spending anything. Equipped gems come from the SimC import, which stores them on
the item as a colon separated gemString; an unrecognised gem is treated as an empty socket.

Folio slots 1, 4 and 5 are selectable. Slots 2 and 3 have a single rune each so they aren't offered.

Everything defaults to Automatic, which resolves to the pick the engine made before any of this existed, so an
untouched profile produces identical results.

These settings are marked type "hidden" because they're owned by this section rather than the auto-rendered
settings panel. SettingsCategories.test.js was scoped to panel-rendered settings so it still guards the panel
without forcing these into it.

49 suites / 288 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
…l gems

The current tier's stat gems were recorded as 12 major / 5 minor. They're 16/9, so every gem in the app was
undervalued and the split between major and minor was wrong. The ratio is now published as GEM_MAJOR_STAT and
GEM_MINOR_STAT constants so the engine's gem lookup reads from one place and a future tier is a single change.
Two duplicated rows (Flawless Versatile Amethyst and Flawless Masterful Peridot) are removed while in there.

This changes results for everyone, since gems are worth more than the app previously thought.

Gem selection is now multi-select. Picking several expands each gear set into one candidate per gem loadout,
which are then ranked together - the same way selecting two rings gives you two candidate sets to compare, rather
than the engine searching for a winner behind the player's back. Picking one leaves it at a single evaluation per
set, exactly as before.

Sockets are interchangeable for stat purposes, so only the multiset matters: two of gem A and one of gem B is the
same stat line whichever socket holds which. Loadouts are therefore combinations rather than permutations, which
keeps the expansion small, and it's capped at 12 regardless - every loadout multiplies the sets evaluated, and the
whole gem spread is worth a fraction of a percent.

49 suites / 300 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
…together

Enchants can now be picked several at a time per slot, the same as gems. Selecting more than one expands each gear
set into a candidate per combination, and those are ranked together - so choosing two weapon enchants gives you
two sets to compare rather than the engine deciding for you.

Gems and enchants expand into one combined list of variants, each a complete wearable configuration. The
expansion is capped at 24 variants: every variant re-evaluates every gear set, so an uncapped cross product of a
few multi-selects would make a run take minutes. The UI says so where the choices are made.

Slot cards get DEFAULT (clear the selection and let the engine pick) and NONE, matching how these are usually
presented.

49 suites / 309 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Top Gear now shows only a Detailed switch by default, and the extra
selection panels appear when it's turned on. The toggle also gates the
engine: while it's off every one of these settings reads as its
Automatic default, so a profile that was configured once and then
switched back to the simple view can't keep steering later runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The expansion was capped at 12 gem loadouts inside 24 total variants. That
keeps a run fast but truncates the search, so the best combination can be one
the run never evaluates. gearVariantLimit turns the cap into a setting, and a
limit of 0 removes it entirely for full combinatorics.

The default is unchanged: leaving the setting alone reproduces the old 12/24
behaviour exactly. Raising it past the default raises the gem cap too.

Exhaustive searches are easy to make unrunnable by accident, so the panel
projects the real variant count from the current selection - the same
arithmetic the engine does, via countGemLoadouts/countEnchantCombinations -
and says how many will actually run before the player commits to one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Folio slots took a single rune, so finding the best combination meant running
Top Gear once per rune and comparing by hand. They now hold a list, and
selecting several expands the run into one variant per combination, ranked
alongside every other set - the same treatment gems and enchants already got.

buildSetVariants gains a third axis, so gems, enchants and runes all multiply
together under one search-depth limit, and the panel's projection counts all
three.

The settings held a bare shortName before this and saved profiles still do, so
getFolioChoices accepts either form. A slot with several runes pinned and no
variant driving it resolves to Automatic rather than quietly taking the first
pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Gems, enchants and Folio runes each had their own copy of the same cartesian
product over per-slot choices, plus its own counting function. They now share
buildChoiceCombinations / countChoiceCombinations in ItemUtilities, which both
the engine and the Folio data already import from.

The two enchant resolvers duplicated the same lookup block; getSlotEnchant now
calls getPinnedEnchant. Renamed from getChosenEnchantId, which returned an
enchant rather than an id.

The selector had three near-identical dropdowns - two helpers plus an inlined
one for gems - collapsed into a single select() with a `multiple` flag. Gem
lists are built once at module scope instead of filtered and de-duped on every
render.

Trimmed comments that restated the code and dropped the unused
DETAILED_GEAR_OPTIONS export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
…unes

A true/false switch in the Top Gear settings. On, the engine searches every
current stat gem, every enchant in every slot and every Folio rune alongside
its normal gear search, instead of the player pinning each one by hand.

It supersedes the pins rather than writing over them, so switching it back off
returns whatever was chosen before, untouched. The gear panel opens for it even
with the Detailed toggle off - the search depth it needs would otherwise be
unreachable - and says the pins are being ignored while it runs.

The engine's own automatic pick is entered as a candidate whenever this is on.
A capped search keeps the first N combinations in build order rather than the
best N, so without that a run could rank below the plain run it was meant to
improve on; a test pins that at several limits.

Gem list filtering moved to GemDB as getCurrentStatGems/getCurrentMetaGems,
which the panel and the search space now share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A run could take anywhere from a second to several minutes depending on how
many items are selected and how wide the gem, enchant and rune search is, and
the only feedback was the button going grey.

runTopGear now takes an optional progress callback and reports about a hundred
times over a run - enough to fill a bar smoothly without the postMessage
traffic becoming the cost itself. The worker forwards those to the page, which
shows the stage, the evaluation count and an estimate of the time left.

The count is evaluations rather than sets, since variants score each set once
per configuration and that multiplier is what makes a run long in the first
place. The estimate waits for a second of real work so the rate isn't measuring
startup.

Runs without a callback are unchanged, which is every caller except the worker.
Classic still has no progress; it splits across several workers and would need
its own aggregation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Slots 1 and 5 were selectable but not worth choosing between: slot 5's runes
are all unmodelled (their runFunc returns no stats, so every pick scored the
same) and slot 1's are procs the engine already picks well. Offering them
multiplied the search by six for nothing.

Only slot 4, the secondary stat slot, is selectable now; the rest resolve to
the engine's automatic pick as they always did. The dropdown is labelled by
what it is rather than by slot number.

FOLIO_SLOT_SETTINGS is the single place that decides this, so the search space,
the combination expansion and the panel all followed from the one change. The
folioSlot1 and folioSlot5 settings are gone rather than left dead in the
profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
It was a dropdown inside the collapsed settings accordion, which is a poor home
for something that changes what an entire run does - easy to miss, and easier
still to leave on without noticing.

It's now a switch on the settings header itself, visible without expanding
anything. The setting is marked hidden so the panel no longer draws it too.

Gated behind an optimizeToggle prop threaded from Top Gear, since the character
panel that hosts these settings is shared with Quick Compare, Trinket Analysis
and several other pages where the switch would do nothing.

Its clicks stop propagating so hitting the switch doesn't also expand the panel
underneath it. Hidden settings are exempt from the labelling test, so there's a
new check that this one keeps its title and tooltip strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The minor stat was set to 9. It's 7.

The twelve gem rows repeated the numbers as literals while GEM_MAJOR_STAT and
GEM_MINOR_STAT sat above them unused by the data, which is how the two came to
disagree. The rows now reference the constants, so a tier update is one edit.

That drift is worth guarding: the engine's gem lookup searches by those exact
values, so a mismatch finds nothing and silently sockets a placeholder gem
rather than failing. Three tests now pin the split, check every current gem
grants it, and check a run never sockets the fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
canBeCatalyzed also required the SimC import to have tagged the item with the
current season's item_conversion field. Anything added by hand, imported
without that field, or carried over from an earlier season had no catalyst
option at all - the menu entry simply never appeared. Slot is the only thing
that decides eligibility now.

Still excluded: pieces that are already tier, and pieces already through the
catalyst. Those aren't about origin.

The conversion keeps the piece you had - stats, item level, socket, tertiary
and embellishment all carry over, and only the item's identity changes. The
copy Player.catalyzeItem adds now takes a copy of the stats rather than sharing
the object, since addStats mutates in place and reached back into the original.

convertToTier returns false instead of half-converting when a spec has no tier
piece for the slot, and catalyzeItem reports rather than silently doing
nothing. A test checks every spec has a piece in all five slots, since a gap
there means the player ticks catalyze and sees no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Two problems, both from the variant work.

Progress updates were throttled by count - about a hundred per run - which is
right over a minute and wrong over a second. Each update re-renders the Top
Gear page and every item card on it, so a run with few sets paid a hundred full
re-renders in the time it took to do a few dozen evaluations, and the fewer
sets there were the worse that ratio got. Updates are now limited to ten a
second, with the last always taken so the bar can't freeze short of the end.

Close Alternatives rendered as a bare score with nothing beside it. Three
causes:

- The differential only compared items and gems. Sets expand into one candidate
  per gem, enchant and rune combination, so most alternatives now differ by an
  enchant or a Folio rune, which nothing looked at. Both are reported and drawn
  now, named rather than iconned.
- The gem comparison tested membership, so "three A one B" against "two A two
  B" read as no difference. It counts occurrences now.
- Gem loadouts were built one gem too long. Socket 0 is the meta and is chosen
  separately, so a loadout only ever fills sockets - 1; the extra entry was
  never read and every loadout had siblings differing only in it. Those
  duplicates scored identically, wasted evaluations, and surfaced as alternative
  rows with nothing to show. On the test gear this halved the real search space
  from 288 variants to 144.

A set that still comes out wearing exactly what the best set wears is skipped
rather than shown, and the loop keeps scanning for a real alternative instead of
spending one of the twelve slots on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Rings were one choice applied to the pair, and applied once. Two things follow
from that being wrong.

The 29 in the ring enchant rows is one ring's worth - the stale "64 x 2" comment
this carried over from said so, just with a figure from an older tier. A set
wears two rings, so a set running the same enchant on both now gets 58 rather
than 29. That lifts every set by the same amount, so rankings barely move, but
the absolute HPS figures were under-counting.

Rings are now two independent slots, Finger1 and Finger2, both drawing from the
one Finger enchant list since the game has no left-ring enchant. They expand as
two axes, so offering four enchants on both is sixteen combinations rather than
four - which is the point, because secondaries diminish and 29 crit plus 29
haste can beat 58 of either. Under Optimize Everything this multiplies the
search accordingly.

Profiles saved before the split hold a single Finger key; normaliseEnchantChoices
spreads it across both rings, and an explicit per-ring choice wins over it.

The report cards share a slot name between the two rings, so each is told which
entry is its own, falling back to the shared key for reports saved earlier.
Close Alternatives names the ring that changed rather than "Rings".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Both become search axes on the same terms as gems, enchants and Folio runes:
pin several in the gear panel and each combination is ranked as its own set.

They differ from the others in what an empty selection falls back to. Gems and
runes fall back to the engine's own pick; these fall back to the single
dropdown in the settings panel, which is what an untouched profile uses and how
the simple path has always worked. Nothing changes until something is pinned.

buildSetVariants now takes its axes by name rather than by position. This is
the fourth one, and adding it positionally would have meant every caller
passing empty arrays through for the axes it doesn't care about.

Only Intellect Food is modelled, so food is a yes/no choice for now - the panel
says as much. The flask list is the four stat flasks; they all grant the same
amount so only the stat differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
71.5 of the player's highest weighted secondary, against the plain intellect
food's 50 intellect. That makes food a real choice for the first time, so it's
worth searching rather than a yes/no.

Food is now a table rather than an if: a row in FOOD_BUFFS plus a name in
CONSUMABLE_OPTIONS is all another one needs. A stat of "bestSecondary" is
resolved per spec at evaluation time, since which secondary that is depends on
the player's weights - the same figure the automatic flask already followed,
now hoisted out of that branch so both use it.

An unrecognised food falls back to the plain intellect one rather than dropping
the buff, which is what a profile holding a stale name would otherwise get.

The report now names the food it picked, as it already did for the flask - a
set winning on the strength of its food would otherwise never say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Top Gear pushed every scored set into an array and only sorted and pruned
it once the whole run had finished. Since only the best 3000 are ever read,
a large search spent gigabytes holding sets it was about to discard: each
scored set retains ~2.5kb, so a 7.8m evaluation run needs ~20GB and the
worker died around a quarter of the way in, leaving the progress bar frozen
on its last report.

TopSets keeps a running top slice instead, admitting a set only once it
beats the current cut-off and trimming in batches. This picks the same sets
the old sort-then-slice did: hardScore is final the moment evalSet returns
and is never touched again, and the top N of a stream by a fixed key is the
top N of the fully sorted list. Sets tied on score can swap places, since
the comparator never returns 0 and leaves ties unordered either way.

Measured over 355,806 evaluations with GC forced at each sample: live heap
growth across the run drops from 876MB to 1MB, peak from 1176MB to 318MB,
and the winning set, its gems, enchants, score and all twelve differentials
are unchanged.

Also stops evaluating the remaining variants of an unwearable set. Validity
depends on the items worn, not the gems, enchants or consumables layered
over them, so the first variant settles it for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Optimize Everything is a "search all of it" switch, so having it also depend
on a separate depth dropdown meant it quietly delivered a truncated search
unless you knew to go and raise that too. It now sets its own depth, and the
dropdown is disabled while it's on so a value left in an older profile can't
truncate the run either.

No limit is also the new default for the gear panel. An untouched profile
expands nothing - with no gems, enchants or runes multi-selected there is
exactly one variant - so this only bites once the player has asked for
combinations, and at that point they should get all of them rather than the
first 24. The toggle-off path still resolves to 24, so a profile that never
opened the panel runs exactly as it did before any of this existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Single-stat gems: each element family had three hybrids and was missing its
own self-matching adjective, so the four solo gems slot straight in at 17 in
one stat. Worth more than a hybrid's major stat, so they win outright when
one stat is far enough ahead to be worth giving up the split - which the
optimiser could never find while they weren't in the database.

Sharding: runTopGear splits into runTopGearShard, which evaluates a slice of
the gear sets, and finishTopGear, which merges the shards into the report.
Retail now runs four workers instead of one. Sets are dealt out round robin
rather than in blocks, since neighbouring sets are near-identical in build
order and a block split would hand one worker all the good sets. The merge
is exact: each shard keeps its own best 3000, and re-ranking their union
keeps the same sets one thread would have, because a set only misses the cut
if 3000 better ones exist and every one of those is in some shard's slice.
Tests pin the winning set, the differentials and the compared count against
an unsharded run at two, three and four ways.

Report highlighting: items you aren't wearing and gems you don't have
socketed get a gold outline. It's an outline rather than a border so it
composes with the vault, exclusive and catalyst styles that already own the
border, and gem comparison is skipped on an item that's new anyway. Needed
carrying gemString through shortenReport, which whitelists fields and was
dropping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A large selection spends real time building sets before a single one is
evaluated, and the bar sat indeterminate through all of it with no way to
tell a slow start from a hang.

The total is exact rather than an estimate. Nothing but the ring and trinket
pair rules rejects a combination, so the set count is the product of the
other slots times the valid pairs in those two. Both rules moved into shared
helpers that the count and the building loops now share, so the bar can't
promise a total the loops never reach - which would leave it stuck short
forever. A test pins the promised total against the number of sets the
evaluation stage then reports.

Two fixes the new stage exposed. The estimate now measures the current stage
rather than the whole run: carrying the run's elapsed time into a stage that
has just started read as hours remaining for its first few updates. And
shards are only summed with others in the same stage, since a shard's set
count and another's evaluation count don't add up to anything - the run is
as far along as its slowest worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Progress reporting isn't what costs time: measured over a 67 second run,
a run with a progress callback and one without differ by 0.5%, inside the
noise, because the engine reports about a hundred times regardless of how
long it takes. There is nothing to gain by reporting less often.

Measuring that did turn up real waste. Sharding split the evaluation but not
the build: every worker built the whole set list and then filtered away the
three quarters it didn't own. createSets now takes the shard and skips
assembling the item list, scoring it and allocating an ItemSet for sets that
belong to another worker. It still walks the whole combination space, which
is only index arithmetic, so the sets keep the same ids and the shards
divide them exactly as before.

Measured over 589,824 sets: 1328ms to build them on one shard, 366ms for
one of four - a 3.63x speedup on a stage that previously ran at full cost on
every worker simultaneously.

Worker count now scales with the machine, up to eight, leaving one core for
the thread drawing the progress bar. Capping at four made sense when extra
workers each repeated the whole build; now they don't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A worker is not free. Each one parses the engine bundle and builds every
database before it evaluates a single set, measured at ~618ms of pure
startup, and the chunk it loads is 10MB. Going from one worker to eight
therefore added that cost eight times over to every run, however small -
which is exactly the fixed floor that made short runs feel slower than they
used to be no matter the set size.

Runs are now sized before any worker is spawned, and workers are added only
as fast as there is work to pay for each one's startup: one worker per 50k
estimated evaluations, up to the core count less one. A small run gets a
single worker again, and a run big enough to need eight still gets eight.

countGearSets counts the sets a selection produces without building them -
exact, since only the ring and trinket pair rules reject a combination, and
createSets now uses it for its own total so the two can't drift.
estimateEvaluations multiplies that by the variant count. Sockets are taken
per slot rather than per set, so it's an estimate, but it only picks a
worker count: being out by a factor of two changes how fast a run is, never
what it returns.

For the record, an A/B on identical work - same gems, enchants and depth -
puts this session's engine at 285ms against 387ms before it, so the engine
itself got 26% faster. The slowdown was startup cost and search space, not
evaluation speed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Progress was held in TopGear's own state, so every report re-rendered the
whole page: the item bar, the character panel, and the gear options panel -
which recomputes the entire enchant and gem search space on every render to
project its combination count. The selected item list is built inline in the
JSX too, so that panel got a fresh array identity each time and could never
skip the work.

Ten of those a second, on the one thread the workers are already competing
with for cores, is the run paying for its own progress bar. My earlier
measurement putting reporting at 0.5% was taken in node with a plain
callback - no postMessage, no React, no contention - so it could not have
seen this.

The bar is now its own component holding its own state, updated through a
ref, so a report repaints the bar and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
createSets built every set into an array before a single one was scored.
Measured at ~790 bytes per set, that is 2.6GB for 3.5 million sets and over
7GB for ten million - past what a browser worker's heap allows, so a large
selection killed the worker while it was still building.

Same shape as the bug that used to freeze runs, one layer up: the results
were bounded but the set list was not.

forEachGearSet hands each set to the evaluator as it is made and keeps
nothing, so a set is scored and discarded before the next is built and
memory no longer depends on the size of the search. Over 3,515,625 sets,
live heap growth drops from 2633MB to 18MB.

Two things this needed, both exact rather than estimated. The socket ceiling
now comes from the best-socketed item in each slot instead of from the built
sets - slots are chosen independently, so that is the same number. The run's
total comes from countGearSets, so the progress bar knows the real total
from its first report rather than after the build.

Also drops the sort by soft score. pruneItems has been commented out for a
long time and TopSets is order independent, so it had no consumer left and
was O(n log n) over millions of sets for nothing.

Building and evaluating are now interleaved, so there is no separate
building stage to report - the setup before it starts is now "Preparing",
and evaluation reports the full total from the outset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
DIzosimov and others added 30 commits September 2, 2026 10:42
SimC reports the Folio as omnium_talents and the importer ignored the line
entirely, so the app assumed the automatic runes: the report diffed the
result against that assumption rather than against what the character has,
and "keep what I have" had nothing to keep.

The entry ids are a different numbering to the rune spell ids the rest of the
app uses, so they need an explicit mapping. Slot 4 is the only slot whose
rune varies - the others are fixed - so it is the only one worth mapping, and
136819 is confirmed as Rune of Critical Power from an export whose owner
confirmed crit was selected. Entries not in that table are ignored and the
slot falls back to the automatic pick, which is exactly what happened before.

Confirming or extending the table is one export: change the rune in game,
re-export, and see which number moved.

keepsExistingGear moved to ItemUtilities so the Folio can read it. Living in
TopGearEngine would have made the import a cycle, since the engine already
imports the Folio data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The previous commit mapped 136819 to the crit rune, inferred from its
position in a single export. That was wrong. Exporting the same character
once per rune shows only the second entry ever moves - 136815 for crit,
136821 haste, 136818 mastery, 136820 versatility - while 136814, 136817,
136819 and 136822 stayed identical across all four. 136819 is one of the
fixed entries and never encoded a choice at all.

All four are now mapped from those exports, so any character's Folio can be
read rather than only one stat.

The tests carry the four export lines themselves, and assert that none of the
entries that never moved is mistaken for the stat rune - which is exactly the
mistake the guessed mapping made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Both answer the same kind of question - what a whole run is allowed to do -
so they belong together and in sight, rather than one on the header and the
other down inside the panel it opens.

The two switches now come from one helper instead of a hand written block
each, since they differ only in which setting they read. They right align as
a pair, and both still stop their clicks from expanding the accordion under
them.

The setting is hidden again, meaning owned by a dedicated section rather than
the auto-rendered panel, and added to the list of hidden settings the
labelling test checks - it's still drawn with a translated title and tooltip,
so losing either would show the raw key.

Its label and tooltip now mention runes, since it has covered the Omnium
Folio since the import landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
SimC reports them on a commented line, so nothing looked at it. They're the
budget any "what should I upgrade" question has to be answered within, so
reading them is the first thing that has to work, and it's the same whatever
that question turns into.

The ids are kept as they appear rather than named. Which id is which crest
tier is game data the app doesn't carry, and attributing someone's crests to
the wrong tier is worse than showing a number with no name yet. Items on that
line are kept apart from currencies, since they aren't spent the same way.

Nothing consumes this yet - the cost of an upgrade, in crests, is a second
table the app doesn't have either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Its own option rather than part of the replace toggle: that one is about what
Top Gear may change for free, this is about what the character can afford to
buy. Off by default.

The planner takes the affordable upgrade that gains the most healing per
crest, then looks again, so the answer is an ordered list of what to buy next
rather than one target set that may be several weeks away. Ranks are
sequential, so only an item's next unbought rank is ever a candidate, and
Valorstones cap the plan alongside crests. Greedy isn't provably optimal - a
cheap rank can unlock a better expensive one - so it's a spending order, not
a claim of the best reachable outcome.

Healing is supplied by a callback, so the planner never scores a set itself
and is tested against a stand-in cost table rather than the real one.

The crest tiers are now named: 3442 Adventurer, 3443 Veteran, 3444 Champion,
3445 Hero, 3446 Myth, confirmed by matching a character's exact in-game
totals against their export. All five amounts were distinct so each id had
one tier it could be, and two exports agree - the Adventurer and Myth counts
moved between them while the rest held. Worth noting the guess I would have
made from the earlier export was wrong: Myth is 3446, not the 3444 that was
nearest to a remembered "around 80".

The cost of each rank is still missing, so hasCrestData is false and the
planner returns no plan rather than a wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Valorstones are gone from the game, so they are gone from the model: the
planner tracks crests only, and one budget rather than two.

Every rank costing the same means the cost table can be derived rather than
typed out. A rank is one step up the item level ladder, stopping at the
track's cap, paid for with the crest named after the track - so the only
things to keep in step are the ladder and the caps, which the app already
had, and a season that changes either needs no edit here.

The planner still divides gain by cost rather than ranking on gain alone.
With a flat price those come out identical, but the budget is per tier, and
the day a rank costs something different this keeps ordering correctly rather
than silently going wrong.

The crafted tracks stay unpriced. They cap out like any other but no crest is
named after them, so crafted items are left out of a plan rather than being
charged a guessed currency.

Verified against a real export end to end: the budget reads as the character
reported it, and the ranks left match their gear - nothing above a track cap,
five ranks on a Myth waist at 318, none on a Hero head at 321.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Crafted gear is made at 305 for no crests, and a single payment of 80 lifts
it to its track's ceiling - Hero crests for a Runed piece at 318, Myth for a
Gilded one at 331. That's a different shape to every other track, which
climbs the item level ladder a rank at a time at 20 crests each, so the two
crafted tracks are written out while the rest stay derived.

They're also the only tracks that spend a crest not named after them, which
is why they can't be folded into the same derivation.

Nothing plans a crafted upgrade yet, for a reason that sits upstream: the
importer tags these tracks from bonus ids 12052 and 12053, and neither
appears on this season's crafted items, so they arrive with no track at all.
Both crafted pieces on the character this was checked against are already at
331 so it changes nothing for them, but a crafted piece below its ceiling
would be missed. Fixing that needs the bonus id that marks each crafted tier
now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
planUpgrades scores each candidate rank through the same evalSet as
everything else, so a rank is worth what it gives this character rather than
its stat gain on paper. Items are copied before being raised, so working out
what an upgrade would be worth never touches the player's own gear.

Purchases already planned are applied before the next is valued. Gear
diminishes, so measuring every step against the original set would keep
overvaluing later ones - and it isn't only about that item, since buying a
crit piece raises what a haste piece is worth. That interaction is why the
finished list isn't in descending order of efficiency, which cost a test
assumption before it cost anything else.

Scores are cached by the levels the set is wearing, since the search asks
about the same combination repeatedly.

Crafted tracks renamed to Hero Crafted and Myth Crafted, after the crest that
pays for them. The Runed and Gilded names are the previous expansion's and
are kept in the cap table and the cost table so gear saved under them still
resolves and is still priced.

The set scoring tests live in their own file: the mocks in CrestSpending's
replace the cost table for that whole file, and these need the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A run with the option on now plans what to buy and the report draws it: each
purchase in the order to make it, what it lifts the piece from and to, its
cost, and the healing it gains, with a running total underneath.

Planned against the player's own Item objects rather than the report's
copies. Those have been through postMessage and lost the methods needed to
work out what raising them would be worth, so the winning set is matched back
by uniqueHash, unfolding weapons the same way shortenReport does. Only the
fields the panel draws are kept, so a saved report doesn't carry another copy
of every item.

The panel is its own component. Partly because the report file is already
long, but mainly so it can be rendered in a test: react-scripts build doesn't
type check, so an undefined name in JSX compiles cleanly and only fails in the
browser, which is how a deleted component shipped earlier in this work. The
report itself can no longer be rendered from the bundled sample - that had
already drifted past several fields the report reads - which is its own
reason for the panel to stand alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
It scored every candidate against a re-gemmed ideal, because it passed Top
Gear's settings straight through. So the percentage answered "how much better
would this item and a full re-gem make me" rather than the question being
asked. It now keeps the character's own gems, enchants and Folio runes, and
only the candidate item - which has none of its own - is gemmed and enchanted
automatically, like any new drop would be.

The gem and enchant expansion is pinned off at the same time. Upgrade Finder
runs a full evaluation per candidate, hundreds of times, on the thread drawing
the page, and Optimize Everything now resolves to no limit - so leaving it on
meant millions of evaluations per candidate for an estimate meant to be rough.
That was a regression this branch introduced when Optimize Everything stopped
capping at 24 variants, and it reached here through a caller I hadn't checked.

Pinning it also keeps the comparison honest: item A only says something about
item B if both were gemmed the same way, and a per candidate search can hand
one of them a better loadout for reasons that have nothing to do with the item.

The settings builder is named upgradeFinderGearSettings rather than the
obvious upgradeFinderSettings, which is already a parameter name in buildItem
and would have shadowed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Keeping the character's own gems means a variant's loadout never reaches a
socket: every loadout resolves to the same gems, so the run evaluates one
identical candidate per loadout. With sixteen gems over four sockets that is
several hundred copies of the same answer.

That is the shape of slowdown that hides well - the run is busy, the progress
bar moves, and none of the work can change the result. Enchants were already
gated this way when they're being kept; gems were not.

Only when every socket is actually holding a gem the database recognises. An
empty socket, or one holding something unrecognised, still falls back to a
pick the loadout decides, so the search there is worth paying for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Workers were spent one per 50,000 estimated evaluations, so a 60k run got a
single worker and a 200k run got four. That put the run time back to being
linear in the size of the search - the exact thing sharding was for - and it
was a mid sized run, not a huge one, that felt it.

The flat slice was the wrong shape. Adding the Nth worker takes the run from
W/(N-1) to W/N, so it saves W/(N(N-1)): the saving shrinks as workers are
added, while each one clears the same fixed startup. Worth having while that
saving beats startup, which settles N around sqrt(W / startup). A 60k run now
gets three workers instead of one, 100k gets four instead of two, and a run
too small to pay for a second still gets one - which is what the flat rule was
introduced to fix.

Both constants are measured rather than guessed: ~10,000 evaluations a second
and ~0.6s of worker startup, both taken from real runs earlier in this branch.

The rule moved to ShardProgress so it could be tested. TopGear.tsx can't be
imported under jest - the worker factory it pulls in uses import.meta.url -
which is the same reason the progress aggregation lives there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
saveAllChar has always written the whole character out, item list included,
but init rebuilt each one from a handful of fields and never read the items
back. So the gear list emptied on every load, and going back to fine tune a
run meant adding and re-ticking everything first.

Items are restored by laying the saved fields over a bare instance rather
than going through the constructor, which derives stats, sockets and quality
from the item id and would undo anything the player had changed - an upgraded
level, a chosen embellishment, a catalysed piece.

Saving also had to start covering more than the import. Every handler that
changes a character's items now goes through one place that refreshes the
list and writes them out, so none can quietly skip it - measured at 3.4ms for
seven characters carrying two hundred items each, which a click won't notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A piece taking two ranks appears twice in the ordered list and reads as two
separate decisions, when the question once you've decided to follow the plan
is what each piece costs in total. The panel now groups the same plan by
piece underneath, adding up the ranks, the crests and the gain. Only when
some piece takes more than one rank - otherwise it would just repeat the list
above it.

Two more fields were being saved and never read back, the same gap the item
list had. Without the Folio runes the Folio quietly falls back to the
automatic pick even with "keep what I have" on; without the crests there is
no budget, so a spending plan comes back empty. Both survive a reload now.

upgradeCurrency also had no default on Player at all - an earlier edit that
silently didn't apply, which went unnoticed because the import assigns the
field anyway. It works either way, but the shape is declared now and a test
covers a character that never imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A plan could only ever be made against the crests the import happened to
read, so there was no way to ask what to save for - what next week's crests
would be worth spending on, or whether a tier is worth farming at all.

The five amounts are now editable settings. The SimC import seeds them, so
they show what the character actually has without anything being configured,
and they're the budget from then on - what the import read is only a fallback
for a character imported before the boxes existed. Zero is a real answer, so
a tier can be excluded rather than only reduced.

They sit in a Crests section with the plan toggle rather than under Top Gear,
which was already the longest section in the panel and is about how a run is
searched rather than what it can afford.

The report says what it planned with, since an edited budget is otherwise
invisible - a plan that spends 500 Hero crests looks the same whether the
character has them or not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
An item added by hand had no upgrade track, because the SimC import reads
that from bonus ids and there is nothing to read from a manual entry. Without
one the piece offers no upgrades in its cogwheel and can never appear in a
crest plan, so there was no way to try "what would a Myth piece here be worth
spending on". The track is now a dropdown on the add form and can be changed
later from any item's cogwheel.

Changing a track alters the item in place rather than adding a copy, unlike
an embellishment or a stat combination: the track doesn't change what the
piece gives you now, only what it can become, so there are no two versions to
compare.

The plan now considers pieces that were in the running but lost their slot. A
piece has to clear the one it would replace before it gains anything, so
planning only the winning set could never buy into one that a couple of
upgrades would make the better choice. Each candidate is pinned up front to
the weakest piece of its slot - which piece it would displace doesn't change
as crests are spent, and deciding it per evaluation would multiply the search
by the number of candidates.

That also meant costing a whole climb rather than a rank at a time. A piece
that needs three ranks to beat the incumbent reads as worthless one rank at a
time and would never be bought into, so every reachable level competes on
what the whole climb costs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Light's Potential grants 695 primary; Potion of Recklessness grants 1725 of
the highest secondary and takes 232 off the lowest. Both are a third
consumable axis, so picking both ranks them against each other in one run
rather than needing two.

Counted at a tenth of the fight. Both last 30 seconds on a five minute
cooldown, and a flask is up the whole time - a potion counted in full would
read as ten times the buff it is next to one. Since both potions share a
duration and a cooldown, scaling doesn't tilt the comparison between them,
only their size against everything else.

Highest and lowest secondary here mean the character's own ratings, through
getHighestStat and getLowestStat, which is what the in-game wording says.
That is deliberately not the BEST_SECONDARY marker the enchants and food use,
which follows the spec's stat weights: a potion reading "your highest
secondary stat" takes whichever rating is largest whether or not the spec
values it most.

Worth knowing: this makes an Optimize Everything run three times bigger, since
every consumable axis multiplies into the rest. A test that pinned the old
count caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Searching two potions reported a 2.56% upgrade on gear that hadn't changed.
Both potions weren't being used at once - the winning set carried exactly one.
The baseline was the problem: it took the settings dropdown, usually None,
while every candidate carried the better potion, so the potion's whole value
was counted as a gear upgrade. The equipped set is now measured across the
same consumables the run searches, so what's left is the difference the gear
makes. Identical gear reports 0% whether one potion is pinned or both are
searched.

Same shape as re-gemming the baseline, and the same lesson: a comparison is
only worth anything if both sides get the same options.

Close alternatives now say which kind of decision they are. Consumables were
being reported as enchants on a slot called "potion", and a weapon enchant
swap appeared three times because the breakdown holds the weapon under three
aliases. Both fixed, and flask, food and potion swaps now render as their own
chips.

Gem-only alternatives are capped at three and have to be worth at least
0.05%. Sets differing only in gems outnumber everything else by orders of
magnitude and are mostly worth a hundredth of a percent, so they filled the
list and buried the trinket, enchant and potion swaps worth reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The threshold only covered alternatives that changed nothing but gems, so a
set shuffling a gem and an enchant together went straight past it - and those
were most of the list. It now applies to every alternative whatever it
changes.

The percentage it filters on was quantised and had to be fixed first. The
score difference was rounded before being divided, so a set 14 healing behind
and one 77 behind both came out as 0.00%: no use to read, and useless to
filter on. Rounding now happens for display, where it belongs.

Alternatives describing the same swap are also collapsed. The same enchant on
either ring is one decision, and the list was showing several of these as
separate rows.

On a run searching three gems and two enchant slots, the list went from
leading with 0.00% rows worth 14 healing, several of them duplicates, to
nothing below 0.056% and no repeats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
A gem alternative was a bare icon: it said what to buy but not where it
goes, which on a set with four sockets is most of the question. Each swapped
gem now carries the socket it would fill, assigned the same way the report
hands gems to the item cards - in slot order, each socketed piece taking the
next ones.

Worth being plain about what that is: gem position isn't modelled anywhere.
The engine picks one multiset for the whole set and any socket would take any
of them, so this is an assignment rather than a recommendation. It is the
same assignment shown on the gear, which is what makes it act-on-able.

With the socket attached these are worth reading again, so the floor drops
back to 0.01% and the cap of three gem-only rows is gone. The threshold still
applies to every alternative whatever it changes, so the sub-0.01% shuffles
that prompted the filter stay out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A partly upgraded set flatters everything compared against it: a piece can
win its slot only because the gear beside it is three ranks short, which is a
reason to spend crests rather than to chase loot. A checkbox now raises every
equipped piece to the top of its own track first, answering the other
question - what is still worth chasing once those crests are spent.

Off by default, since "what beats my gear as it is" is the usual question,
and remembered for the session like the other Upgrade Finder choices.

Each track goes to its own cap rather than a shared one, the raising is done
on copies so the player's gear is untouched, and a piece with no track is
left alone - crafted pieces this season carry none, and there is nothing to
raise them to.

Fixes a bug this uncovered: clone() never carried enchantID, a field added
later without updating it. Upgrades are scored on clones, so under "keep my
gems and enchants" a raised piece silently lost its enchant and fell back to
the automatic pick - which is not what it is wearing, and not what the
baseline was scored with.

The choice of baseline is its own function so the wiring is testable. Testing
the raising alone left it uncovered: the engine could ignore the setting
entirely and every test still passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Raising the player's gear was only half of it. Candidates were still offered
at the level they drop at - one rank of six - so a finished Hero piece was
measured against a fresh Myth one, and the number reported the crests not yet
spent as much as the piece itself. That is the skew WoWAudit rejects the
report for.

With the option on, only the finished versions of a candidate are offered:
"max", the top of the track it drops on, and "bonus", the top of the vault
track above it. A 6/6 Hero piece and a 6/6 Myth one, which is the comparison
worth making. The engine already built all three versions, so this is a
choice of which to offer rather than new arithmetic.

Both the raid and Mythic+ paths go through one function for that choice,
including the dungeons whose end-of-run and vault share a track and so only
ever had two versions to remove one from.

The setting already travels with the report - shortenReport stores the whole
Upgrade Finder settings object - so which mode generated a report can be read
off the link without anything further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
WoWAudit accepts a report only if it was generated a particular way. Most of its conditions the Upgrade
Finder meets and could not fail to meet: there is no AoE or fight-style model to get wrong, no Power
Infusion modelled at all, candidates are never granted a socket, and each is measured on its own against
the baseline. Two are choices, and both have to be right or the report comes back rejected rather than
wrong: the gear has to be fully upgraded on both sides, and the fight has to be five minutes. QE scores a
raid at 6:40.

The toggle sets both together instead of leaving the player to remember one of them, and prints what the
report was run under on the report itself - a number on a page being no evidence of how it was produced.
The stamp separates the conditions the toggle enforced from the ones that merely hold, because claiming
to have checked something there is no check for is worse than saying nothing.

Fight length reaches the scoring code through the character's own cast model, which the rest of the app
shares, so the pin is put back in a finally: a run that throws must not leave the character scored at five
minutes everywhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The 6/6 comparison raises copies, deliberately - the player's own items are shown elsewhere in the app and
saved to the character, so scoring must not rewrite them. But the uploaded report took its equipped list
straight off the character, so a report scored at 6/6 declared gear at 1/6. Measured: the engine scored a
321 head while the report uploaded it at 308.

That contradiction is what WoWAudit catches. It reads the equipped list to check the report was run with
everything upgraded, and rejects it when the levels say otherwise - which is the rejection we have been
getting, and it is not about fight length or any of the Droptimizer conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Crafted gear was already priced - one payment of 80 crests from its base level to its track's ceiling, 331
for Myth and 318 for Hero - but nothing could reach that price. The track dropdown offered only the five
ladder tracks, so an item added by hand could never be marked crafted, and a piece with no track offers no
upgrades and never appears in a plan at all. The 80 crests it costs were simply invisible.

The dropdown now reads whether an item is crafted from the item database and offers the two crafted tracks
instead of the ladder ones. Two things behind it were wrong in the same silent direction: a crafted piece
sitting anywhere but exactly its base level matched no rank and offered nothing, and a plan reported the
climb as starting from the base level rather than from where the piece actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The engine always wore a flask - Automatic picked the best secondary, and there was no way to ask for none -
so every Upgrade Finder score carried 165 of a stat regardless of what Top Gear was set to. It now takes a
"None" flask, which Upgrade Finder always uses. It isn't offered in the settings panel; Top Gear scores
exactly as before.

The pinned-flask route needs no change of its own: Upgrade Finder already turns the detailed options off,
and pinned consumables live behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
The engine already understood a "None" flask, and Upgrade Finder always uses it, but the dropdown only
listed Automatic and the four stat flasks - so there was nothing to pick. It's now an option, and Top Gear
wears no flask when it's chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5STi454aLVN1cAsYyba3J
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant