From f4ea40c4b3589104dfaced3481ba19c5735889ae Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 5 Sep 2026 16:31:22 -0400 Subject: fix: max bet no longer listed numerically, non-bettors can leave mid-round --- internal/casino/baccarat.go | 9 ++++++--- internal/casino/baccarat_test.go | 7 +++++++ internal/casino/blackjack.go | 23 +++++++++++++++-------- internal/casino/blackjack_test.go | 21 +++++++++++++++++++++ internal/casino/machine.go | 3 ++- internal/casino/machine_test.go | 18 ++++++++++++++++++ internal/casino/types.go | 18 ++++++++++++++++-- internal/game/casino_test.go | 12 ++++++++++++ internal/player/player.go | 4 ++-- 9 files changed, 99 insertions(+), 16 deletions(-) (limited to 'internal') diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 6a72caa..800b5e6 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -86,10 +86,13 @@ func (t *baccaratTable) join(name string) []Event { func (t *baccaratTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if !t.players.has(name) { + p := t.players.get(name) + if p == nil { return nil } - if t.phase != baccaratWaiting { + // A wager is committed for the round in flight; everyone else may stand + // up whenever they like. + if p.HasBet { return []Event{t.private(name, "You're in the middle of a baccarat round!")} } t.players.remove(name) @@ -120,7 +123,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even return []Event{t.private(name, "You have already bet this round.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, fmt.Sprintf("Baccarat bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet))} + return []Event{t.private(name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))} } wager, ok := wallet.Wager(amount) if !ok { diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index fc12e4b..522f875 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -336,6 +336,13 @@ func TestBaccaratLeaveAndTableFull(t *testing.T) { if events := busy.leave("alice"); !hasMessage(events, "middle of a baccarat round") { t.Fatalf("leave during betting not blocked: %+v", events) } + // A non-bettor may stand up mid-window; the bettor's round continues. + if events := busy.leave("bob"); !hasMessage(events, "You stand up.") { + t.Fatalf("non-bettor leave blocked during betting: %+v", events) + } + if busy.players.count() != 1 { + t.Fatalf("bob's seat was not freed: count=%d", busy.players.count()) + } } func TestBaccaratMenuAndActions(t *testing.T) { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 90d6feb..442d4a2 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -111,18 +111,21 @@ func (t *blackjackTable) join(name string) []Event { func (t *blackjackTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if !t.players.has(name) { + p := t.players.get(name) + if p == nil { return nil } - if t.phase != blackjackWaiting { + // A wager is committed for the round in flight; everyone else may stand + // up whenever they like. Non-bettor removal reuses the disconnect path, + // which fixes up the active seat while a round is in flight. + if p.HasBet { return []Event{t.private(name, "You're in the middle of a blackjack round!")} } - t.players.remove(name) - events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")} - if t.players.count() == 0 { - events = append(events, t.event("The blackjack table closes.", true)) + events := t.markDisconnectedLocked(name) + if len(events) == 0 { + return nil } - return events + return append(events, t.private(name, "You stand up.")) } func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Event { @@ -142,7 +145,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve return []Event{t.private(name, "You have already bet this round.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet))} + return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))} } wager, ok := wallet.Wager(amount) if !ok { @@ -683,6 +686,10 @@ func blackjackResultMessages(name, result string, payout int) (string, string) { func (t *blackjackTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() + return t.markDisconnectedLocked(name) +} + +func (t *blackjackTable) markDisconnectedLocked(name string) []Event { index := t.players.indexOf(name) if index < 0 { return nil diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 510077d..0f7527c 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -323,6 +323,27 @@ func TestBlackjackDisconnectEarlierSeatPreservesTurn(t *testing.T) { } } +func TestBlackjackNonBettorMayLeaveMidRound(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) + table.join("alice") + table.join("bob") + wa := &testWallet{credits: 100} + table.bet("alice", "", 10, wa) // opens the betting window + if events := table.leave("bob"); !hasMessage(events, "You stand up.") { + t.Fatalf("non-bettor leave blocked mid-round: %+v", events) + } + if table.players.count() != 1 { + t.Fatalf("bob's seat was not freed: count=%d", table.players.count()) + } + // The bettor's round still proceeds on the next tick. + if events := table.tick(); !hasSnapshot(events) { + t.Fatalf("round did not start after the non-bettor left: %+v", events) + } + if events := table.leave("alice"); !hasMessage(events, "middle of a blackjack round") { + t.Fatalf("bettor leave not blocked: %+v", events) + } +} + func hasMessage(events []Event, fragment string) bool { for _, event := range events { if contains(event.Message, fragment) || contains(event.PrivateMessage, fragment) { diff --git a/internal/casino/machine.go b/internal/casino/machine.go index c1e93fe..2f8b753 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -36,6 +36,7 @@ type Machine struct { } func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { + cfg = cfg.Normalize() profile := DefaultHuffAndPuffProfile() if cfg.Payout > 0 { profile = ScaleToRTP(profile, cfg.Payout) @@ -70,7 +71,7 @@ func (m *Machine) setBet(name string, amount int, wallet Wallet) []Event { return m.private("You're in the middle of a spin!") } if amount < m.Config.MinBet || amount > m.Config.MaxBet { - return m.private(fmt.Sprintf("Slot machine bets must be between %d and %d.", m.Config.MinBet, m.Config.MaxBet)) + return m.private(fmt.Sprintf("Slot machine bets must be %s.", betRangeText(m.Config.MinBet, m.Config.MaxBet))) } return m.beginSpinLocked(name, amount, wallet, fmt.Sprintf("%s sets the bet to %d and pulls the lever.", name, amount), diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 79d38fc..53fe2e6 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -5,6 +5,24 @@ import ( "testing" ) +func TestBetRangeTextFormatsUnlimitedMax(t *testing.T) { + if got := betRangeText(1, maxBetUnlimited); got != "1 or more (no maximum)" { + t.Fatalf("unlimited range=%q", got) + } + if got := betRangeText(5, 100); got != "between 5 and 100" { + t.Fatalf("bounded range=%q", got) + } +} + +func TestMachineSetBetUnlimitedMaxMessage(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, rand.New(rand.NewSource(7))) + m.player = &Participant{Name: "alice", Connected: true} + events := m.setBet("alice", 0, &testWallet{credits: 100}) + if !hasMessage(events, "Slot machine bets must be 1 or more (no maximum).") { + t.Fatalf("unexpected bet-range message: %+v", events) + } +} + func TestMachineFreeSpinsUseProfileCount(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) m.profile.FreeSpinCount = 5 diff --git a/internal/casino/types.go b/internal/casino/types.go index 52c4bfa..4d5159a 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -1,5 +1,19 @@ package casino +import "fmt" + +// maxBetUnlimited is the MaxBet sentinel meaning "no wager cap". +const maxBetUnlimited = int(^uint(0) >> 1) + +// betRangeText describes the accepted wager range; the unlimited sentinel is +// rendered as "no maximum" instead of a machine-sized integer. +func betRangeText(minBet, maxBet int) string { + if maxBet == maxBetUnlimited { + return fmt.Sprintf("%d or more (no maximum)", minBet) + } + return fmt.Sprintf("between %d and %d", minBet, maxBet) +} + type GameName string const ( @@ -113,7 +127,7 @@ func (c MachineConfig) Normalize() MachineConfig { c.MinBet = 1 } if c.MaxBet <= 0 { - c.MaxBet = int(^uint(0) >> 1) + c.MaxBet = maxBetUnlimited } if c.SpinTicks <= 0 { c.SpinTicks = 2 @@ -138,7 +152,7 @@ func (c TableConfig) Normalize() TableConfig { c.MinBet = 1 } if c.MaxBet <= 0 { - c.MaxBet = int(^uint(0) >> 1) + c.MaxBet = maxBetUnlimited } return c } diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 9f31a37..e43eb05 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -126,6 +126,13 @@ func TestRenderBaccaratSnapshot(t *testing.T) { if !strings.Contains(output, "\033[38;5;") { t.Fatalf("expected colored baccarat art: %q", output) } + // The light card style applies to baccarat hands too, with the half-block + // edges oriented to touch the white body (▄ top, ▀ bottom). + light := renderBaccaratSnapshot(snapshot, "none", true, 7, "light", nil) + lightLines := strings.Split(light, "\n") + if !strings.Contains(lightLines[1], "▄▄▄▄▄▄▄") || !strings.Contains(lightLines[7], "▀▀▀▀▀▀▀") { + t.Fatalf("baccarat light card edges flipped: top=%q bottom=%q", lightLines[1], lightLines[7]) + } for _, line := range strings.Split(output, "\n") { if color.VisibleLen(line) > 80 { t.Fatalf("baccarat line exceeds console width: %d", color.VisibleLen(line)) @@ -194,4 +201,9 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) { if !strings.Contains(lightPlain, "▀▀▀▀▀▀▀") || !strings.Contains(lightPlain, "▄▄▄▄▄▄▄") { t.Fatalf("expected half-height light card edges: %q", lightPlain) } + // The card edges must touch the white body: ▄ blocks on top, ▀ on bottom. + plainLines := strings.Split(lightPlain, "\n") + if !strings.Contains(plainLines[1], "▄▄▄▄▄▄▄") || !strings.Contains(plainLines[7], "▀▀▀▀▀▀▀") { + t.Fatalf("light card edges flipped: top=%q bottom=%q", plainLines[1], plainLines[7]) + } } diff --git a/internal/player/player.go b/internal/player/player.go index 87c11e9..81ef056 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -127,8 +127,8 @@ var OptionDefs = []OptionDef{ {"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"}, {"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, - {"card_size", OptInt, 9, nil, "Blackjack card size (1, 5, 7, or 9)"}, - {"card_style", OptString, "dark", []string{"dark", "light"}, "Blackjack card style"}, + {"card_size", OptInt, 9, nil, "Casino card size (1, 5, 7, or 9)"}, + {"card_style", OptString, "dark", []string{"dark", "light"}, "Casino card style (dark or light)"}, {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"}, {"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"}, -- cgit v1.2.3