Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
250 lines
13 KiB
HTML
250 lines
13 KiB
HTML
{{define "title"}}UNO · {{.Room.Name}}{{end}}
|
|
|
|
{{define "main"}}
|
|
<div class="space-y-6" data-uno>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
|
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
|
|
</svg>
|
|
<span class="sr-only">Back to the casino</span>
|
|
</a>
|
|
<h1 class="font-display text-3xl font-bold">UNO</h1>
|
|
</div>
|
|
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
|
|
</div>
|
|
|
|
{{template "_chipbar" .}}
|
|
|
|
<!-- The felt. The board takes the room and the money lives in a rail beside it:
|
|
there is no corner free on this table for the house rack to sit in. -->
|
|
<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="min-w-0 space-y-5">
|
|
|
|
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
|
|
all you are ever told about them, and 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>
|
|
|
|
<!-- The middle: what you draw from, and what you play onto. -->
|
|
<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">
|
|
<span class="pete-uno-back" aria-hidden="true"></span>
|
|
<span data-deck-count class="pete-uno-deck-count">0</span>
|
|
</button>
|
|
|
|
<div class="flex flex-col items-center gap-2">
|
|
<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>
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Your hand. -->
|
|
<div class="space-y-2">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<p data-turn-label class="text-xs font-bold uppercase tracking-wider text-white/50" aria-live="polite"></p>
|
|
<p data-count-label class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
|
</div>
|
|
<div data-hand class="pete-uno-hand" aria-label="Your hand"></div>
|
|
</div>
|
|
|
|
<div class="flex min-h-[2.75rem] items-center justify-center">
|
|
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The rail. -->
|
|
<aside class="pete-rail">
|
|
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
|
|
<span data-chip="500" style="--stack: 5"></span>
|
|
<span data-chip="100" style="--stack: 7"></span>
|
|
<span data-chip="25" style="--stack: 4"></span>
|
|
<span data-chip="5" style="--stack: 6"></span>
|
|
</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="pete-meter" data-meter>
|
|
<span class="pete-meter-label">Pays</span>
|
|
<span data-pays class="pete-meter-value">—</span>
|
|
</div>
|
|
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- Naming a colour for a wild. It sits over the felt because until it is
|
|
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 class="pete-uno-wild-box">
|
|
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
|
|
<div class="mt-3 grid grid-cols-2 gap-2.5">
|
|
<button type="button" data-colour-pick="red" class="pete-uno-swatch" data-c="red">Red</button>
|
|
<button type="button" data-colour-pick="blue" class="pete-uno-swatch" data-c="blue">Blue</button>
|
|
<button type="button" data-colour-pick="yellow" class="pete-uno-swatch" data-c="yellow">Yellow</button>
|
|
<button type="button" data-colour-pick="green" class="pete-uno-swatch" data-c="green">Green</button>
|
|
</div>
|
|
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Calling UNO. The card is already on its way down — this is the word you
|
|
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 class="pete-uno-wild-box">
|
|
<p class="font-display text-lg font-bold text-white">Down to one card</p>
|
|
<button type="button" data-uno-call class="pete-uno-call">UNO!</button>
|
|
<div class="pete-uno-timer" aria-hidden="true"><span data-uno-timer></span></div>
|
|
<p class="mt-2 text-xs text-white/50">Say it, or they'll catch you for two.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Playing: shown while a game is live. -->
|
|
<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">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<p data-hint class="text-sm text-[color:var(--ink)]/50">
|
|
Click a card that lights up. Nothing lights up? Draw one.
|
|
</p>
|
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
|
<button type="button" data-draw
|
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
|
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
|
Draw
|
|
</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
|
|
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">
|
|
Take <span data-take-n>0</span>
|
|
</button>
|
|
<button type="button" data-pass
|
|
class="hidden rounded-full bg-[color:var(--accent)] 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">
|
|
Keep it
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
|
|
<!-- Betting: shown between games. -->
|
|
<section data-betting 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="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
|
|
<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="nomercy" class="pete-seg-btn">No Mercy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div data-grid="normal">
|
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
|
{{range .Tables}}
|
|
<button type="button" data-tier="{{.Slug}}"
|
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
|
<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 tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
|
|
</div>
|
|
<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">
|
|
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · winner takes the pot
|
|
</p>
|
|
</button>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<div data-grid="nomercy" class="hidden">
|
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
|
{{range .NoMercy}}
|
|
<button type="button" data-tier="{{.Slug}}"
|
|
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
|
|
<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 tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
|
|
</div>
|
|
<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">
|
|
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · 168 cards
|
|
</p>
|
|
</button>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
|
<div>
|
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
|
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{{range .Denominations}}
|
|
<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">
|
|
Deal
|
|
</button>
|
|
</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">
|
|
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 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
|
|
your hand puts you out of the game. It pays less than normal UNO because it buries the bots too.
|
|
</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>
|
|
|
|
</div>
|
|
{{end}}
|
|
|
|
{{define "scripts"}}
|
|
<script src="/static/js/casino-fx.js" defer></script>
|
|
<script src="/static/js/games.js" defer></script>
|
|
<script src="/static/js/uno.js" defer></script>
|
|
{{end}}
|