games: no mercy on the felt, and the bill that went to the wrong window
The engine has been able to play No Mercy since aca523e. Now a browser can.
The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.
The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.
A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.
Two bugs, both found in a browser and neither findable anywhere else:
The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.
And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -217,6 +217,11 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Live reports whether a seat is still in the game. The felt needs it: a seat the
|
||||
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
|
||||
// indistinguishable from the one that just went out and won.
|
||||
func (s State) Live(seat int) bool { return s.live(seat) }
|
||||
|
||||
// alive lists the seats still in the game.
|
||||
func (s State) alive() []int {
|
||||
var out []int
|
||||
|
||||
@@ -80,6 +80,8 @@ type gamesPage struct {
|
||||
Quizzes []trivia.Tier // trivia's three difficulties
|
||||
Rungs int // how long the trivia ladder is
|
||||
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
||||
NoMercy []uno.Tier // the same three, playing the other rules
|
||||
MercyLimit int // the hand that ends you in No Mercy
|
||||
Stakes []holdem.Tier // hold'em's three tables, by blinds
|
||||
MaxBots int // how many seats hold'em will fill with bots
|
||||
}
|
||||
@@ -153,6 +155,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
||||
Quizzes: trivia.Tiers,
|
||||
Rungs: trivia.Rungs,
|
||||
Tables: uno.Tiers,
|
||||
NoMercy: uno.NoMercyTiers,
|
||||
MercyLimit: uno.MercyLimit,
|
||||
Stakes: holdem.Tiers,
|
||||
MaxBots: holdem.MaxBots,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type unoSeatView struct {
|
||||
Cards int `json:"cards"`
|
||||
You bool `json:"you"`
|
||||
Uno bool `json:"uno"` // down to one card
|
||||
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
|
||||
}
|
||||
|
||||
// unoView is a game as its player may see it.
|
||||
@@ -65,6 +66,11 @@ type unoView struct {
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"`
|
||||
|
||||
// No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking
|
||||
// pays it, and while it stands it is the only thing on the table that matters —
|
||||
// so the felt has to be able to say what it is.
|
||||
Pending int `json:"pending"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
||||
Phase string `json:"phase"`
|
||||
@@ -83,6 +89,7 @@ func viewUno(g uno.State) unoView {
|
||||
Deck: g.Left(),
|
||||
Turn: g.Turn,
|
||||
Dir: g.Dir,
|
||||
Pending: g.Pending,
|
||||
Bet: g.Bet,
|
||||
Pays: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
@@ -92,18 +99,27 @@ func viewUno(g uno.State) unoView {
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
// An empty hand is a seat that went out — *unless* the mercy rule took it, in
|
||||
// which case an empty hand is a grave. Those two look identical from the count
|
||||
// alone, which is why a buried seat is asked about rather than inferred.
|
||||
for i, n := range g.Counts() {
|
||||
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
|
||||
live := g.Live(i)
|
||||
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live}
|
||||
if i == uno.You {
|
||||
seat.Name = "You"
|
||||
} else if i-1 < len(g.Bots) {
|
||||
seat.Name = g.Bots[i-1]
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
if n == 0 {
|
||||
if live && n == 0 {
|
||||
v.Winner = i
|
||||
}
|
||||
}
|
||||
// And you can win a No Mercy table without ever going out: outlive it, and the
|
||||
// last seat standing is you, with a hand still in it.
|
||||
if v.Winner < 0 && g.Outcome.Won() {
|
||||
v.Winner = uno.You
|
||||
}
|
||||
for _, c := range g.Hands[uno.You] {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
@@ -248,6 +264,10 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||
msg = "play the card you drew, or pass"
|
||||
case errors.Is(err, uno.ErrCantPass):
|
||||
msg = "draw first, then you can pass"
|
||||
case errors.Is(err, uno.ErrMustStack):
|
||||
msg = "answer the stack with a draw card, or take it"
|
||||
case errors.Is(err, uno.ErrNoStack):
|
||||
msg = "there's nothing pointed at you to take"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
|
||||
@@ -145,3 +145,71 @@ func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat the mercy rule has buried holds no cards. So does a seat that has just
|
||||
// gone out and won. The view has to tell them apart, because everything it says
|
||||
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
|
||||
// player is looking at a payout or a grave.
|
||||
//
|
||||
// This is the whole reason the view asks the engine (Live) instead of inferring it
|
||||
// from a count of zero.
|
||||
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
|
||||
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bury seat 2, the way the engine does: no cards, and out.
|
||||
g.Hands[2] = nil
|
||||
g.Out[2] = true
|
||||
|
||||
v := viewUno(g)
|
||||
if !v.Seats[2].Out {
|
||||
t.Error("a buried seat must say so; the felt draws it as a grave")
|
||||
}
|
||||
if v.Seats[2].Uno {
|
||||
t.Error("a buried seat is not one card away from going out")
|
||||
}
|
||||
if v.Winner == 2 {
|
||||
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
|
||||
}
|
||||
if v.Winner != -1 {
|
||||
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
|
||||
}
|
||||
|
||||
// And the other ending only this deck has: you win by outliving the table, with
|
||||
// a hand still in front of you.
|
||||
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
|
||||
if v := viewUno(g); v.Winner != uno.You {
|
||||
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
|
||||
}
|
||||
}
|
||||
|
||||
// The bill a stack has run up is the only thing on the table while it stands, so it
|
||||
// has to reach the browser. It is a No Mercy field on a struct the normal game
|
||||
// shares, which is exactly the kind of field that quietly never gets copied across.
|
||||
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Pending = 6
|
||||
g.Phase = uno.PhaseStack
|
||||
|
||||
v := viewUno(g)
|
||||
if v.Pending != 6 {
|
||||
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
||||
}
|
||||
if v.Phase != string(uno.PhaseStack) {
|
||||
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTier(t *testing.T, slug string) uno.Tier {
|
||||
t.Helper()
|
||||
tier, err := uno.TierBySlug(slug)
|
||||
if err != nil {
|
||||
t.Fatalf("no tier %q: %v", slug, err)
|
||||
}
|
||||
return tier
|
||||
}
|
||||
|
||||
@@ -1750,11 +1750,134 @@ html[data-phase="night"] {
|
||||
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
|
||||
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
|
||||
|
||||
/* ---- No Mercy -------------------------------------------------------------
|
||||
A rules dial, not a fourth table, and the felt says so: the same table, the
|
||||
same cards, plus six faces the normal box doesn't print and one thing that
|
||||
can be pointed at you — a stack of draw cards with a bill on it.
|
||||
|
||||
Sizing note, and it is the rule this table was taught the hard way: a card is
|
||||
sized by --uno-h/--uno-w and nothing else. These faces set a *font*, never a
|
||||
box, so a "DISCARD ALL" is the same card as a "7" at every width. */
|
||||
|
||||
/* The long faces. "DISCARD ALL" does not fit across an oval at card size, so it
|
||||
wraps — and the oval is tilted, so it wraps on the tilt, which is what the
|
||||
printed card does too. Specificity has to clear the wild rule above (a
|
||||
reverse-draw-four is a wild), hence the .pete-uno-card in front. */
|
||||
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 1.05rem; }
|
||||
.pete-uno-card .pete-uno-oval[data-size="words"] {
|
||||
font-size: 0.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
/* The wild draws glow.
|
||||
|
||||
These are the cards that decide a game, and No Mercy prints a *coloured* +4
|
||||
right next to the wild one, so at a glance in a hand of twenty they have to be
|
||||
tellable apart. The aura is a sibling behind the card rather than a shadow on
|
||||
it: a box-shadow here would fight the one that says which colour a played wild
|
||||
was named as (data-named, above), and lose.
|
||||
|
||||
It sits under the card (z-index -1) and does not take clicks, so the card is
|
||||
still the button. */
|
||||
.pete-uno-card[data-glow="1"] { position: relative; z-index: 0; }
|
||||
.pete-uno-card[data-glow="1"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.35rem;
|
||||
z-index: -1;
|
||||
border-radius: 0.9rem;
|
||||
pointer-events: none;
|
||||
background: conic-gradient(from 0deg,
|
||||
#d64545, #e0b02c, #46a86b, #3d7fd6, #d64545);
|
||||
filter: blur(7px);
|
||||
opacity: 0.75;
|
||||
animation: pete-uno-glow 3.2s linear infinite;
|
||||
}
|
||||
/* The shimmer across the face itself: a slow sweep of light, the way a foil card
|
||||
catches the room when you tilt it. */
|
||||
.pete-uno-card[data-glow="1"] .pete-uno-face::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(115deg,
|
||||
transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%);
|
||||
background-size: 260% 100%;
|
||||
animation: pete-uno-shine 2.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pete-uno-glow {
|
||||
to { transform: rotate(1turn); }
|
||||
}
|
||||
@keyframes pete-uno-shine {
|
||||
0% { background-position: 140% 0; }
|
||||
55% { background-position: -40% 0; }
|
||||
100% { background-position: -40% 0; }
|
||||
}
|
||||
|
||||
/* The bill. While a stack stands it is the only thing on the table that matters,
|
||||
so it is loud, and it is red. */
|
||||
.pete-uno-pending {
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.18rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: #cc3d4a;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
|
||||
animation: pete-uno-bill 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pete-uno-bill {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
|
||||
/* A seat the mercy rule buried. It stays at the table — the felt would jump if
|
||||
it didn't, and you want to see who's gone — but it is plainly out. */
|
||||
.pete-uno-seat[data-out="1"] {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
||||
|
||||
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
||||
.pete-seg {
|
||||
display: inline-flex;
|
||||
padding: 0.2rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||
}
|
||||
.pete-seg-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.pete-seg-btn:hover { color: var(--ink); }
|
||||
.pete-seg-btn[data-on="1"] {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 0 rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
scrolling sideways, which is the thing to check at 390px with a game on it.
|
||||
No Mercy deals bigger hands than that — it is a deck built to bury you — so
|
||||
this is the table where a twenty-card hand has to wrap and not overflow. */
|
||||
@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-card .pete-uno-oval[data-size="words"] { font-size: 0.42rem; }
|
||||
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 0.9rem; }
|
||||
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
|
||||
takes its size from them, so sizing the box alone left a full-size card
|
||||
hanging out of a small hole, on top of the colour in play. */
|
||||
@@ -1783,6 +1906,11 @@ html[data-phase="night"] {
|
||||
.pete-clock[data-hot="1"],
|
||||
.pete-answer[data-state="wrong"] { animation: none; }
|
||||
.pete-rung { transition: none; }
|
||||
/* The wild draws keep their glow — it is what tells the card apart from the
|
||||
coloured +4 beside it, so it is information — but it stops moving. */
|
||||
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
||||
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
||||
.pete-uno-pending { animation: none; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -514,9 +514,30 @@
|
||||
|
||||
// ---- talking to the table --------------------------------------------------
|
||||
|
||||
// lock is busy, said out loud on the buttons.
|
||||
//
|
||||
// send() drops a click that arrives while a move is in flight, and it is right
|
||||
// to: the board on screen during a script is a board the server has already
|
||||
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
|
||||
// enabled through the whole deal animation, so clicking Leave while the cards
|
||||
// were still flying did nothing at all: no move, no message, no reason given.
|
||||
// (The action buttons never had this problem; panels() hides the whole row when
|
||||
// it isn't your turn.) A button that looks alive and does nothing has lied to
|
||||
// you, so the lock lives on the buttons and not only in the variable.
|
||||
//
|
||||
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
|
||||
// panels() owns that, so this only ever adds a reason to be disabled.
|
||||
function lock(on) {
|
||||
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
|
||||
if (b) b.disabled = on;
|
||||
});
|
||||
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
|
||||
}
|
||||
|
||||
function send(body, msgEl) {
|
||||
if (busy) return Promise.resolve();
|
||||
busy = true;
|
||||
lock(true);
|
||||
say(msgEl, "");
|
||||
// Whatever the last hand said about itself stops being true the moment you do
|
||||
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
||||
@@ -540,7 +561,7 @@
|
||||
});
|
||||
})
|
||||
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||
.then(function () { busy = false; });
|
||||
.then(function () { busy = false; lock(false); });
|
||||
}
|
||||
|
||||
// render0 is the table with nobody at it.
|
||||
|
||||
@@ -39,10 +39,16 @@
|
||||
var tableEl = root.querySelector("[data-table-name]");
|
||||
|
||||
var wildEl = root.querySelector("[data-wild]");
|
||||
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
|
||||
// comes first in the document. See the note in uno.html.
|
||||
var billEl = root.querySelector("[data-bill]");
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var drawBtn = root.querySelector("[data-draw]");
|
||||
var passBtn = root.querySelector("[data-pass]");
|
||||
var takeBtn = root.querySelector("[data-take]");
|
||||
var takeN = root.querySelector("[data-take-n]");
|
||||
var hintEl = root.querySelector("[data-hint]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
@@ -81,14 +87,45 @@
|
||||
|
||||
// ---- drawing the cards -----------------------------------------------------
|
||||
|
||||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
||||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
||||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
||||
// FACES is how each printed face is drawn. The engine sends a word ("skip",
|
||||
// "+2", "discard all"); this is the picture of it.
|
||||
//
|
||||
// The corner is not always the same mark as the middle. "DISCARD ALL" fits
|
||||
// across an oval and does not fit in a corner, so the long faces carry a short
|
||||
// mark for the corners — which is what a real deck does too. `size` is a class,
|
||||
// never a height: a card is sized by --uno-h/--uno-w and nothing else, and a
|
||||
// face that sets its own font in pixels is a face that breaks on a phone.
|
||||
var FACES = {
|
||||
"skip": { mid: "🚫", corner: "🚫" },
|
||||
"reverse": { mid: "⇄", corner: "⇄" },
|
||||
"+2": { mid: "+2", corner: "+2" },
|
||||
"wild": { mid: "★", corner: "★" },
|
||||
"+4": { mid: "+4", corner: "+4" },
|
||||
|
||||
// No Mercy.
|
||||
"skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" },
|
||||
"discard all": { mid: "DISCARD ALL", corner: "≡", size: "words" },
|
||||
"rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" },
|
||||
"+6": { mid: "+6", corner: "+6" },
|
||||
"+10": { mid: "+10", corner: "+10", size: "mid" },
|
||||
// The roulette names a colour and turns cards over until it comes up. The
|
||||
// wheel behind the oval is already the picture of that; the mark is the
|
||||
// question the wheel is asking.
|
||||
"roulette": { mid: "?", corner: "?" },
|
||||
};
|
||||
|
||||
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
|
||||
|
||||
// The faces that make somebody draw. On a wild these are the cards worth the
|
||||
// most and worth watching for, so they get the glow — and it is the one thing
|
||||
// that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance.
|
||||
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
|
||||
|
||||
// card builds one UNO card. The oval across the middle at an angle is the whole
|
||||
// look of the thing — without it a card reads as a coloured button.
|
||||
function card(c, opts) {
|
||||
opts = opts || {};
|
||||
var f = faceOf(c);
|
||||
var el = document.createElement(opts.button ? "button" : "div");
|
||||
if (opts.button) el.type = "button";
|
||||
el.className = "pete-uno-card";
|
||||
@@ -99,7 +136,8 @@
|
||||
|
||||
var oval = document.createElement("span");
|
||||
oval.className = "pete-uno-oval";
|
||||
oval.textContent = GLYPHS[c.value] || c.value;
|
||||
if (f.size) oval.dataset.size = f.size;
|
||||
oval.textContent = f.mid;
|
||||
face.appendChild(oval);
|
||||
|
||||
// The corners, as printed.
|
||||
@@ -107,7 +145,7 @@
|
||||
var corner = document.createElement("span");
|
||||
corner.className = "pete-uno-corner";
|
||||
corner.dataset.at = at;
|
||||
corner.textContent = GLYPHS[c.value] || c.value;
|
||||
corner.textContent = f.corner;
|
||||
corner.setAttribute("aria-hidden", "true");
|
||||
face.appendChild(corner);
|
||||
});
|
||||
@@ -121,16 +159,33 @@
|
||||
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
||||
}
|
||||
|
||||
// The wild draws glow. They are the cards that decide a game — a +4 you were
|
||||
// holding is four cards somebody else is about to be holding — and in No Mercy
|
||||
// there are four of them to tell apart from the coloured +4 sitting next to
|
||||
// them in the same hand. The glow is what says "this one is the nasty one".
|
||||
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
|
||||
|
||||
el.appendChild(face);
|
||||
el.setAttribute("aria-label", label(c));
|
||||
return el;
|
||||
}
|
||||
|
||||
// label is what a screen reader says. Note "+4" is two different cards — No
|
||||
// Mercy prints a coloured one next to the wild one — so the name comes from the
|
||||
// face *and* whether it's a wild, never from the face alone.
|
||||
var SPOKEN = {
|
||||
"+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
|
||||
"wild": "wild", "skip": "skip", "reverse": "reverse",
|
||||
"skip all": "skip everyone", "discard all": "discard all",
|
||||
"rev +4": "reverse and draw four", "roulette": "colour roulette",
|
||||
};
|
||||
|
||||
function label(c) {
|
||||
var v = c.value === "+2" ? "draw two" :
|
||||
c.value === "+4" ? "wild draw four" :
|
||||
c.value === "wild" ? "wild" : c.value;
|
||||
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||||
var v = SPOKEN[c.value] || c.value;
|
||||
if (c.wild) {
|
||||
var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild"
|
||||
return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||||
}
|
||||
return c.color + " " + v;
|
||||
}
|
||||
|
||||
@@ -159,6 +214,7 @@
|
||||
el.dataset.seat = String(i);
|
||||
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
||||
if (s.uno) el.dataset.uno = "1";
|
||||
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
|
||||
|
||||
var fan = document.createElement("div");
|
||||
fan.className = "pete-uno-fan";
|
||||
@@ -179,7 +235,7 @@
|
||||
var count = document.createElement("p");
|
||||
count.className = "pete-uno-count";
|
||||
count.dataset.count = "";
|
||||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
el.appendChild(count);
|
||||
|
||||
seatsEl.appendChild(el);
|
||||
@@ -234,10 +290,22 @@
|
||||
colourEl.textContent = v.color;
|
||||
colourEl.dataset.c = v.color;
|
||||
feltEl.dataset.c = v.color;
|
||||
pending(v.phase === "done" ? 0 : v.pending || 0);
|
||||
}
|
||||
|
||||
var feltEl = root.querySelector(".pete-uno");
|
||||
|
||||
// pending is the bill a stack has run up. It is on the felt, on the button and
|
||||
// in the turn line, because it is the only thing on the table while it stands:
|
||||
// a player who misses it plays into a hand that is about to grow by ten.
|
||||
function pending(n) {
|
||||
if (billEl) {
|
||||
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
|
||||
billEl.classList.toggle("hidden", n <= 0);
|
||||
}
|
||||
if (takeN) takeN.textContent = String(n);
|
||||
}
|
||||
|
||||
function renderRail(v) {
|
||||
if (!v) {
|
||||
paysEl.textContent = "—";
|
||||
@@ -259,6 +327,7 @@
|
||||
var yours = v.turn === 0;
|
||||
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
||||
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
||||
if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
|
||||
turnEl.textContent = who;
|
||||
turnEl.dataset.you = yours ? "1" : "0";
|
||||
var n = v.hand.length;
|
||||
@@ -278,6 +347,11 @@
|
||||
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
||||
text = v.seats[v.winner].name + " went out first.";
|
||||
}
|
||||
// No Mercy has two endings the normal deck hasn't got: you can lose without
|
||||
// anybody going out (the mercy rule got you), and win without going out at all
|
||||
// (it got everybody else).
|
||||
if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done.";
|
||||
if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! 🎉";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
@@ -295,24 +369,45 @@
|
||||
var live = !!game && game.phase !== "done";
|
||||
var yours = live && game.turn === 0;
|
||||
var drawn = live && game.phase === "drawn";
|
||||
var stack = live && game.phase === "stack";
|
||||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||||
});
|
||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
||||
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
|
||||
// moves that exist are answering it with a draw card or taking the lot.
|
||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
|
||||
if (passBtn) passBtn.disabled = busy || !yours;
|
||||
if (deckEl) deckEl.disabled = busy || !yours || drawn;
|
||||
if (takeBtn) takeBtn.disabled = busy || !yours;
|
||||
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
|
||||
}
|
||||
|
||||
// HINTS is the line above the buttons. A game with a stack in it has a rule the
|
||||
// player has just met for the first time, and the table should say what it is
|
||||
// rather than leave them clicking a hand where nothing lights up.
|
||||
var HINTS = {
|
||||
play: "Click a card that lights up. Nothing lights up? Draw one.",
|
||||
drawn: "You drew a card you can play. Play it, or keep it and pass.",
|
||||
stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
|
||||
};
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase !== "done";
|
||||
var drawn = live && v.phase === "drawn";
|
||||
var stack = live && v.phase === "stack";
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
hideWild();
|
||||
|
||||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
||||
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
|
||||
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
|
||||
// No Mercy has no pass: you drew until you found a card that plays, and
|
||||
// playing it is the price of having drawn. The server refuses one anyway;
|
||||
// this is the table not offering a button that cannot work.
|
||||
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
|
||||
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
|
||||
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
|
||||
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
|
||||
controls();
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
@@ -352,7 +447,8 @@
|
||||
if (!el) return;
|
||||
var fan = el.querySelector(".pete-uno-fan");
|
||||
var count = el.querySelector("[data-count]");
|
||||
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
|
||||
var buried = el.dataset.out === "1"; // an empty hand here is a grave, not a win
|
||||
if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards");
|
||||
if (!fan) return;
|
||||
var show = Math.min(left, FAN);
|
||||
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
||||
@@ -442,7 +538,9 @@
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||
}
|
||||
var punished = e.kind === "forced";
|
||||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
||||
// A forced draw is also how a stack ends: somebody stopped answering
|
||||
// and paid the bill, so the bill is gone.
|
||||
if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); }
|
||||
return Promise.all(backs).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
@@ -453,6 +551,65 @@
|
||||
});
|
||||
}
|
||||
|
||||
case "stack":
|
||||
// The bill has moved on to somebody, and N is what it stands at now —
|
||||
// not what was just added to it. A +2 answered with a +10 is a seat
|
||||
// looking at twelve cards.
|
||||
pending(e.n);
|
||||
spotlight(e.seat);
|
||||
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||||
|
||||
case "skipall":
|
||||
// The turn does not move: it comes straight back to the seat that
|
||||
// played it, which is the whole card.
|
||||
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
|
||||
|
||||
case "discard": {
|
||||
// A whole colour left a hand at once. If it was your hand, those cards
|
||||
// are on the table in front of you — so they go before the flight, or
|
||||
// they sit there looking like they were dumped twice.
|
||||
if (e.seat === 0 && e.color) {
|
||||
handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) {
|
||||
el.style.visibility = "hidden";
|
||||
});
|
||||
}
|
||||
bump(e.seat, e.left);
|
||||
var dumpFrom = seatAnchor(e.seat);
|
||||
var dumped = [];
|
||||
for (var d = 0; d < Math.min(e.n, 4); d++) {
|
||||
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
|
||||
}
|
||||
badge(e.seat, "−" + e.n + " " + (e.color || ""));
|
||||
return Promise.all(dumped).then(function () { return wait(220); });
|
||||
}
|
||||
|
||||
case "roulette": {
|
||||
// They turn cards over until your colour comes up, and they keep every
|
||||
// one of them. N is how deep it went.
|
||||
spotlight(e.seat);
|
||||
var spinTo = seatAnchor(e.seat);
|
||||
var spun = [];
|
||||
for (var r = 0; r < Math.min(e.n, 4); r++) {
|
||||
spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
|
||||
}
|
||||
badge(e.seat, "Roulette +" + e.n, "bad");
|
||||
return Promise.all(spun).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
return wait(400);
|
||||
});
|
||||
}
|
||||
|
||||
case "mercy": {
|
||||
// Twenty-five cards, and they are out of the game for good. It is the
|
||||
// one beat in this script allowed to take its time.
|
||||
var grave = seatEl(e.seat);
|
||||
if (grave) grave.dataset.out = "1";
|
||||
bump(e.seat, 0);
|
||||
pending(0); // a dead seat pays no bill and leaves none behind
|
||||
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
||||
}
|
||||
|
||||
case "skip":
|
||||
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
||||
|
||||
@@ -593,6 +750,15 @@
|
||||
move({ kind: "draw" });
|
||||
}
|
||||
|
||||
// Taking the stack: you give in, you eat every card it has run up, and you lose
|
||||
// your turn. It is a move of its own, not a draw — see the engine's MoveTake.
|
||||
if (takeBtn) {
|
||||
takeBtn.addEventListener("click", function () {
|
||||
if (busy || !game || game.turn !== 0 || game.phase !== "stack") return;
|
||||
move({ kind: "take" });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- talking to the table ----------------------------------------------------
|
||||
|
||||
// send takes the table away for the whole move — not just the request, but the
|
||||
@@ -647,6 +813,38 @@
|
||||
});
|
||||
});
|
||||
|
||||
// The rules switch. It is a different dial from the table size — the size is
|
||||
// what you're paid for, the deck is what you're playing with — so throwing it
|
||||
// swaps the three tables for the other three rather than becoming a fourth one.
|
||||
var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };
|
||||
|
||||
function pickRules(which, slug) {
|
||||
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.rules === which ? "1" : "0";
|
||||
});
|
||||
root.querySelectorAll("[data-grid]").forEach(function (g) {
|
||||
g.classList.toggle("hidden", g.dataset.grid !== which);
|
||||
});
|
||||
root.querySelectorAll("[data-note]").forEach(function (p) {
|
||||
p.classList.toggle("hidden", p.dataset.note !== which);
|
||||
});
|
||||
pickTier(slug || DEFAULT_TABLE[which]);
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-rules]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickRules(b.dataset.rules);
|
||||
});
|
||||
});
|
||||
|
||||
// A game that just ended leaves the betting panel on the table it was played at:
|
||||
// the commonest thing a player wants after a hand of No Mercy is another one.
|
||||
function rulesFor(v) {
|
||||
if (!v || !v.tier) return;
|
||||
pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug);
|
||||
}
|
||||
|
||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||
// [data-chip] spans in the rack are the house's, and the house is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
@@ -689,7 +887,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
pickRules("normal");
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||
One to three bots, and the more of them there are the more it pays: up to 3.6× for
|
||||
beating a full table. Anybody else going out first takes your stake.
|
||||
One to three bots, and the more of them there are the more it pays: up to 4.1× for
|
||||
beating a full table. Anybody else going out first takes your stake. Or throw the
|
||||
No Mercy switch: 168 cards, draws that stack, and twenty-five in your hand puts you out.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
<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>
|
||||
|
||||
@@ -100,7 +109,7 @@
|
||||
<!-- 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 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.
|
||||
</p>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
@@ -109,6 +118,14 @@
|
||||
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">
|
||||
@@ -122,7 +139,19 @@
|
||||
<!-- 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}}"
|
||||
@@ -138,6 +167,25 @@
|
||||
</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)]">{{printf "%.1f" .Base}}×</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>
|
||||
@@ -162,9 +210,16 @@
|
||||
Deal
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@@ -12,6 +12,33 @@ This plan reuses that seam wholesale and does not invent a second one.
|
||||
|
||||
A multi-session build. This section is the handover; read it before anything else.
|
||||
|
||||
### Start here (next session)
|
||||
|
||||
**Everything builds, everything is played, and nothing since blackjack is
|
||||
deployed.** Six games (seven, counting the No Mercy dial) sit on main and the live
|
||||
casino is still blackjack alone. Deploying is the next job — see "Next, in order".
|
||||
|
||||
**The money finding, and the thing to actually remember:** the *normal* UNO tiers
|
||||
had been mispriced for a while, and it wasn't No Mercy's fault. They were set
|
||||
against a naive win rate of 43/32/27%; re-measuring says 40.3/29.2/23.3%. The bots
|
||||
improved at some point after the multiples were written down and nobody re-ran the
|
||||
measurement — which §0 already warned about ("the bots and the tiers are a pair").
|
||||
Table and Full House had quietly been charging an **18–19% house edge instead of
|
||||
8%**. All six tiers are repriced off a fresh measurement, and
|
||||
**`TestTheMultiplesAreStillPriced`** now fails the build if any of them drift out
|
||||
of a 2–14% band. It is the test the normal tiers never had, which is precisely how
|
||||
they drifted. *Any change to the bots, the deck, or a rule re-opens this.*
|
||||
|
||||
And the counterintuitive one, which would have shipped wrong if it had been
|
||||
guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% vs
|
||||
40.3% heads-up), so it **pays less** — 2.0/3.1/3.8 against the normal deck's new
|
||||
2.4/3.3/4.1. The mercy rule doesn't care whose hand hits twenty-five; it kills
|
||||
*bots* too, and every bot it buries is one fewer seat that can beat you to the last
|
||||
card. A deck built to be merciless is merciless mostly to the table.
|
||||
|
||||
Still un-deployed: **only blackjack is live.** The other five games (six, now, if
|
||||
you count the No Mercy dial) are on main and have never been deployed.
|
||||
|
||||
### Decisions taken (these close §9's open questions)
|
||||
|
||||
- **Chips are 1:1 with euros.** No second denomination.
|
||||
@@ -400,6 +427,68 @@ A multi-session build. This section is the handover; read it before anything els
|
||||
in.**
|
||||
|
||||
|
||||
- **No Mercy, on the felt, and it plays.** *(2026-07-14. The engine landed in
|
||||
`aca523e`; this is the half you can see. Driven in a browser, and that is where
|
||||
both bugs were.)*
|
||||
- **It is one switch, not a fourth table.** The betting panel grows a two-way
|
||||
segmented control (UNO / No Mercy) that swaps the three tier cards for the other
|
||||
three. The table size stays the tier — it is what you're paid for — and the deck
|
||||
is the other dial. A settled game leaves the panel on the table you just played,
|
||||
because the commonest thing anybody wants after a hand of No Mercy is another one.
|
||||
- **The six faces the normal box doesn't print.** `FACES` in `uno.js` is one table
|
||||
of {middle mark, corner mark, size}: the long ones ("DISCARD ALL", "SKIP ALL")
|
||||
wrap across the tilted oval and carry a short mark for the corners, exactly as a
|
||||
real deck does. Sized by a *font class*, never a box — `--uno-h`/`--uno-w` remain
|
||||
the only thing that decides how big a card is, which is the rule the phone bug
|
||||
taught this table.
|
||||
- **The wild draws glow** (the user asked, and it turned out to be load-bearing).
|
||||
A rainbow aura behind the card plus a slow shine across it, on any wild that
|
||||
makes somebody draw. It is the one thing that tells the *wild* +4 apart from No
|
||||
Mercy's **coloured** +4 sitting next to it in the same hand. It is a sibling
|
||||
behind the card, not a shadow on it, because a box-shadow there would fight the
|
||||
one that says which colour a played wild was named as.
|
||||
- **The stack says what the bill is, in three places**: a red pill on the felt, the
|
||||
turn line ("+6 — stack it, or take it"), and the button itself ("Take 6"). Under
|
||||
a stack the hand only lights the cards that answer it, the deck is dead (you
|
||||
cannot draw your way out of a bill), and the hint line changes to say so.
|
||||
- **A buried seat is not an empty one.** This is the whole trap of the mercy rule
|
||||
in the view layer: a seat killed at twenty-five holds zero cards, and so does a
|
||||
seat that just went out and *won*. The view asks the engine (`State.Live`) rather
|
||||
than inferring from a count of zero, so the winner is never the corpse.
|
||||
`TestABuriedSeatIsNotTheWinner` is that, and it also covers the ending only this
|
||||
deck has — you can win by **outliving** the table, with a hand still in front of
|
||||
you ("Last one standing!"), and you can lose without anybody going out at all.
|
||||
- **Two bugs, and a browser found both.**
|
||||
1. **The bill was writing into the chip bar.** The felt's pending pill was
|
||||
`[data-pending]` — and so is the chip bar's "chips still in flight from a
|
||||
buy-in" readout, and the chip bar lives *inside* `[data-uno]` and comes first
|
||||
in the document. So `root.querySelector` found the wrong one: a stack quietly
|
||||
overwrote the escrow message and never appeared on the felt at all. It is
|
||||
`[data-bill]` now. **A table's own attributes are not a private namespace —
|
||||
the chip bar is in every one of these roots.**
|
||||
2. **Hold'em let you click a button that did nothing** (found while re-driving
|
||||
poker, below). `send()` drops a click that arrives while a move is in flight,
|
||||
which is right — the board on screen during a script is a board the server has
|
||||
already moved past — but the *between-hands* buttons (Deal, Leave, Top up)
|
||||
stayed enabled through the whole deal animation. Clicking Leave while the
|
||||
cards were still flying did nothing: no move, no message, no reason. The lock
|
||||
lives on the buttons now (`lock()`), not only in the `busy` variable.
|
||||
- **Driven in a browser, 2026-07-14.** A Full House game stacked a +6 onto us and
|
||||
the felt said so; taking it worked; a bot went to twenty-five and the seat went
|
||||
grey and said "Buried"; a Duel was won by outliving the table and paid 1,950 on a
|
||||
1,000 stake (2.0× is 1,000 of winnings, less the 5% rake — the quote and the
|
||||
payout agreed, as `Pays()` requires). A 21-card hand at 390px wraps to five rows
|
||||
with every card painted and no sideways scroll. The normal deck still plays and
|
||||
still pays (a Table win on 1,200 paid 3,822). Console silent.
|
||||
|
||||
- **Hold'em, re-driven on the 20M-hand policy.** *(2026-07-14, and it closes Phase
|
||||
4.)* The policy is a data file, so the check was whether the bots still play a real
|
||||
game off it and the money still conserves: sat down for 100 at the 1/2 table, six
|
||||
hands (won a pot with a straight, lost one at showdown, folded the rest), got up
|
||||
with 61, and 4,900 + 61 came back as 4,961 — conserved to the chip. The re-drive is
|
||||
also what turned up the dead-button bug above, which is the argument for the rule:
|
||||
**re-drive after a policy change even though "only data" moved.**
|
||||
|
||||
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
|
||||
browser. The bots had to be retrained from scratch — see below, it is the whole
|
||||
story of this phase.)*
|
||||
@@ -479,48 +568,16 @@ A multi-session build. This section is the handover; read it before anything els
|
||||
size, with chips scaled to match. The bet total hangs *below* the ring
|
||||
(`.pete-spot-total`), which is the existing rule for exactly this reason.
|
||||
|
||||
### Pick this up here — a 20M-hand policy is still training
|
||||
|
||||
The `policy.gob` on main is a **300,000-hand run** — a placeholder. It works (95%
|
||||
heads-up hit rate, and the bots play a real game off it), but it is thin: 4,129
|
||||
nodes. A **20,000,000-hand run** was started on millenia on 2026-07-14 and needs
|
||||
collecting:
|
||||
|
||||
```sh
|
||||
ssh reala@192.168.1.212 'tail -2 ~/pete-train/train.log' # is it done?
|
||||
scp reala@192.168.1.212:~/pete-train/policy-new.gob internal/games/holdem/policy.gob
|
||||
go test ./internal/games/holdem/ -run TestTheBotsAreActuallyTrained -v # hit rate must hold ≥60%
|
||||
```
|
||||
|
||||
Then re-drive the table in a browser and commit it. If the run was lost, just do it
|
||||
again — it is one command and about an hour:
|
||||
|
||||
```sh
|
||||
rsync -az --delete --exclude .git --exclude node_modules ./ reala@192.168.1.212:~/pete-train/
|
||||
ssh reala@192.168.1.212 'cd ~/pete-train && go build ./cmd/holdem-train && \
|
||||
nohup ./holdem-train -iterations 20000000 -workers 30 -out ~/pete-train/policy-new.gob \
|
||||
> ~/pete-train/train.log 2>&1 &'
|
||||
```
|
||||
|
||||
millenia (`reala@192.168.1.212`) has 32 cores and does ~250k hands a minute. The
|
||||
local box does ~110k. Nothing about the *code* is waiting on this — the policy is a
|
||||
data file, and a better one only makes the bots harder.
|
||||
|
||||
### Next, in order
|
||||
|
||||
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
|
||||
five are sitting on main un-deployed — the live casino is blackjack and nothing
|
||||
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
|
||||
binary is out there, but the first player to try a ladder in the first minute
|
||||
after a deploy gets the 503.
|
||||
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
|
||||
no-mercy)" and only normal was ever built. gogobee has the rules
|
||||
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
|
||||
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
|
||||
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
|
||||
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
|
||||
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
|
||||
completely. Shipping No Mercy on the regular tier's prices would misprice it.
|
||||
1. **Deploy.** Hangman, solitaire, trivia, UNO (both rule sets) and hold'em are all
|
||||
played and all of them are sitting on main un-deployed — the live casino is
|
||||
blackjack and nothing else. The server runs `StartTriviaBank`, so trivia's bank
|
||||
fills itself once the binary is out there, but the first player to try a ladder in
|
||||
the first minute after a deploy gets the 503.
|
||||
2. **Nothing else is queued.** Every game in the header line is built. What's left is
|
||||
the open list at the bottom of this section: hold'em has no multiway policy, and
|
||||
blackjack still has no split.
|
||||
|
||||
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
|
||||
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
|
||||
|
||||
Reference in New Issue
Block a user