// The trivia table. // // Same bargain as every other table in the room: the browser holds no game. It // sends an answer, and the server says how it went. The four buttons arrive // without any mark on which of them is right — that index is in the engine // state, on the server — and the reveal only comes back in the event that // decides the question, by which point knowing it is worth nothing. // // The countdown here is decoration, and it is important to be clear about that. // Nothing it does scores anything: the server timed the answer the moment it // arrived, against the clock it started when it served the question. Stopping // this bar, or reloading to restart it, changes nothing at all. What the bar // owes the player is *honesty* — so it is seeded from the seconds the server // says are left, not from when the browser got round to painting. (function () { "use strict"; var root = document.querySelector("[data-trivia]"); if (!root) return; var FX = window.PeteFX; var questionEl = root.querySelector("[data-question]"); var categoryEl = root.querySelector("[data-category]"); var answersEl = root.querySelector("[data-answers]"); var clockEl = root.querySelector("[data-clock]"); var clockFillEl = root.querySelector("[data-clock-fill]"); var countdownEl = root.querySelector("[data-countdown]"); var ladderEl = root.querySelector("[data-ladder]"); var multEl = root.querySelector("[data-multiple]"); var meterEl = root.querySelector("[data-meter]"); var standsEl = root.querySelector("[data-stands]"); var standsLbl = root.querySelector("[data-stands-label]"); var rungEl = root.querySelector("[data-rung]"); var verdictEl = root.querySelector("[data-verdict]"); var betting = root.querySelector("[data-betting]"); var playing = root.querySelector("[data-playing]"); var walkBtn = root.querySelector("[data-walk]"); var walkAmtEl = root.querySelector("[data-walk-amount]"); var betAmount = root.querySelector("[data-bet-amount]"); var startBtn = root.querySelector("[data-start]"); var msgEl = root.querySelector("[data-table-msg]"); var gameMsgEl = root.querySelector("[data-game-msg]"); var purseEl = document.querySelector("[data-chips]"); var spotEl = root.querySelector("[data-spot]"); var houseEl = root.querySelector("[data-house]"); // The bet spot, and the rule that comes with it: the number under the pile is // a readout of the pile, never the other way round. var spot = FX.spot({ spot: spotEl, stack: root.querySelector("[data-stack]"), total: root.querySelector("[data-spot-total]"), }); var bet = 0; // what you're building between games var busy = false; var game = null; // the round as the server last described it var tier = "medium"; var reduced = FX.reduced; function pace(ms) { return reduced ? 0 : ms; } function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } function say(text, tone, where) { var el = where || msgEl; if (!el) return; if (!text) { el.classList.add("hidden"); return; } el.textContent = text; el.classList.remove("hidden"); el.style.color = tone === "bad" ? "#cc3d4a" : ""; } // ---- the clock ------------------------------------------------------------- var raf = null; var clockDeadline = 0; // performance.now() ms at which this question dies var clockLimit = 1; var timedOut = false; // HOT is when the bar stops being a progress meter and starts being a warning. var HOT = 5; // GRACE is how long past its own zero the browser waits before reporting the // timeout. It cannot be negative: the browser's countdown starts when the // response *arrives*, so it is already behind the server's by the latency of // that response, and it therefore always reaches zero after the server has. // The grace is only there so that "always" survives a rounding error. var GRACE = 400; function stopClock() { if (raf) cancelAnimationFrame(raf); raf = null; } function startClock(left, limit) { stopClock(); timedOut = false; clockLimit = limit > 0 ? limit : 1; clockDeadline = performance.now() + left * 1000; tick(); } function tick() { var left = (clockDeadline - performance.now()) / 1000; if (left < 0) left = 0; // A transform, not a width: the browser can run this one without laying the // page out again on every frame. clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")"; countdownEl.textContent = left.toFixed(1) + "s"; clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0"; if (left <= 0) { countdownEl.textContent = "0.0s"; clockEl.dataset.hot = "0"; timeUp(); return; } raf = requestAnimationFrame(tick); } // timeUp reports the clock running out. It is not the browser *deciding* the // question — it is the browser telling the server the player never answered, // and the server (which has been holding the real clock all along) agreeing. function timeUp() { stopClock(); if (timedOut || !game || game.phase !== "playing") return; // A move is already in flight. Come back rather than give up: this fires when // the server has rejected our last timeout report (its clock hadn't run out // yet) and the refresh has re-armed a countdown that is already at zero. Give // up here and the clock sits frozen at 0.0s and the question never resolves. if (busy) { setTimeout(timeUp, 250); return; } timedOut = true; lockAnswers(); setTimeout(function () { // -1 is "no answer". The engine checks the clock before it checks the // choice, so this resolves as the timeout it is rather than a bad move. send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl); }, GRACE); } function clearClock() { stopClock(); clockFillEl.style.transform = "scaleX(0)"; countdownEl.textContent = ""; clockEl.dataset.hot = "0"; } // ---- the question ---------------------------------------------------------- var KEYS = ["1", "2", "3", "4"]; function renderQuestion(v) { answersEl.innerHTML = ""; if (!v || v.phase !== "playing" || !v.answers) { questionEl.textContent = ""; categoryEl.textContent = ""; return; } categoryEl.textContent = v.category || ""; questionEl.textContent = v.question || ""; v.answers.forEach(function (text, i) { var b = document.createElement("button"); b.type = "button"; b.className = "pete-answer"; b.dataset.at = String(i); var key = document.createElement("span"); key.className = "pete-answer-key"; key.textContent = KEYS[i] || ""; key.setAttribute("aria-hidden", "true"); var label = document.createElement("span"); label.textContent = text; b.appendChild(key); b.appendChild(label); b.addEventListener("click", function () { answer(i); }); answersEl.appendChild(b); }); } function lockAnswers() { answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; }); } // reveal marks the board once the server has decided. Nothing in here is known // until it comes back: the right answer arrives in the event, not in the view. function reveal(choice, correct) { answersEl.querySelectorAll(".pete-answer").forEach(function (b) { var i = parseInt(b.dataset.at, 10); b.disabled = true; if (i === choice && i === correct) b.dataset.state = "right"; else if (i === choice) b.dataset.state = "wrong"; else if (i === correct) b.dataset.state = "missed"; else b.dataset.state = "dim"; }); } // ---- the meters ------------------------------------------------------------ function renderLadder(v) { ladderEl.innerHTML = ""; var rungs = (v && v.rungs) || 12; var done = (v && v.rung) || 0; for (var i = 0; i < rungs; i++) { var pip = document.createElement("span"); pip.className = "pete-rung"; pip.dataset.on = i < done ? "1" : "0"; ladderEl.appendChild(pip); } } function renderMeter(v) { if (!v) { multEl.textContent = "—"; standsEl.textContent = "—"; standsLbl.textContent = "if you walk"; meterEl.dataset.cold = "1"; rungEl.textContent = ""; return; } multEl.textContent = v.multiple.toFixed(2) + "×"; standsEl.textContent = (v.stands || 0).toLocaleString(); meterEl.dataset.cold = v.rung === 0 ? "1" : "0"; if (v.phase === "done") { standsLbl.textContent = v.net > 0 ? "banked" : "gone"; rungEl.textContent = ""; return; } standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock"; rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs; } // knock rolls the multiple up to its new value rather than swapping it, so a // right answer reads as the total *growing* — which is the thing you're // deciding whether to risk. function climb(v) { var from = parseFloat(multEl.textContent) || 1; var to = v.multiple; if (reduced) { renderMeter(v); return; } var t0 = performance.now(); meterEl.dataset.hit = "0"; (function step(now) { var p = Math.min(1, (now - t0) / 420); var eased = 1 - Math.pow(1 - p, 3); multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×"; if (p < 1) requestAnimationFrame(step); else renderMeter(v); })(t0); } // ---- the money ------------------------------------------------------------- function settleChips(final) { var payout = final.payout || 0; var back = payout - final.bet; if (payout <= 0) { var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); return chain; } return spot .pour(houseEl, back, { gap: 60 }) .then(function () { return wait(back > 0 ? 380 : 200); }) .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); } // standing puts the stake back on the spot for the next ladder, the way every // other table in the room leaves your bet up. function standing(amount) { var money = window.PeteGames.view(); if (!amount || !money || money.chips < amount) { bet = 0; showBet(); return; } bet = amount; showBet(); // pour grows the pile from whatever is on the spot, and settle has just swept // it clean — so it must not be told the chips are already there. Setting the // amount first counted the standing bet twice, and the spot printed 400 under // a 200 stake. return spot.pour(purseEl, amount); } // ---- phases ---------------------------------------------------------------- var VERDICTS = { walked: "Banked it.", cleared: "Cleared the board! 🎉", wrong: "Wrong.", timeout: "Out of time.", }; function verdict(v) { var text = VERDICTS[v.outcome] || ""; if (!text) { verdictEl.classList.add("hidden"); return; } if (v.net > 0) text += " +" + v.net.toLocaleString(); else if (v.net < 0) text += " " + v.net.toLocaleString(); verdictEl.textContent = text; verdictEl.classList.remove("hidden"); // Confetti only for clearing all twelve — the one thing in here worth it. if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 }); } function setPhase(v) { game = v; var live = !!v && v.phase === "playing"; betting.classList.toggle("hidden", live); playing.classList.toggle("hidden", !live); if (walkBtn) { walkBtn.disabled = !live || !v.can_walk; walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString(); } if (!v || !v.outcome) verdictEl.classList.add("hidden"); } // paint puts a round up with no animation: the resume path, after a reload or a // redeploy. The clock picks up exactly where the server says it is — which is // the whole point of it being the server's clock. function paint(v) { if (!v) { clearClock(); renderQuestion(null); renderLadder(null); renderMeter(null); spot.render(0); setPhase(null); return; } renderQuestion(v); renderLadder(v); renderMeter(v); spot.render(v.phase === "done" ? 0 : v.bet); setPhase(v); if (v.phase === "playing") startClock(v.left, v.limit); else clearClock(); } // ---- the script ------------------------------------------------------------ // play walks the server's events. Same rule as the other tables: on a live // round the money is already right (your stake left your pile when you pressed // Play, and it's on the spot), but on a settling one the chip bar is held back // until the chips have physically come home. A counter that pays you before // the reveal is a counter that has told you the ending. function play(view, money) { var events = view.triv_events || []; var final = view.trivia; var settles = !!final && final.phase === "done"; var chain = Promise.resolve(); if (!settles) money(); stopClock(); events.forEach(function (e) { chain = chain.then(function () { switch (e.kind) { case "ask": // A fresh question. Everything about the last one goes. verdictEl.classList.add("hidden"); renderQuestion(final); renderLadder(final); if (final && final.phase === "playing") startClock(final.left, final.limit); return; case "right": reveal(e.choice, e.correct); if (final) { // The rung lighting and the multiple climbing are one event, // because they are one event: this is what the answer was worth. climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs, stands: final.stands, can_walk: true, phase: "playing" }); renderLadder(final); } return wait(900); case "wrong": reveal(e.choice, e.correct); return wait(1100); case "timeout": reveal(-1, e.correct); return wait(1100); case "settle": return; } }); }); return chain.then(function () { if (!final) { paint(null); money(); return; } if (!settles) { renderMeter(final); setPhase(final); return; } // Over: the clock stops, the money moves, and only then does the bar catch up. clearClock(); playing.classList.add("hidden"); renderMeter(final); renderLadder(final); verdict(final); return settleChips(final) .then(money) .then(function () { return standing(final.bet); }) .then(function () { setPhase(final); }); }); } // ---- talking to the table --------------------------------------------------- function send(path, body, where) { if (busy) return; busy = true; say("", null, where); return window.PeteGames.post(path, body) .then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); }) .catch(function (err) { say(err.message, "bad", where); return window.PeteGames.refresh().then(function (v) { if (v && v.trivia) paint(v.trivia); else { paint(null); spot.render(0); } }); }) .then(function () { busy = false; }); } function answer(i) { if (busy || timedOut || !game || game.phase !== "playing") return; stopClock(); lockAnswers(); send("/api/games/trivia/answer", { choice: i }, gameMsgEl); } if (walkBtn) { walkBtn.addEventListener("click", function () { if (busy || !game || game.phase !== "playing" || !game.can_walk) return; stopClock(); lockAnswers(); send("/api/games/trivia/answer", { walk: true }, gameMsgEl); }); } // 1–4 answers the question. The key is printed on the button it answers. document.addEventListener("keydown", function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return; if (!game || game.phase !== "playing" || busy) return; var i = KEYS.indexOf(e.key); if (i === -1) return; var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]'); if (!btn || btn.disabled) return; e.preventDefault(); answer(i); }); // ---- betting ---------------------------------------------------------------- function showBet() { betAmount.textContent = bet.toLocaleString(); var money = window.PeteGames.view(); if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet; } function pickTier(slug) { tier = slug; root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b.dataset.tier === slug ? "1" : "0"; }); showBet(); } root.querySelectorAll("[data-tier]").forEach(function (b) { b.addEventListener("click", function () { if (busy) return; pickTier(b.dataset.tier); }); }); // The chip you click is the chip that flies. Scoped to buttons: the bare // [data-chip] spans in the corner are the house's rack, and it is not betting. root.querySelectorAll("button[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { if (busy) return; var d = parseInt(btn.dataset.chip, 10); var money = window.PeteGames.view(); if (money && bet + d > money.chips) { say("You haven't got that many chips.", "bad"); return; } bet += d; showBet(); var target = bet; spot.amount = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { if (bet >= target) spot.render(target); // unless Clear got there first }); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { if (busy) return; if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); bet = 0; showBet(); }); } if (startBtn) { startBtn.addEventListener("click", function () { if (busy) return; if (!tier) { say("Pick a difficulty first.", "bad"); return; } if (bet <= 0) { say("Put something on it first.", "bad"); return; } // The stake stays on the spot for the whole ladder: it is what's at risk, // and it is riding on every question until you take it back or lose it. send("/api/games/trivia/start", { bet: bet, tier: tier }); }); } pickTier(tier); var resumed = false; window.PeteGames.onUpdate(function (v) { if (!resumed) { resumed = true; if (v.trivia) { paint(v.trivia); if (v.trivia.phase === "done") verdict(v.trivia); } else { paint(null); } } showBet(); }); })();