aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-05 16:31:22 -0400
committerhistoria <[not public]>2026-09-05 16:31:22 -0400
commitf4ea40c4b3589104dfaced3481ba19c5735889ae (patch)
tree66ead13206e52a65e64d6ba5d1576b63954682e9
parent9a716a7d2ce514f452b425caedc1a9a15cde09f0 (diff)
downloadthehouseoficarus-f4ea40c4b3589104dfaced3481ba19c5735889ae.tar.gz
fix: max bet no longer listed numerically, non-bettors can leave mid-roundHEADmain
-rw-r--r--building_guide/rooms.md3
-rw-r--r--data/cards.yaml3
-rw-r--r--internal/casino/baccarat.go9
-rw-r--r--internal/casino/baccarat_test.go7
-rw-r--r--internal/casino/blackjack.go23
-rw-r--r--internal/casino/blackjack_test.go21
-rw-r--r--internal/casino/machine.go3
-rw-r--r--internal/casino/machine_test.go18
-rw-r--r--internal/casino/types.go18
-rw-r--r--internal/game/casino_test.go12
-rw-r--r--internal/player/player.go4
11 files changed, 103 insertions, 18 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index cf7aa37..4da4284 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -271,7 +271,8 @@ receive one card each with no further actions beyond a configured re-split.
At a blackjack table, `bet` uses the configured minimum wager; `bet min`,
`bet max`, and `bet <amount>` select the wager explicitly.
-Blackjack card art is loaded from `data/cards.yaml`. Each of the 52 rank/suit
+Card art is loaded from `data/cards.yaml` and is shared by every card game
+(blackjack, baccarat). Each of the 52 rank/suit
keys may define Unicode art for sizes 5, 7, and 9. Missing art uses the
generated renderer. Players can choose `option card_size 1`, `5`, `7`, or `9`;
size 1 uses Unicode playing-card symbols. ASCII mode always uses generated
diff --git a/data/cards.yaml b/data/cards.yaml
index 934e1f5..2b4762a 100644
--- a/data/cards.yaml
+++ b/data/cards.yaml
@@ -1,4 +1,5 @@
-# Unicode blackjack card art. Each card may define 5, 7, and 9 line art.
+# Unicode casino card art, shared by every card game (blackjack, baccarat).
+# Each card may define 5, 7, and 9 line art.
# Keys are rank plus suit: A/K/Q/J/10/2-9 followed by C/D/H/S.
# Missing art uses the generated Unicode card renderer.
# Example override:
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"},