- 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)
872 lines
39 KiB
JavaScript
872 lines
39 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════
|
||
// STRATEGY ENGINE — tier-based action recommendation
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Spell helpers ──
|
||
|
||
function hasSpell(name) {
|
||
return STATE.spellsKnown.some(s => s.n === name);
|
||
}
|
||
|
||
// Check if a spell is not on cooldown (has onclick in the DOM AND inner img isn't dimmed)
|
||
function hasReadySpell(name) {
|
||
const el = $$('.btsd, .btqs').find(e =>
|
||
(e.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${name}'`));
|
||
if (!el) return false;
|
||
if (!el.hasAttribute('onclick')) return false;
|
||
// Double-check: the inner img might have opacity:0.5 (cooldown tint)
|
||
// even if the parent has onclick from a previous parse
|
||
const img = el.querySelector('img.btqi, img');
|
||
if (img && img.style.opacity === '0.5') return false;
|
||
return true;
|
||
}
|
||
|
||
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
|
||
|
||
// Threat detection: SP bar max is 120px, monsters >85% are about to special
|
||
const THREAT_PX = Math.round(120 * 85 / 100); // ~102px
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Find the monster with the highest SP/OC bar (most dangerous — about to special)
|
||
// The red bar (nbarred.png) has a max width of 120px
|
||
// Returns the index, or 0 if none found
|
||
function findDangerousMonster() {
|
||
let best = -1, bestSP = -1;
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
|
||
const sp = STATE.monsterSp[i] || 0;
|
||
if (sp > bestSP) { bestSP = sp; best = i; }
|
||
});
|
||
return best >= 0 ? best : 0;
|
||
}
|
||
|
||
// Check if there are 2+ monsters with SP bar > threshold (imminent special attacks)
|
||
function hasHighThreat(threshold) {
|
||
let count = 0;
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
|
||
const sp = STATE.monsterSp[i] || 0;
|
||
if (sp > threshold) count++;
|
||
});
|
||
return count >= 2;
|
||
}
|
||
|
||
// Find optimal target for Domino Strike splash (2H passive) — center-aligned
|
||
// For 2H builds, Domino Strike splashes up to 2 enemies on each side of the target.
|
||
// Attacking edge monsters wastes 50% of the splash potential.
|
||
function findOptimalDominoTarget() {
|
||
const total = STATE.monsters.length;
|
||
if (total <= 2) return findWeakestMonster();
|
||
|
||
let best = -1;
|
||
let bestScore = -1;
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
|
||
let splash = 0;
|
||
if (i - 1 >= 0 && STATE.monsters[i - 1] && STATE.monsters[i - 1].hasAttribute('onclick')) splash++;
|
||
if (i - 2 >= 0 && STATE.monsters[i - 2] && STATE.monsters[i - 2].hasAttribute('onclick')) splash++;
|
||
if (i + 1 < total && STATE.monsters[i + 1] && STATE.monsters[i + 1].hasAttribute('onclick')) splash++;
|
||
if (i + 2 < total && STATE.monsters[i + 2] && STATE.monsters[i + 2].hasAttribute('onclick')) splash++;
|
||
if (splash > bestScore) { bestScore = splash; 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 (!hasReadySpell(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;
|
||
|
||
// Log which mode we're in
|
||
if (STATE.channeling) {
|
||
// Channeling is charged — cast the MOST expensive buff for best value
|
||
candidates.sort((a, b) => b.mpCost - a.mpCost);
|
||
return { type: 'spell', name: candidates[0].spell, selfTarget: true, _reason: 'initCh' };
|
||
} 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, _reason: 'init' };
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Cheapest first to maximize channeling proc chance
|
||
candidates.sort((a, b) => a.mpCost - b.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 (!hasReadySpell(b.spell)) continue;
|
||
const dur = buffDuration(b.icon);
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
const sp = findSpell(b.spell);
|
||
// Use cached base MP cost (snapshotted at battle start from the magic pane)
|
||
// This avoids the channeling 1-MP display issue and Mana Conservation fluctuation
|
||
let mpCost = STATE._baseMpCosts[b.spell] || 0;
|
||
if (mpCost === 0 && sp) {
|
||
// Not cached yet — read from magic pane directly
|
||
const paneEl = $$('.btsd[onmouseover*="' + b.spell.replace(/'/g, "\\'") + '"]',
|
||
document.getElementById('pane_magic'))[0];
|
||
if (paneEl) {
|
||
const costM = (paneEl.getAttribute('onmouseover') || '')
|
||
.match(/set_infopane_spell\('[^']+',\s*'[^']*',\s*'[^']*',\s*(\d+)/);
|
||
if (costM) mpCost = parseInt(costM[1]);
|
||
}
|
||
if (mpCost === 0) mpCost = sp.mp; // last resort: use what parseBattleState gave us
|
||
STATE._baseMpCosts[b.spell] = mpCost;
|
||
}
|
||
// Skip if we can't afford the base cost — game pre-checks even when
|
||
// channeling makes the effective cost 1 MP
|
||
const curMp = parseInt((document.getElementById('vrm') || {}).textContent) || 0;
|
||
const estMaxMp = (STATE.mp > 0 && curMp > 0) ? Math.round(curMp / STATE.mp) : 414;
|
||
if (mpCost > 0 && curMp > 0 && mpCost > curMp) continue;
|
||
// Fill level: how empty is this buff relative to its max observed duration
|
||
let maxDur = STATE.maxBuffDur[b.icon] || 0;
|
||
// If max matches current (first observation, no learning yet), assume min 30 turns
|
||
if (maxDur <= dur) maxDur = Math.max(dur + 1, 30);
|
||
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);
|
||
console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0');
|
||
// 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, _reason: `ch score ${candidates[0].score.toFixed(0)}` };
|
||
}
|
||
}
|
||
|
||
// Fallback: for physical 2H builds, don't waste channeling on weak damage spells.
|
||
// Channeling improves the next spell, but Fiery Blast does negligible damage at IWBTH.
|
||
// Better to use the charge on a basic attack that procs Domino Strike and Penetrated Armor.
|
||
const isPhysical2H = STATE.skillsKnown.includes('Great Cleave') || STATE.skillsKnown.includes('Rending Blow');
|
||
if (isPhysical2H) {
|
||
return null; // Let channeling expire naturally — basic attacks do more than Fiery Blast
|
||
}
|
||
|
||
// Mage fallback: use any cheap spell for mage builds
|
||
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 — only use once per battle
|
||
// (channeling status is tracked from the battle log)
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling && !STATE._mysticUsed)
|
||
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 = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('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;
|
||
}
|
||
// Collect available buff candidates and sort by cost
|
||
const buffCandidates = [];
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
const sp = findSpell(b.spell);
|
||
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
|
||
}
|
||
}
|
||
if (buffCandidates.length > 0) {
|
||
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
|
||
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
|
||
}
|
||
|
||
// 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) — auto-consume when available
|
||
const pGem = document.getElementById('ikey_p');
|
||
const pGemId = pGem ? (pGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
|
||
if (pGemId && pGemId[1] === '10007') {
|
||
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. Weapon skills — these are better than basic attacks
|
||
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 3+ enemies (needs Penetrated Armor from Rending Blow)
|
||
if (STATE.monsters.length >= 3 && 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 = hasHighThreat(THREAT_PX) ? findDangerousMonster() : 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 — only use once per battle
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling && !STATE._mysticUsed)
|
||
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' };
|
||
|
||
// 0b. Spark of Life SP safety
|
||
if (STATE.sp < 0.55) {
|
||
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, _reason: `sp${(STATE.sp*100).toFixed(0)}` };
|
||
}
|
||
}
|
||
|
||
// 0c. Spirit Stance safety auto-disable
|
||
if (STATE.spiritStance && STATE.sp < 0.60) {
|
||
return { type: 'toggle_spirit', _reason: 'sp_safety' };
|
||
}
|
||
|
||
// 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 = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('Cure') ? 'Cure' : null;
|
||
if (c) return { type: 'spell', name: c, selfTarget: true };
|
||
}
|
||
|
||
// 2b. Spirit items — Spark of Life costs 50% SP. Keep SP above 55% so
|
||
// Spark can actually save us.
|
||
if (STATE.sp < 0.55) {
|
||
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 };
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// Note: channeling reduces cost to ~1, but the game pre-checks base MP,
|
||
// 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;
|
||
}
|
||
// Collect available buff candidates and sort by cost
|
||
const buffCandidates = [];
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
const sp = findSpell(b.spell);
|
||
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
|
||
}
|
||
}
|
||
if (buffCandidates.length > 0) {
|
||
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
|
||
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
|
||
}
|
||
|
||
// 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) — auto-consume when available
|
||
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 — Imperil on 2+ mobs or bosses; Weaken on high-threat
|
||
if (CFG.autoDebuff && STATE.mp > 0.20) {
|
||
if (hasReadySpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
|
||
return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' };
|
||
}
|
||
if (hasReadySpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
|
||
const b = findDangerousMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
|
||
return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' };
|
||
}
|
||
}
|
||
|
||
// 5. Weapon skills — prioritize high-threat monsters (high SP/OC bar)
|
||
const isHighThreat = hasHighThreat(THREAT_PX);
|
||
if (isHighThreat) console.log('%c[HV] ⚠ HIGH THREAT (Adept): 2+ monsters with SP >85%', 'color:#f44');
|
||
const skillOC_N = CFG.skillOC || 75;
|
||
if (STATE.oc >= skillOC_N || isHighThreat) {
|
||
if (isHighThreat && STATE.oc < skillOC_N) console.log('%c[HV] ⚠ emergency skill at OC=' + STATE.oc.toFixed(0), 'color:#f80');
|
||
// Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5)
|
||
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
|
||
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
|
||
}
|
||
// Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
|
||
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
|
||
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
|
||
if (hasArmorBreak) {
|
||
const t = isHighThreat ? findDangerousMonster() : 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 = isHighThreat ? findDangerousMonster() : findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
|
||
}
|
||
}
|
||
|
||
// 5. 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 = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster());
|
||
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
|
||
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
|
||
return null;
|
||
}
|
||
|
||
// ── Veteran (150-300) / Master (300+): full rotation ──
|
||
|
||
function strategyVeteran() {
|
||
// 0. Mystic Gem for channeling — only use once per battle
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling && !STATE._mysticUsed)
|
||
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' };
|
||
|
||
// 0b. Spark of Life SP safety — Spark costs 50% SP when triggered.
|
||
// If SP < 55%, consume a spirit item before anything else.
|
||
// Also suppress Spark recast if SP is too low to survive a proc.
|
||
if (STATE.sp < 0.55) {
|
||
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, _reason: `sp${(STATE.sp*100).toFixed(0)}` };
|
||
}
|
||
}
|
||
|
||
// 0c. Spirit Stance safety auto-disable — if left on, drains 10% SP/turn
|
||
if (STATE.spiritStance && STATE.sp < 0.60) {
|
||
return { type: 'toggle_spirit', _reason: 'sp_safety' };
|
||
}
|
||
|
||
// 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, _reason: `hp${(STATE.hp*100).toFixed(0)}` };
|
||
}
|
||
|
||
// 2. Cure
|
||
if (STATE.hp < CFG.cureHP) {
|
||
const c = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('Cure') ? 'Cure' : null;
|
||
if (c) return { type: 'spell', name: c, selfTarget: true, _reason: `hp${(STATE.hp*100).toFixed(0)}` };
|
||
}
|
||
|
||
// 2b. Spirit items — Spark of Life costs 50% SP. Keep SP above 55% so
|
||
// Spark can actually save us. Use spirit draughts/potions proactively.
|
||
if (STATE.sp < 0.55) {
|
||
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, _reason: `sp${(STATE.sp*100).toFixed(0)}` };
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
// Collect available buff candidates and sort by cost
|
||
// No channeling → cheapest first (proc chance), channeling → most expensive (MP value)
|
||
const buffCandidates = [];
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
const sp = findSpell(b.spell);
|
||
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
|
||
}
|
||
}
|
||
if (buffCandidates.length > 0) {
|
||
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
|
||
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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 — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies
|
||
// On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost
|
||
if (CFG.autoDebuff && STATE.mp > 0.20) {
|
||
// Imperil: reduce physical mitigation by 25-50% (stacks with Penetrated Armor)
|
||
// Use on any tanky enemy when facing 2+ monsters or a rare
|
||
if (hasReadySpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
|
||
return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' };
|
||
}
|
||
// Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs
|
||
if (hasReadySpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
|
||
const b = findDangerousMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
|
||
return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' };
|
||
}
|
||
}
|
||
|
||
// 5. Weapon skills — prioritize high-threat monsters (high SP/OC bar)
|
||
// If 2+ enemies have >85% SP bar, they're about to special — emergency
|
||
const isHighThreat = hasHighThreat(THREAT_PX);
|
||
if (isHighThreat) console.log('%c[HV] ⚠ HIGH THREAT: 2+ monsters with SP >85%', 'color:#f44');
|
||
|
||
const askillOC3 = CFG.skillOC || 80;
|
||
if (STATE.oc >= askillOC3 || isHighThreat) {
|
||
if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80');
|
||
// Emergency: 2+ monsters about to special — spend OC to kill them fast
|
||
// Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability)
|
||
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
|
||
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' };
|
||
}
|
||
// Shatter Strike: AoE stun vs 3+ enemies with armor break
|
||
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
|
||
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
|
||
if (hasArmorBreak) {
|
||
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t, _reason: 'stun' };
|
||
}
|
||
}
|
||
// Great Cleave: boss/rare fights or single-target threat
|
||
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
|
||
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t, _reason: isHighThreat ? 'threat' : 'boss' };
|
||
}
|
||
// If high threat and no AoE skill available, use any weapon skill on the most dangerous
|
||
if (isHighThreat && STATE.oc >= (CFG.skillOC || 80)) {
|
||
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 = findDangerousMonster();
|
||
if (t >= 0) return { type: 'skill', name: sk, target: t, _reason: 'threat' };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Spirit gem auto-consume
|
||
const spGem = document.getElementById('ikey_p');
|
||
const spGemId = spGem ? (spGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
|
||
if (spGemId && spGemId[1] === '10007') {
|
||
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true, _reason: 'gem' };
|
||
}
|
||
// Spirit potions — only at SP < threshold (Spark needs 50%)
|
||
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, _reason: `sp${(STATE.sp*100).toFixed(0)}` };
|
||
}
|
||
}
|
||
|
||
// 6. 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, _reason: 'dmg' };
|
||
}
|
||
|
||
// 7. 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 = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster());
|
||
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
|
||
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
|
||
return null;
|
||
}
|