Files
Pete/internal/web/static/js/uno.js
prosolis 18049f6f59 games: the UNO felt other people can sit at, and the pot on the rail
Phase D frontend: uno.html and uno.js rewired from the solo bet-builder to the
session shape hold'em already ships. You sit with a buy-in (or join a table from
the lobby), ante into a pot each hand, deal when ready, and get up with what's in
front of you. Everything is keyed on your_seat now, not seat zero: the felt puts
your hand at the bottom whatever chair you took, and one EventSource per seat
refetches your own redacted view when the table changes. Chat runs along the rail.

The card rendering and the event-script animation are unchanged — a move still
plays back a whole lap of the table — but the money is simpler than solo: a pot
won moves your on-table stack, not your purse, so the chip bar only stirs at
sit-down and get-up. Page renders; the two-browser pass is still to come.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:44:10 -07:00

962 lines
36 KiB
JavaScript
Raw 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 UNO table, gone multiplayer.
//
// Same bargain as hold'em: the browser holds no game. You sit down with a stack,
// and every hand each seat antes into a pot the winner takes. One move goes up and
// what comes back is that move *and every bot turn it handed off to*, as a script
// of events this file plays back slowly enough to follow. Other people are at the
// felt, so a stream keeps your view live while they play; a nudge on it means the
// table changed, and you refetch your own seat's redacted view.
//
// Two rules carried over, both load-bearing: the browser never learns another
// seat's hand (it gets a count), and the chip bar only moves when chips actually
// cross the border — at sit-down and get-up. A pot won mid-session moves inside
// the engine and never touches your purse; it moves your *stack*, which is the
// number on the rail.
(function () {
"use strict";
var root = document.querySelector("[data-uno]");
if (!root) return;
var FX = window.PeteFX;
var G = window.PeteGames;
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 potEl = root.querySelector("[data-pot]");
var stackEl = root.querySelector("[data-stack]");
var tableEl = root.querySelector("[data-table-name]");
var feltEl = root.querySelector(".pete-uno");
var wildEl = root.querySelector("[data-wild]");
var gateEl = root.querySelector("[data-unogate]");
var callBtn = root.querySelector("[data-uno-call]");
var timerEl = root.querySelector("[data-uno-timer]");
var billEl = root.querySelector("[data-bill]");
var actingEl = root.querySelector("[data-acting]");
var betweenEl = root.querySelector("[data-between]");
var sittingEl = root.querySelector("[data-sitting]");
var chatEl = root.querySelector("[data-chat]");
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 dealBtn = root.querySelector("[data-deal]");
var leaveBtn = root.querySelector("[data-leave]");
var sitBtn = root.querySelector("[data-sit]");
var buySlider = root.querySelector("[data-buyin-slider]");
var buyLabel = root.querySelector("[data-buyin]");
var buyNote = root.querySelector("[data-buyin-note]");
var tableStackEl = root.querySelector("[data-table-stack]");
var boughtInEl = root.querySelector("[data-bought-in]");
var anteNoteEl = root.querySelector("[data-ante-note]");
var gameMsgEl = root.querySelector("[data-game-msg]");
var betweenMsg = root.querySelector("[data-between-msg]");
var tableMsg = root.querySelector("[data-table-msg]");
var chatLog = root.querySelector("[data-chat-log]");
var chatForm = root.querySelector("[data-chat-form]");
var chatInput = root.querySelector("[data-chat-input]");
var lobbyEl = root.querySelector("[data-lobby]");
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
var busy = false;
var game = null; // the table as the server last described it (a v.uno)
var me = 0; // your seat at the table
var tier = null; // the selected tier button, when sitting
var pendingWild = -1;
var played = -1;
var stream = null;
var pendingSync = false;
var CALL_MS = 2600;
var gate = null;
var gateTimer = 0;
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 || tableMsg;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
function fmt(n) { return (n || 0).toLocaleString(); }
// ---- drawing the cards -----------------------------------------------------
var FACES = {
"skip": { mid: "🚫", corner: "🚫" },
"reverse": { mid: "⇄", corner: "⇄" },
"+2": { mid: "+2", corner: "+2" },
"wild": { mid: "★", corner: "★" },
"+4": { mid: "+4", corner: "+4" },
"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" },
"roulette": { mid: "?", corner: "?" },
};
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
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);
["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);
});
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;
}
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
el.appendChild(face);
el.setAttribute("aria-label", label(c));
return el;
}
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;
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 -------------------------------------------------------------
var FAN = 8;
function live(v) { return !!v && (v.phase === "play" || v.phase === "drawn" || v.phase === "stack"); }
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
var catchable = {};
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
v.seats.forEach(function (s, i) {
if (i === me) 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 && live(v) ? "1" : "0";
if (s.uno) el.dataset.uno = "1";
if (s.out) el.dataset.out = "1";
if (catchable[i]) {
var got = document.createElement("button");
got.type = "button";
got.className = "pete-uno-catch";
got.dataset.catch = String(i);
got.textContent = "Catch!";
got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
got.addEventListener("click", function (e) { e.stopPropagation(); catchSeat(i); });
el.appendChild(got);
}
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 + (s.bot ? "" : " ●");
el.appendChild(name);
var count = document.createElement("p");
count.className = "pete-uno-count";
count.dataset.count = "";
var stk = s.stack != null ? " · " + fmt(s.stack) : "";
count.textContent = (s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards")) + stk;
el.appendChild(count);
seatsEl.appendChild(el);
});
}
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
function seatAnchor(i) {
if (i === me) return handEl;
var el = seatEl(i);
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
var shownHand = [];
function key(c) { return c.color + "/" + c.value; }
function paintHand(cards, playable, clickable) {
cards = cards || [];
var pool = shownHand.map(key);
handEl.innerHTML = "";
var fresh = 0;
cards.forEach(function (c, i) {
var el = card(c, { button: true });
el.dataset.at = String(i);
var ok = !!clickable && !!playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
var was = pool.indexOf(key(c));
if (was >= 0) { pool.splice(was, 1); el.dataset.settled = "1"; }
else { el.style.setProperty("--i", fresh++); }
handEl.appendChild(el);
});
shownHand = cards.slice();
}
function renderHand(v) {
if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
paintHand(v.hand, playable, live(v) && v.turn === me);
}
function showHand(cards) {
if (!cards) return;
paintHand(cards, {}, false);
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
}
function renderPiles(v) {
discardEl.innerHTML = "";
if (!v) { deckCntEl.textContent = "0"; colourEl.textContent = ""; if (feltEl) feltEl.dataset.c = ""; return; }
deckCntEl.textContent = String(v.deck || 0);
var dealt = (v.deck || 0) > 0 || (v.hand && v.hand.length) || (v.seats || []).some(function (s) { return s.cards > 0; });
if (dealt && v.top && v.top.value) {
var top = card(v.top);
top.classList.add("pete-uno-top");
discardEl.appendChild(top);
colourEl.textContent = v.color;
colourEl.dataset.c = v.color;
if (feltEl) feltEl.dataset.c = v.color;
} else {
colourEl.textContent = "";
if (feltEl) feltEl.dataset.c = "";
}
pending(live(v) ? (v.pending || 0) : 0);
}
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 (potEl) potEl.textContent = fmt(v ? v.pot : 0);
if (stackEl) stackEl.textContent = v ? fmt(v.stack) : "—";
if (tableEl) tableEl.textContent = v && v.tier ? v.tier.name : "";
if (tableStackEl) tableStackEl.textContent = fmt(v ? v.stack : 0);
if (boughtInEl) boughtInEl.textContent = fmt(v ? v.bought_in : 0);
if (anteNoteEl) anteNoteEl.textContent = fmt(v && v.tier ? v.tier.ante : 0);
}
function renderTurn(v) {
if (!v || !live(v)) { turnEl.textContent = ""; countEl.textContent = ""; return; }
var yours = v.turn === me;
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 ----------------------------------------------------------------
function verdict(v) {
if (!v || v.winner == null) { verdictEl.classList.add("hidden"); return; }
var text = "";
var ante = (v.seats[me] && v.seats[me].ante) || 0;
if (v.outcome === "tie") {
text = "Nobody went out — antes back.";
} else if (v.winner === me) {
var net = ((v.seats[me] && v.seats[me].won) || 0) - ante;
var howYou = (v.seats[me] && v.seats[me].cards === 0) ? "You went out! 🎉" : "Last one standing! 🎉";
text = howYou + (net > 0 ? " +" + fmt(net) : "");
} else if (v.winner >= 0) {
var w = v.seats[v.winner] || {};
text = (w.name || "They") + " took the pot." + (ante > 0 ? " " + fmt(ante) : "");
}
if (!text) { verdictEl.classList.add("hidden"); return; }
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 });
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
}
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 controls() {
var lv = live(game);
var yours = lv && game.turn === me;
var drawn = lv && game.phase === "drawn";
var stack = lv && game.phase === "stack";
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) { b.disabled = busy || !yours; });
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;
if (dealBtn) dealBtn.disabled = busy;
if (leaveBtn) leaveBtn.disabled = busy;
}
function setPhase(v) {
game = v;
var seated = !!v;
var lv = live(v);
var yours = lv && v.turn === me;
var drawn = lv && v.phase === "drawn";
var stack = lv && v.phase === "stack";
if (sittingEl) sittingEl.classList.toggle("hidden", seated);
if (chatEl) chatEl.classList.toggle("hidden", !seated);
if (actingEl) actingEl.classList.toggle("hidden", !yours);
if (betweenEl) betweenEl.classList.toggle("hidden", !(seated && !lv));
hideWild();
hideGate();
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
if (hintEl && yours) {
hintEl.textContent = (v.catchable || []).length
? "Somebody's down to one card and never said so. Catch them before you play."
: (HINTS[v.phase] || HINTS.play);
}
controls();
if (!v || v.winner == null) verdictEl.classList.add("hidden");
}
function paint(v) {
if (v && v.your_seat != null) me = v.your_seat;
renderSeats(v);
renderPiles(v);
renderHand(v);
renderRail(v);
renderTurn(v);
setPhase(v);
if (v && !live(v) && v.winner != null) verdict(v);
}
// ---- the script ------------------------------------------------------------
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,
index: opts.index || 0,
sound: opts.sound === undefined ? "card" : opts.sound,
});
}
function bump(seat, left) {
if (seat === me || 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";
if (count) {
var stk = count.textContent.indexOf("·") >= 0 ? count.textContent.slice(count.textContent.indexOf("·")) : "";
count.textContent = (buried ? "Buried" : left + (left === 1 ? " card" : " cards")) + (stk ? " " + stk : "");
}
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";
});
}
function badge(seat, text, tone) {
var host = seat === me ? 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 for a move the acting player just made.
function play(view) {
var events = view.uno_events || [];
var final = view.uno;
if (final && final.your_seat != null) me = final.your_seat;
var chain = Promise.resolve();
events.forEach(function (e, n) {
chain = chain.then(function () {
switch (e.kind) {
case "ante":
return; // the antes are tallied into the pot the deal paints
case "deal":
FX.sfx("shuffle");
for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
paint(final);
return wait(320);
case "play": {
spotlight(e.seat);
var node = card(e.card);
var from = seatAnchor(e.seat);
if (e.seat === me && played >= 0) {
var liveEl = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
if (liveEl) liveEl.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; if (feltEl) feltEl.dataset.c = e.color; }
showHand(e.hand);
return wait(e.seat === me ? 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, sound: "deal" }));
}
var punished = e.kind === "forced";
if (punished) { FX.sfx("bad"); pending(0); badge(e.seat, "+" + e.n, "bad"); }
return Promise.all(backs).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
if (e.seat === me && e.card) return wait(160);
return wait(punished ? 380 : 180);
});
}
case "caught": {
spotlight(e.seat);
var mine = e.seat === me;
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
var whom = mine ? "Caught! " + (caughtBy.name || "They") + " saw it" : "Caught! +" + e.n;
FX.sfx(mine ? "bad" : "catch");
var lands = [];
var at = seatAnchor(e.seat);
for (var k = 0; k < e.n; k++) {
lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
}
badge(e.seat, whom, "bad");
return Promise.all(lands).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
case "miscall": {
FX.sfx("bad");
var wrong = [];
for (var w = 0; w < e.n; w++) {
wrong.push(throwCard(back(), deckEl, seatAnchor(e.seat), { index: w, delay: w * 100, sound: "deal" }));
}
badge(e.seat, "They called it. +" + e.n, "bad");
return Promise.all(wrong).then(function () {
bump(e.seat, e.left);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(520);
});
}
case "stack":
pending(e.n);
spotlight(e.seat);
FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
case "skipall":
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
case "discard": {
if (e.seat === me && 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 () { showHand(e.hand); return wait(220); });
}
case "roulette": {
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);
showHand(e.hand);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(400);
});
}
case "mercy": {
var grave = seatEl(e.seat);
if (grave) grave.dataset.out = "1";
bump(e.seat, 0);
showHand(e.hand);
pending(0);
FX.sfx("bad");
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":
if (feltEl) feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
FX.sfx("uno");
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
deckCntEl.textContent = String(e.n);
deckEl.classList.add("pete-uno-shuffle");
FX.sfx("shuffle");
return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); });
case "pass":
spotlight(e.seat);
return wait(140);
case "settle":
case "sit":
case "leave":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); return; }
paint(final);
if (!live(final) && final.winner != null) verdict(final);
});
}
// ---- moves ------------------------------------------------------------------
function pick(i, c) {
if (busy || !live(game) || game.turn !== me) return;
if (c.wild) { askColour(i); return; }
offer(i, 0);
}
function offer(i, color) {
if (game && (game.uno_at || []).indexOf(i) >= 0) { openGate(i, color); return; }
move({ kind: "play", index: i, color: color });
}
function openGate(i, color) {
gate = { index: i, color: color };
gateEl.classList.remove("hidden");
if (timerEl) {
timerEl.style.animation = "none";
void timerEl.offsetWidth;
timerEl.style.animation = "";
timerEl.style.animationDuration = CALL_MS + "ms";
}
if (callBtn) callBtn.focus();
gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
}
function shut(called) {
if (!gate) return;
var g = gate;
gate = null;
clearTimeout(gateTimer);
gateTimer = 0;
gateEl.classList.add("hidden");
move({ kind: "play", index: g.index, color: g.color, uno: called });
}
function hideGate() {
if (gateTimer) clearTimeout(gateTimer);
gateTimer = 0;
gate = null;
if (gateEl) gateEl.classList.add("hidden");
}
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
function catchSeat(seat) {
if (busy || !live(game) || game.turn !== me) return;
if ((game.catchable || []).indexOf(seat) < 0) return;
move({ kind: "catch", seat: seat });
}
function askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); }
function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); }
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();
offer(i, 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 || !live(game) || game.turn !== me || game.phase !== "drawn") return;
move({ kind: "pass" });
});
function drawOne() {
if (busy || !game || game.phase !== "play" || game.turn !== me) return;
move({ kind: "draw" });
}
if (takeBtn) takeBtn.addEventListener("click", function () {
if (busy || !game || game.turn !== me || game.phase !== "stack") return;
move({ kind: "take" });
});
if (dealBtn) dealBtn.addEventListener("click", function () {
if (busy) return;
move({ kind: "deal" });
});
if (leaveBtn) leaveBtn.addEventListener("click", function () {
if (busy) return;
busy = true;
say("", null, betweenMsg);
G.post("/api/games/uno/leave", {})
.then(function (v) { G.apply(v); unseated(); paint(null); loadLobby(); })
.catch(function (err) { say(err.message, "bad", betweenMsg); })
.then(function () { busy = false; controls(); });
});
// ---- talking to the table --------------------------------------------------
// send takes the table away for the whole move — the request and the script that
// comes back with it — so a click mid-animation can't move against a board the
// server has already left behind. busy comes off at the end of the script.
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
controls();
return G.post(path, body)
.then(function (view) {
G.apply(view);
return play(view);
})
.catch(function (err) {
say(err.message, "bad", where);
return G.refresh().then(function (v) {
if (v && v.uno) paint(v.uno); else paint(null);
});
})
.then(function () {
busy = false;
controls();
if (pendingSync) { pendingSync = false; sync(); }
});
}
// ---- the live stream -------------------------------------------------------
function seated() { loadChat(); connectLive(); }
function unseated() { disconnectLive(); if (chatLog) chatLog.innerHTML = ""; }
function connectLive() {
if (stream || !window.EventSource) return;
stream = new EventSource("/api/games/uno/stream");
stream.onmessage = function (ev) {
var msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (msg.type === "chat") addChat(msg.line);
else if (msg.type === "table") sync();
};
stream.addEventListener("sync", function () { sync(); });
}
function disconnectLive() { if (stream) { stream.close(); stream = null; } }
// sync repaints from the authoritative table — a snapshot, no animation. Held
// while a move of ours is animating, and applied once it finishes.
function sync() {
if (busy) { pendingSync = true; return; }
G.refresh().then(function (v) {
if (!v) return;
if (v.uno) paint(v.uno); else { paint(null); }
});
}
// ---- chat ------------------------------------------------------------------
function loadChat() {
fetch("/api/games/uno/chat", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
if (!data || !chatLog) return;
chatLog.innerHTML = "";
(data.chat || []).forEach(addChat);
})
.catch(function () {});
}
function addChat(line) {
if (!chatLog || !line) return;
var row = document.createElement("div");
row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
var who = document.createElement("span");
who.className = "pete-chat-who";
who.textContent = line.name;
var body = document.createElement("span");
body.className = "pete-chat-body";
body.textContent = line.body;
row.appendChild(who);
row.appendChild(body);
chatLog.appendChild(row);
chatLog.scrollTop = chatLog.scrollHeight;
}
if (chatForm) chatForm.addEventListener("submit", function (e) {
e.preventDefault();
var body = (chatInput.value || "").trim();
if (!body) return;
chatInput.value = "";
G.post("/api/games/uno/say", { body: body })
.then(function (line) { addChat(line); })
.catch(function () { chatInput.value = body; });
});
// ---- sitting down ----------------------------------------------------------
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); });
var el = root.querySelector('[data-tier="' + (slug || DEFAULT_TABLE[which]) + '"]');
if (el) pickTier(el);
}
root.querySelectorAll("[data-rules]").forEach(function (b) {
b.addEventListener("click", function () { if (!busy) pickRules(b.dataset.rules); });
});
function pickTier(btn) {
tier = btn;
root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
syncSit();
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () { if (!busy) pickTier(b); });
});
// syncSit keeps the buy-in slider inside the table's range and the sit button
// honest about whether you can afford the low end.
function syncSit() {
if (!tier || !buySlider) return;
var min = Number(tier.dataset.min), max = Number(tier.dataset.max);
buySlider.min = String(min);
buySlider.max = String(max);
buySlider.step = String(Math.max(1, Math.round((max - min) / 100)));
var cur = Number(buySlider.value);
if (!cur || cur < min || cur > max) buySlider.value = String(min);
var money = G.view();
var chips = money ? money.chips : 0;
if (buyLabel) buyLabel.textContent = fmt(Number(buySlider.value));
if (buyNote) buyNote.textContent = min + "" + max + " · you have " + fmt(chips);
if (sitBtn) sitBtn.disabled = busy || !tier || chips < Number(buySlider.value);
}
if (buySlider) buySlider.addEventListener("input", syncSit);
if (sitBtn) sitBtn.addEventListener("click", function () {
if (busy || !tier) return;
busy = true;
say("", null, tableMsg);
G.post("/api/games/uno/sit", { tier: tier.dataset.tier, buyin: Number(buySlider.value) })
.then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. Deal when you're ready.", null, betweenMsg); })
.catch(function (err) { say(err.message, "bad", tableMsg); })
.then(function () { busy = false; controls(); });
});
// ---- the lobby -------------------------------------------------------------
function loadLobby() {
if (!lobbyEl) return;
fetch("/api/games/uno/tables", { headers: { "Accept": "application/json" } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { renderLobby((data && data.tables) || []); })
.catch(function () {});
}
function renderLobby(tables) {
lobbyEl.innerHTML = "";
if (lobbyWrap) lobbyWrap.classList.toggle("hidden", tables.length === 0);
tables.forEach(function (t) {
var stakes = root.querySelector('[data-tier="' + t.tier + '"]');
var btn = document.createElement("button");
btn.type = "button";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
var lbl = document.createElement("span");
lbl.className = "font-display font-bold";
lbl.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
var seats = document.createElement("span");
seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
seats.textContent = t.humans + "/" + t.seats + " seated";
btn.appendChild(lbl);
btn.appendChild(seats);
btn.addEventListener("click", function () { joinTable(t, stakes); });
lobbyEl.appendChild(btn);
});
}
function joinTable(t, stakes) {
if (busy) return;
var min = stakes ? Number(stakes.dataset.min) : Number(buySlider.value);
var max = stakes ? Number(stakes.dataset.max) : Number(buySlider.value);
var buyIn = Math.min(max, Math.max(min, Number(buySlider.value)));
busy = true;
say("", null, tableMsg);
G.post("/api/games/uno/sit", { table: t.id, buyin: buyIn })
.then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. The next hand deals when the table is ready.", null, betweenMsg); })
.catch(function (err) { say(err.message, "bad", tableMsg); })
.then(function () { busy = false; controls(); });
}
// ---- boot ------------------------------------------------------------------
pickRules("normal");
var resumed = false;
G.onUpdate(function () {
if (resumed) return;
resumed = true;
G.refresh().then(function (v) {
if (v && v.uno) { paint(v.uno); seated(); }
else { paint(null); loadLobby(); syncSit(); }
});
});
})();