From ac7bd465d1d487acd7a69fc38fd2305654710417 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 10 Jul 2026 13:35:55 +1000 Subject: [PATCH 1/2] bcook/blackjack + blackjack-challenge: rotating dealers who retire with the shoe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every shoe now belongs to a named dealer off a per-table roster, and the two retire together at the next betting window: reaching the cut card swaps the dealer, and a slow (heads-up) shoe hits a 20-hand cap first — either way the incoming dealer brings a freshly shuffled shoe, as on a real casino floor. On the felt, the fixed D E A L E R label becomes the working dealer's spaced-caps nameplate, the wait/play lines name the dealer, and a changeover is announced where the cards land ("Marge steps away - Dex takes the shoe") through that betting window. Blackjack staffs a classic floor crew; The Star's Challenge table deals in star names. The opening dealer derives from cfg.Seed rather than r.Rand(), and the reshuffle moving from deal-time to the betting window consumes the same RNG draws in the same order, so the seeded card stream is byte-identical and the smoke scripts' choreographed deals (seed 58 / seed 16) replay unchanged — verified by native replays of both first deals. Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack-challenge/dealer.go | 38 +++++++++ games/bcook/blackjack-challenge/layout.go | 34 ++++++-- games/bcook/blackjack-challenge/room.go | 21 +++++ games/bcook/blackjack-challenge/room_test.go | 78 ++++++++++++++++- games/bcook/blackjack/dealer.go | 37 ++++++++ games/bcook/blackjack/layout.go | 34 ++++++-- games/bcook/blackjack/room.go | 21 +++++ games/bcook/blackjack/room_test.go | 90 ++++++++++++++++++-- 8 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 games/bcook/blackjack-challenge/dealer.go create mode 100644 games/bcook/blackjack/dealer.go diff --git a/games/bcook/blackjack-challenge/dealer.go b/games/bcook/blackjack-challenge/dealer.go new file mode 100644 index 0000000..66ea247 --- /dev/null +++ b/games/bcook/blackjack-challenge/dealer.go @@ -0,0 +1,38 @@ +package main + +import kit "github.com/shellcade/kit/v2" + +// The rotating dealer crew: every shoe belongs to one dealer, and the two +// retire together. A busy table reaches the cut card well inside the hand cap; +// a slow (heads-up) shoe hits the cap first. Either way the swap lands at the +// next betting window — as on a real floor, where dealers rotate off on a +// schedule and the incoming dealer starts from a fresh shuffle. +const handsPerDealer = 20 + +// dealerNames is the dealer roster, cycled in order from a seeded random +// start — star names, for The Star's table. Names stay <= 6 letters so the +// spaced-caps nameplate centred over the dealer's cards never crowds the +// (wide, 30-col) left rules signage. +var dealerNames = [...]string{"Vega", "Nova", "Orion", "Luna", "Rigel", "Stella", "Cass", "Astra"} + +// dealerName is the dealer currently working the table. +func (rm *room) dealerName() string { return dealerNames[rm.dealerIdx] } + +// needsNewDealer reports whether the current dealer's shoe is done: the cut +// card was reached (or a drained round forced a recycle), or the dealer has +// dealt their full shift of hands from a slow-burning shoe. +func (rm *room) needsNewDealer() bool { + return rm.sh.needsReshuffle() || rm.handsThisShoe >= handsPerDealer +} + +// rotateDealer retires the current dealer along with the spent shoe and seats +// the next one off the roster with a freshly shuffled shoe. The note announces +// the change on the felt through the betting window; the incoming dealer's +// first deal clears it. +func (rm *room) rotateDealer(r kit.Room) { + prev := rm.dealerName() + rm.dealerIdx = (rm.dealerIdx + 1) % len(dealerNames) + rm.sh.shuffle(r.Rand()) + rm.handsThisShoe = 0 + rm.dealerNote = prev + " steps away - " + rm.dealerName() + " takes the shoe" +} diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go index f986c31..074f1bb 100644 --- a/games/bcook/blackjack-challenge/layout.go +++ b/games/bcook/blackjack-challenge/layout.go @@ -88,7 +88,9 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { // across the middle of the felt — keeping the centre clear for the cards. f.Text(dealerRow-1, 2, "blackjack pays 2:1 - ties lose", stDim) f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim) - center(f, dealerRow-1, "D E A L E R", stTitle) + // The centred label is the working dealer's nameplate (dealer.go): the crew + // rotates with the shoe, so the name up top says whose shoe this is. + center(f, dealerRow-1, spacedCaps(rm.dealerName()), stTitle) rm.drawDealer(f) // Seats along the rail, centred as a group. @@ -117,7 +119,14 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { func (rm *room) drawDealer(f *kit.Frame) { if len(rm.dealer) == 0 { - center(f, dealerRow+1, "(waiting for bets)", stDim) + // A dealer changeover just happened: announce it where the cards will + // land, brightly, for this betting window (the next deal clears it). + // Otherwise the wait line names the dealer, anchoring the nameplate. + if rm.dealerNote != "" { + center(f, dealerRow+1, rm.dealerNote, stPhase) + } else { + center(f, dealerRow+1, "(dealer "+rm.dealerName()+" waits for bets)", stDim) + } return } // Size and centre the row to the cards actually on the table (the one dealt @@ -412,9 +421,10 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { return default: // No hand left on turn: every player has resolved and the dealer is - // drawing out from its one face-up card. Name the moment so the slow - // draw-out reads as the dealer acting rather than a frozen table. - msg, st = "dealer plays...", stDim + // drawing out from its one face-up card. Name the moment (and the + // dealer, by name) so the slow draw-out reads as the dealer acting + // rather than a frozen table. + msg, st = rm.dealerName()+" plays...", stDim } case phResults: switch n := rm.unreadyCount(); { @@ -749,6 +759,20 @@ func center(f *kit.Frame, row int, s string, st kit.Style) { f.Text(row, (kit.Cols-len([]rune(s)))/2, s, st) } +// spacedCaps letter-spaces s in caps ("Vega" -> "V E G A"): the felt signage +// style the fixed D E A L E R label used before the crew carried names. +func spacedCaps(s string) string { + rs := []rune(strings.ToUpper(s)) + out := make([]rune, 0, len(rs)*2) + for i, r := range rs { + if i > 0 { + out = append(out, ' ') + } + out = append(out, r) + } + return string(out) +} + // centerWithChar centres "
 " on a row: the styled
 // tile cell (width 1, kit v2.9.0) plus its trailing space sit between the two
 // text parts, so a player name in post carries its character right before it.
diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go
index 4f01f74..683fecd 100644
--- a/games/bcook/blackjack-challenge/room.go
+++ b/games/bcook/blackjack-challenge/room.go
@@ -129,6 +129,14 @@ type room struct {
 	dealer  hand
 	joinSeq int
 
+	// The rotating dealer (dealer.go): dealerIdx names who is working the
+	// table, handsThisShoe counts the rounds dealt from the current shoe (the
+	// rotation cap for a shoe the cut card is slow to end), and dealerNote
+	// carries the changeover announcement through the next betting window.
+	dealerIdx     int
+	handsThisShoe int
+	dealerNote    string
+
 	// deadline is the current phase deadline (rendered as the countdown) and
 	// what is the active pending one-shot. pendAt is the instant `what` fires;
 	// for most phases it equals deadline, but pendSettle/pendBettingClose-grace
@@ -162,6 +170,10 @@ func (rm *room) economyOff() bool { return rm.svc.Credits == nil }
 
 func (rm *room) OnStart(r kit.Room) {
 	rm.sh = newShoe(r.Rand())
+	// The opening dealer comes off the room seed, NOT r.Rand(): drawing from
+	// the RNG here would shift the card stream and change every seeded deal
+	// (the smoke script's choreography rides on exact cards from its seed).
+	rm.dealerIdx = int(((rm.cfg.Seed % int64(len(dealerNames))) + int64(len(dealerNames))) % int64(len(dealerNames)))
 	if rm.economyOff() {
 		rm.render(r) // out-of-service: no economy, no betting
 		return
@@ -373,6 +385,11 @@ func (rm *room) enterBetting(r kit.Room) {
 	rm.dealer = nil
 	rm.bettingClosing = false
 	rm.clearSchedule()
+	// A spent shoe retires with its dealer: the cut card (or the handsPerDealer
+	// cap on a slow shoe) swaps in the next dealer, who brings a fresh shuffle.
+	if rm.needsNewDealer() {
+		rm.rotateDealer(r)
+	}
 	for _, s := range rm.seats {
 		s.hands = nil
 		s.placed = false
@@ -622,8 +639,12 @@ func loopTier(tiers []int, cur, budget int) int {
 
 func (rm *room) deal(r kit.Room) {
 	if rm.sh.needsReshuffle() {
+		// Defensive: the changeover at enterBetting (rotateDealer) reshuffles
+		// before any spent shoe reaches a deal, so this never fires in play.
 		rm.sh.shuffle(r.Rand())
 	}
+	rm.dealerNote = "" // the incoming dealer's first deal retires the announcement
+	rm.handsThisShoe++ // one more round on this dealer's shoe (rotation cap)
 	rm.sh.beginRound() // everything dealt before this point is recyclable discards
 	rng := r.Rand()
 	rm.dealer = hand{rm.sh.draw(rng)} // ONE face-up card; the rest draw at the dealer's turn
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 0726b78..9449e5d 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -1453,8 +1453,8 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {
 		t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow)
 	}
-	if !strings.Contains(dealerLabelRow, "D E A L E R") {
-		t.Errorf("DEALER label should remain centred between the rules: %q", dealerLabelRow)
+	if !strings.Contains(dealerLabelRow, spacedCaps(rm.dealerName())) {
+		t.Errorf("dealer nameplate should sit centred between the rules: %q", dealerLabelRow)
 	}
 	// The old mid-felt tagline (row 9) must be clear now.
 	if mid := kittest.String(f, 9); strings.Contains(mid, "blackjack pays") {
@@ -1663,3 +1663,77 @@ func TestPairsMult(t *testing.T) {
 		}
 	}
 }
+
+// TestDealerRotatesWithSpentShoe asserts the dealer changeover rides the shoe:
+// a shoe past its cut card retires the dealer at the next betting window — the
+// roster advances, a fresh shuffle resets the shoe, the hand count restarts,
+// and the changeover is announced on the felt.
+func TestDealerRotatesWithSpentShoe(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	was := rm.dealerIdx
+	rm.sh.pos = rm.sh.cut // cut card reached: the shoe is spent
+	rm.handsThisShoe = 7
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("dealerIdx = %d, want roster advance from %d", rm.dealerIdx, was)
+	}
+	if rm.sh.pos != 0 {
+		t.Fatalf("shoe pos = %d, want 0 (incoming dealer brings a fresh shuffle)", rm.sh.pos)
+	}
+	if rm.handsThisShoe != 0 {
+		t.Fatalf("handsThisShoe = %d, want 0 after the changeover", rm.handsThisShoe)
+	}
+	want := dealerNames[was] + " steps away - " + rm.dealerName() + " takes the shoe"
+	if rm.dealerNote != want {
+		t.Fatalf("dealerNote = %q, want %q", rm.dealerNote, want)
+	}
+	// The announcement renders where the dealer's cards will land.
+	rm.render(tr)
+	if row := kittest.String(tr.LastFrame(a), dealerRow+1); !strings.Contains(row, want) {
+		t.Fatalf("changeover note not on the felt: %q", row)
+	}
+	// A fresh-shoe reopen (nobody bet) must NOT rotate again.
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("reopen rotated the dealer again: idx %d", rm.dealerIdx)
+	}
+}
+
+// TestDealerRotatesAtHandCap asserts a slow-burning shoe still retires its
+// dealer: handsPerDealer rounds dealt swaps the dealer (and the shoe with
+// them) even though the cut card is nowhere near.
+func TestDealerRotatesAtHandCap(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	was := rm.dealerIdx
+	rm.handsThisShoe = handsPerDealer // dealt a full shift; shoe nowhere near the cut
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("hand cap did not rotate the dealer: idx %d, was %d", rm.dealerIdx, was)
+	}
+	if rm.handsThisShoe != 0 || rm.sh.pos != 0 {
+		t.Fatalf("changeover must reset the count and shoe: hands %d, pos %d", rm.handsThisShoe, rm.sh.pos)
+	}
+}
+
+// TestDealCountsHandsAndClearsNote asserts each deal ticks the rotation
+// counter and retires the changeover announcement.
+func TestDealCountsHandsAndClearsNote(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	fund(tr, s, 1000)
+	s.placed = true
+	rm.dealerNote = "Vega steps away - Nova takes the shoe"
+	rm.deal(tr)
+	if rm.handsThisShoe != 1 {
+		t.Fatalf("handsThisShoe = %d after one deal, want 1", rm.handsThisShoe)
+	}
+	if rm.dealerNote != "" {
+		t.Fatalf("dealerNote survived the deal: %q", rm.dealerNote)
+	}
+}
diff --git a/games/bcook/blackjack/dealer.go b/games/bcook/blackjack/dealer.go
new file mode 100644
index 0000000..dc7e5d9
--- /dev/null
+++ b/games/bcook/blackjack/dealer.go
@@ -0,0 +1,37 @@
+package main
+
+import kit "github.com/shellcade/kit/v2"
+
+// The rotating dealer crew: every shoe belongs to one dealer, and the two
+// retire together. A busy table reaches the cut card well inside the hand cap;
+// a slow (heads-up) shoe hits the cap first. Either way the swap lands at the
+// next betting window — as on a real floor, where dealers rotate off on a
+// schedule and the incoming dealer starts from a fresh shuffle.
+const handsPerDealer = 20
+
+// dealerNames is the dealer roster, cycled in order from a seeded random
+// start. Names stay <= 6 letters so the spaced-caps nameplate centred over the
+// dealer's cards never crowds the flanking rules signage.
+var dealerNames = [...]string{"Marge", "Dex", "Ruthie", "Sal", "Pearl", "Gus", "Vera", "Cliff"}
+
+// dealerName is the dealer currently working the table.
+func (rm *room) dealerName() string { return dealerNames[rm.dealerIdx] }
+
+// needsNewDealer reports whether the current dealer's shoe is done: the cut
+// card was reached (or a drained round forced a recycle), or the dealer has
+// dealt their full shift of hands from a slow-burning shoe.
+func (rm *room) needsNewDealer() bool {
+	return rm.sh.needsReshuffle() || rm.handsThisShoe >= handsPerDealer
+}
+
+// rotateDealer retires the current dealer along with the spent shoe and seats
+// the next one off the roster with a freshly shuffled shoe. The note announces
+// the change on the felt through the betting window; the incoming dealer's
+// first deal clears it.
+func (rm *room) rotateDealer(r kit.Room) {
+	prev := rm.dealerName()
+	rm.dealerIdx = (rm.dealerIdx + 1) % len(dealerNames)
+	rm.sh.shuffle(r.Rand())
+	rm.handsThisShoe = 0
+	rm.dealerNote = prev + " steps away - " + rm.dealerName() + " takes the shoe"
+}
diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go
index 7f6ca0a..f0989e6 100644
--- a/games/bcook/blackjack/layout.go
+++ b/games/bcook/blackjack/layout.go
@@ -88,7 +88,9 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	// across the middle of the felt — keeping the centre clear for the cards.
 	f.Text(dealerRow-1, 2, "blackjack pays 3:2", stDim)
 	f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim)
-	center(f, dealerRow-1, "D E A L E R", stTitle)
+	// The centred label is the working dealer's nameplate (dealer.go): the crew
+	// rotates with the shoe, so the name up top says whose shoe this is.
+	center(f, dealerRow-1, spacedCaps(rm.dealerName()), stTitle)
 	rm.drawDealer(f)
 
 	// Seats along the rail, centred as a group.
@@ -117,7 +119,14 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 
 func (rm *room) drawDealer(f *kit.Frame) {
 	if len(rm.dealer) == 0 {
-		center(f, dealerRow+1, "(waiting for bets)", stDim)
+		// A dealer changeover just happened: announce it where the cards will
+		// land, brightly, for this betting window (the next deal clears it).
+		// Otherwise the wait line names the dealer, anchoring the nameplate.
+		if rm.dealerNote != "" {
+			center(f, dealerRow+1, rm.dealerNote, stPhase)
+		} else {
+			center(f, dealerRow+1, "(dealer "+rm.dealerName()+" waits for bets)", stDim)
+		}
 		return
 	}
 	hide := -1
@@ -435,9 +444,10 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
 			return
 		default:
 			// No hand left on turn: every player has resolved and the dealer is
-			// turning its hole card and drawing. Name the moment so the slow
-			// reveal reads as the dealer acting rather than a frozen table.
-			msg, st = "dealer plays...", stDim
+			// turning its hole card and drawing. Name the moment (and the
+			// dealer, by name) so the slow reveal reads as the dealer acting
+			// rather than a frozen table.
+			msg, st = rm.dealerName()+" plays...", stDim
 		}
 	case phResults:
 		switch n := rm.unreadyCount(); {
@@ -792,6 +802,20 @@ func center(f *kit.Frame, row int, s string, st kit.Style) {
 	f.Text(row, (kit.Cols-len([]rune(s)))/2, s, st)
 }
 
+// spacedCaps letter-spaces s in caps ("Vera" -> "V E R A"): the felt signage
+// style the fixed D E A L E R label used before the crew carried names.
+func spacedCaps(s string) string {
+	rs := []rune(strings.ToUpper(s))
+	out := make([]rune, 0, len(rs)*2)
+	for i, r := range rs {
+		if i > 0 {
+			out = append(out, ' ')
+		}
+		out = append(out, r)
+	}
+	return string(out)
+}
+
 // centerWithChar centres "
 " on a row: the styled
 // tile cell (width 1, kit v2.9.0) plus its trailing space sit between the two
 // text parts, so a player name in post carries its character right before it.
diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go
index 7561dcf..d3240a8 100644
--- a/games/bcook/blackjack/room.go
+++ b/games/bcook/blackjack/room.go
@@ -132,6 +132,14 @@ type room struct {
 	dealerHole bool // hole card concealed
 	joinSeq    int
 
+	// The rotating dealer (dealer.go): dealerIdx names who is working the
+	// table, handsThisShoe counts the rounds dealt from the current shoe (the
+	// rotation cap for a shoe the cut card is slow to end), and dealerNote
+	// carries the changeover announcement through the next betting window.
+	dealerIdx     int
+	handsThisShoe int
+	dealerNote    string
+
 	// deadline is the current phase deadline (rendered as the countdown) and
 	// what is the active pending one-shot. pendAt is the instant `what` fires;
 	// for most phases it equals deadline, but pendSettle/pendBettingClose-grace
@@ -165,6 +173,10 @@ func (rm *room) economyOff() bool { return rm.svc.Credits == nil }
 
 func (rm *room) OnStart(r kit.Room) {
 	rm.sh = newShoe(r.Rand())
+	// The opening dealer comes off the room seed, NOT r.Rand(): drawing from
+	// the RNG here would shift the card stream and change every seeded deal
+	// (the smoke script's choreography rides on exact cards from its seed).
+	rm.dealerIdx = int(((rm.cfg.Seed % int64(len(dealerNames))) + int64(len(dealerNames))) % int64(len(dealerNames)))
 	if rm.economyOff() {
 		rm.render(r) // out-of-service: no economy, no betting
 		return
@@ -380,6 +392,11 @@ func (rm *room) enterBetting(r kit.Room) {
 	rm.dealerHole = false
 	rm.bettingClosing = false
 	rm.clearSchedule()
+	// A spent shoe retires with its dealer: the cut card (or the handsPerDealer
+	// cap on a slow shoe) swaps in the next dealer, who brings a fresh shuffle.
+	if rm.needsNewDealer() {
+		rm.rotateDealer(r)
+	}
 	for _, s := range rm.seats {
 		s.hands = nil
 		s.placed = false
@@ -632,8 +649,12 @@ func loopTier(tiers []int, cur, budget int) int {
 
 func (rm *room) deal(r kit.Room) {
 	if rm.sh.needsReshuffle() {
+		// Defensive: the changeover at enterBetting (rotateDealer) reshuffles
+		// before any spent shoe reaches a deal, so this never fires in play.
 		rm.sh.shuffle(r.Rand())
 	}
+	rm.dealerNote = "" // the incoming dealer's first deal retires the announcement
+	rm.handsThisShoe++ // one more round on this dealer's shoe (rotation cap)
 	rm.sh.beginRound() // everything dealt before this point is recyclable discards
 	rng := r.Rand()
 	rm.dealer = hand{rm.sh.draw(rng), rm.sh.draw(rng)} // [up, hole]
diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go
index 68de93a..3eb84ed 100644
--- a/games/bcook/blackjack/room_test.go
+++ b/games/bcook/blackjack/room_test.go
@@ -75,7 +75,7 @@ func TestPairsSideBetLoopsOnP(t *testing.T) {
 	if s.pairsBet != 0 {
 		t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet)
 	}
-	s.bal = 100000                  // deep enough to afford every tier, so the loop wraps only at the top
+	s.bal = 100000                    // deep enough to afford every tier, so the loop wraps only at the top
 	rm.OnInput(tr, a, runeInput('p')) // P advances one tier
 	if s.pairsBet != pairsTiers[1] {
 		t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1])
@@ -300,7 +300,7 @@ func TestSettleBehindBetWinFolds(t *testing.T) {
 	rm.OnJoin(tr, b)
 	sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID]
 	sa.placed, sa.bet = true, 25
-	staked(tr, sa, 925, 75) // a's open stake: main 25 + behind 50 escrowed
+	staked(tr, sa, 925, 75)                                                     // a's open stake: main 25 + behind 50 escrowed
 	sa.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}}, bet: 25}} // a: 5, loses
 	sb.placed, sb.bet = true, 25
 	sb.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // b: 19, beats dealer
@@ -326,7 +326,7 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
 	rm.OnJoin(tr, b)
 	sa := rm.seats[a.AccountID]
 	sa.placed, sa.bet = true, 25
-	staked(tr, sa, 900, 75) // main 25 + behind 50 escrowed; 900 bankroll left
+	staked(tr, sa, 900, 75)                                                      // main 25 + behind 50 escrowed; 900 bankroll left
 	sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes
 	sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
 	delete(rm.seats, b.AccountID) // b left mid-round
@@ -353,9 +353,9 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
 	s.placed = true
 	s.bet = 50
 	s.pairsBet = 10
-	staked(tr, s, 940, 60)   // main 50 + pairs 10 escrowed
-	s.pairsWin = 70          // a mixed pair already resolved at deal...
-	s.grossThisRound = 70    // ...and folded into the open stake's gross
+	staked(tr, s, 940, 60)                                                      // main 50 + pairs 10 escrowed
+	s.pairsWin = 70                                                             // a mixed pair already resolved at deal...
+	s.grossThisRound = 70                                                       // ...and folded into the open stake's gross
 	s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
 	rm.dealer = hand{{10, suitClub}, {9, suitDiamond}}                          // 19 -> hand pushes
 	rm.settle(tr)
@@ -1274,8 +1274,8 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {
 		t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow)
 	}
-	if !strings.Contains(dealerLabelRow, "D E A L E R") {
-		t.Errorf("DEALER label should remain centred between the rules: %q", dealerLabelRow)
+	if !strings.Contains(dealerLabelRow, spacedCaps(rm.dealerName())) {
+		t.Errorf("dealer nameplate should sit centred between the rules: %q", dealerLabelRow)
 	}
 	// The old mid-felt tagline (row 9) must be clear now.
 	if mid := kittest.String(f, 9); strings.Contains(mid, "blackjack pays") {
@@ -1528,3 +1528,77 @@ func TestBrokeSeatRebuysOnRInBetting(t *testing.T) {
 		t.Fatal("re-bought seat still cannot place a bet")
 	}
 }
+
+// TestDealerRotatesWithSpentShoe asserts the dealer changeover rides the shoe:
+// a shoe past its cut card retires the dealer at the next betting window — the
+// roster advances, a fresh shuffle resets the shoe, the hand count restarts,
+// and the changeover is announced on the felt.
+func TestDealerRotatesWithSpentShoe(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	was := rm.dealerIdx
+	rm.sh.pos = rm.sh.cut // cut card reached: the shoe is spent
+	rm.handsThisShoe = 7
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("dealerIdx = %d, want roster advance from %d", rm.dealerIdx, was)
+	}
+	if rm.sh.pos != 0 {
+		t.Fatalf("shoe pos = %d, want 0 (incoming dealer brings a fresh shuffle)", rm.sh.pos)
+	}
+	if rm.handsThisShoe != 0 {
+		t.Fatalf("handsThisShoe = %d, want 0 after the changeover", rm.handsThisShoe)
+	}
+	want := dealerNames[was] + " steps away - " + rm.dealerName() + " takes the shoe"
+	if rm.dealerNote != want {
+		t.Fatalf("dealerNote = %q, want %q", rm.dealerNote, want)
+	}
+	// The announcement renders where the dealer's cards will land.
+	rm.render(tr)
+	if row := kittest.String(tr.LastFrame(a), dealerRow+1); !strings.Contains(row, want) {
+		t.Fatalf("changeover note not on the felt: %q", row)
+	}
+	// A fresh-shoe reopen (nobody bet) must NOT rotate again.
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("reopen rotated the dealer again: idx %d", rm.dealerIdx)
+	}
+}
+
+// TestDealerRotatesAtHandCap asserts a slow-burning shoe still retires its
+// dealer: handsPerDealer rounds dealt swaps the dealer (and the shoe with
+// them) even though the cut card is nowhere near.
+func TestDealerRotatesAtHandCap(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	was := rm.dealerIdx
+	rm.handsThisShoe = handsPerDealer // dealt a full shift; shoe nowhere near the cut
+	rm.enterBetting(tr)
+	if rm.dealerIdx != (was+1)%len(dealerNames) {
+		t.Fatalf("hand cap did not rotate the dealer: idx %d, was %d", rm.dealerIdx, was)
+	}
+	if rm.handsThisShoe != 0 || rm.sh.pos != 0 {
+		t.Fatalf("changeover must reset the count and shoe: hands %d, pos %d", rm.handsThisShoe, rm.sh.pos)
+	}
+}
+
+// TestDealCountsHandsAndClearsNote asserts each deal ticks the rotation
+// counter and retires the changeover announcement.
+func TestDealCountsHandsAndClearsNote(t *testing.T) {
+	a := mkPlayer("a")
+	rm, tr := newGame(t, a)
+	rm.OnJoin(tr, a)
+	s := rm.seats[a.AccountID]
+	fund(tr, s, 1000)
+	s.placed = true
+	rm.dealerNote = "Marge steps away - Dex takes the shoe"
+	rm.deal(tr)
+	if rm.handsThisShoe != 1 {
+		t.Fatalf("handsThisShoe = %d after one deal, want 1", rm.handsThisShoe)
+	}
+	if rm.dealerNote != "" {
+		t.Fatalf("dealerNote survived the deal: %q", rm.dealerNote)
+	}
+}

From 195df873e1577d83e7a18b15df697759e9ddf21e Mon Sep 17 00:00:00 2001
From: Brandon Cook 
Date: Fri, 10 Jul 2026 13:44:37 +1000
Subject: [PATCH 2/2] blackjack + blackjack-challenge: plain-cased dealer
 nameplate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Show the working dealer's name as written ("Vega"), not letter-spaced caps
("V E G A") — reads as a person's name, and drops the spacedCaps helper.

Co-Authored-By: Claude Fable 5 
---
 games/bcook/blackjack-challenge/layout.go    | 16 +---------------
 games/bcook/blackjack-challenge/room_test.go |  2 +-
 games/bcook/blackjack/layout.go              | 16 +---------------
 games/bcook/blackjack/room_test.go           |  2 +-
 4 files changed, 4 insertions(+), 32 deletions(-)

diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go
index 074f1bb..080c3e7 100644
--- a/games/bcook/blackjack-challenge/layout.go
+++ b/games/bcook/blackjack-challenge/layout.go
@@ -90,7 +90,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim)
 	// The centred label is the working dealer's nameplate (dealer.go): the crew
 	// rotates with the shoe, so the name up top says whose shoe this is.
-	center(f, dealerRow-1, spacedCaps(rm.dealerName()), stTitle)
+	center(f, dealerRow-1, rm.dealerName(), stTitle)
 	rm.drawDealer(f)
 
 	// Seats along the rail, centred as a group.
@@ -759,20 +759,6 @@ func center(f *kit.Frame, row int, s string, st kit.Style) {
 	f.Text(row, (kit.Cols-len([]rune(s)))/2, s, st)
 }
 
-// spacedCaps letter-spaces s in caps ("Vega" -> "V E G A"): the felt signage
-// style the fixed D E A L E R label used before the crew carried names.
-func spacedCaps(s string) string {
-	rs := []rune(strings.ToUpper(s))
-	out := make([]rune, 0, len(rs)*2)
-	for i, r := range rs {
-		if i > 0 {
-			out = append(out, ' ')
-		}
-		out = append(out, r)
-	}
-	return string(out)
-}
-
 // centerWithChar centres "
 " on a row: the styled
 // tile cell (width 1, kit v2.9.0) plus its trailing space sit between the two
 // text parts, so a player name in post carries its character right before it.
diff --git a/games/bcook/blackjack-challenge/room_test.go b/games/bcook/blackjack-challenge/room_test.go
index 9449e5d..8283d29 100644
--- a/games/bcook/blackjack-challenge/room_test.go
+++ b/games/bcook/blackjack-challenge/room_test.go
@@ -1453,7 +1453,7 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {
 		t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow)
 	}
-	if !strings.Contains(dealerLabelRow, spacedCaps(rm.dealerName())) {
+	if !strings.Contains(dealerLabelRow, rm.dealerName()) {
 		t.Errorf("dealer nameplate should sit centred between the rules: %q", dealerLabelRow)
 	}
 	// The old mid-felt tagline (row 9) must be clear now.
diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go
index f0989e6..fc7d46b 100644
--- a/games/bcook/blackjack/layout.go
+++ b/games/bcook/blackjack/layout.go
@@ -90,7 +90,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
 	f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim)
 	// The centred label is the working dealer's nameplate (dealer.go): the crew
 	// rotates with the shoe, so the name up top says whose shoe this is.
-	center(f, dealerRow-1, spacedCaps(rm.dealerName()), stTitle)
+	center(f, dealerRow-1, rm.dealerName(), stTitle)
 	rm.drawDealer(f)
 
 	// Seats along the rail, centred as a group.
@@ -802,20 +802,6 @@ func center(f *kit.Frame, row int, s string, st kit.Style) {
 	f.Text(row, (kit.Cols-len([]rune(s)))/2, s, st)
 }
 
-// spacedCaps letter-spaces s in caps ("Vera" -> "V E R A"): the felt signage
-// style the fixed D E A L E R label used before the crew carried names.
-func spacedCaps(s string) string {
-	rs := []rune(strings.ToUpper(s))
-	out := make([]rune, 0, len(rs)*2)
-	for i, r := range rs {
-		if i > 0 {
-			out = append(out, ' ')
-		}
-		out = append(out, r)
-	}
-	return string(out)
-}
-
 // centerWithChar centres "
 " on a row: the styled
 // tile cell (width 1, kit v2.9.0) plus its trailing space sit between the two
 // text parts, so a player name in post carries its character right before it.
diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go
index 3eb84ed..691cd57 100644
--- a/games/bcook/blackjack/room_test.go
+++ b/games/bcook/blackjack/room_test.go
@@ -1274,7 +1274,7 @@ func TestRulesTaglineFlanksTheDealer(t *testing.T) {
 	if !strings.Contains(dealerLabelRow, "dealer stands on 17") {
 		t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow)
 	}
-	if !strings.Contains(dealerLabelRow, spacedCaps(rm.dealerName())) {
+	if !strings.Contains(dealerLabelRow, rm.dealerName()) {
 		t.Errorf("dealer nameplate should sit centred between the rules: %q", dealerLabelRow)
 	}
 	// The old mid-felt tagline (row 9) must be clear now.