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:
parent
3a041f41da
commit
2107527c72
6 changed files with 236 additions and 188 deletions
|
|
@ -605,6 +605,8 @@ const KB = {
|
|||
{ lvl: 60, name: 'Haste' },
|
||||
{ lvl: 70, name: 'Weaken' },
|
||||
{ lvl: 130, name: 'Imperil' },
|
||||
{ lvl: 140, name: 'Heartseeker' },
|
||||
{ lvl: 175, name: 'Arcane Focus' },
|
||||
{ lvl: 220, name: 'Full-Cure' },
|
||||
],
|
||||
|
||||
|
|
@ -639,6 +641,7 @@ const KB = {
|
|||
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
|
||||
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
|
||||
{ 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.' },
|
||||
],
|
||||
};
|
||||
|
|
@ -676,13 +679,16 @@ function shouldBuff(name, 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: 2 },
|
||||
{ icon: 'protection', spell: 'Protection', minTurns: 2 },
|
||||
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png
|
||||
{ icon: 'regen', spell: 'Regen', minTurns: 2 },
|
||||
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 },
|
||||
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 },
|
||||
{ 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)
|
||||
|
|
@ -788,18 +794,67 @@ function getBestDamageSpell() {
|
|||
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
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 };
|
||||
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;
|
||||
|
|
@ -931,23 +986,10 @@ function strategyNovice() {
|
|||
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 };
|
||||
}
|
||||
// If channeling is charged, consume it on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -1066,23 +1108,10 @@ function strategyAdept() {
|
|||
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 };
|
||||
}
|
||||
// Use channeling charge on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -1213,23 +1242,10 @@ function strategyVeteran() {
|
|||
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 };
|
||||
}
|
||||
// Consume channeling on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
|
|||
|
|
@ -605,6 +605,8 @@ const KB = {
|
|||
{ lvl: 60, name: 'Haste' },
|
||||
{ lvl: 70, name: 'Weaken' },
|
||||
{ lvl: 130, name: 'Imperil' },
|
||||
{ lvl: 140, name: 'Heartseeker' },
|
||||
{ lvl: 175, name: 'Arcane Focus' },
|
||||
{ lvl: 220, name: 'Full-Cure' },
|
||||
],
|
||||
|
||||
|
|
@ -639,6 +641,7 @@ const KB = {
|
|||
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
|
||||
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
|
||||
{ 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.' },
|
||||
],
|
||||
};
|
||||
|
|
@ -676,13 +679,16 @@ function shouldBuff(name, 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: 2 },
|
||||
{ icon: 'protection', spell: 'Protection', minTurns: 2 },
|
||||
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png
|
||||
{ icon: 'regen', spell: 'Regen', minTurns: 2 },
|
||||
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 },
|
||||
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 },
|
||||
{ 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)
|
||||
|
|
@ -788,18 +794,67 @@ function getBestDamageSpell() {
|
|||
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
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 };
|
||||
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;
|
||||
|
|
@ -931,23 +986,10 @@ function strategyNovice() {
|
|||
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 };
|
||||
}
|
||||
// If channeling is charged, consume it on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -1066,23 +1108,10 @@ function strategyAdept() {
|
|||
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 };
|
||||
}
|
||||
// Use channeling charge on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -1213,23 +1242,10 @@ function strategyVeteran() {
|
|||
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 };
|
||||
}
|
||||
// Consume channeling on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// CONFIG — default settings
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const VERSION = '0.12.0';
|
||||
const VERSION = '0.13.0';
|
||||
|
||||
const CFG = {
|
||||
// — Battle automation
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// ==UserScript==
|
||||
// @name HV Unified
|
||||
// @namespace hvunified
|
||||
// @version 0.12.0
|
||||
// @version 0.13.0
|
||||
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
|
||||
// @author GaboGG + Hermes
|
||||
// @match *://*.hentaiverse.org/*
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const KB = {
|
|||
{ lvl: 60, name: 'Haste' },
|
||||
{ lvl: 70, name: 'Weaken' },
|
||||
{ lvl: 130, name: 'Imperil' },
|
||||
{ lvl: 140, name: 'Heartseeker' },
|
||||
{ lvl: 175, name: 'Arcane Focus' },
|
||||
{ lvl: 220, name: 'Full-Cure' },
|
||||
],
|
||||
|
||||
|
|
@ -78,6 +80,7 @@ const KB = {
|
|||
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
|
||||
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
|
||||
{ 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.' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,13 +31,16 @@ function shouldBuff(name, 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: 2 },
|
||||
{ icon: 'protection', spell: 'Protection', minTurns: 2 },
|
||||
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 1 }, // icon: sparklife.png
|
||||
{ icon: 'regen', spell: 'Regen', minTurns: 2 },
|
||||
{ icon: 'absorb', spell: 'Absorb', minTurns: 2 },
|
||||
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 3 },
|
||||
{ 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)
|
||||
|
|
@ -143,18 +146,67 @@ function getBestDamageSpell() {
|
|||
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
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 };
|
||||
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;
|
||||
|
|
@ -286,23 +338,10 @@ function strategyNovice() {
|
|||
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 };
|
||||
}
|
||||
// If channeling is charged, consume it on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -421,23 +460,10 @@ function strategyAdept() {
|
|||
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 };
|
||||
}
|
||||
// Use channeling charge on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
@ -568,23 +594,10 @@ function strategyVeteran() {
|
|||
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 };
|
||||
}
|
||||
// Consume channeling on the highest-MP buff that needs refreshing
|
||||
{
|
||||
const ch = findBestChannelingTarget();
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
const t = findWeakestMonster();
|
||||
|
|
|
|||
Loading…
Reference in a new issue