Boost Planner: 'extra cash now' input finds the best campaign combo (any level mix, up to 3 lanes — slots rule confirmed by Marty)
- boostSim(): generic lane simulator to 3xL7, exact same reserve rules as computePlan (verified: agrees on all 8 baseline starts) - enumerateBoostCombos(): every affordable add-on for the free slots, activation once per level - Overview card ranks every combo vs 'just bank it' by time to endgame; share link carries &boost= - test_scenarios section 8: baseline consistency, combo costs, ranking, the +$300 scenario (best: add L3+L2, 3 cycles faster than banking)
This commit is contained in:
+161
-1
@@ -1271,6 +1271,11 @@
|
|||||||
<label>Park at which level?</label>
|
<label>Park at which level?</label>
|
||||||
<select id="parkPhase"></select>
|
<select id="parkPhase"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>💵 Extra cash to inject now (optional)</label>
|
||||||
|
<input type="number" id="boostFunds" min="0" step="10" placeholder="e.g. 300" style="width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);color:var(--text-primary);font-size:0.95rem;">
|
||||||
|
<div class="sub-hint">The Boost Planner below finds the best campaign combo those dollars can buy</div>
|
||||||
|
</div>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
<input type="checkbox" id="showReferral">
|
<input type="checkbox" id="showReferral">
|
||||||
@@ -1319,6 +1324,15 @@
|
|||||||
|
|
||||||
<div class="results active" id="tab-overview">
|
<div class="results active" id="tab-overview">
|
||||||
<div class="summary-grid" id="summaryCards"></div>
|
<div class="summary-grid" id="summaryCards"></div>
|
||||||
|
<div class="table-wrap" id="boostWrap" style="display:none">
|
||||||
|
<div class="table-title">💵 Boost Planner — what your extra cash can do</div>
|
||||||
|
<div class="legend"><div class="legend-item" id="boostSubtitle"></div></div>
|
||||||
|
<table><thead><tr>
|
||||||
|
<th>Move</th><th class="num">Upfront cost</th><th class="num">Profit/cycle after</th>
|
||||||
|
<th>Time to 3× L7</th><th>vs. just banking it</th>
|
||||||
|
</tr></thead><tbody id="boostBody"></tbody></table>
|
||||||
|
<div class="sub-hint" style="margin-top:8px" id="boostNote"></div>
|
||||||
|
</div>
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<h3>📈 Cumulative Profit Over Time</h3>
|
<h3>📈 Cumulative Profit Over Time</h3>
|
||||||
<div id="chartContainer"><canvas id="profitChart"></canvas></div>
|
<div id="chartContainer"><canvas id="profitChart"></canvas></div>
|
||||||
@@ -1641,6 +1655,146 @@ function phaseInfo(phases, pocketPct = 0) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Boost Planner (Marty 2026-07-31) ───────────────────────────────────────
|
||||||
|
// "I have extra cash right now — what's the best combination of campaigns to
|
||||||
|
// buy?" Slots rule confirmed by Marty: any level mix, up to 3 simultaneous
|
||||||
|
// campaigns, no sequential requirement. The planner enumerates every
|
||||||
|
// affordable add-on combo for the free slots, runs each through a generic
|
||||||
|
// lane simulator that follows the SAME reserve rules as computePlan (bank
|
||||||
|
// profits, recycle carried campaigns, roll the full payout on the upgrade
|
||||||
|
// cycle, activation once per level), and ranks by time to 3× L7. The
|
||||||
|
// baseline ("just bank it") gets the same cash as reserves, so the
|
||||||
|
// comparison is honest: buying lanes has to BEAT banking to win.
|
||||||
|
const LEVEL_TO_PHASE = { 1:'L1-only', 2:'L2+L1', 3:'L3+L1', 4:'L4+L3', 5:'L5+L4', 6:'L6', 7:'L7' };
|
||||||
|
|
||||||
|
function laneStats(lanes) {
|
||||||
|
let payout = 0, profit = 0, camp = 0;
|
||||||
|
for (const lv of lanes) {
|
||||||
|
const L = LEVELS[lv - 1];
|
||||||
|
payout += L.payout; profit += L.netProfit; camp += L.camp;
|
||||||
|
}
|
||||||
|
return { payout, profit, camp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate from an arbitrary lane set + starting reserve to 3×L7.
|
||||||
|
// Mirrors computePlan's rules exactly; capped for safety.
|
||||||
|
function boostSim(startLanes, bal0, pocketPct = 0) {
|
||||||
|
const keepFrac = 1 - pocketPct / 100;
|
||||||
|
let lanes = [...startLanes].sort((a, b) => b - a);
|
||||||
|
let bal = bal0;
|
||||||
|
let cycles = 0;
|
||||||
|
const activated = new Set(lanes);
|
||||||
|
const path = [lanes.join('+')];
|
||||||
|
let guard = 0;
|
||||||
|
while (!(lanes.length === 3 && lanes.every(l => l === 7)) && guard++ < 40) {
|
||||||
|
const maxLane = Math.max(...lanes);
|
||||||
|
const targetKey = maxLane >= 7 ? '3xL7' : LEVEL_TO_PHASE[maxLane + 1];
|
||||||
|
const targetSlots = targetKey === '3xL7' ? [7, 7, 7] : [...PHASE_SLOTS[targetKey]];
|
||||||
|
// Carried lanes: current campaigns that keep running in the target phase.
|
||||||
|
const remaining = [...targetSlots];
|
||||||
|
const carried = [];
|
||||||
|
for (const lv of lanes) {
|
||||||
|
const j = remaining.indexOf(lv);
|
||||||
|
if (j !== -1) { carried.push(lv); remaining.splice(j, 1); }
|
||||||
|
}
|
||||||
|
let cost = 0;
|
||||||
|
for (const lv of remaining) {
|
||||||
|
const L = LEVELS[lv - 1];
|
||||||
|
cost += L.camp + (activated.has(lv) ? 0 : L.act);
|
||||||
|
activated.add(lv);
|
||||||
|
}
|
||||||
|
const s = laneStats(lanes);
|
||||||
|
const carriedCamp = carried.reduce((sum, lv) => sum + LEVELS[lv - 1].camp, 0);
|
||||||
|
let spin = 0;
|
||||||
|
while (spin++ < 500) {
|
||||||
|
cycles++;
|
||||||
|
if (bal + s.payout - carriedCamp >= cost) { bal += s.payout - carriedCamp - cost; break; }
|
||||||
|
bal += s.profit * keepFrac;
|
||||||
|
}
|
||||||
|
lanes = targetSlots.sort((a, b) => b - a);
|
||||||
|
path.push(targetKey);
|
||||||
|
}
|
||||||
|
cycles += 1; // the first 3×L7 endgame cycle (matches computePlan's 3xL7 entry)
|
||||||
|
return { cycles, months: Math.round(cycles * 19 / 30.4 * 10) / 10, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every add-on combo (multiset of levels, up to the free slots) affordable
|
||||||
|
// within `budget`. Activation charged once per newly-activated level.
|
||||||
|
function enumerateBoostCombos(currentLanes, budget) {
|
||||||
|
const freeSlots = 3 - currentLanes.length;
|
||||||
|
const activated = new Set();
|
||||||
|
for (let lv = 1; lv <= Math.max(...currentLanes); lv++) activated.add(lv);
|
||||||
|
const combos = [];
|
||||||
|
const walk = (startLv, picked) => {
|
||||||
|
if (picked.length > 0) {
|
||||||
|
const seen = new Set(activated);
|
||||||
|
let cost = 0;
|
||||||
|
for (const lv of picked) {
|
||||||
|
const L = LEVELS[lv - 1];
|
||||||
|
cost += L.camp + (seen.has(lv) ? 0 : L.act);
|
||||||
|
seen.add(lv);
|
||||||
|
}
|
||||||
|
if (cost <= budget) combos.push({ lanes: [...picked], cost });
|
||||||
|
}
|
||||||
|
if (picked.length >= freeSlots) return;
|
||||||
|
for (let lv = startLv; lv >= 1; lv--) walk(lv, [...picked, lv]);
|
||||||
|
};
|
||||||
|
walk(7, []);
|
||||||
|
return combos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank every affordable move for `extra` dollars from the current phase.
|
||||||
|
function boostPlan(phaseKey, extra, pocketPct = 0) {
|
||||||
|
const currentLanes = [...PHASE_SLOTS[phaseKey]];
|
||||||
|
const baseline = boostSim(currentLanes, extra, pocketPct);
|
||||||
|
const options = enumerateBoostCombos(currentLanes, extra).map(c => {
|
||||||
|
const lanes = [...currentLanes, ...c.lanes];
|
||||||
|
const sim = boostSim(lanes, extra - c.cost, pocketPct);
|
||||||
|
return { ...c, lanes, sim, profitPerCycle: laneStats(lanes).profit };
|
||||||
|
});
|
||||||
|
options.sort((a, b) => a.sim.cycles - b.sim.cycles || a.cost - b.cost);
|
||||||
|
return { baseline, currentLanes, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoost(phaseKey, mode, pocketPct) {
|
||||||
|
const wrap = document.getElementById('boostWrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
const amount = parseInt(document.getElementById('boostFunds')?.value) || 0;
|
||||||
|
if (amount <= 0 || phaseKey === '3xL7') { wrap.style.display = 'none'; return; }
|
||||||
|
wrap.style.display = '';
|
||||||
|
const plan = boostPlan(phaseKey, amount, pocketPct);
|
||||||
|
const cur = plan.currentLanes.map(l => 'L' + l).join(' + ');
|
||||||
|
document.getElementById('boostSubtitle').textContent =
|
||||||
|
`$${amount.toLocaleString()} extra while running ${cur} — every affordable combo, simulated to 3× L7`;
|
||||||
|
const money = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||||
|
const rows = [];
|
||||||
|
const base = plan.baseline;
|
||||||
|
const deltaTxt = sim => {
|
||||||
|
const d = base.cycles - sim.cycles;
|
||||||
|
if (d <= 0) return d === 0 ? 'same speed' : `${-d} cycle${d === -1 ? '' : 's'} slower`;
|
||||||
|
const mo = Math.round(d * 19 / 30.4 * 10) / 10;
|
||||||
|
return `${d} cycle${d === 1 ? '' : 's'} (~${mo} mo) faster`;
|
||||||
|
};
|
||||||
|
rows.push(`<tr><td>🏦 Bank it (no new campaigns)</td><td class="num">$0</td>` +
|
||||||
|
`<td class="num">${money(laneStats(plan.currentLanes).profit)}</td>` +
|
||||||
|
`<td>${base.cycles} cycles · ~${base.months} mo</td><td>—</td></tr>`);
|
||||||
|
plan.options.slice(0, 5).forEach((o, i) => {
|
||||||
|
const label = 'Add ' + o.lanes.slice(plan.currentLanes.length).map(l => 'L' + l).join(' + ');
|
||||||
|
const best = i === 0 && o.sim.cycles < base.cycles;
|
||||||
|
rows.push(`<tr${best ? ' style="background:rgba(74,222,128,.07)"' : ''}>` +
|
||||||
|
`<td>${best ? '⭐ ' : ''}${label}</td><td class="num">${money(o.cost)}</td>` +
|
||||||
|
`<td class="num">${money(o.profitPerCycle)}</td>` +
|
||||||
|
`<td>${o.sim.cycles} cycles · ~${o.sim.months} mo</td><td>${deltaTxt(o.sim)}</td></tr>`);
|
||||||
|
});
|
||||||
|
document.getElementById('boostBody').innerHTML = rows.join('');
|
||||||
|
document.getElementById('boostNote').textContent =
|
||||||
|
'Rules per the program: up to 3 campaigns at once, any level mix — no need to climb sequentially. ' +
|
||||||
|
'Activation is charged once per level; unspent cash goes into reserves either way. ' +
|
||||||
|
(mode === 'park' ? 'Shown on the climb-to-endgame basis; if you park, the best combo is simply the highest profit/cycle you can afford.' :
|
||||||
|
mode === 'pocket' ? `Simulated with your ${pocketPct}% pocket setting.` :
|
||||||
|
'Simulated in Accelerate mode, same reserve math as the rest of the calculator.');
|
||||||
|
}
|
||||||
|
|
||||||
function getLevelCost(upTo) {
|
function getLevelCost(upTo) {
|
||||||
if (upTo <= 0) return 0;
|
if (upTo <= 0) return 0;
|
||||||
return LEVELS.slice(0, upTo).reduce((s, l) => s + l.total, 0);
|
return LEVELS.slice(0, upTo).reduce((s, l) => s + l.total, 0);
|
||||||
@@ -1756,6 +1910,7 @@ function updateAll() {
|
|||||||
const selfCost = getLevelCost(selfLevel);
|
const selfCost = getLevelCost(selfLevel);
|
||||||
const effective = getEffectiveStart(giftLevel, selfLevel);
|
const effective = getEffectiveStart(giftLevel, selfLevel);
|
||||||
let phases = getPhaseSequence(effective);
|
let phases = getPhaseSequence(effective);
|
||||||
|
renderBoost(phases[0], mode, pocketPct);
|
||||||
// Park & collect: keep the park-level select in sync with the sequence,
|
// Park & collect: keep the park-level select in sync with the sequence,
|
||||||
// then truncate the journey at the chosen level (terminal = collect there).
|
// then truncate the journey at the chosen level (terminal = collect there).
|
||||||
const parkSel = document.getElementById('parkPhase');
|
const parkSel = document.getElementById('parkPhase');
|
||||||
@@ -2121,7 +2276,7 @@ ${numPeople > 0 ? `
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Event Wiring ──────────────────────────────────────────
|
// ─── Event Wiring ──────────────────────────────────────────
|
||||||
['numPeople','commissionPct','giftLevel','selfFundLevel','showReferral','alreadyJoined','strategyMode','pocketPct','parkPhase'].forEach(id => {
|
['numPeople','commissionPct','giftLevel','selfFundLevel','showReferral','alreadyJoined','strategyMode','pocketPct','parkPhase','boostFunds'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
el.addEventListener('input', updateAll);
|
el.addEventListener('input', updateAll);
|
||||||
el.addEventListener('change', updateAll);
|
el.addEventListener('change', updateAll);
|
||||||
@@ -2160,6 +2315,8 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
|
|||||||
const park = p.get('park');
|
const park = p.get('park');
|
||||||
if (park) document.getElementById('parkPhase').dataset.want = park;
|
if (park) document.getElementById('parkPhase').dataset.want = park;
|
||||||
}
|
}
|
||||||
|
const boost = p.get('boost');
|
||||||
|
if (boost && /^\d{1,6}$/.test(boost)) document.getElementById('boostFunds').value = boost;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
updateAll();
|
updateAll();
|
||||||
@@ -2189,6 +2346,9 @@ function getShareUrl(refUser) {
|
|||||||
if (modeSel.value === 'pocket') url += '&pk=' + document.getElementById('pocketPct').value;
|
if (modeSel.value === 'pocket') url += '&pk=' + document.getElementById('pocketPct').value;
|
||||||
if (modeSel.value === 'park') url += '&park=' + encodeURIComponent(document.getElementById('parkPhase').value);
|
if (modeSel.value === 'park') url += '&park=' + encodeURIComponent(document.getElementById('parkPhase').value);
|
||||||
}
|
}
|
||||||
|
const boostEl = document.getElementById('boostFunds');
|
||||||
|
const boostVal = boostEl ? parseInt(boostEl.value) || 0 : 0;
|
||||||
|
if (boostVal > 0) url += '&boost=' + boostVal;
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-2
@@ -12,9 +12,10 @@ if (start < 0 || end < 0) { console.error("FATAL: extraction markers missing");
|
|||||||
// Indirect eval: keeps the extracted declarations out of this module's scope;
|
// Indirect eval: keeps the extracted declarations out of this module's scope;
|
||||||
// the trailing expression hands back everything we need.
|
// the trailing expression hands back everything we need.
|
||||||
const { LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence,
|
const { LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence,
|
||||||
estimateMonths, estimateYear1Cum, buildMonthlyTimeline } = (0, eval)(
|
estimateMonths, estimateYear1Cum, buildMonthlyTimeline,
|
||||||
|
boostSim, enumerateBoostCombos, boostPlan } = (0, eval)(
|
||||||
html.slice(start, end) +
|
html.slice(start, end) +
|
||||||
";({LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence, estimateMonths, estimateYear1Cum, buildMonthlyTimeline})"
|
";({LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence, estimateMonths, estimateYear1Cum, buildMonthlyTimeline, boostSim, enumerateBoostCombos, boostPlan})"
|
||||||
);
|
);
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -134,5 +135,48 @@ console.log("7) strategy modes:");
|
|||||||
check("parked timeline runs 10 collection cycles", tl.length === 10);
|
check("parked timeline runs 10 collection cycles", tl.length === 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("8) Boost Planner (extra-funds combos, Marty 2026-07-31):");
|
||||||
|
{
|
||||||
|
// 8a. boostSim with $0 extra must agree with computePlan for every start.
|
||||||
|
for (let eff = 0; eff <= 7; eff++) {
|
||||||
|
const phases = getPhaseSequence(eff);
|
||||||
|
const plan = computePlan(phases);
|
||||||
|
const planTotal = phases.reduce((s, k) => s + plan[k].cycles, 0);
|
||||||
|
const sim = boostSim([...PHASE_SLOTS[phases[0]]], 0);
|
||||||
|
check(`eff=${eff}: boostSim baseline ${sim.cycles} == computePlan total ${planTotal}`,
|
||||||
|
sim.cycles === planTotal, `sim path ${sim.path.join(" → ")}`);
|
||||||
|
}
|
||||||
|
// 8b. combo enumeration: costs + slot/activation rules from an L1-only start.
|
||||||
|
const combos = enumerateBoostCombos([1], 300);
|
||||||
|
const byKey = Object.fromEntries(combos.map(c => [c.lanes.join("+"), c.cost]));
|
||||||
|
check("add L3 costs 165 (camp 150 + act 15)", byKey["3"] === 165, `got ${byKey["3"]}`);
|
||||||
|
check("add L3+L1 costs 178 (L1 already activated: camp only)", byKey["3+1"] === 178, `got ${byKey["3+1"]}`);
|
||||||
|
check("add L1+L1 costs 26 (two camp-only lanes)", byKey["1+1"] === 26, `got ${byKey["1+1"]}`);
|
||||||
|
check("L4 (330) not affordable at $300", !("4" in byKey));
|
||||||
|
check("no combo exceeds 2 added lanes (3 slots total)", combos.every(c => c.lanes.length <= 2));
|
||||||
|
// 8c. Marty's scenario: extra cash buys a real speedup over banking it.
|
||||||
|
const p300 = boostPlan("L1-only", 300);
|
||||||
|
const best = p300.options[0];
|
||||||
|
check(`+$300 from L1: best combo [add L${best.lanes.slice(1).join("+L")}] beats banking ` +
|
||||||
|
`(${best.sim.cycles} < ${p300.baseline.cycles} cycles)`,
|
||||||
|
best.sim.cycles < p300.baseline.cycles);
|
||||||
|
// 8d. options are ranked (cycles ascending, cost tiebreak).
|
||||||
|
const sorted = p300.options.every((o, i, a) =>
|
||||||
|
i === 0 || a[i - 1].sim.cycles < o.sim.cycles ||
|
||||||
|
(a[i - 1].sim.cycles === o.sim.cycles && a[i - 1].cost <= o.cost));
|
||||||
|
check("options ranked by cycles then cost", sorted);
|
||||||
|
// 8e. community claim probe: report best 1-added-lane vs 2-added-lanes at +$1000.
|
||||||
|
const p1k = boostPlan("L1-only", 1000);
|
||||||
|
const best1 = p1k.options.filter(o => o.lanes.length === 2)[0];
|
||||||
|
const best2 = p1k.options.filter(o => o.lanes.length === 3)[0];
|
||||||
|
if (best1 && best2) {
|
||||||
|
console.log(` info: +$1000 from L1 — best 2-lane total [${best1.lanes.join("+")}] ${best1.sim.cycles}cy` +
|
||||||
|
` vs best 3-lane total [${best2.lanes.join("+")}] ${best2.sim.cycles}cy`);
|
||||||
|
}
|
||||||
|
console.log(` info: +$300 from L1 top 3: ` + p300.options.slice(0, 3)
|
||||||
|
.map(o => `[add ${o.lanes.slice(1).map(l => "L" + l).join("+") || "?"}] $${o.cost} → ${o.sim.cycles}cy/${o.sim.months}mo`)
|
||||||
|
.join(" · ") + ` · bank: ${p300.baseline.cycles}cy/${p300.baseline.months}mo`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURE(S)`);
|
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURE(S)`);
|
||||||
process.exit(failures === 0 ? 0 : 1);
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user