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:
parent
37b502aa65
commit
c0c626c0bf
5 changed files with 87 additions and 45 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const STATE = {
|
|||
inBattle: false,
|
||||
battleInitialized: false,
|
||||
|
||||
maxBuffDur: {}, // Tracks highest duration seen per buff icon
|
||||
|
||||
hoverTarget: -1,
|
||||
interruptHover: false,
|
||||
interruptAlert: false,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue