v0.13.32 - Fix channeling waste when maxBuffDur hasn't learned true cap

- When maxBuffDur gap (max - current) is < 10, the cap is unreliable
  (first observation mid-battle, not at full duration)
- In that case, skip the fill-level formula entirely
- Only suggest a buff for channeling if it actually needs refresh
  (dur <= minTurns from BUFF_PRIORITY)
- Once maxBuffDur learns the true cap (after a fresh cast), the
  normal fill-level scoring takes over correctly
This commit is contained in:
GaboGG 2026-07-27 13:22:15 -04:00
parent 533fad252a
commit b8e2e945ec
6 changed files with 48 additions and 18 deletions

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.31 // @version 0.13.32
// @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/*
@ -17,7 +17,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.13.31'; const VERSION = '0.13.32';
const CFG = { const CFG = {
// — Battle automation // — Battle automation
@ -991,7 +991,13 @@ function findBestChannelingTarget() {
let maxDur = STATE.maxBuffDur[b.icon] || 0; let maxDur = STATE.maxBuffDur[b.icon] || 0;
// If max matches current (first observation, no learning yet), assume min 30 turns // If max matches current (first observation, no learning yet), assume min 30 turns
if (maxDur <= dur) maxDur = Math.max(dur + 1, 30); if (maxDur <= dur) maxDur = Math.max(dur + 1, 30);
const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; // Check if maxBuffDur is unreliable: if the gap between max and current
// is tiny (<10% of max), we haven't seen the true cap yet. In that case,
// just use the simple shouldBuff check instead.
const isReliable = maxDur > dur + 10;
const emptiness = isReliable
? Math.min(1, (maxDur - dur) / maxDur)
: (dur <= b.minTurns ? 0.5 : 0); // treat as half-empty if past minTurns
// Score: emptiness × mp_cost — an empty expensive buff is best // Score: emptiness × mp_cost — an empty expensive buff is best
const score = emptiness * mpCost; const score = emptiness * mpCost;
candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur });
@ -1002,8 +1008,12 @@ function findBestChannelingTarget() {
console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0'); console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0');
// Only use a buff if it actually needs refreshing (score >= 30 means // Only use a buff if it actually needs refreshing (score >= 30 means
// e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell) // e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell)
if (candidates[0].score >= 30) { // When maxBuffDur is unreliable (gap < 10), skip the threshold for
return { type: 'spell', name: candidates[0].spell, selfTarget: true }; // buffs that have ticked past their minTurns — they genuinely need refresh
const top = candidates[0];
const gapOk = top.maxDur - top.dur > 10;
if (top.score >= 30 || (!gapOk && top.dur <= BUFF_PRIORITY.find(b => b.icon === top.icon)?.minTurns)) {
return { type: 'spell', name: top.spell, selfTarget: true };
} }
} }
@ -2294,7 +2304,7 @@ function getDifficultyAdvice() {
reason = ''; reason = '';
} }
const currentIdx = difficulties.indexOf(difficulty); const currentIdx = difficulties.indexOf(difficulty.toUpperCase());
const suggestedIdx = difficulties.indexOf(suggested); const suggestedIdx = difficulties.indexOf(suggested);
// Only warn if current difficulty is LOWER than suggested // Only warn if current difficulty is LOWER than suggested

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.31 // @version 0.13.32
// @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/*
@ -17,7 +17,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.13.31'; const VERSION = '0.13.32';
const CFG = { const CFG = {
// — Battle automation // — Battle automation
@ -991,7 +991,13 @@ function findBestChannelingTarget() {
let maxDur = STATE.maxBuffDur[b.icon] || 0; let maxDur = STATE.maxBuffDur[b.icon] || 0;
// If max matches current (first observation, no learning yet), assume min 30 turns // If max matches current (first observation, no learning yet), assume min 30 turns
if (maxDur <= dur) maxDur = Math.max(dur + 1, 30); if (maxDur <= dur) maxDur = Math.max(dur + 1, 30);
const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; // Check if maxBuffDur is unreliable: if the gap between max and current
// is tiny (<10% of max), we haven't seen the true cap yet. In that case,
// just use the simple shouldBuff check instead.
const isReliable = maxDur > dur + 10;
const emptiness = isReliable
? Math.min(1, (maxDur - dur) / maxDur)
: (dur <= b.minTurns ? 0.5 : 0); // treat as half-empty if past minTurns
// Score: emptiness × mp_cost — an empty expensive buff is best // Score: emptiness × mp_cost — an empty expensive buff is best
const score = emptiness * mpCost; const score = emptiness * mpCost;
candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur });
@ -1002,8 +1008,12 @@ function findBestChannelingTarget() {
console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0'); console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0');
// Only use a buff if it actually needs refreshing (score >= 30 means // Only use a buff if it actually needs refreshing (score >= 30 means
// e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell) // e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell)
if (candidates[0].score >= 30) { // When maxBuffDur is unreliable (gap < 10), skip the threshold for
return { type: 'spell', name: candidates[0].spell, selfTarget: true }; // buffs that have ticked past their minTurns — they genuinely need refresh
const top = candidates[0];
const gapOk = top.maxDur - top.dur > 10;
if (top.score >= 30 || (!gapOk && top.dur <= BUFF_PRIORITY.find(b => b.icon === top.icon)?.minTurns)) {
return { type: 'spell', name: top.spell, selfTarget: true };
} }
} }
@ -2294,7 +2304,7 @@ function getDifficultyAdvice() {
reason = ''; reason = '';
} }
const currentIdx = difficulties.indexOf(difficulty); const currentIdx = difficulties.indexOf(difficulty.toUpperCase());
const suggestedIdx = difficulties.indexOf(suggested); const suggestedIdx = difficulties.indexOf(suggested);
// Only warn if current difficulty is LOWER than suggested // Only warn if current difficulty is LOWER than suggested

View file

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

View file

@ -62,7 +62,7 @@ function getDifficultyAdvice() {
reason = ''; reason = '';
} }
const currentIdx = difficulties.indexOf(difficulty); const currentIdx = difficulties.indexOf(difficulty.toUpperCase());
const suggestedIdx = difficulties.indexOf(suggested); const suggestedIdx = difficulties.indexOf(suggested);
// Only warn if current difficulty is LOWER than suggested // Only warn if current difficulty is LOWER than suggested

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.31 // @version 0.13.32
// @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

@ -291,7 +291,13 @@ function findBestChannelingTarget() {
let maxDur = STATE.maxBuffDur[b.icon] || 0; let maxDur = STATE.maxBuffDur[b.icon] || 0;
// If max matches current (first observation, no learning yet), assume min 30 turns // If max matches current (first observation, no learning yet), assume min 30 turns
if (maxDur <= dur) maxDur = Math.max(dur + 1, 30); if (maxDur <= dur) maxDur = Math.max(dur + 1, 30);
const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; // Check if maxBuffDur is unreliable: if the gap between max and current
// is tiny (<10% of max), we haven't seen the true cap yet. In that case,
// just use the simple shouldBuff check instead.
const isReliable = maxDur > dur + 10;
const emptiness = isReliable
? Math.min(1, (maxDur - dur) / maxDur)
: (dur <= b.minTurns ? 0.5 : 0); // treat as half-empty if past minTurns
// Score: emptiness × mp_cost — an empty expensive buff is best // Score: emptiness × mp_cost — an empty expensive buff is best
const score = emptiness * mpCost; const score = emptiness * mpCost;
candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur });
@ -302,8 +308,12 @@ function findBestChannelingTarget() {
console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0'); console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0');
// Only use a buff if it actually needs refreshing (score >= 30 means // Only use a buff if it actually needs refreshing (score >= 30 means
// e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell) // e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell)
if (candidates[0].score >= 30) { // When maxBuffDur is unreliable (gap < 10), skip the threshold for
return { type: 'spell', name: candidates[0].spell, selfTarget: true }; // buffs that have ticked past their minTurns — they genuinely need refresh
const top = candidates[0];
const gapOk = top.maxDur - top.dur > 10;
if (top.score >= 30 || (!gapOk && top.dur <= BUFF_PRIORITY.find(b => b.icon === top.icon)?.minTurns)) {
return { type: 'spell', name: top.spell, selfTarget: true };
} }
} }