games: no mercy on the felt, and the bill that went to the wrong window
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
This commit is contained in:
@@ -514,9 +514,30 @@
|
||||
|
||||
// ---- talking to the table --------------------------------------------------
|
||||
|
||||
// lock is busy, said out loud on the buttons.
|
||||
//
|
||||
// send() drops a click that arrives while a move is in flight, and it is right
|
||||
// to: the board on screen during a script is a board the server has already
|
||||
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
|
||||
// enabled through the whole deal animation, so clicking Leave while the cards
|
||||
// were still flying did nothing at all: no move, no message, no reason given.
|
||||
// (The action buttons never had this problem; panels() hides the whole row when
|
||||
// it isn't your turn.) A button that looks alive and does nothing has lied to
|
||||
// you, so the lock lives on the buttons and not only in the variable.
|
||||
//
|
||||
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
|
||||
// panels() owns that, so this only ever adds a reason to be disabled.
|
||||
function lock(on) {
|
||||
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
|
||||
if (b) b.disabled = on;
|
||||
});
|
||||
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
|
||||
}
|
||||
|
||||
function send(body, msgEl) {
|
||||
if (busy) return Promise.resolve();
|
||||
busy = true;
|
||||
lock(true);
|
||||
say(msgEl, "");
|
||||
// Whatever the last hand said about itself stops being true the moment you do
|
||||
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
||||
@@ -540,7 +561,7 @@
|
||||
});
|
||||
})
|
||||
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||
.then(function () { busy = false; });
|
||||
.then(function () { busy = false; lock(false); });
|
||||
}
|
||||
|
||||
// render0 is the table with nobody at it.
|
||||
|
||||
@@ -39,10 +39,16 @@
|
||||
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]");
|
||||
@@ -81,14 +87,45 @@
|
||||
|
||||
// ---- 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" };
|
||||
// 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";
|
||||
@@ -99,7 +136,8 @@
|
||||
|
||||
var oval = document.createElement("span");
|
||||
oval.className = "pete-uno-oval";
|
||||
oval.textContent = GLYPHS[c.value] || c.value;
|
||||
if (f.size) oval.dataset.size = f.size;
|
||||
oval.textContent = f.mid;
|
||||
face.appendChild(oval);
|
||||
|
||||
// The corners, as printed.
|
||||
@@ -107,7 +145,7 @@
|
||||
var corner = document.createElement("span");
|
||||
corner.className = "pete-uno-corner";
|
||||
corner.dataset.at = at;
|
||||
corner.textContent = GLYPHS[c.value] || c.value;
|
||||
corner.textContent = f.corner;
|
||||
corner.setAttribute("aria-hidden", "true");
|
||||
face.appendChild(corner);
|
||||
});
|
||||
@@ -121,16 +159,33 @@
|
||||
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 = 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 : "");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -159,6 +214,7 @@
|
||||
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";
|
||||
@@ -179,7 +235,7 @@
|
||||
var count = document.createElement("p");
|
||||
count.className = "pete-uno-count";
|
||||
count.dataset.count = "";
|
||||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
el.appendChild(count);
|
||||
|
||||
seatsEl.appendChild(el);
|
||||
@@ -234,10 +290,22 @@
|
||||
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 = "—";
|
||||
@@ -259,6 +327,7 @@
|
||||
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;
|
||||
@@ -278,6 +347,11 @@
|
||||
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();
|
||||
@@ -295,24 +369,45 @@
|
||||
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";
|
||||
});
|
||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
||||
// 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 (deckEl) deckEl.disabled = busy || !yours || drawn;
|
||||
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);
|
||||
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||||
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");
|
||||
}
|
||||
@@ -352,7 +447,8 @@
|
||||
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");
|
||||
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);
|
||||
@@ -442,7 +538,9 @@
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||
}
|
||||
var punished = e.kind === "forced";
|
||||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
||||
// 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));
|
||||
@@ -453,6 +551,65 @@
|
||||
});
|
||||
}
|
||||
|
||||
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); });
|
||||
|
||||
@@ -593,6 +750,15 @@
|
||||
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
|
||||
@@ -647,6 +813,38 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -689,7 +887,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
pickRules("normal");
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
|
||||
Reference in New Issue
Block a user