Files
Pete/internal/web/static/js/trivia.js
prosolis 39ed293f4f games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:15:11 -07:00

564 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
FX.sfx("blip");
return;
case "right":
reveal(e.choice, e.correct);
// Three notes up, one per rung climbed — the ladder, in sound.
[0, 0.09, 0.18].forEach(function (d, i) {
FX.sfx("tick", { delay: d, v: i });
});
FX.sfx("win", { delay: 0.1 });
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);
FX.sfx("bad");
return wait(1100);
case "timeout":
// The clock beat you to it, which is a different kind of bad.
reveal(-1, e.correct);
FX.sfx("lose");
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);
});
}
// 14 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();
});
})();