diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/baccarat.go | 26 | ||||
| -rw-r--r-- | internal/casino/baccarat_rules.go | 56 | ||||
| -rw-r--r-- | internal/casino/baccarat_variants.go | 98 | ||||
| -rw-r--r-- | internal/casino/baccarat_variants_test.go | 139 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 2 | ||||
| -rw-r--r-- | internal/casino/blackjack_rules.go | 66 | ||||
| -rw-r--r-- | internal/casino/blackjack_rules_test.go | 49 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 16 | ||||
| -rw-r--r-- | internal/casino/types.go | 22 | ||||
| -rw-r--r-- | internal/game/baccarat_rules_test.go | 84 | ||||
| -rw-r--r-- | internal/game/blackjack_rules.go | 46 | ||||
| -rw-r--r-- | internal/game/blackjack_rules_test.go | 41 | ||||
| -rw-r--r-- | internal/game/look_target.go | 4 |
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) { |
