v0.13.12 - Fill-level scoring for channeling consumption

- 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)
This commit is contained in:
GaboGG 2026-07-26 13:44:17 -04:00
parent 37b502aa65
commit c0c626c0bf
5 changed files with 87 additions and 45 deletions

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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

View file

@ -13,6 +13,8 @@ const STATE = {
inBattle: false,
battleInitialized: false,
maxBuffDur: {}, // Tracks highest duration seen per buff icon
hoverTarget: -1,
interruptHover: false,
interruptAlert: false,

View file

@ -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 };
}