5 actions today
5 days left
Volunteer
VOLUNTEER
Fundraise
FUNDRAISE
Advertise
ADVERTISE
RESULTS
Your campaign style: The Risk Taker
Building your share card...
Your election share card
Cheering crowd
Candidate
PROJECTION
' ); win.document.close(); showPopup( "Share image opened", "Your browser opened the result image in a new tab. Press and hold or save it from there to share.", shareText + "\n" + shareUrl, [{ label: "OK", onClick: () => hidePopup() }], {} ); return; } throw new Error("Could not open a new tab."); } catch (err){ showPopup( "Share failed", "I generated the image, but this browser would not open the share sheet, copy, download, or open it automatically.", "Try again after hosting the game on a normal website domain or use a different browser/device.", [{ label: "OK", onClick: () => hidePopup() }], {} ); } } async function shareFinalImage(){ if (!lastFinalPayload) return; await sharePayloadImage(lastFinalPayload, lastFinalPayload.shareContext || { scenarioKey, level: current2028Level, dailyInfo: window.dailyInfo || null }); } function stopFinalTicker(){ if (finalTickerInterval){ clearInterval(finalTickerInterval); finalTickerInterval = null; } finalTickerLines = []; finalTickerIdx = 0; if (finalTickerEl){ finalTickerEl.innerHTML = ""; finalTickerEl.classList.remove("on"); finalTickerEl.style.removeProperty("--tickerDuration"); } } function startFinalTicker(lines){ stopFinalTicker(); finalTickerLines = (lines || []).filter(Boolean); if (!finalTickerEl || !finalTickerLines.length) return; const joined = finalTickerLines.join(" • "); const safeText = escapeHtml(joined); const duration = Math.max(18, Math.min(46, joined.length * 0.14)); finalTickerEl.classList.add("on"); finalTickerEl.style.setProperty("--tickerDuration", `${duration}s`); finalTickerEl.innerHTML = `
` + `${safeText}` + `` + `
`; } function partyPlural(p){ return p === "D" ? "Democrats" : "Republicans"; } function htmlLineBreaks(lines){ return (lines || []).map(x => escapeHtml(x)).join("
"); } function showRecordScreen(returnTo){ overlay.classList.add("startMode"); showPopup("Your Election Record", "", "", [], {}); popupBody.innerHTML = ""; popupButtons.innerHTML = ""; const record = loadRecord(); let scrubbed2028 = false; for (const key of Object.keys(record)){ if (/^\d+$/.test(key) && record[key] && record[key].mode === "2028"){ delete record[key]; scrubbed2028 = true; } } if (scrubbed2028) saveRecord(record); const trumpEntry = record.historic_2016 || null; const obamaEntry = record.historic_2012 || null; const dailyParts = etNowParts(); const dailyStore = loadDailyStore(); const dailyStats = getRunHistoryStats("daily"); const wrap = document.createElement("div"); wrap.style.display = "grid"; wrap.style.gap = "14px"; function makeRecordShell(accentA, accentB){ const shell = document.createElement("div"); shell.style.border = "1px solid rgba(255,255,255,0.18)"; shell.style.borderRadius = "18px"; shell.style.padding = "12px"; shell.style.background = `linear-gradient(180deg, ${accentA}, ${accentB})`; shell.style.boxShadow = "0 14px 28px rgba(0,0,0,0.22), inset 0 1px 0 rgba(255,255,255,0.08)"; return shell; } function makeActionButton(label, onClick, dark){ const btn = document.createElement("button"); btn.className = "btn"; btn.textContent = label; btn.style.width = "auto"; btn.style.padding = "7px 11px"; btn.style.fontSize = "11px"; btn.style.lineHeight = "1"; btn.style.minWidth = "0"; btn.style.borderRadius = "999px"; btn.style.background = dark ? "#111" : "rgba(255,255,255,0.12)"; btn.style.color = "#fff"; btn.style.borderColor = dark ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.16)"; btn.addEventListener("click", (e) => { e.stopPropagation(); onClick(); }); return btn; } function makeButtonRow(){ const row = document.createElement("div"); row.style.display = "flex"; row.style.flexWrap = "wrap"; row.style.justifyContent = "flex-end"; row.style.gap = "8px"; row.style.marginTop = "10px"; return row; } function appendMetaLine(card, text, opts){ const line = document.createElement("div"); line.textContent = text; line.style.fontWeight = (opts && opts.weight) || "800"; line.style.fontSize = (opts && opts.size) || "12px"; line.style.lineHeight = "1.25"; line.style.color = (opts && opts.color) || "rgba(255,255,255,0.95)"; if (opts && opts.marginTop) line.style.marginTop = opts.marginTop; card.appendChild(line); return line; } function appendRecordResult(card, entry){ const row = document.createElement("div"); row.style.display = "flex"; row.style.flexWrap = "wrap"; row.style.alignItems = "baseline"; row.style.gap = "8px"; row.style.marginTop = "4px"; const main = document.createElement("div"); main.textContent = entry ? `${entry.tie ? "TIED" : (entry.won ? "WON" : "LOST")} ${entry.youEV}-${entry.oppEV}` : "No result yet"; main.style.fontWeight = "900"; main.style.fontSize = "18px"; main.style.letterSpacing = "0.3px"; main.style.color = "#fff"; row.appendChild(main); if (entry && entry.sweep){ const badge = document.createElement("span"); badge.textContent = "Sweep!"; badge.style.color = "#fff200"; badge.style.fontWeight = "900"; row.appendChild(badge); } else if (entry && earnedLandslide(entry)){ const badge = document.createElement("span"); badge.textContent = "Landslide!"; badge.style.color = "var(--gold)"; badge.style.fontWeight = "900"; row.appendChild(badge); } card.appendChild(row); } function createMediaCard(config){ const shell = makeRecordShell(config.accentA, config.accentB); const row = document.createElement("div"); row.style.display = "grid"; row.style.gridTemplateColumns = "88px minmax(0,1fr)"; row.style.gap = "12px"; row.style.alignItems = "start"; shell.appendChild(row); const imgWrap = document.createElement("div"); imgWrap.style.width = "88px"; imgWrap.style.height = "88px"; imgWrap.style.borderRadius = "18px"; imgWrap.style.overflow = "hidden"; imgWrap.style.background = "rgba(255,255,255,0.10)"; imgWrap.style.border = "1px solid rgba(255,255,255,0.14)"; imgWrap.style.display = "grid"; imgWrap.style.placeItems = "center"; const img = document.createElement("img"); img.src = config.imgSrc; img.alt = ""; img.style.width = "100%"; img.style.height = "100%"; img.style.objectFit = config.fit || "cover"; imgWrap.appendChild(img); row.appendChild(imgWrap); const body = document.createElement("div"); row.appendChild(body); const kicker = document.createElement("div"); kicker.textContent = config.kicker || ""; kicker.style.fontSize = "10px"; kicker.style.fontWeight = "900"; kicker.style.letterSpacing = "1.1px"; kicker.style.textTransform = "uppercase"; kicker.style.color = "rgba(255,255,255,0.72)"; body.appendChild(kicker); const heading = document.createElement("div"); heading.textContent = config.title; heading.style.fontSize = "22px"; heading.style.fontWeight = "900"; heading.style.lineHeight = "1"; heading.style.margin = "4px 0 8px"; heading.style.color = "#fff"; body.appendChild(heading); if (config.subtitle){ appendMetaLine(body, config.subtitle, { size:"13px", weight:"800", color:"rgba(255,255,255,0.78)" }); } return { shell, body }; } const dailyCardParts = createMediaCard({ title: "Daily Challenge", kicker: "Today's map", subtitle: `Record: ${dailyStats.wins} wins - ${dailyStats.losses} losses`, imgSrc: "cheer3.png", fit: "cover", accentA: "rgba(14,60,152,0.96)", accentB: "rgba(7,19,53,0.96)" }); appendMetaLine(dailyCardParts.body, dailyParts.dateLabel, { size:"12px", weight:"900", color:"rgba(255,255,255,0.92)", marginTop:"8px" }); if (dailyStore && dailyStore.dateKey === dailyParts.key && dailyStore.completed && dailyStore.finalPayload){ const dailyEntry = dailyStore.finalPayload.recordEntry || null; appendRecordResult(dailyCardParts.body, dailyEntry); const row = makeButtonRow(); row.appendChild(makeActionButton("SEE RESULTS", () => { hidePopup(); scenarioKey = "daily"; currentGameMode = "daily"; window.dailyInfo = { dateKey: dailyParts.key, dateLabel: dailyStore.dateLabel || dailyParts.dateLabel }; playerParty = (dailyStore.finalPayload && dailyStore.finalPayload.playerParty) || playerParty; showFinalResults(dailyStore.finalPayload); }, true)); dailyCardParts.body.appendChild(row); } else if (dailyStore && dailyStore.dateKey === dailyParts.key && dailyStore.snapshot){ appendMetaLine(dailyCardParts.body, "Resume your current daily run.", { size:"14px", weight:"900", marginTop:"6px" }); const row = makeButtonRow(); row.appendChild(makeActionButton("RESUME", () => { hidePopup(); restoreDailySnapshot(dailyStore.snapshot); redraw(); }, true)); dailyCardParts.body.appendChild(row); } else { appendMetaLine(dailyCardParts.body, "A new challenge is ready.", { size:"14px", weight:"900", marginTop:"6px" }); const row = makeButtonRow(); row.appendChild(makeActionButton("PLAY NOW", () => { hidePopup(); startDailyChallenge(); }, true)); dailyCardParts.body.appendChild(row); } wrap.appendChild(dailyCardParts.shell); function appendHistoricSection(title, entry, scenario, imgSrc, accentA, accentB){ const cardParts = createMediaCard({ title, kicker: scenario === "2016" ? "Historic scenario" : "Historic scenario", subtitle: entry ? "Best saved result" : "Play this scenario to set a result", imgSrc, fit: "contain", accentA, accentB }); if (entry){ appendRecordResult(cardParts.body, entry); } else { appendMetaLine(cardParts.body, "No result yet", { size:"16px", weight:"900", marginTop:"4px" }); } const row = makeButtonRow(); if (entry && entry.sharePayload){ row.appendChild(makeActionButton("SEE RESULTS", () => { hidePopup(); scenarioKey = scenario; currentGameMode = scenario; playerParty = scenario === "2016" ? "D" : "R"; window.dailyInfo = null; showFinalResults(entry.sharePayload); }, true)); } row.appendChild(makeActionButton("REPLAY", () => { hidePopup(); hideFinalResults(); startHistoricScenario(scenario); }, true)); cardParts.body.appendChild(row); wrap.appendChild(cardParts.shell); } appendHistoricSection("Trump Scenario", trumpEntry, "2016", "TRUMP.png", "rgba(116,19,29,0.96)", "rgba(48,7,11,0.96)"); appendHistoricSection("Obama Scenario", obamaEntry, "2012", "OBAMA.png", "rgba(17,56,136,0.96)", "rgba(8,20,52,0.96)"); popupBody.appendChild(wrap); const back = document.createElement("button"); back.className = "btn"; back.textContent = returnTo === "final" ? "BACK TO RESULTS" : "BACK TO MENU"; back.addEventListener("click", () => { hidePopup(); if (returnTo === "final" && lastFinalPayload) showFinalResults(lastFinalPayload); else showCampaignMenu(); }); popupButtons.appendChild(back); } // ---------------- SCENARIOS ---------------- // Lean convention in scenario inputs: "Lean Republican is +x" (R positive, D negative) // Internal lean stored as Dem% (0..100): Dem% = 50 - leanR let scenarioKey = "2028"; window.dailyInfo = null; const SCENARIOS = { "learn": { title: "Learn to Play", subtitle: "Tutorial", mode: "modern", playerChoice: false, safeD: 0, safeR: 0, winTotal: 0, states: [] }, "2028": { title: "Run for President 2028", subtitle: "Level ladder", mode: "modern", playerChoice: false, safeD: 0, safeR: 0, winTotal: 0, states: [] }, "2016": { title: "Try to Beat Trump — 2016", subtitle: "Relive the upset", mode: "historic", playerChoice: false, playerPartyFixed: "D", safeD: 219, // includes DC safeR: 188, winTotal: 270, states: [ { id:"MN", ev:10, img:"MN.png", leanDem: 50 - (-1.5) }, { id:"NH", ev: 4, img:"NH.png", leanDem: 50 - (-0.4) }, { id:"MI", ev:16, img:"MI.png", leanDem: 50 - (0.2) }, { id:"PA", ev:20, img:"PA.png", leanDem: 50 - (0.7) }, { id:"WI", ev:10, img:"WI.png", leanDem: 50 - (0.8) }, { id:"FL", ev:29, img:"FL.png", leanDem: 50 - (1.2) }, { id:"AZ", ev:11, img:"AZ.png", leanDem: 50 - (3.5) }, { id:"NC", ev:15, img:"NC.png", leanDem: 50 - (3.6) }, { id:"GA", ev:16, img:"GA.png", leanDem: 50 - (5.1) }, ] }, "2012": { title: "Try to Beat Obama — 2012", subtitle: "Take on the incumbent", mode: "historic", playerChoice: false, playerPartyFixed: "R", safeD: 238, safeR: 164, winTotal: 270, states: [ { id:"WI", ev:10, img:"WI.png", leanDem: 50 - (-3.7) }, { id:"CO", ev: 9, img:"CO.png", leanDem: 51.6}, { id:"IA", ev: 6, img:"IA.png", leanDem: 50.7}, { id:"PA", ev:20, img:"PA.png", leanDem: 51.0}, { id:"VA", ev:13, img:"VA.png", leanDem: 50 - (-1.1) }, { id:"FL", ev:29, img:"FL.png", leanDem: 48.9}, { id:"NC", ev:15, img:"NC.png", leanDem: 50 - (2.0) }, { id:"OH", ev:18, img:"OH.png", leanDem: 50 - (3.0) }, { id:"GA", ev:16, img:"GA.png", leanDem: 50 - (4.8) }, ] } }; function buildStateObjects(list){ return list.map(s => ({ id: s.id, ev: s.ev, img: s.img, lean: clamp(s.leanDem, 0, 100), startLean: clamp(s.leanDem, 0, 100), myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 })); } function isTutorialMode(){ return currentGameMode === "learn"; } function getTutorialDef(levelNum){ const idx = Math.max(1, Math.min(TUTORIAL_LEVELS.length, levelNum || 1)) - 1; return TUTORIAL_LEVELS[idx]; } function tutorialDef(){ return isTutorialMode() ? getTutorialDef(currentTutorialLevel) : null; } function tutorialAllowedActions(){ const def = tutorialDef(); return new Set((def && def.allowedActions) || []); } function buildTutorialLevelStates(def){ return def.states.map(s => ({ id: s.id, ev: s.ev, img: s.img, leanDem: clamp(50 - Number(s.leanR || 0), 0, 100) })); } function loadTutorialLevel(levelNum){ const def = getTutorialDef(levelNum); currentTutorialLevel = def.level; scenarioKey = "learn"; currentGameMode = "learn"; window.dailyInfo = null; playerParty = def.playerParty || "D"; currentActionsBase = def.actionsBase || 5; currentDaysTotal = def.daysTotal || 3; states = buildStateObjects(buildTutorialLevelStates(def)); TOTAL_EV = def.states.reduce((sum, s) => sum + s.ev, 0); WIN_EV = Math.floor(TOTAL_EV / 2) + 1; SCENARIOS["learn"].title = def.title; SCENARIOS["learn"].subtitle = `Lesson ${def.level}`; } function loadScenario(key){ scenarioKey = key; currentGameMode = key; window.dailyInfo = null; currentActionsBase = ACTIONS_BASE; currentDaysTotal = DAYS_TOTAL; const sc = SCENARIOS[key]; if (key === "2028"){ load2028Level(getNext2028Level()); return; } states = buildStateObjects(sc.states); TOTAL_EV = states.reduce((a,s)=>a+s.ev,0); if (sc.mode === "historic"){ const mySafe = (sc.playerPartyFixed === "D") ? sc.safeD : sc.safeR; WIN_EV = Math.max(0, sc.winTotal - mySafe); } else { WIN_EV = Math.ceil(TOTAL_EV / 2); } } function load2028Level(levelNum){ const def = getLevelDef(levelNum); current2028Level = def.level; scenarioKey = "2028"; currentGameMode = "2028"; window.dailyInfo = null; currentActionsBase = ACTIONS_BASE; currentDaysTotal = DAYS_TOTAL; playerParty = def.playerParty; states = buildStateObjects(build2028LevelStates(def)); TOTAL_EV = def.totalEV; WIN_EV = Math.floor(TOTAL_EV / 2) + 1; SCENARIOS["2028"].title = `Run for President 2028 — Level ${def.level}`; SCENARIOS["2028"].subtitle = `${playerParty === "R" ? "Republican" : "Democratic"} nominee`; } function aiCanAdvertise(){ if (isTutorialMode()) return tutorialAllowedActions().has("advertise"); if (scenarioKey === "daily") return true; if (scenarioKey !== "2028") return true; const def = getLevelDef(current2028Level || getNext2028Level()); return !!(def && def.aiCanAdvertise); } function aiCanVolunteer(){ if (isTutorialMode()) return tutorialAllowedActions().has("volunteer"); if (scenarioKey === "daily") return false; if (scenarioKey !== "2028") return true; const def = getLevelDef(current2028Level || getNext2028Level()); return !!(def && def.aiCanVolunteer); } function showSafeBoardThenBegin(){ const sc = SCENARIOS[scenarioKey]; if (sc.mode !== "historic"){ runOpeningReveal(); return; } const swing = TOTAL_EV; const historicIntro = { "2016": { titleHtml: "2016 CAMPAIGN", bodyHtml: `
` + `
With five days until the November 8 election, Donald Trump appears to be trailing — holding just 188 safe electoral votes compared to 219 Democratic locks.
` + `
But nine swing states remain, representing ${swing} electoral votes — and beneath the surface, Trump may be stronger than he looks.
` + `
Can you overcome the odds where Hillary Clinton failed?
` + `
`, imgSrc: "TRUMP.png" }, "2012": { titleHtml: "2012 CAMPAIGN", bodyHtml: `
` + `
With five days until the November 6 election, Barack Obama holds a narrow edge — but the outcome is far from certain.
` + `
Republicans begin with 164 safe electoral votes, while Democrats hold 238 — leaving a massive ${swing} electoral votes in play across the battleground map.
` + `
Can you secure victory where Mitt Romney could not?
` + `
`, imgSrc: "OBAMA.png" } }; const intro = historicIntro[scenarioKey] || { titleHtml: "HISTORIC CAMPAIGN", bodyHtml: `
Safe Democrat states: ${sc.safeD} EV
Safe Republican states: ${sc.safeR} EV
Swing states on this board: ${swing} EV.
`, imgSrc: INTRO_TIM_ICON }; overlay.classList.add("startMode"); showPopup( intro.titleHtml, "", "Tap BEGIN to start campaigning.", [ { label: "BEGIN CAMPAIGN", onClick: () => { hidePopup(); runOpeningReveal(); } } ], { bodyHtml: intro.bodyHtml, titleClass: "arcadeTitle", showImg: true, imgSrc: intro.imgSrc, imgSize: 120 } ); } // ---------------- TONES ---------------- const GAMEPLAY_WHITE_CUTOFF = 0.3; const GAMEPLAY_LIGHT_CUTOFF = 1.2; const GAMEPLAY_DARK_CUTOFF = 2.2; const RESULTS_LIGHT_CUTOFF = 1.2; const RESULTS_DARK_CUTOFF = 2.2; const TONE_BUCKETS = 7; const TONE_NEUTRAL_INDEX = 3; const reds = ["#CC0000","#FF0000","#FFAAAA"]; const blues = ["#B1B1FF","#8989FF","#6262FF"]; const BLUE_LOCK = "#4D7CFF"; const RED_LOCK = "#FF3B30"; function getCssVar(name){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function clamp(x, lo, hi){ return Math.max(lo, Math.min(hi, x)); } function toneIndex(d){ const m = d - 50; if (m <= -GAMEPLAY_DARK_CUTOFF) return 0; if (m <= -GAMEPLAY_LIGHT_CUTOFF) return 1; if (m < -GAMEPLAY_WHITE_CUTOFF) return 2; if (m <= GAMEPLAY_WHITE_CUTOFF) return 3; if (m < GAMEPLAY_LIGHT_CUTOFF) return 4; if (m < GAMEPLAY_DARK_CUTOFF) return 5; return 6; } function colorForToneIndex(i){ if (i === 0) return reds[0]; if (i === 1) return reds[1]; if (i === 2) return reds[2]; if (i === 3) return "#ffffff"; if (i === 4) return blues[0]; if (i === 5) return blues[1]; return blues[2]; } function colorForLean(d){ return colorForToneIndex(toneIndex(d)); } function brightColorForLean(d){ const m = d - 50; const diff = Math.abs(m); if (diff < RESULTS_LIGHT_CUTOFF) return (m >= 0) ? "#B1B1FF" : "#FFAAAA"; if (diff < RESULTS_DARK_CUTOFF) return (m >= 0) ? "#8989FF" : "#FF0000"; return (m >= 0) ? "#6262FF" : "#CC0000"; } function finalTileColor(s){ if (s.locked === "BLUE") return BLUE_LOCK; if (s.locked === "RED") return RED_LOCK; return document.body.classList.contains("resultsOpen") ? brightColorForLean(s.lean) : colorForLean(s.lean); } function leanText(leanDemPct){ // Election-night label (vague) const m = leanDemPct - 50; const diff = Math.abs(m); if (diff < RESULTS_LIGHT_CUTOFF) return "Tossup"; const side = (m >= 0) ? "D" : "R"; if (diff >= RESULTS_DARK_CUTOFF) return (side === "D") ? "Likely D" : "Likely R"; return (side === "D") ? "Leans D" : "Leans R"; } function marginText(leanDemPct){ // Gameplay label (precise) const m = leanDemPct - 50; const side = (m >= 0) ? "D" : "R"; return `${side} +${Math.abs(m).toFixed(1)}`; } // ---------------- DOM ---------------- const statusTxt = document.getElementById("statusTxt"); const partyTxt = document.getElementById("partyTxt"); const iconGrid = document.getElementById("iconGrid"); const stateGrid= document.getElementById("stateGrid"); const bar = document.getElementById("bar"); const cursor = document.getElementById("cursor"); const barLabel = document.getElementById("barLabel"); const detailBox = document.getElementById("detailBox"); const ticker = document.getElementById("ticker"); const resultsBtn = document.getElementById("resultsBtn"); const overlay = document.getElementById("overlay"); const popupPanel = overlay.querySelector(".popup"); const popupImg = document.getElementById("popupImg"); popupImg.onerror = () => { const s = (popupImg.getAttribute("src") || ""); if (s.includes("TimKane.png")) { popupImg.src = "timkane_grain.png"; return; } popupImg.classList.remove("on"); }; const popupTitle = document.getElementById("popupTitle"); const popupBody = document.getElementById("popupBody"); const popupButtons = document.getElementById("popupButtons"); const popupHint = document.getElementById("popupHint"); const finalResults = document.getElementById("finalResults"); const actionHint = document.getElementById("actionHint"); const finalTitleEl = document.getElementById("finalTitle"); const finalBody = document.getElementById("finalBody"); const finalTickerEl = document.getElementById("finalTicker"); const finalButtons = document.getElementById("finalButtons"); const cheerImg = document.getElementById("cheerImg"); const finalProjectionBar = document.getElementById("finalProjectionBar"); const finalProjectionCursor = document.getElementById("finalProjectionCursor"); const finalProjectionLabel = document.getElementById("finalProjectionLabel"); const barWrapEl = document.querySelector(".barWrap"); const candidateImg = document.getElementById("candidateImg"); const finalModern = document.getElementById("finalModern"); const finalStyleLine = document.getElementById("finalStyleLine"); const finalShareCardImg = document.getElementById("finalShareCardImg"); const finalShareLoading = document.getElementById("finalShareLoading"); const finalCompare = document.getElementById("finalCompare"); const finalSecondaryButtons = document.getElementById("finalSecondaryButtons"); const finalMenuBtn = document.getElementById("finalMenuBtn"); const shareElectionBtn = document.getElementById("shareElectionBtn"); // ---------------- AUDIO ---------------- let audioCtx = null; function ensureAudio(){ if (!audioCtx){ audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === "suspended") audioCtx.resume().catch(()=>{}); } let muteSfx = false; function beep(freq=440, dur=0.06, type="square", gain=0.04, force=false){ if (!audioCtx) return; if (muteSfx && !force) return; const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.type = type; o.frequency.value = freq; g.gain.value = gain; o.connect(g); g.connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime + dur); } function clunk(force=false){ beep(160, 0.05, "square", 0.045, force); } function ping(force=false){ beep(760, 0.06, "square", 0.035, force); } function thump(force=false){ beep(110, 0.07, "square", 0.05, force); } function zap(force=false){ beep(520, 0.05, "square", 0.035, force); beep(820, 0.05, "square", 0.03, force); } function bassChord(force=false){ beep(90, 0.08, "square", 0.05, force); beep(135, 0.08, "square", 0.04, force); } function cheerBlue(force=false){ beep(540, 0.07, "square", 0.05, force); beep(680, 0.09, "square", 0.04, force); beep(820, 0.11, "square", 0.03, force); } function cheerRed(force=false){ beep(420, 0.07, "square", 0.05, force); beep(520, 0.09, "square", 0.04, force); beep(620, 0.11, "square", 0.03, force); } function digitalCheer(force=false){ if (!audioCtx) return; const notes = [620, 784, 988, 1175, 1568]; notes.forEach((freq, idx) => { setTimeout(() => beep(freq, 0.055 + (idx * 0.01), "square", 0.042 - (idx * 0.004), force), idx * 58); }); setTimeout(() => beep(880, 0.12, "triangle", 0.026, force), 150); } // ---------------- GAME STATE ---------------- let playerParty = null; // "D" | "R" function playerSign(){ return (playerParty === "D") ? +1 : -1; } function partyName(p){ return p === "D" ? "DEMOCRAT" : "REPUBLICAN"; } function displayPartyName(p){ return p === "D" ? "Democrat" : "Republican"; } function aiParty(){ return playerParty === "D" ? "R" : "D"; } let daysLeft = currentDaysTotal; let actionsRemaining = currentActionsBase; let pendingStateAction = null; // null or "volunteer" let inputLocked = true; let playerDayTapCount = 0; let aiDayTapCount = 0; let tvUsedToday = false; let adResolutionInProgress = false; // fundraise buffs let tapBuff = 0.0; let adBuff = 0.0; // Opening reveal phase let revealOn = false; let revealedCount = 0; // Stars visibility: let revealStars = false; // Reveal stage let revealStage = false; // Election night let electionNight = false; // election-night statuses // election-night counting suspense let electionCounting = false; let countingStateId = null; let countTimers = []; function randn(){ // Box–Muller let u = 0, v = 0; while (u === 0) u = Math.random(); while (v === 0) v = Math.random(); return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); } function fmtMargin(m){ const side = (m >= 0) ? "D" : "R"; const val = Math.abs(m).toFixed(1); return `${side} +${val}`; } function noiseMargin(tally, sd){ return tally + randn() * sd; } let activeStateId = null; function createEmptyPlayerStats(){ return { totalActions:0, taps:0, volunteers:0, fundraisers:0, issueAds:0, bioAds:0, lateActions:0, stateTouches:{}, tapStates:{} }; } let playerStats = createEmptyPlayerStats(); function resetPlayerStats(){ playerStats = createEmptyPlayerStats(); } function snapshotPlayerStats(){ return JSON.parse(JSON.stringify(playerStats || createEmptyPlayerStats())); } function markPlayerAction(kind, meta){ if (!playerStats) resetPlayerStats(); playerStats.totalActions += 1; if (daysLeft <= 2) playerStats.lateActions += 1; if (kind && Object.prototype.hasOwnProperty.call(playerStats, kind)) playerStats[kind] += 1; if (meta && meta.stateId){ playerStats.stateTouches[meta.stateId] = (playerStats.stateTouches[meta.stateId] || 0) + 1; if (kind === "taps") playerStats.tapStates[meta.stateId] = 1; } } // ticker loop let tickerInterval = null; const TICKER_LINES = [ "Polls closing across key states...", "Early vote numbers incoming...", "Margins remain tight statewide...", "Analysts watching turnout carefully...", "Both campaigns projecting confidence tonight...", "Too early to call several battlegrounds..." ]; // cheer rotation let cheerInterval = null; let cheerIdx = 0; let finalTickerInterval = null; let finalTickerLines = []; let finalTickerIdx = 0; let lastFinalPayload = null; let aiPrepPulseOn = false; let menuCelebrateNext = false; // Impact visuals let aiFlash = { id:null, color:null }; let adGlow = { ids:[], color:null }; let tapPulse = { id:null, color:null }; let nationalPulse = { ids:[], cls:null }; // AI phase flag let aiPhaseOn = false; function setAiPhase(on){ aiPhaseOn = !!on; if (!aiPhaseOn) aiPrepPulseOn = false; document.body.classList.toggle("aiPhase", !!on); redraw(); } function setAiPrepPulse(on){ aiPrepPulseOn = !!on; redraw(); } function startElectionTicker(){ stopElectionTicker(); tickerInterval = setInterval(() => { if (!electionNight) return; showTicker(TICKER_LINES[Math.floor(Math.random()*TICKER_LINES.length)]); }, 1800); } function stopElectionTicker(){ if (tickerInterval){ clearInterval(tickerInterval); tickerInterval = null; } } function startCheerLoop(){ stopCheerLoop(); cheerIdx = 0; cheerImg.src = CHEER_IMAGES[0]; cheerInterval = setInterval(() => { cheerIdx = (cheerIdx + 1) % CHEER_IMAGES.length; cheerImg.src = CHEER_IMAGES[cheerIdx]; }, CHEER_ROTATE_MS); } function stopCheerLoop(){ if (cheerInterval){ clearInterval(cheerInterval); cheerInterval = null; } } // ------- SIX STATES ------- let states = [ { id:"PA", ev:19, img:"PA.png", lean:47.7, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"NC", ev:16, img:"NC.png", lean:48.5, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"GA", ev:16, img:"GA.png", lean:50.9, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"OH", ev:17, img:"OH.png", lean:49.2, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"AZ", ev:11, img:"AZ.png", lean:46.5, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"CO", ev:10, img:"CO.png", lean:50.0, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"WI", ev:10, img:"WI.png", lean:48.8, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"NH", ev:4, img:"NH.png", lean:51.0, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, { id:"IA", ev:6, img:"IA.png", lean:49.0, myStars:0, aiStars:0, locked:null, status:"idle", myTaps:0, aiTaps:0 }, ]; // ---------------- POPUP ---------------- function showPopup(title, body, hint, buttons, opts){ hint = hint || ""; buttons = buttons || []; opts = opts || {}; overlay.classList.remove("adResultGood","adResultBad"); if (opts.overlayClass) overlay.classList.add(opts.overlayClass); popupTitle.className = "popupTitle" + (opts.titleClass ? ` ${opts.titleClass}` : ""); popupTitle.innerHTML = opts.titleHtml || title; if (opts.bodyHtml){ popupBody.innerHTML = opts.bodyHtml; } else { popupBody.textContent = body; } popupHint.textContent = hint; popupButtons.innerHTML = ""; if (opts.showImg){ popupImg.src = (opts.imgSrc || INTRO_TIM_ICON); popupImg.style.width = (opts.imgSize || 78) + "px"; popupImg.style.height = (opts.imgSize || 78) + "px"; popupImg.classList.add("on"); } else { popupImg.style.width = "78px"; popupImg.style.height = "78px"; popupImg.classList.remove("on"); } for (const b of buttons){ const btn = document.createElement("button"); btn.className = "btn" + (b.className ? ` ${b.className}` : ""); if (b.imgSrc){ const wrap = document.createElement("span"); wrap.className = "menuBtnContent"; const textWrap = document.createElement("span"); textWrap.className = "menuBtnText"; const titleEl = document.createElement("span"); titleEl.className = "menuBtnTitle"; titleEl.textContent = b.label; textWrap.appendChild(titleEl); if (b.sublabel){ const sub = document.createElement("span"); sub.className = "btnSmall"; sub.textContent = b.sublabel; textWrap.appendChild(sub); } wrap.appendChild(textWrap); const img = document.createElement("img"); img.className = `menuBtnThumb ${b.imgClass || ""}`.trim(); img.src = b.imgSrc; img.alt = ""; wrap.appendChild(img); btn.appendChild(wrap); } else { btn.textContent = b.label; if (b.sublabel){ const sub = document.createElement("span"); sub.className = "btnSmall"; sub.textContent = b.sublabel; btn.appendChild(sub); } } btn.addEventListener("click", (e) => { e.stopPropagation(); ensureAudio(); clunk(true); if (b.onClick) b.onClick(); }); popupButtons.appendChild(btn); } overlay.classList.add("on"); } function hidePopup(){ overlay.classList.remove("on"); overlay.classList.remove("adMode"); overlay.classList.remove("adResultGood"); overlay.classList.remove("adResultBad"); overlay.classList.remove("startMode"); overlay.classList.remove("introMode"); overlay.classList.remove("campaignHubMode"); } popupPanel.addEventListener("click", (e) => e.stopPropagation()); overlay.addEventListener("click", () => { ensureAudio(); const canDismiss = !adResolutionInProgress && !overlay.classList.contains("adResultGood") && !overlay.classList.contains("adResultBad"); if (canDismiss) hidePopup(); }); // ---------------- TICKER ---------------- function showTicker(text){ ticker.classList.add("on"); ticker.textContent = text; } function hideTicker(){ ticker.classList.remove("on"); ticker.textContent = ""; } // ---------------- DETAIL BOX ---------------- function hideDetailBox(){ detailBox.classList.remove("on","detailRed","detailBlue","countingBox"); detailBox.textContent = ""; detailBox.innerHTML = ""; } function buildClosestTickerData(stateResults, count=3){ const closestStates = [...(stateResults || [])] .sort((a,b) => (a.closeness - b.closeness) || ((STATE_NAME[a.id] || a.id).localeCompare(STATE_NAME[b.id] || b.id))) .slice(0, count); return { closestStates, closestIds: new Set(closestStates.map(s => s.id)), tickerLines: closestStates.map(s => `${STATE_NAME[s.id] || s.id}: ${partyPlural(s.winnerParty)} win by ${s.margin}`) }; } function showDetailBox(text, color){ detailBox.classList.add("on"); detailBox.classList.remove("countingBox"); detailBox.textContent = text; detailBox.classList.remove("detailRed","detailBlue"); if (color === "red") detailBox.classList.add("detailRed"); if (color === "blue") detailBox.classList.add("detailBlue"); } function analyticsModeForGame(){ if (currentGameMode === "daily") return "daily"; if (currentGameMode === "learn") return "learning"; if (currentGameMode === "2016") return "trump"; if (currentGameMode === "2012") return "obama"; if (currentGameMode === "2028") return "2028"; return String(currentGameMode || scenarioKey || "unknown"); } function didPlayerWinForAnalytics(payload){ if (!payload) return false; if (currentGameMode === "daily") { const total = Number(payload.totalAvailableEV || TOTAL_EV || 0); const youEV = Number(payload.youEV || 0); return total > 0 ? (youEV * 2 >= total) : false; } return !payload.tie && payload.winnerParty === playerParty; } function postGameComplete(payload){ if (!ANALYTICS_ENDPOINT || !payload) return; const analyticsMode = analyticsModeForGame(); const result = didPlayerWinForAnalytics(payload) ? "win" : "loss"; const body = JSON.stringify({ event_name: "game_complete", mode: analyticsMode, result, build: BUILD_VERSION, user_agent: navigator.userAgent || "" }); try { if (navigator.sendBeacon){ const blob = new Blob([body], { type: "text/plain;charset=UTF-8" }); navigator.sendBeacon(ANALYTICS_ENDPOINT, blob); return; } fetch(ANALYTICS_ENDPOINT, { method: "POST", mode: "cors", keepalive: true, headers: { "Content-Type": "text/plain;charset=UTF-8" }, body }).catch((err) => { console.warn("Game complete analytics failed", err); }); } catch (err) { console.warn("Game complete analytics failed", err); } } // ---------------- FINAL RESULTS ---------------- function showFinalResults(payload){ lastFinalPayload = payload; finalResults.classList.remove("red","blue","learnMode"); if (finalTitleEl) finalTitleEl.classList.add("blinking"); const sc = SCENARIOS[scenarioKey] || SCENARIOS["2028"]; if (currentGameMode !== "learn" && !payload.tie && payload.winnerParty){ finalResults.classList.add(payload.winnerParty === "D" ? "blue" : "red"); } if (finalButtons) finalButtons.innerHTML = ""; const stateResults = payload.stateResults || []; const { tickerLines } = buildClosestTickerData(stateResults, 3); if (payload.volunteerFlips && payload.volunteerFlips.length){ tickerLines.push(`Volunteer flips: ${payload.volunteerFlips.join(", ")}`); } finalResults.classList.add("modernMode"); const isLearn = currentGameMode === "learn"; const isDaily = currentGameMode === "daily"; const isHistoric = sc.mode === "historic"; const youWon = !payload.tie && payload.winnerParty === playerParty; if (isLearn){ finalResults.classList.add("learnMode"); finalTitleEl.textContent = `Lesson ${currentTutorialLevel} Results`; finalTitleEl.classList.remove("blinking"); if (finalStyleLine){ finalStyleLine.innerHTML = `Lesson result: ${escapeHtml(youWon ? "Victory" : (payload.tie ? "Tie Game" : "Keep Learning"))}`; } if (finalCompare){ const completed = Math.max(getTutorialProgress(), Number(payload.tutorialProgress || 0)); finalCompare.innerHTML = `
${escapeHtml(`You finished Lesson ${currentTutorialLevel} with ${payload.youEV} EV to ${payload.oppEV}.`)}
` + `
${escapeHtml(`Lessons completed: ${completed} / ${TUTORIAL_LEVELS.length}`)}
`; } renderMiniFinalButtons([ { label: "Try Again", onClick: () => { hideFinalResults(); startTutorialLevel(currentTutorialLevel); } }, ...(currentTutorialLevel < TUTORIAL_LEVELS.length ? [{ label: "Proceed to Next Lesson", onClick: () => { hideFinalResults(); startTutorialLevel(currentTutorialLevel + 1); } }] : [{ label: "Finish Learning", onClick: () => { hideFinalResults(); completeTutorialCampaign(); } }]), { label: "Main Menu", onClick: () => { hideFinalResults(); showCampaignMenu(false); } } ]); const socialRow = document.querySelector('.finalSocialRow'); if (socialRow) socialRow.style.display = "none"; if (finalMenuBtn) finalMenuBtn.onclick = () => { hideFinalResults(); showCampaignMenu(false); }; } else if (isDaily){ finalTitleEl.textContent = "Your Daily Electoral Map"; finalTitleEl.classList.remove("blinking"); if (finalStyleLine){ finalStyleLine.innerHTML = `Your campaign style: ${escapeHtml(payload.campaignStyle || campaignStyleLabel(payload))}`; } if (finalCompare){ const compareDetail = compareDetailLine(payload); finalCompare.innerHTML = compareDetail ? `
${escapeHtml(compareDetail)}
` : ""; } renderMiniFinalButtons([]); const socialRow = document.querySelector('.finalSocialRow'); if (socialRow) socialRow.style.display = "grid"; if (finalMenuBtn) finalMenuBtn.onclick = () => { menuCelebrateNext = true; hideFinalResults(); showCampaignMenu(); }; } else if (isHistoric){ const yearLabel = shareHeadline(payload); const opponent = scenarioKey === "2016" ? "Trump" : (scenarioKey === "2012" ? "Obama" : "your rival"); const historicSweep = stateResults.length && stateResults.every(s => s.winnerParty === playerParty); const badges = []; if (historicSweep) badges.push("Sweep!"); else if (payload.recordEntry && earnedLandslide(payload.recordEntry)) badges.push("Landslide!"); finalTitleEl.textContent = yearLabel; finalTitleEl.classList.remove("blinking"); if (finalStyleLine){ const verdict = payload.tie ? "Tie Game" : (payload.winnerParty === playerParty ? `You beat ${opponent}` : `${opponent} prevails`); finalStyleLine.innerHTML = `Historic result: ${escapeHtml(verdict)}`; } if (finalCompare){ const lines = [ `
${escapeHtml(`You: ${payload.youEV} EV • ${opponent}: ${payload.oppEV} EV`)}
` ]; if (badges.length) lines.push(`
${escapeHtml(badges.join(" • "))}
`); finalCompare.innerHTML = lines.join(""); } renderMiniFinalButtons([ { label: "Replay Scenario", onClick: () => { hideFinalResults(); startHistoricScenario(scenarioKey); } } ]); const socialRow = document.querySelector('.finalSocialRow'); if (socialRow) socialRow.style.display = "grid"; if (finalMenuBtn) finalMenuBtn.onclick = () => { menuCelebrateNext = true; hideFinalResults(); showCampaignMenu(); }; } else { finalTitleEl.textContent = "Your 2028 Electoral Map"; finalTitleEl.classList.remove("blinking"); if (finalStyleLine){ finalStyleLine.innerHTML = `Your campaign style: ${escapeHtml(payload.campaignStyle || campaignStyleLabel(payload))}`; } if (finalCompare){ const stats = getRunHistoryStats("2028"); const compareDetail = compareDetailLine(payload); finalCompare.innerHTML = `
${escapeHtml(`Record: ${stats.wins} wins - ${stats.losses} losses`)}
` + `
${escapeHtml(`Win streak: ${stats.winStreak}`)}
` + (compareDetail ? `
${escapeHtml(compareDetail)}
` : ""); } renderMiniFinalButtons([]); const socialRow = document.querySelector('.finalSocialRow'); if (socialRow) socialRow.style.display = "grid"; if (finalMenuBtn) finalMenuBtn.onclick = () => { menuCelebrateNext = true; hideFinalResults(); showCampaignMenu(); }; } if (candidateImg) candidateImg.style.display = "none"; if (shareElectionBtn) shareElectionBtn.onclick = () => { shareFinalImage(); }; renderFinalShareCard(payload); if (finalShareLoading) finalShareLoading.textContent = isLearn ? "Lesson map ready." : "Building your share card..."; if (isLearn && finalShareCardImg && payload && payload.shareStates) { renderFinalShareCard(payload); } finalResults.classList.add("on"); document.body.classList.add("resultsOpen"); if (finalProjectionBar && finalProjectionCursor){ requestAnimationFrame(() => { if (finalProjectionBar && finalProjectionCursor) renderProjectionBar(finalProjectionBar, finalProjectionCursor); setTimeout(() => renderProjectionBar(finalProjectionBar, finalProjectionCursor), 0); }); } stopCheerLoop(); stopFinalTicker(); } function hideFinalResults(){ document.body.classList.remove("resultsOpen"); finalResults.classList.remove("on","red","blue","modernMode","learnMode"); if (finalTitleEl){ finalTitleEl.classList.remove("blinking"); finalTitleEl.textContent = "RESULTS"; } if (finalStyleLine) finalStyleLine.innerHTML = ""; if (finalCompare) finalCompare.innerHTML = ""; if (finalBody) finalBody.innerHTML = ""; if (finalButtons) finalButtons.innerHTML = ""; if (finalSecondaryButtons) finalSecondaryButtons.innerHTML = ""; if (finalTickerEl) finalTickerEl.textContent = ""; if (finalProjectionLabel) finalProjectionLabel.textContent = "Projected Electoral Vote"; if (finalShareCardImg){ finalShareCardImg.classList.remove("ready"); finalShareCardImg.removeAttribute("src"); } if (finalShareLoading){ finalShareLoading.style.display = "flex"; finalShareLoading.textContent = "Building your share card..."; } if (candidateImg){ candidateImg.removeAttribute("src"); candidateImg.style.display = "none"; } if (finalMenuBtn) finalMenuBtn.onclick = null; if (shareElectionBtn) shareElectionBtn.onclick = null; if (cheerImg && CHEER_IMAGES && CHEER_IMAGES.length){ cheerIdx = 0; cheerImg.src = CHEER_IMAGES[0]; } stopCheerLoop(); stopFinalTicker(); } // ---------------- TOP LINE ---------------- function flashNumberSpan(span){ if (!span) return; span.classList.remove("flash"); void span.offsetWidth; span.classList.add("flash"); } function partyClass(p){ return (p === "D") ? "turnBlue" : "turnRed"; } function renderTop(flags){ flags = flags || {}; const dayLabel = daysLeft === 1 ? "day" : "days"; const actionLine = aiPhaseOn ? `${actionsRemaining} actions today` : `${actionsRemaining} actions today`; const dayLine = `${daysLeft} ${dayLabel} left`; statusTxt.innerHTML = `${actionLine}
${dayLine}`; if (electionNight){ statusTxt.innerHTML = `Click each state to see outcome
Doubleclick for fast results`; partyTxt.innerHTML = ``; return; } if (!playerParty){ partyTxt.textContent = ""; } else { const yourTurn = !aiPhaseOn; const p = yourTurn ? playerParty : aiParty(); const label = yourTurn ? "Your Turn" : "Opponent's Turn"; const extraClass = (!yourTurn && aiPrepPulseOn) ? " turnAlert" : ""; partyTxt.innerHTML = `${label}: ${displayPartyName(p)}`; } if (!aiPhaseOn && flags.flashActions) flashNumberSpan(document.getElementById("actionsNum")); if (flags.flashDays) flashNumberSpan(document.getElementById("daysNum")); } function renderBarLabel(){ const sc = SCENARIOS[scenarioKey] || SCENARIOS["2028"]; let label = "PROJECTION"; if (electionNight){ if (sc.mode === "historic"){ label = `ELECTION NIGHT — NEED ${WIN_EV} EV`; } else if (currentGameMode === "daily") { label = "DAILY CHALLENGE — ELECTION NIGHT"; } else { label = "ELECTION NIGHT"; } } else if (revealStage){ label = "PROJECTION (TURNOUT VISIBLE)"; } barLabel.textContent = label; if (finalProjectionLabel) finalProjectionLabel.textContent = label; if (!finalProjectionBar || !finalProjectionCursor) return; } function renderIcons(){ const cells = [...iconGrid.querySelectorAll(".cell")]; const lesson = tutorialDef(); const allowedTutorialActions = tutorialAllowedActions(); iconGrid.style.display = (lesson && lesson.hideActionButtons) ? "none" : "grid"; for (const c of cells){ c.classList.remove("selected","disabled"); c.style.pointerEvents = "auto"; } if (pendingStateAction === "volunteer"){ const volCell = cells.find(c => c.dataset.action === "volunteer"); if (volCell) volCell.classList.add("selected"); } const disableAll = inputLocked || revealOn || revealStage || electionNight; const tvCell = cells.find(c => c.dataset.action === "advertise"); if (tvCell){ if (disableAll) tvCell.classList.add("disabled"); if (TV_ONCE_PER_DAY && tvUsedToday) tvCell.classList.add("disabled"); } const frCell = cells.find(c => c.dataset.action === "fundraise"); if (frCell){ const frBlocked = disableAll || (daysLeft <= FUNDRAISE_BLOCK_LAST_DAYS) || (actionsRemaining < FUNDRAISE_COST); if (frBlocked) frCell.classList.add("disabled"); } const volCell = cells.find(c => c.dataset.action === "volunteer"); if (volCell && disableAll) volCell.classList.add("disabled"); if (lesson){ for (const c of cells){ if (!allowedTutorialActions.has(c.dataset.action)) c.classList.add("disabled"); } } for (const c of cells){ if (c.classList.contains("disabled")) c.style.pointerEvents = "none"; } } function stateIsRevealed(idx){ return !revealOn || (idx < revealedCount); } function renderStates(){ stateGrid.innerHTML = ""; for (let idx=0; idx { img.style.display = "none"; fallback.classList.add("on"); }); const ev = document.createElement("div"); ev.className = "ev"; ev.textContent = s.ev; const st = document.createElement("div"); st.className = "stars"; { const myP = playerParty; // "D" or "R" const oppP = aiParty(); // during single-player; maps to opponent in multiplayer const myClass = (myP === "D") ? "starD" : "starR"; const oppClass = (oppP === "D") ? "starD" : "starR"; // During campaign/election (pre-reveal), only show the active player's volunteers. // In this prototype, that means only the human player's stars until Reveal Stage. st.innerHTML = revealStars ? `${"★".repeat(s.myStars)}${s.aiStars ? "★".repeat(s.aiStars) : ""}` : `${"★".repeat(s.myStars)}`; } const lt = document.createElement("div"); lt.className = "leanTxt"; lt.textContent = (revealOn && !isRevealed) ? "" : (electionNight ? (s.status === "unresolved" ? leanText(s.lean) : (s.locked === "BLUE" ? "DEMOCRAT" : "REPUBLICAN")) : marginText(s.lean)); cell.appendChild(ab); if (isRevealed){ cell.appendChild(img); cell.appendChild(fallback); } cell.appendChild(ev); cell.appendChild(st); cell.appendChild(lt); if (aiFlash.id === s.id){ cell.classList.add(aiFlash.color === "red" ? "aiTapRed" : "aiTapBlue"); } if (adGlow.ids.includes(s.id)){ cell.classList.add(adGlow.color === "red" ? "adGlowRed" : "adGlowBlue"); } if (tapPulse.id === s.id){ cell.classList.add(tapPulse.color === "red" ? "tapPulseRed" : "tapPulseBlue"); } if (nationalPulse.ids.includes(s.id) && nationalPulse.cls){ cell.classList.add(nationalPulse.cls); } let disabled = false; if (revealOn && !isRevealed) disabled = true; if (revealStage) disabled = true; if (electionNight){ if (s.status !== "unresolved") disabled = true; } else { if (inputLocked) disabled = true; } if (disabled) cell.classList.add("disabled"); cell.addEventListener("click", () => onStateTap(s.id)); stateGrid.appendChild(cell); } } // ---------------- EV BAR ---------------- function clearBarBlocks(targetBar){ if (!targetBar) return; [...targetBar.querySelectorAll(".evBlock")].forEach(n => n.remove()); } function projectionTotals(){ const sc = SCENARIOS[scenarioKey] || SCENARIOS["2028"]; let target = WIN_EV; if (sc.mode === "historic") { const playerFixed = sc.playerPartyFixed || playerParty; target = (playerFixed === "D") ? Math.max(0, TOTAL_EV - WIN_EV) : WIN_EV; } return { total: TOTAL_EV, target }; } function setCursor(targetBar=bar, targetCursor=cursor){ if (!targetBar || !targetCursor) return; const w = targetBar.clientWidth || 1; const totals = projectionTotals(); const cursorX = (totals.target / totals.total) * w; targetCursor.style.left = `${cursorX - (targetCursor.offsetWidth/2)}px`; } function renderProjectionBar(targetBar=bar, targetCursor=cursor){ if (!targetBar) return; clearBarBlocks(targetBar); const sc = SCENARIOS[scenarioKey] || SCENARIOS["2028"]; const totals = projectionTotals(); if (!electionNight){ { const buckets = new Array(TONE_BUCKETS).fill(0); for (const s of states){ buckets[toneIndex(s.lean)] += s.ev; } let sum = buckets.reduce((a,b)=>a+b,0); if (sum !== TOTAL_EV) buckets[TONE_NEUTRAL_INDEX] += (TOTAL_EV - sum); for (let ti=0; tiHistoric rival` : ""; } else { actionHint.innerHTML = ""; } } function redraw(flags){ updateActionHint(); flags = flags || {}; renderTop({ flashActions: !!flags.flashActions, flashDays: !!flags.flashDays }); renderIcons(); renderStates(); renderBar(); renderBarLabel(); resultsBtn.classList.toggle("on", !!revealStage); const rTop = document.getElementById("resultsBtnTop"); if (rTop) rTop.classList.toggle("on", !!revealStage); saveDailySnapshot(); } // ---------------- INPUT ---------------- iconGrid.addEventListener("click", (e) => { ensureAudio(); const cell = e.target.closest(".cell"); if (!cell) return; if (cell.classList.contains("disabled")) { thump(); return; } if (inputLocked || revealOn || revealStage || electionNight) return; const action = cell.dataset.action; if (action === "volunteer"){ pendingStateAction = "volunteer"; thump(); showTicker(`Volunteer selected: Next, tap on a state to secretly boost turnout on Election Day by +${VOLUNTEER_PER_STAR.toFixed(1)}!`); redraw(); return; } if (action === "fundraise"){ runFundraise(); return; } if (action === "advertise"){ if (TV_ONCE_PER_DAY && tvUsedToday){ thump(); return; } runAdvertiseMenu(); return; } }); const resultsBtnTop = document.getElementById("resultsBtnTop"); if (resultsBtnTop){ resultsBtnTop.addEventListener("click", () => { ensureAudio(); clunk(); if (!revealStage) return; revealStage = false; runElectionNight(); }); } resultsBtn.addEventListener("click", () => { ensureAudio(); clunk(); if (!revealStage) return; revealStage = false; runElectionNight(); }); function tapBaseForDayTap(dayTapNumber){ return TAP_DAY1_BASE - TAP_DAY_STEP * Math.max(0, dayTapNumber - 1); } function tapDeltaFor(stateTapCount, dayTapNumber){ const dayBase = tapBaseForDayTap(dayTapNumber); const raw = dayBase - TAP_STEP * (stateTapCount - 1); return Math.max(TAP_FLOOR, raw); } function onStateTap(id){ ensureAudio(); if (electionNight){ const s = states.find(x => x.id === id); if (!s || s.status !== "unresolved") { thump(); return; } if (electionCounting && countingStateId === id){ // Second click: snap to the call (countTimers || []).forEach(t => { try{ clearTimeout(t); }catch(e){} }); countTimers = []; finalizeElectionNightState(id); return; } activateElectionNightState(id); return; } if (inputLocked || revealOn || revealStage) return; if (daysLeft <= 0 || actionsRemaining <= 0) return; const s = states.find(x => x.id === id); if (!s) return; if (pendingStateAction === "volunteer"){ pendingStateAction = null; if (s.myStars < MAX_STARS){ s.myStars += 1; thump(); } else { thump(); } showTicker(`Your extra volunteers in ${STATE_NAME[id] || id} will boost voter turnout on Election Day!`); markPlayerAction("volunteers", { stateId:id }); consumeActions(1); return; } clunk(); const beforeTone = toneIndex(s.lean); s.myTaps += 1; playerDayTapCount += 1; const base = tapDeltaFor(s.myTaps, playerDayTapCount); const delta = (base + tapBuff) * playerSign(); s.lean = clamp(s.lean + delta, 0, 100); const afterTone = toneIndex(s.lean); if (afterTone !== beforeTone) ping(); triggerTapPulse(s.id, 1000); showTicker(`Your campaign visit to ${STATE_NAME[id] || id} boosted your popularity there!`); markPlayerAction("taps", { stateId:id }); consumeActions(1); } function consumeActions(n){ actionsRemaining = Math.max(0, actionsRemaining - n); if (actionsRemaining === 0){ inputLocked = true; pendingStateAction = null; } redraw({ flashActions:true }); if (actionsRemaining === 0) endPlayerDay(); } // ---------------- FUNDRAISE ---------------- function runFundraise(){ if (inputLocked || revealOn || revealStage || electionNight) return; if (daysLeft <= FUNDRAISE_BLOCK_LAST_DAYS) { thump(); return; } if (actionsRemaining < FUNDRAISE_COST) { thump(); return; } clunk(); tapBuff += FUNDRAISE_BUFF; adBuff += FUNDRAISE_BUFF; markPlayerAction("fundraisers"); consumeActions(FUNDRAISE_COST); zap(); showTicker("Fundraising increased the impact of your future actions!"); } // ---------------- ADVERTISE ---------------- function applyIssueAdCustom(issue, targets){ if (adResolutionInProgress || inputLocked || revealOn || revealStage || electionNight) return; adResolutionInProgress = true; try { hidePopup(); tvUsedToday = true; markPlayerAction("issueAds"); consumeActions(1); const delta = (ISSUE_BASE + adBuff) * playerSign(); for (const s of targets){ s.lean = clamp(s.lean + delta, 0, 100); } zap(); triggerAdGlow(targets.map(s => s.id)); showTicker("Your television ads shifted public opinion!"); redraw(); } catch (err) { console.error("applyIssueAdCustom failed:", err); hidePopup(); redraw(); } finally { adResolutionInProgress = false; } } function applyBioAd(){ if (adResolutionInProgress || inputLocked || revealOn || revealStage || electionNight) return; adResolutionInProgress = true; try { hidePopup(); tvUsedToday = true; markPlayerAction("bioAds"); consumeActions(1); const delta = (0.10 + adBuff) * playerSign(); for (const s of states){ s.lean = clamp(s.lean + delta, 0, 100); } zap(); triggerNationalPulse("party-short", 1000); showTicker("Your television ads shifted public opinion!"); redraw(); } catch (err) { console.error("applyBioAd failed:", err); hidePopup(); redraw(); } finally { adResolutionInProgress = false; } } function applyAttackAd(){ if (adResolutionInProgress || inputLocked || revealOn || revealStage || electionNight) return; adResolutionInProgress = true; try { hidePopup(); tvUsedToday = true; consumeActions(1); const success = Math.random() < 0.70; const delta = (success ? (0.25 + adBuff) : -(0.10 + adBuff)) * playerSign(); for (const s of states){ s.lean = clamp(s.lean + delta, 0, 100); } const msg = success ? "Boom! Your attack ad lands. It moves every active state in your direction." : "Yikes. The attack ad backfires and moves every active state against you."; showPopup( success ? "NEGATIVE ATTACK AD — BOOM" : "NEGATIVE ATTACK AD — YIKES", msg, "", [{ label: "OK", onClick: () => { hidePopup(); setTimeout(() => triggerNationalPulse(success ? "party-long" : "yellow", 1500), 40); } }], { overlayClass: success ? "adResultGood" : "adResultBad" } ); zap(); redraw(); } catch (err) { console.error("applyAttackAd failed:", err); hidePopup(); redraw(); adResolutionInProgress = false; } } function sampleWithoutReplacement(arr, n){ const pool = [...arr]; const out = []; while (pool.length && out.length < n){ const i = Math.floor(Math.random() * pool.length); out.push(pool.splice(i,1)[0]); } return out; } function pickRandomStates(n){ return sampleWithoutReplacement(states, Math.min(n, states.length)); } function runAdvertiseMenu(){ if (inputLocked || revealOn || revealStage || electionNight) return; if (actionsRemaining <= 0) return; if (TV_ONCE_PER_DAY && tvUsedToday) return; overlay.classList.add("adMode"); const issues = sampleWithoutReplacement(ISSUE_LIST, 2); const shuffledTargets = sampleWithoutReplacement(states, Math.min(states.length, ISSUE_TARGETS * 2)); const t1 = shuffledTargets.slice(0, Math.min(ISSUE_TARGETS, shuffledTargets.length)); const t2 = shuffledTargets.slice(t1.length, Math.min(t1.length + ISSUE_TARGETS, shuffledTargets.length)); function names(a){return a.map(s=>s.id).join(", ");} showPopup( "ADVERTISE", "Choose a message to broadcast nationwide.", "Tap outside or press CANCEL to back out.", [ {label:issues[0].toUpperCase(), sublabel:"Improve standing in " + names(t1), onClick:() => applyIssueAdCustom(issues[0], t1)}, {label:issues[1].toUpperCase(), sublabel:"Improve standing in " + names(t2), onClick:() => applyIssueAdCustom(issues[1], t2)}, {label:"BIOGRAPHY", sublabel:"Boost all active states nationwide", onClick:() => applyBioAd()}, {label:"ATTACK", sublabel:"70% boom, 30% backfire nationwide", onClick:() => applyAttackAd()}, {label:"CANCEL", onClick:() => hidePopup()} ], {} ); } function triggerAdGlow(targetIds){ const color = (playerParty === "D") ? "blue" : "red"; adGlow = { ids:[...targetIds], color }; redraw(); setTimeout(() => { adGlow = { ids:[], color:null }; redraw(); }, AD_GLOW_MS); } function triggerTapPulse(stateId, durationMs){ const color = (playerParty === "D") ? "blue" : "red"; tapPulse = { id: stateId, color }; redraw(); setTimeout(() => { if (tapPulse.id === stateId){ tapPulse = { id:null, color:null }; redraw(); } }, durationMs); } function triggerNationalPulse(kind, durationMs){ let cls = null; if (kind === "yellow") cls = "pulseAllYellow"; else if (kind === "party-short") cls = (playerParty === "D") ? "pulseAllBlue" : "pulseAllRed"; else if (kind === "party-long") cls = (playerParty === "D") ? "pulseAllBlueLong" : "pulseAllRedLong"; nationalPulse = { ids: states.map(s => s.id), cls }; redraw(); setTimeout(() => { nationalPulse = { ids:[], cls:null }; redraw(); }, durationMs); } function applyIssueAd(issue){ hidePopup(); if (TV_ONCE_PER_DAY && tvUsedToday) return; if (actionsRemaining <= 0) return; tvUsedToday = true; consumeActions(1); const targets = pickRandomStates(ISSUE_TARGETS); const targetIds = targets.map(s => s.id); triggerAdGlow(targetIds); const delta = (ISSUE_BASE + adBuff) * playerSign(); let changed = 0; for (const s of targets){ const before = toneIndex(s.lean); s.lean = clamp(s.lean + delta, 0, 100); const after = toneIndex(s.lean); if (after !== before) changed++; } if (changed) ping(); zap(); showTicker("Your television ads shifted public opinion!"); redraw(); } // ---------------- TURN FLOW ---------------- function endPlayerDay(){ inputLocked = true; pendingStateAction = null; setAiPhase(true); setAiPrepPulse(true); showTicker(isTutorialMode() ? "Tutorial opponent is making moves…" : "Opponent is making moves…"); setTimeout(() => { setAiPrepPulse(false); aiTurnAnimated(() => { setAiPhase(false); setTimeout(() => { if (!electionNight) hideTicker(); }, 350); setTimeout(() => endDay(), 350); }); }, 2000); } function pickSmartAiTarget(used){ used = used || new Set(); const aiSign = -playerSign(); const aiIsDem = (aiSign === 1); const defendList = []; const attackList = []; for (const s of states){ if (used.has(s.id)) continue; const demAhead = s.lean >= 50; const aiWinning = (aiIsDem && demAhead) || (!aiIsDem && !demAhead); const margin = Math.abs(s.lean - 50); if (aiWinning) defendList.push({ state:s, danger:margin }); else attackList.push({ state:s, danger:margin }); } defendList.sort((a,b)=>a.danger-b.danger); attackList.sort((a,b)=>a.danger-b.danger); if (defendList.length && Math.random() < 0.55) return defendList[0].state; if (attackList.length) return attackList[0].state; const unused = states.filter(s => !used.has(s.id)); if (unused.length) return unused[Math.floor(Math.random()*unused.length)]; return null; } function pickDailyAiTarget(used){ used = used || new Set(); const aiIsDem = (aiParty() === "D"); const ranked = states.filter(s => !used.has(s.id)).map(s => { const aiMargin = aiIsDem ? (s.lean - 50) : (50 - s.lean); const closeness = Math.abs(s.lean - 50); const winnableBoost = aiMargin > -2.2 ? 120 : (aiMargin > -4 ? 60 : 0); const flipBoost = aiMargin < 0 ? 24 : 8; const score = (s.ev * 10) + winnableBoost + flipBoost - (closeness * 12); return { state:s, score }; }).sort((a,b) => b.score - a.score); return ranked.length ? ranked[0].state : null; } function aiRunBioAd(){ const color = aiParty() === "D" ? "blue" : "red"; adGlow = { ids: states.map(s => s.id), color }; redraw(); setTimeout(() => { adGlow = { ids:[], color:null }; redraw(); }, AD_GLOW_MS); const delta = 0.10 * aiAdStrengthMultiplier() * (-playerSign()); for (const s of states){ s.lean = clamp(s.lean + delta, 0, 100); } zap(); showTicker("Opponent television ads shifted public opinion."); } function aiRunTvAd(){ const issue = ISSUE_LIST[Math.floor(Math.random() * ISSUE_LIST.length)]; const targets = pickRandomStates(ISSUE_TARGETS); const targetIds = targets.map(s => s.id); const color = aiParty() === "D" ? "blue" : "red"; adGlow = { ids:[...targetIds], color }; redraw(); setTimeout(() => { adGlow = { ids:[], color:null }; redraw(); }, AD_GLOW_MS); const delta = ((ISSUE_BASE * aiAdStrengthMultiplier()) + adBuff) * (-playerSign()); let changed = 0; for (const s of targets){ const before = toneIndex(s.lean); s.lean = clamp(s.lean + delta, 0, 100); const after = toneIndex(s.lean); if (after !== before) changed++; } if (changed) ping(); zap(); showTicker("Opponent television ads shifted public opinion."); } function aiFlashColor(){ return aiParty() === "D" ? "blue" : "red"; } function aiAdStrengthMultiplier(){ return (currentGameMode === "daily" && window.dailyInfo && window.dailyInfo.weekdayName === "Friday") ? 1.5 : 1; } function aiTurnAnimated(done){ muteSfx = true; const totalActions = currentActionsBase; const used = new Set(); const canAdvertise = aiCanAdvertise(); const canVolunteer = aiCanVolunteer(); let i = 0; let adUsed = false; function finish(){ muteSfx = false; aiFlash = { id:null, color:null }; redraw(); if (done) done(); } function step(){ if (i >= totalActions){ finish(); return; } if (!adUsed && canAdvertise){ adUsed = true; if (scenarioKey === "daily") aiRunBioAd(); else aiRunTvAd(); i++; setTimeout(step, AI_FLASH_STAY_MS); return; } const target = (scenarioKey === "daily") ? pickDailyAiTarget(used) : pickSmartAiTarget(used); if (!target){ finish(); return; } used.add(target.id); aiFlash = { id: target.id, color: aiFlashColor() }; redraw(); const margin = Math.abs(target.lean - 50); const VOL_CHANCE = margin < 2.5 ? 0.45 : 0.25; const doVolunteer = canVolunteer && (Math.random() < VOL_CHANCE); if (doVolunteer){ target.aiStars = Math.min(MAX_STARS, target.aiStars + 1); thump(); } else { const beforeTone = toneIndex(target.lean); target.aiTaps += 1; aiDayTapCount += 1; const base = tapDeltaFor(target.aiTaps, aiDayTapCount); const delta = (base + tapBuff) * (-playerSign()); target.lean = clamp(target.lean + delta, 0, 100); const afterTone = toneIndex(target.lean); if (afterTone !== beforeTone) ping(); clunk(true); } i++; setTimeout(() => { aiFlash = { id:null, color:null }; redraw(); step(); }, AI_FLASH_STAY_MS); } setTimeout(step, 200); } function endDay(){ dayProgress = Math.min(currentDaysTotal, dayProgress + 1); daysLeft = Math.max(0, currentDaysTotal - dayProgress); tvUsedToday = false; pendingStateAction = null; adResolutionInProgress = false; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; for (const s of states){ s.myTaps = 0; s.aiTaps = 0; } bassChord(); setTimeout(() => { if (daysLeft === 0){ enterRevealStage(); } else { inputLocked = false; redraw({ flashDays:true, flashActions:true }); } }, 260); } // ---------------- REVEAL STAGE ---------------- function enterRevealStage(){ inputLocked = true; revealStage = true; revealStars = true; document.body.classList.add("revealMode"); hideDetailBox(); hideTicker(); redraw(); showTicker("Reveal stage — turnout is now visible. Tap SEE THE RESULTS when ready."); } // ---------------- ELECTION NIGHT ---------------- function computeFinalDemPct(s){ const projectedDem = s.lean; const netStars = s.myStars - s.aiStars; const turnoutSwingDem = netStars * VOLUNTEER_PER_STAR * playerSign(); return clamp(projectedDem + turnoutSwingDem, 0, 100); } function finalizeElectionNightState(id){ const s = states.find(x => x.id === id); if (!s) return; const finalDem = computeFinalDemPct(s); const winBlue = finalDem >= 50; const stateName = STATE_NAME[s.id] || s.id; s.locked = winBlue ? "BLUE" : "RED"; s.status = "active"; activeStateId = id; countingStateId = null; electionCounting = false; // Final line in the count box (keep a little drama) const cF = document.getElementById("cF"); if (cF) cF.textContent = `${winBlue ? "Democrat" : "Republican"} wins ${stateName}!`; if (winBlue) cheerBlue(); else cheerRed(); redraw(); if (!states.some(x => x.status === "unresolved")){ const last = states.find(x => x.id === activeStateId); if (last && last.status === "active") last.status = "resolved"; activeStateId = null; redraw(); endElectionNight(); } } function activateElectionNightState(id){ if (electionCounting) return; if (countTimers && countTimers.length){ for (const tm of countTimers) clearTimeout(tm); } countTimers = []; if (activeStateId){ const prev = states.find(x => x.id === activeStateId); if (prev && prev.status === "active") prev.status = "resolved"; } const s = states.find(x => x.id === id); if (!s) return; electionCounting = true; countingStateId = id; const finalDem = computeFinalDemPct(s); const tally = finalDem - 50; // positive = D lead const stateName = STATE_NAME[s.id] || s.id; detailBox.classList.remove("on","detailRed","detailBlue","countingBox"); detailBox.innerHTML = ""; detailBox.offsetHeight; // force a clean repaint before the next countdown card detailBox.classList.add("on","countingBox"); detailBox.innerHTML = `
${stateName} — ${s.ev} EV
` + `
` + `
` + `
` + `
` + `
`; function setLine(id, text){ const el = document.getElementById(id); if (!el || countingStateId !== s.id) return; el.textContent = text; el.classList.remove("d","r","tc"); const side = text.startsWith("D") ? "d" : (text.startsWith("R") ? "r" : "tc"); el.classList.add(side); el.classList.add("on"); } function marginLine(margin, pct){ const side = (margin >= 0) ? "D" : "R"; const val = Math.abs(margin).toFixed(1); return `${side} +${val} with ${pct}% vote counted`; } const t15 = 1200, t45 = 2800, t85 = 3700, t99 = 4650, tCall = t99 + 500; redraw(); countTimers.push(setTimeout(() => setLine("c15", marginLine(noiseMargin(tally, 9.0), 15)), t15)); countTimers.push(setTimeout(() => setLine("c45", marginLine(noiseMargin(tally, 4.0), 45)), t45)); countTimers.push(setTimeout(() => setLine("c85", marginLine(noiseMargin(tally, 1.0), 85)), t85)); countTimers.push(setTimeout(() => setLine("c99", marginLine(tally, 99)), t99)); countTimers.push(setTimeout(() => { if (countingStateId === id) finalizeElectionNightState(id); }, tCall)); } function runElectionNight(){ inputLocked = true; revealStage = false; electionNight = true; if (barWrapEl) barWrapEl.classList.add("electionNightMode"); document.body.classList.remove("revealMode"); hideFinalResults(); revealStars = true; activeStateId = null; for (const s of states){ s.locked = null; s.status = "unresolved"; } hidePopup(); detailBox.classList.remove("detailRed","detailBlue"); detailBox.classList.add("on","countingBox"); detailBox.innerHTML = `
Election Night
` + `
Tap any unresolved state to begin the count.
` + `
The counting panel is pinned open so the board stays steady.
` + `
 
` + `
 
` + `
Waiting for your first state call…
`; redraw(); showTicker("Election Night — tap any unresolved state to resolve it."); startElectionTicker(); } function showCampaignMenu(celebrate){ celebrate = !!celebrate || !!menuCelebrateNext; menuCelebrateNext = false; inputLocked = true; aiPhaseOn = false; document.body.classList.remove("aiPhase"); document.body.classList.remove("revealMode"); dayProgress = 0; daysLeft = currentDaysTotal; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; pendingStateAction = null; tvUsedToday = false; tapBuff = 0.0; adBuff = 0.0; resetPlayerStats(); revealOn = false; revealedCount = 0; revealStars = false; revealStage = false; electionNight = false; activeStateId = null; electionCounting = false; countingStateId = null; setAiPhase(false); stopElectionTicker(); hideDetailBox(); hideTicker(); hideFinalResults(); aiFlash = { id:null, color:null }; adGlow = { ids:[], color:null }; loadScenario("2028"); redraw(); const nextLevel = getNext2028Level(); overlay.classList.add("campaignHubMode"); const imgWithFallback = (src, fallback, alt) => `${alt}`; const bodyHtml = `
TIM KANE'S
`; showPopup("", "", "", [], { bodyHtml }); const actionMap = { daily: () => { hidePopup(); startDailyChallenge(); }, learn: () => { hidePopup(); startTutorialLevel(getNextTutorialLevel()); }, "2016": () => { hidePopup(); startHistoricScenario("2016"); }, "2012": () => { hidePopup(); startHistoricScenario("2012"); }, friend: () => { hidePopup(); showFriendChallenge(); }, records: () => { hidePopup(); showRecordScreen("menu"); } }; popupBody.querySelectorAll(".campaignHubCard[data-action]").forEach((card) => { card.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const action = card.getAttribute("data-action"); const handler = actionMap[action]; if (!handler) return; ensureAudio(); digitalCheer(true); card.classList.add("pressed"); setTimeout(() => card.classList.remove("pressed"), 220); setTimeout(handler, 110); }); }); if (celebrate){ ensureAudio(); digitalCheer(true); } } function showRunAgainPopup(){ if (hasUnlockedCampaignHub()){ showCampaignMenu(); return; } showPopup( "Run again?", "Beat Level 1 to unlock the full campaign menu.", "", [ { label: "PLAY LEVEL 1 AGAIN", onClick: () => { hidePopup(); start2028Level(1); } } ], {} ); } function endElectionNight(){ electionNight = false; if (barWrapEl) barWrapEl.classList.remove("electionNightMode"); document.body.classList.remove("revealMode"); stopElectionTicker(); hideTicker(); const sc = SCENARIOS[scenarioKey] || SCENARIOS["2028"]; let demSwing = 0; for (const s of states){ if (s.locked === "BLUE") demSwing += s.ev; } const repSwing = TOTAL_EV - demSwing; const demEV = (sc.mode === "historic") ? (sc.safeD + demSwing) : demSwing; const repEV = (sc.mode === "historic") ? (sc.safeR + repSwing) : repSwing; let winTotal = (sc.mode === "historic") ? sc.winTotal : WIN_EV; let winnerParty = "T"; let tie = false; if (sc.mode === "historic"){ winnerParty = (demEV >= winTotal) ? "D" : "R"; } else { if (demEV === repEV){ tie = true; } else { winnerParty = (demEV > repEV) ? "D" : "R"; if (winnerParty === playerParty){ if (currentGameMode === "2028") markCurrentLevelComplete(); } } } const youEV = (playerParty === "D") ? demEV : repEV; const oppEV = (playerParty === "D") ? repEV : demEV; const margin = youEV - oppEV; const totalAvailableEV = (sc.mode === "historic") ? (sc.safeD + sc.safeR + TOTAL_EV) : TOTAL_EV; const stateResults = states.map(s => { const projectedDem = clamp(s.lean, 0, 100); const finalDem = computeFinalDemPct(s); const projectedWinnerParty = projectedDem >= 50 ? "D" : "R"; const finalWinnerParty = finalDem >= 50 ? "D" : "R"; const startWinnerParty = (Number(s.startLean || projectedDem) >= 50) ? "D" : "R"; const winner = finalWinnerParty === "D" ? "Democrat" : "Republican"; const side = finalWinnerParty === "D" ? "D" : "R"; const volunteerFlipped = (projectedWinnerParty !== finalWinnerParty) && ((s.myStars - s.aiStars) !== 0); return { id: s.id, ev: s.ev, winner, winnerParty: finalWinnerParty, projectedWinnerParty, startWinnerParty, flippedToPlayer: finalWinnerParty === playerParty && startWinnerParty !== finalWinnerParty, volunteerFlipped, marginValue: Math.abs(finalDem - 50), margin: `${side} +${Math.abs(finalDem - 50).toFixed(1)}`, closeness: Math.abs(finalDem - 50) }; }); const decidingStates = [...stateResults].sort((a,b) => a.closeness - b.closeness).slice(0,3); const volunteerFlips = stateResults.filter(s => s.volunteerFlipped).map(s => s.id); const sweep = stateResults.length > 0 && stateResults.every(s => s.winnerParty === playerParty); const shareStates = states.map(s => ({ id:s.id, ev:s.ev, img:s.img })); const recordEntry = { level: current2028Level, won: !tie && winnerParty === playerParty, tie, youEV, oppEV, margin, mode: currentGameMode, at: Date.now(), sweep, totalAvailableEV }; const finalPayload = { winnerParty, demEV, repEV, youEV, oppEV, winTotal, tie, decidingStates, stateResults, volunteerFlips, sweep, recordEntry, totalAvailableEV, shareStates, playerParty, playerStats: snapshotPlayerStats(), shareContext: { scenarioKey, level: current2028Level, dailyInfo: window.dailyInfo || null } }; finalPayload.campaignStyle = campaignStyleLabel(finalPayload); if (isTutorialMode()){ finalPayload.tutorialProgress = setTutorialProgress(currentTutorialLevel); postGameComplete(finalPayload); advanceTutorialAfterElection(finalPayload); return; } finalPayload.levelComparison = saveRunToHistory(finalPayload); recordEntry.sharePayload = JSON.parse(JSON.stringify(finalPayload)); recordEntry.shareContext = JSON.parse(JSON.stringify(finalPayload.shareContext)); recordEntry.campaignStyle = finalPayload.campaignStyle; recordEntry.levelComparison = finalPayload.levelComparison; if (currentGameMode === "2028") saveLevelRecord(recordEntry); if (currentGameMode === "2016" || currentGameMode === "2012") saveHistoricRecord(recordEntry, currentGameMode); postGameComplete(finalPayload); if (currentGameMode === "daily") saveDailyCompletion(finalPayload); showFinalResults(finalPayload); } // ---------------- OPENING FLOW ---------------- function startDailyChallenge(){ overlay.classList.add("startMode"); const parts = etNowParts(); const store = loadDailyStore(); if (store && store.dateKey === parts.key && store.completed && store.finalPayload){ window.dailyInfo = { dateKey: parts.key, dateLabel: store.dateLabel || parts.dateLabel, weekdayName: parts.weekdayName }; scenarioKey = "daily"; currentGameMode = "daily"; hidePopup(); showFinalResults(store.finalPayload); return; } if (store && store.dateKey === parts.key && store.snapshot){ if (isValidDailySnapshot(store.snapshot)){ restoreDailySnapshot(store.snapshot); hidePopup(); redraw(); return; } saveDailyStore({ dateKey:parts.key, dateLabel:parts.dateLabel, completed:false, snapshot:null, finalPayload:null }); } const def = generateDailyDefinition(parts); currentGameMode = "daily"; scenarioKey = "daily"; window.dailyInfo = { dateKey:def.dateKey, dateLabel:def.dateLabel, weekdayName:def.weekdayName }; playerParty = def.playerParty; currentActionsBase = def.actionsBase; currentDaysTotal = 5; dayProgress = 0; daysLeft = currentDaysTotal; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; pendingStateAction = null; tvUsedToday = false; tapBuff = 0.0; adBuff = 0.0; revealOn = false; revealedCount = 0; revealStars = false; revealStage = false; electionNight = false; activeStateId = null; inputLocked = true; aiPhaseOn = false; resetPlayerStats(); states = buildStateObjects(build2028LevelStates(def)); TOTAL_EV = def.totalEV; WIN_EV = Math.floor(TOTAL_EV / 2) + 1; saveDailyStore({ dateKey:def.dateKey, dateLabel:def.dateLabel, completed:false, snapshot:null, finalPayload:null }); redraw(); saveDailySnapshot(); showPopup( `Daily Challenge — ${def.dateLabel}`, `One official game today. No retries. You are the ${displayPartyName(def.playerParty)} nominee. You have ${currentActionsBase} actions per day for ${currentDaysTotal} days. ${def.weekdayName === "Friday" ? "Friday twist: opponent ads hit 50% harder than normal.\n" : ""}Can you win the most of the ${TOTAL_EV} electoral votes in play?`, "", [{ label: "BEGIN DAILY CHALLENGE", onClick: () => { hidePopup(); runOpeningReveal(); } }], {} ); } function startIntro(){ load2028Level(1); redraw(); overlay.classList.add("startMode"); overlay.classList.add("introMode"); showPopup( "TIM KANE'S SWING STATE", "", "", [ { label: "OPEN THE CAMPAIGN MENU", onClick: () => { hidePopup(); showCampaignMenu(false); } } ], { showImg:false, titleHtml: "", bodyHtml: openingStatsHtml() } ); const btns = popupButtons.querySelectorAll(".btn"); if (btns[0]) btns[0].classList.add("btnBig"); fetchHomeScreenStats(true) .then(() => { if (overlay.classList.contains("on") && overlay.classList.contains("introMode")){ popupBody.innerHTML = openingStatsHtml(); } }) .catch((err) => { console.warn("Intro stats refresh failed", err); }); } function startPartySelect(){ showCampaignMenu(false); } function start2028PartyChoice(){ start2028Level(getNext2028Level()); } function completeTutorialCampaign(){ overlay.classList.add("startMode"); hideDetailBox(); hideTicker(); showPopup( "Learning Module Complete!", `You have finished Learn to Play. You now know how campaign visits, volunteers, ads, and fundraising work together. Your browser will remember all ${getTutorialProgress()} completed lessons.`, "Head back to the main menu whenever you are ready.", [ { label: "MAIN MENU", onClick: () => { hidePopup(); showCampaignMenu(false); } } ], {} ); } function advanceTutorialAfterElection(payload){ if (!payload) return; payload.campaignStyle = campaignStyleLabel(payload); showFinalResults(payload); } function startTutorialLevel(levelNum){ overlay.classList.add("startMode"); loadTutorialLevel(levelNum); hideDetailBox(); hideTicker(); hideFinalResults(); setAiPhase(false); dayProgress = 0; daysLeft = currentDaysTotal; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; pendingStateAction = null; tvUsedToday = false; tapBuff = 0.0; adBuff = 0.0; revealOn = false; revealedCount = 0; revealStars = false; revealStage = false; electionNight = false; activeStateId = null; inputLocked = true; aiPhaseOn = false; if (barWrapEl) barWrapEl.classList.remove("electionNightMode"); resetPlayerStats(); redraw(); const def = tutorialDef(); showPopup( def.title, def.intro, "", [{ label: "BEGIN CAMPAIGN", onClick: () => { hidePopup(); runOpeningReveal(); } }], {} ); } function start2028Level(levelNum){ overlay.classList.add("startMode"); load2028Level(levelNum); resetPlayerStats(); redraw(); const def = getLevelDef(levelNum); const partyName = def.playerParty === "R" ? "Republican" : "Democratic"; const adRule = def.aiCanAdvertise ? "AI ads are enabled." : "AI cannot advertise in this level."; const volunteerRule = def.aiCanVolunteer ? "AI volunteers are enabled." : "AI cannot use volunteers in this level."; showPopup( `Run For President 2028 — Level ${def.level}`, `You are the ${partyName} nominee. Win the most of the ${TOTAL_EV} electoral votes in play. You need ${WIN_EV} EV to win this level. ${adRule} ${volunteerRule}`, "", [ { label: "BEGIN CAMPAIGN", onClick: () => { hidePopup(); runOpeningReveal(); } } ], {} ); } function startHistoricScenario(key){ overlay.classList.add("startMode"); loadScenario(key); const sc = SCENARIOS[key]; playerParty = sc.playerPartyFixed; dayProgress = 0; daysLeft = currentDaysTotal; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; pendingStateAction = null; tvUsedToday = false; tapBuff = 0.0; adBuff = 0.0; revealOn = false; revealedCount = 0; revealStars = false; revealStage = false; electionNight = false; if (barWrapEl) barWrapEl.classList.remove("electionNightMode"); activeStateId = null; electionCounting = false; countingStateId = null; for (const s of states){ s.myStars = 0; s.aiStars = 0; s.myTaps = 0; s.aiTaps = 0; s.locked = null; s.status = "idle"; s.lean = clamp(s.lean,0,100); } redraw(); showSafeBoardThenBegin(); } function chooseParty(choice){ hidePopup(); playerParty = choice; for (const s of states){ s.myStars = 0; s.aiStars = 0; s.myTaps = 0; s.aiTaps = 0; s.locked = null; s.status = "idle"; } dayProgress = 0; daysLeft = currentDaysTotal; actionsRemaining = currentActionsBase; playerDayTapCount = 0; aiDayTapCount = 0; pendingStateAction = null; tvUsedToday = false; tapBuff = 0.0; adBuff = 0.0; resetPlayerStats(); redraw(); runOpeningReveal(); } function runOpeningReveal(){ inputLocked = true; revealOn = true; revealedCount = 0; revealStars = false; revealStage = false; electionNight = false; setAiPhase(false); stopElectionTicker(); tvUsedToday = false; hideTicker(); hideDetailBox(); hideFinalResults(); for (const s of states){ s.locked = null; s.status = "idle"; } redraw(); const total = states.length; function unlockAndGo(){ revealOn = false; inputLocked = false; pendingStateAction = null; redraw({ flashActions:true, flashDays:true }); } function step(){ revealedCount = Math.min(total, revealedCount + 1); redraw(); if (revealedCount < total){ setTimeout(step, REVEAL_BEAT_MS); } else { setTimeout(unlockAndGo, 0); } } setTimeout(step, REVEAL_BEAT_MS); } // ---------------- INIT ---------------- loadScenario("2028"); redraw(); window.addEventListener("resize", () => renderBar()); startIntro(); })();