From c0c626c0bf895ca4e5baef731850d058fbe9815b Mon Sep 17 00:00:00 2001 From: GaboGG Date: Sun, 26 Jul 2026 13:44:17 -0400 Subject: [PATCH] v0.13.12 - Fill-level scoring for channeling consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Score = emptiness_ratio × mp_cost - emptiness_ratio = (max_dur - current) / max_dur - max_dur is tracked dynamically per buff in STATE.maxBuffDur (learned from the highest duration seen per icon) - Freshly cast buffs score ~0 regardless of MP cost - A 100-MP buff at 1/90 turns scores 98.9 - A 40-MP buff at 25/50 turns scores 20.0 - A 140-MP buff at 71/80 turns scores 15.75 - Removed the Heartseeker >50 skip (no longer needed - fill level handles it) --- scripts/hv-unified.user.js | 44 +++++++++++++++++++++++++------------- scripts/latest.user.js | 44 +++++++++++++++++++++++++------------- src/battle-parser.js | 7 +++++- src/state.js | 2 ++ src/strategy-engine.js | 35 ++++++++++++++++++------------ 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/scripts/hv-unified.user.js b/scripts/hv-unified.user.js index 66ec63f..c7c38e5 100644 --- a/scripts/hv-unified.user.js +++ b/scripts/hv-unified.user.js @@ -77,6 +77,8 @@ const STATE = { inBattle: false, battleInitialized: false, + maxBuffDur: {}, // Tracks highest duration seen per buff icon + hoverTarget: -1, interruptHover: false, interruptAlert: false, @@ -483,7 +485,12 @@ function parseBattleState() { // 'permanent' means the buff lasts indefinitely (Absorb, autocast effects) const isPermanent = omo.includes("'permanent'"); const name = (img.src || '').split('/').pop().replace('.png', ''); - STATE.buffs[name] = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + const turns = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + STATE.buffs[name] = turns; + // Track the max duration seen for this buff (for fill-level calculation) + if (turns < 999 && turns > (STATE.maxBuffDur[name] || 0)) { + STATE.maxBuffDur[name] = turns; + } }); } // Spark of Life check @@ -861,16 +868,22 @@ function kickstartChanneling() { return null; } -// ── Channeling consumption — pick the buff that benefits most from being free ── +// ── Channeling consumption — pick the best value from fill level × cost ── // 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. +// - It's nearly empty (close to expiring relative to its max duration) +// - It costs a lot of MP (more savings from the free cast) +// +// Score = emptiess_ratio × mp_cost +// = (max_dur - current) / max_dur × mp_cost +// +// A 100-MP buff with 90 turns max at 1 turn remaining: (90-1)/90 * 100 = 98.9 +// A 40-MP buff with 50 turns max at 25 remaining: (50-25)/50 * 40 = 20.0 +// A 140-MP buff with 80 turns max at 71 remaining: (80-71)/80 * 140 = 15.75 +// A freshly cast buff always scores near 0. +// +// max_dur is tracked dynamically per buff in STATE.maxBuffDur. +// It updates each time we see a new high, learning your actual duration. function findBestChannelingTarget() { if (!STATE.channeling || STATE.mp < 0.10) return null; @@ -879,19 +892,20 @@ function findBestChannelingTarget() { 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 }); + // Fill level: how empty is this buff relative to its max observed duration + const maxDur = STATE.maxBuffDur[b.icon] || (dur + 10); // fallback if unknown + const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; + // Score: emptiness × mp_cost — an empty expensive buff is best + const score = emptiness * mpCost; + candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); } if (candidates.length > 0) { candidates.sort((a, b) => b.score - a.score); // Only use a buff if it actually needs refreshing (score >= 5 means - // e.g. a 50-MP spell with <9 turns left or a 140-MP spell with <27 turns) + // e.g. 10% emptiness on a 50-MP spell, or 5% on a 100-MP spell) if (candidates[0].score >= 5) { return { type: 'spell', name: candidates[0].spell, selfTarget: true }; } diff --git a/scripts/latest.user.js b/scripts/latest.user.js index 66ec63f..c7c38e5 100644 --- a/scripts/latest.user.js +++ b/scripts/latest.user.js @@ -77,6 +77,8 @@ const STATE = { inBattle: false, battleInitialized: false, + maxBuffDur: {}, // Tracks highest duration seen per buff icon + hoverTarget: -1, interruptHover: false, interruptAlert: false, @@ -483,7 +485,12 @@ function parseBattleState() { // 'permanent' means the buff lasts indefinitely (Absorb, autocast effects) const isPermanent = omo.includes("'permanent'"); const name = (img.src || '').split('/').pop().replace('.png', ''); - STATE.buffs[name] = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + const turns = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + STATE.buffs[name] = turns; + // Track the max duration seen for this buff (for fill-level calculation) + if (turns < 999 && turns > (STATE.maxBuffDur[name] || 0)) { + STATE.maxBuffDur[name] = turns; + } }); } // Spark of Life check @@ -861,16 +868,22 @@ function kickstartChanneling() { return null; } -// ── Channeling consumption — pick the buff that benefits most from being free ── +// ── Channeling consumption — pick the best value from fill level × cost ── // 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. +// - It's nearly empty (close to expiring relative to its max duration) +// - It costs a lot of MP (more savings from the free cast) +// +// Score = emptiess_ratio × mp_cost +// = (max_dur - current) / max_dur × mp_cost +// +// A 100-MP buff with 90 turns max at 1 turn remaining: (90-1)/90 * 100 = 98.9 +// A 40-MP buff with 50 turns max at 25 remaining: (50-25)/50 * 40 = 20.0 +// A 140-MP buff with 80 turns max at 71 remaining: (80-71)/80 * 140 = 15.75 +// A freshly cast buff always scores near 0. +// +// max_dur is tracked dynamically per buff in STATE.maxBuffDur. +// It updates each time we see a new high, learning your actual duration. function findBestChannelingTarget() { if (!STATE.channeling || STATE.mp < 0.10) return null; @@ -879,19 +892,20 @@ function findBestChannelingTarget() { 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 }); + // Fill level: how empty is this buff relative to its max observed duration + const maxDur = STATE.maxBuffDur[b.icon] || (dur + 10); // fallback if unknown + const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; + // Score: emptiness × mp_cost — an empty expensive buff is best + const score = emptiness * mpCost; + candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); } if (candidates.length > 0) { candidates.sort((a, b) => b.score - a.score); // Only use a buff if it actually needs refreshing (score >= 5 means - // e.g. a 50-MP spell with <9 turns left or a 140-MP spell with <27 turns) + // e.g. 10% emptiness on a 50-MP spell, or 5% on a 100-MP spell) if (candidates[0].score >= 5) { return { type: 'spell', name: candidates[0].spell, selfTarget: true }; } diff --git a/src/battle-parser.js b/src/battle-parser.js index 36e32b8..d0c40fe 100644 --- a/src/battle-parser.js +++ b/src/battle-parser.js @@ -145,7 +145,12 @@ function parseBattleState() { // 'permanent' means the buff lasts indefinitely (Absorb, autocast effects) const isPermanent = omo.includes("'permanent'"); const name = (img.src || '').split('/').pop().replace('.png', ''); - STATE.buffs[name] = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + const turns = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50); + STATE.buffs[name] = turns; + // Track the max duration seen for this buff (for fill-level calculation) + if (turns < 999 && turns > (STATE.maxBuffDur[name] || 0)) { + STATE.maxBuffDur[name] = turns; + } }); } // Spark of Life check diff --git a/src/state.js b/src/state.js index b6984b9..db45b01 100644 --- a/src/state.js +++ b/src/state.js @@ -13,6 +13,8 @@ const STATE = { inBattle: false, battleInitialized: false, + maxBuffDur: {}, // Tracks highest duration seen per buff icon + hoverTarget: -1, interruptHover: false, interruptAlert: false, diff --git a/src/strategy-engine.js b/src/strategy-engine.js index 854c22f..6809e2c 100644 --- a/src/strategy-engine.js +++ b/src/strategy-engine.js @@ -213,16 +213,22 @@ function kickstartChanneling() { return null; } -// ── Channeling consumption — pick the buff that benefits most from being free ── +// ── Channeling consumption — pick the best value from fill level × cost ── // 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. +// - It's nearly empty (close to expiring relative to its max duration) +// - It costs a lot of MP (more savings from the free cast) +// +// Score = emptiess_ratio × mp_cost +// = (max_dur - current) / max_dur × mp_cost +// +// A 100-MP buff with 90 turns max at 1 turn remaining: (90-1)/90 * 100 = 98.9 +// A 40-MP buff with 50 turns max at 25 remaining: (50-25)/50 * 40 = 20.0 +// A 140-MP buff with 80 turns max at 71 remaining: (80-71)/80 * 140 = 15.75 +// A freshly cast buff always scores near 0. +// +// max_dur is tracked dynamically per buff in STATE.maxBuffDur. +// It updates each time we see a new high, learning your actual duration. function findBestChannelingTarget() { if (!STATE.channeling || STATE.mp < 0.10) return null; @@ -231,19 +237,20 @@ function findBestChannelingTarget() { 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 }); + // Fill level: how empty is this buff relative to its max observed duration + const maxDur = STATE.maxBuffDur[b.icon] || (dur + 10); // fallback if unknown + const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1; + // Score: emptiness × mp_cost — an empty expensive buff is best + const score = emptiness * mpCost; + candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur }); } if (candidates.length > 0) { candidates.sort((a, b) => b.score - a.score); // Only use a buff if it actually needs refreshing (score >= 5 means - // e.g. a 50-MP spell with <9 turns left or a 140-MP spell with <27 turns) + // e.g. 10% emptiness on a 50-MP spell, or 5% on a 100-MP spell) if (candidates[0].score >= 5) { return { type: 'spell', name: candidates[0].spell, selfTarget: true }; }