games: you buy the deck, and win it back a card at a time

Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, and every card you get home to a foundation pays a fifty-second
of the tier's multiple back. Cash the board whenever you like and keep
what you've banked, so a board that has gone dead is a decision rather
than a wall. No undo: the stake is spent the moment the deck is bought,
and an undo would be a way to walk a losing board backwards until it wins.

Three deals, and the two dials are the whole difficulty of Klondike.
Patient draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.

internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.

The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.

The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.

The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.

Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.

Not yet driven in a browser.
This commit is contained in:
prosolis
2026-07-14 01:40:14 -07:00
parent fe2195e85f
commit 5ca056bf20
16 changed files with 3185 additions and 216 deletions

View File

@@ -570,10 +570,13 @@ html[data-phase="night"] {
/* One card. The wrapper does the flight, the inner face does the flip, so the
two never fight over the same transform. */
/* The size is a variable because solitaire has seven columns to fit and
blackjack has two hands. Everything below is in terms of it, so a table can
shrink its cards without a second set of rules. */
.pete-card {
perspective: 700px;
height: 8.4rem;
width: 6rem;
height: var(--card-h, 8.4rem);
width: var(--card-w, 6rem);
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.pete-card-inner {
@@ -1078,6 +1081,210 @@ html[data-phase="night"] {
border-color: var(--accent);
}
/* ---- solitaire -----------------------------------------------------------
Seven columns, four foundations, a stock and a waste, all of which have to
fit across the felt on a phone as well as a desk. So the card size is one
variable derived from the width available, and every gap and fan below is
derived from *that* — the board scales as one thing rather than as nine
things that each stop fitting at a different width.
The cards themselves don't move by CSS here. A move in solitaire can take a
card from anywhere to anywhere, so the table re-renders the board and then
animates each card from where it just was (see the FLIP note in
solitaire.js). CSS's job is only to say where things sit. */
.pete-solitaire {
--card-w: clamp(2.5rem, 10.6vw, 5.2rem);
--card-h: calc(var(--card-w) * 1.4);
--fan-up: calc(var(--card-h) * 0.29); /* how much of a face-up card shows under the next */
--fan-down: calc(var(--card-h) * 0.14); /* a face-down one shows less: there's nothing to read */
}
/* An empty place a card could be: the stock when it's spent, a foundation
waiting for its ace, a column waiting for a king. Same footprint as a card,
so nothing on the board reflows when one empties. */
.pete-slot {
position: relative;
display: grid;
place-items: center;
height: var(--card-h);
width: var(--card-w);
flex: none;
border-radius: 0.55rem;
border: 2px dashed rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.12);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.18);
transition: border-color 0.18s ease, background 0.18s ease, transform 0.12s ease;
}
.pete-slot-glyph {
font-size: calc(var(--card-w) * 0.42);
line-height: 1;
color: rgba(255, 255, 255, 0.3);
}
.pete-slot-glyph[data-red="1"] { color: rgba(255, 170, 170, 0.35); }
/* A card sitting *in* a slot — the top of a foundation. The slot keeps its
footprint whether or not there's a card on it, so an ace going home doesn't
reflow the row, and a drop target has somewhere to be while it's empty. */
.pete-slot > .pete-card {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
}
/* The stock is a button, and it looks like the back of a card because that is
what it is: a pile you can turn over. */
.pete-stock {
border-style: solid;
border-color: rgba(0, 0, 0, 0.15);
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
linear-gradient(150deg, #b4553f, #8d3f2f);
cursor: pointer;
}
.pete-stock:hover { filter: brightness(1.08); }
.pete-stock:active { transform: translateY(1px) scale(0.98); }
/* Spent, but there's a pass left: it stops being a pile of cards and starts
being the gesture of turning the waste back over. */
.pete-stock[data-empty="1"] {
background: rgba(0, 0, 0, 0.12);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.25);
}
/* Spent, and no passes left. Not a button any more — and it says so by looking
like the dead thing it is rather than by silently refusing clicks. */
.pete-stock[data-dead="1"] {
cursor: default;
opacity: 0.4;
}
.pete-stock[data-dead="1"]:hover { filter: none; }
.pete-stock[data-dead="1"]:active { transform: none; }
.pete-slot-count {
position: absolute;
bottom: -0.5rem;
left: 50%;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
padding: 0.05rem 0.5rem;
font-size: 0.7rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
.pete-slot-recycle { color: rgba(255, 255, 255, 0.45); }
.pete-slot-recycle svg { height: 1.4rem; width: 1.4rem; }
/* The waste. Three cards fanned sideways, the top one on the right — you can
see what's under it, and only the top one is yours to take. */
.pete-waste {
position: relative;
display: flex;
height: var(--card-h);
min-width: var(--card-w);
}
.pete-waste .pete-card + .pete-card {
margin-left: calc(var(--card-w) * -0.72);
}
.pete-waste .pete-card { position: relative; }
/* The seven columns. They share the width evenly and never scroll: a board you
have to scroll sideways is a board you can't plan on. */
.pete-tableau {
display: grid;
grid-template-columns: repeat(7, var(--card-w));
justify-content: space-between;
gap: 0.5rem;
align-items: start;
min-height: calc(var(--card-h) * 2.6);
}
.pete-col {
display: flex;
flex-direction: column;
align-items: center;
min-height: var(--card-h);
}
/* Cards overlap up the column, and how much of one shows depends on whether
there's anything to see. The rule keys off the card *above*, which is why
it's an adjacent-sibling selector and not something the JS has to compute. */
.pete-col .pete-card { position: relative; }
.pete-col .pete-card[data-face="down"] + .pete-card {
margin-top: calc(var(--fan-down) - var(--card-h));
}
.pete-col .pete-card[data-face="up"] + .pete-card {
margin-top: calc(var(--fan-up) - var(--card-h));
}
/* A card you can pick up says so. */
.pete-card[data-live="1"] { cursor: pointer; }
.pete-card[data-live="1"]:hover .pete-card-front {
filter: brightness(1.06);
}
/* The card (or run) in your hand: lifted off the felt, and glowing, so it's
obvious what a click on a column is about to move. */
.pete-card[data-held="1"] {
z-index: 5;
transform: translateY(-0.55rem);
transition: transform 0.14s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pete-card[data-held="1"] .pete-card-front {
box-shadow:
0 3px 0 rgba(0,0,0,0.18),
0 10px 22px rgba(0,0,0,0.32),
0 0 0 3px rgba(242, 181, 61, 0.9);
}
/* A place the held card would actually go. Lit *before* you click it, because
the alternative is learning the rules by being told no. */
.pete-slot[data-drop="1"],
.pete-col[data-drop="1"] .pete-slot,
.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front {
border-color: rgba(242, 181, 61, 0.9);
box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.45);
}
.pete-slot[data-drop="1"],
.pete-col[data-drop="1"] .pete-slot {
background: rgba(242, 181, 61, 0.12);
}
/* A move that won't go. Said in the one language a board can speak. */
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
/* A card arriving on a foundation lands with a flash: it is the only move in
the game that pays you, so it is the only one that gets a noise. */
.pete-home-flash { animation: pete-home 0.5s ease-out; }
@keyframes pete-home {
0% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.9); }
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
}
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
it's a column down the right of the felt; on a narrow one it lies down and
sits under the board. */
.pete-rail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 1rem 1.5rem;
}
@media (min-width: 1024px) {
.pete-rail {
flex-direction: column;
justify-content: flex-start;
width: 9.5rem;
gap: 1.5rem;
}
}
/* In the rail the rack is just another thing in a column, so it drops the
absolute positioning it uses when it's pinned to the corner of a felt. */
.pete-rack[data-at="rail"] {
position: static;
justify-content: center;
}
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,
@@ -1089,6 +1296,9 @@ html[data-phase="night"] {
.pete-shake,
.pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; }
.pete-nope,
.pete-home-flash { animation: none; }
.pete-card[data-held="1"] { transition: none; }
}
}