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: 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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/*
|
||||||
|
|
|
||||||
|
|
@ -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.' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue