hv-unified/src/strategy-engine.js
GaboGG d9fc1e547c Fix channeling: consume charge on useful spell, don't let it wear off
Channeling works: proc on mana-costing spell -> next spell costs 1 MP
and is 50% stronger/longer. The charge lasts 5 ticks (15 with Mystic
Gem) then wears off and is wasted.

Previously the script would basic attack with channeling charged,
wasting the bonus. Now:
- If channeling is active and nothing more urgent, still cast a buff
  (refresh anything under 10 turns) or a cheap damage spell
- Never basic attack while channeling is charged
- Novice/Adept/Veteran all have this protection
2026-07-21 14:45:23 -04:00

563 lines
22 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
const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 2 },
{ icon: 'protection', spell: 'Protection', minTurns: 2 },
{ icon: 'spark_of_life', spell: 'Spark of Life', minTurns: 1 },
{ icon: 'regen', spell: 'Regen', minTurns: 2 },
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 },
// Shadow Veil: icon key = lowercased spell name without spaces
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 },
];
// 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"
// 3. SP bar present (third btm5 child with nbarred.png)
const style = m.getAttribute('style') || '';
const inner = m.innerHTML || '';
// Check for gold border (boss indicator)
if (style.includes('BD7400') || style.includes('E6CCA3')) return true;
// Check for SP bar (only bosses have all 3 bars: HP, MP, SP)
if (inner.includes('nbarred.png')) 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.
function kickstartChanneling() {
if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance
// Regen has 17 MP = best cost/proc ratio among buffs we always want active
if (STATE.mp > 0.20) {
// Try buffs in priority order — Regen is best for sustain + channel proc
for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon);
// Only refresh if expiring soon (< 5 turns) or missing
if ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
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. Gems proactively — don't hoard
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Mana gem — use when MP < 50%
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Spirit gem — use when SP is low (keeps Spirit Stance available)
if (STATE.sp < CFG.spiritPotionSP) {
const gem = hasUsableGem('spirit');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure only if items aren't available and HP is critical
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
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
// Regen is hp-dependent: only cast if HP is below threshold
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, 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 a useful spell
if (STATE.channeling && STATE.mp > 0.10) {
for (const b of BUFF_PRIORITY) {
if (hasSpell(b.spell)) {
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 10) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale']);
if (cheap) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: cheap, target: t };
}
}
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. Gems proactively — don't hoard
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// Spirit gem — use when SP is low
if (STATE.sp < CFG.spiritPotionSP) {
const gem = hasUsableGem('spirit');
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 — prioritize by remaining duration (lowest = most urgent)
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
// 4. Debuffs — only on important fights (rare/boss/ultimate monsters)
// In random mob fights, just attack through them
if (anyRareMonster() || STATE.monsters.length <= 2) {
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 at OC 60 — only if we have enough SP to sustain it
// Costs 1 SP + 10% OC per round (wiki formula)
// Minimum 40% SP = ~20 rounds of sustain before we run out
// This prevents wasting a turn toggling on/off when SP is low
if (STATE.oc >= 60 && !STATE.spiritStance && STATE.sp > 0.40 && !STATE._spiritCooldown) {
STATE._spiritCooldown = 3;
return { type: 'toggle_spirit' };
}
// Toggle off when SP is critically low
if (STATE.sp < 0.03 && STATE.spiritStance && !STATE._spiritCooldown) {
STATE._spiritCooldown = 3;
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 a spell
if (STATE.channeling && STATE.mp > 0.10) {
for (const b of BUFF_PRIORITY) {
if (hasSpell(b.spell)) {
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 10) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale']);
if (cheap) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: cheap, target: t };
}
}
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. Gems proactively — don't hoard, use at reasonable thresholds
if (STATE.hp < 0.60) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
if (STATE.sp < CFG.spiritPotionSP) {
const gem = hasUsableGem('spirit');
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 — prioritize by remaining duration
if (CFG.autoBuff) {
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
// 4. Debuffs — only on important fights (rare/boss)
if (CFG.autoDebuff && STATE.mp > 0.2 && (anyRareMonster() || STATE.monsters.length <= 2)) {
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 at OC 60
if (CFG.autoSpirit && STATE.oc >= CFG.spiritStanceOC && !STATE.spiritStance && STATE.sp > 0.10)
return { type: 'toggle_spirit' };
if (STATE.sp < 0.03 && STATE.spiritStance)
return { type: 'toggle_spirit' };
// 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 a spell
if (STATE.channeling && STATE.mp > 0.10) {
for (const b of BUFF_PRIORITY) {
if (hasSpell(b.spell)) {
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 10) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true };
}
}
}
const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale']);
if (cheap) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: cheap, target: t };
}
}
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t };
return null;
}