v0.13.0 - Heartseeker buff, channeling efficiency, 100% buff uptime

- Added Heartseeker to BUFF_PRIORITY (+25% phys dmg, +10% crit self-buff)
- Channeling consumption: mp_cost/(dur+1) scoring, no arbitrary cutoff
- KickstartChanneling: prefers highest-MP-cost buffs for better proc chance
- Raised minTurns on all buffs for 100% uptime (Haste/Protect/Regen/Absorb/Shadow 2->4, Spark 1->3)
- Updated knowledge-base spellUnlocks and milestones
- Removed <10 turn hard cutoff from channeling target selection
This commit is contained in:
GaboGG 2026-07-26 11:25:40 -04:00
parent 3a041f41da
commit 2107527c72
6 changed files with 236 additions and 188 deletions

View file

@ -605,6 +605,8 @@ const KB = {
{ lvl: 60, name: 'Haste' }, { lvl: 60, name: 'Haste' },
{ lvl: 70, name: 'Weaken' }, { lvl: 70, name: 'Weaken' },
{ lvl: 130, name: 'Imperil' }, { lvl: 130, name: 'Imperil' },
{ lvl: 140, name: 'Heartseeker' },
{ lvl: 175, name: 'Arcane Focus' },
{ lvl: 220, name: 'Full-Cure' }, { lvl: 220, name: 'Full-Cure' },
], ],
@ -639,6 +641,7 @@ const KB = {
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' }, { lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' }, { lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
{ lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' }, { lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' },
{ lvl: 140, tip: 'Heartseeker — +25% physical damage, +10% crit. Self-buff, priority over Arcane Focus for 2H.' },
{ lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' }, { lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' },
], ],
}; };
@ -676,13 +679,16 @@ function shouldBuff(name, minTurns) {
} }
// Known buffs with their priority, icon name, spell name, and refresh threshold // 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 = [ const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 2 }, { icon: 'haste', spell: 'Haste', minTurns: 4 }, // 100% uptime: refresh well before expiry
{ icon: 'protection', spell: 'Protection', minTurns: 2 }, { icon: 'protection', spell: 'Protection', minTurns: 4 },
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png { icon: 'sparklife', spell: 'Spark of Life', minTurns: 3 }, // icon: sparklife.png
{ icon: 'regen', spell: 'Regen', minTurns: 2 }, { icon: 'heartseeker', spell: 'Heartseeker', minTurns: 6 }, // Long duration, refresh at 6 to avoid gap
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 }, { icon: 'regen', spell: 'Regen', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 }, { icon: 'absorb', spell: 'Absorb', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 4 },
]; ];
// Known important monster names (bosses, legendaries, ultimates) // Known important monster names (bosses, legendaries, ultimates)
@ -788,18 +794,67 @@ function getBestDamageSpell() {
function kickstartChanneling() { function kickstartChanneling() {
if (STATE.channeling) return null; if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance // 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 // Higher MP cost = higher channeling proc chance.
// Regen/Absorb/Heartseeker have the best cost/proc ratio among buffs.
if (STATE.mp > 0.20) { if (STATE.mp > 0.20) {
// Try buffs in priority order — Regen is best for sustain + channel proc // Collect all buffs that need refreshing, sorted by MP cost descending
const candidates = [];
for (const b of BUFF_PRIORITY) { for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon); 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 ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue; if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true }; 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 buff that benefits most from being free ──
// Channeling gives: 1 MP cost + 50% longer duration.
// The best target is the buff where:
// - MP cost is high (more savings from the free cast)
// - Remaining duration is low (the 50% extension won't be wasted)
// Score formula: mp_cost / (dur + 1)
// - Expiring now (dur=0): cost × 1.0 — best candidate
// - Expiring soon (dur=5): cost × 0.17 — good
// - Fresh (dur=80): cost × 0.01 — barely worth refreshing
// No arbitrary cutoffs — every buff is a candidate, scored continuously.
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;
// Skip completely fresh Heartseeker (> 50 turns means just cast)
if (b.icon === 'heartseeker' && dur > 50) continue;
const sp = findSpell(b.spell);
const mpCost = sp ? sp.mp : 0;
// Score: higher MP cost + lower remaining turns = better channeling target
const score = mpCost / (dur + 1);
candidates.push({ ...b, mpCost, dur, score });
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
// Fallback: cheap damage spell rather than waste the charge on basic attack
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 };
} }
return null; return null;
@ -931,23 +986,10 @@ function strategyNovice() {
if (k) return k; if (k) return k;
} }
// If channeling is charged, consume it on a useful spell // If channeling is charged, consume it on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -1066,23 +1108,10 @@ function strategyAdept() {
if (k) return k; if (k) return k;
} }
// Use channeling charge on a spell // Use channeling charge on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -1213,23 +1242,10 @@ function strategyVeteran() {
if (k) return k; if (k) return k;
} }
// Consume channeling on a spell // Consume channeling on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();

View file

@ -605,6 +605,8 @@ const KB = {
{ lvl: 60, name: 'Haste' }, { lvl: 60, name: 'Haste' },
{ lvl: 70, name: 'Weaken' }, { lvl: 70, name: 'Weaken' },
{ lvl: 130, name: 'Imperil' }, { lvl: 130, name: 'Imperil' },
{ lvl: 140, name: 'Heartseeker' },
{ lvl: 175, name: 'Arcane Focus' },
{ lvl: 220, name: 'Full-Cure' }, { lvl: 220, name: 'Full-Cure' },
], ],
@ -639,6 +641,7 @@ const KB = {
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' }, { lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' }, { lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
{ lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' }, { lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' },
{ lvl: 140, tip: 'Heartseeker — +25% physical damage, +10% crit. Self-buff, priority over Arcane Focus for 2H.' },
{ lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' }, { lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' },
], ],
}; };
@ -676,13 +679,16 @@ function shouldBuff(name, minTurns) {
} }
// Known buffs with their priority, icon name, spell name, and refresh threshold // 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 = [ const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 2 }, { icon: 'haste', spell: 'Haste', minTurns: 4 }, // 100% uptime: refresh well before expiry
{ icon: 'protection', spell: 'Protection', minTurns: 2 }, { icon: 'protection', spell: 'Protection', minTurns: 4 },
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png { icon: 'sparklife', spell: 'Spark of Life', minTurns: 3 }, // icon: sparklife.png
{ icon: 'regen', spell: 'Regen', minTurns: 2 }, { icon: 'heartseeker', spell: 'Heartseeker', minTurns: 6 }, // Long duration, refresh at 6 to avoid gap
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 }, { icon: 'regen', spell: 'Regen', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 }, { icon: 'absorb', spell: 'Absorb', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 4 },
]; ];
// Known important monster names (bosses, legendaries, ultimates) // Known important monster names (bosses, legendaries, ultimates)
@ -788,18 +794,67 @@ function getBestDamageSpell() {
function kickstartChanneling() { function kickstartChanneling() {
if (STATE.channeling) return null; if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance // 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 // Higher MP cost = higher channeling proc chance.
// Regen/Absorb/Heartseeker have the best cost/proc ratio among buffs.
if (STATE.mp > 0.20) { if (STATE.mp > 0.20) {
// Try buffs in priority order — Regen is best for sustain + channel proc // Collect all buffs that need refreshing, sorted by MP cost descending
const candidates = [];
for (const b of BUFF_PRIORITY) { for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon); 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 ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue; if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true }; 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 buff that benefits most from being free ──
// Channeling gives: 1 MP cost + 50% longer duration.
// The best target is the buff where:
// - MP cost is high (more savings from the free cast)
// - Remaining duration is low (the 50% extension won't be wasted)
// Score formula: mp_cost / (dur + 1)
// - Expiring now (dur=0): cost × 1.0 — best candidate
// - Expiring soon (dur=5): cost × 0.17 — good
// - Fresh (dur=80): cost × 0.01 — barely worth refreshing
// No arbitrary cutoffs — every buff is a candidate, scored continuously.
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;
// Skip completely fresh Heartseeker (> 50 turns means just cast)
if (b.icon === 'heartseeker' && dur > 50) continue;
const sp = findSpell(b.spell);
const mpCost = sp ? sp.mp : 0;
// Score: higher MP cost + lower remaining turns = better channeling target
const score = mpCost / (dur + 1);
candidates.push({ ...b, mpCost, dur, score });
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
// Fallback: cheap damage spell rather than waste the charge on basic attack
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 };
} }
return null; return null;
@ -931,23 +986,10 @@ function strategyNovice() {
if (k) return k; if (k) return k;
} }
// If channeling is charged, consume it on a useful spell // If channeling is charged, consume it on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -1066,23 +1108,10 @@ function strategyAdept() {
if (k) return k; if (k) return k;
} }
// Use channeling charge on a spell // Use channeling charge on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -1213,23 +1242,10 @@ function strategyVeteran() {
if (k) return k; if (k) return k;
} }
// Consume channeling on a spell // Consume channeling on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();

View file

@ -2,7 +2,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.12.0'; const VERSION = '0.13.0';
const CFG = { const CFG = {
// — Battle automation // — Battle automation

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.12.0 // @version 0.13.0
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
// @author GaboGG + Hermes // @author GaboGG + Hermes
// @match *://*.hentaiverse.org/* // @match *://*.hentaiverse.org/*

View file

@ -44,6 +44,8 @@ const KB = {
{ lvl: 60, name: 'Haste' }, { lvl: 60, name: 'Haste' },
{ lvl: 70, name: 'Weaken' }, { lvl: 70, name: 'Weaken' },
{ lvl: 130, name: 'Imperil' }, { lvl: 130, name: 'Imperil' },
{ lvl: 140, name: 'Heartseeker' },
{ lvl: 175, name: 'Arcane Focus' },
{ lvl: 220, name: 'Full-Cure' }, { lvl: 220, name: 'Full-Cure' },
], ],
@ -78,6 +80,7 @@ const KB = {
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' }, { lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' }, { lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
{ lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' }, { lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' },
{ lvl: 140, tip: 'Heartseeker — +25% physical damage, +10% crit. Self-buff, priority over Arcane Focus for 2H.' },
{ lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' }, { lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' },
], ],
}; };

View file

@ -31,13 +31,16 @@ function shouldBuff(name, minTurns) {
} }
// Known buffs with their priority, icon name, spell name, and refresh threshold // 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 = [ const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 2 }, { icon: 'haste', spell: 'Haste', minTurns: 4 }, // 100% uptime: refresh well before expiry
{ icon: 'protection', spell: 'Protection', minTurns: 2 }, { icon: 'protection', spell: 'Protection', minTurns: 4 },
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png { icon: 'sparklife', spell: 'Spark of Life', minTurns: 3 }, // icon: sparklife.png
{ icon: 'regen', spell: 'Regen', minTurns: 2 }, { icon: 'heartseeker', spell: 'Heartseeker', minTurns: 6 }, // Long duration, refresh at 6 to avoid gap
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 }, { icon: 'regen', spell: 'Regen', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 }, { icon: 'absorb', spell: 'Absorb', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 4 },
]; ];
// Known important monster names (bosses, legendaries, ultimates) // Known important monster names (bosses, legendaries, ultimates)
@ -143,18 +146,67 @@ function getBestDamageSpell() {
function kickstartChanneling() { function kickstartChanneling() {
if (STATE.channeling) return null; if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance // 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 // Higher MP cost = higher channeling proc chance.
// Regen/Absorb/Heartseeker have the best cost/proc ratio among buffs.
if (STATE.mp > 0.20) { if (STATE.mp > 0.20) {
// Try buffs in priority order — Regen is best for sustain + channel proc // Collect all buffs that need refreshing, sorted by MP cost descending
const candidates = [];
for (const b of BUFF_PRIORITY) { for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon); 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 ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue; if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
return { type: 'spell', name: b.spell, selfTarget: true }; 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 buff that benefits most from being free ──
// Channeling gives: 1 MP cost + 50% longer duration.
// The best target is the buff where:
// - MP cost is high (more savings from the free cast)
// - Remaining duration is low (the 50% extension won't be wasted)
// Score formula: mp_cost / (dur + 1)
// - Expiring now (dur=0): cost × 1.0 — best candidate
// - Expiring soon (dur=5): cost × 0.17 — good
// - Fresh (dur=80): cost × 0.01 — barely worth refreshing
// No arbitrary cutoffs — every buff is a candidate, scored continuously.
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;
// Skip completely fresh Heartseeker (> 50 turns means just cast)
if (b.icon === 'heartseeker' && dur > 50) continue;
const sp = findSpell(b.spell);
const mpCost = sp ? sp.mp : 0;
// Score: higher MP cost + lower remaining turns = better channeling target
const score = mpCost / (dur + 1);
candidates.push({ ...b, mpCost, dur, score });
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
// Fallback: cheap damage spell rather than waste the charge on basic attack
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 };
} }
return null; return null;
@ -286,23 +338,10 @@ function strategyNovice() {
if (k) return k; if (k) return k;
} }
// If channeling is charged, consume it on a useful spell // If channeling is charged, consume it on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -421,23 +460,10 @@ function strategyAdept() {
if (k) return k; if (k) return k;
} }
// Use channeling charge on a spell // Use channeling charge on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();
@ -568,23 +594,10 @@ function strategyVeteran() {
if (k) return k; if (k) return k;
} }
// Consume channeling on a spell // Consume channeling on the highest-MP buff that needs refreshing
if (STATE.channeling && STATE.mp > 0.10) { {
for (const b of BUFF_PRIORITY) { const ch = findBestChannelingTarget();
if (hasSpell(b.spell)) { if (ch) return ch;
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(); const t = findWeakestMonster();