' ); 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 = `
`;
}
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:
`
`, imgSrc: "TRUMP.png" }, "2012": { titleHtml: "2012 CAMPAIGN", bodyHtml: `
`, imgSrc: "OBAMA.png" } }; const intro = historicIntro[scenarioKey] || { titleHtml: "HISTORIC CAMPAIGN", bodyHtml: `
`, 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 = `
` + `
`; } 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 ? `
` : ""; } 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 = [ `
` ]; if (badges.length) lines.push(`
`); 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 = `
` + `
` + (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`
: "";
} 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 =
`
`;
const bodyHtml = `