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:
martbost
2026-07-31 13:54:17 -05:00
committed by root
parent 8e7420db30
commit 5a6a3f0a04
2 changed files with 207 additions and 3 deletions
+161 -1
View File
@@ -1271,6 +1271,11 @@
<label>Park at which level?</label>
<select id="parkPhase"></select>
</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-group">
<input type="checkbox" id="showReferral">
@@ -1319,6 +1324,15 @@
<div class="results active" id="tab-overview">
<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">
<h3>📈 Cumulative Profit Over Time</h3>
<div id="chartContainer"><canvas id="profitChart"></canvas></div>
@@ -1641,6 +1655,146 @@ function phaseInfo(phases, pocketPct = 0) {
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) {
if (upTo <= 0) return 0;
return LEVELS.slice(0, upTo).reduce((s, l) => s + l.total, 0);
@@ -1756,6 +1910,7 @@ function updateAll() {
const selfCost = getLevelCost(selfLevel);
const effective = getEffectiveStart(giftLevel, selfLevel);
let phases = getPhaseSequence(effective);
renderBoost(phases[0], mode, pocketPct);
// Park & collect: keep the park-level select in sync with the sequence,
// then truncate the journey at the chosen level (terminal = collect there).
const parkSel = document.getElementById('parkPhase');
@@ -2121,7 +2276,7 @@ ${numPeople > 0 ? `
}
// ─── 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);
el.addEventListener('input', updateAll);
el.addEventListener('change', updateAll);
@@ -2160,6 +2315,8 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
const park = p.get('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();
@@ -2189,6 +2346,9 @@ function getShareUrl(refUser) {
if (modeSel.value === 'pocket') url += '&pk=' + document.getElementById('pocketPct').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;
}
+46 -2
View File
@@ -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;
// the trailing expression hands back everything we need.
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) +
";({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;
@@ -134,5 +135,48 @@ console.log("7) strategy modes:");
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)`);
process.exit(failures === 0 ? 0 : 1);