aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 18:57:30 -0400
committerhistoria <[not public]>2026-09-30 18:57:30 -0400
commita5b9c1052c4640ba519e608e0c97e603ce6aa5ef (patch)
treec48ac64ca8d1ec2505eedd18fb9e2fc39a885689 /internal/game
parent2317022c16b520dd5272facfd5092a4c58cac44b (diff)
downloadthehouseoficarus-a5b9c1052c4640ba519e608e0c97e603ce6aa5ef.tar.gz
feat: blackjack and baccarat rules felt added, casino layout simplified
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/baccarat_rules_test.go15
-rw-r--r--internal/game/blackjack_rules_test.go7
-rw-r--r--internal/game/casino.go4
-rw-r--r--internal/game/casino_ux_test.go2
-rw-r--r--internal/game/craps_variants_test.go65
-rw-r--r--internal/game/render_baccarat_felt.go111
-rw-r--r--internal/game/render_baccarat_felt_test.go92
-rw-r--r--internal/game/render_blackjack_felt.go137
-rw-r--r--internal/game/render_blackjack_felt_test.go84
-rw-r--r--internal/game/render_roulette.go7
-rw-r--r--internal/game/vesta_world_test.go66
11 files changed, 548 insertions, 42 deletions
diff --git a/internal/game/baccarat_rules_test.go b/internal/game/baccarat_rules_test.go
index 14328c1..63e2dc2 100644
--- a/internal/game/baccarat_rules_test.go
+++ b/internal/game/baccarat_rules_test.go
@@ -28,9 +28,10 @@ func TestLookBaccaratFixtureWithoutScenery(t *testing.T) {
for _, target := range []string{"baccarat", "baccarat table", "table", "test", "test table"} {
conn.data = nil
g.doLookTarget(sess, target)
- for _, want := range []string{"Variant: EZ Baccarat", "Number of decks: 6", "Dragon 7 (dragon_7): Enabled; 40:1", "Panda 8 (panda_8): Not offered"} {
- if !strings.Contains(string(conn.data), want) {
- t.Fatalf("look %s missing %q: %s", target, want, conn.data)
+ text := plainRoulette(string(conn.data))
+ for _, want := range []string{"EZ BACCARAT — NO COMMISSION", "DECKS", "6", "DRAGON 7", "40:1", "PANDA 8", "NOT OFFERED"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("look %s missing %q: %s", target, want, text)
}
}
}
@@ -67,12 +68,16 @@ func TestCasinoFloorBaccaratProfiles(t *testing.T) {
events := g.Casino.Join(tc.room, cfg, "alice")
found := false
for _, e := range events {
- if e.Message == cfg.BaccaratRulesText() {
+ if view, ok := e.Snapshot.(*casino.BaccaratRulesView); ok {
found = true
+ art := renderBaccaratRulesFelt(view.Config, "none", true)
+ if !strings.Contains(art, "DRAWING — AUTOMATIC PUNTO BANCO") {
+ t.Fatalf("room %d: rules felt incomplete", tc.room)
+ }
}
}
if !found {
- t.Fatalf("room %d: join missing rule card", tc.room)
+ t.Fatalf("room %d: join missing rules felt", tc.room)
}
if !strings.Contains(cfg.BaccaratRulesText(), tc.spot+"): Enabled") {
t.Fatalf("room %d: missing optional bet", tc.room)
diff --git a/internal/game/blackjack_rules_test.go b/internal/game/blackjack_rules_test.go
index 33598c3..47d057c 100644
--- a/internal/game/blackjack_rules_test.go
+++ b/internal/game/blackjack_rules_test.go
@@ -29,9 +29,10 @@ func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
if !g.lookBlackjackTable(sess, target) {
t.Fatalf("did not match %q", target)
}
- for _, want := range []string{"Number of decks: 1", "Blackjack pays 6:5", "Double after split: Yes"} {
- if !strings.Contains(string(conn.data), want) {
- t.Fatalf("look %s missing %q: %s", target, want, conn.data)
+ text := plainRoulette(string(conn.data))
+ for _, want := range []string{"DECKS", "1", "BLACKJACK PAYS 6 TO 5", "AFTER SPLIT" + strings.Repeat(" ", 21) + "YES"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("look %s missing %q: %s", target, want, text)
}
}
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 744eac6..f29efd6 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -719,6 +719,10 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
return renderSlotSnapshot(s, mode, unicode)
case *casino.BlackjackSnapshot:
return renderBlackjackSnapshotFor(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt, sess.Player.Name)
+ case *casino.BlackjackRulesView:
+ return renderBlackjackRulesFelt(s.Config, mode, unicode)
+ case *casino.BaccaratRulesView:
+ return renderBaccaratRulesFelt(s.Config, mode, unicode)
case *casino.BaccaratSnapshot:
return renderBaccaratSnapshot(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt)
}
diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go
index 0d5ad63..aa98c87 100644
--- a/internal/game/casino_ux_test.go
+++ b/internal/game/casino_ux_test.go
@@ -96,7 +96,7 @@ func TestTableStatusShowsStateAndRules(t *testing.T) {
}
conn.data = nil
g.executeTableStatus(sess, []string{"rules"}, "table rules")
- if !strings.Contains(stripANSI(string(conn.data)), "payout") {
+ if !strings.Contains(stripANSI(string(conn.data)), "BLACKJACK PAYS") {
t.Fatalf("table rules missing: %q", stripANSI(string(conn.data)))
}
}
diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go
index 76d1424..42f3529 100644
--- a/internal/game/craps_variants_test.go
+++ b/internal/game/craps_variants_test.go
@@ -64,40 +64,57 @@ func TestCrapsVariantRendering(t *testing.T) {
func TestShippedCrapsGallery(t *testing.T) {
w := world.New(filepath.Join("..", "..", "data"))
- variants := []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"}
- for i, v := range variants {
- id := 6185 + i
- room, err := w.LoadRoom(id)
- if err != nil {
- t.Fatal(err)
- }
- if len(room.CasinoTables) != 1 {
- t.Fatal(id, "missing table")
+ northVariants := []string{"simplified", "high_point", "crapless"}
+ southVariants := []string{"new_york", "die_rich", "diceless"}
+ for i, v := range northVariants {
+ id := 6186 + i
+ room := loadCrapsRoom(t, w, id, v)
+ if i > 0 && room.Exits["south"].Room != id-1 {
+ t.Fatal(id, "missing return exit")
}
- cfg := room.CasinoTables[0]
- if cfg.Game != casino.GameCraps || cfg.Craps.Variant != v {
- t.Fatal(id, cfg)
+ if i < len(northVariants)-1 && room.Exits["north"].Room != id+1 {
+ t.Fatal(id, "unreachable next table")
}
- if err := cfg.Validate(); err != nil {
- t.Fatal(id, err)
+ }
+ for i, v := range southVariants {
+ id := 6189 + i
+ room := loadCrapsRoom(t, w, id, v)
+ if i == 0 && room.Exits["north"].Room != 6185 {
+ t.Fatal(id, "south gallery unreachable from the bank pit")
}
- if i > 0 && room.Exits["south"].Room != id-1 {
+ if i > 0 && room.Exits["north"].Room != id-1 {
t.Fatal(id, "missing return exit")
}
- if i < len(variants)-1 && room.Exits["north"].Room != id+1 {
+ if i < len(southVariants)-1 && room.Exits["south"].Room != id+1 {
t.Fatal(id, "unreachable next table")
}
- m := casino.NewManager(1)
- m.Join(id, cfg, "a")
- if m.Table(id, cfg.ID) == nil {
- t.Fatal(id, "table registration")
- }
}
- room, err := w.LoadRoom(6182)
+ room := loadCrapsRoom(t, w, 6185, "bank")
+ if room.Exits["west"].Room != 6182 || room.Exits["east"].Room != 6118 {
+ t.Fatal("pit exits missing", room.Exits)
+ }
+}
+
+func loadCrapsRoom(t *testing.T, w *world.World, id int, variant string) *world.Room {
+ t.Helper()
+ room, err := w.LoadRoom(id)
if err != nil {
t.Fatal(err)
}
- if room.Exits["west"].Room != 6185 {
- t.Fatal("gallery unreachable from roulette")
+ if len(room.CasinoTables) != 1 {
+ t.Fatal(id, "missing table")
+ }
+ cfg := room.CasinoTables[0]
+ if cfg.Game != casino.GameCraps || cfg.Craps.Variant != variant {
+ t.Fatal(id, cfg)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(id, err)
+ }
+ m := casino.NewManager(1)
+ m.Join(id, cfg, "a")
+ if m.Table(id, cfg.ID) == nil {
+ t.Fatal(id, "table registration")
}
+ return room
}
diff --git a/internal/game/render_baccarat_felt.go b/internal/game/render_baccarat_felt.go
new file mode 100644
index 0000000..84f91e3
--- /dev/null
+++ b/internal/game/render_baccarat_felt.go
@@ -0,0 +1,111 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// A baccarat felt: the three main wagers as betting boxes, the optional side
+// bets and house settings in labelled boxes, and the punto banco drawing
+// table across the bottom. Reuses the shared felt canvas and palette.
+func renderBaccaratRulesFelt(cfg casino.TableConfig, mode string, unicode bool) string {
+ b := cfg.Baccarat.Normalize()
+ c := newCrapsCanvas(22)
+ // Title block: variant, scoring, naturals.
+ c.box(0, 0, 76, 4)
+ c.center(1, 75, 1, baccaratVariantTitle(b), crapsGold)
+ c.center(1, 75, 2, "ACE 1 | 2-9 FACE | 10/J/Q/K 0 — HIGHEST LAST DIGIT WINS", crapsInk)
+ c.center(1, 75, 3, "AN INITIAL 8 OR 9 ON EITHER HAND STOPS ALL DRAWING", crapsMuted)
+ // Three betting boxes: PLAYER | TIE | BANKER.
+ spots := [3][2]int{{0, 25}, {25, 50}, {50, 76}}
+ for _, x := range spots {
+ c.box(x[0], 4, x[1], 8)
+ }
+ c.center(1, 24, 5, "PLAYER", crapsGold)
+ c.center(26, 49, 5, "TIE", crapsGold)
+ c.center(51, 75, 5, "BANKER", crapsGold)
+ c.center(1, 24, 6, "PAYS 1:1", crapsInk)
+ c.center(26, 49, 6, "PAYS "+ratioText(b.TiePayout), crapsInk)
+ c.center(51, 75, 6, bankerPayoutText(b), crapsInk)
+ c.center(1, 24, 7, "", crapsMuted)
+ c.center(26, 49, 7, "PLAYER & BANKER PUSH", crapsMuted)
+ c.center(51, 75, 7, bankerNoteText(b), crapsMuted)
+ // Side bets and house settings.
+ boxY := [2][2]int{{8, 15}, {8, 15}}
+ xs := [2][2]int{{0, 38}, {38, 76}}
+ for i := range boxY {
+ c.box(xs[i][0], boxY[i][0], xs[i][1], boxY[i][1])
+ }
+ c.center(1, 37, 9, "SIDE BETS", crapsGold)
+ c.center(39, 75, 9, "HOUSE", crapsGold)
+ side := [][2]string{
+ {"PLAYER PAIR", optionalText(b.PairBets, "11:1")},
+ {"BANKER PAIR", optionalText(b.PairBets, "11:1")},
+ {"DRAGON 7", optionalText(b.Dragon7, "40:1")},
+ {"PANDA 8", optionalText(b.Panda8, "25:1")},
+ {"SUPER 6", optionalText(b.Super6Bet, "12:1")},
+ }
+ house := [][2]string{
+ {"DECKS", fmt.Sprintf("%d", b.Decks)},
+ {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"BET LIMITS", betLimitsText(cfg.MinBet, cfg.MaxBet)},
+ {"SEATS", fmt.Sprintf("%d", cfg.MaxPlayers)},
+ {"BETTING WINDOW", fmt.Sprintf("%d TICKS", cfg.BettingTicks)},
+ }
+ for j, row := range side {
+ c.bjRule(0, 38, 10+j, row[0], row[1])
+ }
+ for j, row := range house {
+ c.bjRule(38, 76, 10+j, row[0], row[1])
+ }
+ // Drawing table across the bottom.
+ c.box(0, 15, 76, 21)
+ c.center(1, 75, 16, "DRAWING — AUTOMATIC PUNTO BANCO", crapsGold)
+ draw := []string{
+ "PLAYER: DRAWS 0-5; STANDS 6-7. BANKER VS A STANDING PLAYER: DRAWS 0-5.",
+ "BANKER VS A DRAWING PLAYER: 0-2 ALWAYS DRAWS; 3 DRAWS UNLESS VS 8;",
+ "4 DRAWS VS 2-7; 5 DRAWS VS 4-7; 6 DRAWS VS 6-7; 7 STANDS.",
+ "ONE WAGER PER PLAYER PER ROUND; SIDE BETS SETTLE INDEPENDENTLY.",
+ }
+ for i, line := range draw {
+ c.text(3, 17+i, line, crapsInk)
+ }
+ return c.render(mode, unicode)
+}
+
+func baccaratVariantTitle(b casino.BaccaratConfig) string {
+ switch b.Variant {
+ case casino.BaccaratSuper6:
+ return "SUPER 6 BACCARAT — NO COMMISSION"
+ case casino.BaccaratEZ:
+ return "EZ BACCARAT — NO COMMISSION"
+ default:
+ return "PUNTO BANCO — COMMISSION"
+ }
+}
+func bankerPayoutText(b casino.BaccaratConfig) string {
+ switch b.Variant {
+ case casino.BaccaratSuper6, casino.BaccaratEZ, casino.BaccaratCommission:
+ return "PAYS 1:1"
+ default:
+ return "PAYS " + ratioText(b.BankerPayout)
+ }
+}
+func bankerNoteText(b casino.BaccaratConfig) string {
+ switch b.Variant {
+ case casino.BaccaratSuper6:
+ return "WINNING 6 PAYS 1:2"
+ case casino.BaccaratEZ:
+ return "3-CARD 7 PUSHES"
+ default:
+ return fmt.Sprintf("%s%% COMMISSION", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6g", (1-b.BankerPayout)*100), "0"), "."))
+ }
+}
+func optionalText(enabled bool, payout string) string {
+ if enabled {
+ return payout
+ }
+ return "NOT OFFERED"
+}
diff --git a/internal/game/render_baccarat_felt_test.go b/internal/game/render_baccarat_felt_test.go
new file mode 100644
index 0000000..dabaa36
--- /dev/null
+++ b/internal/game/render_baccarat_felt_test.go
@@ -0,0 +1,92 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func TestBaccaratRulesFeltGeometryAndPhrases(t *testing.T) {
+ cfg := casino.TableConfig{ID: "harbor", Game: casino.GameBaccarat, MinBet: 1, MaxBet: 100, MaxPlayers: 8, BettingTicks: 35,
+ Baccarat: casino.BaccaratConfig{Variant: casino.BaccaratCommission, PairBets: true, Decks: 8, ShuffleAt: 0.25, TiePayout: 8, BankerPayout: 0.95}}
+ for _, unicode := range []bool{false, true} {
+ art := renderBaccaratRulesFelt(cfg, "none", unicode)
+ rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n")
+ if len(rows) != 22 {
+ t.Fatalf("felt height %d", len(rows))
+ }
+ for _, row := range rows {
+ if color.VisibleLen(row) != 77 {
+ t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row)
+ }
+ }
+ for _, want := range []string{
+ "PUNTO BANCO — COMMISSION", "HIGHEST LAST DIGIT WINS", "STOPS ALL DRAWING",
+ "PLAYER", "TIE", "BANKER", "PAYS 1:1", "PAYS 8 TO 1", "5% COMMISSION",
+ "PLAYER & BANKER PUSH",
+ "SIDE BETS", "HOUSE", "PLAYER PAIR", "BANKER PAIR", "11:1",
+ "DRAGON 7", "PANDA 8", "SUPER 6", "NOT OFFERED",
+ "DECKS", "SHUFFLE AT", "BET LIMITS", "1-100 CREDITS", "35 TICKS",
+ "DRAWING — AUTOMATIC PUNTO BANCO", "DRAWS 0-5", "6 DRAWS VS 6-7",
+ "ONE WAGER PER PLAYER PER ROUND",
+ } {
+ if !strings.Contains(art, want) {
+ t.Fatalf("missing %q in unicode=%v felt", want, unicode)
+ }
+ }
+ // The raw dump's phrases must not leak onto the felt.
+ for _, stale := range []string{"Number of decks", "Drawing: Automatic", "Fractional credits:"} {
+ if strings.Contains(art, stale) {
+ t.Fatalf("stale prose %q on felt", stale)
+ }
+ }
+ }
+}
+
+func TestBaccaratRulesFeltFollowsConfig(t *testing.T) {
+ cfg := casino.TableConfig{ID: "crown", Game: casino.GameBaccarat, MinBet: 100, MaxBet: 10000, BettingTicks: 35,
+ Baccarat: casino.BaccaratConfig{Variant: casino.BaccaratEZ, Dragon7: true, Panda8: true, Decks: 6, TiePayout: 9, BankerPayout: 1, ShuffleAt: 0.5}}
+ art := renderBaccaratRulesFelt(cfg, "none", true)
+ for _, want := range []string{"EZ BACCARAT — NO COMMISSION", "PAYS 9 TO 1", "3-CARD 7 PUSHES",
+ "DRAGON 7" + strings.Repeat(" ", 23) + "40:1", "PANDA 8" + strings.Repeat(" ", 24) + "25:1",
+ "100-10000 CREDITS", "50% REMAINING"} {
+ if !strings.Contains(art, want) {
+ t.Fatalf("missing %q: %s", want, art)
+ }
+ }
+ cfg2 := casino.TableConfig{ID: "charter", Game: casino.GameBaccarat,
+ Baccarat: casino.BaccaratConfig{Variant: casino.BaccaratSuper6, Super6Bet: true, TiePayout: 9}}
+ art2 := renderBaccaratRulesFelt(cfg2, "none", true)
+ for _, want := range []string{"SUPER 6 BACCARAT — NO COMMISSION", "WINNING 6 PAYS 1:2", "SUPER 6" + strings.Repeat(" ", 24) + "12:1", "PAYS 9 TO 1"} {
+ if !strings.Contains(art2, want) {
+ t.Fatalf("missing %q: %s", want, art2)
+ }
+ }
+}
+
+func TestBaccaratJoinPrintsFeltNotProse(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ g.Casino.Join(1, casino.TableConfig{ID: "bac", Game: casino.GameBaccarat}, "alice")
+ events := g.Casino.Join(1, casino.TableConfig{ID: "bac", Game: casino.GameBaccarat}, "bob")
+ found := false
+ for _, e := range events {
+ if view, ok := e.Snapshot.(*casino.BaccaratRulesView); ok {
+ found = true
+ art := renderBaccaratRulesFelt(view.Config, "none", true)
+ if !strings.Contains(art, "PUNTO BANCO — COMMISSION") || !strings.Contains(art, "DECKS") {
+ t.Fatal("rules felt missing phrases", art)
+ }
+ if e.Message != "" {
+ t.Fatal("rules event carries prose", e.Message)
+ }
+ }
+ if strings.Contains(e.Message+e.PrivateMessage, "Number of decks") {
+ t.Fatal("raw rules prose leaked onto join", e)
+ }
+ }
+ if !found {
+ t.Fatal("join did not attach the rules felt")
+ }
+}
diff --git a/internal/game/render_blackjack_felt.go b/internal/game/render_blackjack_felt.go
new file mode 100644
index 0000000..41a5382
--- /dev/null
+++ b/internal/game/render_blackjack_felt.go
@@ -0,0 +1,137 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// A blackjack felt: the classic centre-display phrases a real table prints
+// (blackjack payout, dealer draw rule, insurance), then the remaining
+// configurable rules arranged in labelled boxes. It reuses the craps felt
+// canvas and palette: fixed 77-column art, dark green baize, gold headings.
+// Fixed engine behaviour (peek timing, rounding) is left off the felt.
+func renderBlackjackRulesFelt(cfg casino.TableConfig, mode string, unicode bool) string {
+ b := cfg.Blackjack.Normalize()
+ c := newCrapsCanvas(21)
+ // Centre display: the phrases printed on every real blackjack table.
+ c.box(0, 0, 76, 5)
+ c.center(1, 75, 2, "BLACKJACK PAYS "+ratioText(b.BlackjackPayout), crapsGold)
+ dealer := "DEALER MUST STAND ON 17 AND DRAW TO 16"
+ if b.DealerHitsSoft17() {
+ dealer = "DEALER HITS SOFT 17"
+ }
+ c.center(1, 75, 3, dealer, crapsInk)
+ c.center(1, 75, 4, "INSURANCE PAYS "+ratioText(b.InsurancePayout), crapsGold)
+ // Four labelled rule boxes share edges with the centre display above.
+ boxY := [4][2]int{{5, 12}, {5, 12}, {12, 20}, {12, 20}}
+ xs := [4][2]int{{0, 38}, {38, 76}, {0, 38}, {38, 76}}
+ for i := range boxY {
+ c.box(xs[i][0], boxY[i][0], xs[i][1], boxY[i][1])
+ }
+ titles := [4]string{"THE SHOE", "PAYS", "DOUBLE & SPLIT", "HOUSE"}
+ for i, title := range titles {
+ c.center(xs[i][0]+1, xs[i][1]-1, boxY[i][0]+1, title, crapsGold)
+ }
+ rows := [4][][2]string{
+ {
+ {"DECKS", fmt.Sprintf("%d", b.Decks)},
+ {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"SHOE LOW", "RESHUFFLE MID-ROUND"},
+ {"HOLE CARD", "YES (PEEK)"},
+ {"", ""},
+ },
+ {
+ {"ORDINARY WIN", "1:1"},
+ {"TIE", "PUSH"},
+ {"BJ VS BJ", "PUSH"},
+ {"SPLIT-HAND 21", "1:1"},
+ {"SPLIT+DOUBLE 21", splitDouble21Text(b)},
+ {"", ""},
+ },
+ {
+ {"DOUBLE", "ANY TWO, ONE CARD"},
+ {"AFTER SPLIT", yesNoText(b.AllowsDoubleAfterSplit())},
+ {"MAX HANDS", fmt.Sprintf("%d (RESPLIT %s)", b.MaxSplitHands, yesNoText(b.MaxSplitHands > 2))},
+ {"RESPLIT ACES", yesNoText(b.AllowsResplitAces())},
+ {"PAIRS", pairsText(b)},
+ {"SPLIT ACES", "ONE CARD, NO HITS"},
+ },
+ {
+ {"SURRENDER", surrenderText(b)},
+ {"INSURANCE COST", "HALF THE BET"},
+ {"BET LIMITS", betLimitsText(cfg.MinBet, cfg.MaxBet)},
+ {"SEATS", fmt.Sprintf("%d", cfg.MaxPlayers)},
+ {"BETTING WINDOW", fmt.Sprintf("%d TICKS", cfg.BettingTicks)},
+ {"ACTION WINDOW", fmt.Sprintf("%d TICKS", b.ActionTicks)},
+ },
+ }
+ for i, box := range rows {
+ for j, row := range box {
+ if row[0] == "" && row[1] == "" {
+ continue
+ }
+ c.bjRule(xs[i][0], xs[i][1], boxY[i][0]+2+j, row[0], row[1])
+ }
+ }
+ return c.render(mode, unicode)
+}
+
+// bjRule paints one "LABEL ..... VALUE" row inside a box: the label muted at
+// the left margin, the value right-aligned to the box's inner right edge.
+func (c *crapsCanvas) bjRule(x1, x2, y int, label, value string) {
+ c.text(x1+2, y, label, crapsMuted)
+ vx := x2 - 1 - len([]rune(value))
+ if vx < x1+2+len([]rune(label))+1 {
+ vx = x1 + 2 + len([]rune(label)) + 1
+ }
+ c.text(vx, y, value, crapsInk)
+}
+
+// betLimitsText renders the wager range compactly so it fits inside a box.
+func betLimitsText(minBet, maxBet int) string {
+ if maxBet == int(^uint(0)>>1) {
+ return fmt.Sprintf("%d+ CREDITS", minBet)
+ }
+ return fmt.Sprintf("%d-%d CREDITS", minBet, maxBet)
+}
+
+func ratioText(p float64) string {
+ switch p {
+ case 1.5:
+ return "3 TO 2"
+ case 1.2:
+ return "6 TO 5"
+ case 2:
+ return "2 TO 1"
+ case 1:
+ return "1 TO 1"
+ default:
+ return strconv.FormatFloat(p, 'g', -1, 64) + " TO 1"
+ }
+}
+func splitDouble21Text(b casino.BlackjackConfig) string {
+ if b.CountsDoubleSplitBlackjack() {
+ return "BLACKJACK " + ratioText(b.BlackjackPayout)
+ }
+ return "1:1"
+}
+func yesNoText(v bool) string {
+ if v {
+ return "YES"
+ }
+ return "NO"
+}
+func pairsText(b casino.BlackjackConfig) string {
+ if b.AllowsUnlikeTenSplit() {
+ return "RANKS OR TENS"
+ }
+ return "IDENTICAL RANKS"
+}
+func surrenderText(b casino.BlackjackConfig) string {
+ if b.AllowsSurrender() {
+ return "LATE, TWO CARDS"
+ }
+ return "NOT OFFERED"
+}
diff --git a/internal/game/render_blackjack_felt_test.go b/internal/game/render_blackjack_felt_test.go
new file mode 100644
index 0000000..a67251a
--- /dev/null
+++ b/internal/game/render_blackjack_felt_test.go
@@ -0,0 +1,84 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func TestBlackjackRulesFeltGeometryAndPhrases(t *testing.T) {
+ cfg := casino.TableConfig{ID: "midnight", Game: casino.GameBlackjack, MinBet: 100, MaxBet: 10000, MaxPlayers: 6, BettingTicks: 18,
+ Blackjack: casino.BlackjackConfig{ActionTicks: 15, DoubleSplitBlackjack: &[]bool{false}[0]}}
+ for _, unicode := range []bool{false, true} {
+ art := renderBlackjackRulesFelt(cfg, "none", unicode)
+ rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n")
+ if len(rows) != 21 {
+ t.Fatalf("felt height %d", len(rows))
+ }
+ for _, row := range rows {
+ if color.VisibleLen(row) != 77 {
+ t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row)
+ }
+ }
+ for _, want := range []string{
+ "BLACKJACK PAYS 3 TO 2", "DEALER MUST STAND ON 17 AND DRAW TO 16", "INSURANCE PAYS 2 TO 1",
+ "THE SHOE", "PAYS", "DOUBLE & SPLIT", "HOUSE",
+ "DECKS", "SHUFFLE AT", "25% REMAINING", "RESHUFFLE MID-ROUND", "HOLE CARD",
+ "ORDINARY WIN", "SPLIT+DOUBLE 21", "DOUBLE", "ANY TWO, ONE CARD", "MAX HANDS", "ONE CARD, NO HITS",
+ "SURRENDER", "NOT OFFERED", "100-10000 CREDITS", "18 TICKS", "15 TICKS",
+ } {
+ if !strings.Contains(art, want) {
+ t.Fatalf("missing %q in unicode=%v felt", want, unicode)
+ }
+ }
+ // The raw dump's phrases and fixed engine rules must not leak onto the felt.
+ for _, stale := range []string{"Number of decks", "Dealer hole card / peek:", "FRACTIONS", "BJ CHECK", "HOUSE BLACKJACK"} {
+ if strings.Contains(art, stale) {
+ t.Fatalf("stale prose %q on felt", stale)
+ }
+ }
+ }
+}
+
+func TestBlackjackRulesFeltFollowsConfig(t *testing.T) {
+ yes, no := true, false
+ cfg := casino.TableConfig{ID: "high", Game: casino.GameBlackjack, MinBet: 5, MaxBet: 500,
+ Blackjack: casino.BlackjackConfig{Decks: 1, BlackjackPayout: 1.2, DealerHitSoft17: &yes, Surrender: &yes,
+ ShuffleAt: 0.5, MaxSplitHands: 2, DoubleAfterSplit: &no, ResplitAces: &yes, SplitUnlikeTenValues: &no,
+ DoubleSplitBlackjack: &no, InsurancePayout: 2}}
+ art := renderBlackjackRulesFelt(cfg, "none", true)
+ for _, want := range []string{"BLACKJACK PAYS 6 TO 5", "DEALER HITS SOFT 17", "LATE, TWO CARDS", "50% REMAINING",
+ "AFTER SPLIT NO", "RESPLIT ACES YES", "PAIRS IDENTICAL RANKS",
+ "SPLIT+DOUBLE 21 1:1", "5-500 CREDITS"} {
+ if !strings.Contains(art, want) {
+ t.Fatalf("missing %q: %s", want, art)
+ }
+ }
+}
+
+func TestBlackjackJoinPrintsFeltNotProse(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, BettingTicks: 2}, "alice")
+ events := g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, BettingTicks: 2}, "bob")
+ found := false
+ for _, e := range events {
+ if view, ok := e.Snapshot.(*casino.BlackjackRulesView); ok {
+ found = true
+ art := renderBlackjackRulesFelt(view.Config, "none", true)
+ if !strings.Contains(art, "BLACKJACK PAYS 3 TO 2") || !strings.Contains(art, "DECKS") {
+ t.Fatal("rules felt missing phrases", art)
+ }
+ if e.Message != "" {
+ t.Fatal("rules event carries prose", e.Message)
+ }
+ }
+ if strings.Contains(e.Message+e.PrivateMessage, "Number of decks") {
+ t.Fatal("raw rules prose leaked onto join", e)
+ }
+ }
+ if !found {
+ t.Fatal("join did not attach the rules felt")
+ }
+}
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index 1e7f971..0e4b1a3 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -597,6 +597,13 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int
}
func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
+ if cfg.Game == casino.GameBlackjack {
+ // The whole rule card is a rendered felt.
+ return g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg})
+ }
+ if cfg.Game == casino.GameBaccarat {
+ return g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg})
+ }
rules := g.colorize(sess, "casino_action", cfg.RulesText())
if cfg.Game == casino.GameCraps {
snap := &casino.CrapsSnapshot{Phase: "waiting"}
diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go
index 50e799c..1a26bf6 100644
--- a/internal/game/vesta_world_test.go
+++ b/internal/game/vesta_world_test.go
@@ -269,12 +269,11 @@ func TestVestaEarlySkillsAndSeparateServices(t *testing.T) {
func TestVestaCasinoFloorsAndVariants(t *testing.T) {
_, rooms, zones := loadVesta(t)
- grid := world.BuildGrid(6102, func(id int) (*world.Room, bool) {
+ grid := world.BuildGrid(6101, func(id int) (*world.Room, bool) {
r, ok := rooms[id]
return r, ok
}, nil, nil, nil)
games := map[string]int{}
- floors := map[int]int{}
future := 0
soft17, surrender, singleDeck, huff, classic := false, false, false, false, false
for id, r := range rooms {
@@ -284,14 +283,17 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
}
continue
}
- floor := grid.Coord[id][2]
- floors[floor]++
+ // The vestibule stays at street level (below the gaming floor);
+ // every gaming room shares 6101's floor.
+ if id != 6100 && grid.Coord[id][2] != grid.Coord[6101][2] {
+ t.Errorf("casino room %d drifted off the gaming floor", id)
+ }
if strings.Contains(r.Name, "Future ") {
future++
}
count := len(r.CasinoTables) + len(r.CasinoMachines)
- if count > 1 || count > 0 && floor != 0 {
- t.Errorf("room %d: games must have individual ground-floor salons", id)
+ if count > 1 {
+ t.Errorf("room %d: games must have individual salons", id)
}
for _, table := range r.CasinoTables {
games[string(table.Game)]++
@@ -307,11 +309,57 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
classic = classic || machine.Variant == ""
}
}
- if games["blackjack"] < 5 || games["baccarat"] < 3 || games["slots"] < 5 || !soft17 || !surrender || !singleDeck || !huff || !classic {
+ if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic {
t.Errorf("casino variant coverage lost: %v", games)
}
- if future < 15 || floors[0] < 30 || floors[1] < 15 || floors[2] < 15 || floors[3] < 15 {
- t.Errorf("casino needs a large ground floor and substantial expansion floors: floors=%v future=%d", floors, future)
+ if future != 0 {
+ t.Errorf("placeholder galleries remain: %d", future)
+ }
+ // One salon per game sits on the gaming street; each keeps its editions
+ // within a couple of steps north or south, so no variant is ever far away.
+ street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots"}
+ for id, game := range street {
+ r := rooms[id]
+ found := false
+ for _, table := range r.CasinoTables {
+ found = found || string(table.Game) == game
+ }
+ for _, machine := range r.CasinoMachines {
+ found = found || string(machine.Game) == game
+ }
+ if !found {
+ t.Errorf("street room %d lost its %s salon", id, game)
+ }
+ }
+ // Street links: the blackjack salon joins the vestibule by stairs and
+ // the street eastward; the middle salons connect west and east; the
+ // slots salon ends the street.
+ for id, want := range map[int][]world.ExitDir{
+ 6101: {world.Down, world.East},
+ 6127: {world.West, world.East},
+ 6182: {world.West, world.East},
+ 6185: {world.West, world.East},
+ 6118: {world.West},
+ } {
+ r := rooms[id]
+ for _, dir := range want {
+ exit, ok := r.Exits[dir]
+ if !ok || rooms[exit.Room] == nil {
+ t.Errorf("street room %d lost its %s link", id, dir)
+ }
+ }
+ }
+ if rooms[6100].Exits[world.Up].Room != 6101 || rooms[5111].Exits[world.North].Room != 6100 {
+ t.Fatal("vestibule disconnected")
+ }
+ foundBank := false
+ for _, ref := range rooms[6100].Objects {
+ if ref.ID == "bank_booth" {
+ foundBank = true
+ }
+ }
+ if !foundBank {
+ t.Fatal("casino lost its bank booth")
}
}