hv-unified/src/strategy-engine.js
GaboGG 23f00450be v0.13.14 - Fix observer crash, raise channeling threshold to 30
- 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
2026-07-26 13:53:01 -04:00

703 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════════════════
// STRATEGY ENGINE — tier-based action recommendation
// ═══════════════════════════════════════════════════════════════════════
// ── Spell helpers ──
function hasSpell(name) {
return STATE.spellsKnown.some(s => s.n === name);
}
function findSpell(name) {
return STATE.spellsKnown.find(s => s.n === name);
}
function findDmgSpell(list) {
const s = STATE.spellsKnown.find(sp => list.includes(sp.n));
return s ? s.n : null;
}
// ── Buff helpers ──
// Returns the buff duration from STATE.buffs, or 0 if not present
function buffDuration(name) {
return STATE.buffs[name] || 0;
}
// Should we cast this buff? Only if missing OR about to expire (< threshold turns)
function shouldBuff(name, minTurns) {
const dur = buffDuration(name);
return dur === 0 || dur < minTurns;
}
// Known buffs with their priority, icon name, spell name, and refresh threshold
// NOTE: Heartseeker and Arcane Focus are mutually exclusive. For a 2H (physical) build,
// Heartseeker is preferred (+25% phys dmg, +10% crit). Arcane Focus is for magic builds.
const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 4 }, // 100% uptime: refresh well before expiry
{ icon: 'protection', spell: 'Protection', minTurns: 4 },
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 3 }, // icon: sparklife.png
{ icon: 'heartseeker', spell: 'Heartseeker', minTurns: 6 }, // Long duration, refresh at 6 to avoid gap
{ icon: 'regen', spell: 'Regen', minTurns: 4 },
{ icon: 'absorb', spell: 'Absorb', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 4 },
];
// Known important monster names (bosses, legendaries, ultimates)
const RARE_MONSTERS = [
'manbearpig', 'white bunneh', 'mithra', 'dalek',
'konata', 'mikuru asahina', 'ryouko asakura', 'yuki nagato',
'skuld', 'urd', 'verdandi', 'yggdrasil',
'rhaegal', 'viserion', 'drogon',
'real life', 'invisible pink unicorn', 'flying spaghetti monster',
'recycled boss rush', 'bottomless dungeon', 'new game +',
'achievement grind', 'time trial mode', 'hardcore mode',
];
function isRareMonster(idx) {
const m = STATE.monsters[idx];
if (!m) {
// Fallback: check battle log names
return STATE.battleMonsterNames.some(name =>
RARE_MONSTERS.some(r => name.includes(r))
);
}
// Boss/rare monsters have distinct visual cues:
// 1. Gold border: style="border-color:#BD7400"
// 2. Gold background on label: style="background:#E6CCA3"
// NOTE: At high levels/difficulties, normal monsters also get SP bars,
// so we can't use nbarred.png as a boss indicator anymore.
const style = m.getAttribute('style') || '';
const inner = m.innerHTML || '';
// Check for gold border (boss indicator — still reliable)
if (style.includes('BD7400') || style.includes('E6CCA3')) return true;
// Fallback: check name from DOM
const n = m.querySelector('.btm3');
if (!n) return false;
const name = (n.textContent || '').replace(/^\d+\s*/, '').trim().toLowerCase();
// Use CSS text parser for boss names rendered in font
const cssName = readCSSText(n);
return RARE_MONSTERS.some(r => name.includes(r) || cssName.includes(r));
}
function anyRareMonster() {
return STATE.monsters.some((m, i) => isRareMonster(i));
}
function findWeakestMonster() {
let best = -1, bestW = Infinity;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const hp = m.querySelector('img[src$="nbargreen.png"]');
const w = hp ? parseInt(hp.style.width || '') : 120;
if (w < bestW && w > 0) { bestW = w; best = i; }
});
return best >= 0 ? best : 0;
}
function findStrongestMonster() {
let best = -1, bestW = -1;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const hp = m.querySelector('img[src$="nbargreen.png"]');
const w = hp ? parseInt(hp.style.width || '') : 120;
if (w > bestW) { bestW = w; best = i; }
});
return best >= 0 ? best : 0;
}
function checkMonsterDebuff(idx, db) {
const m = STATE.monsters[idx];
if (!m) return false;
const s = m.querySelector('.btm6');
if (!s) return false;
return s.innerHTML.toLowerCase().includes(db.toLowerCase() + '.png') ||
s.innerHTML.toLowerCase().includes('wpn_' + db.toLowerCase());
}
// ── Damage spell tier list ──
const SPELL_T3 = ['Ragnarok', 'Paradise Lost', 'Flames of Loki', 'Fimbulvetr', 'Wrath of Thor', 'Storms of Njord'];
const SPELL_T2 = ['Disintegrate', 'Banishment', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst'];
const SPELL_T1 = ['Corruption', 'Smite', 'Fiery Blast', 'Freeze', 'Shockblast', 'Gale'];
function getBestDamageSpell() {
if (STATE.monsters.length >= 2) {
const aoe = findDmgSpell(SPELL_T3.concat(SPELL_T2));
if (aoe) return aoe;
}
return findDmgSpell(SPELL_T3.concat(SPELL_T2, SPELL_T1));
}
// ── Channeling maintenance ──
//
// Channeling: procs randomly when casting any mana-costing spell.
// Proc chance = spell_cost / (base_mana * 1.2)
// Cannot proc from spells cast while already Channeling.
// Effect: next spell costs 1 MP and is 50% stronger (or 50% longer for buffs)
// Duration: 5 ticks (15 with Mystic Gem in P-slot)
//
// Best trigger spells: Regen/Absorb (17 MP = ~6% proc chance each cast)
// Cheaper spells have proportionally lower proc chance.
// Checks if we're in the initial buff-up phase (most buffs are missing)
function isFirstRoundBuffs() {
let missing = 0, total = 0;
for (const b of BUFF_PRIORITY) {
if (!hasSpell(b.spell)) continue;
total++;
if (buffDuration(b.icon) === 0) missing++;
}
return total > 0 && missing >= total * 0.6; // 60%+ of our buffs are down
}
// Round 1 strategy: cast buffs from cheapest to most expensive.
// - Cheaper spells first = more casts before channeling = more proc chances
// - If channeling procs mid-sequence, switch to most expensive remaining buff
// Returns the next buff to cast, or null if all buffs are up.
function castInitialBuffs() {
if (!isFirstRoundBuffs()) return null;
if (STATE.mp < 0.20) return null;
// Collect all missing/expiring buffs with their MP cost
const candidates = [];
for (const b of BUFF_PRIORITY) {
if (!hasSpell(b.spell)) continue;
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 3) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
candidates.push({ ...b, mpCost: sp ? sp.mp : 999, dur });
}
}
if (candidates.length === 0) return null;
if (STATE.channeling) {
// Channeling is charged — cast the MOST expensive buff for best value
candidates.sort((a, b) => b.mpCost - a.mpCost);
} else {
// No channeling — cast the CHEAPEST buff first for more proc attempts
candidates.sort((a, b) => a.mpCost - b.mpCost);
}
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
function kickstartChanneling() {
if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance.
// Higher MP cost = higher channeling proc chance.
// Regen/Absorb/Heartseeker have the best cost/proc ratio among buffs.
if (STATE.mp > 0.20) {
// Collect all buffs that need refreshing, sorted by MP cost descending
const candidates = [];
for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon);
if ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
candidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
}
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.mpCost - a.mpCost);
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
}
return null;
}
// ── Channeling consumption — pick the best value from fill level × cost ──
// Channeling gives: 1 MP cost + 50% longer duration.
// The best target is the buff where:
// - It's nearly empty (close to expiring relative to its max duration)
// - It costs a lot of MP (more savings from the free cast)
//
// Score = emptiess_ratio × mp_cost
// = (max_dur - current) / max_dur × mp_cost
//
// A 100-MP buff with 90 turns max at 1 turn remaining: (90-1)/90 * 100 = 98.9
// A 40-MP buff with 50 turns max at 25 remaining: (50-25)/50 * 40 = 20.0
// A 140-MP buff with 80 turns max at 71 remaining: (80-71)/80 * 140 = 15.75
// A freshly cast buff always scores near 0.
//
// max_dur is tracked dynamically per buff in STATE.maxBuffDur.
// It updates each time we see a new high, learning your actual duration.
function findBestChannelingTarget() {
if (!STATE.channeling || STATE.mp < 0.10) return null;
const candidates = [];
for (const b of BUFF_PRIORITY) {
if (!hasSpell(b.spell)) continue;
const dur = buffDuration(b.icon);
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
const mpCost = sp ? sp.mp : 0;
// Fill level: how empty is this buff relative to its max observed duration
const maxDur = STATE.maxBuffDur[b.icon] || (dur + 10); // fallback if unknown
const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1;
// Score: emptiness × mp_cost — an empty expensive buff is best
const score = emptiness * mpCost;
candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur });
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
// Only use a buff if it actually needs refreshing (score >= 30 means
// e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell)
if (candidates[0].score >= 30) {
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
}
// Fallback: ANY damage spell rather than waste the charge on basic attack
// Try elementals first (cheapest), then any spell we know
const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale',
'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst',
'Banishment', 'Disintegrate']);
if (cheap) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: cheap, target: t };
}
// Last resort: any spell we know that costs MP
for (const sp of STATE.spellsKnown) {
if (sp.mp > 0 && sp.mp < (STATE.mp * 100)) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: sp.n, target: t };
}
}
return null;
}
// ── Core strategy: forum-corrected item-before-spell priority ──
//
// Priority order:
// 1. Mystic Gem if channeling is down (free OC gen)
// 2. Health/Mana/Spirit gems proactively (don't hoard)
// 3. Cure if HP critical
// 4. Buffs (Protection, Regen, Haste)
// 5. Debuffs (Imperil, Weaken)
// 6. Spirit Stance at OC 60-80% (don't waste OC on skills before stance)
// 7. Weapon skills only at high OC (80+) to let OC build
// 8. Damage spells
// 9. Basic attack builds OC naturally
function getRecommendedAction() {
parseBattleState();
if (STATE.tier === 'novice') return strategyNovice();
if (STATE.tier === 'adept') return strategyAdept();
return strategyVeteran(); // Veteran+ use the same engine
}
// Returns non-null ONLY when there's something smarter than basic attack
function getSmartAction() {
const a = getRecommendedAction();
if (!a || a.type === 'attack') return null;
return a;
}
// ── Novice (1-50): survival first ──
function strategyNovice() {
// 0. Mystic Gem for channeling — free OC
// (channeling status is tracked from the battle log)
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling)
return { type: 'item', id: gem.id, selfTarget: true };
// 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure
if (STATE.hp < CFG.cureHP) {
const c = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true };
}
// 3. Buffs — cast if missing or about to expire
// On first round, cast cheapest-first to proc channeling
{
const ib = castInitialBuffs();
if (ib) return ib;
}
// If channeling is active, prioritize most expensive buff for best value
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
// 4. Mana/spirit items — AFTER buffs (Novice)
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Spirit gem (P-slot) — use immediately when available (free to refill)
const pGem = document.getElementById('ikey_p');
const pGemId = pGem ? (pGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
if (pGemId && pGemId[1] === '10007') {
// P-slot has a Spirit Gem (10007) — use it
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
}
// Spirit draughts/potions — only when SP drops below threshold
if (STATE.sp < CFG.spiritPotionSP) {
// Check non-P-slot spirit items only
for (const [slot, info] of Object.entries(STATE.itemsKnown)) {
if (slot === 'p') continue;
const def = ITEMS[parseInt(info)];
if (def && def.t === 'spirit') {
return { type: 'item', id: slot, selfTarget: true };
}
}
}
// 5. Spirit Stance at OC 60 — but only if we have SP to sustain it
if (STATE.oc >= 60 && !STATE.spiritStance && STATE.sp > 0.10)
return { type: 'toggle_spirit' };
// 6. Weapon skills only at OC thresholds with specific conditions
const skillOC = CFG.skillOC || 80;
if (STATE.oc >= skillOC) {
// Great Cleave: boss/rare fights only
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
}
// Rending Blow: AoE armor pen vs 5+ enemies
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
}
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t };
}
}
}
// 6. Damage spells (reserve MP for Cure)
const dmg = CFG.useAttackSpells ? findDmgSpell(SPELL_T1) : null;
if (dmg && STATE.mp > 0.25) {
const cure = findSpell('Cure') || findSpell('Full-Cure');
const cureCost = cure ? (cure.mp / 100) * (STATE.level || 1) : 4;
const mpMax = STATE.mp * 100;
const reserve = Math.max(0.15, cureCost / mpMax);
if (STATE.mp - reserve > 0.25) {
const t = findWeakestMonster();
if (t >= 0) return { type: 'spell', name: dmg, target: t };
}
}
// 7. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// If channeling is charged, consume it on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t };
return null;
}
// ── Adept (50-150): building power ──
function strategyAdept() {
// 0. Mystic Gem for channeling — free OC
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling)
return { type: 'item', id: gem.id, selfTarget: true };
// 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure
if (STATE.hp < CFG.cureHP) {
const c = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true };
}
// 3. Buffs
// On first round, cast cheapest-first to proc channeling
{
const ib = castInitialBuffs();
if (ib) return ib;
}
// If channeling is active, prioritize most expensive buff for best value
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
// 4. Mana/spirit items — AFTER buffs (Adept)
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Spirit gem (P-slot) — use immediately
const apGem = document.getElementById('ikey_p');
const apGemId = apGem ? (apGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
if (apGemId && apGemId[1] === '10007') {
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
}
// Spirit draughts/potions — only at SP < threshold
if (STATE.sp < CFG.spiritPotionSP) {
for (const [slot, info] of Object.entries(STATE.itemsKnown)) {
if (slot === 'p') continue;
const def = ITEMS[parseInt(info)];
if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true };
}
}
// 4. Debuffs — only on actual boss fights (gold border style)
// At high levels/difficulties, all monsters have SP bars, so
// we only debuff when a gold-bordered rare/boss is present.
if (anyRareMonster()) {
if (hasSpell('Imperil')) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
return { type: 'spell', name: 'Imperil', target: b };
}
if (hasSpell('Weaken')) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
return { type: 'spell', name: 'Weaken', target: b };
}
}
// 5. Weapon skills — before spirit stance
const skillOC_N = CFG.skillOC || 75;
if (STATE.oc >= skillOC_N) {
// Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
}
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t };
}
}
// Great Cleave: boss/rare fights only (single target, lower priority than AoE)
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
}
}
// 6. Spirit Stance — manual only (no auto-activation)
// Spark of Life consumes 50% SP when triggered. Auto-disable if SP
// drops below 65% to ensure Spark always has enough SP.
if (STATE.spiritStance && STATE.sp < 0.65 && !STATE._spiritCooldown) {
STATE._spiritCooldown = 5;
return { type: 'toggle_spirit' };
}
if (STATE._spiritCooldown > 0) STATE._spiritCooldown--;
// 7. Damage spells
const dmg = CFG.useAttackSpells ? getBestDamageSpell() : null;
if (dmg && STATE.mp > 0.2) {
const t = STATE.monsters.length > 1 ? findWeakestMonster() : findStrongestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: dmg, target: t };
}
// 8. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// Use channeling charge on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t };
return null;
}
// ── Veteran (150-300) / Master (300+): full rotation ──
function strategyVeteran() {
// 0. Mystic Gem for channeling
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling)
return { type: 'item', id: gem.id, selfTarget: true };
// 1. Health items — safe to use early
if (STATE.hp < 0.60) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure
if (STATE.hp < CFG.cureHP) {
const c = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true };
}
// 3. Buffs
// On first round, cast cheapest-first to proc channeling
if (CFG.autoBuff) {
{
const ib = castInitialBuffs();
if (ib) return ib;
}
// If channeling is active, prioritize most expensive buff for best value
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
// 4. Mana/spirit items — AFTER buffs (Veteran)
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Spirit gem (P-slot) — use immediately
const vpGem = document.getElementById('ikey_p');
const vpGemId = vpGem ? (vpGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
if (vpGemId && vpGemId[1] === '10007') {
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
}
// Spirit draughts/potions — only at SP < threshold
if (STATE.sp < CFG.spiritPotionSP) {
for (const [slot, info] of Object.entries(STATE.itemsKnown)) {
if (slot === 'p') continue;
const def = ITEMS[parseInt(info)];
if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true };
}
}
// 4. Debuffs — only on actual boss fights (gold border)
if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) {
if (hasSpell('Imperil')) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
return { type: 'spell', name: 'Imperil', target: b };
}
if (hasSpell('Weaken')) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
return { type: 'spell', name: 'Weaken', target: b };
}
}
// 5. Spirit Stance — manual only. Auto-disable if SP < 65% (Spark needs 50%)
if (CFG.autoSpirit && STATE.oc >= CFG.spiritStanceOC && !STATE.spiritStance && STATE.sp > 0.65)
return { type: 'toggle_spirit' };
if (STATE.spiritStance && STATE.sp < 0.65 && !STATE._spiritCooldown) {
STATE._spiritCooldown = 5;
return { type: 'toggle_spirit' };
}
if (STATE._spiritCooldown > 0) STATE._spiritCooldown--;
// 6. Weapon skills only at OC thresholds (let OC build via basic attacks)
const askillOC3 = CFG.skillOC || 80;
if (STATE.oc >= askillOC3) {
// Great Cleave: boss/rare fights only
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
}
// Rending Blow + Shatter Strike: AoE vs 5+ enemies
if (STATE.monsters.length >= 5) {
if (STATE.skillsKnown.includes('Rending Blow')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
}
if (STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t };
}
}
}
// Other weapon skills (non-2H): generic use
if (!STATE.skillsKnown.includes('Great Cleave')) {
const ps = ['Frenzied Blows', 'Skyward Sword', 'Iris Strike', 'Merciful Blow',
'Vital Strike', 'Shield Bash', 'Concussive Strike'];
for (const sk of ps) {
if (STATE.skillsKnown.includes(sk)) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: sk, target: t };
}
}
}
}
// 7. Damage spells
const dmg = CFG.useAttackSpells ? getBestDamageSpell() : null;
if (dmg && STATE.mp > 0.15) {
const t = STATE.monsters.length > 1 ? findWeakestMonster() : 0;
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: dmg, target: t };
}
// 8. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// Consume channeling on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t };
return null;
}