hv-unified/references/monsterbation-battle-patterns.md
GaboGG 402db6bb2f Initial commit: HV Unified v0.11.0 structure
- 23 source files in src/ (build via scripts/build.sh)
- Forum-sourced player knowledge in references/
- DESIGN.md with architecture and corrections
- References to existing scripts (Monsterbation, jpx, HV Utils)
2026-07-20 19:30:26 -04:00

45 KiB
Raw Permalink Blame History

Monsterbation 1.4.1.2 — Battle Interaction Patterns Deep Dive

Complementary Analysis to hv-scripts-analysis.md

===========================================================================

1. HOVER ATTACK SYSTEM — EXACT FLOW

1.1 The Complete Lifecycle (mouse enter → monster click)

MOUSE ENTER monster area
  ↓
  Monsters() [line 1318] attaches event listeners:
    - mouseout → ClearTarget on .btm{Cfg.hoverArea}
    - mouseover → SetTarget(i) on .btm{Cfg.hoverArea}
    - mousedown → HandleClick(i) on full .btm1
    - contextmenu → preventDefault on full .btm1
    - wheel → HandleWheel(i) on full .btm1
  ↓
SET TARGET(i) [line 1378] fires:
    target = i;  // Set the global target variable
    THEN: if hover enabled, no interrupt, no alert, monster alive:
      → Hover();
  ↓
HOVER() [line 1359]:
    if (hovering) return;  // Guard: one hover per event cycle
    hovering = true;
    
    // Priority chain for action selection:
    if (override)           → override();    // from mouseEngage/monsterBar
    else if (shiftHeld)     → cfg.hoverShiftAction();
    else if (ctrlHeld)      → cfg.hoverCtrlAction();
    else if (altHeld)       → cfg.hoverAltAction();
    else                    → cfg.hoverAction();
    
    // Inject one-time impulse action after configured action:
    if (impulse) {
      impulse();       // Executes the impulse
      done = true;     // Prevents re-trigger
      impulse = false; // Clears the impulse
    }
    
    monsters[target].click();  // CRITICAL: always clicks the monster
                               // This is the actual turn submission!
  ↓
SERVER processes turn → new HTML page loads → loop repeats

1.2 Key Design Decisions

Monster click ALWAYS fires last. This is the single most important architectural pattern: the monsters[target].click() on line 1376 is the final action in Hover(). Every spell cast, item use, or toggle happens before it. The monster click triggers the page's built-in onclick handler, which submits the turn to the server.

Hovering flag prevents re-entry. The hovering boolean (line 1360) prevents Hover() from being called recursively. It's reset to false in Observe() (line 1130) after each MutationObserver-triggered turn cycle.

hoverArea config (line 135-136, 870-871): Controls which sub-element of .btm1 triggers the mouseover:

  • 1: whole monster box
  • 2: monster icon
  • 3: monster name
  • 4: monster vitals/HP bar
  • 6: monster status effects area

1.3 Interrupt System

Two global booleans control whether hover fires:

Flag Set By Meaning
interruptHover ToggleHover() or cfg.startRoundWithHover User manually toggled hover on/off
interruptAlert Alerts() + Durations() Spark, low HP/MP/SP, or buffs expiring

InterruptAlert is set per-turn in:

  • Alerts() [line 1196-1245]: Checks spark (fallenshield.png without bar_dgreen.png), low HP, low MP, low SP
  • Durations() [line 1247-1293]: Checks alertBuffs regex against effect icons with < 2 turns remaining

The minSP auto formula [line 1002]: 0.5 - 0.5 * spboost / (spboost + 100) — dynamically scales based on Spirit Tank upgrades.

1.4 Modifier Key Hover Overrides

When a modifier key is held during hover, the action changes:

shiftHeld + cfg.hoverShiftAction → evaluated during Hover() via handleKeys()
ctrlHeld  + cfg.hoverCtrlAction  → syncs on keydown/keyup
altHeld   + cfg.hoverAltAction   → same mechanism

The modifier state is tracked via handleKeys() [line 670] and handleKeyup() [line 686] which update shiftHeld, ctrlHeld, altHeld on every key event. This is separate from the modifier-based keybinding system.

1.5 Mouse Engage Mode

When cfg.mouseEngage = true [line 989-991]:

  • mousedown sets override based on which mouse button: left→cfg.clickLeft, middle→cfg.clickMiddle, right→cfg.clickRight
  • mouseup clears override and sets release = true
  • Hover() then calls override() instead of cfg.hoverAction

1.6 Hover Autoresume

On keyup [line 686-695]: If cfg.hoverAutoresume is true, clears interruptHover and re-fires Hover() if conditions permit. This enables the "hold key to modify, release to resume" workflow.

===========================================================================

2. SPELL ROTATION SYSTEM

2.1 Strongest() — The Core Combinator

function Strongest(actions) {
    return function() {
        var n = actions.length;
        while (n-- > 0)
            actions[n]();  // Executes from LAST to FIRST
    };
}

Critical detail: Strongest iterates backwards (from last to first). This means:

  • FOR TARGETED SPELLS: put most desired action first in array — it gets called last (closest to monster click)
  • FOR UNTARGETED SPELLS/ITEMS: put most desired last in array — it gets called first (before any targeting)

Why? Because targeted spells set up state (via the dummy element trick) that the monster click then resolves. The LAST spell run in the loop is the one whose state is active when monsters[target].click() fires. Untargeted actions complete immediately and don't need the monster click.

2.2 Impulse() — One-Shot Injection

function Impulse(action) {
    return function() {
        if (done) return;  // Only fires once per turn cycle
        impulse = action;  // Stores for later execution in Hover()
        if (interruptHover || interruptAlert || !monsters[target] || !monsters[target].hasAttribute('onclick')) {
            action();       // Immediate execution if hover is inactive
            done = true;
            impulse = false;
        }
    };
}

The Impulse pattern: If hover is active and healthy, the action is stored in the impulse variable and waits for the next Hover() call. If hover is stopped or no target, it fires immediately. The done flag is reset when release is set (on mouseup/keyup), allowing one impulse per user interaction.

2.3 How Spell Icons Are Found in the DOM

function Cast(name) {
    return function() {
        var spell;
        // Guard: don't recast the currently active spell
        if (document.getElementsByClassName('btii')[0].innerHTML != name &&
            // Find spell icon by its onmouseover text containing the spell name
            (spell = document.querySelector('.bts > div[onclick][onmouseover*="\\\'' + name + '\\\'"]'))) {
            // DUMMY ELEMENT TRICK:
            dummy.setAttribute('onclick', spell.getAttribute('onmouseover'));
            dummy.click();   // Triggers the spell's onmouseover → sets targeting mode
            spell.click();   // Clicks the actual spell icon → selects it
        }
    };
}

Selector breakdown: .bts > div[onclick][onmouseover*="'SpellName'"]

  • .bts = battle spell containers
  • div[onclick] = only clickable divs (available spells)
  • [onmouseover*="'SpellName'"] = substring match on the onmouseover attribute

The onmouseover attribute on spell icons contains text like: ('Imperil', 3) — indicating the spell name and turn cost. The regex index \'' is the game's way of representing the spell name in the attribute.

Guard against recasting: Line 336 checks document.getElementsByClassName('btii')[0].innerHTML != name — this is the "currently selected spell" indicator at the top of the battle page. If Imperil is already queued, it won't try to cast it again, preventing wasteful clicks.

2.4 The Dummy Element Trick

The dummy element (line 735) is a detached <div> created once at script init. Its purpose is to bridge between the game's mouseover-click expectations:

  1. The game's spell icons work on a two-step model: mouseover selects the spell (shows targeting reticle), click confirms
  2. The dummy element's onclick is set to the spell's onmouseover text
  3. dummy.click() triggers that onmouseover behavior without needing actual mouse movement
  4. Then spell.click() actually selects the spell
  5. Finally monsters[target].click() completes the targeting

2.5 Default Rotation Configuration

// From settings (lines 123-126):
hoverAction: "Nothing",                                              // Default: plain attack
hoverShiftAction: "Strongest([Cast('Ragnarok'), ...])"              // Shift: dark spells
hoverCtrlAction: "Strongest([Cast('Paradise Lost'), ...])"          // Ctrl: holy spells
hoverAltAction: "Strongest([Cast('Flames of Loki'), ...])"          // Alt: fire spells

The action strings are eval()'d at init (line 1007), converting string representations into actual function objects stored in cfg.hoverAction etc.

2.6 Use() — Item Consumption

function Use(id) {
    return function() {
        var item;
        if ((item = document.getElementById('ikey_' + id))) {
            dummy.setAttribute('onclick', item.getAttribute('onmouseover'));
            dummy.click();
            item.click();
        }
    };
}

Items are found by their DOM ID: ikey_1 through ikey_15 for regular items, ikey_s1-ikey_s6 for scrolls, ikey_n1-ikey_n6 for infusions, and ikey_p for the power gem. The 'p' special ID in Use('p') maps to the gem.

===========================================================================

3. KEYBINDING SYSTEM

3.1 Bind() Function Signature

Bind(KEY_CODE, MODIFIER, ACTION)
// OR
Bind(KEY_CODE, ACTION)  // Modifier defaults to NoMod

Implementation [lines 709-714]:

function Bind(key, mod, command) {
    if (!command) {
        command = mod;
        mod = NoMod;
    }
    if (command) {
        bindings.push(new Keybind(key, mod, command));
    }
}

The third-argument-optional pattern: if only two arguments are passed, the second is treated as the action and modifier defaults to NoMod.

3.2 Keybind Object

function Keybind(key, mod, action) {
    this.keyCode = key;      // JavaScript keyCode integer
    this.modifier = mod;     // Function: takes event, returns bool
    this.action = action;    // Function: the action to execute
}

3.3 Modifier Key Functions

NoMod(e)         !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey
Shift(e)         e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
Ctrl(e)          e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey
Alt(e)           e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey
CtrlShift(e)     !e.altKey && e.shiftKey && e.ctrlKey && !e.metaKey
AltShift(e)      !e.ctrlKey && e.altKey && e.shiftKey && !e.metaKey
CtrlAlt(e)       !e.shiftKey && e.ctrlKey && e.altKey && !e.metaKey
CtrlAltShift(e)  e.shiftKey && e.altKey && e.ctrlKey && !e.metaKey
Any(e)           !e.metaKey  // ALL modifier combos except meta

All modifier functions explicitly check !e.metaKey to avoid interfering with OS-level shortcuts.

3.4 Key Code Constants

KEY_A=65 through KEY_Z=90
KEY_0=48 through KEY_9=57
KEY_SPACE=32, KEY_ENTER=13, KEY_PAGEUP=33, KEY_PAGEDOWN=34,
KEY_END=35, KEY_HOME=36, KEY_LEFT/UP/RIGHT/DOWN=37/38/39/40
KEY_F1=112 through KEY_F12=123
KEY_COMMA=188, KEY_PERIOD=190, KEY_SLASH/FORWARDSLASH=191
KEY_GRAVE/TILDE=192, KEY_LBRACKET=219, KEY_BACKSLASH=220
KEY_SEMI=186, KEY_RBRACKET=221, KEY_APOSTROPHE=222
KEY_SHIFT=16, KEY_CTRL=17, KEY_ALT=18

Note: KEY_SLASH and KEY_FORWARDSLASH are the same key (191). KEY_GRAVE and KEY_TILDE are the same (192). This means you can't bind backtick and tilde to different actions.

3.5 Event Handling Flow

// On page load, Enhance() registers:
document.addEventListener('keydown', handleKeys, true);
document.addEventListener('keyup', handleKeyup, true);

// handleKeys [line 670]:
function handleKeys(e) {
    if (release) { done = false; release = false; }  // Reset impulse guard
    saveKeyDown();                                     // Save original onkeydown
    shiftHeld = e.shiftKey;                           // Update global modifier state
    ctrlHeld = e.ctrlKey;
    altHeld = e.altKey;
    // Linear scan through bindings array:
    for (var i = 0; i < bindings.length; i++) {
        bind = bindings[i];
        if (e.keyCode == bind.keyCode && bind.modifier(e)) {
            bind.action();  // Execute and RETURN — stops original keydown
            return;
        }
    }
    loadKeyDown();  // Restore original onkeydown if no binding matched
}

Key insight: saveKeyDown() and loadKeyDown() save and restore the page's original document.onkeydown handler. This ensures that non-bound keys (like typing in chat) still work. When a binding matches, the original handler is NOT restored — the action fires instead.

The saveKeyDown() function [line 698] injects a <script> tag that runs: var oldkeydown = document.onkeydown ? document.onkeydown : oldkeydown; document.onkeydown = null;. This nullification blocks the page's built-in keyboard handler (which would normally submit a turn for Space/Enter).

loadKeyDown() restores it with document.onkeydown = oldkeydown;.

3.6 Default Bindings

// Healing
Bind(KEY_SPACE, Any, Strongest([Cast('Cure'), HoverAction(Cast('Cure'), true)]));
Bind(KEY_A, Strongest([Use(4), Cast('Full-Cure'), Cast('Cure')]));          // Normal
Bind(KEY_A, Shift, Strongest([Use(7), Use(4), ...]));                      // Shift+health
Bind(KEY_A, Ctrl/Alt, same);                                                // Ctrl/Alt

// Scrolls/items
Bind(KEY_X, Strongest([Use('s1'), Use('s4'), Use('s2'), Use(2), Use(1)]));
Bind(KEY_X, Shift/Ctrl/Alt, variants with infusions);

// Buff spells
Bind(KEY_C, Any, Cast('Regen'));
Bind(KEY_V, Any, Cast(damage));  // damage = 'Arcane Focus' or 'Heartseeker' (line 746)

// Impulse items
Bind(KEY_Q, Impulse(Use(5)));  // Q = one-shot item 5
Bind(KEY_W, Any, Impulse(Use(3)));
Bind(KEY_E, Impulse(Use(6)));

// Hover toggle
Bind(KEY_Z, Any, ToggleHover);
Bind(KEY_S, Any, Impulse(Toggle('Spirit')));

// Monster targeting (Imperil specific monsters)
Bind(KEY_1, Any, Strongest([TargetMonster(1), Cast('Imperil')]));  // 1 = monster B
Bind(KEY_2, Any, Strongest([TargetMonster(4), Cast('Imperil')]));  // 2 = monster E
Bind(KEY_3, Any, Strongest([TargetMonster(7), Cast('Imperil')]));  // 3 = monster H

// Settings
Bind(KEY_P, Settings);

3.7 Valid Action Types

Action Signature Description
Cast('Spell Name') Finds spell icon by onmouseover substring Case-insensitive spell name
Use('ID') Finds item by element ID ikey_ID 'p' for gem, 1-15 items, s1-s6 scrolls, n1-n6 infusions
Toggle('Type') Finds checkbox by ckey_type Attack, Focus, Defend, Spirit
Nothing No-op Unbind a key or plain attack when used with HoverAction
TargetMonster(N) Clicks monster N 0-based index (A=0, B=1, ..., J=9)
NextRound Clicks btcp + finishbattle button Advances to next battle
Strongest([a1,a2,...]) Backwards-iterating combinator Last action wins for targeted, first for untargeted
HoverAction(action, alert?) Performs action on hover target Second param: true = respect alert interrupts
Impulse(action) One-shot injection into hover rotation Only fires once per turn cycle
ToggleHover Toggles interruptHover flag Pause/resume hover play
Drops Calls ShowDrops(false) Display drop log
CursorUp/Down Move targeting cursor Boundary-clamped
CursorTarget Click monster at cursor Use with Strongest for conditional
CursorHover Engage hover at cursor position
ClearTarget Sets target=false Stop hover targeting
Settings Opens configuration interface

3.8 release/done Guard System

// On keydown [line 671]:
if (release) { done = false; release = false; }

// On keyup [line 691]:
release = true;

This two-flag system prevents Impulse actions from firing twice on a single keystroke: done blocks re-execution until release has been set (on keyup), and on the next keydown, done is cleared. This means holding a key only fires the impulse once.

===========================================================================

4. PROFILE / PERSONA / SET SYSTEM

4.1 Data Structure

cfg
├── settings (top-level defaults from settings object)
├── persona[0..8].settings     (per-persona overrides)
│   └── set[0..6].settings     (per-equipment-set overrides within persona)
└── isekai                      (separate tree for isekai mode)
    ├── settings
    └── persona[0..8].settings
        └── set[0..6].settings

Structure from settings (lines 282-320):

cfg = {
    name: '[persistent]',
    persona: [
        { name: 'persona 1', settings: {}, set: [
            { name: 'set 1', settings: {} },
            ...7 sets per persona
        ]},
        ...9 personas
    ],
    isekai: {
        name: '[isekai]', settings: {},
        persona: [...]  // mirror structure
    }
}

4.2 Config Resolution Order (LoadCfg)

function LoadCfg(p, s, i) {  // p=persona index, s=set index, i=isekai flag
    // Priority (highest first):
    // 1. isekai.persona[p].set[s].settings[setting]        // if i && p && s
    // 2. persistent.persona[p].set[s].settings[setting]    // if !i && p && s (or inherit)
    // 3. isekai.persona[p].settings[setting]               // if i && p
    // 4. persistent.persona[p].settings[setting]           // if !i && p (or inherit)
    // 5. isekai.settings[setting]                          // if i
    // 6. localStorage.HVmbcfg[setting]                     // saved config
    // 7. settings[setting]                                 // script defaults
}

The isekaiInherit flag (line 17, default true): When in isekai mode, if a setting doesn't exist in the isekai profile, it falls through to the persistent persona's equivalent. This means Isekai profiles can inherit everything from persistent, only overriding what differs.

4.3 Profile Storage

// Profile tracking in localStorage:
localStorage.HVmbp = JSON.stringify({
    p:  <persona index>  or  0 for base,
    ip: <isekai persona> or  0 for base,
    s1..s9:  <set index per persona>  or 0,
    is1..is9: <isekai set index per persona> or 0
});

// Config storage:
localStorage.HVmbcfg = JSON.stringify(cfg);

4.4 Auto-Switching Mechanism

function ProfileSwitch() {  // [line 2240]
    if (!cfg.profileAutoswitch) return;
    
    // Detect current persona from page DOM:
    var choice;
    if ((choice = document.querySelector('[name="persona_set"] [selected]'))) {
        profile[(isekai ? 'i' : '') + 'p'] = choice.value;
    }
    // Detect current equipment set from page DOM:
    if ((choice = document.querySelector('[src*="equip/set"][src$="_on.png"]'))) {
        profile[(isekai ? 'i' : '') + 's' + profile[...]] = parseInt(choice.src.match(regexp.number));
    }
    // Persist change:
    if (JSON.stringify(profile) != localStorage.HVmbp) {
        localStorage.HVmbp = JSON.stringify(profile);
    }
}

Trigger: OutOfCombat() [line 2224] calls ProfileSwitch() whenever the user is NOT on a battle page. This means switching persona or equipment set on the character page automatically updates the profile pointer. The next time they enter battle, LoadCfg() reads the updated profile and applies the matching settings.

Battle-time switching: The CfgButton (gear icon during battle) and the SettingsLink (under Character on main page) both show a dropdown menu of configured persona → set trees. Selecting one updates profile in localStorage and calls location.href = location.href for in-battle switching (full page reload to re-apply).

4.5 Isekai Detection

var isekai = document.URL.indexOf('isekai') > -1 ? 'i' : '';  // [line 732]

All localStorage keys are suffixed with the isekai flag:

  • HVcursor vs HVcursori
  • HVtrackdrops vs HVtrackdropsi
  • etc.

This ensures persistent and isekai game modes don't collide in storage.

===========================================================================

5. UI SYSTEM

5.1 cfgInterface (Settings Panel)

The settings interface (Settings() function, line 388) renders a full configuration form into #mainpane. It uses a declarative settingsData array (lines 776-963) where each entry is:

[name, type, label, helpText, width]
// Types: 'h'=header, 'b'=boolean/checkbox, 'i'=integer, 'f'=float,
//        's'=string, 't'=textarea, 'a'=array, 'o'=object

The form dynamically reads from the resolved config chain. Settings that are inherited (unchanged from parent) are shown at 50% opacity. User changes go directly into the appropriate settings object (cfg.persona[p].set[s].settings etc.) using the Change() closure.

Profile selector: A <select> at the bottom shows the persona/set hierarchy with isekai as a separate subtree. The auto checkbox toggles automatic profile switching.

JSON dump mode: A "dump" button renders the entire config as a JSON textarea that can be edited directly — useful for bulk changes or sharing configs.

5.2 CfgButton (In-Battle Gear Icon)

function CfgButton() {  // [line 1704]
    var div = document.createElement('div');
    div.id = 'cfgbutton';
    div.innerHTML = '\u2699';  // Unicode gear symbol
    div.onclick = Settings;
    document.body.appendChild(div);
    // Builds dropdown menu of persona/set tree...
}

The gear icon appears at position: absolute; top: 686px; left: 1220px (or adjusted for condenseLeft). Hovering it reveals a dropdown (#mbprofile) with the profile tree. Selecting a profile triggers a location.reload() so the new config takes effect.

5.3 Quickbar Extension

cfg.quickbarExtend: array of IDs
// 0 = space, 1 = gem, string = spell/skill/item ID

The ExtendQuickbar() function (line 1429) creates additional quickbar buttons:

  • Spell icons: finds the DOM element by ID, extracts the icon name from the onmouseover attribute via regexp.spellicon (, '(\w+)'), maps to /y/a/{name}.png
  • Item icons: parses item name (works with the "default font" obfuscation), maps known item name substrings to icon filenames (e.g., 'ealth' → healthpot.png, 'ana' → manapot.png, 'pirit' → spiritpot.png)
  • Gem (ID=1): uses gem[] array populated by Gems() function
  • Usable highlighting: potions on the quickbar get the usable CSS class when the player's MP/SP is low enough for full potion value, triggering blink animation

5.4 Cooldowns Display

function ShowCooldowns() {  // [line 1631]
    var buttons = quickbar.querySelectorAll('.btqs[onmouseover]:not([onclick])');
    // For each button WITHOUT onclick (on cooldown):
    //   Parse onmouseover for spell info: regexp.spellinfo = /\('([\w\s-]+)'.*, (\d+)\)/
    //   Check timelog.lastuse[spellName] vs timelog.turn
    //   Display remaining cooldown turns as an overlay div.cooldown
}

The cooldown system works by tracking which spell was used on which turn (timelog.lastuse), then comparing against the spell's cooldown from the onmouseover attribute. The formula [line 1637]: cooldown = spellCooldown - currentTurn + lastUsedTurn.

The cooldown number is overlaid on the quickbar button with z-index: 3 and style color: black; font-size: 20px; font-weight: bold.

5.5 Alert Colours System

Configured via cfg.colours object (lines 41-62) and cfg.alertColours + cfg.alertBackground flags.

Alert conditions checked in Alerts() [line 1196]:

Condition Trigger Colour
Spark of Life fallenshield.png present but no bar_dgreen.png cfg.colours.spark (magenta)
Low HP HP bar width ≤ threshold × bar width cfg.colours.lowhp (deeppink)
Low MP MP ratio ≤ cfg.minMP cfg.colours.lowmp (darkslateblue)
Low SP SP ratio ≤ cfg.minSP cfg.colours.lowsp (indigo)
OC Full Overcharge bar ≥ 100% cfg.colours.ocfull (mediumspringgreen)

Background target:

  • alertBackground=true: colours the full #csp element
  • alertBackground=false: colours #pane_vitals and spirit stance button individually

Buff expiry alerts (in Durations()):

  • Tests cfg.alertBuffs regex against effect icon filenames
  • Triggers when any matching buff has < 2 turns remaining
  • Colour: cfg.colours.expiring (lightblue)
  • Also triggers interruptAlert when cfg.stopOnBuffsExpiring

Channelling detection: If channeling.png icon is present in player effects, background changes to cfg.colours.channelling (aquamarine).

Alert priority chain (visual):

  1. Spark/low vitals colour (highest priority)
  2. Buffs expiring colour
  3. Channelling colour
  4. OC full colour (spirit button only)
  5. Default colour

5.6 Additional UI Features

Durations display: Overlays turn counts on effect icons as .effect_duration divs. Stack count shown either as border thickness (stackBorder=true) or as "xN" text.

Log colours: cfg.logColours adds CSS classes to battle log rows based on regex matching:

  • .miss (evade/block/parry), .damage, .item, .attack, .spell, .recovery, .effect, .spirit, .proficiency

Turn dividers: cfg.turnDividers inserts <hr> between turns in the battle log.

Round counter: cfg.showRound displays "Round N / Total" in the battle area. cfg.bigRoundCounter shows it large in the top-right.

Monster HP display: cfg.showMonsterHP calculates and displays HP numbers next to monsters using monsterData.hp[i] parsed from the battle log and the current bar width ratio.

Monster shortening bars: cfg.shortenHPbars scales monster HP bar widths relative to the monster with highest max HP in the round.

Monster numbers: cfg.monsterNumbers replaces monster letter icons with numbers (1-10).

Monster highlighting: cfg.monsterKeywords highlights monsters matching a regex by setting their background to cfg.colours.monster.

Monster status colours: Stunned (wpn_stun.png) → cfg.colours.stun, Imperilled (imperil.png) → cfg.colours.imperil, Both → cfg.colours.stunimperil.

===========================================================================

6. OUT-OF-BATTLE FEATURES (CrunkJuice Integration)

6.1 The OutOfCombat() Function

function OutOfCombat() {  // [line 2224]
    DeleteLog();
    ProfileSwitch();
    SettingsLink();
}

Called only when NOT on a battle page (no #textlog or #riddlemaster). This is the entry point for all out-of-combat functionality. Note: CrunkJuice is a separate companion script — Monsterbation itself only has these three out-of-battle functions. The full non-combat automation (ED confirm, sell all, monster feeding, etc.) lives in CrunkJuice.

function SettingsLink() {  // [line 2250]
    // Inserts "Monsterbation Settings" link under the Character sidebar
    // Uses the game's custom font system (c5m, c5o, c5n, c5s, etc.)
    // to spell out "MONSTERBATION SETTINGS" in the default font
    // Also builds the profile dropdown menu
}

Default font trick: The game uses a CSS-based obfuscation where letter classes like .c5m render as specific characters. Monsterbation constructs the settings link text using these classes (lines 2264-2285) so it matches the game's visual style.

Profile dropdown: Same persona → set → isekai tree as the in-battle CfgButton, but positioned under the settings link instead of the gear icon. Selection updates localStorage.HVmbp.

6.3 DeleteLog()

function DeleteLog() {  // [line 2230]
    // Clears temporary localStorage when navigating away:
    localStorage.removeItem('HVmonsterData' + isekai);
    localStorage.removeItem('HVtimelog' + isekai);
    localStorage.removeItem('HVvitals' + isekai);
    localStorage.removeItem('HVcursor' + isekai);
    // Configurable deletion of drop log and combat log
    // 0: never, 1: when leaving battle section, 2: at end of battle
}

6.4 CrunkJuice Features (Separate Script, NOT in this file)

Based on the changelog and analysis document, CrunkJuice provides:

  • ED confirm (Energy Drink confirmation dialog)
  • Faster "sell all" button
  • Monster morale/hunger display
  • Feed pills/crystals to all monsters
  • Monster database search
  • RE timer/counter
  • Quality filter in bazaar
  • Arena page auto-open

These are NOT in the Monsterbation source — they're in a separate script that users run alongside Monsterbation.

===========================================================================

7. localStorage USAGE PATTERNS

7.1 Complete localStorage Keyspace

Key Type Purpose Persistence
HVmbcfg JSON string Full configuration (merged settings + persona tree) Permanent (saved from Settings panel)
HVmbp JSON string Current profile selection {p, ip, s1..s9, is1..is9} Permanent (auto-saved)
HVmonsterData{i} JSON string Parsed monster IDs, names, HP values, highlights Temporary (cleared: battle end, navigation)
HVtimelog{i} JSON string Turn counter, action counter, round number, spell last-use timestamps Temporary + stored via beforeunload
HVcombatlog{i} JSON string Full combat stats (damage dealt/taken by element, miss/evade/parry/etc.) Semi-persistent (configurable: 0/1/2)
HVtrackdrops{i} JSON string Drop tracking (Crystals, Equips, Mats, Artifacts, etc.) Semi-persistent (configurable: 0/1/2)
HVvitals{i} JSON string Maximum HP/MP/SP values seen Semi-persistent
HVcursor{i} Integer Last cursor position (0-9 for monster targeting) Temporary (cleared: battle end, navigation)

The {i} suffix is 'i' for isekai mode, '' for persistent.

7.2 Write Timing

On every turn (in Observe() [line 1095]): MonsterData and combat stats are kept in memory; no localStorage write (performance-critical path).

On page unload (StoreTmp(), beforeunload event [line 2401]): Temporary state is flushed to localStorage. This ensures monster HP data, timers, and combat stats survive a page refresh or browser crash.

On battle end (in Observe() at finishbattle check):

  • HVmonsterData, HVtimelog, HVvitals, HVcursor → removed (battle is over)
  • HVtrackdrops → either removed or saved based on cfg.deleteDropLog
  • HVcombatlog → either removed or saved based on cfg.deleteCombatLog
  • Dispatches CustomEvent("battleEnd") with timelog, combatlog, droplog data

On navigation away (in DeleteLog() [line 2230]): Conditional cleanup based on deleteDropLog and deleteCombatLog — setting 1 deletes when URL no longer contains "Battle".

On profile change (CfgButton click / SettingsLink click): HVmbp updated immediately.

On settings save (Save() in Settings panel): HVmbcfg written; HVmbcfg can also be removed (Reset button).

7.3 The beforeunload Trick

window.addEventListener('beforeunload', StoreTmp);  // [line 2401]

The beforeunload handler fires when the page is about to be replaced (new battle round via normal navigation) or closed. It saves all in-memory state to localStorage so the next page load can restore it. This is how monster data persists across rounds without re-parsing the full battle log on each turn.

7.4 Data Isolation Between Persistent and Isekai

All keys use the isekai suffix ('' or 'i'), ensuring that playing in persistent mode and isekai mode don't interfere with each other's stats, cursor positions, or monster data. The profile tracking (HVmbp) uses separate fields within the same JSON object: p vs ip, s1 vs is1.

7.5 Storage Size Considerations

The HVcombatlog can grow large on long battles (Arenas, Grindfests, Item World). It tracks per-element damage for four categories (pdealt, mdealt, ptaken, mtaken) with sub-categories for spirit shield absorption. The deleteCombatLog setting (0/1/2) controls cleanup to manage storage usage.

===========================================================================

8. JPX INTEGRATION (AJAX Round Advance)

8.1 The ajaxRound Feature

// settings (line 74):
ajaxRound: true,  // advance to next round using ajax
                  // set to false if you use other scripts that do not support this

8.2 Implementation

function NoPopup() {  // [line 2187]
    if (!(btcp = document.getElementById('btcp'))) return;
    if (cfg.ajaxRound) {
        btcp.onclick = function() {
            var x = new XMLHttpRequest();
            x.onreadystatechange = function() {
                if (x.readyState == XMLHttpRequest.DONE) {
                    if (x.status == 200) {
                        var doc = (new DOMParser()).parseFromString(x.responseText, 'text/html');
                        document.body.innerHTML = doc.body.innerHTML;
                        // Re-inject battle scripts:
                        var script = document.createElement('script');
                        script.type = 'text/javascript';
                        if (doc.getElementById('riddlemaster')) {
                            // Special handling for RiddleMaster pages
                            script.innerHTML = doc.getElementsByTagName('script')[2].innerHTML
                                .replace('e("riddleanswer").value = "?";', '')
                                .replace('e("riddleform").submit();', '');
                        } else {
                            script.innerHTML = 'var t = setTimeout(function(){}, 0); for (var i = t; i > 0' +
                                (cfg.ajaxIntervals ? ' && i > t - ' + cfg.ajaxIntervals : '') +
                                '; i--) clearInterval(i); battle = new Battle();';
                        }
                        document.getElementById('mainpane').appendChild(script);
                        var event = new Event('DOMContentLoaded');
                        document.dispatchEvent(event);
                    }
                    // Error handling: alert on failure
                }
            };
            x.open('GET', document.location.href, true);
            x.send();
        };
    }
    // Fallback: normal click if ajax not enabled
    if (cfg.noPopup && ...) {
        btcp.click();  // Normal form submit → full page reload
    }
}

8.3 How AJAX Round Advance Works

  1. Intercept the "Next Round" button: The #btcp element's onclick is replaced with an AJAX handler
  2. Fetch the current page URL via XMLHttpRequest: This returns the HTML for the next round
  3. Replace entire document.body with the new page's body via innerHTML
  4. Re-initialize the game engine: Inject a <script> that calls battle = new Battle() — this re-creates the game's JavaScript state on the new page without a full page load
  5. Clear stray intervals: clearInterval(i) loop clears any timers that might have been set by the previous page's code
  6. Dispatch DOMContentLoaded: So other scripts (like jpx) see the new page as freshly loaded
  7. RiddleMaster special case: When a riddle appears between rounds, the script removes the auto-submit code so the riddle doesn't auto-answer with "?"

Critical for inter-script compatibility (line 74 comment): "set to false if you use other scripts that do not support this". When Monsterbation replaces the entire body via AJAX without a full page reload, other userscripts that run at document-start might not re-trigger. Scripts that expect @run-at document-start or DOMContentLoaded need to handle this. The document.dispatchEvent(new Event('DOMContentLoaded')) on line 2208-2209 is the compatibility bridge — it fires a synthetic DOMContentLoaded event that other scripts can listen for.

8.4 The jpx Compatibility Note

The comment at line 74 — "supports jpx AJAX round advance" — means that when cfg.ajaxRound = true, jpx should be able to detect the new round via the synthetic DOMContentLoaded event. The ajaxIntervals setting (line 75, default 100) controls a timing parameter: cfg.ajaxIntervals = 100 means the interval clearing loop on line 2205 runs for (var i = t; i > 0 && i > t - 100; i--) — clearing only the 100 most recent intervals rather than ALL intervals. Higher values clear more but may cause more flashing; 0 clears none.

8.5 logPasteover Feature

// settings (line 66):
logPasteover: false  // add last turn of previous round to new round log. requires ajaxRound

Implementation in Enhance() [line 970-972]:

if (cfg.logPasteover && turn) {
    log.firstChild.innerHTML += '<tr><td class="tls"></td></tr>' + turn;
    FormatLog();
}

When using AJAX rounds, the battle log is replaced each round. logPasteover preserves the last turn's log entries from the previous round and appends them to the new round's log, providing continuity. The turn variable is extracted in ProcessLog() [line 1549] and persists because it's on the global scope.

===========================================================================

9. BATTLE LOG PARSING — THE OBSERVE SYSTEM

9.1 MutationObserver Pattern

// In Enhance() [line 994]:
var obs = new MutationObserver(Observe);
obs.observe(log.firstChild, {childList: true});

Monsterbation watches the battle log's first child for DOM changes. When the server returns a new turn, the game updates the log HTML, the MutationObserver fires Observe(), and Monsterbation processes the new state.

9.2 Observe() Flow

Observe() called [line 1095]:
  ↓
  Check for finishbattle.png → if present: battle is OVER
    → ProcessLog(), FormatLog(), TrackDrops(), Profbar()
    → ShowDrops(true), ShowUsage(), ShowDamage()
    → Dispatch CustomEvent("battleEnd")
    → Clean localStorage, auto-dismiss popup
  ↓
  If still in battle:
    → hovering = false  // Reset hover guard
    → Gems()            // Re-parse gem state
    → Alerts()          // Check HP/MP/SP/Spark
    → Durations()       // Update effect timers
    → Monsters()        // Re-attach event listeners to new monsters
    → Confirm()         // Add ED/flee confirmation
    → ExtendQuickbar()  // Rebuild extended quickbar
    → ProcessLog()      // Parse combat stats from new log line
    → ShowCooldowns()   // Update cooldown overlays
    → MaxVitals()       // Track max HP/MP/SP
    → FormatLog()       // Apply log colours
    → TrackDrops()      // Parse drops from log
    → Profbar()         // Update proficiency sidebar
    → NoPopup()         // Set up AJAX round advance

9.3 ProcessLog() Regex Engine

The battle log parsing uses a comprehensive set of named regex patterns (lines 754-774):

regexp.turn    = /(.+?)<tr><td class="tls">/    // Extract full turn text
regexp.action  = />([^<>]+)<\/td><\/tr>...       // "You cast Imperil" etc.
regexp.use     = /You (cast|use) ([\w\s-]+)/    // Identify spell/item use
regexp.damage  = /[^<>]+damage( \(.../           // Extract damage lines
regexp.type    = /for (\d+) (\w+) damage/        // "for 12345 fire damage"
regexp.crit    = /(You crit| crits | blasts )/    // Critical hit detection
regexp.miss    = /(You evade|You block|...)/      // Miss/evade/parry/resist

9.4 Speed Tracking

// In ProcessLog() [line 1552]:
if (!timelog.startTime && cfg.trackSpeed) timelog.startTime = Date.now();
// ... counts timelog.turn and timelog.action ...
// At battle end (ShowDrops):
var speed = (timelog.action * 60000 / (Date.now() - timelog.startTime)).toFixed(1);

Calculates actions-per-minute from the wall clock, displayed at battle end.

===========================================================================

10. ARCHITECTURAL PATTERNS SUMMARY

10.1 The "Click Elements, Not POST" Principle

Every action submission goes through DOM element clicking:

  • Spells: document.querySelector('.bts > div[onclick][onmouseover*="SpellName"]') → dummy trick → click
  • Items: document.getElementById('ikey_X') → dummy trick → click
  • Toggles: document.getElementById('ckey_spirit') → dummy trick → click
  • Monster attacks: monsters[i].click() directly
  • Next round: document.getElementById('btcp').click() or AJAX intercept

NO raw HTTP requests for gameplay actions — the only HTTP request is the AJAX round advance, which fetches the current URL (mirroring a normal page load).

10.2 The "Parse DOM, Not HTTP" Principle

All game state is extracted from the DOM:

  • Monster alive/dead: monsters[i].hasAttribute('onclick')
  • HP/MP/SP: Bar image widths divided by 414 (or 207 for isekai)
  • Overcharge: Orange bar width or #vcp > div width
  • Spark of Life: Presence of fallenshield.png without bar_dgreen.png
  • Buffs/Debuffs: Parsing onmouseover attributes on effect icon <img> tags
  • Spell cooldowns: Parsing onmouseover on quickbar buttons, cross-referencing with timelog.lastuse
  • Monster data: Parsing the battle log's initial HTML for MID and HP values
  • Drops: Regex parsing the end-of-battle textlog

10.3 The "Global MutationObserver" Pattern

Instead of polling or hooking into the game's JavaScript (which changes between updates), Monsterbation watches the battle log's DOM for changes. This is more reliable than trying to hook into the game's internal Battle class, which could change structure between HV updates.

10.4 The "Eval String Config" Pattern

Certain config values are stored as strings and eval()'d at init:

cfg.hoverAction = eval("Strongest([Cast('Ragnarok'), ...])");
cfg.clickRight = eval("Strongest([Cast('FUS RO DAH'), ...])");
cfg.bind = eval("Bind(KEY_SPACE, Any, ...); Bind(KEY_Z, ...); ...");

This allows users to write arbitrary JavaScript in their config strings, but the script includes validation to prevent Use() inside HoverAction() (the "Fearsome powers thrust Laputa into orbit" error on lines 420-422).

10.5 The "RiddleMaster as Exit Condition" Pattern

function Riddlemaster() {  // [line 1146]
    var bot;
    if (!cfg.clickableRiddlemaster || !(bot = document.getElementById('riddlebot'))) return;
    // Creates clickable A/B/C answer buttons on the riddle
    // Also increments a "horse" counter in timelog for tracking riddle frequency
}

RiddleMaster detection is a core compliance mechanism. When a riddle appears, speed tracking counts it (timelog.horse++), and the script can add clickable answer buttons. But it does NOT auto-solve — user must click.

10.6 The Global State Machine

// Critical state variables (line 732-745):
var target          = false;       // Current hover target monster index
var interruptHover  = undefined;   // Hover enabled/disabled (bool)
var interruptAlert  = false;       // Emergency alert active (bool)
var hovering        = false;       // Guard against re-entrant Hover()
var override        = false;       // Mouse-engage override action
var impulse         = false;       // Impulse one-shot action
var done            = false;       // Impulse already-fired guard
var release         = false;       // Key/mouse release flag
var shiftHeld       = false;       // Global shift key state
var ctrlHeld        = false;       // Global ctrl key state
var altHeld         = false;       // Global alt key state
var cursor          = -1;          // Targeting cursor position

These globals implement a simple state machine governing all user interactions. The states interact according to strict precedence rules in Hover() and handleKeys(), creating deterministic behavior from concurrent inputs.

===========================================================================

11. RULES COMPLIANCE IMPLICATIONS

11.1 One User Input = One Turn

Monsterbation's architecture inherently enforces this rule:

  • Each monsters[target].click() = one turn submission
  • Hover fires once per mouseover event (guarded by hovering flag)
  • Key presses fire bound actions once per keydown (guarded by release/done)
  • The MutationObserver waits for the server to respond before re-enabling interaction

11.2 What the Script Deliberately Does NOT Do

  • NO auto-start: No code to click arena/battle entrance buttons
  • NO auto-feed: Monster feeding is in CrunkJuice (separate script), not here
  • NO auto-solve RiddleMaster: Only adds clickable A/B/C buttons, does not pick answers
  • NO multi-action: Each user action produces exactly one server round-trip
  • NO raw HTTP POSTs: All actions go through DOM clicks

11.3 Grey Areas

  • AJAX round advance: cfg.ajaxRound auto-fetches the next round without user clicking "Next Round." The comment at line 74 explicitly says "set to false if you use other scripts that do not support this" — the feature was controversial enough to warrant an off switch and inter-script compatibility warning.
  • cfg.noPopup with !cfg.stopAtBattleEnd: Automatically dismisses the end-of-battle popup and enters next round (line 1125-1128). This bridges one battle to the next without user input, though the user must still perform the first action of the new battle.
  • cfg.stopOnEquipDrop: The one concession — stops auto-dismissal when valuable equipment drops.

===========================================================================