diff options
| -rw-r--r-- | building_guide/rooms.md | 102 | ||||
| -rw-r--r-- | data/rooms/casino/6112.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/casino/6114.yaml | 8 | ||||
| -rw-r--r-- | data/rooms/casino/6116.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/casino/6127.yaml | 8 | ||||
| -rw-r--r-- | data/rooms/casino/6128.yaml | 11 | ||||
| -rw-r--r-- | data/rooms/casino/6129.yaml | 11 | ||||
| -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 |
20 files changed, 750 insertions, 47 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 25c6f5f..af19119 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -282,7 +282,8 @@ Two blackjack knobs are player-favourable and on by default, so set them explicitly when authoring a standard-rules table: - `split_unlike_ten_values: true` lets any two 10-valued cards split - (K+Q, 10+J). Standard play splits only identical ranks. + (K+Q, 10+J). Casinos vary: some permit any ten-valued pair, others + require identical ranks. - `double_split_blackjack: true` pays a 3+ card hand that reaches 21 after a split and double as a blackjack (3:2). Standard play pays 1:1 for any 21 on a split hand, doubled or not. Both raise the table's actual RTP above the @@ -292,7 +293,45 @@ Baccarat's `banker_payout` (0.95 = a 5% commission) rounds the commission UP: a winning banker bet too small to carry a whole credit of commission returns only the stake rather than paying a commission-free 1:1. Payout multipliers are capped (`blackjack_payout`/`insurance_payout` ≤ 5, `tie_payout` ≤ 10, -`banker_payout` ≤ 0.99) so a typo cannot silently create an absurd table. +commission-table `banker_payout` ≤ 0.99) so a typo cannot silently create an +absurd table. No-commission baccarat variants require `banker_payout: 1` +(or omit it); their compensating exceptions are enforced by the engine. + +### Blackjack rule cards and test tables + +`look blackjack`, `look blackjack table`, or `look <table-id>` displays the +fixture's complete effective rule card (including omitted/default settings, +fixed engine rules, limits, and timers). Joining with `play blackjack` also +shows it privately. No scenery object or room-description text is required: +placing a `casino_tables` entry in any room brings the rule card with it. +Currently only one table of each game is allowed per room; use separate rooms +for different blackjack tables. + +The casino has these test profiles: + +| Room | Table | Decks | Blackjack | Soft 17 | DAS | Maximum hands | Re-split aces | Surrender | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 6110 | Classic | 6 | 3:2 | Stand | Yes | 4 | No | No | +| 6112 | Mercy | 6 | 3:2 | Stand | Yes | 4 | Yes | Late | +| 6114 | Single Deck | 1 | 6:5 | Hit | No | 2 | No | No | +| 6116 | Midnight | 8 | 3:2 | Hit | Yes | 4 | No | No | + +All four disable the nonstandard split-and-double blackjack bonus. Use +`blackjack_payout: 1.5` for 3:2 or `1.2` for 6:5. `max_split_hands: 1` +disables splitting; 2 allows one split, and larger values allow re-splitting +within the cap (aces additionally require `resplit_aces`). + +Common casino variations not currently configurable are restricted double-down +totals (e.g. 9–11 or 10–11 only), hitting split aces, disabling insurance, +and a separate even-money action for a natural against an ace. Doubling is +always for the full original stake, not “double for less.” Late surrender is +supported; early surrender is not. Side bets are not implemented. + +Casino-fidelity caveats: fractional credits round down. Shoe coverage may +trigger an early shuffle, and exhaustion rebuilds the shoe mid-round rather +than guaranteeing a casino-style finite shoe for the whole round. A connected +solo player's action timer also has special grace behavior. None of these +mechanics were changed by adding rule cards. At a blackjack table, `bet` uses the configured minimum wager; `bet min`, `bet max`, and `bet <amount>` select the wager explicitly. @@ -318,22 +357,73 @@ casino_tables: min_bet: 1 max_bet: 1000 baccarat: + variant: commission # commission (default), super_6, ez_baccarat decks: 8 shuffle_at: 0.25 - tie_payout: 8 - banker_payout: 0.95 + tie_payout: 8 # 8:1 or 9:1 are common + banker_payout: 0.95 # omit or use 1 for no-commission variants + pair_bets: false # player_pair and banker_pair, 11:1 + dragon_7: false # winning three-card Banker 7, 40:1 + panda_8: false # winning three-card Player 8, 25:1 + super_6_bet: false # winning Banker 6, 12:1 ``` At a baccarat table, `bet <amount> on player`, `bet <amount> on banker`, and `bet <amount> on tie` place wagers (the words and amounts may come in any order, and `min`/`max` work as usual). A bare `bet` repeats the previous -amount and target. Winning player bets pay 1:1; winning banker bets pay -`banker_payout`:1 (0.95 is the classic 5% commission); winning tie bets pay +amount and target. Winning player bets pay 1:1; on commission tables winning +banker bets pay `banker_payout`:1 (0.95 is the classic 5% commission); winning tie bets pay `tie_payout`:1. On a tie, player and banker bets push. When the betting window closes, both hands are dealt by the standard tableau: naturals (8 or 9) stand immediately, the player hand draws on 0-5, and the banker hand follows the usual third-card rules. +#### Baccarat variants, rule cards, and test tables + +- **Commission punto banco:** default 5% commission on Banker winnings; + reduced commission can be configured with `banker_payout` (e.g. 0.98). +- **Super 6 (`super_6`):** no commission; Banker wins pay 1:1 except a + winning Banker 6, with either two or three cards, pays 1:2. +- **EZ Baccarat (`ez_baccarat`):** no commission; Banker wins pay 1:1 except + a winning **three-card** Banker 7 pushes the Banker wager. Player and Tie + wagers still lose on that outcome. A two-card Banker 7 pays normally. + +All variants use the same automatic punto banco drawing tableau. Standard, +mini-, and midi-baccarat mainly differ in seating, limits, dealing pace, and +card handling, not this tableau; they do not need separate settlement engines. +Deck count, cut threshold, limits, seats, and Tie payout remain table-specific. + +Optional wagers default off and may be enabled independently on any variant. +Use `bet 10 on player_pair`, `banker_pair`, `dragon_7`, `panda_8`, or `super_6` +where offered. Pairs mean matching **ranks** of the first two cards (not merely +matching baccarat values); they settle independently of which hand wins and +can win on a tie. Dragon 7, Panda 8, and Super 6 require an outright hand win, +not a tie. Their fixed profit odds are listed in the configuration example. +The separate Super 6 optional bet is not the main Banker's half-pay rule. + +**Current wagering limitation:** one wager per player per round. Optional +bets are selectable wagers, not additions to a simultaneous Player/Banker bet. +All use the table's bet limits. Fractional winnings round down; for exact +5% commissions use multiples of 20 credits, and for half-pay wins use even +stakes. Commission is deducted immediately, not collected later on a ledger. + +`look baccarat`, `look baccarat table`, and `look <table-id>` display the full +rule card, as does joining with `play baccarat`. It lists every supported +setting, including disabled optional bets, automatic drawing rules, rounding, +and operational limits. It belongs to the table and works in any room without +a plaque or special room text. One baccarat table is allowed per room. + +| Room | Table | Variant | Decks | Tie pays | Optional wagers | +| --- | --- | --- | --- | --- | --- | +| 6127 | Harbor | 5% commission | 8 | 8:1 | Player/Banker pair | +| 6128 | Charter | Super 6 | 6 | 9:1 | Pairs, Super 6 | +| 6129 | Crown | EZ Baccarat | 8 | 8:1 | Dragon 7, Panda 8 | + +Not modeled: player squeeze/shoe-passing ceremonies, burn-card rituals, +deferred commission, simultaneous wagers, and other casino-specific side-bet +paytables (such as Dragon Bonus or progressive jackpots). These are not +implied by selecting an implemented variant. + Single-player machines use `casino_machines` and are separate from multiplayer tables: diff --git a/data/rooms/casino/6112.yaml b/data/rooms/casino/6112.yaml index 098ae6a..a3ce9e5 100644 --- a/data/rooms/casino/6112.yaml +++ b/data/rooms/casino/6112.yaml @@ -19,7 +19,7 @@ casino_tables: blackjack_payout: 1.5 dealer_hit_soft_17: false max_split_hands: 4 - resplit_aces: false + resplit_aces: true double_after_split: true double_split_blackjack: false insurance_payout: 2 @@ -29,6 +29,6 @@ casino_tables: objects: - name: rules plaque description: Play blackjack; bet min, bet max or bet <amount>. Blackjack pays 3:2; insurance 2:1. 6 - deck(s). Dealer stands on soft 17. Late surrender offered. Double after split allowed. Split 21 pays + deck(s). Dealer stands on soft 17. Late surrender and re-splitting aces offered. Double after split allowed. Split 21 pays 1:1; split identical ranks only. inroom_description: A rules plaque stands where travelers can read it. diff --git a/data/rooms/casino/6114.yaml b/data/rooms/casino/6114.yaml index 17440d9..ee6ca12 100644 --- a/data/rooms/casino/6114.yaml +++ b/data/rooms/casino/6114.yaml @@ -16,8 +16,8 @@ casino_tables: blackjack: decks: 1 shuffle_at: 0.25 - blackjack_payout: 1.5 - dealer_hit_soft_17: false + blackjack_payout: 1.2 + dealer_hit_soft_17: true max_split_hands: 2 resplit_aces: false double_after_split: false @@ -28,7 +28,7 @@ casino_tables: split_unlike_ten_values: false objects: - name: rules plaque - description: Play blackjack; bet min, bet max or bet <amount>. Blackjack pays 3:2; insurance 2:1. 1 - deck(s). Dealer stands on soft 17. No surrender. No double after split. Split 21 pays 1:1; split identical + description: Play blackjack; bet min, bet max or bet <amount>. Blackjack pays 6:5; insurance 2:1. 1 + deck(s). Dealer hits soft 17. No surrender. No double after split. Split 21 pays 1:1; split identical ranks only. inroom_description: A rules plaque stands where travelers can read it. diff --git a/data/rooms/casino/6116.yaml b/data/rooms/casino/6116.yaml index 283aaea..23f7010 100644 --- a/data/rooms/casino/6116.yaml +++ b/data/rooms/casino/6116.yaml @@ -14,7 +14,7 @@ casino_tables: min_bet: 100 max_bet: 10000 blackjack: - decks: 6 + decks: 8 shuffle_at: 0.25 blackjack_payout: 1.5 dealer_hit_soft_17: true @@ -28,7 +28,7 @@ casino_tables: split_unlike_ten_values: false objects: - name: rules plaque - description: Play blackjack; bet min, bet max or bet <amount>. Blackjack pays 3:2; insurance 2:1. 6 + description: Play blackjack; bet min, bet max or bet <amount>. Blackjack pays 3:2; insurance 2:1. 8 deck(s). Dealer hits soft 17. No surrender. Double after split allowed. Split 21 pays 1:1; split identical ranks only. inroom_description: A rules plaque stands where travelers can read it. diff --git a/data/rooms/casino/6127.yaml b/data/rooms/casino/6127.yaml index 40642f7..b3f24cc 100644 --- a/data/rooms/casino/6127.yaml +++ b/data/rooms/casino/6127.yaml @@ -1,6 +1,6 @@ name: House of Icarus — Harbor Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack - room. Its own rules are engraved at the entrance. + room. A traditional commission baccarat table offers pair bets; look at the table for its rules. exits: east: room: 6108 @@ -12,12 +12,14 @@ casino_tables: max_players: 8 betting_ticks: 35 baccarat: + variant: commission + pair_bets: true decks: 8 shuffle_at: 0.25 tie_payout: 8 banker_payout: 0.95 objects: - name: rules plaque - description: Play baccarat. Bet 1–100 credits on player, banker or tie. Player pays 1:1, banker 0.95:1 - (commission rounded up), tie 8:1. 8 decks; fixed punto banco drawing rules. + description: Play baccarat at the commission table. Look baccarat table for the current rules and + available bets, including player_pair and banker_pair. inroom_description: A rules plaque stands where travelers can read it. diff --git a/data/rooms/casino/6128.yaml b/data/rooms/casino/6128.yaml index 80ec1db..b1d0edf 100644 --- a/data/rooms/casino/6128.yaml +++ b/data/rooms/casino/6128.yaml @@ -1,6 +1,6 @@ name: House of Icarus — Charter Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack - room. Its own rules are engraved at the entrance. + room. A Super 6 no-commission baccarat table waits here; look at the table for its rules. exits: east: room: 6112 @@ -12,12 +12,15 @@ casino_tables: max_players: 8 betting_ticks: 35 baccarat: + variant: super_6 + pair_bets: true + super_6_bet: true decks: 6 shuffle_at: 0.25 tie_payout: 9 - banker_payout: 0.95 + banker_payout: 1 objects: - name: rules plaque - description: Play baccarat. Bet 25–2500 credits on player, banker or tie. Player pays 1:1, banker 0.95:1 - (commission rounded up), tie 9:1. 6 decks; fixed punto banco drawing rules. + description: Play baccarat at the Super 6 table. Look baccarat table for the current rules and + available bets. Winning Banker sixes pay half; the separate super_6 wager pays 12:1. inroom_description: A rules plaque stands where travelers can read it. diff --git a/data/rooms/casino/6129.yaml b/data/rooms/casino/6129.yaml index be6eee4..a491f4f 100644 --- a/data/rooms/casino/6129.yaml +++ b/data/rooms/casino/6129.yaml @@ -1,6 +1,6 @@ name: House of Icarus — Crown Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack - room. Its own rules are engraved at the entrance. + room. An EZ Baccarat no-commission table offers Dragon 7 and Panda 8; look at the table for its rules. exits: east: room: 6116 @@ -12,12 +12,15 @@ casino_tables: max_players: 8 betting_ticks: 35 baccarat: + variant: ez_baccarat + dragon_7: true + panda_8: true decks: 8 shuffle_at: 0.25 tie_payout: 8 - banker_payout: 0.98 + banker_payout: 1 objects: - name: rules plaque - description: Play baccarat. Bet 100–10000 credits on player, banker or tie. Player pays 1:1, banker - 0.98:1 (commission rounded up), tie 8:1. 8 decks; fixed punto banco drawing rules. + description: Play baccarat at the EZ table. Look baccarat table for the current rules and available + bets. A winning three-card Banker seven pushes Banker bets; Dragon 7 and Panda 8 are offered. inroom_description: A rules plaque stands where travelers can read it. 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) { |
