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:
prosolis
2026-07-14 16:49:27 -07:00
parent 5139385350
commit 4ad96dcb5e
5 changed files with 301 additions and 44 deletions

View File

@@ -35,13 +35,18 @@ func TestDevCasino(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
fundUser(t, bobPlayer, 5000)
seedTriviaBank(t)
payload, _ := json.Marshal(SessionUser{
Sub: "sub-1", Username: "reala", Name: "Reala",
Exp: time.Now().Add(24 * time.Hour).Unix(),
})
cookie := s.auth.sign(payload)
// The full table runtime, so the turn clock and the reaper are live under the
// browser exactly as in production.
s.StartTableClock(context.Background())
cookie := devCookie(s, "reala", "Reala")
// A second player, so a shared table can be reviewed — hold'em is multiplayer
// now, and one browser cannot see two people at the felt. Plant this cookie in
// a second browser profile (or a private window) to sit down as Bob.
bobCookie := devCookie(s, "bob", "Bob")
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -57,19 +62,31 @@ func TestDevCasino(t *testing.T) {
t.Fatal(err)
}
// Written to a file, not printed: `go test` buffers stdout, and the browser
// driver needs the cookie while the server is still running.
// driver needs the cookie while the server is still running. The second cookie
// rides alongside it, newline-separated, for the second browser.
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
if err := os.WriteFile(out, []byte(cookie+"\n"+bobCookie), 0o600); err != nil {
t.Fatal(err)
}
}
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\nBOB %s=%s\n\n",
addr, sessionCookie, cookie, sessionCookie, bobCookie)
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
t.Cleanup(func() { _ = srv.Close() })
_ = srv.Serve(ln)
}
// devCookie mints a signed session for a player the rig has funded, so the felt
// can be driven as them.
func devCookie(s *Server, username, name string) string {
payload, _ := json.Marshal(SessionUser{
Sub: "sub-" + username, Username: username, Name: name,
Exp: time.Now().Add(24 * time.Hour).Unix(),
})
return s.auth.sign(payload)
}
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
// difficulty.
//

View File

@@ -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

View File

@@ -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(); }
});
})();

View File

@@ -145,6 +145,21 @@
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- The rail talk. Chat lives at the felt and does not leave it for Matrix;
every line is stamped with the hand it was said during. Shown while you're
seated. -->
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
<form data-chat-form class="mt-3 flex items-center gap-2">
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
<button type="submit"
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
Say
</button>
</form>
</section>
<!-- Sitting down: shown when you aren't at a table. -->
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
@@ -195,6 +210,13 @@
Sit down
</button>
<!-- Or pull up a chair at a table someone else has already opened. Bots keep
every open table full, so there is always a seat to take. -->
<div data-lobby-wrap class="mt-6 hidden">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
<div data-lobby class="mt-2 grid gap-2"></div>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
</p>