diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/baccarat.go | 6 | ||||
| -rw-r--r-- | internal/casino/baccarat_variants_test.go | 13 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 6 | ||||
| -rw-r--r-- | internal/casino/blackjack_rules_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/types.go | 16 | ||||
| -rw-r--r-- | internal/game/baccarat_rules_test.go | 15 | ||||
| -rw-r--r-- | internal/game/blackjack_rules_test.go | 7 | ||||
| -rw-r--r-- | internal/game/casino.go | 4 | ||||
| -rw-r--r-- | internal/game/casino_ux_test.go | 2 | ||||
| -rw-r--r-- | internal/game/craps_variants_test.go | 65 | ||||
| -rw-r--r-- | internal/game/render_baccarat_felt.go | 111 | ||||
| -rw-r--r-- | internal/game/render_baccarat_felt_test.go | 92 | ||||
| -rw-r--r-- | internal/game/render_blackjack_felt.go | 137 | ||||
| -rw-r--r-- | internal/game/render_blackjack_felt_test.go | 84 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 7 | ||||
| -rw-r--r-- | internal/game/vesta_world_test.go | 66 |
16 files changed, 587 insertions, 48 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 0890d59..50ac111 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -72,7 +72,11 @@ func (t *baccaratTable) join(name string) []Event { return []Event{t.private(name, "That baccarat table is full.")} } events := []Event{t.addSeatLocked(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}})} - events = append(events, t.private(name, t.Config.BaccaratRulesText()), t.menuEvent(name)) + // The rules board is a rendered felt (presentation layer); the engine + // only ships the configuration. + rules := t.private(name, "") + rules.Snapshot = &BaccaratRulesView{Config: t.Config} + events = append(events, rules, t.menuEvent(name)) return events } diff --git a/internal/casino/baccarat_variants_test.go b/internal/casino/baccarat_variants_test.go index 4f88227..094412e 100644 --- a/internal/casino/baccarat_variants_test.go +++ b/internal/casino/baccarat_variants_test.go @@ -89,8 +89,17 @@ func TestBaccaratEZLiveSettlementAndOptionalMenus(t *testing.T) { cfg := TableConfig{ID: "ez", Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: BaccaratEZ, Dragon7: true}} table := newBaccaratTestTable(cfg) events := table.join("alice") - if !hasMessage(events, "Variant: EZ Baccarat") || !hasMessage(events, "dragon_7") { - t.Fatal("join missing rules/optional wager") + rulesView := false + for _, e := range events { + if view, ok := e.Snapshot.(*BaccaratRulesView); ok { + rulesView = true + if !view.Config.Baccarat.Dragon7 { + t.Fatal("rules felt missing the enabled Dragon 7 wager") + } + } + } + if !rulesView { + t.Fatal("join missing rules felt") } table.join("bob") forceShoe(table, baccaratCards("A A A A 2 5")...) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 53bee6c..bd5b86b 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -139,7 +139,11 @@ func (t *blackjackTable) join(name string) []Event { return []Event{t.private(name, "That blackjack table is full.")} } events := []Event{t.addSeatLocked(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}})} - events = append(events, t.private(name, t.Config.BlackjackRulesText()), t.menuEvent(name)) + // The rules board is a rendered felt (presentation layer); the engine + // only ships the configuration. + rules := t.private(name, "") + rules.Snapshot = &BlackjackRulesView{Config: t.Config} + events = append(events, rules, t.menuEvent(name)) return events } diff --git a/internal/casino/blackjack_rules_test.go b/internal/casino/blackjack_rules_test.go index 62f4d0a..b0c9459 100644 --- a/internal/casino/blackjack_rules_test.go +++ b/internal/casino/blackjack_rules_test.go @@ -27,7 +27,7 @@ func TestBlackjackRuleCardDefaultsAndOverrides(t *testing.T) { events := table.join("alice") found := false for _, e := range events { - if e.Message == text { + if view, ok := e.Snapshot.(*BlackjackRulesView); ok && view.Config.Blackjack == table.Config.Blackjack { found = true if e.Public { t.Error("rules broadcast to bystanders") @@ -35,7 +35,7 @@ func TestBlackjackRuleCardDefaultsAndOverrides(t *testing.T) { } } if !found { - t.Fatal("joining did not display effective rules") + t.Fatal("joining did not ship the rules view") } } } diff --git a/internal/casino/types.go b/internal/casino/types.go index 62a8f34..ecded85 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -323,6 +323,14 @@ type BlackjackSnapshot struct { func (*BlackjackSnapshot) snapshot() {} +// BlackjackRulesView ships a table's configuration so the presentation layer +// can draw the house-rule felt instead of a raw rules dump. +type BlackjackRulesView struct { + Config TableConfig +} + +func (*BlackjackRulesView) snapshot() {} + // BaccaratBetView lists one participant's wager on a baccarat outcome. type BaccaratBetView struct { Name string @@ -340,6 +348,14 @@ type BaccaratSnapshot struct { func (*BaccaratSnapshot) snapshot() {} +// BaccaratRulesView ships a table's configuration so the presentation layer +// can draw the house-rule felt instead of a raw rules dump. +type BaccaratRulesView struct { + Config TableConfig +} + +func (*BaccaratRulesView) snapshot() {} + type Participant struct { Name string Connected bool 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") } } |
