- 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
703 lines
28 KiB
JavaScript
703 lines
28 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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;
|
||
}
|