AGY built a full 60-round arena simulator (hv-battle-sim.py) using
the user's actual gear/stats and ran 10 iterations:
Original: 0/10 survived (0%)
Improved: 10/10 survived (100%)
Critical changes from simulation findings:
- Health items + Cure moved to TOP of priority chain (before skills)
- Dynamic HP threshold: 60% when 7+ mobs, normal threshold otherwise
- SP threshold raised from 55% to 60% for Spark safety buffer
- Mana maintenance raised to prevent spell lockout
- AoE crowd control prioritized for 4+ mobs
Full report in agy_sim_output.txt
- Bug 1: Purchase tab URL is 'screen=purchase', not 'screen=buy'
- Bug 2: dynjs_eqstore is declared with 'const', not 'var', so it doesn't
create a window property. getHVEquipStore() now also checks bare names
which works with @grant none and the IIFE scope chain
- Fixed price parsing: purchase table has 2 td cells (label + price),
previously required >3 cells so prices were always empty
- Names with 🔺 prefix are cleaned before storage
- scrapeBuyPage() scrapes equipment for sale from Bazaar Buy tab
(URL: ss=am&screen=buy or ss=bi), marks as source='buy'
- Stores merge with existing: buy items don't overwrite equipped/armory
- Auto-detect routing handles buy page URL patterns
- analyze_gear.py updated with 3-source output:
🟢 Equipped = currently worn
📦 Stored = in armory inventory
🛒 For Sale = purchasable from Bazaar
- Shows level limits (Lv155 + 15 = Lv170 cap), prices where available
- Re-scrape after visiting Buy tab to include market items
- When 7+ enemies and OC >= 60: toggles Spirit Stance ON before casting
Rending Blow, then toggles OFF after one skill
- Uses 3-state flag: press1=toggleON, press2=castSkill, press3=toggleOFF
- Only triggers on normal (non-panic) Rending Blow — step 0e emergency
skill still fires without spirit to avoid delay
- Resets on battle init and when conditions change
- skillOC default lowered from 75 to 60 — with 300ms Q debounce, it took
~6 presses to go from 50 to 70 OC (too slow against IWBTH enemies)
- Threat threshold lowered from 85% (102px) to 70% (84px) — triggers
earlier when monsters have high SP bars
- Added single-monster emergency: if ANY enemy >90% SP, fire skills
even without 2+ monsters (catches the lone special attacker)
- Step 0d high-threat check now fires for solitary imminent threats too
- Previously only scraped items visible in the current filter tab
(had to visit each tab: New, Two-Handed, Light, Heavy, etc.)
- Now also scans the full dynjs_equip store for ALL items in your
account (305 total) — every filter tab's worth of data at once
- Category guessed from tooltip type (Cloth, Light, Heavy, Weapon, etc.)
- One visit to the Armory page + HV.scrapeGear() = complete inventory
- equip-UID.js loads asynchonously after document-end, so dynjs_equip
might not exist when init() first runs
- Now retries up to 10 times (500ms apart, 5 seconds total) waiting for
the store to populate before scraping
- Added HV.gearStore() to check whether the store is loaded
- If store never loads, falls back to name/ID scrape without stats
- Previously: tried equips.set() + 300ms delays + popup_box polling
which was fragile, async, and often saved stats to wrong slots
- Now: reads window.dynjs_equip / dynjs_eqstore which contains ALL
item data pre-loaded on page load (name, quality, full tooltip HTML)
- Uses DOMParser to parse the embedded .eq HTML into structured stats
(type, level, condition, burden, ADB, mit, attributes, etc.)
- Zero hover, zero delays, zero popup_box interaction — instant stats
- Works on Character Equipment page AND Armory pages simultaneously
- Removed triggerAllTooltips() and captureAllTooltips() — obsolete
- scrapeCharacterEquip() now only reads names/IDs from DOM slots (no tooltip
popup reading — that was attaching wrong stats to wrong slots)
- captureAllTooltips() now calls logGearSummary() instead of autoScrapeGear()
so the tooltip stats aren't wiped by a re-scrape
- Workflow: visit Character Equipment page, then run HV.captureTooltips()
to populate full stats with 300ms hover delay per item
- captureAllTooltips() iterates each equipment slot, calls equips.set()
to trigger the tooltip popup, waits 300ms for the DOM to update,
then reads and saves the full stat block from #popup_box
- Catches 'Popup Box' default text vs real tooltip
- Saves stats directly to localStorage gear DB
- Exposed as HV.captureTooltips() for console use
- For best results: visit Character Equipment page, run HV.captureTooltips()
- .eqb parent has onclick and structure, but the onmouseover with
equips.set() and the item name text are on a CHILD div within .eqb
- Fixed scrapeCharacterEquip() and triggerAllTooltips() to query
:scope > div[onmouseover] inside each .eqb slot
- Fixed debugScrapeGear() diagnostic to show inner div content
- New debugScrapeGear() function checks DOM structure:
- Whether #eqsb exists and its slot count
- Whether #equiplist exists
- Whether #popup_box exists and its content
- Whether equips global is available
- Exposed as HV.gearDebug() for console use
- Character Equipment page (?s=Character&ss=eq): reads #eqsb > .eqb
divs with onmouseover='equips.set(ID,...)' for item names and IDs
- triggerAllTooltips() calls equips.set() for each item to populate
the #popup_box tooltip, then parses full stat blocks (ADB, damage,
mitigation, attributes, burden, interference, condition)
- Armory Organize page: reads #equiplist table rows with
onmouseover='hover_equip(ID)' and checkbox values
- Reads right-side #equipinfo preview for condition and stats
- Modify detail page: reads full stat tables
- Added to window.HV public API: gear(), gearSummary(), scrapeGear()
- Now accessible from console as HV.gear(), HV.gearSummary()
- Also keep getGearDB() and logGearSummary() directly available
- New module: gear-scraper.js (10K, 200+ lines)
- Auto-scrapes Character page (?ss=ch) for equipped gear with full stat
blocks: ADB, damage, crit, parry, evade, burden, mitigation, stats
- Auto-scrapes Armory inventory page for all items with IDs, names,
categories, potency, durability
- Scrapes Modify detail page for one item's complete stat table
- Stores everything in localStorage['hvunified_geardb']
- ParseHV.advice() now includes gear info
- Public API: getGearDB() returns full gear database
- Added step 0d in Veteran: if 2+ monsters have SP >85%, fire weapon
skills BEFORE buffs, items, debuffs, or anything else
- Only needs OC >= 20 (one skill's worth, builds fast from basic attacks)
- Rending Blow > Shatter Strike > Great Cleave, targeting most dangerous monster
- Formerly: weapon skills were at step 11, after all buffs and items.
Enemies got multiple turns to special attack during the buff-up phase.
Now dangerous monsters get shut down immediately.
- Imperil no longer casts on every 2-mob wave (Estoc's native Penetrated
Armor handles armor-breaking for <5 targets)
- Imperil threshold raised from MP>20% to MP>35%
- Imperil now only fires on bosses/rares OR 5+ monster waves (Grindfest)
- Weaken now only on high-threat (SP>85%) or bosses, not every 3-mob wave
- Formerly: Imperil on 2+ mobs, Weaken on 3+ mobs — drained MP fast
- Regular buff loop in all 3 tiers now collects candidates then sorts
by MP cost ascending (cheapest first) when no channeling, or
descending (most expensive first) when channeling is active
- Previously just iterated BUFF_PRIORITY declaration order (Haste →
Spark → Heartseeker → Regen → Absorb → Shadow Veil) which wasn't
cost-ordered
- _reason now shows 'buff' (cheapest first) or 'chBuff' (most expensive)
- castInitialBuffs now tags actions with _reason='init' (cheapest-first
sort, no channeling) or _reason='initCh' (most-expensive-first,
channeling already charged)
- Makes the log clearly show which buff path fired
- Some quickbar/deprecated elements retain onclick even when on cooldown
if the cooldown indicator is only visual (img opacity:0.5) not structural
- Now also checks img.btqi / img style.opacity !== '0.5'
- This catches cases where the parent div has onclick but the inner image
is visually dimmed (game cooldown)
- Added hasReadySpell() that checks DOM for onclick attribute (off cooldown)
- Buff loop (all tiers) now uses hasReadySpell instead of hasSpell
- Cure/Full-Cure now checks cooldown before casting
- Channeling target selection checks cooldown
- castInitialBuffs checks cooldown
- Imperil/Weaken debuffs check cooldown
- hasSpell() kept for knowledge checks (do we know this spell at all)
- Prevents silent failures where on-cooldown spells are selected but
can't actually be cast (btsd[onclick] fails to find them)
BUG FIXES (P0):
- Spark of Life SP safety: consume spirit items when SP < 55% BEFORE
buffs, Cure, or any other action (both Adept and Veteran)
- Spirit Stance safety auto-disable: auto-toggle OFF if SP drops below 60%
- useItem() fallback: auto_ slot IDs now lookup by onmouseover attribute
(on-cooldown items without DOM id were silently failing)
- setTimeout targeting race: removed 50ms delay in castTargetSpell/useSkill
(was causing targeting failures with fast Q-repeat)
- maxBuffDur pre-seeded with L150+ baseline values (haste=80, heartseeker=120,
sparklife=50, etc.) so fill-level scoring works from first turn
STRATEGY (P1):
- Channeling waste eliminated for 2H physical builds: no more Fiery Blast
fallback — let channeling expire on a basic attack (Domino Strike +
Penetrated Armor do more damage)
- Weapon skill thresholds lowered from 5+ to 3+ enemies (IWBTH survivability)
- Expanded debuffs: Imperil on any 2+ mobs, Weaken on high-threat targets
(not just bosses anymore)
- Domino Strike optimal targeting: findOptimalDominoTarget() picks center
monsters to max splash coverage (up to 2 targets each side)
NEW FEATURES:
- Domino Strike splash positioning algorithm added to all tiers
- Full _reason tags on every action return for decision tracing
- agy research report saved at hermes_report.md (554 lines)
- maxBuffDur now saved to localStorage['hvunified_maxBuffDur'] on every
new high observation and loaded on STATE init
- Survives page reloads: Heartseeker's 180-turn max stays learned
- Removed the unreliable-max fallback hack — no longer needed
- First Q press of a fresh session has accurate fill-level scores
for all buffs that were observed in previous sessions
- When maxBuffDur gap (max - current) is < 10, the cap is unreliable
(first observation mid-battle, not at full duration)
- In that case, skip the fill-level formula entirely
- Only suggest a buff for channeling if it actually needs refresh
(dur <= minTurns from BUFF_PRIORITY)
- Once maxBuffDur learns the true cap (after a fresh cast), the
normal fill-level scoring takes over correctly
- Q-key log now shows: HP, current MP, MP%, SP%, OC, monster count,
buff durations, and per-monster SP bar levels (spBars: 0:120 1:95 ...)
- High-threat detection logs a red '⚠ HIGH THREAT' warning
- Emergency skill usage at low OC logs an orange '⚠ emergency' warning
- Now you can paste me the console log and I'll see exact threat levels,
why skills did/didn't fire, and what the state was before each action
- Fix: items on cooldown (no id attr) were invisible to parser
Spirit Draught/Potion on cooldown couldn't be auto-consumed
- Fix: spiritPotionSP raised from 30% to 60%
- New: parse monster SP bar widths into STATE.monsterSp[]
- New: findDangerousMonster() — targets highest SP bar monster
- New: hasHighThreat() — true if 2+ monsters >85% SP (about to special)
- Weapon skills now fire on high-threat even without full OC
- Basic attacks prefer dangerous monsters in all tiers
- Raised spiritPotionSP threshold from 30% to 60%
- Added Spirit item check at step 2b in Adept and Veteran tiers:
if SP < 55%, consume Spirit Draught/Potion immediately
- Spark of Life costs 50% max SP when triggered. If SP is too low,
Spark fails and you die. Keeping SP above 55% ensures Spark always
has enough fuel to save you from a killing blow on IWBTH
- Changed Veteran suggestion from PFUDOR (20x) to IWBTH (15x)
- PFUDOR is now only suggested at Master tier (L300+)
- Since you're already on IWBTH, the banner won't show anymore
(current == suggested, so no nag)
- executeAction now sets STATE._lastActionTime after dispatching
- Q handler checks this instead of a local var
- When an action executes, the Q debounce resets so the next Q press
waits the full 300ms before attempting again
- Prevents stale-state queueing when game hasn't processed the
previous action yet (was sending 12+ 'skill' commands to the
game, though only 1 actually ran)
- Added _lastQTime debounce (300ms) to the Q key handler
- Holding Q now fires at most ~3 actions per second instead
of ~20, preventing duplicate actions from stale state reads
- The previous issue: 6 identical Haste casts, then 8 identical
Spark casts, then 12 identical Heartseeker casts — all from
state not yet updated by the game
- castInitialBuffs was sorting by mpCost descending (b.mpCost - a.mpCost)
- So it picked Spark of Life (70) before Haste (41), defeating the
whole purpose of casting cheapest-first to proc channeling
- Changed to ascending sort (a.mpCost - b.mpCost): Haste first, then
Spark, etc.
- Was using vrhd (HP element) instead of vrm (current MP)
- Now reads #vrm textContent for current MP value
- Simply compares mpCost > curMp to skip unaffordable spells
- This prevents the infinite Heartseeker loop when you
don't have enough MP even though the buff is a good target
- Removed SPELL_COSTS hardcoded table
- Base MP costs are now read from the magic pane's onmouseover
tooltip at battle init and cached in STATE._baseMpCosts
- Cache is populated during parseBattleState() and persists
for the entire battle, avoiding channeling's 1-MP display
- Cache resets on new battle, so level-ups or stat changes
are picked up when starting a new arena
- Removed hardcoded SPELL_COSTS table (was brittle)
- Now reads base MP cost directly from the magic pane's
onmouseover tooltip (always shows true base cost, unaffected
by channeling's 1-MP display or Mana Conservation)
- Added affordable check: skip spells where base cost exceeds
current MP, even though channeling would make it cost 1
- The game's native MP check (shown by "You do not have enough")
checks against base cost, not channeling-reduced cost
- Some spells show mp=1 in the DOM due to high Mana Conservation
proficiency, making fill-level scoring produce near-zero values
- Now: if mpCost < 5, look up the base cost from SPELL_COSTS table
- This ensures Heartseeker (141 MP) scores correctly even when the
DOM shows it costing 1 MP
- Magic pane query used '.btsd[onclick]' which excludes
on-cooldown spells (opacity:0.5, no onclick attribute)
- Heartseeker was invisible to hasSpell() during its cooldown
period, which is exactly when channeling procs from it
- Changed to '.btsd' with onmouseover fallback
- Logs the top candidate from findBestChannelingTarget() on every Q press
- Shows: spell, score, dur, maxDur, mpCost, emptiness%
- Next time channeling doesn't fire on a buff, the console will show
the actual computed scores so we can see what's happening
- When maxBuffDur for a buff equals its current duration (first observation
in a battle), the emptiness was 0 — making the script think all buffs
are full and skipping them
- Now: if maxDur <= current dur, assume a minimum of 30 turns as the max
- This ensures buffs like Shadow Veil at 8/8 are treated as 8/30 (73% empty)
instead of 8/8 (0% empty)
- The regex only matched 'X turns remaining' text format
- Buffs like channeling and freshly cast spells use the raw third
parameter of set_infopane_effect('Name','desc',X) without 'turns rem'
- These were defaulting to 50 turns, making all fresh buffs appear
as having 50 turns when they really had 0-5
- Added fallback to parse the last numeric argument of the function call
- Mystic Gem usage now tracked with STATE._mysticUsed flag
Prevents re-triggering the gem check after it's been consumed
(stale STATE.itemsKnown was causing infinite 'item → p' loops)
- Flag resets on new battle initialization
- Added buff durations + channeling status to Q-key console output
Shows: ch=true/false mp=X% oc=X buffs: heartseeker=47 haste=24 ...
- Removed call to undefined finalizeBattleLog() that was spamming
ReferenceErrors and potentially corrupting the MutationObserver
- Raised channeling score threshold from 5 to 30: now only picks a
buff for channeling if it's at least 30% empty on a 100-MP spell
or 60% empty on a 50-MP spell
- Spark at 30/60 turns (50% empty, score 44) now falls to cheap
damage fallback instead of wasting channeling
- Channeling detection was ONLY from battle log text entries
- When log entries scroll off (new round starts), the 'gains the effect'
message is lost and STATE.channeling stays false permanently
- Added effects pane check: if channeling.png is visible, force channeling=true
- This fixes the root cause of channeling being ignored when the log
has scrolled past the proc message
- Also bumped version properly this time
- Score = emptiness_ratio × mp_cost
- emptiness_ratio = (max_dur - current) / max_dur
- max_dur is tracked dynamically per buff in STATE.maxBuffDur
(learned from the highest duration seen per icon)
- Freshly cast buffs score ~0 regardless of MP cost
- A 100-MP buff at 1/90 turns scores 98.9
- A 40-MP buff at 25/50 turns scores 20.0
- A 140-MP buff at 71/80 turns scores 15.75
- Removed the Heartseeker >50 skip (no longer needed - fill level handles it)
- Reverted the >10 turns cutoff (wrong approach)
- Instead, added a minimum score threshold of 5 to the best candidate
- At score >= 5: a 50-MP spell with <9 turns, a 140-MP spell with <27 turns
- Below score 5: falls through to cheap damage spell fallback
- This prevents recasting fresh buffs while still considering all spells
- findBestChannelingTarget: skip buffs with >10 remaining turns
- When all buffs are fresh, falls through to cheap damage spell
instead of wasting channeling on a nearly-full duration buff
- This prevents recasting Spark of Life at 15 turns just because
its score happens to be highest among all fresh buffs
- The vitals observer check 'battleLog && !battleLog.summary' referenced
an undefined variable, throwing on every observer tick
- Simplified to just check for pane_completion existence
- This crash may have been interfering with state parsing on the observer
- config.js: 0.13.0 -> 0.13.8
- header.user.js: 0.12.0 -> 0.13.8 (was never bumped past 0.12.0)
- Now the @version tag in the userscript header matches the internal VERSION