Driven in a browser for the first time, which is where three bugs were. Every visit to /games/uno was a 500: the page was never added to the list server.go parses into the games template set, so render() answered "unknown page". The casino tests all call their handlers directly and never go through render(), so nothing saw it. TestEveryCasinoPageRenders now walks the mux and asks for every page the casino routes to. The play script hid the first card that lit up rather than the one you clicked, so playing any other playable card made an innocent card vanish. And on a phone the discard sized its box but not its card, which takes its size from --uno-h, so a full-size card hung out of a small hole and covered the colour in play. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
691 lines
24 KiB
JavaScript
691 lines
24 KiB
JavaScript
// The UNO table.
|
||
//
|
||
// Same bargain as every other table in the room: the browser holds no game. It
|
||
// sends one move, and what comes back is that move *and every bot turn it handed
|
||
// off to*, as a script of events. So a round trip here is a whole lap of the
|
||
// table, and this file's main job is to play that lap back slowly enough that
|
||
// you can see what happened to you.
|
||
//
|
||
// Two rules carried over from the tables that came before, both load-bearing:
|
||
//
|
||
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
|
||
// the chip bar does not move until the chips that justify it have landed. So a
|
||
// settling game holds the money back until the payout has physically swept home
|
||
// — a counter that pays you before the last card goes down has told you the
|
||
// ending.
|
||
//
|
||
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
|
||
// backs you see up there is drawn from a number, because a number is all that
|
||
// crossed the wire.
|
||
(function () {
|
||
"use strict";
|
||
|
||
var root = document.querySelector("[data-uno]");
|
||
if (!root) return;
|
||
|
||
var FX = window.PeteFX;
|
||
|
||
var seatsEl = root.querySelector("[data-seats]");
|
||
var handEl = root.querySelector("[data-hand]");
|
||
var deckEl = root.querySelector("[data-deck]");
|
||
var deckCntEl = root.querySelector("[data-deck-count]");
|
||
var discardEl = root.querySelector("[data-discard]");
|
||
var colourEl = root.querySelector("[data-colour]");
|
||
var turnEl = root.querySelector("[data-turn-label]");
|
||
var countEl = root.querySelector("[data-count-label]");
|
||
var verdictEl = root.querySelector("[data-verdict]");
|
||
var paysEl = root.querySelector("[data-pays]");
|
||
var meterEl = root.querySelector("[data-meter]");
|
||
var tableEl = root.querySelector("[data-table-name]");
|
||
|
||
var wildEl = root.querySelector("[data-wild]");
|
||
var betting = root.querySelector("[data-betting]");
|
||
var playing = root.querySelector("[data-playing]");
|
||
var drawBtn = root.querySelector("[data-draw]");
|
||
var passBtn = root.querySelector("[data-pass]");
|
||
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]");
|
||
|
||
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 game as the server last described it
|
||
var tier = "table";
|
||
var pendingWild = -1; // the wild you clicked, waiting on a colour
|
||
var played = -1; // the card you just played, so the script lifts that one out
|
||
// of the hand and not merely the first one that lit up
|
||
|
||
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" : "";
|
||
}
|
||
|
||
// ---- drawing the cards -----------------------------------------------------
|
||
|
||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
||
|
||
// card builds one UNO card. The oval across the middle at an angle is the whole
|
||
// look of the thing — without it a card reads as a coloured button.
|
||
function card(c, opts) {
|
||
opts = opts || {};
|
||
var el = document.createElement(opts.button ? "button" : "div");
|
||
if (opts.button) el.type = "button";
|
||
el.className = "pete-uno-card";
|
||
el.dataset.c = c.wild ? "wild" : c.color;
|
||
|
||
var face = document.createElement("span");
|
||
face.className = "pete-uno-face";
|
||
|
||
var oval = document.createElement("span");
|
||
oval.className = "pete-uno-oval";
|
||
oval.textContent = GLYPHS[c.value] || c.value;
|
||
face.appendChild(oval);
|
||
|
||
// The corners, as printed.
|
||
["tl", "br"].forEach(function (at) {
|
||
var corner = document.createElement("span");
|
||
corner.className = "pete-uno-corner";
|
||
corner.dataset.at = at;
|
||
corner.textContent = GLYPHS[c.value] || c.value;
|
||
corner.setAttribute("aria-hidden", "true");
|
||
face.appendChild(corner);
|
||
});
|
||
|
||
// A wild is four quadrants of colour — and once it has been played as a
|
||
// colour, that colour is the one it wears on the pile.
|
||
if (c.wild) {
|
||
var wheel = document.createElement("span");
|
||
wheel.className = "pete-uno-wheel";
|
||
face.appendChild(wheel);
|
||
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
||
}
|
||
|
||
el.appendChild(face);
|
||
el.setAttribute("aria-label", label(c));
|
||
return el;
|
||
}
|
||
|
||
function label(c) {
|
||
var v = c.value === "+2" ? "draw two" :
|
||
c.value === "+4" ? "wild draw four" :
|
||
c.value === "wild" ? "wild" : c.value;
|
||
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||
return c.color + " " + v;
|
||
}
|
||
|
||
function back() {
|
||
var el = document.createElement("div");
|
||
el.className = "pete-uno-card pete-uno-card-back";
|
||
var b = document.createElement("span");
|
||
b.className = "pete-uno-back";
|
||
el.appendChild(b);
|
||
return el;
|
||
}
|
||
|
||
// ---- the board -------------------------------------------------------------
|
||
|
||
// FAN is the most backs a bot's hand draws, however many it holds. Past this
|
||
// the fan is unreadable and the number beside it is doing the work anyway.
|
||
var FAN = 8;
|
||
|
||
function renderSeats(v) {
|
||
seatsEl.innerHTML = "";
|
||
if (!v) return;
|
||
v.seats.forEach(function (s, i) {
|
||
if (s.you) return; // you are the hand at the bottom, not a seat up here
|
||
var el = document.createElement("div");
|
||
el.className = "pete-uno-seat";
|
||
el.dataset.seat = String(i);
|
||
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
||
if (s.uno) el.dataset.uno = "1";
|
||
|
||
var fan = document.createElement("div");
|
||
fan.className = "pete-uno-fan";
|
||
var show = Math.min(s.cards, FAN);
|
||
for (var n = 0; n < show; n++) {
|
||
var b = back();
|
||
b.style.setProperty("--i", n);
|
||
b.style.setProperty("--n", show);
|
||
fan.appendChild(b);
|
||
}
|
||
el.appendChild(fan);
|
||
|
||
var name = document.createElement("p");
|
||
name.className = "pete-uno-name";
|
||
name.textContent = s.name;
|
||
el.appendChild(name);
|
||
|
||
var count = document.createElement("p");
|
||
count.className = "pete-uno-count";
|
||
count.dataset.count = "";
|
||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
||
el.appendChild(count);
|
||
|
||
seatsEl.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
|
||
|
||
// Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
|
||
// fan; the deck is the deck.
|
||
function seatAnchor(i) {
|
||
if (i === 0) return handEl;
|
||
var el = seatEl(i);
|
||
return el ? el.querySelector(".pete-uno-fan") : deckEl;
|
||
}
|
||
|
||
function renderHand(v) {
|
||
handEl.innerHTML = "";
|
||
if (!v || !v.hand) return;
|
||
var playable = {};
|
||
(v.playable || []).forEach(function (i) { playable[i] = true; });
|
||
var yours = v.turn === 0 && v.phase !== "done";
|
||
|
||
v.hand.forEach(function (c, i) {
|
||
var el = card(c, { button: true });
|
||
el.style.setProperty("--i", i);
|
||
el.dataset.at = String(i);
|
||
var ok = yours && playable[i];
|
||
el.dataset.on = ok ? "1" : "0";
|
||
el.disabled = !ok || busy;
|
||
if (ok) el.addEventListener("click", function () { pick(i, c); });
|
||
handEl.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function renderPiles(v) {
|
||
discardEl.innerHTML = "";
|
||
if (!v) {
|
||
deckCntEl.textContent = "0";
|
||
colourEl.textContent = "";
|
||
root.querySelector(".pete-uno").dataset.c = "";
|
||
return;
|
||
}
|
||
deckCntEl.textContent = String(v.deck);
|
||
var top = card(v.top);
|
||
top.classList.add("pete-uno-top");
|
||
discardEl.appendChild(top);
|
||
|
||
// The colour in play, which after a wild is not the colour of the card you
|
||
// are looking at. So it is said in words, and the felt takes a tint of it —
|
||
// this is the single most missable fact on the table.
|
||
colourEl.textContent = v.color;
|
||
colourEl.dataset.c = v.color;
|
||
feltEl.dataset.c = v.color;
|
||
}
|
||
|
||
var feltEl = root.querySelector(".pete-uno");
|
||
|
||
function renderRail(v) {
|
||
if (!v) {
|
||
paysEl.textContent = "—";
|
||
meterEl.dataset.cold = "1";
|
||
tableEl.textContent = "";
|
||
return;
|
||
}
|
||
paysEl.textContent = (v.pays || 0).toLocaleString();
|
||
meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
|
||
tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
|
||
}
|
||
|
||
function renderTurn(v) {
|
||
if (!v || v.phase === "done") {
|
||
turnEl.textContent = "";
|
||
countEl.textContent = "";
|
||
return;
|
||
}
|
||
var yours = v.turn === 0;
|
||
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
||
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
||
turnEl.textContent = who;
|
||
turnEl.dataset.you = yours ? "1" : "0";
|
||
var n = v.hand.length;
|
||
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
|
||
}
|
||
|
||
// ---- phases ----------------------------------------------------------------
|
||
|
||
var VERDICTS = {
|
||
won: "You went out! 🎉",
|
||
lost: "Beaten to it.",
|
||
stuck: "Nobody could move.",
|
||
};
|
||
|
||
function verdict(v) {
|
||
var text = VERDICTS[v.outcome] || "";
|
||
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
||
text = v.seats[v.winner].name + " went out first.";
|
||
}
|
||
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");
|
||
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
|
||
}
|
||
|
||
function setPhase(v) {
|
||
game = v;
|
||
var live = !!v && v.phase !== "done";
|
||
betting.classList.toggle("hidden", live);
|
||
playing.classList.toggle("hidden", !live);
|
||
hideWild();
|
||
|
||
var yours = live && v.turn === 0;
|
||
if (drawBtn) {
|
||
drawBtn.disabled = !yours || v.phase === "drawn" || busy;
|
||
drawBtn.classList.toggle("hidden", !!(live && v.phase === "drawn"));
|
||
}
|
||
if (passBtn) {
|
||
passBtn.classList.toggle("hidden", !(live && v.phase === "drawn"));
|
||
passBtn.disabled = !yours || busy;
|
||
}
|
||
if (deckEl) deckEl.disabled = !yours || v.phase === "drawn" || busy;
|
||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||
}
|
||
|
||
// paint puts the board up with no animation: the resume path, after a reload or
|
||
// a redeploy. Whatever the server says is on the table is on the table.
|
||
function paint(v) {
|
||
renderSeats(v);
|
||
renderPiles(v);
|
||
renderHand(v);
|
||
renderRail(v);
|
||
renderTurn(v);
|
||
spot.render(v && v.phase !== "done" ? v.bet : 0);
|
||
setPhase(v);
|
||
}
|
||
|
||
// ---- the script ------------------------------------------------------------
|
||
|
||
// throwCard sends a card from one place to another across the felt.
|
||
function throwCard(node, from, to, opts) {
|
||
opts = opts || {};
|
||
return FX.flyNode(node, from, to, {
|
||
duration: opts.duration || 380,
|
||
lift: opts.lift == null ? 0.7 : opts.lift,
|
||
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
|
||
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
|
||
delay: opts.delay || 0,
|
||
});
|
||
}
|
||
|
||
// bump keeps a bot's fan honest during the script: the server's count is the
|
||
// truth, but between events the fan has to grow and shrink as cards move, or
|
||
// the flight lands on a pile that hasn't changed.
|
||
function bump(seat, left) {
|
||
if (seat === 0 || left == null) return;
|
||
var el = seatEl(seat);
|
||
if (!el) return;
|
||
var fan = el.querySelector(".pete-uno-fan");
|
||
var count = el.querySelector("[data-count]");
|
||
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
|
||
if (!fan) return;
|
||
var show = Math.min(left, FAN);
|
||
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
||
while (fan.children.length < show) fan.appendChild(back());
|
||
Array.prototype.forEach.call(fan.children, function (b, i) {
|
||
b.style.setProperty("--i", i);
|
||
b.style.setProperty("--n", fan.children.length);
|
||
});
|
||
el.dataset.uno = left === 1 ? "1" : "0";
|
||
}
|
||
|
||
function spotlight(seat) {
|
||
seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
|
||
el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
|
||
});
|
||
}
|
||
|
||
// badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
|
||
// these things; the badge is what makes them land on somebody.
|
||
function badge(seat, text, tone) {
|
||
var host = seat === 0 ? handEl : seatEl(seat);
|
||
if (!host || reduced) return Promise.resolve();
|
||
var b = document.createElement("span");
|
||
b.className = "pete-uno-badge";
|
||
b.dataset.tone = tone || "";
|
||
b.textContent = text;
|
||
host.appendChild(b);
|
||
return new Promise(function (r) {
|
||
setTimeout(function () { b.remove(); r(); }, 900);
|
||
});
|
||
}
|
||
|
||
// play walks the server's script. On a live game the money is already right —
|
||
// your stake left your pile when you pressed Deal, and it's on the spot — so
|
||
// the chip bar can catch up immediately. On a settling one it waits.
|
||
function play(view, money) {
|
||
var events = view.uno_events || [];
|
||
var final = view.uno;
|
||
var settles = !!final && final.phase === "done";
|
||
var chain = Promise.resolve();
|
||
|
||
if (!settles) money();
|
||
|
||
events.forEach(function (e, n) {
|
||
chain = chain.then(function () {
|
||
switch (e.kind) {
|
||
case "deal":
|
||
// The deal isn't animated card by card: seven cards to each of four
|
||
// seats is 28 flights and a player who wants to play. The hand fans in
|
||
// on its own (CSS), which reads as being dealt without taking as long.
|
||
paint(final);
|
||
return wait(320);
|
||
|
||
case "play": {
|
||
spotlight(e.seat);
|
||
var node = card(e.card);
|
||
var from = seatAnchor(e.seat);
|
||
// Your own card leaves the hand it was in, so the hand has to lose it
|
||
// before the flight or the card is briefly in two places.
|
||
if (e.seat === 0 && played >= 0) {
|
||
var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
|
||
if (live) live.style.visibility = "hidden";
|
||
played = -1;
|
||
}
|
||
bump(e.seat, e.left);
|
||
return throwCard(node, from, discardEl, { index: n })
|
||
.then(function () {
|
||
discardEl.innerHTML = "";
|
||
var top = card(e.card);
|
||
top.classList.add("pete-uno-top", "pete-uno-land");
|
||
discardEl.appendChild(top);
|
||
if (e.color) {
|
||
colourEl.textContent = e.color;
|
||
colourEl.dataset.c = e.color;
|
||
feltEl.dataset.c = e.color;
|
||
}
|
||
return wait(e.seat === 0 ? 120 : 300);
|
||
});
|
||
}
|
||
|
||
case "draw":
|
||
case "forced": {
|
||
spotlight(e.seat);
|
||
var to = seatAnchor(e.seat);
|
||
var backs = [];
|
||
for (var i = 0; i < Math.min(e.n, 4); i++) {
|
||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||
}
|
||
var punished = e.kind === "forced";
|
||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
||
return Promise.all(backs).then(function () {
|
||
bump(e.seat, e.left);
|
||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||
// Your own drawn card comes face up — it's yours, and the server
|
||
// sent its face for exactly this.
|
||
if (e.seat === 0 && e.card) return wait(160);
|
||
return wait(punished ? 380 : 180);
|
||
});
|
||
}
|
||
|
||
case "skip":
|
||
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
||
|
||
case "reverse":
|
||
feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
|
||
return badge(e.seat, "Reverse").then(function () { return wait(60); });
|
||
|
||
case "uno":
|
||
return badge(e.seat, "UNO!", "uno");
|
||
|
||
case "reshuffle":
|
||
deckEl.classList.add("pete-uno-shuffle");
|
||
return wait(420).then(function () {
|
||
deckEl.classList.remove("pete-uno-shuffle");
|
||
});
|
||
|
||
case "pass":
|
||
spotlight(e.seat);
|
||
return wait(140);
|
||
|
||
case "settle":
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
|
||
return chain.then(function () {
|
||
if (!final) { paint(null); money(); return; }
|
||
|
||
if (!settles) {
|
||
paint(final);
|
||
return;
|
||
}
|
||
|
||
// Over: the board settles, the money moves, and only then does the bar
|
||
// catch up.
|
||
renderSeats(final);
|
||
renderPiles(final);
|
||
renderHand(final);
|
||
renderTurn(final);
|
||
renderRail(final);
|
||
verdict(final);
|
||
playing.classList.add("hidden");
|
||
return settleChips(final)
|
||
.then(money)
|
||
.then(function () { return standing(final.bet); })
|
||
.then(function () { setPhase(final); });
|
||
});
|
||
}
|
||
|
||
// ---- the money -------------------------------------------------------------
|
||
|
||
function settleChips(final) {
|
||
var payout = final.payout || 0;
|
||
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||
|
||
var back = payout - final.bet;
|
||
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 game, the way every
|
||
// other table in the room leaves your bet up. pour grows the pile from what it
|
||
// is told is already there, so the amount is never set first — that bug printed
|
||
// double the stake under trivia's chips for a day.
|
||
function standing(amount) {
|
||
var money = window.PeteGames.view();
|
||
if (!amount || !money || money.chips < amount) {
|
||
bet = 0;
|
||
showBet();
|
||
return;
|
||
}
|
||
bet = amount;
|
||
showBet();
|
||
return spot.pour(purseEl, amount);
|
||
}
|
||
|
||
// ---- moves ------------------------------------------------------------------
|
||
|
||
function pick(i, c) {
|
||
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
|
||
if (c.wild) { askColour(i); return; }
|
||
move({ kind: "play", index: i });
|
||
}
|
||
|
||
function askColour(i) {
|
||
pendingWild = i;
|
||
wildEl.classList.remove("hidden");
|
||
}
|
||
|
||
function hideWild() {
|
||
pendingWild = -1;
|
||
if (wildEl) wildEl.classList.add("hidden");
|
||
}
|
||
|
||
// COLOURS maps the colour a player names to the number the engine calls it.
|
||
// Wild is 0 there on purpose — a move with no colour on it must not quietly
|
||
// mean red — so these start at 1 and this table is the only place that knows.
|
||
var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
|
||
|
||
root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
if (busy || pendingWild < 0) return;
|
||
var i = pendingWild;
|
||
var c = COLOURS[b.dataset.colourPick];
|
||
hideWild();
|
||
move({ kind: "play", index: i, color: c });
|
||
});
|
||
});
|
||
|
||
var cancelWild = root.querySelector("[data-colour-cancel]");
|
||
if (cancelWild) cancelWild.addEventListener("click", hideWild);
|
||
|
||
function move(body) {
|
||
played = body.kind === "play" ? body.index : -1;
|
||
send("/api/games/uno/move", body, gameMsgEl);
|
||
}
|
||
|
||
if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
|
||
if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
|
||
if (passBtn) {
|
||
passBtn.addEventListener("click", function () {
|
||
if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return;
|
||
move({ kind: "pass" });
|
||
});
|
||
}
|
||
|
||
function drawOne() {
|
||
if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
|
||
move({ kind: "draw" });
|
||
}
|
||
|
||
// ---- talking to the table ----------------------------------------------------
|
||
|
||
function send(path, body, where) {
|
||
if (busy) return;
|
||
busy = true;
|
||
say("", null, where);
|
||
lock();
|
||
return window.PeteGames.post(path, body)
|
||
.then(function (view) {
|
||
busy = false;
|
||
return play(view, function () { window.PeteGames.apply(view); });
|
||
})
|
||
.catch(function (err) {
|
||
busy = false;
|
||
say(err.message, "bad", where);
|
||
return window.PeteGames.refresh().then(function (v) {
|
||
if (v && v.uno) paint(v.uno);
|
||
else { paint(null); spot.render(0); }
|
||
});
|
||
});
|
||
}
|
||
|
||
// lock takes the table away while a move is in flight, so a second click can't
|
||
// send a second move against a game the first one is about to change.
|
||
function lock() {
|
||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = true; });
|
||
if (drawBtn) drawBtn.disabled = true;
|
||
if (passBtn) passBtn.disabled = true;
|
||
if (deckEl) deckEl.disabled = true;
|
||
}
|
||
|
||
// ---- 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 rack are the house's, and the house 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 (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||
// The stake sits on the spot for the whole game: it is what's riding on you
|
||
// going out first.
|
||
send("/api/games/uno/start", { bet: bet, tier: tier });
|
||
});
|
||
}
|
||
|
||
pickTier(tier);
|
||
|
||
var resumed = false;
|
||
window.PeteGames.onUpdate(function (v) {
|
||
if (!resumed) {
|
||
resumed = true;
|
||
if (v.uno) {
|
||
paint(v.uno);
|
||
if (v.uno.phase === "done") verdict(v.uno);
|
||
} else {
|
||
paint(null);
|
||
}
|
||
}
|
||
showBet();
|
||
});
|
||
})();
|