games: the UNO felt other people can sit at, and the pot on the rail

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

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

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 18:44:10 -07:00
parent 927ed84163
commit 18049f6f59
2 changed files with 454 additions and 607 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -13,23 +13,24 @@
</a> </a>
<h1 class="font-display text-3xl font-bold">UNO</h1> <h1 class="font-display text-3xl font-bold">UNO</h1>
</div> </div>
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p> <p class="text-sm text-[color:var(--ink)]/50">Sit down, ante up, and go out first — the pot's yours</p>
</div> </div>
{{template "_chipbar" .}} {{template "_chipbar" .}}
<!-- The felt. The board takes the room and the money lives in a rail beside it: <!-- The felt. Other seats along the top, the deck and the pile in the middle
there is no corner free on this table for the house rack to sit in. --> with the pot beside them, your hand at the bottom. Every ante on this table
is riding in the pot until somebody goes out and takes it. -->
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10"> <section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8"> <div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0 space-y-5"> <div class="min-w-0 space-y-5">
<!-- The bots. Each one is a name, a fan of backs, and a count — which is <!-- The other seats. Each one is a name, a stack, a fan of backs and a
all you are ever told about them, and all a real opponent shows you. --> count — all a real opponent shows you. -->
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div> <div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
<!-- The middle: what you draw from, and what you play onto. --> <!-- The middle: what you draw from, what you play onto, and the pot. -->
<div class="flex items-center justify-center gap-5 sm:gap-8"> <div class="flex items-center justify-center gap-5 sm:gap-8">
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card"> <button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
<span class="pete-uno-back" aria-hidden="true"></span> <span class="pete-uno-back" aria-hidden="true"></span>
@@ -39,16 +40,14 @@
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div> <div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
<p data-colour class="pete-uno-colour" aria-live="polite"></p> <p data-colour class="pete-uno-colour" aria-live="polite"></p>
<!-- The bill. While a stack stands it is the only thing on the table
that matters, so it is said on the felt and not only on a button.
It is data-*bill*, not data-pending: the chip bar already owns
data-pending (chips still in flight from a buy-in), the chip bar is
inside this root, and it comes first in the document — so a stack
was quietly writing "+10 coming your way" over the escrow readout
and never showing up on the felt at all. -->
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p> <p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
</div> </div>
<!-- The pot. Every seat's ante is in here until the hand is won. -->
<div class="pete-uno-pot text-center">
<span class="block text-xs font-bold uppercase tracking-wider text-white/50">Pot</span>
<span data-pot class="block font-display text-2xl font-bold tabular-nums text-white">0</span>
</div>
</div> </div>
<!-- Your hand. --> <!-- Your hand. -->
@@ -74,24 +73,17 @@
<span data-chip="5" style="--stack: 6"></span> <span data-chip="5" style="--stack: 6"></span>
</div> </div>
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="text-center"> <div class="text-center">
<div class="pete-meter" data-meter> <div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span> <span class="pete-meter-label">In front of you</span>
<span data-pays class="pete-meter-value"></span> <span data-stack class="pete-meter-value"></span>
</div> </div>
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p> <p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div> </div>
</aside> </aside>
</div> </div>
<!-- Naming a colour for a wild. It sits over the felt because until it is <!-- Naming a colour for a wild. -->
answered there is no legal move on the table underneath it. -->
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour"> <div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
<div class="pete-uno-wild-box"> <div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Pick a colour</p> <p class="font-display text-lg font-bold text-white">Pick a colour</p>
@@ -105,13 +97,7 @@
</div> </div>
</div> </div>
<!-- Calling UNO. The card is already on its way down — this is the word you <!-- Calling UNO. -->
owe the table for it, and the bar underneath is how long you've got.
Let it run out and the bots take you for two.
There is no cancel here, and there doesn't need to be: clicking a card
has always played it on this table, so a gate that makes you press one
more thing is strictly more warning than you used to get. -->
<div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO"> <div data-unogate class="pete-uno-wild hidden" role="dialog" aria-label="Call UNO">
<div class="pete-uno-wild-box"> <div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Down to one card</p> <p class="font-display text-lg font-bold text-white">Down to one card</p>
@@ -122,8 +108,8 @@
</div> </div>
</section> </section>
<!-- Playing: shown while a game is live. --> <!-- Acting: shown while it's your turn. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10"> <section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<p data-hint class="text-sm text-[color:var(--ink)]/50"> <p data-hint class="text-sm text-[color:var(--ink)]/50">
Click a card that lights up. Nothing lights up? Draw one. Click a card that lights up. Nothing lights up? Draw one.
@@ -134,9 +120,6 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition"> hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Draw Draw
</button> </button>
<!-- Giving in to a stack. It is a decision, not a draw — you are paying a
bill somebody ran up and pointing at you — so it gets a button of its
own, and the button says what it costs. -->
<button type="button" data-take <button type="button" data-take
class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete class="hidden rounded-full bg-[#cc3d4a] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition"> hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
@@ -152,15 +135,51 @@
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p> <p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section> </section>
<!-- Betting: shown between games. --> <!-- Between hands: deal the next one, or get up. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10"> <section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
Bought in for <span data-bought-in class="tabular-nums">0</span> · <span data-ante-note class="tabular-nums">0</span> to ante
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-leave
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Get up
</button>
<button type="button" data-deal
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Next hand
</button>
</div>
</div>
<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, 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">
<!-- Two dials, and they are different dials. The table size is the tier — it is
what you're paid for. No Mercy is the *deck*, and it is a switch across all
three sizes, which is why it sits up here beside the question rather than
being a fourth table. -->
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div> <div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which table?</div>
<div class="pete-seg" role="group" aria-label="Which rules"> <div class="pete-seg" role="group" aria-label="Which rules">
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button> <button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button> <button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
@@ -170,7 +189,7 @@
<div data-grid="normal"> <div data-grid="normal">
<div class="mt-2 grid gap-2 sm:grid-cols-3"> <div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}} {{range .Tables}}
<button type="button" data-tier="{{.Slug}}" <button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition"> class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span> <span class="font-display text-lg font-bold">{{.Name}}</span>
@@ -178,7 +197,7 @@
</div> </div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p> <p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40"> <p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · winner takes the pot {{.Bots}} bot{{if gt .Bots 1}}s{{end}} · buy in {{.MinBuy}}{{.MaxBuy}}
</p> </p>
</button> </button>
{{end}} {{end}}
@@ -188,7 +207,7 @@
<div data-grid="nomercy" class="hidden"> <div data-grid="nomercy" class="hidden">
<div class="mt-2 grid gap-2 sm:grid-cols-3"> <div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .NoMercy}} {{range .NoMercy}}
<button type="button" data-tier="{{.Slug}}" <button type="button" data-tier="{{.Slug}}" data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-ante="{{.Ante}}"
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition"> class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span> <span class="font-display text-lg font-bold">{{.Name}}</span>
@@ -203,38 +222,31 @@
</div> </div>
</div> </div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4"> <div class="mt-5">
<div> <div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div> <div class="mt-2 flex items-center gap-3">
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div> <input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <button type="button" data-sit
{{range .Denominations}} class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition"> hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal Sit down
</button> </button>
<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> </div>
<!-- No Mercy pays *less*, which reads like a typo until you've played one: the
mercy rule buries bots too, and every bot it buries is one fewer seat that
can beat you to the last card. Say so, rather than let it look like a bug. -->
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40"> <p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you. Everyone antes, and the first one out takes the pot less {{.RakePct}}% rake. Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
</p> </p>
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40"> <p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in 168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in your hand puts you out of the hand. The pot still goes to whoever goes out first.
your hand puts you out of the game. It pays less than normal UNO because it buries the bots too.
</p> </p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p> <p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section> </section>