diff --git a/internal/web/static/js/uno.js b/internal/web/static/js/uno.js index 0427d0e..58ecb52 100644 --- a/internal/web/static/js/uno.js +++ b/internal/web/static/js/uno.js @@ -1,22 +1,17 @@ -// The UNO table. +// The UNO table, gone multiplayer. // -// Same bargain as every other table in the room: the browser holds no game. It -// sends one move, and what comes back is that move *and every bot turn it handed -// off to*, as a script of events. So a round trip here is a whole lap of the -// table, and this file's main job is to play that lap back slowly enough that -// you can see what happened to you. +// 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 from the tables that came before, both load-bearing: -// -// The number under the pile is a readout of the pile (PeteFX.spot owns that), and -// the chip bar does not move until the chips that justify it have landed. So a -// settling game holds the money back until the payout has physically swept home -// β€” a counter that pays you before the last card goes down has told you the -// ending. -// -// And the browser never learns a bot's hand. It gets a *count*. Every fan of -// backs you see up there is drawn from a number, because a number is all that -// crossed the wire. +// 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"; @@ -24,6 +19,7 @@ if (!root) return; var FX = window.PeteFX; + var G = window.PeteGames; var seatsEl = root.querySelector("[data-seats]"); var handEl = root.querySelector("[data-hand]"); @@ -34,53 +30,59 @@ var turnEl = root.querySelector("[data-turn-label]"); var countEl = root.querySelector("[data-count-label]"); var verdictEl = root.querySelector("[data-verdict]"); - var paysEl = root.querySelector("[data-pays]"); - var meterEl = root.querySelector("[data-meter]"); + var 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]"); - // 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 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 betAmount = root.querySelector("[data-bet-amount]"); - var startBtn = root.querySelector("[data-start]"); - var msgEl = root.querySelector("[data-table-msg]"); - var gameMsgEl = root.querySelector("[data-game-msg]"); + var 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 purseEl = document.querySelector("[data-chips]"); - var spotEl = root.querySelector("[data-spot]"); - var houseEl = root.querySelector("[data-house]"); + var gameMsgEl = root.querySelector("[data-game-msg]"); + var betweenMsg = root.querySelector("[data-between-msg]"); + var tableMsg = root.querySelector("[data-table-msg]"); - var spot = FX.spot({ - spot: spotEl, - stack: root.querySelector("[data-stack]"), - total: root.querySelector("[data-spot-total]"), - }); + 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 bet = 0; // what you're building between games var busy = false; - var game = null; // the game as the server last described it - var tier = "table"; - var pendingWild = -1; // the wild you clicked, waiting on a colour - var played = -1; // the card you just played, so the script lifts that one out - // of the hand and not merely the first one that lit up + var 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; - // The UNO call. CALL_MS is how long you get to say it once the card is on its - // way down β€” long enough that nobody playing the game misses it, short enough - // that it is still a thing you have to *do*. The engine has the other half of - // this rule (see call.go); this is only the window. var CALL_MS = 2600; - var gate = null; // the play waiting on a call: {index, color} + var gate = null; var gateTimer = 0; var reduced = FX.reduced; @@ -88,7 +90,7 @@ function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } function say(text, tone, where) { - var el = where || msgEl; + var el = where || tableMsg; if (!el) return; if (!text) { el.classList.add("hidden"); return; } el.textContent = text; @@ -96,44 +98,28 @@ el.style.color = tone === "bad" ? "#cc3d4a" : ""; } + function fmt(n) { return (n || 0).toLocaleString(); } + // ---- drawing the cards ----------------------------------------------------- - // FACES is how each printed face is drawn. The engine sends a word ("skip", - // "+2", "discard all"); this is the picture of it. - // - // The corner is not always the same mark as the middle. "DISCARD ALL" fits - // across an oval and does not fit in a corner, so the long faces carry a short - // mark for the corners β€” which is what a real deck does too. `size` is a class, - // never a height: a card is sized by --uno-h/--uno-w and nothing else, and a - // face that sets its own font in pixels is a face that breaks on a phone. var FACES = { "skip": { mid: "🚫", corner: "🚫" }, "reverse": { mid: "⇄", corner: "⇄" }, "+2": { mid: "+2", corner: "+2" }, "wild": { mid: "β˜…", corner: "β˜…" }, "+4": { mid: "+4", corner: "+4" }, - - // No Mercy. "skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" }, "discard all": { mid: "DISCARD ALL", corner: "≑", size: "words" }, "rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" }, "+6": { mid: "+6", corner: "+6" }, "+10": { mid: "+10", corner: "+10", size: "mid" }, - // The roulette names a colour and turns cards over until it comes up. The - // wheel behind the oval is already the picture of that; the mark is the - // question the wheel is asking. "roulette": { mid: "?", corner: "?" }, }; function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; } - // The faces that make somebody draw. On a wild these are the cards worth the - // most and worth watching for, so they get the glow β€” and it is the one thing - // that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance. var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true }; - // card builds one UNO card. The oval across the middle at an angle is the whole - // look of the thing β€” without it a card reads as a coloured button. function card(c, opts) { opts = opts || {}; var f = faceOf(c); @@ -151,7 +137,6 @@ oval.textContent = f.mid; face.appendChild(oval); - // The corners, as printed. ["tl", "br"].forEach(function (at) { var corner = document.createElement("span"); corner.className = "pete-uno-corner"; @@ -161,19 +146,12 @@ face.appendChild(corner); }); - // A wild is four quadrants of colour β€” and once it has been played as a - // colour, that colour is the one it wears on the pile. if (c.wild) { var wheel = document.createElement("span"); wheel.className = "pete-uno-wheel"; face.appendChild(wheel); if (c.color && c.color !== "wild") el.dataset.named = c.color; } - - // The wild draws glow. They are the cards that decide a game β€” a +4 you were - // holding is four cards somebody else is about to be holding β€” and in No Mercy - // there are four of them to tell apart from the coloured +4 sitting next to - // them in the same hand. The glow is what says "this one is the nasty one". if (c.wild && DRAWS[c.value]) el.dataset.glow = "1"; el.appendChild(face); @@ -181,9 +159,6 @@ 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", @@ -194,7 +169,7 @@ function label(c) { var v = SPOKEN[c.value] || c.value; if (c.wild) { - var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild" + var name = c.value === "wild" ? "wild" : "wild " + v; return name + (c.color && c.color !== "wild" ? ", played as " + c.color : ""); } return c.color + " " + v; @@ -211,10 +186,10 @@ // ---- the board ------------------------------------------------------------- - // FAN is the most backs a bot's hand draws, however many it holds. Past this - // the fan is unreadable and the number beside it is doing the work anyway. var FAN = 8; + function live(v) { return !!v && (v.phase === "play" || v.phase === "drawn" || v.phase === "stack"); } + function renderSeats(v) { seatsEl.innerHTML = ""; if (!v) return; @@ -222,18 +197,14 @@ (v.catchable || []).forEach(function (i) { catchable[i] = true; }); v.seats.forEach(function (s, i) { - if (s.you) return; // you are the hand at the bottom, not a seat up here + 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 && v.phase !== "done" ? "1" : "0"; + el.dataset.turn = v.turn === i && live(v) ? "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 + if (s.out) el.dataset.out = "1"; - // One card, and it never said so. This button is the only thing on the felt - // that gives it away β€” a bot that forgets to call is silent by definition, - // and the tell was always meant to be the count beside its fan. The button - // is the table doing you the courtesy of putting a target on it. if (catchable[i]) { var got = document.createElement("button"); got.type = "button"; @@ -241,10 +212,7 @@ 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); - }); + got.addEventListener("click", function (e) { e.stopPropagation(); catchSeat(i); }); el.appendChild(got); } @@ -261,13 +229,14 @@ var name = document.createElement("p"); name.className = "pete-uno-name"; - name.textContent = s.name; + name.textContent = s.name + (s.bot ? "" : " ●"); el.appendChild(name); var count = document.createElement("p"); count.className = "pete-uno-count"; count.dataset.count = ""; - count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards"); + 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); @@ -276,47 +245,30 @@ function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); } - // Where a card flies to or from, for a seat. Yours is the hand; a bot's is its - // fan; the deck is the deck. function seatAnchor(i) { - if (i === 0) return handEl; + if (i === me) return handEl; var el = seatEl(i); return el ? el.querySelector(".pete-uno-fan") : deckEl; } - // shownHand is the faces currently on screen. paintHand diffs against it so that - // only cards that are actually new fan in β€” without it, every redraw re-deals - // the whole hand in front of you, and a hand gets redrawn several times a lap. var shownHand = []; - function key(c) { return c.color + "/" + c.value; } - // paintHand draws your fan. `live` is whether the cards are yours to click, - // which mid-script they are not: the game on screen is one the server has - // already moved past, and a card clicked during the lap would be a move against - // a board that no longer exists. - function paintHand(cards, playable, live) { + function paintHand(cards, playable, clickable) { cards = cards || []; - // What was already on the table stays put. Matched by face and consumed, so a - // hand holding two red fives doesn't count one of them twice. 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 = !!live && !!playable[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"; // it was already in your hand: don't deal it again - } else { - el.style.setProperty("--i", fresh++); - } + if (was >= 0) { pool.splice(was, 1); el.dataset.settled = "1"; } + else { el.style.setProperty("--i", fresh++); } handEl.appendChild(el); }); shownHand = cards.slice(); @@ -326,52 +278,34 @@ if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; } var playable = {}; (v.playable || []).forEach(function (i) { playable[i] = true; }); - paintHand(v.hand, playable, v.turn === 0 && v.phase !== "done"); + paintHand(v.hand, playable, live(v) && v.turn === me); } - // showHand redraws your fan from the hand the server stamped on the event that - // just changed it β€” see Event.Hand in the engine. - // - // This is the fix for a hand that used to sit there stale for the whole lap. - // bump() keeps the *bots'* fans honest and has always refused seat zero, so - // when a +4 landed on you at the top of a lap you watched four backs fly into - // your hand and then nothing: the cards themselves only turned up seconds - // later, when the script ended and paint() finally ran. You were looking at a - // hand you no longer held. function showHand(cards) { if (!cards) return; paintHand(cards, {}, false); - // And the line above the fan counts what's in it, so it moves too. countEl.textContent = cards.length + (cards.length === 1 ? " card β€” UNO!" : " cards"); } function renderPiles(v) { discardEl.innerHTML = ""; - if (!v) { - deckCntEl.textContent = "0"; + 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 = ""; - root.querySelector(".pete-uno").dataset.c = ""; - return; + if (feltEl) feltEl.dataset.c = ""; } - deckCntEl.textContent = String(v.deck); - var top = card(v.top); - top.classList.add("pete-uno-top"); - discardEl.appendChild(top); - - // The colour in play, which after a wild is not the colour of the card you - // are looking at. So it is said in words, and the felt takes a tint of it β€” - // this is the single most missable fact on the table. - colourEl.textContent = v.color; - colourEl.dataset.c = v.color; - feltEl.dataset.c = v.color; - pending(v.phase === "done" ? 0 : v.pending || 0); + pending(live(v) ? (v.pending || 0) : 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" : ""; @@ -381,143 +315,113 @@ } function renderRail(v) { - if (!v) { - paysEl.textContent = "β€”"; - meterEl.dataset.cold = "1"; - tableEl.textContent = ""; - return; - } - paysEl.textContent = (v.pays || 0).toLocaleString(); - meterEl.dataset.cold = v.phase === "done" ? "1" : "0"; - tableEl.textContent = v.tier.name + " Β· " + v.tier.base.toFixed(1) + "Γ—"; + 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 || v.phase === "done") { - turnEl.textContent = ""; - countEl.textContent = ""; - return; - } - var yours = v.turn === 0; + 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; + var n = (v.hand || []).length; countEl.textContent = n + (n === 1 ? " card β€” UNO!" : " cards"); } // ---- phases ---------------------------------------------------------------- - var VERDICTS = { - won: "You went out! πŸŽ‰", - lost: "Beaten to it.", - stuck: "Nobody could move.", - }; - function verdict(v) { - var text = VERDICTS[v.outcome] || ""; - if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) { - text = v.seats[v.winner].name + " went out first."; + if (!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) : ""); } - // No Mercy has two endings the normal deck hasn't got: you can lose without - // anybody going out (the mercy rule got you), and win without going out at all - // (it got everybody else). - if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done."; - if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! πŸŽ‰"; if (!text) { verdictEl.classList.add("hidden"); return; } - if (v.net > 0) text += " +" + v.net.toLocaleString(); - else if (v.net < 0) text += " " + v.net.toLocaleString(); verdictEl.textContent = text; verdictEl.classList.remove("hidden"); - // burst() is the room's win sound as well as its confetti; anything else is - // a night that didn't go your way. - if (v.outcome === "won") FX.burst(verdictEl, { count: 34 }); - else FX.sfx("lose"); + if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 }); + else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose"); } - // controls is the one place that decides what you can click: whose turn the - // server says it is, and whether a move of yours is still in flight. Both - // halves matter, and they used to be spread across three functions that each - // knew half of it β€” which is how the table came to unlock itself in the middle - // of a bot's turn. - function controls() { - var live = !!game && game.phase !== "done"; - var yours = live && game.turn === 0; - var drawn = live && game.phase === "drawn"; - var stack = live && game.phase === "stack"; - handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { - b.disabled = busy || !yours || b.dataset.on !== "1"; - }); - // Catching is free, but it is still a move: it can't go up while one of yours - // is in flight, and it can't go up on somebody else's turn. - seatsEl.querySelectorAll("[data-catch]").forEach(function (b) { - b.disabled = busy || !yours; - }); - // Under a stack you cannot draw. The deck is not a way out of a bill: the two - // moves that exist are answering it with a draw card or taking the lot. - if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack; - if (passBtn) passBtn.disabled = busy || !yours; - if (takeBtn) takeBtn.disabled = busy || !yours; - if (deckEl) deckEl.disabled = busy || !yours || drawn || stack; - } - - // HINTS is the line above the buttons. A game with a stack in it has a rule the - // player has just met for the first time, and the table should say what it is - // rather than leave them clicking a hand where nothing lights up. var HINTS = { play: "Click a card that lights up. Nothing lights up? Draw one.", drawn: "You drew a card you can play. Play it, or keep it and pass.", stack: "Only draw cards will do here. Answer it, and it lands on somebody else β€” or take the lot.", }; + function 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 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); + 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); - // 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) { - // A seat sitting on one card it never called is the most valuable thing on - // the table, and it is worth more than whatever the phase was going to say. + 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); } - if (!live && v) rulesFor(v); // it's over: leave the panel where the game was controls(); - if (!v || !v.outcome) verdictEl.classList.add("hidden"); + if (!v || v.winner == null) verdictEl.classList.add("hidden"); } - // paint puts the board up with no animation: the resume path, after a reload or - // a redeploy. Whatever the server says is on the table is on the table. function paint(v) { + if (v && v.your_seat != null) me = v.your_seat; renderSeats(v); renderPiles(v); renderHand(v); renderRail(v); renderTurn(v); - spot.render(v && v.phase !== "done" ? v.bet : 0); setPhase(v); + if (v && !live(v) && v.winner != null) verdict(v); } // ---- the script ------------------------------------------------------------ - // throwCard sends a card from one place to another across the felt. It lands - // with a slap β€” "card" for one going down on the pile, "deal" (softer, lower) - // for one coming off the deck into a hand, which is a card being *placed*. function throwCard(node, from, to, opts) { opts = opts || {}; return FX.flyNode(node, from, to, { @@ -531,20 +435,17 @@ }); } - // bump keeps a bot's fan honest during the script: the server's count is the - // truth, but between events the fan has to grow and shrink as cards move, or - // the flight lands on a pile that hasn't changed. - // - // Seat zero is not a bot and is not bumped β€” a fan of backs is no use to you, - // you want the faces. showHand does yours, from the hand the event carries. function bump(seat, left) { - if (seat === 0 || left == null) return; + 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"; // an empty hand here is a grave, not a win - if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards"); + 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); @@ -562,41 +463,31 @@ }); } - // badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say - // these things; the badge is what makes them land on somebody. function badge(seat, text, tone) { - var host = seat === 0 ? handEl : seatEl(seat); + 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); - }); + return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); }); } - // play walks the server's script. On a live game the money is already right β€” - // your stake left your pile when you pressed Deal, and it's on the spot β€” so - // the chip bar can catch up immediately. On a settling one it waits. - function play(view, money) { + // 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; - var settles = !!final && final.phase === "done"; + if (final && final.your_seat != null) me = final.your_seat; var chain = Promise.resolve(); - if (!settles) money(); - 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": - // The deal isn't animated card by card: seven cards to each of four - // seats is 28 flights and a player who wants to play. The hand fans in - // on its own (CSS), which reads as being dealt without taking as long. - // The sound does the same job: a riffle, then cards landing, rather - // than twenty-eight separate slaps. FX.sfx("shuffle"); for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c }); paint(final); @@ -606,28 +497,21 @@ spotlight(e.seat); var node = card(e.card); var from = seatAnchor(e.seat); - // Your own card leaves the hand it was in, so the hand has to lose it - // before the flight or the card is briefly in two places. - if (e.seat === 0 && played >= 0) { - var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]'); - if (live) live.style.visibility = "hidden"; + 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; - feltEl.dataset.c = e.color; - } - showHand(e.hand); // the card has landed; close the gap it left - return wait(e.seat === 0 ? 120 : 300); - }); + 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": @@ -639,35 +523,21 @@ backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" })); } var punished = e.kind === "forced"; - if (punished) FX.sfx("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"); } + 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); // your cards, face up, as they land + showHand(e.hand); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); - // Your own drawn card comes face up β€” it's yours, and the server - // sent its face for exactly this. - if (e.seat === 0 && e.card) return wait(160); + if (e.seat === me && e.card) return wait(160); return wait(punished ? 380 : 180); }); } - // ---- the call, and the catch --------------------------------------- - // - // Somebody went down to one card without saying so, and somebody else - // noticed. Two cards off the deck, and the seat that took them wears the - // name of whoever spotted it β€” because "caught" with nobody attached is - // just a mystery. case "caught": { spotlight(e.seat); - var mine = e.seat === 0; + 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; - // Catching a bot is a pointed finger; being caught is a thud. + var whom = mine ? "Caught! " + (caughtBy.name || "They") + " saw it" : "Caught! +" + e.n; FX.sfx(mine ? "bad" : "catch"); var lands = []; var at = seatAnchor(e.seat); @@ -683,16 +553,15 @@ }); } - // You called a seat that had nothing to hide. Two cards, and they're - // yours: the price of the catch button not being a thing you just mash. case "miscall": { FX.sfx("bad"); var wrong = []; for (var w = 0; w < e.n; w++) { - wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" })); + wrong.push(throwCard(back(), deckEl, seatAnchor(e.seat), { index: w, delay: w * 100, sound: "deal" })); } - badge(0, "They called it. +" + e.n, "bad"); + 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); @@ -700,27 +569,17 @@ } 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); FX.sfx("bad"); 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"; - }); + 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); @@ -729,15 +588,10 @@ 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); - }); + return Promise.all(dumped).then(function () { showHand(e.hand); 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 = []; @@ -754,13 +608,11 @@ } 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); - showHand(e.hand); // if it was you, the hand goes with you - pending(0); // a dead seat pays no bill and leaves none behind + showHand(e.hand); + pending(0); FX.sfx("bad"); return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); }); } @@ -769,7 +621,7 @@ return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); }); case "reverse": - feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1"; + if (feltEl) feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1"; return badge(e.seat, "Reverse").then(function () { return wait(60); }); case "uno": @@ -777,101 +629,40 @@ return badge(e.seat, "UNO!", "uno"); case "reshuffle": - // The discard has just gone back under, so the counter has to as - // well: the draws that follow in this same script count down from it, - // and counting down from a deck that still reads empty gets nowhere. deckCntEl.textContent = String(e.n); deckEl.classList.add("pete-uno-shuffle"); FX.sfx("shuffle"); - return wait(420).then(function () { - deckEl.classList.remove("pete-uno-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); money(); return; } - - if (!settles) { - paint(final); - return; - } - - // Over: the board settles, the money moves, and only then does the bar - // catch up. - renderSeats(final); - renderPiles(final); - renderHand(final); - renderTurn(final); - renderRail(final); - verdict(final); - playing.classList.add("hidden"); - return settleChips(final) - .then(money) - .then(function () { return standing(final.bet); }) - .then(function () { setPhase(final); }); + if (!final) { paint(null); return; } + paint(final); + if (!live(final) && final.winner != null) verdict(final); }); } - // ---- the money ------------------------------------------------------------- - - function settleChips(final) { - var payout = final.payout || 0; - if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); - - // Not `back`: that is the card back up there, and a number wearing its name - // is a landmine for whoever next wants a face-down card in here. - var profit = payout - final.bet; - return spot - .pour(houseEl, profit, { gap: 60 }) - .then(function () { return wait(profit > 0 ? 380 : 200); }) - .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); - } - - // standing puts the stake back on the spot for the next game, the way every - // other table in the room leaves your bet up. pour grows the pile from what it - // is told is already there, so the amount is never set first β€” that bug printed - // double the stake under trivia's chips for a day. - function standing(amount) { - var money = window.PeteGames.view(); - if (!amount || !money || money.chips < amount) { - bet = 0; - showBet(); - return; - } - bet = amount; - showBet(); - return spot.pour(purseEl, amount); - } - // ---- moves ------------------------------------------------------------------ function pick(i, c) { - if (busy || !game || game.phase === "done" || game.turn !== 0) return; - if (c.wild) { askColour(i); return; } // the colour first; the call comes after + if (busy || !live(game) || game.turn !== me) return; + if (c.wild) { askColour(i); return; } offer(i, 0); } - // offer is the last thing between a card and the pile. If playing it leaves you - // holding one card, the table wants a word out of you first. - // - // Which cards those are is the server's answer, not a count of your hand: No - // Mercy's "discard all" takes every card of its colour with it, so a six-card - // hand can land on one, and a browser subtracting one from six would let you - // walk into a catch it never warned you about. function offer(i, color) { - if (game && (game.uno_at || []).indexOf(i) >= 0) { - openGate(i, color); - return; - } + if (game && (game.uno_at || []).indexOf(i) >= 0) { openGate(i, color); return; } move({ kind: "play", index: i, color: color }); } @@ -879,10 +670,6 @@ gate = { index: i, color: color }; gateEl.classList.remove("hidden"); if (timerEl) { - // Restart the drain. The bar is a CSS animation and this element gets - // reused, so without kicking the browser between the two assignments it - // stays where the last one finished β€” a full bar that never moves, which - // is worse than no bar at all. timerEl.style.animation = "none"; void timerEl.offsetWidth; timerEl.style.animation = ""; @@ -892,8 +679,6 @@ gateTimer = setTimeout(function () { shut(false); }, CALL_MS); } - // shut plays the card the gate was holding, with or without the word. Letting - // the clock run out is a real answer β€” it is the answer that gets you caught. function shut(called) { if (!gate) return; var g = gate; @@ -904,9 +689,6 @@ move({ kind: "play", index: g.index, color: g.color, uno: called }); } - // hideGate drops the gate without playing anything. Only for the paths that tear - // the board down under it (an error, a resume) β€” a gate that outlived its game - // would play a card into the next one. function hideGate() { if (gateTimer) clearTimeout(gateTimer); gateTimer = 0; @@ -916,39 +698,25 @@ if (callBtn) callBtn.addEventListener("click", function () { shut(true); }); - // Catching a seat that went quiet. It is not a turn β€” right or wrong, the turn - // stays with you β€” but it is a move, so it goes through the same door as one. function catchSeat(seat) { - if (busy || !game || game.phase === "done" || game.turn !== 0) return; + 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 askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); } + function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); } - function hideWild() { - pendingWild = -1; - if (wildEl) wildEl.classList.add("hidden"); - } - - // COLOURS maps the colour a player names to the number the engine calls it. - // Wild is 0 there on purpose β€” a move with no colour on it must not quietly - // mean red β€” so these start at 1 and this table is the only place that knows. var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 }; - root.querySelectorAll("[data-colour-pick]").forEach(function (b) { b.addEventListener("click", function () { if (busy || pendingWild < 0) return; var i = pendingWild; var c = COLOURS[b.dataset.colourPick]; hideWild(); - offer(i, c); // named the colour; now, is it your last but one? + offer(i, c); }); }); - var cancelWild = root.querySelector("[data-colour-cancel]"); if (cancelWild) cancelWild.addEventListener("click", hideWild); @@ -959,168 +727,235 @@ if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); }); if (deckEl) deckEl.addEventListener("click", function () { drawOne(); }); - if (passBtn) { - passBtn.addEventListener("click", function () { - if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return; - move({ kind: "pass" }); - }); - } - + 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 !== 0) return; + 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" }); + }); - // 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" }); - }); - } + 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 ---------------------------------------------------- + // ---- talking to the table -------------------------------------------------- - // send takes the table away for the whole move β€” not just the request, but the - // script that comes back with it. A lap of this table is seconds of animation, - // and for every one of them the game on screen is a game the server has already - // moved past. Clicking a card in there would send a move against a board that - // no longer exists. So busy comes off at the end of the script, not the end of - // the request, which is what the other tables do too. + // 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 window.PeteGames.post(path, body) + return G.post(path, body) .then(function (view) { - return play(view, function () { window.PeteGames.apply(view); }); + G.apply(view); + return play(view); }) .catch(function (err) { say(err.message, "bad", where); - return window.PeteGames.refresh().then(function (v) { - if (v && v.uno) paint(v.uno); - else { paint(null); spot.render(0); } + return G.refresh().then(function (v) { + if (v && v.uno) paint(v.uno); else paint(null); }); }) .then(function () { busy = false; controls(); - showBet(); + if (pendingSync) { pendingSync = false; sync(); } }); } - // ---- betting ------------------------------------------------------------------ + // ---- the live stream ------------------------------------------------------- - function showBet() { - betAmount.textContent = bet.toLocaleString(); - var money = window.PeteGames.view(); - if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet; + 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); } + }); } - function pickTier(slug) { - tier = slug; - root.querySelectorAll("[data-tier]").forEach(function (b) { - b.dataset.on = b.dataset.tier === slug ? "1" : "0"; - }); - showBet(); + // ---- 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 () {}); } - root.querySelectorAll("[data-tier]").forEach(function (b) { - b.addEventListener("click", function () { - if (busy) return; - pickTier(b.dataset.tier); - }); + 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; }); }); - // 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. + // ---- 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); - }); - pickTier(slug || DEFAULT_TABLE[which]); + 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) return; - pickRules(b.dataset.rules); - }); + b.addEventListener("click", function () { if (!busy) 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); + function pickTier(btn) { + tier = btn; + root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; }); + syncSit(); } - - // The chip you click is the chip that flies. Scoped to buttons: the bare - // [data-chip] spans in the rack are the house's, and the house is not betting. - root.querySelectorAll("button[data-chip]").forEach(function (btn) { - btn.addEventListener("click", function () { - if (busy) return; - var d = parseInt(btn.dataset.chip, 10); - var money = window.PeteGames.view(); - if (money && bet + d > money.chips) { - say("You haven't got that many chips.", "bad"); - return; - } - bet += d; - showBet(); - - var target = bet; - spot.amount = bet; - FX.fly(btn, spotEl, { denom: d }).then(function () { - if (bet >= target) spot.render(target); // unless Clear got there first - }); - }); + root.querySelectorAll("[data-tier]").forEach(function (b) { + b.addEventListener("click", function () { if (!busy) pickTier(b); }); }); - var clearBtn = root.querySelector("[data-bet-clear]"); - if (clearBtn) { - clearBtn.addEventListener("click", function () { - if (busy) return; - if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); - bet = 0; - showBet(); + // 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); }); } - if (startBtn) { - startBtn.addEventListener("click", function () { - if (busy) return; - if (bet <= 0) { say("Put something on it first.", "bad"); return; } - // The stake sits on the spot for the whole game: it is what's riding on you - // going out first. - send("/api/games/uno/start", { bet: bet, tier: tier }); - }); + 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; - window.PeteGames.onUpdate(function (v) { - if (!resumed) { - resumed = true; - if (v.uno) { - paint(v.uno); - if (v.uno.phase === "done") verdict(v.uno); - } else { - paint(null); - } - } - showBet(); + 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(); } + }); }); })(); diff --git a/internal/web/templates/uno.html b/internal/web/templates/uno.html index 4657fce..3dcddc0 100644 --- a/internal/web/templates/uno.html +++ b/internal/web/templates/uno.html @@ -13,23 +13,24 @@

UNO

-

Go out first, or it was somebody else's night

+

Sit down, ante up, and go out first β€” the pot's yours

{{template "_chipbar" .}} - +
- +
- +
@@ -74,24 +73,17 @@
-
- Bet -
- -
-
- Pays - β€” + In front of you + β€”

- + - +
- -