aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-08-03 14:08:09 -0400
committerhistoria <[not public]>2026-08-03 14:08:09 -0400
commit612e429de438821ae8e099d6b54f87c11ff6c1b5 (patch)
treead6f9edf74339b9b50f9c7c2cd314ee7b6e04b2c /internal
parentf51424c90dbce7191a31b6e675818c985f0f4539 (diff)
downloadthehouseoficarus-612e429de438821ae8e099d6b54f87c11ff6c1b5.tar.gz
refactor: casino game slop refactor
Diffstat (limited to 'internal')
-rw-r--r--internal/cardart/store.go27
-rw-r--r--internal/casino/betting.go60
-rw-r--r--internal/casino/blackjack.go773
-rw-r--r--internal/casino/blackjack_test.go233
-rw-r--r--internal/casino/machine.go54
-rw-r--r--internal/casino/machine_test.go44
-rw-r--r--internal/casino/manager.go14
-rw-r--r--internal/casino/manager_test.go19
-rw-r--r--internal/casino/slots.go2
-rw-r--r--internal/casino/slots_test.go15
-rw-r--r--internal/casino/table.go115
-rw-r--r--internal/casino/types.go114
-rw-r--r--internal/game/casino.go360
-rw-r--r--internal/game/casino_test.go80
-rw-r--r--internal/game/cmd_option.go20
-rw-r--r--internal/game/cmd_registry.go6
-rw-r--r--internal/game/game.go4
-rw-r--r--internal/game/render_help.go2
-rw-r--r--internal/player/player.go2
19 files changed, 1794 insertions, 150 deletions
diff --git a/internal/cardart/store.go b/internal/cardart/store.go
new file mode 100644
index 0000000..0763e46
--- /dev/null
+++ b/internal/cardart/store.go
@@ -0,0 +1,27 @@
+package cardart
+
+import (
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Store struct {
+ Cards map[string]map[int][]string `yaml:"cards"`
+}
+
+func Load(path string) *Store {
+ store := &Store{Cards: make(map[string]map[int][]string)}
+ data, err := os.ReadFile(path)
+ if err != nil || yaml.Unmarshal(data, store) != nil {
+ return store
+ }
+ return store
+}
+
+func (s *Store) Art(key string, size int) []string {
+ if s == nil || s.Cards == nil {
+ return nil
+ }
+ return s.Cards[key][size]
+}
diff --git a/internal/casino/betting.go b/internal/casino/betting.go
new file mode 100644
index 0000000..3ffee88
--- /dev/null
+++ b/internal/casino/betting.go
@@ -0,0 +1,60 @@
+package casino
+
+// countdown tracks tick-based deadlines for table phases (betting windows,
+// insurance offers, and player action timeouts).
+type countdown struct {
+ clock int
+ deadline int
+}
+
+func (c *countdown) start(ticks int) { c.clock, c.deadline = 0, ticks }
+func (c *countdown) stop() { c.clock, c.deadline = 0, 0 }
+func (c *countdown) tick() { c.clock++ }
+func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline }
+
+// allBet reports whether at least one connected participant exists and every
+// connected participant has placed a bet. Disconnected participants never
+// hold a round open.
+func allBet(seats []*Participant) bool {
+ connected := false
+ for _, p := range seats {
+ if !p.Connected {
+ continue
+ }
+ connected = true
+ if !p.HasBet {
+ return false
+ }
+ }
+ return connected
+}
+
+// anyBet reports whether any participant, connected or not, has a wager
+// committed to the current round.
+func anyBet(seats []*Participant) bool {
+ for _, p := range seats {
+ if p.HasBet {
+ return true
+ }
+ }
+ return false
+}
+
+// sitOutNames lists participants without a bet, in seat order.
+func sitOutNames(seats []*Participant) []string {
+ var names []string
+ for _, p := range seats {
+ if !p.HasBet {
+ names = append(names, p.Name)
+ }
+ }
+ return names
+}
+
+// clearBets resets per-round betting state on every participant.
+func clearBets(seats []*Participant) {
+ for _, p := range seats {
+ p.HasBet = false
+ p.Wager = Wager{}
+ }
+}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
new file mode 100644
index 0000000..a158a24
--- /dev/null
+++ b/internal/casino/blackjack.go
@@ -0,0 +1,773 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "strings"
+ "sync"
+)
+
+type blackjackPhase string
+
+const (
+ blackjackWaiting blackjackPhase = "waiting"
+ blackjackBetting blackjackPhase = "betting"
+ blackjackInsurance blackjackPhase = "insurance"
+ blackjackPlaying blackjackPhase = "playing"
+)
+
+type blackjackCard struct {
+ rank string
+ suit string
+}
+
+func (c blackjackCard) String() string { return c.rank + " of " + c.suit }
+
+func (c blackjackCard) value() int {
+ switch c.rank {
+ case "A":
+ return 11
+ case "K", "Q", "J":
+ return 10
+ default:
+ var value int
+ fmt.Sscanf(c.rank, "%d", &value)
+ return value
+ }
+}
+
+type blackjackHand struct {
+ cards []blackjackCard
+ bet int
+ fromSplit bool
+ doubled bool
+ stood bool
+ bust bool
+ surrendered bool
+}
+
+func (h *blackjackHand) total() (int, bool) {
+ total, aces := 0, 0
+ for _, card := range h.cards {
+ total += card.value()
+ if card.rank == "A" {
+ aces++
+ }
+ }
+ for total > 21 && aces > 0 {
+ total -= 10
+ aces--
+ }
+ soft := aces > 0
+ return total, soft
+}
+
+func (h *blackjackHand) blackjack(rules BlackjackConfig) bool {
+ total, _ := h.total()
+ return len(h.cards) == 2 && !h.fromSplit && total == 21 || rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21
+}
+
+type blackjackPlayer struct {
+ Participant
+ hands []*blackjackHand
+ insurance int
+ declinedInsurance bool
+ lastBet int
+}
+
+type blackjackShoe struct {
+ cards []blackjackCard
+ rng *rand.Rand
+ decks int
+}
+
+func newBlackjackShoe(decks int, rng *rand.Rand) *blackjackShoe {
+ s := &blackjackShoe{rng: rng, decks: decks}
+ s.shuffle()
+ return s
+}
+
+func (s *blackjackShoe) shuffle() {
+ ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
+ suits := []string{"clubs", "diamonds", "hearts", "spades"}
+ s.cards = make([]blackjackCard, 0, s.decks*52)
+ for deck := 0; deck < s.decks; deck++ {
+ for _, suit := range suits {
+ for _, rank := range ranks {
+ s.cards = append(s.cards, blackjackCard{rank: rank, suit: suit})
+ }
+ }
+ }
+ s.rng.Shuffle(len(s.cards), func(i, j int) { s.cards[i], s.cards[j] = s.cards[j], s.cards[i] })
+}
+
+func (s *blackjackShoe) draw(rules BlackjackConfig) blackjackCard {
+ if len(s.cards) <= int(float64(s.decks*52)*rules.ShuffleAt) {
+ s.shuffle()
+ }
+ card := s.cards[len(s.cards)-1]
+ s.cards = s.cards[:len(s.cards)-1]
+ return card
+}
+
+type blackjackTable struct {
+ mu sync.Mutex
+ RoomID int
+ Config TableConfig
+ Rules BlackjackConfig
+ participants map[string]*blackjackPlayer
+ order []string
+ phase blackjackPhase
+ timer countdown
+ activePlayer int
+ activeHand int
+ dealer blackjackHand
+ shoe *blackjackShoe
+}
+
+func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable {
+ cfg = cfg.Normalize()
+ rules := cfg.Blackjack.Normalize()
+ return &blackjackTable{
+ RoomID: roomID, Config: cfg, Rules: rules,
+ participants: make(map[string]*blackjackPlayer), phase: blackjackWaiting,
+ shoe: newBlackjackShoe(rules.Decks, rng),
+ }
+}
+
+func (t *blackjackTable) seats() []*Participant {
+ seats := make([]*Participant, 0, len(t.order))
+ for _, name := range t.order {
+ if p := t.participants[name]; p != nil {
+ seats = append(seats, &p.Participant)
+ }
+ }
+ return seats
+}
+
+func (t *blackjackTable) event(message string, public bool) Event {
+ return Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"}
+}
+
+func (t *blackjackTable) private(name, message string) Event {
+ e := t.event(message, false)
+ e.PrivateTo = name
+ return e
+}
+
+func (t *blackjackTable) playerAction(name, publicMessage, privateMessage string) Event {
+ e := t.event(publicMessage, true)
+ e.PrivateTo = name
+ e.PrivateMessage = privateMessage
+ return e
+}
+
+func (t *blackjackTable) menuEvent(name string) Event {
+ e := t.private(name, t.menu())
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Kind: MenuBlackjackBet}
+ return e
+}
+
+func (t *blackjackTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ if t.phase != blackjackWaiting {
+ e := t.private(name, "You resume your place.")
+ if t.phase != blackjackBetting {
+ e.Blackjack = t.snapshot(false)
+ }
+ return []Event{e}
+ }
+ return []Event{t.menuEvent(name)}
+ }
+ if len(t.participants) >= t.Config.MaxPlayers {
+ return []Event{t.private(name, "That blackjack table is full.")}
+ }
+ t.participants[name] = &blackjackPlayer{Participant: Participant{Name: name, Connected: true}}
+ t.order = append(t.order, name)
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")}
+ events = append(events, t.menuEvent(name))
+ return events
+}
+
+func (t *blackjackTable) leave(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.participants[name]
+ if p == nil {
+ return nil
+ }
+ if t.phase != blackjackWaiting {
+ return []Event{t.private(name, "You're in the middle of a blackjack round!")}
+ }
+ delete(t.participants, name)
+ for i, n := range t.order {
+ if n == name {
+ t.order = append(t.order[:i], t.order[i+1:]...)
+ break
+ }
+ }
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
+ if len(t.participants) == 0 {
+ events = append(events, t.event("The blackjack table closes.", true))
+ }
+ return events
+}
+
+func (t *blackjackTable) bet(name string, amount int, wallet Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.participants[name]
+ if p == nil {
+ return []Event{t.private(name, "You aren't playing blackjack.")}
+ }
+ if t.phase != blackjackWaiting && t.phase != blackjackBetting {
+ return []Event{t.private(name, "That blackjack table isn't accepting bets.")}
+ }
+ if p.HasBet {
+ return []Event{t.private(name, "You have already bet this round.")}
+ }
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet {
+ return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet))}
+ }
+ wager, ok := wallet.Wager(amount)
+ if !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits for that bet.")}
+ }
+ p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))}
+ if len(t.participants) > 1 && t.phase == blackjackWaiting {
+ t.phase = blackjackBetting
+ t.timer.start(t.Config.BettingTicks)
+ events = append(events, t.event(fmt.Sprintf("Blackjack betting is open for %d ticks.", t.Config.BettingTicks), true))
+ }
+ if allBet(t.seats()) {
+ events = append(events, t.startRound()...)
+ }
+ return events
+}
+
+func (t *blackjackTable) tick() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ switch t.phase {
+ case blackjackBetting:
+ t.timer.tick()
+ if allBet(t.seats()) {
+ return t.startRound()
+ }
+ if !t.timer.expired() {
+ return nil
+ }
+ var events []Event
+ for _, name := range sitOutNames(t.seats()) {
+ events = append(events, t.event(fmt.Sprintf("%s sits out this blackjack round.", name), true))
+ }
+ if anyBet(t.seats()) {
+ events = append(events, t.startRound()...)
+ } else {
+ t.resetRound()
+ }
+ return events
+ case blackjackInsurance:
+ if t.insuranceSettled() {
+ return t.finishInsurance()
+ }
+ t.timer.tick()
+ if !t.timer.expired() {
+ return nil
+ }
+ return t.finishInsurance()
+ case blackjackPlaying:
+ if t.bettorCount() <= 1 {
+ return nil
+ }
+ t.timer.tick()
+ if !t.timer.expired() {
+ return nil
+ }
+ name := t.activeName()
+ t.participants[name].hands[t.activeHand].stood = true
+ events := []Event{t.private(name, "Your action timed out; standing.")}
+ events = append(events, t.finishHand()...)
+ return events
+ default:
+ return nil
+ }
+}
+
+func (t *blackjackTable) action(name, action string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.phase == blackjackInsurance {
+ return t.insuranceAction(name, action)
+ }
+ if t.phase != blackjackPlaying || t.activeName() != name {
+ return []Event{t.private(name, "It's not your turn.")}
+ }
+ p := t.participants[name]
+ hand := p.hands[t.activeHand]
+ t.timer.start(t.Rules.ActionTicks)
+ switch action {
+ case "hit":
+ if fromSplitAces(hand) {
+ return []Event{t.private(name, "Split aces cannot be hit.")}
+ }
+ hand.cards = append(hand.cards, t.shoe.draw(t.Rules))
+ total, _ := hand.total()
+ if total > 21 {
+ hand.bust = true
+ }
+ e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1]))
+ e.Blackjack = t.snapshot(false)
+ events := []Event{e}
+ if total >= 21 {
+ events = append(events, t.finishHand()...)
+ } else {
+ events = append(events, t.prompt()...)
+ }
+ return events
+ case "stand":
+ hand.stood = true
+ return t.finishHand()
+ case "double":
+ if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) {
+ return []Event{t.private(name, "You can only double a qualifying hand.")}
+ }
+ if _, ok := p.Wallet.Wager(hand.bet); !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits to double that hand.")}
+ }
+ hand.bet *= 2
+ hand.doubled = true
+ hand.stood = true
+ hand.cards = append(hand.cards, t.shoe.draw(t.Rules))
+ if total, _ := hand.total(); total > 21 {
+ hand.bust = true
+ }
+ e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.")
+ e.Blackjack = t.snapshot(false)
+ return append([]Event{e}, t.finishHand()...)
+ case "split":
+ return t.splitHand(name, p, hand)
+ case "surrender":
+ if !t.Rules.AllowsSurrender() || hand.fromSplit || len(hand.cards) != 2 {
+ return []Event{t.private(name, "Surrender is not available for this hand.")}
+ }
+ hand.surrendered = true
+ return t.finishHand()
+ default:
+ return []Event{t.private(name, "Unknown blackjack action.")}
+ }
+}
+
+// insuranceAction handles commands during the insurance offer window:
+// "insurance" buys cover for half the base bet; any other action declines.
+func (t *blackjackTable) insuranceAction(name, action string) []Event {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ return []Event{t.private(name, "Insurance is only offered to players with a bet this round.")}
+ }
+ if p.insurance > 0 || p.declinedInsurance {
+ return []Event{t.private(name, "You have already decided about insurance.")}
+ }
+ var events []Event
+ if action == "insurance" {
+ amount := p.hands[0].bet / 2
+ if amount <= 0 {
+ return []Event{t.private(name, "Your bet is too small to insure.")}
+ }
+ if _, ok := p.Wallet.Wager(amount); !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits for insurance.")}
+ }
+ p.insurance = amount
+ events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount)))
+ } else {
+ p.declinedInsurance = true
+ events = append(events, t.private(name, "You decline insurance."))
+ }
+ if t.insuranceSettled() {
+ events = append(events, t.finishInsurance()...)
+ }
+ return events
+}
+
+// insuranceSettled reports whether every connected bettor has taken or
+// declined insurance.
+func (t *blackjackTable) insuranceSettled() bool {
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet || !p.Connected {
+ continue
+ }
+ if p.insurance == 0 && !p.declinedInsurance {
+ return false
+ }
+ }
+ return true
+}
+
+// finishInsurance resolves the insurance window: a dealer blackjack settles
+// the round immediately, otherwise play begins.
+func (t *blackjackTable) finishInsurance() []Event {
+ if t.dealerBlackjack() {
+ return t.settle()
+ }
+ t.beginPlay()
+ return t.finishHand()
+}
+
+func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackjackHand) []Event {
+ if len(hand.cards) != 2 || len(p.hands) >= t.Rules.MaxSplitHands || !splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) {
+ return []Event{t.private(name, "That hand cannot be split.")}
+ }
+ if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() {
+ return []Event{t.private(name, "Aces cannot be re-split at this table.")}
+ }
+ if _, ok := p.Wallet.Wager(hand.bet); !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits to split that hand.")}
+ }
+ left := &blackjackHand{cards: []blackjackCard{hand.cards[0]}, bet: hand.bet, fromSplit: true}
+ right := &blackjackHand{cards: []blackjackCard{hand.cards[1]}, bet: hand.bet, fromSplit: true}
+ left.cards = append(left.cards, t.shoe.draw(t.Rules))
+ right.cards = append(right.cards, t.shoe.draw(t.Rules))
+ index := t.activeHand
+ p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...)
+ e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.")
+ e.Blackjack = t.snapshot(false)
+ events := []Event{e}
+ if left.cards[0].rank == "A" {
+ left.stood = !(t.Rules.AllowsResplitAces() && left.cards[1].rank == "A")
+ right.stood = !(t.Rules.AllowsResplitAces() && right.cards[1].rank == "A")
+ events = append(events, t.finishHand()...)
+ } else {
+ events = append(events, t.prompt()...)
+ }
+ return events
+}
+
+func splitCompatible(a, b blackjackCard, unlikeTens bool) bool {
+ if a.rank == b.rank {
+ return true
+ }
+ return unlikeTens && a.value() == 10 && b.value() == 10
+}
+
+func (t *blackjackTable) startRound() []Event {
+ t.dealer = blackjackHand{cards: []blackjackCard{t.shoe.draw(t.Rules), t.shoe.draw(t.Rules)}}
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ continue
+ }
+ p.hands = []*blackjackHand{{cards: []blackjackCard{t.shoe.draw(t.Rules), t.shoe.draw(t.Rules)}, bet: p.Wager.Total}}
+ if p.hands[0].bet/2 <= 0 {
+ // A half-bet that rounds to zero cannot be insured.
+ p.declinedInsurance = true
+ }
+ }
+ roundEvent := t.event("New blackjack hand!", true)
+ roundEvent.Blackjack = t.snapshot(false)
+ events := []Event{roundEvent}
+ if t.dealer.cards[1].rank == "A" {
+ t.phase = blackjackInsurance
+ t.timer.start(t.Rules.ActionTicks)
+ events = append(events, t.event("The dealer shows an ace. Insurance is on offer.", true))
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet || !p.Connected || p.declinedInsurance {
+ continue
+ }
+ events = append(events, t.private(name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2)))
+ }
+ if t.insuranceSettled() {
+ events = append(events, t.finishInsurance()...)
+ }
+ return events
+ }
+ if t.dealerBlackjack() {
+ events = append(events, t.settle()...)
+ return events
+ }
+ t.beginPlay()
+ return append(events, t.finishHand()...)
+}
+
+func (t *blackjackTable) beginPlay() {
+ t.phase = blackjackPlaying
+ t.timer.start(t.Rules.ActionTicks)
+ t.activePlayer, t.activeHand = 0, 0
+}
+
+func (t *blackjackTable) prompt() []Event {
+ name := t.activeName()
+ if name == "" {
+ return t.settle()
+ }
+ p := t.participants[name]
+ h := p.hands[t.activeHand]
+ ranks := make([]string, len(h.cards))
+ for i, card := range h.cards {
+ ranks[i] = card.rank
+ }
+ actions := t.availableActions(p, h)
+ e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", ")))
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions}
+ return []Event{e}
+}
+
+func (t *blackjackTable) availableActions(p *blackjackPlayer, hand *blackjackHand) []string {
+ if fromSplitAces(hand) {
+ // Split aces receive one card each and may only be re-split.
+ actions := []string{"stand"}
+ if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && t.Rules.AllowsResplitAces() &&
+ splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) {
+ actions = append(actions, "split")
+ }
+ return actions
+ }
+ actions := []string{"hit", "stand"}
+ if len(hand.cards) == 2 && !hand.doubled && (!hand.fromSplit || t.Rules.AllowsDoubleAfterSplit()) {
+ actions = append(actions, "double")
+ }
+ if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) &&
+ !(hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces()) {
+ actions = append(actions, "split")
+ }
+ if t.Rules.AllowsSurrender() && !hand.fromSplit && len(hand.cards) == 2 {
+ actions = append(actions, "surrender")
+ }
+ return actions
+}
+
+func fromSplitAces(hand *blackjackHand) bool {
+ return hand.fromSplit && len(hand.cards) > 0 && hand.cards[0].rank == "A"
+}
+
+func (t *blackjackTable) finishHand() []Event {
+ t.skipCompletedHands()
+ if t.activeName() == "" {
+ return t.settle()
+ }
+ return t.prompt()
+}
+
+func (t *blackjackTable) skipCompletedHands() {
+ for t.activePlayer < len(t.order) {
+ p := t.participants[t.order[t.activePlayer]]
+ if p == nil || !p.HasBet {
+ t.activePlayer++
+ t.activeHand = 0
+ continue
+ }
+ for t.activeHand < len(p.hands) {
+ h := p.hands[t.activeHand]
+ total, _ := h.total()
+ if h.stood || h.bust || h.surrendered || total >= 21 || h.blackjack(t.Rules) {
+ t.activeHand++
+ continue
+ }
+ return
+ }
+ t.activePlayer++
+ t.activeHand = 0
+ }
+}
+
+func (t *blackjackTable) activeName() string {
+ if t.activePlayer >= len(t.order) {
+ return ""
+ }
+ return t.order[t.activePlayer]
+}
+
+func (t *blackjackTable) dealerBlackjack() bool {
+ return t.dealer.blackjack(t.Rules)
+}
+
+func (t *blackjackTable) settle() []Event {
+ for {
+ total, soft := t.dealer.total()
+ if total > 17 || total == 17 && (!soft || !t.Rules.DealerHitsSoft17()) {
+ break
+ }
+ t.dealer.cards = append(t.dealer.cards, t.shoe.draw(t.Rules))
+ }
+ dealerTotal, _ := t.dealer.total()
+ dealerEvent := t.event("Dealer's hand:", true)
+ dealerEvent.Blackjack = t.snapshot(true)
+ events := []Event{dealerEvent}
+ dealerBlackjack := t.dealerBlackjack()
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ continue
+ }
+ for _, hand := range p.hands {
+ result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
+ if payout > 0 && p.Wallet != nil {
+ p.Wallet.PayCredits(payout)
+ }
+ publicResult, privateResult := blackjackResultMessages(name, result, payout)
+ settlement := t.playerAction(name, publicResult, privateResult)
+ if result == "blackjack" {
+ settlement.Color = "casino_jackpot_win"
+ } else if result == "win" {
+ settlement.Color = "casino_big_win"
+ }
+ events = append(events, settlement)
+ }
+ if p.insurance > 0 {
+ insurancePayout := 0
+ if dealerBlackjack {
+ insurancePayout = int(math.Round(float64(p.insurance) * (1 + t.Rules.InsurancePayout)))
+ }
+ if insurancePayout > 0 {
+ p.Wallet.PayCredits(insurancePayout)
+ }
+ events = append(events, t.private(name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
+ }
+ }
+ t.resetRound()
+ for _, name := range t.order {
+ if p := t.participants[name]; p != nil && p.Connected {
+ events = append(events, t.menuEvent(name))
+ }
+ }
+ return events
+}
+
+func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int) {
+ total, _ := hand.total()
+ if hand.surrendered {
+ return "surrender", hand.bet / 2
+ }
+ if hand.bust {
+ return "bust", 0
+ }
+ playerBlackjack := hand.blackjack(rules)
+ if playerBlackjack && dealerBlackjack {
+ return "push", hand.bet
+ }
+ if dealerBlackjack {
+ return "dealer blackjack", 0
+ }
+ if playerBlackjack {
+ return "blackjack", int(math.Round(float64(hand.bet) * (1 + rules.BlackjackPayout)))
+ }
+ if total > 21 || dealerTotal > total && dealerTotal <= 21 {
+ return "loss", 0
+ }
+ if dealerTotal == total {
+ return "push", hand.bet
+ }
+ if dealerTotal > 21 {
+ return "win", hand.bet * 2
+ }
+ return "win", hand.bet * 2
+}
+
+func (t *blackjackTable) resetRound() {
+ t.phase = blackjackWaiting
+ t.timer.stop()
+ for _, p := range t.participants {
+ p.HasBet, p.Wager, p.hands, p.insurance, p.declinedInsurance = false, Wager{}, nil, 0, false
+ }
+}
+
+func (t *blackjackTable) rebetAmount(name string) int {
+ if p := t.participants[name]; p != nil && p.lastBet > 0 {
+ return p.lastBet
+ }
+ return t.Config.MinBet
+}
+
+func (t *blackjackTable) bettorCount() int {
+ count := 0
+ for _, p := range t.participants {
+ if p.HasBet {
+ count++
+ }
+ }
+ return count
+}
+
+func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
+ snapshot := &BlackjackSnapshot{}
+ for i, card := range t.dealer.cards {
+ if i == 0 && !revealDealer {
+ snapshot.Dealer = append(snapshot.Dealer, BlackjackCardView{Rank: "?", Hidden: true})
+ continue
+ }
+ snapshot.Dealer = append(snapshot.Dealer, BlackjackCardView{Rank: card.rank, Suit: card.suit})
+ }
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ continue
+ }
+ for _, hand := range p.hands {
+ view := BlackjackPlayerView{Name: name, Score: handScore(hand)}
+ for _, card := range hand.cards {
+ view.Cards = append(view.Cards, BlackjackCardView{Rank: card.rank, Suit: card.suit})
+ }
+ snapshot.Players = append(snapshot.Players, view)
+ }
+ }
+ if len(t.dealer.cards) > 1 {
+ snapshot.DealerShownScore = handScore(&blackjackHand{cards: []blackjackCard{t.dealer.cards[1]}})
+ }
+ snapshot.DealerScore = handScore(&t.dealer)
+ snapshot.DealerRevealed = revealDealer
+ return snapshot
+}
+
+func (t *blackjackTable) menu() string {
+ return "New hand! You can bet, bet <amount> (inc. min/max), or stop."
+}
+
+func handScore(hand *blackjackHand) string {
+ total, soft := hand.total()
+ if !soft {
+ return fmt.Sprintf("%d", total)
+ }
+ return fmt.Sprintf("%d or %d", total-10, total)
+}
+
+func blackjackResultMessages(name, result string, payout int) (string, string) {
+ switch result {
+ case "win", "blackjack":
+ return name + " wins!", fmt.Sprintf("You win! +%d credits", payout)
+ case "push":
+ return name + " pushes.", fmt.Sprintf("You push. +%d credits", payout)
+ default:
+ return name + " loses.", "You lose!"
+ }
+}
+
+func (t *blackjackTable) markDisconnected(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = false
+ }
+ return nil
+}
+
+func (t *blackjackTable) markConnected(name string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ }
+}
+
+func (t *blackjackTable) hasParticipant(name string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.participants[name] != nil
+}
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
new file mode 100644
index 0000000..e8381fb
--- /dev/null
+++ b/internal/casino/blackjack_test.go
@@ -0,0 +1,233 @@
+package casino
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) {
+ hand := &blackjackHand{cards: []blackjackCard{{rank: "A"}, {rank: "6"}}}
+ total, soft := hand.total()
+ if total != 17 || !soft {
+ t.Fatalf("total=%d soft=%v, want soft 17", total, soft)
+ }
+ hand.cards = append(hand.cards, blackjackCard{rank: "K"})
+ total, soft = hand.total()
+ if total != 17 || soft {
+ t.Fatalf("total=%d soft=%v, want hard 17", total, soft)
+ }
+}
+
+func TestBlackjackSettlementRules(t *testing.T) {
+ rules := (BlackjackConfig{}).Normalize()
+ natural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "K"}}}
+ if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 {
+ t.Fatalf("natural result=%s payout=%d", result, payout)
+ }
+ dealerNatural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "Q"}}}
+ if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 {
+ t.Fatalf("dealer natural result=%s payout=%d", result, payout)
+ }
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 {
+ t.Fatalf("dealer blackjack result=%s payout=%d", result, payout)
+ }
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 {
+ t.Fatalf("dealer bust result=%s payout=%d", result, payout)
+ }
+}
+
+func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
+ if events := table.bet("alice", 10, wa); hasMessage(events, "round begins") {
+ t.Fatal("round started before all players bet")
+ }
+ events := table.bet("bob", 10, wb)
+ if !hasMessage(events, "New blackjack hand") {
+ t.Fatalf("round did not start after all players bet: %+v", events)
+ }
+}
+
+func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ }
+ table.order = []string{"alice"}
+ table.phase = blackjackPlaying
+ if events := table.tick(); len(events) != 0 {
+ t.Fatalf("solo action timed out: %+v", events)
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("solo table phase changed to %s", table.phase)
+ }
+}
+
+func TestBlackjackRebetKeepsLastWager(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MinBet: 5, MaxBet: 100}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice"}, lastBet: 25}
+ if got := table.rebetAmount("alice"); got != 25 {
+ t.Fatalf("rebet=%d, want 25", got)
+ }
+}
+
+func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "K"}, {rank: "6"}}}}}
+ table.order = []string{"alice"}
+ table.activePlayer = 0
+ table.activeHand = 0
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ event := table.prompt()[0]
+ if event.Message != "Your turn (K,6).\nActions: hit, stand, double" {
+ t.Fatalf("prompt=%q", event.Message)
+ }
+ if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" ||
+ len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" {
+ t.Fatalf("prompt menu=%+v", event.Menu)
+ }
+}
+
+func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ event := table.menuEvent("alice")
+ if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet {
+ t.Fatalf("menu event=%+v", event.Menu)
+ }
+}
+
+func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "K"}, {rank: "A"}}
+ table.phase = blackjackInsurance
+ events := table.action("alice", "insurance")
+ if w.credits != 95 {
+ t.Fatalf("insurance stake credits=%d, want 95", w.credits)
+ }
+ if w.paid != 15 {
+ t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid)
+ }
+ if !hasMessage(events, "Insurance pays 15 credits.") {
+ t.Fatalf("missing insurance payout message: %+v", events)
+ }
+ if table.phase != blackjackWaiting {
+ t.Fatalf("phase=%s, want waiting after dealer blackjack settles", table.phase)
+ }
+}
+
+func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "9"}, {rank: "A"}}
+ table.phase = blackjackInsurance
+ events := table.action("alice", "stand")
+ if !hasMessage(events, "You decline insurance.") {
+ t.Fatalf("missing decline message: %+v", events)
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("phase=%s, want playing after insurance declined", table.phase)
+ }
+ if !hasMessage(events, "Your turn (10,8).") {
+ t.Fatalf("round did not continue after insurance: %+v", events)
+ }
+}
+
+func TestBlackjackDoubleSetsBust(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ // decks 0 disables the shuffle threshold so the forced card is drawn.
+ table.shoe = &blackjackShoe{cards: []blackjackCard{{rank: "K"}}}
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ hand := table.participants["alice"].hands[0]
+ events := table.action("alice", "double")
+ if !hand.bust {
+ t.Fatal("doubling into 26 did not set the bust flag")
+ }
+ if !hasMessage(events, "loses") {
+ t.Fatalf("busted double did not settle as a loss: %+v", events)
+ }
+}
+
+func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) {
+ resplit := true
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ResplitAces: &resplit}}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []blackjackCard{{rank: "A"}, {rank: "A"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ p := table.participants["alice"]
+ actions := table.availableActions(p, p.hands[0])
+ if len(actions) != 2 || actions[0] != "stand" || actions[1] != "split" {
+ t.Fatalf("split ace actions=%v, want [stand split]", actions)
+ }
+ if events := table.action("alice", "hit"); !hasMessage(events, "Split aces cannot be hit.") {
+ t.Fatalf("hit on split aces not rejected: %+v", events)
+ }
+}
+
+func TestBlackjackBettingIgnoresDisconnected(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ table.markDisconnected("bob")
+ w := &testWallet{credits: 100}
+ events := table.bet("alice", 10, w)
+ if !hasMessage(events, "New blackjack hand") {
+ t.Fatalf("disconnected player stalled the round: %+v", events)
+ }
+}
+
+func TestBlackjackReconnectShowsTableSnapshot(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ events := table.join("alice")
+ if len(events) != 1 || events[0].Blackjack == nil {
+ t.Fatalf("reconnect during play lacked a table snapshot: %+v", events)
+ }
+}
+
+func hasMessage(events []Event, fragment string) bool {
+ for _, event := range events {
+ if contains(event.Message, fragment) || contains(event.PrivateMessage, fragment) {
+ return true
+ }
+ }
+ return false
+}
+
+func contains(value, fragment string) bool {
+ return strings.Contains(value, fragment)
+}
+
+func newTestRand() *rand.Rand { return rand.New(rand.NewSource(7)) }
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index d9fa590..887e154 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -40,6 +40,7 @@ func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine {
if cfg.Payout > 0 {
profile = ScaleToRTP(profile, cfg.Payout)
}
+ profile = profile.Normalize()
return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng}
}
@@ -97,23 +98,14 @@ func (m *Machine) start(name string, wallet Wallet) []Event {
if m.bet <= 0 {
return m.private("Set a wager first with 'bet <amount>'.")
}
- wager := m.bet
- if m.freeSpins > 0 {
- m.spinBet = wager
- } else {
- if _, ok := wallet.Wager(wager); !ok {
- return m.private("You don't have enough chips and credits for that bet.")
- }
- m.spinBet = wager
+ if _, ok := wallet.Wager(m.bet); !ok {
+ return m.private("You don't have enough chips and credits for that bet.")
}
+ m.spinBet = m.bet
m.player.Wallet = wallet
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
m.phase = machineSpinning
- if m.freeSpins > 0 {
- m.freeSpins--
- return m.playerAction(fmt.Sprintf("%s begins a free spin.", name), "You begin a free spin.")
- }
return m.playerAction(fmt.Sprintf("%s pulls the lever on the slots machine.", name), "You pull the lever on the slots machine.")
}
@@ -179,7 +171,7 @@ func (m *Machine) tick() []Event {
m.reel++
message := fmt.Sprintf("Reel %d stops...", m.reel)
events := m.publicSlot(message, m.reel)
- if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel) >= 2 && !m.suspense {
+ if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense {
m.suspense = true
events = append(events, m.public("Two scatters! The remaining reels slow down...")...)
}
@@ -232,19 +224,16 @@ func (m *Machine) finishSpin() []Event {
if credits > m.spinBet {
winEvents[0].Color = "casino_big_win"
}
- if credits >= 2*m.spinBet {
- winEvents[0].Color = "casino_jackpot_win"
- }
events = append(events, winEvents...)
}
if len(m.spin.Wins) > 0 {
- winEvents := m.private(formatWins(m.spin.Wins))
+ winEvents := m.private(formatWins(m.spin.Wins, m.profile.Ways))
winEvents[0].Color = "casino_win"
events = append(events, winEvents...)
}
if m.spin.ScatterCount >= 3 && m.freeSpins == 0 {
- m.freeSpins = 8
- events = append(events, m.public("Three scatter symbols trigger 8 free spins!")...)
+ m.freeSpins = m.profile.FreeSpinCount
+ events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
}
if m.freeSpins > 0 {
m.freeSpins--
@@ -269,6 +258,7 @@ func (m *Machine) leave(name string) []Event {
}
m.player = nil
m.bet = 0
+ m.payoutRemainder = 0
return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name,
Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}}
}
@@ -322,11 +312,11 @@ func (m *Machine) publicSlot(message string, stopped int) []Event {
Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}}
}
-func stoppedScatters(grid [3][5]string, reels int) int {
+func stoppedScatters(grid [3][5]string, reels int, trigger string) int {
count := 0
for row := 0; row < 3; row++ {
for reel := 0; reel < reels && reel < 5; reel++ {
- if grid[row][reel] == "scatter" {
+ if grid[row][reel] == trigger {
count++
}
}
@@ -334,28 +324,22 @@ func stoppedScatters(grid [3][5]string, reels int) int {
return count
}
-func formatWins(wins []SlotWin) string {
- message := "Winning lines/ways (243-way evaluation):\n"
+func formatWins(wins []SlotWin, ways int) string {
+ message := fmt.Sprintf("Winning lines/ways (%d-way evaluation):\n", ways)
for _, win := range wins {
- message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / 243 = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, win.Payout)
+ message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / %d = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, ways, win.Payout)
}
return message[:len(message)-1]
}
+const slotMenuText = "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine"
+
func (m *Machine) privateMenu(prefix string) []Event {
- if m.phase != machineReady {
- events := m.private(prefix)
- events[0].Color = "casino_menu"
- return events
- }
- menu := "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine"
- if prefix == "" {
- prefix = "\n" + menu
- } else {
- prefix += "\n\n" + menu
- }
events := m.private(prefix)
events[0].Color = "casino_menu"
+ if m.phase == machineReady {
+ events[0].Menu = &MenuView{Kind: MenuSlotCommands, Body: slotMenuText}
+ }
return events
}
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
new file mode 100644
index 0000000..fdfa42c
--- /dev/null
+++ b/internal/casino/machine_test.go
@@ -0,0 +1,44 @@
+package casino
+
+import (
+ "math/rand"
+ "testing"
+)
+
+func TestMachineFreeSpinsUseProfileCount(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ m.profile.FreeSpinCount = 5
+ m.player = &Participant{Name: "alice", Connected: true, Wallet: &testWallet{credits: 100}}
+ m.spinBet = 10
+ m.spin = &SlotSpin{ScatterCount: 3}
+ events := m.finishSpin()
+ if m.freeSpins != 4 {
+ t.Fatalf("freeSpins=%d, want 4 remaining after the first of 5 chained spins", m.freeSpins)
+ }
+ if m.phase != machineSpinning {
+ t.Fatalf("phase=%s, want spinning", m.phase)
+ }
+ if !hasMessage(events, "trigger 5 free spins") {
+ t.Fatalf("missing profile-count free spin message: %+v", events)
+ }
+}
+
+func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ m.player = &Participant{Name: "alice", Connected: true}
+ events := m.privateMenu("You sit down at the slot machine.")
+ if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || events[0].Menu.Body == "" {
+ t.Fatalf("slot menu event=%+v", events)
+ }
+}
+
+func TestMachineLeaveResetsPayoutRemainder(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ m.player = &Participant{Name: "alice", Connected: true}
+ m.bet = 10
+ m.payoutRemainder = 0.5
+ m.leave("alice")
+ if m.payoutRemainder != 0 {
+ t.Fatalf("payoutRemainder=%v leaked to the next player", m.payoutRemainder)
+ }
+}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index d6a3825..46ad692 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -165,6 +165,20 @@ func (m *Manager) Bet(roomID int, tableID, name string, amount int, wallet Walle
return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, Player: name, PrivateTo: name, Message: "That game is not available here."}}
}
+func (m *Manager) Action(roomID int, tableID, name, action string) []Event {
+ if table := m.Table(roomID, tableID); table != nil {
+ return table.action(name, action)
+ }
+ return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}}
+}
+
+func (m *Manager) RebetAmount(roomID int, tableID, name string) int {
+ if table := m.Table(roomID, tableID); table != nil {
+ return table.rebetAmount(name)
+ }
+ return 0
+}
+
func (m *Manager) MarkDisconnected(name string) []Event {
m.mu.Lock()
tables := make([]*Table, 0, len(m.tables))
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 245133d..8d6dd3d 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -64,6 +64,25 @@ func TestMultiplePlayersWaitForBets(t *testing.T) {
m.Bet(1, "slots", "bob", 10, wb)
}
+func TestTableBettingIgnoresDisconnected(t *testing.T) {
+ m := NewManager(1)
+ cfg := TableConfig{ID: "slots", Game: GameSlots, BettingTicks: 20}
+ m.Join(1, cfg, "alice")
+ m.Join(1, cfg, "bob")
+ m.MarkDisconnected("bob")
+ w := &testWallet{credits: 100}
+ events := m.Bet(1, "slots", "alice", 10, w)
+ found := false
+ for _, event := range events {
+ if event.Type == EventRoundStarted {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("disconnected player stalled the table's round")
+ }
+}
+
func TestMachineAutobetCountdownAndStop(t *testing.T) {
m := NewManager(1)
cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index 9d53bb1..5c387c8 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -145,7 +145,7 @@ func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []S
for _, symbol := range p.Symbols {
length, ways := matchingLength(grid, symbol.ID)
if length >= 3 && symbol.Payout[length] > 0 {
- payout := float64(bet) * symbol.Payout[length] * float64(ways) / 243.0
+ payout := float64(bet) * symbol.Payout[length] * float64(ways) / float64(p.Ways)
total += payout
wins = append(wins, SlotWin{Symbol: symbol.ID, Length: length, Ways: ways, Multiplier: symbol.Payout[length], Payout: payout})
}
diff --git a/internal/casino/slots_test.go b/internal/casino/slots_test.go
index 22e39bf..47754dc 100644
--- a/internal/casino/slots_test.go
+++ b/internal/casino/slots_test.go
@@ -28,6 +28,21 @@ func TestScaleToRTP(t *testing.T) {
}
}
+func TestEvaluateSlotHonorsProfileWays(t *testing.T) {
+ profile := SlotProfile{Rows: 3, Reels: 5, Ways: 1,
+ Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 2}}}}
+ grid := [3][5]string{}
+ for row := range grid {
+ for reel := range grid[row] {
+ grid[row][reel] = "x"
+ }
+ }
+ payout, _ := evaluateSlot(grid, 10, profile)
+ if payout != 4860 {
+ t.Fatalf("payout=%v, want 4860 (bet 10 x 2 x 243 ways / 1)", payout)
+ }
+}
+
func TestEvaluateSlotReportsWinningWays(t *testing.T) {
profile := SlotProfile{Rows: 3, Reels: 5, Ways: 243,
Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 2}}}}
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 1b9d0b5..e993cb8 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -21,22 +21,39 @@ type Table struct {
participants map[string]*Participant
order []string
phase tablePhase
- deadline int
- clock int
+ timer countdown
rng *rand.Rand
+ blackjack *blackjackTable
}
func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table {
- return &Table{
+ t := &Table{
RoomID: roomID,
Config: cfg,
phase: phaseWaiting,
participants: make(map[string]*Participant),
rng: rng,
}
+ if cfg.Game == GameBlackjack {
+ t.blackjack = newBlackjackTable(roomID, cfg, rng)
+ }
+ return t
+}
+
+func (t *Table) seats() []*Participant {
+ seats := make([]*Participant, 0, len(t.order))
+ for _, name := range t.order {
+ if p := t.participants[name]; p != nil {
+ seats = append(seats, p)
+ }
+ }
+ return seats
}
func (t *Table) join(name string) []Event {
+ if t.blackjack != nil {
+ return t.blackjack.join(name)
+ }
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -52,6 +69,9 @@ func (t *Table) join(name string) []Event {
}
func (t *Table) leave(name string) []Event {
+ if t.blackjack != nil {
+ return t.blackjack.leave(name)
+ }
t.mu.Lock()
defer t.mu.Unlock()
p := t.participants[name]
@@ -76,6 +96,9 @@ func (t *Table) leave(name string) []Event {
}
func (t *Table) bet(name string, amount int, wallet Wallet) []Event {
+ if t.blackjack != nil {
+ return t.blackjack.bet(name, amount, wallet)
+ }
t.mu.Lock()
defer t.mu.Unlock()
p := t.participants[name]
@@ -85,12 +108,12 @@ func (t *Table) bet(name string, amount int, wallet Wallet) []Event {
if t.phase != phaseWaiting && t.phase != phaseBetting {
return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table isn't accepting bets."}}
}
- if amount < t.Config.MinBet || amount > t.Config.MaxBet {
- return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}}
- }
if p.HasBet {
return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You have already bet this round."}}
}
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet {
+ return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}}
+ }
wager, ok := wallet.Wager(amount)
if !ok {
return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You don't have enough chips and credits for that bet."}}
@@ -100,40 +123,39 @@ func (t *Table) bet(name string, amount int, wallet Wallet) []Event {
p.Wallet = wallet
if t.phase == phaseWaiting && len(t.participants) > 1 {
t.phase = phaseBetting
- t.clock = 0
- t.deadline = t.Config.BettingTicks
+ t.timer.start(t.Config.BettingTicks)
}
events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetPlaced, Player: name, Message: fmt.Sprintf("%s bets %d credits at the %s table.", name, amount, t.Config.ID), Public: true}}
- if t.phase == phaseBetting && t.clock == 0 {
- events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.deadline), Public: true})
+ if t.phase == phaseBetting && t.timer.clock == 0 {
+ events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.Config.BettingTicks), Public: true})
}
- if len(t.participants) == 1 || t.allBet() {
+ if len(t.participants) == 1 || allBet(t.seats()) {
events = append(events, t.resolveRound()...)
}
return events
}
func (t *Table) tick() []Event {
+ if t.blackjack != nil {
+ return t.blackjack.tick()
+ }
t.mu.Lock()
defer t.mu.Unlock()
if t.phase != phaseBetting {
return nil
}
- t.clock++
- if t.allBet() {
+ t.timer.tick()
+ if allBet(t.seats()) {
return t.resolveRound()
}
- if t.clock < t.deadline {
+ if !t.timer.expired() {
return nil
}
var events []Event
- for _, name := range t.order {
- p := t.participants[name]
- if p != nil && !p.HasBet {
- events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true})
- }
+ for _, name := range sitOutNames(t.seats()) {
+ events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true})
}
- if t.anyBet() {
+ if anyBet(t.seats()) {
events = append(events, t.resolveRound()...)
} else {
t.resetRound()
@@ -164,36 +186,14 @@ func (t *Table) resolveRound() []Event {
func (t *Table) resetRound() {
t.phase = phaseWaiting
- t.clock = 0
- t.deadline = 0
- for _, p := range t.participants {
- p.HasBet = false
- p.Wager = Wager{}
- }
-}
-
-func (t *Table) allBet() bool {
- if len(t.participants) == 0 {
- return false
- }
- for _, p := range t.participants {
- if !p.HasBet {
- return false
- }
- }
- return true
-}
-
-func (t *Table) anyBet() bool {
- for _, p := range t.participants {
- if p.HasBet {
- return true
- }
- }
- return false
+ t.timer.stop()
+ clearBets(t.seats())
}
func (t *Table) markDisconnected(name string) []Event {
+ if t.blackjack != nil {
+ return t.blackjack.markDisconnected(name)
+ }
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -203,6 +203,10 @@ func (t *Table) markDisconnected(name string) []Event {
}
func (t *Table) markConnected(name string) {
+ if t.blackjack != nil {
+ t.blackjack.markConnected(name)
+ return
+ }
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -211,7 +215,24 @@ func (t *Table) markConnected(name string) {
}
func (t *Table) hasParticipant(name string) bool {
+ if t.blackjack != nil {
+ return t.blackjack.hasParticipant(name)
+ }
t.mu.Lock()
defer t.mu.Unlock()
return t.participants[name] != nil
}
+
+func (t *Table) action(name, action string) []Event {
+ if t.blackjack == nil {
+ return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, PrivateTo: name, Message: "That game has no player actions."}}
+ }
+ return t.blackjack.action(name, action)
+}
+
+func (t *Table) rebetAmount(name string) int {
+ if t.blackjack != nil {
+ return t.blackjack.rebetAmount(name)
+ }
+ return t.Config.MinBet
+}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index f284869..1c4326a 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -1,20 +1,73 @@
package casino
-import "fmt"
-
type GameName string
const (
- GameSlots GameName = "slots"
+ GameSlots GameName = "slots"
+ GameBlackjack GameName = "blackjack"
)
+type BlackjackConfig struct {
+ Decks int `yaml:"decks,omitempty"`
+ ShuffleAt float64 `yaml:"shuffle_at,omitempty"`
+ BlackjackPayout float64 `yaml:"blackjack_payout,omitempty"`
+ DealerHitSoft17 *bool `yaml:"dealer_hit_soft_17,omitempty"`
+ MaxSplitHands int `yaml:"max_split_hands,omitempty"`
+ ResplitAces *bool `yaml:"resplit_aces,omitempty"`
+ DoubleAfterSplit *bool `yaml:"double_after_split,omitempty"`
+ DoubleSplitBlackjack *bool `yaml:"double_split_blackjack,omitempty"`
+ InsurancePayout float64 `yaml:"insurance_payout,omitempty"`
+ Surrender *bool `yaml:"surrender,omitempty"`
+ ActionTicks int `yaml:"action_ticks,omitempty"`
+ SplitUnlikeTenValues *bool `yaml:"split_unlike_ten_values,omitempty"`
+}
+
+func ruleBool(value *bool, defaultValue bool) bool {
+ if value == nil {
+ return defaultValue
+ }
+ return *value
+}
+
+func (c BlackjackConfig) DealerHitsSoft17() bool { return ruleBool(c.DealerHitSoft17, false) }
+func (c BlackjackConfig) AllowsResplitAces() bool { return ruleBool(c.ResplitAces, false) }
+func (c BlackjackConfig) AllowsDoubleAfterSplit() bool { return ruleBool(c.DoubleAfterSplit, true) }
+func (c BlackjackConfig) CountsDoubleSplitBlackjack() bool {
+ return ruleBool(c.DoubleSplitBlackjack, true)
+}
+func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) }
+func (c BlackjackConfig) AllowsUnlikeTenSplit() bool { return ruleBool(c.SplitUnlikeTenValues, true) }
+
+func (c BlackjackConfig) Normalize() BlackjackConfig {
+ if c.Decks <= 0 {
+ c.Decks = 8
+ }
+ if c.ShuffleAt <= 0 || c.ShuffleAt >= 1 {
+ c.ShuffleAt = 0.25
+ }
+ if c.BlackjackPayout <= 0 {
+ c.BlackjackPayout = 1.5
+ }
+ if c.MaxSplitHands <= 0 {
+ c.MaxSplitHands = 4
+ }
+ if c.InsurancePayout <= 0 {
+ c.InsurancePayout = 2
+ }
+ if c.ActionTicks <= 0 {
+ c.ActionTicks = 20
+ }
+ return c
+}
+
type TableConfig struct {
- ID string `yaml:"id"`
- Game GameName `yaml:"game"`
- MaxPlayers int `yaml:"max_players,omitempty"`
- BettingTicks int `yaml:"betting_ticks,omitempty"`
- MinBet int `yaml:"min_bet,omitempty"`
- MaxBet int `yaml:"max_bet,omitempty"`
+ ID string `yaml:"id"`
+ Game GameName `yaml:"game"`
+ MaxPlayers int `yaml:"max_players,omitempty"`
+ BettingTicks int `yaml:"betting_ticks,omitempty"`
+ MinBet int `yaml:"min_bet,omitempty"`
+ MaxBet int `yaml:"max_bet,omitempty"`
+ Blackjack BlackjackConfig `yaml:"blackjack,omitempty"`
}
type MachineConfig struct {
@@ -87,6 +140,24 @@ const (
EventClosed EventType = "closed"
)
+type MenuKind string
+
+const (
+ MenuSlotCommands MenuKind = "slot_commands"
+ MenuBlackjackBet MenuKind = "blackjack_bet"
+ MenuBlackjackTurn MenuKind = "blackjack_turn"
+)
+
+// MenuView carries a structured menu alongside its plain-text Message so the
+// presentation layer can enrich it (balances, command colors) without parsing
+// message prefixes.
+type MenuView struct {
+ Kind MenuKind
+ Body string // command list (MenuSlotCommands)
+ Title string // prompt heading (MenuBlackjackTurn)
+ Actions []string // available commands (MenuBlackjackTurn)
+}
+
type Event struct {
RoomID int
TableID string
@@ -98,6 +169,8 @@ type Event struct {
Color string
Public bool
Slot *SlotSnapshot
+ Blackjack *BlackjackSnapshot
+ Menu *MenuView
}
type SlotSnapshot struct {
@@ -105,11 +178,24 @@ type SlotSnapshot struct {
StoppedReels int
}
-func (e Event) String() string {
- if e.Message != "" {
- return e.Message
- }
- return fmt.Sprintf("%s at %s", e.Type, e.TableID)
+type BlackjackCardView struct {
+ Rank string
+ Suit string
+ Hidden bool
+}
+
+type BlackjackPlayerView struct {
+ Name string
+ Cards []BlackjackCardView
+ Score string
+}
+
+type BlackjackSnapshot struct {
+ Dealer []BlackjackCardView
+ DealerShownScore string
+ DealerScore string
+ DealerRevealed bool
+ Players []BlackjackPlayerView
}
type Participant struct {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index c6fb63b..801c887 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -4,7 +4,9 @@ import (
"fmt"
"strconv"
"strings"
+ "unicode/utf8"
+ "thehouseoficarus/internal/cardart"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
@@ -17,6 +19,9 @@ type casinoWallet struct {
}
func (w casinoWallet) Wager(amount int) (casino.Wager, bool) {
+ if amount <= 0 {
+ return casino.Wager{}, false
+ }
chips := countChips(w.p)
if chips > amount {
chips = amount
@@ -119,23 +124,46 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
g.emitCasinoEvents(g.Casino.StartMachine(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p}))
return
}
- if len(args) == 1 && args[0] == "max" {
- g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, machine.Config.MaxBet))
- return
- }
if len(args) == 1 {
+ if args[0] == "max" {
+ g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, machine.Config.MaxBet))
+ return
+ }
if amount, err := strconv.Atoi(args[0]); err == nil && amount > 0 {
g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, amount))
return
}
}
- g.emitCasinoEvents(g.Casino.StartMachine(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p}))
+ sess.WriteLine("Use bet, bet <amount>, or bet max.")
return
}
if len(args) == 0 {
+ if table, playing := g.Casino.Participation(p.Name); playing && table != nil && table.Config.Game == casino.GameBlackjack {
+ amount := g.Casino.RebetAmount(table.RoomID, table.Config.ID, p.Name)
+ g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p}))
+ return
+ }
sess.WriteLine("Bet how much?")
return
}
+ if table, playing := g.Casino.Participation(p.Name); playing && table != nil && table.Config.Game == casino.GameBlackjack {
+ amount := 0
+ switch args[0] {
+ case "min":
+ amount = table.Config.MinBet
+ case "max":
+ amount = table.Config.MaxBet
+ default:
+ var err error
+ amount, err = strconv.Atoi(args[0])
+ if err != nil {
+ sess.WriteLine("Use bet, bet <amount>, bet min, or bet max.")
+ return
+ }
+ }
+ g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p}))
+ return
+ }
amount, err := strconv.Atoi(args[0])
if err != nil || amount <= 0 {
sess.WriteLine("Your bet must be a positive whole number.")
@@ -161,6 +189,19 @@ func (g *Game) executeAutoBet(sess *net.Session, args []string, rawInput string)
sess.WriteLine("Autobet is only available at games without player choices.")
}
+func (g *Game) executeBlackjackAction(sess *net.Session, action string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ table, playing := g.Casino.Participation(p.Name)
+ if !playing || table == nil {
+ sess.WriteLine("You're not playing a multiplayer casino game.")
+ return
+ }
+ g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action))
+}
+
func (g *Game) CasinoTick() {
if g.Casino != nil {
g.emitCasinoEvents(g.Casino.Tick())
@@ -169,7 +210,7 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" {
+ if event.Message == "" && event.Menu == nil {
continue
}
category := event.Color
@@ -182,48 +223,51 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
message := event.Message
- if category == "casino_menu" && sess.Player != nil && sess.Player.Name == event.PrivateTo {
- message = g.casinoMenuBalance(sess, message)
- }
if event.Slot != nil && sess.Player != nil {
message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode"))
}
+ if event.Blackjack != nil && sess.Player != nil {
+ message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
sess.WriteLine(g.colorize(sess, category, message))
}
}
- if event.PrivateMessage != "" && event.PrivateTo != "" {
- g.charsMu.Lock()
- sess := g.loggedInChars[event.PrivateTo]
- g.charsMu.Unlock()
- if sess != nil {
- privateMessage := event.PrivateMessage
- if category == "casino_menu" {
- privateMessage = g.casinoMenuBalance(sess, privateMessage)
- }
- sess.WriteLine(g.colorize(sess, category, privateMessage))
- if category == "casino_menu" {
- g.writePrompt(sess)
- }
- }
+ if event.PrivateTo == "" {
+ continue
}
- if event.PrivateTo != "" {
- g.charsMu.Lock()
- sess := g.loggedInChars[event.PrivateTo]
- g.charsMu.Unlock()
- if sess != nil && event.PrivateMessage == "" {
- privateMessage := event.Message
- if category == "casino_menu" {
- privateMessage = g.casinoMenuBalance(sess, privateMessage)
- }
- sess.WriteLine(g.colorize(sess, category, privateMessage))
- if category == "casino_menu" {
- g.writePrompt(sess)
- }
- }
+ text := event.PrivateMessage
+ if text == "" {
+ text = event.Message
+ }
+ if text == "" && event.Menu == nil {
+ continue
+ }
+ g.charsMu.Lock()
+ sess := g.loggedInChars[event.PrivateTo]
+ g.charsMu.Unlock()
+ if sess == nil {
+ continue
+ }
+ privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text)
+ if event.Blackjack != nil && sess.Player != nil {
+ privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
+ sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
+ if category == "casino_menu" {
+ g.writePrompt(sess)
}
}
}
+// renderCasinoMessage colorizes a casino message unless it is an
+// already-composed structured menu.
+func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
+ if category == "casino_menu" && menu != nil && menu.Kind != casino.MenuSlotCommands {
+ return message
+ }
+ return g.colorize(sess, category, message)
+}
+
func casinoEventColor(eventType casino.EventType) string {
if eventType == casino.EventPayout {
return "casino_win"
@@ -231,14 +275,230 @@ func casinoEventColor(eventType casino.EventType) string {
return "casino_action"
}
-func (g *Game) casinoMenuBalance(sess *net.Session, message string) string {
- if sess == nil || sess.Player == nil {
+func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
+ if category != "casino_menu" || menu == nil {
return message
}
chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
- line := fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
- return strings.Replace(message, "Slot machine commands:", line+"\n\nSlot machine commands:", 1)
+ balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
+ switch menu.Kind {
+ case casino.MenuBlackjackBet:
+ command := func(value string) string { return g.colorize(sess, "casino_command", value) }
+ return fmt.Sprintf("%s\n\nNew hand! You can %s, %s (inc. min/max), or %s.",
+ balance, command("bet"), command("bet <amount>"), command("stop"))
+ case casino.MenuBlackjackTurn:
+ commands := make([]string, len(menu.Actions))
+ for i, action := range menu.Actions {
+ commands[i] = g.colorize(sess, "casino_command", action)
+ }
+ return menu.Title + "\nActions: " + strings.Join(commands, ", ")
+ case casino.MenuSlotCommands:
+ if message == "" {
+ return balance + "\n\n" + menu.Body
+ }
+ return message + "\n\n" + balance + "\n\n" + menu.Body
+ }
+ return message
+}
+
+func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = blackjackCardSize(unicode, size)
+ var lines []string
+ dealerTitle := "Dealer:"
+ if snapshot.DealerRevealed {
+ dealerTitle = "Dealer (" + snapshot.DealerScore + "):"
+ } else if snapshot.DealerShownScore != "" {
+ dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
+ }
+ lines = append(lines, blackjackCardLine(mode, dealerTitle))
+ lines = append(lines, blackjackCardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ for _, player := range snapshot.Players {
+ lines = append(lines, blackjackCardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score)))
+ lines = append(lines, blackjackCardsLine(mode, player.Cards, size, unicode, style, art)...)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func blackjackCardLine(mode, text string) string {
+ spec := color.NoColor()
+ spec.Fg = 39
+ return color.Render(mode, spec, text)
+}
+
+func blackjackCardsLine(mode string, cards []casino.BlackjackCardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ if size == 1 {
+ parts := make([]string, len(cards))
+ for i, card := range cards {
+ parts[i] = colorizeCardSymbol(mode, card)
+ }
+ return []string{strings.Join(parts, " ")}
+ }
+ artLines := make([][]string, len(cards))
+ for i, card := range cards {
+ artLines[i] = blackjackCardArt(card, size, unicode, style, art)
+ }
+ lines := make([]string, size)
+ for row := 0; row < size; row++ {
+ var lineParts []string
+ for i, card := range artLines {
+ lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style))
+ }
+ lines[row] = strings.Join(lineParts, " ")
+ }
+ return lines
+}
+
+func blackjackCardSize(unicode bool, size int) int {
+ if !unicode {
+ return 5
+ }
+ if size == 1 || size == 5 || size == 7 || size == 9 {
+ return size
+ }
+ return 7
+}
+
+var blackjackSuitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣"}
+
+func blackjackSymbol(card casino.BlackjackCardView) string {
+ if card.Hidden {
+ return "?"
+ }
+ return card.Rank + blackjackSuitSymbols[card.Suit]
+}
+
+func blackjackCardArt(card casino.BlackjackCardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if card.Hidden {
+ return generatedCardArt("?", "", size, unicode, style)
+ }
+ key := card.Rank + strings.ToUpper(card.Suit[:1])
+ if configured := art.Art(key, size); len(configured) == size {
+ return configured
+ }
+ return generatedCardArt(card.Rank, card.Suit, size, unicode, style)
+}
+
+func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string {
+ if size < 5 {
+ size = 5
+ }
+ border := "-"
+ left, right := "+", "+"
+ if unicode {
+ border, left, right = "─", "┌", "┐"
+ }
+ bottomLeft, bottomRight := left, right
+ if unicode {
+ bottomLeft, bottomRight = "└", "┘"
+ }
+ vertical := "|"
+ if unicode {
+ vertical = "│"
+ }
+ if unicode && style == "light" {
+ lines := []string{strings.Repeat("▀", size)}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ return append(lines, strings.Repeat("▄", size))
+ }
+ lines := []string{left + strings.Repeat(border, size-2) + right}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight)
+ return lines
+}
+
+func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string {
+ inner := size - 2
+ lines := make([]string, 0, size-2)
+ for row := 1; row < size-1; row++ {
+ content := strings.Repeat(" ", inner)
+ if row == 1 {
+ content = rank + strings.Repeat(" ", inner-len(rank))
+ }
+ if row == (size-1)/2 {
+ suitText := ""
+ if suit != "" {
+ suitText = suitLetter(suit, unicode)
+ }
+ suitWidth := utf8.RuneCountInString(suitText)
+ leftPadding := (inner - suitWidth) / 2
+ content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth)
+ }
+ if row == size-2 {
+ content = strings.Repeat(" ", inner-len(rank)) + rank
+ }
+ lines = append(lines, vertical+content+vertical)
+ }
+ return lines
+}
+
+func suitLetter(suit string, unicode bool) string {
+ if !unicode {
+ return strings.ToUpper(suit[:1])
+ }
+ return blackjackSuitSymbols[suit]
+}
+
+func colorizeCardLine(mode, line string, card casino.BlackjackCardView, row, size int, style string) string {
+ red := card.Suit == "hearts" || card.Suit == "diamonds"
+ rankColor := 27
+ if red {
+ rankColor = 196
+ }
+ suitColor := 27
+ if red {
+ suitColor = 196
+ }
+ rankLine := row == 1 || row == size-2
+ light := style == "light"
+ bodyBackground := light && row > 0 && row < size-1
+ var builder strings.Builder
+ for _, r := range line {
+ if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) {
+ spec := color.ColorSpec{Fg: 15}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ } else if r == ' ' {
+ if bodyBackground {
+ builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " "))
+ } else {
+ builder.WriteRune(r)
+ }
+ } else {
+ value := suitColor
+ if rankLine {
+ value = rankColor
+ }
+ spec := color.ColorSpec{Fg: value}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ }
+ }
+ return builder.String()
+}
+
+func colorizeCardSymbol(mode string, card casino.BlackjackCardView) string {
+ if card.Hidden {
+ return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
+ }
+ rankColor, suitColor := 27, 27
+ if card.Suit == "hearts" || card.Suit == "diamonds" {
+ rankColor, suitColor = 196, 196
+ }
+ runes := []rune(blackjackSymbol(card))
+ rankLength := len([]rune(card.Rank))
+ return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
+ color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
}
func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string {
@@ -295,23 +555,17 @@ func colorSlotSymbol(mode, symbol string) string {
spec.Fg = 250
switch symbol {
case "straw":
- spec = color.NoColor()
- spec.Gradient = []int{220, 226}
+ spec.Fg = 220
case "stick":
- spec = color.NoColor()
- spec.Gradient = []int{34, 46}
+ spec.Fg = 46
case "brick":
- spec = color.NoColor()
- spec.Gradient = []int{160, 196}
+ spec.Fg = 196
case "hat":
- spec = color.NoColor()
- spec.Gradient = []int{129, 201}
+ spec.Fg = 201
case "wolf":
- spec = color.NoColor()
- spec.Gradient = []int{27, 39}
+ spec.Fg = 39
case "scatter":
- spec = color.NoColor()
- spec.Gradient = []int{196, 220, 46, 51, 129, 201}
+ spec.Fg = 226
default:
spec.Dim = true
}
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index b2b37c4..b74baab 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -6,8 +6,45 @@ import (
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
)
+type casinoTestConn struct {
+ data []byte
+}
+
+func (c *casinoTestConn) ReadMessage() (string, error) { return "", nil }
+func (c *casinoTestConn) Write(b []byte) (int, error) {
+ c.data = append(c.data, b...)
+ return len(b), nil
+}
+func (c *casinoTestConn) Close() error { return nil }
+func (c *casinoTestConn) SetEcho(bool) error { return nil }
+
+func TestExecuteBetRejectsInvalidMachineArgs(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.MachineConfig{ID: "slots", Game: casino.GameSlots}
+ g.Casino.JoinMachine(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ for _, args := range [][]string{{"banana"}, {"-5"}, {"10", "extra"}} {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "Use bet, bet <amount>, or bet max.") {
+ t.Fatalf("bet %v: expected usage error, got %q", args, string(conn.data))
+ }
+ }
+ // A bare bet at a machine reaches the spin path instead of erroring.
+ g.Casino.SetMachineBet(1, "slots", "alice", 10)
+ conn.data = nil
+ g.executeBet(sess, nil, "bet")
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("bare bet should attempt a spin, got %q", string(conn.data))
+ }
+}
+
func TestRenderSlotSnapshotColorsWordSymbols(t *testing.T) {
snapshot := &casino.SlotSnapshot{StoppedReels: 1}
snapshot.Grid[0][0] = "scatter"
@@ -27,3 +64,46 @@ func TestRenderSlotSnapshotColorsWordSymbols(t *testing.T) {
t.Fatalf("slot output lacks the Unicode frame: %q", output)
}
}
+
+func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
+ snapshot := &casino.BlackjackSnapshot{
+ Dealer: []casino.BlackjackCardView{{Rank: "?", Hidden: true}, {Rank: "K", Suit: "hearts"}},
+ Players: []casino.BlackjackPlayerView{{Name: "alice", Score: "20", Cards: []casino.BlackjackCardView{{Rank: "5", Suit: "clubs"}, {Rank: "Q", Suit: "spades"}}}},
+ }
+ plain := renderBlackjackSnapshot(snapshot, "none", false, 5, "dark", nil)
+ if !strings.Contains(plain, "+---+") || !strings.Contains(plain, "Dealer:") || !strings.Contains(plain, "alice (20):") {
+ t.Fatalf("unexpected blackjack art: %q", plain)
+ }
+ output := renderBlackjackSnapshot(snapshot, "xterm256", false, 5, "dark", nil)
+ if !strings.Contains(output, "\033[38;5;") {
+ t.Fatalf("expected colored blackjack art: %q", output)
+ }
+ if strings.ContainsAny(output, "│─┌┐└┘") {
+ t.Fatalf("blackjack art contains Unicode borders: %q", output)
+ }
+ for _, line := range strings.Split(output, "\n") {
+ if color.VisibleLen(line) > 80 {
+ t.Fatalf("blackjack line exceeds console width: %d", color.VisibleLen(line))
+ }
+ }
+ unicodeOutput := renderBlackjackSnapshot(snapshot, "none", true, 7, "dark", nil)
+ if !strings.Contains(unicodeOutput, "│5 │") || !strings.Contains(unicodeOutput, "│ 5│") || !strings.Contains(unicodeOutput, "│ ♣ │") {
+ t.Fatalf("unexpected Unicode card alignment: %q", unicodeOutput)
+ }
+ coloredUnicode := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "dark", nil)
+ if !strings.Contains(coloredUnicode, "\033[38;5;15m") || !strings.Contains(coloredUnicode, "\033[38;5;27m") {
+ t.Fatalf("expected white borders and blue card art: %q", coloredUnicode)
+ }
+ light := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "light", nil)
+ if !strings.Contains(light, "\033[48;5;15m") {
+ t.Fatalf("expected white card background in light style: %q", light)
+ }
+ lightLines := strings.Split(light, "\n")
+ if strings.Contains(lightLines[1], "\033[48;5;15m") || strings.Contains(lightLines[7], "\033[48;5;15m") {
+ t.Fatalf("card edges should not have a background: %q", light)
+ }
+ lightPlain := renderBlackjackSnapshot(snapshot, "none", true, 7, "light", nil)
+ if !strings.Contains(lightPlain, "▀▀▀▀▀▀▀") || !strings.Contains(lightPlain, "▄▄▄▄▄▄▄") {
+ t.Fatalf("expected half-height light card edges: %q", lightPlain)
+ }
+}
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 55a04de..d35ca12 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -62,6 +62,14 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
value := strings.ToLower(parts[1])
+ if def.Name == "card_size" {
+ switch value {
+ case "1", "5", "7", "9":
+ default:
+ sess.WriteLine("Invalid value for card_size: use 1, 5, 7, or 9.")
+ return
+ }
+ }
parsed, ok := parseOptionValue(def, value)
if !ok {
valid := formatValidValues(def)
@@ -73,11 +81,17 @@ func (g *Game) doOption(sess *net.Session, input string) {
p.Options = make(map[string]any)
}
p.Options[def.Name] = parsed
+ if def.Name == "unicode" && parsed == false {
+ p.Options["card_size"] = 5
+ }
if sess.Account.Options == nil {
sess.Account.Options = make(map[string]any)
}
sess.Account.Options[def.Name] = parsed
+ if def.Name == "unicode" && parsed == false {
+ sess.Account.Options["card_size"] = 5
+ }
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
if err == nil {
@@ -85,6 +99,9 @@ func (g *Game) doOption(sess *net.Session, input string) {
acc.Options = make(map[string]any)
}
acc.Options[def.Name] = parsed
+ if def.Name == "unicode" && parsed == false {
+ acc.Options["card_size"] = 5
+ }
g.AccountStore.SaveAccount(acc)
}
sess.WriteLine(fmt.Sprintf("%s set to %s.", def.Name, formatOptionValue(p, def)))
@@ -119,6 +136,9 @@ func formatValidValues(def *player.OptionDef) string {
}
return "string"
case player.OptInt:
+ if def.Name == "card_size" {
+ return "1/5/7/9"
+ }
return "num"
}
return ""
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 4c41216..db6cd96 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -28,6 +28,12 @@ var commandRegistry = map[string]commandDef{
"spin": {(*Game).executeBet, ClassActive},
"autobet": {(*Game).executeAutoBet, ClassActive},
"autospin": {(*Game).executeAutoBet, ClassActive},
+ "hit": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "hit") }, ClassActive},
+ "stand": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "stand") }, ClassActive},
+ "double": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "double") }, ClassActive},
+ "split": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "split") }, ClassActive},
+ "insurance": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "insurance") }, ClassActive},
+ "surrender": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "surrender") }, ClassActive},
"style": {(*Game).executeStyle, ClassInstant},
"look": {(*Game).executeLook, ClassInstant},
"l": {(*Game).executeLook, ClassInstant},
diff --git a/internal/game/game.go b/internal/game/game.go
index cac0ec9..23e0635 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -2,11 +2,13 @@ package game
import (
"fmt"
+ "path/filepath"
"sort"
"strings"
"sync"
"time"
+ "thehouseoficarus/internal/cardart"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/config"
@@ -42,6 +44,7 @@ type Deps struct {
Ticks *engine.Engine
ColorConfig *config.ColorsConfig
ConstantColorConfig *config.ColorsConfig
+ CardArt *cardart.Store
DataDir string
}
@@ -91,6 +94,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *
Ticks: engine.New(),
ColorConfig: colorConfig,
ConstantColorConfig: constantColorConfig,
+ CardArt: cardart.Load(filepath.Join(dataDir, "cards.yaml")),
DataDir: dataDir,
},
GlobalFlags: NewGlobalFlagStore(),
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 8af3096..afd5b86 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -57,6 +57,8 @@ var commandList = []cmdEntry{
{"bet", "Active", "Place a casino wager"},
{"autobet / autospin", "Active", "Automatically repeat a casino wager"},
{"spin", "Active", "Spin the current casino wager"},
+ {"hit / stand / double / split", "Active", "Play a blackjack hand"},
+ {"insurance / surrender", "Active", "Use blackjack table options"},
{"mine", "Active", "Mine rocks (Mining)"},
{"mix", "Active", "Mix potions (Pharmacy)"},
{"mods / modlist", "Instant", "List available science modules"},
diff --git a/internal/player/player.go b/internal/player/player.go
index b5b79f9..87c11e9 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -127,6 +127,8 @@ var OptionDefs = []OptionDef{
{"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"},
{"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"},
{"unicode", OptBool, true, nil, "Unicode box-drawing characters"},
+ {"card_size", OptInt, 9, nil, "Blackjack card size (1, 5, 7, or 9)"},
+ {"card_style", OptString, "dark", []string{"dark", "light"}, "Blackjack card style"},
{"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"},
{"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"},
{"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"},