games: the felt that knows which seat is yours, and the rail you can talk on
Phase C frontend: the hold'em felt runs on the shared-table runtime. - holdem.js reads view.your_seat instead of assuming seat zero — every "you" test (layout, your cards, the burst on a pot you win, the verdict) is keyed on it now, so a joiner at seat 2 sees their own hand at the bottom. - Leaving is its own endpoint, and a bust closes a solo table; play() animates a session-ending hand (the last showdown) before the felt clears. - A live table: one EventSource per seated player. The server pushes a nudge on every table change and a chat line as it is said; a nudge refetches the player's own redacted view (a hole card must never ride a frame that fans to the table), and a frame that lands mid-animation is held until the script finishes. - Chat on the felt (a _chat panel, messages only) and a lobby that lists tables with a seat going spare. Two-cookie dev rig (reala + bob), with the turn clock and reaper live under it. Browser-confirmed for solo: sit renders your seat and the rail, a hand deals and conserves to the chip (bought in 100, 100 in front), chat sends. The two-browser multiplayer pass (join, live sync between windows, shared-table conservation) is still owed before this deploys. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -2369,6 +2369,27 @@ html[data-room] .pete-felt {
|
||||
.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; }
|
||||
.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; }
|
||||
|
||||
/* The rail. Chat runs along the felt — messages only, no typing indicators, and
|
||||
it never leaves for Matrix. Your own lines lean the other way and take the
|
||||
accent, so a glance tells you who is talking. */
|
||||
.pete-chat-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
padding: 0.15rem 0.1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pete-chat-who {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.pete-chat-body {
|
||||
color: color-mix(in srgb, var(--ink) 85%, transparent);
|
||||
word-break: break-word;
|
||||
}
|
||||
.pete-chat-mine .pete-chat-who { color: color-mix(in srgb, var(--ink) 55%, transparent); }
|
||||
|
||||
/* The action bar. Raise is a slider, because a raise is a *size* and a text box
|
||||
makes you type a number you have not thought about. */
|
||||
.pete-raise {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -60,6 +60,13 @@
|
||||
var boughtEl = root.querySelector("[data-bought-in]");
|
||||
var rakeEl = root.querySelector("[data-session-rake]");
|
||||
|
||||
var chatSection = root.querySelector("[data-chat]");
|
||||
var chatLog = root.querySelector("[data-chat-log]");
|
||||
var chatForm = root.querySelector("[data-chat-form]");
|
||||
var chatInput = root.querySelector("[data-chat-input]");
|
||||
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
|
||||
var lobbyEl = root.querySelector("[data-lobby]");
|
||||
|
||||
var sitBtn = root.querySelector("[data-sit]");
|
||||
var buySlider = root.querySelector("[data-buyin-slider]");
|
||||
var buyLabel = root.querySelector("[data-buyin]");
|
||||
@@ -70,7 +77,10 @@
|
||||
var tableMsg = root.querySelector("[data-table-msg]");
|
||||
|
||||
var view = null; // the table, as the server last described it
|
||||
var me = 0; // which seat is yours — no longer always zero, at a shared table
|
||||
var busy = false;
|
||||
var pendingSync = false; // a pushed frame arrived mid-animation; re-render when free
|
||||
var stream = null; // the open EventSource, when seated
|
||||
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
||||
var shown = []; // what each seat's stack label currently reads
|
||||
var pot = null; // the middle pile, a PeteFX.spot
|
||||
@@ -171,6 +181,7 @@
|
||||
|
||||
function render(v) {
|
||||
view = v;
|
||||
me = v.your_seat || 0;
|
||||
|
||||
// The seats along the top, and you underneath.
|
||||
seatsEl.innerHTML = "";
|
||||
@@ -178,7 +189,7 @@
|
||||
seatEls = [];
|
||||
shown = [];
|
||||
v.seats.forEach(function (s, i) {
|
||||
var mine = i === 0;
|
||||
var mine = i === me;
|
||||
var built = seat(s, i, mine);
|
||||
seatEls[i] = built;
|
||||
shown[i] = s.stack;
|
||||
@@ -222,11 +233,12 @@
|
||||
// last hand, but the table you sit down at is the one that's open to you.
|
||||
var live = !!view && view.phase !== "done";
|
||||
sitting.classList.toggle("hidden", live);
|
||||
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== 0);
|
||||
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== me);
|
||||
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
||||
if (chatSection) chatSection.classList.toggle("hidden", !live);
|
||||
if (!live) return;
|
||||
|
||||
if (view.phase === "betting" && view.to_act === 0) {
|
||||
if (view.phase === "betting" && view.to_act === me) {
|
||||
checkBtn.classList.toggle("hidden", !view.can_check);
|
||||
callBtn.classList.toggle("hidden", view.can_check);
|
||||
callAmt.textContent = money(view.owed);
|
||||
@@ -274,18 +286,22 @@
|
||||
// happening happens here; render() is the state it settles into.
|
||||
function play(events, final) {
|
||||
var chain = Promise.resolve();
|
||||
// No table to settle into — the session closed and storage has already cleared
|
||||
// it. There is nothing to animate onto, and render() would walk seats that
|
||||
// aren't there.
|
||||
if (!final) { render0(); return Promise.resolve(); }
|
||||
if (!events || !events.length) { render(final); return chain; }
|
||||
if (!events || !events.length) {
|
||||
// Nothing to animate. Either settle into the table we ended on, or — if the
|
||||
// session closed and storage cleared it — clear the felt.
|
||||
if (final) render(final); else render0();
|
||||
return chain;
|
||||
}
|
||||
|
||||
events.forEach(function (e) {
|
||||
chain = chain.then(function () { return beat(e, final); });
|
||||
});
|
||||
return chain.then(function () {
|
||||
render(final);
|
||||
// A hand can be the last one — a bust closes the table, so there is no state
|
||||
// to settle into. Play it out and say what it did anyway (the seats are still
|
||||
// on the felt from the previous render), then clear.
|
||||
verdict(events, final);
|
||||
if (final) render(final); else render0();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,7 +352,7 @@
|
||||
|
||||
case "show":
|
||||
if (!s) return;
|
||||
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === 0);
|
||||
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === me);
|
||||
flash(s.root);
|
||||
return wait(420);
|
||||
|
||||
@@ -353,7 +369,7 @@
|
||||
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
||||
potTotal.textContent = money(pot.amount);
|
||||
moveStack(e.seat, e.amount);
|
||||
if (e.seat === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
||||
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
||||
return wait(260);
|
||||
});
|
||||
|
||||
@@ -375,8 +391,8 @@
|
||||
if (s.state === "out") return;
|
||||
var built = seatEls[i];
|
||||
if (!built) return;
|
||||
var face = (i === 0 && s.cards) ? s.cards[round] : null;
|
||||
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
|
||||
var face = (i === me && s.cards) ? s.cards[round] : null;
|
||||
var card = Cards.el(face, { deal: true, tilt: i !== me });
|
||||
built.cards.appendChild(card);
|
||||
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
||||
beats.push(wait(70 * i));
|
||||
@@ -477,25 +493,25 @@
|
||||
function verdict(events, final) {
|
||||
var won = 0, showed = false, busted = false;
|
||||
events.forEach(function (e) {
|
||||
if (e.kind === "win" && e.seat === 0) won += e.amount;
|
||||
if (e.kind === "win" && e.seat === me) won += e.amount;
|
||||
if (e.kind === "show") showed = true;
|
||||
if (e.kind === "bust") busted = true;
|
||||
if (e.kind === "bust" && e.seat === me) busted = true;
|
||||
});
|
||||
if (busted) {
|
||||
show("You're out of chips. Sit down again when you're ready.", "lose");
|
||||
FX.sfx("lose");
|
||||
return;
|
||||
}
|
||||
if (!events.some(function (e) { return e.kind === "end"; })) return;
|
||||
if (!final || !events.some(function (e) { return e.kind === "end"; })) return;
|
||||
|
||||
var me = final.seats[0];
|
||||
var mine = final.seats[me];
|
||||
if (won > 0) {
|
||||
// The pot coming your way already burst (and so already cheered) back in
|
||||
// the "win" event. This is only the words.
|
||||
show(showed
|
||||
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
||||
: "They folded. You take " + money(won) + ".", "win");
|
||||
} else if (me.state === "folded") {
|
||||
} else if (mine.state === "folded") {
|
||||
show("Folded.", "lose");
|
||||
} else {
|
||||
show("No good this time.", "lose");
|
||||
@@ -510,7 +526,7 @@
|
||||
}
|
||||
|
||||
function handName(events) {
|
||||
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === 0; })[0];
|
||||
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === me; })[0];
|
||||
return mine && mine.text ? mine.text : "the best hand";
|
||||
}
|
||||
|
||||
@@ -556,26 +572,48 @@
|
||||
.post("/api/games/holdem/move", body)
|
||||
.then(function (v) {
|
||||
window.PeteGames.apply(v);
|
||||
return play(v.holdem_events, v.holdem || null).then(function () {
|
||||
if (!v.holdem) { render0(); return; }
|
||||
if (v.holdem.phase === "done") {
|
||||
var got = v.holdem.payout, put = v.holdem.bought_in;
|
||||
say(tableMsg, got > put
|
||||
? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack."
|
||||
: got === 0
|
||||
? "Cleaned out. Better luck at the next table."
|
||||
: "You got up " + money(put - got) + " down. " + money(got) + " back on your stack.");
|
||||
setTimeout(render0, 2600);
|
||||
}
|
||||
});
|
||||
// No table came back: the session ended inside this move — a bust closes a
|
||||
// solo table. play() animates the last hand (its "bust" beat says the words)
|
||||
// and clears the felt.
|
||||
return play(v.holdem_events, v.holdem || null);
|
||||
})
|
||||
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||
.then(function () { busy = false; lock(false); });
|
||||
.then(function () { busy = false; lock(false); flushSync(); });
|
||||
}
|
||||
|
||||
// flushSync applies a table frame that arrived while a hand was animating. The
|
||||
// felt is now settled, so the held re-render will not clobber a script.
|
||||
function flushSync() {
|
||||
if (pendingSync) { pendingSync = false; sync(); }
|
||||
}
|
||||
|
||||
// leave gets you up from the table. It is its own endpoint, not a move: the
|
||||
// chips cross back and the felt may close behind you, neither of which is a play
|
||||
// in a hand. The felt clears and the stack you took is reported from what was in
|
||||
// front of you a moment ago.
|
||||
function leave() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
lock(true);
|
||||
var stack = view ? view.stack : 0;
|
||||
verdictEl.classList.add("hidden");
|
||||
window.PeteGames
|
||||
.post("/api/games/holdem/leave", {})
|
||||
.then(function (v) {
|
||||
window.PeteGames.apply(v);
|
||||
render0();
|
||||
say(tableMsg, stack > 0
|
||||
? money(stack) + " back on your stack. Sit down again when you're ready."
|
||||
: "");
|
||||
})
|
||||
.catch(function (err) { say(betweenMsg, err.message, "bad"); })
|
||||
.then(function () { busy = false; lock(false); flushSync(); });
|
||||
}
|
||||
|
||||
// render0 is the table with nobody at it.
|
||||
function render0() {
|
||||
view = null;
|
||||
unseated();
|
||||
seatsEl.innerHTML = "";
|
||||
youEl.innerHTML = "";
|
||||
boardEl.innerHTML = "";
|
||||
@@ -584,6 +622,7 @@
|
||||
sideEl.classList.add("hidden");
|
||||
panels();
|
||||
syncSit();
|
||||
loadLobby();
|
||||
}
|
||||
|
||||
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
||||
@@ -612,7 +651,7 @@
|
||||
});
|
||||
|
||||
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
||||
if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); });
|
||||
if (leaveBtn) leaveBtn.addEventListener("click", function () { leave(); });
|
||||
if (topupBtn) topupBtn.addEventListener("click", function () {
|
||||
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
||||
});
|
||||
@@ -668,6 +707,7 @@
|
||||
.then(function (v) {
|
||||
window.PeteGames.apply(v);
|
||||
render(v.holdem);
|
||||
seated();
|
||||
// A table with nobody dealt in yet is a table waiting for you to say go.
|
||||
say(betweenMsg, "You're in. Deal when you're ready.");
|
||||
})
|
||||
@@ -675,6 +715,163 @@
|
||||
.then(function () { busy = false; });
|
||||
});
|
||||
|
||||
// ---- the live table --------------------------------------------------------
|
||||
//
|
||||
// Poker is where the room stops being just you and the house: other people are
|
||||
// at the felt, and what they do has to reach you without your asking. That is
|
||||
// one EventSource. The server pushes a nudge when the table changes and a line
|
||||
// when somebody speaks; the nudge is not the state (a hole card must never ride
|
||||
// a frame that fans to the whole table), so on a nudge we refetch our own
|
||||
// seat's view, which is authoritative and already redacted.
|
||||
|
||||
// seated opens the stream and loads the rail. Called the moment you sit down.
|
||||
function seated() {
|
||||
loadChat();
|
||||
connectLive();
|
||||
}
|
||||
|
||||
// unseated tears it down. Called when the felt clears — you got up, or busted.
|
||||
function unseated() {
|
||||
disconnectLive();
|
||||
if (chatLog) chatLog.innerHTML = "";
|
||||
}
|
||||
|
||||
function connectLive() {
|
||||
if (stream || !window.EventSource) return;
|
||||
stream = new EventSource("/api/games/holdem/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();
|
||||
};
|
||||
// The opening nudge (event: sync) just says "come and look".
|
||||
stream.addEventListener("sync", function () { sync(); });
|
||||
// EventSource reconnects itself on a dropped connection. The one case we don't
|
||||
// want it retrying is a table that has closed (the stream 409s) — but by then
|
||||
// we have called unseated() and closed it ourselves.
|
||||
}
|
||||
|
||||
function disconnectLive() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
}
|
||||
|
||||
// sync re-renders the felt from the authoritative table, but never mid-animation
|
||||
// — a frame that lands while a hand is playing is held and applied once the
|
||||
// script finishes, or it would repaint the table out from under it.
|
||||
function sync() {
|
||||
if (busy) { pendingSync = true; return; }
|
||||
window.PeteGames.refresh().then(function (v) {
|
||||
if (!v) return;
|
||||
if (v.holdem) render(v.holdem); else { render0(); }
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the rail --------------------------------------------------------------
|
||||
|
||||
function loadChat() {
|
||||
fetch("/api/games/holdem/chat", { headers: { "Accept": "application/json" } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !chatLog) return;
|
||||
chatLog.innerHTML = "";
|
||||
(data.chat || []).forEach(addChat);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function addChat(line) {
|
||||
if (!chatLog || !line) return;
|
||||
var row = document.createElement("div");
|
||||
row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
|
||||
var who = document.createElement("span");
|
||||
who.className = "pete-chat-who";
|
||||
who.textContent = line.name;
|
||||
var body = document.createElement("span");
|
||||
body.className = "pete-chat-body";
|
||||
body.textContent = line.body;
|
||||
row.appendChild(who);
|
||||
row.appendChild(body);
|
||||
chatLog.appendChild(row);
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
|
||||
if (chatForm) chatForm.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var body = (chatInput.value || "").trim();
|
||||
if (!body) return;
|
||||
chatInput.value = "";
|
||||
window.PeteGames
|
||||
.post("/api/games/holdem/say", { body: body })
|
||||
.then(function (line) { addChat(line); })
|
||||
.catch(function () { chatInput.value = body; });
|
||||
});
|
||||
|
||||
// ---- the lobby -------------------------------------------------------------
|
||||
//
|
||||
// The tables other people have open, each with a seat going spare. Joining
|
||||
// takes the buy-in the slider is set to, clamped to what that table allows.
|
||||
|
||||
function loadLobby() {
|
||||
if (!lobbyEl) return;
|
||||
fetch("/api/games/holdem/tables", { headers: { "Accept": "application/json" } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (data) {
|
||||
var tables = (data && data.tables) || [];
|
||||
renderLobby(tables);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function renderLobby(tables) {
|
||||
lobbyEl.innerHTML = "";
|
||||
lobbyWrap.classList.toggle("hidden", tables.length === 0);
|
||||
tables.forEach(function (t) {
|
||||
var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
|
||||
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 label = document.createElement("span");
|
||||
label.className = "font-display font-bold";
|
||||
label.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(label);
|
||||
btn.appendChild(seats);
|
||||
btn.addEventListener("click", function () { join(t, stakes); });
|
||||
lobbyEl.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function join(t, stakes) {
|
||||
if (busy) return;
|
||||
// Buy in for what the slider says, but only what this table allows.
|
||||
var buyIn = buyIn0(stakes);
|
||||
busy = true;
|
||||
say(tableMsg, "");
|
||||
window.PeteGames
|
||||
.post("/api/games/holdem/sit", { table: t.id, buyin: buyIn })
|
||||
.then(function (v) {
|
||||
window.PeteGames.apply(v);
|
||||
render(v.holdem);
|
||||
seated();
|
||||
say(betweenMsg, "You're in. The next hand deals when the table is ready.");
|
||||
})
|
||||
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
// buyIn0 is a legal buy-in for a table you are joining: fifty big blinds, or as
|
||||
// close as the table's range allows.
|
||||
function buyIn0(stakes) {
|
||||
if (!stakes) return Number(buySlider.value);
|
||||
var min = Number(stakes.dataset.min), max = Number(stakes.dataset.max), bb = Number(stakes.dataset.bb);
|
||||
var want = Number(buySlider.value);
|
||||
if (stakes === tier) return Math.min(max, Math.max(min, want)); // same table: honour the slider
|
||||
return Math.min(max, Math.max(min, 50 * bb));
|
||||
}
|
||||
|
||||
// ---- boot ------------------------------------------------------------------
|
||||
|
||||
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
||||
@@ -683,7 +880,7 @@
|
||||
pickBots(botBtns[1]);
|
||||
|
||||
window.PeteGames.refresh().then(function (v) {
|
||||
if (v && v.holdem) render(v.holdem);
|
||||
else render0();
|
||||
if (v && v.holdem) { render(v.holdem); seated(); }
|
||||
else { render0(); loadLobby(); }
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user