aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go26
-rw-r--r--internal/casino/baccarat_rules.go56
-rw-r--r--internal/casino/baccarat_variants.go98
-rw-r--r--internal/casino/baccarat_variants_test.go139
-rw-r--r--internal/casino/blackjack.go2
-rw-r--r--internal/casino/blackjack_rules.go66
-rw-r--r--internal/casino/blackjack_rules_test.go49
-rw-r--r--internal/casino/config_validate.go16
-rw-r--r--internal/casino/types.go22
-rw-r--r--internal/game/baccarat_rules_test.go84
-rw-r--r--internal/game/blackjack_rules.go46
-rw-r--r--internal/game/blackjack_rules_test.go41
-rw-r--r--internal/game/look_target.go4
13 files changed, 627 insertions, 22 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 53ed0a9..68f363e 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -19,10 +19,6 @@ const (
baccaratSpotTie = "tie"
)
-func baccaratSpotValid(spot string) bool {
- return spot == baccaratSpotPlayer || spot == baccaratSpotBanker || spot == baccaratSpotTie
-}
-
type baccaratPlayer struct {
Participant
lastBet int
@@ -53,7 +49,7 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
func (t *baccaratTable) menuEvent(name string) Event {
return t.betMenuEvent(name, []MenuCommand{
{Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"},
- {Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"},
+ {Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "Wager on a specific outcome (one wager per round)"},
{Command: "stop", Desc: "Leave the table"},
})
}
@@ -68,7 +64,7 @@ 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.menuEvent(name))
+ events = append(events, t.private(name, t.Config.BaccaratRulesText()), t.menuEvent(name))
return events
}
@@ -100,10 +96,10 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
spot = p.lastSpot
}
if spot == "" {
- return []Event{t.private(name, "Bet on what? Try \"bet <amount> on player\", \"bet <amount> on banker\", or \"bet <amount> on tie\".")}
+ return []Event{t.private(name, "Bet on what? Try bet <amount> on "+t.Rules.betChoices()+".")}
}
- if !baccaratSpotValid(spot) {
- return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie.")}
+ if !t.Rules.allowsSpot(spot) {
+ return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie, or an enabled optional bet. Available: "+t.Rules.betChoices()+".")}
}
if p.HasBet {
return []Event{t.private(name, "You have already bet this round.")}
@@ -163,11 +159,14 @@ func (t *baccaratTable) deal() []Event {
round := t.event("New baccarat hand!", true)
round.Snapshot = t.snapshot(player, banker)
events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)}
+ if explanation := baccaratVariantOutcome(player, banker, t.Rules); explanation != "" {
+ events = append(events, t.event(explanation, true))
+ }
for _, p := range t.players.ordered() {
if !p.HasBet {
continue
}
- result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
+ result, payout, settleErr := settleBaccaratHand(p.lastSpot, p.Wager.Total, player, banker, t.Rules)
// The shared settlement path pays before announcing and leaves the
// stake pending on any failure, so a refund can never be paid on top
// of a payout that was not durable.
@@ -311,8 +310,11 @@ func bankerDraws(bankerTotal, playerThird int) bool {
}
}
-// settleBaccaratBet returns the result label and total payout (stake plus
-// winnings) for one wager. Player and banker bets push on a tie. Winnings
+// settleBaccaratBet is the ordinary main-wager payout helper. Gameplay must
+// use settleBaccaratHand first to apply variant exceptions and optional bets;
+// totals alone cannot identify a three-card Banker seven.
+// It returns the result label and total payout (stake plus winnings).
+// Player and banker bets push on a tie. Winnings
// round down to whole credits, so the banker commission effectively rounds
// up: never bet less than one commission unit.
func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int, error) {
diff --git a/internal/casino/baccarat_rules.go b/internal/casino/baccarat_rules.go
new file mode 100644
index 0000000..82cc294
--- /dev/null
+++ b/internal/casino/baccarat_rules.go
@@ -0,0 +1,56 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+)
+
+// BaccaratRulesText travels with the configured table, never with scenery.
+func (c TableConfig) BaccaratRulesText() string {
+ c = c.Normalize()
+ b := c.Baccarat
+ variant := "Commission punto banco"
+ // Avoid floating-point display noise in the commission percentage.
+ banker := fmt.Sprintf("Banker wins pay %s (commission %s%% of winnings)", payoutRatio(b.BankerPayout), strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", (1-b.BankerPayout)*100), "0"), "."))
+ switch b.Variant {
+ case BaccaratSuper6:
+ variant = "Super 6 / no commission"
+ banker = "Banker wins pay 1:1, except a winning 6 pays 1:2 (two or three cards); no commission"
+ case BaccaratEZ:
+ variant = "EZ Baccarat / no commission"
+ banker = "Banker wins pay 1:1, except a winning three-card 7 pushes; no commission (Player bets still lose)"
+ }
+ optional := func(enabled bool, text string) string {
+ if enabled {
+ return "Enabled; " + text
+ }
+ return "Not offered"
+ }
+ return strings.Join([]string{
+ "Baccarat table " + c.ID + " — rules",
+ "Variant: " + variant,
+ fmt.Sprintf("Number of decks: %d", b.Decks),
+ "Player wins pay 1:1",
+ banker,
+ "Tie pays " + payoutRatio(b.TiePayout) + "; Player and Banker bets push on ties",
+ "Player pair (player_pair): " + optional(b.PairBets, "11:1; first two Player cards have identical rank"),
+ "Banker pair (banker_pair): " + optional(b.PairBets, "11:1; first two Banker cards have identical rank"),
+ "Dragon 7 (dragon_7): " + optional(b.Dragon7, "40:1; Banker wins with a three-card 7"),
+ "Panda 8 (panda_8): " + optional(b.Panda8, "25:1; Player wins with a three-card 8"),
+ "Super 6 bet (super_6): " + optional(b.Super6Bet, "12:1; Banker wins with 6 (two or three cards)"),
+ "Optional bets settle independently; pairs can win on ties; Dragon/Panda/Super 6 require an outright hand win",
+ "Scoring: Ace = 1; 2–9 face value; 10/J/Q/K = 0; highest last digit of the sum wins",
+ "Drawing: Automatic punto banco; no player hit/stand decisions",
+ "Naturals: An initial 8 or 9 on either hand stops all drawing",
+ "Player draws on 0–5, stands on 6–7 (unless a natural ends play)",
+ "Banker, if Player stands: Draw on 0–5, stand on 6–7",
+ "Banker, if Player draws: 0–2 draw; 3 draws except against 8; 4 against 2–7; 5 against 4–7; 6 against 6–7; 7 stands",
+ "Fractional credits: Rounded down; any commission rounds up and is collected on each win",
+ "Wagers: One per player per round, including optional bets; no simultaneous main/optional wagers",
+ "Available bets: " + b.betChoices(),
+ "Bet limits (all wagers): " + betRangeText(c.MinBet, c.MaxBet) + " credits",
+ fmt.Sprintf("Seats: %d; betting window: %d ticks", c.MaxPlayers, c.BettingTicks),
+ fmt.Sprintf("Shuffle threshold: %g%% of shoe remaining; reshuffle before a round if fewer than six cards remain", b.ShuffleAt*100),
+ "Dealing style: Automatic; no player squeeze, shoe passing, or burn-card ceremony",
+ }, "\n")
+}
diff --git a/internal/casino/baccarat_variants.go b/internal/casino/baccarat_variants.go
new file mode 100644
index 0000000..95b5041
--- /dev/null
+++ b/internal/casino/baccarat_variants.go
@@ -0,0 +1,98 @@
+package casino
+
+import "strings"
+
+const (
+ BaccaratCommission = "commission"
+ BaccaratSuper6 = "super_6"
+ BaccaratEZ = "ez_baccarat"
+)
+
+// Optional wagers use fixed, conventional profit odds. They are evaluated
+// independently of the main Banker wager (notably the EZ Dragon 7 push).
+func (c BaccaratConfig) spots() []string {
+ spots := []string{baccaratSpotPlayer, baccaratSpotBanker, baccaratSpotTie}
+ if c.PairBets {
+ spots = append(spots, "player_pair", "banker_pair")
+ }
+ if c.Dragon7 {
+ spots = append(spots, "dragon_7")
+ }
+ if c.Panda8 {
+ spots = append(spots, "panda_8")
+ }
+ if c.Super6Bet {
+ spots = append(spots, "super_6")
+ }
+ return spots
+}
+
+func (c BaccaratConfig) allowsSpot(spot string) bool {
+ for _, allowed := range c.spots() {
+ if spot == allowed {
+ return true
+ }
+ }
+ return false
+}
+
+func (c BaccaratConfig) betChoices() string { return strings.Join(c.spots(), "|") }
+
+func baccaratPair(hand []card) bool {
+ return len(hand) >= 2 && hand[0].rank == hand[1].rank
+}
+
+// settleBaccaratHand needs the complete hands: totals alone cannot identify
+// a three-card seven/eight or an initial pair.
+func settleBaccaratHand(spot string, bet int, player, banker []card, rules BaccaratConfig) (string, int, error) {
+ rules = rules.Normalize()
+ if !rules.allowsSpot(spot) {
+ return "loss", 0, nil
+ }
+ pt, bt := baccaratTotal(player), baccaratTotal(banker)
+ win, odds := false, 0.0
+ switch spot {
+ case "player_pair":
+ win, odds = baccaratPair(player), 11
+ case "banker_pair":
+ win, odds = baccaratPair(banker), 11
+ case "dragon_7":
+ win, odds = bt > pt && bt == 7 && len(banker) == 3, 40
+ case "panda_8":
+ win, odds = pt > bt && pt == 8 && len(player) == 3, 25
+ case "super_6":
+ win, odds = bt > pt && bt == 6, 12
+ default:
+ if spot == baccaratSpotBanker && bt > pt {
+ if rules.Variant == BaccaratEZ && bt == 7 && len(banker) == 3 {
+ return "push", bet, nil
+ }
+ if rules.Variant == BaccaratSuper6 && bt == 6 {
+ // Floor the total return, not a positive-profit minimum: a one-credit
+ // stake returns one credit, not two, on this half-pay outcome.
+ payout, err := MulPayout(bet, 1.5)
+ return "win", payout, err
+ }
+ }
+ return settleBaccaratBet(spot, bet, pt, bt, rules)
+ }
+ if !win {
+ return "loss", 0, nil
+ }
+ payout, err := MulPayout(bet, 1+odds)
+ return "win", payout, err
+}
+
+func baccaratVariantOutcome(player, banker []card, rules BaccaratConfig) string {
+ pt, bt := baccaratTotal(player), baccaratTotal(banker)
+ if bt <= pt {
+ return ""
+ }
+ if rules.Variant == BaccaratEZ && bt == 7 && len(banker) == 3 {
+ return "EZ Baccarat: Banker wins with a three-card 7. Banker bets push; Player bets lose."
+ }
+ if rules.Variant == BaccaratSuper6 && bt == 6 {
+ return "Super 6: Banker wins with 6. Banker bets pay 1:2."
+ }
+ return ""
+}
diff --git a/internal/casino/baccarat_variants_test.go b/internal/casino/baccarat_variants_test.go
new file mode 100644
index 0000000..4f88227
--- /dev/null
+++ b/internal/casino/baccarat_variants_test.go
@@ -0,0 +1,139 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+)
+
+func baccaratCards(ranks string) []card {
+ var cards []card
+ for _, rank := range strings.Fields(ranks) {
+ cards = append(cards, card{rank: rank})
+ }
+ return cards
+}
+
+func TestBaccaratVariantSettlement(t *testing.T) {
+ cases := []struct {
+ name, variant, spot, player, banker, result string
+ bet, payout int
+ }{
+ {"commission six", BaccaratCommission, "banker", "A A 2", "3 3", "win", 100, 195},
+ {"super six two cards", BaccaratSuper6, "banker", "A A 2", "3 3", "win", 100, 150},
+ {"super six three cards", BaccaratSuper6, "banker", "A A 2", "A A 4", "win", 100, 150},
+ {"super six odd stake", BaccaratSuper6, "banker", "A A 2", "3 3", "win", 3, 4},
+ {"super six tiny stake", BaccaratSuper6, "banker", "A A 2", "3 3", "win", 1, 1},
+ {"super six other win", BaccaratSuper6, "banker", "A A 2", "3 4", "win", 100, 200},
+ {"six tied banker", BaccaratSuper6, "banker", "2 4", "3 3", "push", 100, 100},
+ {"six tied tie wager", BaccaratSuper6, "tie", "2 4", "3 3", "win", 100, 900},
+ {"six loses", BaccaratSuper6, "banker", "3 4", "3 3", "loss", 100, 0},
+ {"ez dragon banker", BaccaratEZ, "banker", "A A 2", "A A 5", "push", 100, 100},
+ {"ez dragon player", BaccaratEZ, "player", "A A 2", "A A 5", "loss", 100, 0},
+ {"ez dragon tie", BaccaratEZ, "tie", "A A 2", "A A 5", "loss", 100, 0},
+ {"ez two card seven", BaccaratEZ, "banker", "A A 2", "3 4", "win", 100, 200},
+ {"ez other three card", BaccaratEZ, "banker", "A A 2", "A A 4", "win", 100, 200},
+ {"ez seven loses", BaccaratEZ, "banker", "A A 6", "A A 5", "loss", 100, 0},
+ {"ez seven ties", BaccaratEZ, "tie", "3 4", "A A 5", "win", 100, 900},
+ {"player pair on tie", BaccaratCommission, "player_pair", "3 3", "2 4", "win", 10, 120},
+ {"banker pair loses main hand", BaccaratCommission, "banker_pair", "4 5", "3 3", "win", 10, 120},
+ {"faces not rank pair", BaccaratCommission, "player_pair", "K Q 6", "3 4", "loss", 10, 0},
+ {"third card not pair", BaccaratCommission, "player_pair", "A 2 A", "3 4", "loss", 10, 0},
+ {"dragon wins despite main push", BaccaratEZ, "dragon_7", "A A 2", "A A 5", "win", 10, 410},
+ {"dragon not two card", BaccaratEZ, "dragon_7", "A A 2", "3 4", "loss", 10, 0},
+ {"dragon not tie", BaccaratEZ, "dragon_7", "3 4", "A A 5", "loss", 10, 0},
+ {"panda win", BaccaratEZ, "panda_8", "A A 6", "A A 5", "win", 10, 260},
+ {"panda not natural", BaccaratEZ, "panda_8", "4 4", "3 4", "loss", 10, 0},
+ {"panda not tie", BaccaratEZ, "panda_8", "A A 6", "A A 6", "loss", 10, 0},
+ {"super6 side two card", BaccaratSuper6, "super_6", "A A 2", "3 3", "win", 10, 130},
+ {"super6 side three card", BaccaratSuper6, "super_6", "A A 2", "A A 4", "win", 10, 130},
+ {"super6 side tie", BaccaratSuper6, "super_6", "2 4", "3 3", "loss", 10, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rules := BaccaratConfig{Variant: tc.variant, PairBets: true, Dragon7: true, Panda8: true, Super6Bet: true}
+ result, payout, err := settleBaccaratHand(tc.spot, tc.bet, baccaratCards(tc.player), baccaratCards(tc.banker), rules)
+ if err != nil || result != tc.result || payout != tc.payout {
+ t.Fatalf("got %s/%d/%v; want %s/%d", result, payout, err, tc.result, tc.payout)
+ }
+ })
+ }
+ _, _, err := settleBaccaratHand("dragon_7", MaxCredits, baccaratCards("A A 2"), baccaratCards("A A 5"), BaccaratConfig{Dragon7: true})
+ if err == nil {
+ t.Fatal("optional payout overflow accepted")
+ }
+}
+
+func TestBaccaratVariantValidation(t *testing.T) {
+ for _, variant := range []string{"", BaccaratCommission, BaccaratSuper6, BaccaratEZ} {
+ cfg := TableConfig{ID: "test", Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: variant}}
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if err := cfg.Normalize().Validate(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, rules := range []BaccaratConfig{{Variant: "typo"}, {Variant: BaccaratSuper6, BankerPayout: .95}, {Variant: BaccaratEZ, BankerPayout: .98}, {BankerPayout: 1}} {
+ if err := (TableConfig{ID: "test", Game: GameBaccarat, Baccarat: rules}).Validate(); err == nil {
+ t.Fatalf("accepted %+v", rules)
+ }
+ }
+ for _, rules := range []BaccaratConfig{{Variant: BaccaratEZ}, {PairBets: true}, {Dragon7: true}, {Panda8: true}, {Super6Bet: true}} {
+ if err := (TableConfig{ID: "test", Game: GameBlackjack, Baccarat: rules}).Validate(); err == nil {
+ t.Fatalf("foreign rule accepted: %+v", rules)
+ }
+ }
+}
+
+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")
+ }
+ table.join("bob")
+ forceShoe(table, baccaratCards("A A A A 2 5")...)
+ wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
+ table.bet("alice", "banker", 10, wa)
+ events = table.bet("bob", "dragon_7", 10, wb)
+ if wa.paid != 10 || wb.paid != 410 {
+ t.Fatalf("banker=%d dragon=%d", wa.paid, wb.paid)
+ }
+ if !hasMessage(events, "Banker bets push; Player bets lose") {
+ t.Fatal("special outcome not explained")
+ }
+ table = newBaccaratTestTable(TableConfig{ID: "plain", Game: GameBaccarat})
+ table.join("alice")
+ wallet := &testWallet{credits: 100}
+ events = table.bet("alice", "dragon_7", 10, wallet)
+ if wallet.credits != 100 || table.players.get("alice").HasBet || !hasMessage(events, "Available: player|banker|tie") {
+ t.Fatal("disabled optional bet accepted")
+ }
+}
+
+func TestBaccaratRuleCard(t *testing.T) {
+ for _, variant := range []string{BaccaratCommission, BaccaratSuper6, BaccaratEZ} {
+ cfg := TableConfig{ID: "test", Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: variant, Decks: 6, TiePayout: 9}}
+ text := cfg.BaccaratRulesText()
+ for _, want := range []string{"Number of decks: 6", "Tie pays 9:1", "Player pair (player_pair): Not offered", "Dragon 7 (dragon_7): Not offered", "Panda 8 (panda_8): Not offered", "Super 6 bet (super_6): Not offered", "One per player per round"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("missing %q: %s", want, text)
+ }
+ }
+ switch variant {
+ case BaccaratCommission:
+ if !strings.Contains(text, "commission 5%") {
+ t.Fatal(text)
+ }
+ case BaccaratSuper6:
+ if !strings.Contains(text, "winning 6 pays 1:2") {
+ t.Fatal(text)
+ }
+ case BaccaratEZ:
+ if !strings.Contains(text, "winning three-card 7 pushes") {
+ t.Fatal(text)
+ }
+ }
+ }
+}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index ed56d15..a1cb112 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -119,7 +119,7 @@ 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.menuEvent(name))
+ events = append(events, t.private(name, t.Config.BlackjackRulesText()), t.menuEvent(name))
return events
}
diff --git a/internal/casino/blackjack_rules.go b/internal/casino/blackjack_rules.go
new file mode 100644
index 0000000..8bf63a8
--- /dev/null
+++ b/internal/casino/blackjack_rules.go
@@ -0,0 +1,66 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+)
+
+// BlackjackRulesText is the table's rule card, built from effective defaults.
+// Keep fixed engine rules here too: players should not have to guess them.
+func (c TableConfig) BlackjackRulesText() string {
+ c = c.Normalize()
+ b := c.Blackjack
+ yesNo := func(v bool) string {
+ if v {
+ return "Yes"
+ }
+ return "No"
+ }
+ dealer := "stands on"
+ if b.DealerHitsSoft17() {
+ dealer = "hits"
+ }
+ surrender := "Not offered"
+ if b.AllowsSurrender() {
+ surrender = "Late (original two-card hand only)"
+ }
+ split := "Identical ranks only"
+ if b.AllowsUnlikeTenSplit() {
+ split = "Identical ranks or any two ten-valued cards"
+ }
+ return strings.Join([]string{
+ fmt.Sprintf("Blackjack table %s — rules", c.ID),
+ fmt.Sprintf("Number of decks: %d", b.Decks),
+ "Blackjack pays " + payoutRatio(b.BlackjackPayout),
+ "Dealer " + dealer + " soft 17",
+ "Dealer hole card / peek: Yes; blackjack checked before player actions (after insurance)",
+ "Ordinary wins pay 1:1; ties push; player and dealer blackjack push",
+ "Double down: Any initial two cards; one additional card only",
+ "Double after split: " + yesNo(b.AllowsDoubleAfterSplit()),
+ fmt.Sprintf("Maximum split hands: %d (including original hand)", b.MaxSplitHands),
+ "Re-splitting: " + yesNo(b.MaxSplitHands > 2) + " (within maximum hands)",
+ "Re-splitting aces: " + yesNo(b.AllowsResplitAces()) + " (within maximum hands)",
+ "Split pairs: " + split,
+ "Split aces: One card per ace; no hitting or doubling",
+ "Split-hand 21 pays 1:1, except the optional bonus below",
+ "Split-and-doubled 21 receives blackjack payout: " + yesNo(b.CountsDoubleSplitBlackjack()),
+ "Surrender: " + surrender,
+ "Insurance: Offered against an ace; costs half the original bet; pays " + payoutRatio(b.InsurancePayout),
+ "Fractional credits: Rounded down (including insurance stake and surrender return)",
+ fmt.Sprintf("Shuffle threshold: %g%% of shoe remaining; may shuffle earlier for round coverage", b.ShuffleAt*100),
+ "Shoe exhaustion: Reshuffle mid-round if necessary",
+ "Bet limits: " + betRangeText(c.MinBet, c.MaxBet) + " credits",
+ fmt.Sprintf("Seats: %d; betting window: %d ticks; action window: %d ticks", c.MaxPlayers, c.BettingTicks, b.ActionTicks),
+ }, "\n")
+}
+
+func payoutRatio(p float64) string {
+ switch p {
+ case 1.5:
+ return "3:2"
+ case 1.2:
+ return "6:5"
+ default:
+ return fmt.Sprintf("%g:1", p)
+ }
+}
diff --git a/internal/casino/blackjack_rules_test.go b/internal/casino/blackjack_rules_test.go
new file mode 100644
index 0000000..62f4d0a
--- /dev/null
+++ b/internal/casino/blackjack_rules_test.go
@@ -0,0 +1,49 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBlackjackRuleCardDefaultsAndOverrides(t *testing.T) {
+ yes, no := true, false
+ cases := []struct {
+ cfg BlackjackConfig
+ want []string
+ }{
+ {BlackjackConfig{}, []string{"Number of decks: 8", "Blackjack pays 3:2", "Dealer stands on soft 17", "Double after split: Yes", "Maximum split hands: 4", "Surrender: Not offered"}},
+ {BlackjackConfig{Decks: 1, BlackjackPayout: 1.2, DealerHitSoft17: &yes, DoubleAfterSplit: &no, MaxSplitHands: 2, DoubleSplitBlackjack: &no}, []string{"Number of decks: 1", "Blackjack pays 6:5", "Dealer hits soft 17", "Double after split: No", "Re-splitting: No", "Split-and-doubled 21 receives blackjack payout: No"}},
+ {BlackjackConfig{Decks: 6, ResplitAces: &yes, Surrender: &yes}, []string{"Number of decks: 6", "Re-splitting aces: Yes", "Surrender: Late"}},
+ }
+ for _, tc := range cases {
+ cfg := TableConfig{ID: "test", Game: GameBlackjack, Blackjack: tc.cfg}
+ text := cfg.BlackjackRulesText()
+ for _, want := range tc.want {
+ if !strings.Contains(text, want) {
+ t.Errorf("missing %q in %s", want, text)
+ }
+ }
+ table := newBlackjackTable(42, cfg, newTestRand())
+ events := table.join("alice")
+ found := false
+ for _, e := range events {
+ if e.Message == text {
+ found = true
+ if e.Public {
+ t.Error("rules broadcast to bystanders")
+ }
+ }
+ }
+ if !found {
+ t.Fatal("joining did not display effective rules")
+ }
+ }
+}
+
+func TestSixToFiveNaturalPayout(t *testing.T) {
+ hand := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "K"}}}
+ _, payout, err := settleHand(hand, 20, false, BlackjackConfig{BlackjackPayout: 1.2})
+ if err != nil || payout != 22 {
+ t.Fatalf("payout=%d err=%v", payout, err)
+ }
+}
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index 9ad1776..a35f039 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -128,8 +128,17 @@ func validateBaccaratTable(c TableConfig) error {
if b.TiePayout > maxTiePayout {
return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout)
}
- if b.BankerPayout > maxBankerPayout {
- return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout)
+ switch b.Variant {
+ case "", BaccaratCommission:
+ if b.BankerPayout > maxBankerPayout {
+ return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout)
+ }
+ case BaccaratSuper6, BaccaratEZ:
+ if b.BankerPayout != 0 && b.BankerPayout != 1 {
+ return fmt.Errorf("%s requires banker_payout: 1 (or omit it)", b.Variant)
+ }
+ default:
+ return fmt.Errorf("unsupported baccarat variant %q", b.Variant)
}
// Non-blocking for the same reason as blackjack above.
return nil
@@ -168,7 +177,8 @@ func hasBlackjackRuleBlock(c TableConfig) bool {
func hasBaccaratRuleBlock(c TableConfig) bool {
b := c.Baccarat
- return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0
+ return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0 ||
+ b.Variant != "" || b.PairBets || b.Dragon7 || b.Panda8 || b.Super6Bet
}
func validateShoe(decks int, shuffleAt float64) error {
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 2d4ba02..5efb43e 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -56,8 +56,8 @@ func (c BlackjackConfig) CountsDoubleSplitBlackjack() bool {
func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) }
// AllowsUnlikeTenSplit reports whether any two 10-valued cards may split
-// (K+Q, 10+J). Standard casinos split only identical ranks, so a true value
-// (the default) is player-favourable.
+// (K+Q, 10+J). Casinos vary on whether identical ranks are required; true
+// (the default) is the more permissive setting.
func (c BlackjackConfig) AllowsUnlikeTenSplit() bool { return ruleBool(c.SplitUnlikeTenValues, true) }
func (c BlackjackConfig) Normalize() BlackjackConfig {
@@ -82,10 +82,15 @@ func (c BlackjackConfig) Normalize() BlackjackConfig {
return c
}
-// BaccaratConfig holds punto banco house rules: shoe composition and payout
-// multipliers. Banker wins pay BankerPayout:1 (a 5% commission game is 0.95);
-// tie wins pay TiePayout:1.
+// BaccaratConfig holds punto banco rules, including no-commission exceptions
+// and optional wagers. Commission tables pay BankerPayout:1 (0.95 = 5% fee);
+// no-commission variants pay even money except for their specified exception.
type BaccaratConfig struct {
+ Variant string `yaml:"variant,omitempty"` // commission, super_6, ez_baccarat
+ PairBets bool `yaml:"pair_bets,omitempty"`
+ Dragon7 bool `yaml:"dragon_7,omitempty"`
+ Panda8 bool `yaml:"panda_8,omitempty"`
+ Super6Bet bool `yaml:"super_6_bet,omitempty"`
Decks int `yaml:"decks,omitempty"`
ShuffleAt float64 `yaml:"shuffle_at,omitempty"`
TiePayout float64 `yaml:"tie_payout,omitempty"`
@@ -93,6 +98,9 @@ type BaccaratConfig struct {
}
func (c BaccaratConfig) Normalize() BaccaratConfig {
+ if c.Variant == "" {
+ c.Variant = BaccaratCommission
+ }
if c.Decks <= 0 {
c.Decks = 8
}
@@ -102,7 +110,9 @@ func (c BaccaratConfig) Normalize() BaccaratConfig {
if c.TiePayout <= 0 {
c.TiePayout = 8
}
- if c.BankerPayout <= 0 {
+ if c.Variant == BaccaratSuper6 || c.Variant == BaccaratEZ {
+ c.BankerPayout = 1
+ } else if c.BankerPayout <= 0 {
c.BankerPayout = 0.95
}
return c
diff --git a/internal/game/baccarat_rules_test.go b/internal/game/baccarat_rules_test.go
new file mode 100644
index 0000000..14328c1
--- /dev/null
+++ b/internal/game/baccarat_rules_test.go
@@ -0,0 +1,84 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func TestLookBaccaratFixtureWithoutScenery(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ room := "name: Anywhere\ncasino_tables:\n- id: test\n game: baccarat\n baccarat:\n variant: ez_baccarat\n decks: 6\n dragon_7: true\n"
+ if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 0644); err != nil {
+ t.Fatal(err)
+ }
+ g := &Game{Deps: Deps{World: world.New(dir)}, Casino: casino.NewManager(1)}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ sess.Player.RoomID = 1
+ 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)
+ }
+ }
+ }
+ if g.lookBaccaratTable(sess, "other") {
+ t.Fatal("matched unrelated target")
+ }
+}
+
+func TestCasinoFloorBaccaratProfiles(t *testing.T) {
+ g := &Game{Deps: Deps{World: world.New("../../data")}, Casino: casino.NewManager(1)}
+ cases := []struct {
+ room int
+ variant string
+ decks int
+ tie float64
+ spot string
+ }{
+ {6127, casino.BaccaratCommission, 8, 8, "player_pair"},
+ {6128, casino.BaccaratSuper6, 6, 9, "super_6"},
+ {6129, casino.BaccaratEZ, 8, 8, "dragon_7"},
+ }
+ for _, tc := range cases {
+ table, ok := g.casinoTable(tc.room, "baccarat")
+ if !ok {
+ t.Fatalf("room %d: table unavailable", tc.room)
+ }
+ cfg := table.Config
+ if cfg.Baccarat.Variant != tc.variant || cfg.Baccarat.Decks != tc.decks || cfg.Baccarat.TiePayout != tc.tie {
+ t.Fatalf("room %d: %+v", tc.room, cfg.Baccarat)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ events := g.Casino.Join(tc.room, cfg, "alice")
+ found := false
+ for _, e := range events {
+ if e.Message == cfg.BaccaratRulesText() {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("room %d: join missing rule card", tc.room)
+ }
+ if !strings.Contains(cfg.BaccaratRulesText(), tc.spot+"): Enabled") {
+ t.Fatalf("room %d: missing optional bet", tc.room)
+ }
+ // Each fixture gets a fresh manager so participation ownership does not
+ // prevent the same test player from joining the next table.
+ g.Casino = casino.NewManager(1)
+ }
+}
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
new file mode 100644
index 0000000..61aab47
--- /dev/null
+++ b/internal/game/blackjack_rules.go
@@ -0,0 +1,46 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+// Tables are room fixtures in their own right, not authored scenery objects.
+func (g *Game) lookBlackjackTable(sess *net.Session, target string) bool {
+ return g.lookCardTable(sess, target, casino.GameBlackjack)
+}
+
+func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
+ return g.lookCardTable(sess, target, casino.GameBaccarat)
+}
+
+func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameName) bool {
+ room, err := g.World.LoadRoom(sess.Player.RoomID)
+ if err != nil {
+ return false
+ }
+ target = strings.TrimSpace(strings.ToLower(target))
+ for _, cfg := range room.CasinoTables {
+ if cfg.Game != game {
+ continue
+ }
+ id := strings.ToLower(cfg.ID)
+ if target != string(game) && target != string(game)+" table" && target != id && target != id+" table" && !(target == "table" && len(room.CasinoTables) == 1) {
+ continue
+ }
+ // Use the active configuration: live edits may be pending until a round ends.
+ table, ok := g.casinoTable(room.ID, string(cfg.Game))
+ if !ok {
+ return false
+ }
+ if game == casino.GameBaccarat {
+ sess.WriteLine(table.Config.BaccaratRulesText())
+ } else {
+ sess.WriteLine(table.Config.BlackjackRulesText())
+ }
+ return true
+ }
+ return false
+}
diff --git a/internal/game/blackjack_rules_test.go b/internal/game/blackjack_rules_test.go
new file mode 100644
index 0000000..33598c3
--- /dev/null
+++ b/internal/game/blackjack_rules_test.go
@@ -0,0 +1,41 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: Anywhere\ncasino_tables:\n- id: test\n game: blackjack\n blackjack:\n decks: 1\n blackjack_payout: 1.2\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ g := &Game{Deps: Deps{World: world.New(dir)}, Casino: casino.NewManager(1)}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ sess.Player.RoomID = 1
+ for _, target := range []string{"blackjack", "blackjack table", "table", "test", "test table"} {
+ conn.data = nil
+ 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)
+ }
+ }
+ }
+ if g.lookBlackjackTable(sess, "other") {
+ t.Fatal("matched unrelated target")
+ }
+}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 49a2ec7..76836d6 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,6 +47,10 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) {
+ return
+ }
+
var best *world.MobInstance
bestQ := world.MatchNone
for _, m := range g.MobStore.MobsInRoom(p.RoomID) {