aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/rooms.md102
-rw-r--r--data/rooms/casino/6112.yaml4
-rw-r--r--data/rooms/casino/6114.yaml8
-rw-r--r--data/rooms/casino/6116.yaml4
-rw-r--r--data/rooms/casino/6127.yaml8
-rw-r--r--data/rooms/casino/6128.yaml11
-rw-r--r--data/rooms/casino/6129.yaml11
-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
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) {