The engine has been able to play No Mercy since aca523e. Now a browser can.
The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.
The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.
A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.
Two bugs, both found in a browser and neither findable anywhere else:
The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.
And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
906 lines
34 KiB
JavaScript
906 lines
34 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]");
|
||
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
|
||
// comes first in the document. See the note in uno.html.
|
||
var billEl = root.querySelector("[data-bill]");
|
||
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 takeBtn = root.querySelector("[data-take]");
|
||
var takeN = root.querySelector("[data-take-n]");
|
||
var hintEl = root.querySelector("[data-hint]");
|
||
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 -----------------------------------------------------
|
||
|
||
// FACES is how each printed face is drawn. The engine sends a word ("skip",
|
||
// "+2", "discard all"); this is the picture of it.
|
||
//
|
||
// The corner is not always the same mark as the middle. "DISCARD ALL" fits
|
||
// across an oval and does not fit in a corner, so the long faces carry a short
|
||
// mark for the corners — which is what a real deck does too. `size` is a class,
|
||
// never a height: a card is sized by --uno-h/--uno-w and nothing else, and a
|
||
// face that sets its own font in pixels is a face that breaks on a phone.
|
||
var FACES = {
|
||
"skip": { mid: "🚫", corner: "🚫" },
|
||
"reverse": { mid: "⇄", corner: "⇄" },
|
||
"+2": { mid: "+2", corner: "+2" },
|
||
"wild": { mid: "★", corner: "★" },
|
||
"+4": { mid: "+4", corner: "+4" },
|
||
|
||
// No Mercy.
|
||
"skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" },
|
||
"discard all": { mid: "DISCARD ALL", corner: "≡", size: "words" },
|
||
"rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" },
|
||
"+6": { mid: "+6", corner: "+6" },
|
||
"+10": { mid: "+10", corner: "+10", size: "mid" },
|
||
// The roulette names a colour and turns cards over until it comes up. The
|
||
// wheel behind the oval is already the picture of that; the mark is the
|
||
// question the wheel is asking.
|
||
"roulette": { mid: "?", corner: "?" },
|
||
};
|
||
|
||
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
|
||
|
||
// The faces that make somebody draw. On a wild these are the cards worth the
|
||
// most and worth watching for, so they get the glow — and it is the one thing
|
||
// that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance.
|
||
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
|
||
|
||
// 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 f = faceOf(c);
|
||
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";
|
||
if (f.size) oval.dataset.size = f.size;
|
||
oval.textContent = f.mid;
|
||
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 = f.corner;
|
||
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;
|
||
}
|
||
|
||
// The wild draws glow. They are the cards that decide a game — a +4 you were
|
||
// holding is four cards somebody else is about to be holding — and in No Mercy
|
||
// there are four of them to tell apart from the coloured +4 sitting next to
|
||
// them in the same hand. The glow is what says "this one is the nasty one".
|
||
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
|
||
|
||
el.appendChild(face);
|
||
el.setAttribute("aria-label", label(c));
|
||
return el;
|
||
}
|
||
|
||
// label is what a screen reader says. Note "+4" is two different cards — No
|
||
// Mercy prints a coloured one next to the wild one — so the name comes from the
|
||
// face *and* whether it's a wild, never from the face alone.
|
||
var SPOKEN = {
|
||
"+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
|
||
"wild": "wild", "skip": "skip", "reverse": "reverse",
|
||
"skip all": "skip everyone", "discard all": "discard all",
|
||
"rev +4": "reverse and draw four", "roulette": "colour roulette",
|
||
};
|
||
|
||
function label(c) {
|
||
var v = SPOKEN[c.value] || c.value;
|
||
if (c.wild) {
|
||
var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild"
|
||
return name + (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";
|
||
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
|
||
|
||
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.out ? "Buried" : 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;
|
||
pending(v.phase === "done" ? 0 : v.pending || 0);
|
||
}
|
||
|
||
var feltEl = root.querySelector(".pete-uno");
|
||
|
||
// pending is the bill a stack has run up. It is on the felt, on the button and
|
||
// in the turn line, because it is the only thing on the table while it stands:
|
||
// a player who misses it plays into a hand that is about to grow by ten.
|
||
function pending(n) {
|
||
if (billEl) {
|
||
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
|
||
billEl.classList.toggle("hidden", n <= 0);
|
||
}
|
||
if (takeN) takeN.textContent = String(n);
|
||
}
|
||
|
||
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";
|
||
if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take 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.";
|
||
}
|
||
// No Mercy has two endings the normal deck hasn't got: you can lose without
|
||
// anybody going out (the mercy rule got you), and win without going out at all
|
||
// (it got everybody else).
|
||
if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done.";
|
||
if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! 🎉";
|
||
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 });
|
||
}
|
||
|
||
// controls is the one place that decides what you can click: whose turn the
|
||
// server says it is, and whether a move of yours is still in flight. Both
|
||
// halves matter, and they used to be spread across three functions that each
|
||
// knew half of it — which is how the table came to unlock itself in the middle
|
||
// of a bot's turn.
|
||
function controls() {
|
||
var live = !!game && game.phase !== "done";
|
||
var yours = live && game.turn === 0;
|
||
var drawn = live && game.phase === "drawn";
|
||
var stack = live && game.phase === "stack";
|
||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||
});
|
||
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
|
||
// moves that exist are answering it with a draw card or taking the lot.
|
||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
|
||
if (passBtn) passBtn.disabled = busy || !yours;
|
||
if (takeBtn) takeBtn.disabled = busy || !yours;
|
||
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
|
||
}
|
||
|
||
// HINTS is the line above the buttons. A game with a stack in it has a rule the
|
||
// player has just met for the first time, and the table should say what it is
|
||
// rather than leave them clicking a hand where nothing lights up.
|
||
var HINTS = {
|
||
play: "Click a card that lights up. Nothing lights up? Draw one.",
|
||
drawn: "You drew a card you can play. Play it, or keep it and pass.",
|
||
stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
|
||
};
|
||
|
||
function setPhase(v) {
|
||
game = v;
|
||
var live = !!v && v.phase !== "done";
|
||
var drawn = live && v.phase === "drawn";
|
||
var stack = live && v.phase === "stack";
|
||
betting.classList.toggle("hidden", live);
|
||
playing.classList.toggle("hidden", !live);
|
||
hideWild();
|
||
|
||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
|
||
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
|
||
// No Mercy has no pass: you drew until you found a card that plays, and
|
||
// playing it is the price of having drawn. The server refuses one anyway;
|
||
// this is the table not offering a button that cannot work.
|
||
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
|
||
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
|
||
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
|
||
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
|
||
controls();
|
||
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]");
|
||
var buried = el.dataset.out === "1"; // an empty hand here is a grave, not a win
|
||
if (count) count.textContent = buried ? "Buried" : 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";
|
||
// A forced draw is also how a stack ends: somebody stopped answering
|
||
// and paid the bill, so the bill is gone.
|
||
if (punished) { pending(0); 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 "stack":
|
||
// The bill has moved on to somebody, and N is what it stands at now —
|
||
// not what was just added to it. A +2 answered with a +10 is a seat
|
||
// looking at twelve cards.
|
||
pending(e.n);
|
||
spotlight(e.seat);
|
||
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||
|
||
case "skipall":
|
||
// The turn does not move: it comes straight back to the seat that
|
||
// played it, which is the whole card.
|
||
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
|
||
|
||
case "discard": {
|
||
// A whole colour left a hand at once. If it was your hand, those cards
|
||
// are on the table in front of you — so they go before the flight, or
|
||
// they sit there looking like they were dumped twice.
|
||
if (e.seat === 0 && e.color) {
|
||
handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) {
|
||
el.style.visibility = "hidden";
|
||
});
|
||
}
|
||
bump(e.seat, e.left);
|
||
var dumpFrom = seatAnchor(e.seat);
|
||
var dumped = [];
|
||
for (var d = 0; d < Math.min(e.n, 4); d++) {
|
||
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
|
||
}
|
||
badge(e.seat, "−" + e.n + " " + (e.color || ""));
|
||
return Promise.all(dumped).then(function () { return wait(220); });
|
||
}
|
||
|
||
case "roulette": {
|
||
// They turn cards over until your colour comes up, and they keep every
|
||
// one of them. N is how deep it went.
|
||
spotlight(e.seat);
|
||
var spinTo = seatAnchor(e.seat);
|
||
var spun = [];
|
||
for (var r = 0; r < Math.min(e.n, 4); r++) {
|
||
spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
|
||
}
|
||
badge(e.seat, "Roulette +" + e.n, "bad");
|
||
return Promise.all(spun).then(function () {
|
||
bump(e.seat, e.left);
|
||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||
return wait(400);
|
||
});
|
||
}
|
||
|
||
case "mercy": {
|
||
// Twenty-five cards, and they are out of the game for good. It is the
|
||
// one beat in this script allowed to take its time.
|
||
var grave = seatEl(e.seat);
|
||
if (grave) grave.dataset.out = "1";
|
||
bump(e.seat, 0);
|
||
pending(0); // a dead seat pays no bill and leaves none behind
|
||
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
||
}
|
||
|
||
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":
|
||
// The discard has just gone back under, so the counter has to as
|
||
// well: the draws that follow in this same script count down from it,
|
||
// and counting down from a deck that still reads empty gets nowhere.
|
||
deckCntEl.textContent = String(e.n);
|
||
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 });
|
||
|
||
// Not `back`: that is the card back up there, and a number wearing its name
|
||
// is a landmine for whoever next wants a face-down card in here.
|
||
var profit = payout - final.bet;
|
||
return spot
|
||
.pour(houseEl, profit, { gap: 60 })
|
||
.then(function () { return wait(profit > 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" });
|
||
}
|
||
|
||
// Taking the stack: you give in, you eat every card it has run up, and you lose
|
||
// your turn. It is a move of its own, not a draw — see the engine's MoveTake.
|
||
if (takeBtn) {
|
||
takeBtn.addEventListener("click", function () {
|
||
if (busy || !game || game.turn !== 0 || game.phase !== "stack") return;
|
||
move({ kind: "take" });
|
||
});
|
||
}
|
||
|
||
// ---- talking to the table ----------------------------------------------------
|
||
|
||
// send takes the table away for the whole move — not just the request, but the
|
||
// script that comes back with it. A lap of this table is seconds of animation,
|
||
// and for every one of them the game on screen is a game the server has already
|
||
// moved past. Clicking a card in there would send a move against a board that
|
||
// no longer exists. So busy comes off at the end of the script, not the end of
|
||
// the request, which is what the other tables do too.
|
||
function send(path, body, where) {
|
||
if (busy) return;
|
||
busy = true;
|
||
say("", null, where);
|
||
controls();
|
||
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.uno) paint(v.uno);
|
||
else { paint(null); spot.render(0); }
|
||
});
|
||
})
|
||
.then(function () {
|
||
busy = false;
|
||
controls();
|
||
showBet();
|
||
});
|
||
}
|
||
|
||
// ---- 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 rules switch. It is a different dial from the table size — the size is
|
||
// what you're paid for, the deck is what you're playing with — so throwing it
|
||
// swaps the three tables for the other three rather than becoming a fourth one.
|
||
var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };
|
||
|
||
function pickRules(which, slug) {
|
||
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||
b.dataset.on = b.dataset.rules === which ? "1" : "0";
|
||
});
|
||
root.querySelectorAll("[data-grid]").forEach(function (g) {
|
||
g.classList.toggle("hidden", g.dataset.grid !== which);
|
||
});
|
||
root.querySelectorAll("[data-note]").forEach(function (p) {
|
||
p.classList.toggle("hidden", p.dataset.note !== which);
|
||
});
|
||
pickTier(slug || DEFAULT_TABLE[which]);
|
||
}
|
||
|
||
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
if (busy) return;
|
||
pickRules(b.dataset.rules);
|
||
});
|
||
});
|
||
|
||
// A game that just ended leaves the betting panel on the table it was played at:
|
||
// the commonest thing a player wants after a hand of No Mercy is another one.
|
||
function rulesFor(v) {
|
||
if (!v || !v.tier) return;
|
||
pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug);
|
||
}
|
||
|
||
// 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 });
|
||
});
|
||
}
|
||
|
||
pickRules("normal");
|
||
|
||
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();
|
||
});
|
||
})();
|