games: a table of bots you have to beat to the last card

UNO, played for chips. You stake once, sit down against one to three bots,
and going out first pays the table: 2.2x heads up, 3.6x against a full house.
Anybody else going out first takes the stake. The table size is the tier,
because it is the only dial UNO has.

The bots move inside ApplyMove. A game with opponents is normally where you
reach for a socket, and the plan says solo UNO must not — so one request plays
your move and every bot turn behind it, and hands back the whole lap as a
script the felt plays in order.

The RNG is in the state rather than an argument to it: the bots choose and a
spent deck reshuffles, so the engine needs randomness mid-game, and there is no
generator alive across requests to pass in. The seed rides in the state and each
step derives its own. The game still replays exactly as it fell.

The zero value of Color is Wild, and that is the whole point of it: a wild
played with the colour field missing from the JSON must be refused, not
quietly played as a red one. It was red for an hour.

The browser never sees a bot's card — not the deck, not a hand, not the face of
a card a bot drew, which is most of the deck. Seats cross the wire as a name and
a count.

The multiples are measured, not guessed: playing the first legal card you hold
wins 43/32/27% of the time against these bots, so the tiers price that to lose
about 8% a game and leave good play worth roughly the house's edge.

PeteFX.flyNode is the throw with the chip taken out of it, so a card can be
thrown across the felt the same way. fly() is now that with a chip in it.

Not yet driven in a browser, which in this room means not yet finished.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 07:07:17 -07:00
parent 3e9b93af55
commit 79c857023f
14 changed files with 3438 additions and 17 deletions

View File

@@ -1421,6 +1421,346 @@ html[data-phase="night"] {
justify-content: center;
}
/* ---- uno ------------------------------------------------------------------
The card is the whole look: a coloured rectangle with a white oval laid
across it at an angle. Drop the oval and it reads as a button, so the oval
is not decoration — it is what makes the thing a card.
The bots are drawn from a *count*. That is all the server sends, and the fan
of backs up there is generated from a number rather than from cards, because
there are no cards to draw. */
/* The felt takes a tint of the colour in play. After a wild, the card on the
pile is not the colour you have to follow — this is the table saying so. */
.pete-uno[data-c="red"] { --play: #d64545; }
.pete-uno[data-c="blue"] { --play: #3d7fd6; }
.pete-uno[data-c="yellow"] { --play: #e0b02c; }
.pete-uno[data-c="green"] { --play: #46a86b; }
.pete-uno::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(80% 55% at 50% 45%, var(--play, transparent), transparent 70%);
opacity: 0.16;
transition: opacity 0.5s ease, background 0.5s ease;
}
.pete-uno-card {
position: relative;
height: var(--uno-h, 6.4rem);
width: var(--uno-w, 4.3rem);
border-radius: 0.55rem;
padding: 0.28rem;
background: #fff;
box-shadow: 0 3px 0 rgba(0,0,0,0.22), 0 6px 14px rgba(0,0,0,0.18);
flex: none;
}
.pete-uno-face {
position: relative;
display: grid;
place-items: center;
height: 100%;
width: 100%;
border-radius: 0.38rem;
overflow: hidden;
background: var(--uno, #2b2118);
color: #fff;
}
.pete-uno-card[data-c="red"] { --uno: #d64545; }
.pete-uno-card[data-c="blue"] { --uno: #3d7fd6; }
.pete-uno-card[data-c="yellow"] { --uno: #e0b02c; }
.pete-uno-card[data-c="green"] { --uno: #46a86b; }
.pete-uno-card[data-c="wild"] { --uno: #2b2118; }
/* A wild that has been played wears the colour it was called as — the pile has
to show what you must follow, not what was printed. */
.pete-uno-card[data-named="red"] { box-shadow: 0 0 0 3px #d64545, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="blue"] { box-shadow: 0 0 0 3px #3d7fd6, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="yellow"] { box-shadow: 0 0 0 3px #e0b02c, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="green"] { box-shadow: 0 0 0 3px #46a86b, 0 3px 0 rgba(0,0,0,0.22); }
/* The oval. Tilted, because it is tilted on the card in your hand. */
.pete-uno-oval {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 78%;
width: 62%;
border-radius: 50%;
transform: rotate(-24deg);
background: #fff;
color: var(--uno, #2b2118);
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.45rem;
font-weight: 700;
line-height: 1;
text-shadow: 0 1px 0 rgba(0,0,0,0.10);
}
.pete-uno-card[data-c="wild"] .pete-uno-oval { color: #2b2118; font-size: 1.15rem; }
.pete-uno-corner {
position: absolute;
z-index: 1;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
color: #fff;
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
}
.pete-uno-corner[data-at="tl"] { top: 0.22rem; left: 0.28rem; }
.pete-uno-corner[data-at="br"] { bottom: 0.22rem; right: 0.28rem; transform: rotate(180deg); }
/* The four quadrants of a wild, under the oval. */
.pete-uno-wheel {
position: absolute;
inset: 0;
background:
conic-gradient(#d64545 0 25%, #e0b02c 0 50%, #46a86b 0 75%, #3d7fd6 0);
opacity: 0.92;
}
/* The back. Every card you are not entitled to see is one of these. */
.pete-uno-card-back { padding: 0.28rem; }
.pete-uno-back {
display: grid;
place-items: center;
height: 100%;
width: 100%;
border-radius: 0.38rem;
background:
radial-gradient(120% 80% at 50% 0%, rgba(255,255,255,0.16), transparent 60%),
#2b2118;
}
.pete-uno-back::after {
content: "UNO";
display: block;
transform: rotate(-24deg);
padding: 0.1rem 0.35rem;
border-radius: 999px;
background: #e0b02c;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.02em;
}
/* Your hand. It fans, it wraps, and a card you can play stands up out of it —
being shown what goes is the game teaching you, and the server still decides. */
.pete-uno-hand {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
min-height: 6.8rem;
padding-top: 0.6rem;
}
.pete-uno-hand .pete-uno-card {
animation: pete-uno-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: calc(var(--i, 0) * 45ms);
transition: transform 0.16s ease, filter 0.16s ease;
}
.pete-uno-hand .pete-uno-card[data-on="1"] {
cursor: pointer;
transform: translateY(-0.5rem);
}
.pete-uno-hand .pete-uno-card[data-on="1"]:hover { transform: translateY(-1.1rem) scale(1.03); }
.pete-uno-hand .pete-uno-card[data-on="0"] { filter: brightness(0.62) saturate(0.7); }
@keyframes pete-uno-in {
from { transform: translateY(2rem) rotate(6deg); opacity: 0; }
to { transform: translateY(-0.5rem); opacity: 1; }
}
/* A seat: a fan of backs, a name, a count. The fan overlaps, so eight cards
read as a hand rather than a row. */
.pete-uno-seat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
position: relative;
padding: 0.5rem 0.7rem;
border-radius: 1rem;
transition: background 0.25s ease, transform 0.25s ease;
}
.pete-uno-seat[data-turn="1"] {
background: rgba(255,255,255,0.12);
transform: translateY(-2px);
}
.pete-uno-seat[data-uno="1"] .pete-uno-count { color: #ffd76e; }
.pete-uno-fan {
display: flex;
--uno-h: 3.2rem;
--uno-w: 2.15rem;
}
.pete-uno-fan .pete-uno-card {
margin-left: -1.35rem;
transform: rotate(calc((var(--i, 0) - (var(--n, 1) - 1) / 2) * 4deg));
transform-origin: bottom center;
box-shadow: 0 2px 0 rgba(0,0,0,0.22);
}
.pete-uno-fan .pete-uno-card:first-child { margin-left: 0; }
.pete-uno-fan .pete-uno-back::after { font-size: 0.4rem; padding: 0.06rem 0.22rem; }
.pete-uno-name {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 700;
color: #fff;
line-height: 1;
}
.pete-uno-count {
font-size: 0.7rem;
font-weight: 700;
color: rgba(255,255,255,0.55);
line-height: 1;
}
/* The deck you draw from, and the pile you play onto. */
.pete-uno-deck {
position: relative;
--uno-h: 6.4rem;
--uno-w: 4.3rem;
height: var(--uno-h);
width: var(--uno-w);
border-radius: 0.55rem;
padding: 0.28rem;
background: #fff;
box-shadow:
0 3px 0 rgba(0,0,0,0.22),
0.28rem 0.28rem 0 -0.06rem rgba(255,255,255,0.35),
0.5rem 0.5rem 0 -0.12rem rgba(255,255,255,0.18);
transition: transform 0.15s ease;
}
.pete-uno-deck:not(:disabled):hover { transform: translateY(-3px); }
.pete-uno-deck:disabled { opacity: 0.75; }
.pete-uno-deck-count {
position: absolute;
bottom: -0.55rem;
left: 50%;
transform: translateX(-50%);
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.4;
}
.pete-uno-shuffle { animation: pete-uno-shuffle 0.42s ease; }
@keyframes pete-uno-shuffle {
0%, 100% { transform: none; }
30% { transform: translateY(-4px) rotate(-3deg); }
65% { transform: translateY(2px) rotate(2deg); }
}
.pete-uno-discard {
display: grid;
place-items: center;
height: 6.4rem;
width: 4.3rem;
border-radius: 0.55rem;
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.14);
}
.pete-uno-land { animation: pete-uno-land 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes pete-uno-land {
from { transform: scale(1.14) rotate(-4deg); }
to { transform: none; }
}
.pete-uno-colour {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.15rem 0.6rem;
border-radius: 999px;
color: #fff;
background: rgba(0,0,0,0.3);
}
.pete-uno-colour[data-c="red"] { background: #d64545; }
.pete-uno-colour[data-c="blue"] { background: #3d7fd6; }
.pete-uno-colour[data-c="yellow"] { background: #e0b02c; color: #2b2118; }
.pete-uno-colour[data-c="green"] { background: #46a86b; }
/* A word thrown at a seat: SKIPPED, UNO!, +4. */
.pete-uno-badge {
position: absolute;
top: -0.4rem;
left: 50%;
z-index: 5;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #fff;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
animation: pete-uno-badge 0.9s ease forwards;
}
.pete-uno-badge[data-tone="bad"] { background: #cc3d4a; color: #fff; }
.pete-uno-badge[data-tone="uno"] { background: #e0b02c; }
@keyframes pete-uno-badge {
0% { transform: translate(-50%, 0.4rem) scale(0.7); opacity: 0; }
25% { transform: translate(-50%, -0.5rem) scale(1.06); opacity: 1; }
75% { transform: translate(-50%, -0.9rem) scale(1); opacity: 1; }
100% { transform: translate(-50%, -1.6rem) scale(0.95); opacity: 0; }
}
/* Naming a colour. It covers the felt because until it's answered there is no
legal move on the table underneath it. */
.pete-uno-wild {
position: absolute;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: rgba(10, 20, 15, 0.55);
backdrop-filter: blur(2px);
}
.pete-uno-wild-box {
padding: 1.25rem 1.5rem;
border-radius: 1.25rem;
background: rgba(20, 40, 30, 0.92);
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
text-align: center;
}
.pete-uno-swatch {
padding: 0.7rem 1.4rem;
border-radius: 0.75rem;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1rem;
font-weight: 700;
color: #fff;
background: var(--sw, #666);
box-shadow: 0 3px 0 rgba(0,0,0,0.3);
transition: transform 0.12s ease, filter 0.12s ease;
}
.pete-uno-swatch:hover { transform: translateY(-2px); filter: brightness(1.08); }
.pete-uno-swatch:active { transform: translateY(1px); }
.pete-uno-swatch[data-c="red"] { --sw: #d64545; }
.pete-uno-swatch[data-c="blue"] { --sw: #3d7fd6; }
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
/* A phone. The cards get smaller so a hand of ten still fits without the felt
scrolling sideways, which is the thing to check at 390px with a game on it. */
@media (max-width: 639px) {
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
.pete-uno-discard { height: 5.2rem; width: 3.5rem; }
.pete-uno-hand .pete-uno-card { padding: 0.22rem; }
.pete-uno-oval { font-size: 1.15rem; }
.pete-uno-seat { padding: 0.35rem 0.45rem; }
}
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,