From 17aac3018824df7306361ddbb173d901a3d417c6 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 10 Jul 2026 14:19:45 +1000 Subject: [PATCH 1/2] bcook/blackjack: make surrender and insurance read on the felt A surrendered hand now shows SURR (dim) where its total would sit, from the fold through results, and its settlement summary says "SURR -25" instead of "LOSE -25" - the half-stake number explained rather than a played-out defeat. Insurance is visible per seat on the value row: an open offer reads "ins?" on every placed seat still deciding, a bought stake reads "INS" beside the hand total and stays through turns and results, and a decline shows nothing. A seat that insures and then splits keeps the marker - the compact split layout re-homes INS onto the freed value row (the "aces: 1 card" note wins when both apply). Challenge is untouched: that table has neither surrender nor insurance. Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack/layout.go | 35 +++++++++++- games/bcook/blackjack/room.go | 8 +++ games/bcook/blackjack/room_test.go | 88 ++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index 324ed6d..cd21f9a 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -235,8 +235,12 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act // Split aces take exactly one card each and stand — so both hands lock the // moment they're split and the turn passes on. Name the rule beneath them, // so a locked "can't hit, turn moved on" reads as intended, not broken. + // Otherwise the freed value row keeps a riding insurance stake visible + // for a seat that insured before splitting. if splitAces(s) { centerSlot(f, seatValRow, slot, "aces: 1 card", stDim) + } else if tag := rm.insTag(s); tag != "" { + centerSlot(f, seatValRow, slot, strings.TrimSpace(tag), stDim) } if rm.phase == phResults && s.result != "" { centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) @@ -258,12 +262,20 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act } drawCardsAnim(f, seatCardRow, col, h.cards, -1, rm.seatResolver(s.p, hi, h)) col += w + 1 + // A surrendered hand reads SURR where its total would sit — the fold + // (half the stake back) must be unmistakable on the felt, not a hand + // that quietly stopped playing. + if h.surrendered { + vals = append(vals, "SURR") + continue + } vals = append(vals, valueLabel(h.cards, h.fromSplit)+dblTag(h)) } // During results the value line doubles as the ready indicator: a readied // seat shows READY where its hand total was, so who's holding up the table - // reads at a glance. - valStr, valSt := strings.Join(vals, " "), valueStyle(s) + // reads at a glance. The insurance tag (ins? / INS) rides the value line + // so a bought stake stays visible through the round. + valStr, valSt := strings.Join(vals, " ")+rm.insTag(s), valueStyle(s) if rm.phase == phResults && s.ready { valStr, valSt = "READY", stWin } @@ -926,6 +938,20 @@ func dblTag(h *phand) string { return "" } +// insTag marks the seat's insurance state on its value row: " ins?" while the +// offer is open on this seat, " INS" once the stake is riding (kept through +// turns and results, so who insured stays readable), and nothing for a +// declined offer. +func (rm *room) insTag(s *seat) string { + switch { + case s.insurance > 0: + return " INS" + case rm.phase == phInsurance && s.placed && !s.insuranceDecided: + return " ins?" + } + return "" +} + // valueLabel formats a hand's total for the felt. Only a NATURAL two-card 21 // (not one formed by splitting) reads as "BJ"; a split two-card 21 — the kind a // split ace hitting a ten makes — reads as a plain "21", since it is a plain 21 @@ -948,6 +974,9 @@ func valueLabel(h hand, fromSplit bool) string { func valueStyle(s *seat) kit.Style { for _, h := range s.hands { + if h.surrendered { + return stDim // a folded hand reads quiet, not like a live total + } if h.cards.isBust() { return stLose } @@ -965,6 +994,6 @@ func resultStyle(result string) kit.Style { case strings.HasPrefix(result, "LOSE"), strings.HasPrefix(result, "BUST"): return stLose default: - return stDim + return stDim // PUSH, and the quiet SURR fold (half the stake back) } } diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index cfcc2a6..b848688 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "sort" "strconv" "time" @@ -1209,6 +1210,13 @@ func (rm *room) settle(r kit.Room) { // (clamped to the payout ceiling), then feed the board on a new peak. net := rm.settleOpenStake(s) s.result = resultText(net) + // A surrendered round says so: "SURR -13" explains the odd half-stake + // number where "LOSE -13" would read like a played-out defeat. Only a + // lone hand can surrender, so the seat-level summary is unambiguous + // (side bets still fold into the net, signed either way). + if len(s.hands) == 1 && s.hands[0].surrendered { + s.result = fmt.Sprintf("SURR %+d", net) + } if s.bal > s.highScore { s.highScore = s.bal } diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 691cd57..19db8a8 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -1602,3 +1602,91 @@ func TestDealCountsHandsAndClearsNote(t *testing.T) { t.Fatalf("dealerNote survived the deal: %q", rm.dealerNote) } } + +// TestSurrenderReadsOnTheFelt asserts a surrendered hand is unmistakable: the +// value row reads SURR (dim, not a live total) through the round, and the +// settlement summary says SURR with the half-stake net rather than a played-out +// LOSE. +func TestSurrenderReadsOnTheFelt(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + staked(tr, s, 1000, 50) + s.hands = []*phand{{cards: hand{{10, suitHeart}, {6, suitSpade}}, bet: 50, surrendered: true, resolved: true}} + rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} + rm.phase = phTurns + rm.render(tr) + if row := kittest.String(tr.LastFrame(a), seatValRow); !strings.Contains(row, "SURR") { + t.Fatalf("surrendered hand's value row does not read SURR: %q", row) + } + rm.settle(tr) + // Half of 50 rounds up to the player: 25 back, net -25. + if s.result != "SURR -25" { + t.Fatalf("surrendered result = %q, want SURR -25", s.result) + } + rm.render(tr) + if row := kittest.String(tr.LastFrame(a), seatChipRow); !strings.Contains(row, "SURR -25") { + t.Fatalf("results row does not carry the SURR summary: %q", row) + } +} + +// TestInsuranceReadsOnTheFelt asserts the insurance state is visible per seat: +// an open offer reads "ins?" on the value row, a bought stake reads "INS" and +// stays visible after the offer window resolves into turns. +func TestInsuranceReadsOnTheFelt(t *testing.T) { + a, b := mkPlayer("alice"), mkPlayer("bob") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + for _, s := range []*seat{sa, sb} { + s.placed = true + fund(tr, s, 1000) + s.hands = []*phand{{cards: hand{{10, suitHeart}, {9, suitSpade}}, bet: 50}} + } + rm.dealer = hand{{rankAce, suitClub}, {7, suitDiamond}} // ace up: insurance offered + rm.dealerHole = true + rm.enterInsurance(tr) + rm.render(tr) + // Both seats undecided: each value row carries the open-offer marker. + if row := kittest.String(tr.LastFrame(a), seatValRow); strings.Count(row, "ins?") != 2 { + t.Fatalf("undecided seats should both read ins?: %q", row) + } + // Alice buys, bob declines: only the bought stake reads INS. + rm.OnInput(tr, a, runeInput('y')) + rm.OnInput(tr, b, runeInput('n')) // all answered -> resolves into turns + if rm.phase != phTurns { + t.Fatalf("phase = %q after all answered, want %q", rm.phase, phTurns) + } + rm.render(tr) + row := kittest.String(tr.LastFrame(a), seatValRow) + if strings.Count(row, "INS") != 1 || strings.Contains(row, "ins?") { + t.Fatalf("bought insurance should read INS on exactly one seat: %q", row) + } +} + +// TestInsuranceTagSurvivesSplit asserts a seat that insured and then split +// keeps its INS marker: the compact split layout re-homes the tag on the freed +// value row. +func TestInsuranceTagSurvivesSplit(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + fund(tr, s, 1000) + s.insurance = 25 + s.hands = []*phand{ + {cards: hand{{8, suitHeart}, {3, suitSpade}}, bet: 50, fromSplit: true}, + {cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50, fromSplit: true}, + } + rm.dealer = hand{{rankAce, suitClub}, {7, suitDiamond}} + rm.dealerHole = true + rm.phase = phTurns + rm.render(tr) + if row := kittest.String(tr.LastFrame(a), seatValRow); !strings.Contains(row, "INS") { + t.Fatalf("split seat lost its INS marker: %q", row) + } +} From 00a296ba440fd85f49ba277598cf45003255aeba Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 10 Jul 2026 14:24:36 +1000 Subject: [PATCH 2/2] blackjack + blackjack-challenge: pairs line drops the tile; losses self-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairs side-bet line sits directly under its seat's own name row, which already carries the player's character tile — so the line renders as bare text now (betting stake and result both), and the unused centerSlotChar helper retires. Tiles stay on the backers line, where the face identifies OTHER players. A lost side bet is the owner's quiet news: "pairs lost" renders only on the viewer's own seat, while wins broadcast to every viewer. Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack-challenge/layout.go | 26 +++------- games/bcook/blackjack-challenge/room_test.go | 52 ++++++++++++++++---- games/bcook/blackjack/layout.go | 31 +++++------- games/bcook/blackjack/room_test.go | 52 ++++++++++++++++---- 4 files changed, 102 insertions(+), 59 deletions(-) diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go index 311176b..942c901 100644 --- a/games/bcook/blackjack-challenge/layout.go +++ b/games/bcook/blackjack-challenge/layout.go @@ -293,13 +293,12 @@ func pairsMult(kind string) int { // seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 8:1") // or a quiet "pairs lost". func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { - ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet if rm.phase == phBetting { if s.pairsBet > 0 && (s.placed || own) { // Match the seat's bet line (stDim) rather than the bright own-seat // cyan — the side stake reads as part of the same quiet bet block, // not a highlight competing with the active-seat and prompt colours. - centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stDim) + centerSlot(f, seatCardRow+2, slot, fmt.Sprintf("+pairs %d", s.pairsBet), stDim) } return } @@ -314,10 +313,14 @@ func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { return } if s.pairsKind != "" { - centerSlotChar(f, seatPairRow, slot, ch, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin) + centerSlot(f, seatPairRow, slot, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin) return } - centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim) + // A lost side bet is the seat owner's quiet news, not a table event: only + // the viewer's own seat says "pairs lost" (wins above broadcast to all). + if own { + centerSlot(f, seatPairRow, slot, "pairs lost", stDim) + } } // drawBackersLine renders, on a seat's dedicated backers row, a token per player @@ -807,21 +810,6 @@ func centerSlot(f *kit.Frame, row, slot int, s string, st kit.Style) { f.Text(row, slot+(slotW-n)/2, s, st) } -// centerSlotChar centres " " within a slotW-wide column: -// the styled character cell (width 1) plus a space precede the text, tying the -// line to a specific player by face. The text is clamped so the tile + text -// never overflow the slot. -func centerSlotChar(f *kit.Frame, row, slot int, ch kit.Cell, text string, st kit.Style) { - tr := []rune(text) - if len(tr) > slotW-2 { - tr = tr[:slotW-2] - } - w := 2 + len(tr) - col := slot + (slotW-w)/2 - f.Set(row, col, ch) - f.Text(row, col+2, string(tr), st) -} - func (rm *room) remaining() int { if rm.deadline.IsZero() || rm.lastNow.IsZero() { return 0 diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go index 8283d29..cd2342e 100644 --- a/games/bcook/blackjack-challenge/room_test.go +++ b/games/bcook/blackjack-challenge/room_test.go @@ -1380,10 +1380,11 @@ func TestBettingShowsPairsSideBet(t *testing.T) { } } -// TestPairsLineCarriesCharacterTile asserts the Star Pairs side-bet line is -// prefixed with the placing player's arcade character tile, so whose side bet is -// whose reads from the face beside it, not just the column. -func TestPairsLineCarriesCharacterTile(t *testing.T) { +// TestPairsLineCarriesNoCharacterTile asserts the Star Pairs side-bet line is +// bare text: it sits directly under its seat's own name row (which already +// carries the tile), so a face there restates the obvious. Tiles remain on +// the backers line, where they identify OTHER players. +func TestPairsLineCarriesNoCharacterTile(t *testing.T) { a := mkPlayer("alice") a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'} rm, tr := newGame(t, a) @@ -1394,14 +1395,45 @@ func TestPairsLineCarriesCharacterTile(t *testing.T) { row := kittest.String(f, seatCardRow+2) idx := colIndex(row, "+pairs 25") - if idx < 2 { - t.Fatalf("pairs line not found (or no room for a tile) on row %d: %q", seatCardRow+2, row) + if idx < 0 { + t.Fatalf("pairs line not found on row %d: %q", seatCardRow+2, row) + } + if got := f.Cells[seatCardRow+2][idx-2]; got == kit.CharacterCell(a.Character) { + t.Errorf("pairs line still carries the character tile: %+v", got) + } +} + +// TestPairsLossIsSelfOnly asserts a lost side bet is the owner's quiet news: +// the "pairs lost" note renders on the viewer's own seat only, while a win +// broadcasts to every viewer. +func TestPairsLossIsSelfOnly(t *testing.T) { + a, b := mkPlayer("alice"), mkPlayer("bob") + rm, tr := newGame(t, a, b) + rm.what = pendNone + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + // Alice lost her pairs (no pair dealt); bob won his (a paid kind is set). + sa.placed, sa.pairsBet, sa.pairsKind = true, 25, "" + sa.hands = []*phand{{cards: hand{{9, suitHeart}, {5, suitSpade}}, bet: 50}} + sb.placed, sb.pairsBet, sb.pairsKind = true, 25, "mixed" + sb.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitSpade}}, bet: 50}} + rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} + rm.phase = phTurns + rm.render(tr) + + // Alice sees her own loss and bob's win. + rowA := kittest.String(tr.LastFrame(a), seatPairRow) + if !strings.Contains(rowA, "pairs lost") || !strings.Contains(rowA, "MIXED") { + t.Fatalf("owner's view should carry its loss and the win: %q", rowA) } - if got, want := f.Cells[seatCardRow+2][idx-2], kit.CharacterCell(a.Character); got != want { - t.Errorf("cell before the pairs bet = %+v, want the character tile %+v", got, want) + // Bob sees his win but NOT alice's loss. + rowB := kittest.String(tr.LastFrame(b), seatPairRow) + if strings.Contains(rowB, "pairs lost") { + t.Fatalf("another viewer should not see alice's pairs loss: %q", rowB) } - if sp := f.Cells[seatCardRow+2][idx-1].Rune; sp != ' ' && sp != 0 { - t.Errorf("no space between the character tile and the pairs bet (got %q)", sp) + if !strings.Contains(rowB, "MIXED") { + t.Fatalf("wins should broadcast to every viewer: %q", rowB) } } diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index cd21f9a..4d5c952 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -310,15 +310,17 @@ func pairsMult(kind string) int { // ambiguous; it shows only once placed, or for the seat's owner, matching how // the main bet stays private until placed. Once the cards are dealt it moves to // seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 12:1") -// or a quiet "pairs lost". +// or, on the viewer's own seat only, a quiet "pairs lost". The line carries no +// character tile: it sits directly under the seat's own name row, so a face +// there restates the obvious (backs on OTHER seats keep tiles — see +// drawBackersLine, where the face is the information). func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { - ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet if rm.phase == phBetting { if s.pairsBet > 0 && (s.placed || own) { // Match the seat's bet line (stDim) rather than the bright own-seat // cyan — the side stake reads as part of the same quiet bet block, // not a highlight competing with the active-seat and prompt colours. - centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stDim) + centerSlot(f, seatCardRow+2, slot, fmt.Sprintf("+pairs %d", s.pairsBet), stDim) } return } @@ -333,10 +335,14 @@ func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { return } if s.pairsKind != "" { - centerSlotChar(f, seatPairRow, slot, ch, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin) + centerSlot(f, seatPairRow, slot, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin) return } - centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim) + // A lost side bet is the seat owner's quiet news, not a table event: only + // the viewer's own seat says "pairs lost" (wins above broadcast to all). + if own { + centerSlot(f, seatPairRow, slot, "pairs lost", stDim) + } } // drawBackersLine renders, on a seat's dedicated backers row, a token per player @@ -870,21 +876,6 @@ func centerSlot(f *kit.Frame, row, slot int, s string, st kit.Style) { f.Text(row, slot+(slotW-n)/2, s, st) } -// centerSlotChar centres " " within a slotW-wide column: -// the styled character cell (width 1) plus a space precede the text, tying the -// line to a specific player by face. The text is clamped so the tile + text -// never overflow the slot. -func centerSlotChar(f *kit.Frame, row, slot int, ch kit.Cell, text string, st kit.Style) { - tr := []rune(text) - if len(tr) > slotW-2 { - tr = tr[:slotW-2] - } - w := 2 + len(tr) - col := slot + (slotW-w)/2 - f.Set(row, col, ch) - f.Text(row, col+2, string(tr), st) -} - func (rm *room) remaining() int { if rm.deadline.IsZero() || rm.lastNow.IsZero() { return 0 diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 19db8a8..a0fc008 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -1207,10 +1207,11 @@ func TestBettingShowsPairsSideBet(t *testing.T) { } } -// TestPairsLineCarriesCharacterTile asserts the Perfect Pairs side-bet line is -// prefixed with the placing player's arcade character tile, so whose side bet is -// whose reads from the face beside it, not just the column. -func TestPairsLineCarriesCharacterTile(t *testing.T) { +// TestPairsLineCarriesNoCharacterTile asserts the Perfect Pairs side-bet line is +// bare text: it sits directly under its seat's own name row (which already +// carries the tile), so a face there restates the obvious. Tiles remain on +// the backers line, where they identify OTHER players. +func TestPairsLineCarriesNoCharacterTile(t *testing.T) { a := mkPlayer("alice") a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'} rm, tr := newGame(t, a) @@ -1221,14 +1222,45 @@ func TestPairsLineCarriesCharacterTile(t *testing.T) { row := kittest.String(f, seatCardRow+2) idx := colIndex(row, "+pairs 25") - if idx < 2 { - t.Fatalf("pairs line not found (or no room for a tile) on row %d: %q", seatCardRow+2, row) + if idx < 0 { + t.Fatalf("pairs line not found on row %d: %q", seatCardRow+2, row) + } + if got := f.Cells[seatCardRow+2][idx-2]; got == kit.CharacterCell(a.Character) { + t.Errorf("pairs line still carries the character tile: %+v", got) + } +} + +// TestPairsLossIsSelfOnly asserts a lost side bet is the owner's quiet news: +// the "pairs lost" note renders on the viewer's own seat only, while a win +// broadcasts to every viewer. +func TestPairsLossIsSelfOnly(t *testing.T) { + a, b := mkPlayer("alice"), mkPlayer("bob") + rm, tr := newGame(t, a, b) + rm.what = pendNone + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + // Alice lost her pairs (no pair dealt); bob won his (a paid kind is set). + sa.placed, sa.pairsBet, sa.pairsKind = true, 25, "" + sa.hands = []*phand{{cards: hand{{9, suitHeart}, {5, suitSpade}}, bet: 50}} + sb.placed, sb.pairsBet, sb.pairsKind = true, 25, "mixed" + sb.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitSpade}}, bet: 50}} + rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} + rm.phase = phTurns + rm.render(tr) + + // Alice sees her own loss and bob's win. + rowA := kittest.String(tr.LastFrame(a), seatPairRow) + if !strings.Contains(rowA, "pairs lost") || !strings.Contains(rowA, "MIXED") { + t.Fatalf("owner's view should carry its loss and the win: %q", rowA) } - if got, want := f.Cells[seatCardRow+2][idx-2], kit.CharacterCell(a.Character); got != want { - t.Errorf("cell before the pairs bet = %+v, want the character tile %+v", got, want) + // Bob sees his win but NOT alice's loss. + rowB := kittest.String(tr.LastFrame(b), seatPairRow) + if strings.Contains(rowB, "pairs lost") { + t.Fatalf("another viewer should not see alice's pairs loss: %q", rowB) } - if sp := f.Cells[seatCardRow+2][idx-1].Rune; sp != ' ' && sp != 0 { - t.Errorf("no space between the character tile and the pairs bet (got %q)", sp) + if !strings.Contains(rowB, "MIXED") { + t.Fatalf("wins should broadcast to every viewer: %q", rowB) } }