Your own message came back twice — once from the POST that sent it, once echoed over your own SSE stream — so the felt printed it on the rail twice. Drop any chat id already seen; reset the seen-set when the log is cleared (unseated, and on a full chat reload). Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
970 lines
36 KiB
JavaScript
970 lines
36 KiB
JavaScript
// 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 chatSeen = {}; // chat line ids already on the rail, to drop echoed duplicates
|
||
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 = ""; chatSeen = {}; }
|
||
|
||
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 = "";
|
||
chatSeen = {};
|
||
(data.chat || []).forEach(addChat);
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
function addChat(line) {
|
||
if (!chatLog || !line) return;
|
||
// Your own line comes back twice — once from the POST that sent it, once
|
||
// echoed over your own stream — so drop any id already on the rail.
|
||
if (line.id) {
|
||
if (chatSeen[line.id]) return;
|
||
chatSeen[line.id] = true;
|
||
}
|
||
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(); }
|
||
});
|
||
});
|
||
})();
|