Trivia had every Go test passing and had never been in a browser, which this plan's own rule says means nothing. So: play it. The game itself holds up. The clock drains honestly and does not restart on a reload, the multiple compounds, walking pays exactly what the felt quoted, the reveal marks the right answer, and the auto-submit at zero lands as a timeout rather than an illegal move. The next question's answer never crosses the wire. Two bugs only the browser could show: - The spot printed double the stake after every settled game. standing() set spot.amount and *then* poured the chips on, and pour grows the pile from what it is told is already there. The money was always right; the number under the chips was not, which is the one rule the felt is built on. - The house rack sat on top of the multiplier at 390px. Its 5.75rem inset is not a margin, it is the width of blackjack's shoe — so on a phone the rack sits in the middle of the felt. It now shrinks on small screens and pulls into the corner where the corner is empty; data-at says which rack is which, because pulling blackjack's to the edge slides it under the deck. The dev rig seeds its own question bank now (one real OpenTDB batch per difficulty), because a fresh dev database 503s every start otherwise.
550 lines
18 KiB
JavaScript
550 lines
18 KiB
JavaScript
// 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 || busy || !game || game.phase !== "playing") 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();
|
||
});
|
||
})();
|