aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/craps.go638
-rw-r--r--internal/casino/craps_clock.go120
-rw-r--r--internal/casino/craps_clock_test.go309
-rw-r--r--internal/casino/craps_projection.go225
-rw-r--r--internal/casino/craps_projection_test.go83
-rw-r--r--internal/casino/craps_rules.go205
-rw-r--r--internal/casino/craps_test.go403
-rw-r--r--internal/casino/craps_ux.go84
-rw-r--r--internal/casino/manager.go14
-rw-r--r--internal/casino/manager_test.go9
-rw-r--r--internal/casino/registry.go7
-rw-r--r--internal/casino/table_ux.go2
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/game/blackjack_rules.go4
-rw-r--r--internal/game/casino.go27
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/cmd_registry.go25
-rw-r--r--internal/game/cmd_stop.go4
-rw-r--r--internal/game/craps_test.go79
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_craps.go328
-rw-r--r--internal/game/render_craps_test.go130
-rw-r--r--internal/game/render_roulette.go14
-rw-r--r--internal/game/roulette_test.go1
24 files changed, 2698 insertions, 25 deletions
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
new file mode 100644
index 0000000..5e5e876
--- /dev/null
+++ b/internal/casino/craps.go
@@ -0,0 +1,638 @@
+package casino
+
+import (
+ "fmt"
+ "math/rand"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type crapsBet struct {
+ kind string
+ number int
+ stake Wager
+ odds Wager
+}
+type crapsPlayer struct {
+ Participant
+ bets []*crapsBet
+ lastSpot string
+ lastBet int
+ autoRoll bool
+ autoElapsed time.Duration
+}
+
+func (p *crapsPlayer) participantPtr() *Participant { return &p.Participant }
+
+type crapsTable struct {
+ tableCore[*crapsPlayer]
+ rng *rand.Rand
+ queue []string
+ point int
+ dice [2]int
+ rolled bool
+ made map[int]bool
+ seen map[int]bool
+ roundElapsed time.Duration
+ multiplayer bool
+ lastCall bool
+}
+type CrapsBetView struct {
+ Name, Target string
+ Amount, Odds int
+}
+type CrapsSnapshot struct {
+ Phase string
+ Point int
+ Dice [2]int
+ Queue []string
+ Made, Seen []int
+ Bets []CrapsBetView
+ Remaining int // legacy snapshot field; presentation uses seconds below
+ ShootIn int
+ DealerIn int
+ AutoRoll bool
+ AutoIn int
+ Field12 int
+}
+
+func (*CrapsSnapshot) snapshot() {}
+func newCrapsTable(room int, cfg TableConfig, rng *rand.Rand) *crapsTable {
+ return &crapsTable{tableCore: newTableCore[*crapsPlayer](&tableBase{RoomID: room, Config: cfg.Normalize()}), rng: rng, made: map[int]bool{}, seen: map[int]bool{}}
+}
+func crapsTarget(b *crapsBet) string {
+ if b.number == 0 {
+ return b.kind
+ }
+ if b.kind == "hop" {
+ return fmt.Sprintf("hop %d %d", b.number/10, b.number%10)
+ }
+ return fmt.Sprintf("%s %d", b.kind, b.number)
+}
+func (t *crapsTable) view() *CrapsSnapshot {
+ s := &CrapsSnapshot{Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12}
+ if t.hasLiveBets() && t.multiplayer {
+ s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed)
+ s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed)
+ }
+ if p := t.shooter(); p != nil && p.autoRoll {
+ s.AutoRoll = true
+ s.AutoIn = max(s.ShootIn, crapsSeconds(crapsAutoDelay-p.autoElapsed))
+ }
+ for n := 2; n <= 12; n++ {
+ if t.made[n] {
+ s.Made = append(s.Made, n)
+ }
+ if t.seen[n] {
+ s.Seen = append(s.Seen, n)
+ }
+ }
+ for _, p := range t.players.ordered() {
+ for _, b := range p.bets {
+ s.Bets = append(s.Bets, CrapsBetView{p.Name, crapsTarget(b), b.stake.Total, b.odds.Total})
+ }
+ }
+ return s
+}
+func (t *crapsTable) menu(name string) Event {
+ e := t.betMenuEvent(name, t.commands())
+ e.Snapshot = t.view()
+ return e
+}
+func (t *crapsTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.players.has(name) {
+ return []Event{t.menu(name)}
+ }
+ if t.players.count() >= t.Config.MaxPlayers {
+ return []Event{t.private(name, "That craps table is full.")}
+ }
+ e := t.addSeatLocked(name, &crapsPlayer{Participant: Participant{Name: name, Connected: true}})
+ t.queue = append(t.queue, name)
+ timing := t.syncRoundClock()
+ return append([]Event{e, t.menu(name)}, timing...)
+}
+func (t *crapsTable) remove(name string) {
+ t.removeSeatLocked(name)
+ for i, n := range t.queue {
+ if n == name {
+ t.queue = append(t.queue[:i], t.queue[i+1:]...)
+ break
+ }
+ }
+ if t.players.count() == 0 {
+ t.point = 0
+ t.rolled = false
+ t.made = map[int]bool{}
+ t.seen = map[int]bool{}
+ t.phase = phaseWaiting
+ t.window.close()
+ }
+}
+func (t *crapsTable) leave(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.players.get(name)
+ if p == nil {
+ return nil
+ }
+ if p.HasBet {
+ return []Event{t.private(name, "Your live craps bets must resolve first; use unbet for optional bets or stop to queue departure.")}
+ }
+ t.remove(name)
+ t.syncRoundClock()
+ return t.closeIfEmptyLocked([]Event{t.playerAction(name, name+" leaves craps.", "You leave craps.")})
+}
+func parseCrapsTarget(spot string) (kind string, n int, ok bool) {
+ f := strings.Fields(strings.ToLower(spot))
+ if len(f) == 0 {
+ return
+ }
+ kind = f[0]
+ switch kind {
+ case "pass", "dontpass", "come", "dontcome", "field", "seven", "craps", "eleven", "ace-deuce", "aces", "boxcars", "horn", "fire", "small", "tall", "all":
+ return kind, 0, len(f) == 1
+ case "place", "buy", "lay", "hard":
+ if len(f) != 2 {
+ return
+ }
+ n, _ = strconv.Atoi(f[1])
+ ok = crapsNumber(n) && (kind != "hard" || n%2 == 0)
+ return
+ case "hop":
+ if len(f) != 3 {
+ return
+ }
+ a, _ := strconv.Atoi(f[1])
+ b, _ := strconv.Atoi(f[2])
+ return kind, a*10 + b, a >= 1 && a <= 6 && b >= 1 && b <= 6
+ }
+ return
+}
+func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ reject := func(s string) []Event { return []Event{t.private(name, s)} }
+ p := t.players.get(name)
+ if p == nil {
+ return reject("You aren't playing craps.")
+ }
+ if spot == "" {
+ spot = p.lastSpot
+ }
+ spot = strings.ToLower(strings.TrimSpace(spot))
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet {
+ return reject("Craps bets must be " + betRangeText(t.Config.MinBet, t.Config.MaxBet) + ".")
+ }
+ // Bound every possible return, including the 999:1 Fire Bet, before debit.
+ if _, err := MulPayout(amount, 1000); err != nil {
+ return reject("That potential payout is too large.")
+ }
+ if strings.HasPrefix(spot, "odds ") {
+ target := strings.TrimPrefix(spot, "odds ")
+ for _, b := range p.bets {
+ if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) {
+ continue
+ }
+ if b.number == 0 || (b.kind != "pass" && b.kind != "come" && b.kind != "dontpass" && b.kind != "dontcome") {
+ continue
+ }
+ against := strings.HasPrefix(b.kind, "dont")
+ x, y := crapsTrueOdds(b.number)
+ if against {
+ x, y = y, x
+ }
+ _ = x
+ limit := t.Config.Craps.oddsLimit(b.number, against)
+ if b.odds.Total > 0 {
+ continue
+ }
+ if int64(amount) > int64(b.stake.Total)*int64(limit) || amount%y != 0 {
+ return reject("Odds already placed, above the free-odds limit, or not in exact payout units.")
+ }
+ stake, err := w.Wager(amount)
+ if err != nil {
+ return reject(walletErrorMessage(err))
+ }
+ b.odds = stake
+ p.Wallet = w
+ p.lastSpot, p.lastBet = spot, amount
+ return []Event{t.private(name, fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target)), t.betSummary(p)}
+ }
+ return reject("Place an established line/come bet before taking odds.")
+ }
+ kind, n, ok := parseCrapsTarget(spot)
+ if !ok {
+ return reject("Unknown craps target. Use table rules.")
+ }
+ if (kind == "pass" || kind == "dontpass") && t.point != 0 {
+ return reject("Line bets are accepted only on come-out.")
+ }
+ if (kind == "come" || kind == "dontcome") && t.point == 0 {
+ return reject("Come bets require a table point.")
+ }
+ if (kind == "fire" || kind == "small" || kind == "tall" || kind == "all") && t.rolled {
+ return reject("Bonus bets close before the shooter's first roll.")
+ }
+ for _, b := range p.bets {
+ if b.kind == kind && b.number == n {
+ return reject("That bet is already on the table.")
+ }
+ }
+ unit := 1
+ switch kind {
+ case "place":
+ switch n {
+ case 4, 10, 5, 9:
+ unit = 5
+ default:
+ unit = 6
+ }
+ case "buy":
+ _, unit = crapsTrueOdds(n)
+ case "lay":
+ unit, _ = crapsTrueOdds(n)
+ case "horn":
+ unit = 4
+ }
+ if amount%unit != 0 {
+ return reject(fmt.Sprintf("Use multiples of %d for exact payouts.", unit))
+ }
+ stake, err := w.Wager(amount)
+ if err != nil {
+ return reject(walletErrorMessage(err))
+ }
+ p.bets = append(p.bets, &crapsBet{kind: kind, number: n, stake: stake})
+ p.Wallet = w
+ p.HasBet = true
+ p.SittingOut = false
+ p.lastSpot, p.lastBet = spot, amount
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet {casino_chips}%d{/} on %s.", amount, spot))}
+ events = append(events, t.syncRoundClock()...)
+ return append(events, t.betSummary(p))
+}
+func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) (settlementLine, bool) {
+ payout := 0
+ if profit >= 0 {
+ payout = b.stake.Total + b.stake.Total/den*profit
+ if b.kind == "buy" || b.kind == "lay" {
+ commissionBase := payout - b.stake.Total
+ if b.kind == "buy" {
+ commissionBase = b.stake.Total
+ }
+ // Removals (zero profit) return the full stake without commission.
+ if profit > 0 {
+ payout -= (commissionBase + 19) / 20
+ }
+ }
+ }
+ ids := []string{b.stake.ID}
+ if b.odds.Total > 0 {
+ ids = append(ids, b.odds.ID)
+ if refundOdds {
+ payout += b.odds.Total
+ } else if oddsProfit >= 0 {
+ x, y := crapsTrueOdds(b.number)
+ if strings.HasPrefix(b.kind, "dont") {
+ x, y = y, x
+ }
+ payout += b.odds.Total + b.odds.Total/y*x
+ }
+ }
+ line := settlementLine{Label: crapsBetLabel(b), Stake: b.stake.Total + b.odds.Total, Returned: payout, Result: "push"}
+ if payout > line.Stake {
+ line.Result = "win"
+ } else if payout < line.Stake {
+ line.Result = "loss"
+ }
+ return line, p.Wallet.PayCredits(payout, ids...) != nil
+}
+func (t *crapsTable) roll() []Event {
+ a, b := t.rng.Intn(6)+1, t.rng.Intn(6)+1
+ return t.resolveRoll(a, b)
+}
+
+// resolveRoll is deterministic; randomness is exclusively in roll.
+func (t *crapsTable) resolveRoll(a, b int) []Event {
+ s := a + b
+ oldPoint := t.point
+ sevenOut := oldPoint != 0 && s == 7
+ t.dice = [2]int{a, b}
+ t.rolled = true
+ if oldPoint == s {
+ t.made[s] = true
+ }
+ if s != 7 {
+ t.seen[s] = true
+ }
+ shooter := "Dealer"
+ if len(t.queue) > 0 {
+ shooter = t.queue[0]
+ }
+ events := []Event{t.event(fmt.Sprintf("{casino_emphasis}%s shoots{/} {casino_payout}%d{/}", shooter, s), true)}
+ var results []Event
+ for _, p := range t.players.ordered() {
+ p.autoElapsed = 0
+ var lines []settlementLine
+ failed := false
+ var live []*crapsBet
+ for _, bet := range p.bets {
+ done, x, y := crapsOutcome(bet.kind, bet.number, a, b, oldPoint, t.Config.Craps.Field12)
+ switch bet.kind {
+ case "fire":
+ done = sevenOut
+ y = 1
+ x = -1
+ if done {
+ switch len(t.made) {
+ case 4:
+ x = 24
+ case 5:
+ x = 249
+ case 6:
+ x = 999
+ }
+ }
+ case "small", "tall", "all":
+ lo, hi := 2, 12
+ if bet.kind == "small" {
+ hi = 6
+ }
+ if bet.kind == "tall" {
+ lo = 8
+ }
+ hit := true
+ for n := lo; n <= hi; n++ {
+ if n != 7 && !t.seen[n] {
+ hit = false
+ }
+ }
+ done = hit || s == 7
+ x = -1
+ y = 1
+ if hit {
+ x = 34
+ if bet.kind == "all" {
+ x = 175
+ }
+ }
+ }
+ if done {
+ refundOdds := oldPoint == 0 && bet.kind == "come"
+ line, err := t.pay(p, bet, x, y, x, refundOdds)
+ if err {
+ failed = true
+ } else {
+ lines = append(lines, line)
+ }
+ continue
+ }
+ if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && crapsNumber(s) {
+ bet.number = s
+ }
+ live = append(live, bet)
+ }
+ p.bets = live
+ p.HasBet = len(live) > 0
+ if len(lines) > 0 || failed {
+ results = append(results, t.settlementEvent(p, lines, failed))
+ }
+ }
+ if s == 7 {
+ t.seen = map[int]bool{}
+ }
+ if oldPoint == 0 && crapsNumber(s) {
+ t.point = s
+ events = append(events, t.event(fmt.Sprintf("{casino_emphasis}POINT %d{/} / Puck ON. Make your point!", s), true))
+ } else if oldPoint == s || sevenOut {
+ if oldPoint == s {
+ events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", s), true))
+ }
+ t.point = 0
+ }
+ if sevenOut {
+ t.made = map[int]bool{}
+ t.rolled = false
+ if len(t.queue) > 1 {
+ t.queue = append(t.queue[1:], t.queue[0])
+ }
+ events = append(events, t.event("{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter.", true))
+ }
+ events = append(events, t.finishCrapsControls()...)
+ t.phase = phaseWaiting
+ events = append(events, t.syncRoundClock()...)
+ events[0].Snapshot = t.view()
+ events = append(events, results...)
+ for _, p := range t.players.ordered() {
+ if p.Connected && !p.SittingOut {
+ events = append(events, t.betSummary(p), t.reminder(p.Name))
+ }
+ }
+ return events
+}
+
+// finishCrapsControls requires the table lock. Unlike one-round games,
+// contracts end independently, so only seats with no live bets may depart.
+func (t *crapsTable) finishCrapsControls() []Event {
+ var events []Event
+ for _, p := range t.players.ordered() {
+ if p.HasBet {
+ continue
+ }
+ if p.autoRoll {
+ p.autoRoll = false
+ p.autoElapsed = 0
+ events = append(events, t.private(p.Name, "{casino_emphasis}Autoroll complete{/} — all your bets have settled. Manual play resumed."))
+ }
+ if p.LeaveAfter {
+ t.remove(p.Name)
+ events = append(events, t.playerAction(p.Name, p.Name+" leaves craps.", "Your bets are settled. You leave craps."))
+ } else if p.SitOutAfter {
+ p.SitOutAfter = false
+ p.SittingOut = true
+ }
+ }
+ for i := 0; i < len(t.queue); i++ {
+ p := t.players.get(t.queue[0])
+ if p != nil && !p.SittingOut {
+ break
+ }
+ t.queue = append(t.queue[1:], t.queue[0])
+ }
+ return events
+}
+func (t *crapsTable) tick() []Event { return t.tickElapsed(600 * time.Millisecond) }
+func (t *crapsTable) action(name, action string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.players.get(name)
+ if p == nil {
+ return []Event{t.private(name, "You aren't playing craps.")}
+ }
+ if events, ok := t.controlLocked(name, action); ok {
+ events = append(events, t.finishCrapsControls()...)
+ return append(events, t.syncRoundClock()...)
+ }
+ if action == "autoroll" {
+ if !p.HasBet {
+ return []Event{t.private(name, "Place a bet before enabling autoroll.")}
+ }
+ if !p.autoRoll {
+ p.autoRoll = true
+ p.autoElapsed = 0
+ }
+ return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 seconds on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play.")}
+ }
+ if action == "roll" {
+ events := t.syncRoundClock()
+ if t.shooter() != p {
+ return append(events, t.private(name, "Only the shooter may roll."))
+ }
+ if !t.hasLiveBets() {
+ return append(events, t.private(name, "Place a bet, then roll whenever you're ready."))
+ }
+ if t.multiplayer && t.roundElapsed < crapsBetDelay {
+ return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d seconds{/}. Bets remain open until you roll.", crapsSeconds(crapsBetDelay-t.roundElapsed))))
+ }
+ return append(events, t.roll()...)
+ }
+ if strings.HasPrefix(action, "unbet ") {
+ target := strings.TrimPrefix(action, "unbet ")
+ if strings.TrimSpace(strings.TrimPrefix(target, "odds ")) == "all" {
+ return append(t.unbetAllLocked(p), t.finishCrapsControls()...)
+ }
+ oddsOnly := strings.HasPrefix(target, "odds ")
+ target = strings.TrimPrefix(target, "odds ")
+ for i, b := range p.bets {
+ if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) {
+ continue
+ }
+ if oddsOnly {
+ if b.odds.Total == 0 {
+ continue
+ }
+ e := t.private(name, "Odds returned.")
+ if err := p.Wallet.PayCredits(b.odds.Total, b.odds.ID); err != nil {
+ e.Message = payoutFailedMessage()
+ }
+ b.odds = Wager{}
+ return []Event{e, t.betSummary(p)}
+ }
+ if b.kind == "pass" || b.kind == "come" || b.kind == "fire" || b.kind == "small" || b.kind == "tall" || b.kind == "all" {
+ return []Event{t.private(name, "That contract bet cannot be removed.")}
+ }
+ line, failed := t.pay(p, b, 0, 1, 0, true)
+ var lines []settlementLine
+ if !failed {
+ lines = append(lines, line)
+ }
+ e := t.settlementEvent(p, lines, failed)
+ p.bets = append(p.bets[:i], p.bets[i+1:]...)
+ p.HasBet = len(p.bets) > 0
+ events := append([]Event{e}, t.finishCrapsControls()...)
+ events = append(events, t.syncRoundClock()...)
+ if t.players.has(name) {
+ events = append(events, t.betSummary(p))
+ }
+ return events
+ }
+ return []Event{t.private(name, "No matching removable bet.")}
+ }
+ return []Event{t.private(name, "Use table rules for craps commands.")}
+}
+func (t *crapsTable) rebet(name string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.players.get(name); p != nil {
+ return p.lastSpot, p.lastBet
+ }
+ return "", t.Config.MinBet
+}
+
+// unbetAllLocked returns every non-contract wager and all free odds in one
+// atomic wallet save, keeping line/come contracts and bonus entries live.
+func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event {
+ total := 0
+ var ids []string
+ var live []*crapsBet
+ for _, b := range p.bets {
+ switch b.kind {
+ case "pass", "come", "fire", "small", "tall", "all":
+ if b.odds.Total > 0 {
+ total += b.odds.Total
+ ids = append(ids, b.odds.ID)
+ b.odds = Wager{}
+ }
+ live = append(live, b)
+ default:
+ total += b.stake.Total + b.odds.Total
+ ids = append(ids, b.stake.ID)
+ if b.odds.Total > 0 {
+ ids = append(ids, b.odds.ID)
+ }
+ }
+ }
+ if total == 0 {
+ return []Event{t.private(p.Name, "Nothing removable on the table.")}
+ }
+ p.bets = live
+ p.HasBet = len(live) > 0
+ e := t.private(p.Name, fmt.Sprintf("You take down {casino_chips}%d{/} credits of optional bets; contracts stay up.", total))
+ if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ e.Message = payoutFailedMessage()
+ }
+ events := []Event{e, t.betSummary(p)}
+ events = append(events, t.syncRoundClock()...)
+ if t.players.has(p.Name) {
+ events = append(events, t.reminder(p.Name))
+ }
+ return events
+}
+
+func (t *crapsTable) status(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return []Event{t.menu(name)}
+}
+func (t *crapsTable) markDisconnected(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.players.get(name)
+ if p == nil {
+ return nil
+ }
+ var events []Event
+ // Refunding after observing intermediate dice would turn contract bets into
+ // free options. Disconnect consumes live bets; shutdown alone refunds them.
+ for _, b := range p.bets {
+ line, failed := t.pay(p, b, -1, 1, -1, false)
+ var lines []settlementLine
+ if !failed {
+ lines = append(lines, line)
+ }
+ events = append(events, t.settlementEvent(p, lines, failed))
+ }
+ t.remove(name)
+ events = append(events, t.syncRoundClock()...)
+ return append(events, t.event(name+" leaves craps; live wagers are forfeited.", true))
+}
+func (t *crapsTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ events := t.refundAllLocked()
+ for _, p := range t.players.ordered() {
+ p.bets = nil
+ p.HasBet = false
+ p.autoRoll = false
+ p.autoElapsed = 0
+ }
+ t.point = 0
+ t.rolled = false
+ t.made = map[int]bool{}
+ t.seen = map[int]bool{}
+ t.phase = phaseWaiting
+ t.window.close()
+ return events
+}
diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go
new file mode 100644
index 0000000..cb43731
--- /dev/null
+++ b/internal/casino/craps_clock.go
@@ -0,0 +1,120 @@
+package casino
+
+import (
+ "fmt"
+ "time"
+)
+
+const (
+ crapsBetDelay = 10 * time.Second
+ crapsDealerDelay = 60 * time.Second
+ crapsAutoDelay = 3 * time.Second
+)
+
+func crapsSeconds(d time.Duration) int {
+ if d <= 0 {
+ return 0
+ }
+ return int((d + time.Second - 1) / time.Second)
+}
+func (t *crapsTable) shooter() *crapsPlayer {
+ if len(t.queue) == 0 {
+ return nil
+ }
+ return t.players.get(t.queue[0])
+}
+func (t *crapsTable) hasLiveBets() bool {
+ for _, p := range t.players.ordered() {
+ if p.HasBet {
+ return true
+ }
+ }
+ return false
+}
+func (t *crapsTable) activePlayers() int {
+ n := 0
+ for _, p := range t.players.ordered() {
+ if p.Connected && !p.SittingOut {
+ n++
+ }
+ }
+ return n
+}
+
+// Called under the table lock whenever seats or wagers change. Only entering
+// multiplayer or starting a new round opens a window; adding bets never
+// extends it. A solo player has no forced deadline at all.
+func (t *crapsTable) syncRoundClock() []Event {
+ multi := t.activePlayers() > 1
+ if !t.hasLiveBets() {
+ t.phase = phaseWaiting
+ t.roundElapsed = 0
+ t.lastCall = false
+ t.multiplayer = multi
+ return nil
+ }
+ fresh := t.phase == phaseWaiting || (multi && !t.multiplayer)
+ t.multiplayer = multi
+ if fresh {
+ t.roundElapsed = 0
+ t.lastCall = false
+ }
+ t.phase = phasePlaying
+ if multi && t.roundElapsed < crapsBetDelay {
+ t.phase = phaseBetting
+ }
+ if fresh && multi {
+ return []Event{t.event("{casino_emphasis}Bets open{/} — 10 seconds before the shooter can roll. Betting stays open until the dice roll.", true)}
+ }
+ return nil
+}
+
+func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ events := t.syncRoundClock()
+ if elapsed <= 0 || !t.hasLiveBets() {
+ return events
+ }
+ t.roundElapsed += elapsed
+ p := t.shooter()
+ if p == nil {
+ return events
+ }
+ if p.autoRoll && p.HasBet {
+ p.autoElapsed += elapsed
+ }
+ if t.multiplayer && !t.lastCall && t.roundElapsed >= crapsBetDelay-3*time.Second {
+ t.lastCall = true
+ events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! 3 seconds until %s can shoot!{/}", p.Name), true))
+ }
+ if t.multiplayer && t.roundElapsed < crapsBetDelay {
+ return events
+ }
+ if t.phase == phaseBetting {
+ events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in 60 seconds if needed.", p.Name), true))
+ }
+ t.phase = phasePlaying
+ if (p.autoRoll && p.HasBet && p.autoElapsed >= crapsAutoDelay) || (t.multiplayer && t.roundElapsed >= crapsBetDelay+crapsDealerDelay) {
+ return append(events, t.roll()...)
+ }
+ return events
+}
+
+// StopAutoRoll is atomic with ticking and does not queue a departure or touch
+// any wager. A second stop, in manual mode, retains normal casino leave behavior.
+func (t *Table) StopAutoRoll(name string) ([]Event, bool) {
+ game, ok := t.game.(*crapsTable)
+ if !ok {
+ return nil, false
+ }
+ game.mu.Lock()
+ defer game.mu.Unlock()
+ p := game.players.get(name)
+ if p == nil || !p.autoRoll {
+ return nil, false
+ }
+ p.autoRoll = false
+ p.autoElapsed = 0
+ return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table.")}, true
+}
diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go
new file mode 100644
index 0000000..0e68e6d
--- /dev/null
+++ b/internal/casino/craps_clock_test.go
@@ -0,0 +1,309 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func crapsEventText(events []Event) string {
+ var s strings.Builder
+ for _, e := range events {
+ s.WriteString(e.Message)
+ s.WriteByte('\n')
+ s.WriteString(e.PrivateMessage)
+ }
+ return s.String()
+}
+func TestCrapsMultiplayerClockAndLateBets(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ tb.join("bob")
+ w := &testWallet{chips: 1000}
+ tb.bet("alice", "pass", 10, w)
+ if tb.view().ShootIn != 10 || tb.view().DealerIn != 70 {
+ t.Fatal(tb.view())
+ }
+ if text := crapsEventText(tb.tickElapsed(6 * time.Second)); strings.Contains(text, "Last call") {
+ t.Fatal("early warning")
+ }
+ events := tb.tickElapsed(time.Second)
+ if !strings.Contains(crapsEventText(events), "Last call for bets! 3 seconds until alice can shoot!") {
+ t.Fatal(events)
+ }
+ if !events[0].Public {
+ t.Fatal("warning not broadcast")
+ }
+ tb.action("alice", "roll")
+ if tb.dice != [2]int{} {
+ t.Fatal("early shoot")
+ }
+ tb.tickElapsed(3 * time.Second)
+ if tb.view().ShootIn != 0 || tb.view().DealerIn != 60 {
+ t.Fatal(tb.view())
+ }
+ tb.bet("alice", "field", 10, w)
+ if len(w.open) != 2 {
+ t.Fatal("late bet rejected")
+ }
+ tb.action("alice", "unbet field")
+ if len(tb.players.get("alice").bets) != 1 {
+ t.Fatal("late removal rejected")
+ }
+ if strings.Contains(crapsEventText(tb.tickElapsed(59*time.Second)), "Last call") {
+ t.Fatal("warning repeated")
+ }
+ if tb.dice != [2]int{} {
+ t.Fatal("early forced roll")
+ }
+ tb.tickElapsed(time.Second)
+ if tb.dice == [2]int{} {
+ t.Fatal("60 second dealer fallback missing")
+ }
+}
+func TestCrapsSoloAndRosterTransitions(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 1000}
+ tb.bet("alice", "fire", 1, w)
+ tb.tickElapsed(500 * time.Second)
+ if tb.dice != [2]int{} || tb.view().DealerIn != 0 || tb.view().ShootIn != 0 {
+ t.Fatal("solo deadline", tb.view())
+ }
+ tb.action("alice", "roll")
+ if tb.dice == [2]int{} {
+ t.Fatal("solo could not roll")
+ }
+ tb.join("bob")
+ if tb.view().ShootIn != 10 {
+ t.Fatal("join did not give bettors a window")
+ }
+ tb.tickElapsed(4 * time.Second)
+ tb.bet("alice", "field", 10, w)
+ if tb.view().ShootIn != 6 {
+ t.Fatal("bet extended timer")
+ }
+ tb.leave("bob")
+ if tb.view().ShootIn != 0 || tb.view().DealerIn != 0 {
+ t.Fatal("departure left forced timer")
+ }
+ tb.join("bob")
+ tb.action("bob", "sitout")
+ if tb.view().ShootIn != 0 {
+ t.Fatal("sit-out counts as playing")
+ }
+ tb.refundAll()
+ if tb.hasLiveBets() {
+ t.Fatal("refund retained wagers")
+ }
+}
+func TestCrapsAutorollPaceAndStop(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 1000}
+ tb.bet("alice", "fire", 1, w)
+ tb.action("alice", "autoroll")
+ tb.tickElapsed(2999 * time.Millisecond)
+ if tb.dice != [2]int{} {
+ t.Fatal("autoroll fired early")
+ }
+ tb.tickElapsed(time.Millisecond)
+ if tb.dice == [2]int{} {
+ t.Fatal("autoroll did not fire")
+ }
+ if p := tb.players.get("alice"); p.HasBet && p.autoElapsed != 0 {
+ t.Fatal("pace not reset")
+ }
+ handle := &Table{tableBase: tb.tableBase, game: tb}
+ tb.players.get("alice").autoRoll = true
+ if _, stopped := handle.StopAutoRoll("alice"); !stopped {
+ t.Fatal("stop failed")
+ }
+ before := tb.dice
+ tb.tickElapsed(500 * time.Second)
+ if tb.dice != before || !tb.hasParticipant("alice") {
+ t.Fatal("stop rolled or departed")
+ }
+ if _, stopped := handle.StopAutoRoll("alice"); stopped {
+ t.Fatal("manual stop incorrectly consumed")
+ }
+ // A one-roll bet ends autoroll immediately, regardless of the outcome.
+ tb = crapsFixture()
+ tb.join("alice")
+ w = &testWallet{chips: 100}
+ tb.bet("alice", "field", 10, w)
+ tb.action("alice", "autoroll")
+ tb.tickElapsed(3 * time.Second)
+ if tb.players.get("alice").autoRoll || tb.players.get("alice").HasBet {
+ t.Fatal("autoroll survived last bet")
+ }
+ before = tb.dice
+ tb.tickElapsed(3 * time.Second)
+ if tb.dice != before {
+ t.Fatal("extra roll after completion")
+ }
+}
+func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ tb.join("bob")
+ w := &testWallet{chips: 100}
+ tb.bet("alice", "field", 10, w)
+ tb.action("alice", "autoroll")
+ tb.tickElapsed(3 * time.Second)
+ if tb.dice != [2]int{} {
+ t.Fatal("autoroll stole multiplayer betting time")
+ }
+ tb.tickElapsed(7 * time.Second)
+ if tb.dice == [2]int{} {
+ t.Fatal("autoroll failed when ready")
+ }
+ // A non-shooter's opt-in never takes the dice away from the current shooter.
+ tb = crapsFixture()
+ tb.join("alice")
+ tb.join("bob")
+ w = &testWallet{chips: 100}
+ tb.bet("bob", "field", 10, w)
+ tb.action("bob", "autoroll")
+ tb.tickElapsed(11 * time.Second)
+ if tb.dice != [2]int{} {
+ t.Fatal("non-shooter autorolled")
+ }
+}
+func TestCrapsManagerUsesElapsedCadence(t *testing.T) {
+ for _, step := range []time.Duration{100 * time.Millisecond, 600 * time.Millisecond, time.Second} {
+ m := NewManager(4)
+ cfg := TableConfig{ID: "c", Game: GameCraps}
+ m.Join(1, cfg, "alice")
+ w := &testWallet{chips: 100}
+ m.Bet(1, "c", "alice", "field", 10, w)
+ m.Action(1, "c", "alice", "autoroll")
+ tb := m.Table(1, "c").game.(*crapsTable)
+ for elapsed := step; elapsed < 3*time.Second; elapsed += step {
+ m.TickElapsed(step)
+ if tb.dice != [2]int{} {
+ t.Fatal("early", step)
+ }
+ }
+ m.TickElapsed(step)
+ if tb.dice == [2]int{} {
+ t.Fatal("wrong cadence", step)
+ }
+ }
+}
+func TestCrapsConsolidatedResultsAndBetNumbers(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 1000}
+ events := tb.bet("alice", "pass", 10, w)
+ if !strings.Contains(crapsEventText(events), "ROLL RESULTS ON YOUR BETS") {
+ t.Fatal("no post-bet roll table")
+ }
+ tb.bet("alice", "dontpass", 10, w)
+ tb.resolveRoll(2, 3)
+ events = tb.resolveRoll(3, 4)
+ balances, settlements := 0, 0
+ for _, e := range events {
+ if e.ShowBalance {
+ balances++
+ }
+ if strings.Contains(e.Message, "Pass [5]:") || strings.Contains(e.Message, "Pass [5]") {
+ settlements++
+ if !strings.Contains(e.Message, "Don't pass [5]") {
+ t.Fatal("not combined", e.Message)
+ }
+ }
+ }
+ if balances != 1 || settlements != 1 {
+ t.Fatal("duplicate balance or separate results", balances, settlements, crapsEventText(events))
+ }
+ text := crapsEventText(events)
+ for _, ugly := range []string{"returned (stake", "Craps: bets open", "Phase: waiting"} {
+ if strings.Contains(text, ugly) {
+ t.Fatal(ugly)
+ }
+ }
+ if !strings.Contains(text, "{casino_result_loss}") || !strings.Contains(text, "{casino_result_win}") {
+ t.Fatal("missing outcome styles")
+ }
+}
+
+func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 1000}
+ tb.bet("alice", "fire", 1, w)
+ tb.bet("alice", "pass", 10, w)
+ tb.resolveRoll(3, 3)
+ tb.bet("alice", "odds pass", 20, w)
+ tb.bet("alice", "place 4", 10, w)
+ tb.bet("alice", "field", 10, w)
+ before := w.paid
+ events := tb.action("alice", "unbet all")
+ text := crapsEventText(events)
+ if !strings.Contains(text, "take down {casino_chips}40{/}") || !strings.Contains(text, "contracts stay up") {
+ t.Fatal(text)
+ }
+ if w.paid != before+40 {
+ t.Fatal("refund amount", w.paid-before)
+ }
+ bets := tb.players.get("alice").bets
+ if len(bets) != 2 || bets[0].kind != "fire" || bets[1].kind != "pass" || bets[1].odds.Total != 0 {
+ t.Fatal("contracts or odds mishandled", bets)
+ }
+ if tb.hasLiveBets() != true || len(w.open) != 2 {
+ t.Fatal("pass and fire stakes must stay pending", w.open)
+ }
+ tb.action("alice", "unbet all")
+ if w.paid != before+40 {
+ t.Fatal("second unbet all moved money", w.paid)
+ }
+ // Roll table formatting: grouped totals, single-level tags only.
+ tb2 := crapsFixture()
+ tb2.join("alice")
+ w2 := &testWallet{chips: 100}
+ tb2.bet("alice", "pass", 1, w2)
+ table := tb2.rollTable(tb2.players.get("alice"))
+ for _, tag := range []string{"{casino_payout}7{/}", "{casino_result_win}+1{/}", "{casino_payout}2{/}", "{casino_result_loss}-1{/}"} {
+ if !strings.Contains(table, tag) {
+ t.Fatal("missing", tag, table)
+ }
+ }
+ if strings.Contains(table, "{casino_muted}") || strings.Contains(table, "{casino_result_win}+{") {
+ t.Fatal("nested or unknown tag", table)
+ }
+}
+
+func TestCrapsRollTableHardPairLabels(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 100}
+ tb.bet("alice", "hard 8", 10, w)
+ tb.bet("alice", "hop 2 3", 10, w)
+ tb.point = 8
+ table := tb.rollTable(tb.players.get("alice"))
+ for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+90{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}easy 8{/}", "{casino_payout}any other{/}"} {
+ if !strings.Contains(table, want) {
+ t.Fatal("missing", want, table)
+ }
+ }
+ // Bets paying on the same total combine into one row.
+ tb2 := crapsFixture()
+ tb2.join("alice")
+ w2 := &testWallet{chips: 100}
+ tb2.bet("alice", "field", 10, w2)
+ tb2.bet("alice", "place 8", 12, w2)
+ tb2.point = 8
+ table = tb2.rollTable(tb2.players.get("alice"))
+ if strings.Count(table, "{casino_payout}8{/}") != 1 || !strings.Contains(table, "{casino_result_win}+4{/}") {
+ t.Fatal("same-label payouts must combine", table)
+ }
+ // Hard bets alone on the come-out pay nothing and say so.
+ tb3 := crapsFixture()
+ tb3.join("alice")
+ w3 := &testWallet{chips: 100}
+ tb3.bet("alice", "hard 8", 10, w3)
+ if text := tb3.rollTable(tb3.players.get("alice")); !strings.Contains(text, "OFF on the come-out") {
+ t.Fatal(text)
+ }
+}
diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go
new file mode 100644
index 0000000..5be2f28
--- /dev/null
+++ b/internal/casino/craps_projection.go
@@ -0,0 +1,225 @@
+package casino
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// crapsReturn is the pure payout math shared by settlement and by the
+// player-facing "what could win on the next roll" projection, so the two can
+// never disagree. It mirrors the money path of crapsTable.pay exactly.
+func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int {
+ payout := 0
+ if profit >= 0 {
+ payout = b.stake.Total + b.stake.Total/den*profit
+ if b.kind == "buy" || b.kind == "lay" {
+ base := payout - b.stake.Total
+ if b.kind == "buy" {
+ base = b.stake.Total
+ }
+ // Removals (zero profit) return the full stake without commission.
+ if profit > 0 {
+ payout -= (base + 19) / 20
+ }
+ }
+ }
+ if b.odds.Total > 0 {
+ if refundOdds {
+ payout += b.odds.Total
+ } else if oddsProfit >= 0 {
+ x, y := crapsTrueOdds(b.number)
+ if strings.HasPrefix(b.kind, "dont") {
+ x, y = y, x
+ }
+ payout += b.odds.Total + b.odds.Total/y*x
+ }
+ }
+ return payout
+}
+
+// crapsNet returns settlement profit: returns (stake included) less commit.
+func crapsNet(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int {
+ return crapsReturn(b, profit, den, oddsProfit, refundOdds) - (b.stake.Total + b.odds.Total)
+}
+
+// crapsRefundOdds mirrors come bets' come-out refundOdds policy.
+func crapsRefundOdds(b *crapsBet, point int) bool { return point == 0 && b.kind == "come" }
+
+// crapsRollView is one possible dice pair: its total, the pair shown, and the
+// player's combined settlement profit if it rolled next.
+type CrapsRollView struct {
+ Total int
+ Rolls string
+ Net int
+}
+
+// projectionFor evaluates one player's live bets as if the listed pair rolled
+// with the current table point, via the same rules engine used for settlement.
+func (t *crapsTable) projectionFor(p *crapsPlayer) []CrapsRollView {
+ var rows []CrapsRollView
+ for total := 2; total <= 12; total++ {
+ for a := 1; a <= 6; a++ {
+ b := total - a
+ if b < 1 || b > 6 || a > b {
+ continue
+ }
+ net := 0
+ for _, bet := range p.bets {
+ done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12)
+ if !done {
+ // An unresolved contract keeps its stake; only bonuses
+ // can complete within a single non-settling roll.
+ if bet.kind == "fire" {
+ x = crapsFireProfit(len(t.made))
+ } else if bet.kind == "small" || bet.kind == "tall" || bet.kind == "all" {
+ lo, hi := 2, 12
+ if bet.kind == "small" {
+ hi = 6
+ }
+ if bet.kind == "tall" {
+ lo = 8
+ }
+ if crapsBonusComplete(lo, hi, t.seen, a+b) {
+ x = 34
+ if bet.kind == "all" {
+ x = 175
+ }
+ } else {
+ continue
+ }
+ } else {
+ continue
+ }
+ }
+ net += crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point))
+ }
+ rows = append(rows, CrapsRollView{total, fmt.Sprintf("%d+%d", a, b), net})
+ }
+ }
+ return rows
+}
+
+func crapsFireProfit(made int) int {
+ switch made {
+ case 4:
+ return 24
+ case 5:
+ return 249
+ case 6:
+ return 999
+ }
+ return 0
+}
+
+func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool {
+ for n := lo; n <= hi; n++ {
+ if n != 7 && !seen[n] && n != next {
+ return false
+ }
+ }
+ return true
+}
+
+// crapsRollLabel names the roll result a bet cares about: hardways and hop
+// bets distinguish their exact pair ("4+4"); every other bet reads by total.
+func crapsRollLabel(bet *crapsBet, a, b int) string {
+ s := a + b
+ switch bet.kind {
+ case "hard":
+ if a == b {
+ return fmt.Sprintf("%d+%d", a, b)
+ }
+ if s == 7 {
+ return "7"
+ }
+ return fmt.Sprintf("easy %d", s)
+ case "hop":
+ if bet.number == a*10+b || bet.number == b*10+a {
+ return fmt.Sprintf("%d+%d", a, b)
+ }
+ return "any other"
+ }
+ return fmt.Sprint(s)
+}
+
+// rollTable lists each roll result and its combined payout across the
+// player's bets, one row per roll. Dice-distinguishing bets (hardways, hop)
+// name their pair; everything else reads by total. Pairs that share a
+// label and outcome collapse; across bets, same-roll payouts add. Tags
+// stay single-level: the tag expander does not nest.
+func (t *crapsTable) rollTable(p *crapsPlayer) string {
+ agg := map[string]int{}
+ for _, bet := range p.bets {
+ perLabel := map[string]int{}
+ for a := 1; a <= 6; a++ {
+ for b := a; b <= 6; b++ {
+ done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12)
+ if !done {
+ // Unresolved contracts stay put; only bonuses can
+ // complete within a single non-settling roll.
+ switch bet.kind {
+ case "fire":
+ x = crapsFireProfit(len(t.made))
+ case "small", "tall", "all":
+ lo, hi := 2, 12
+ if bet.kind == "small" {
+ hi = 6
+ }
+ if bet.kind == "tall" {
+ lo = 8
+ }
+ if !crapsBonusComplete(lo, hi, t.seen, a+b) {
+ continue
+ }
+ x = 34
+ if bet.kind == "all" {
+ x = 175
+ }
+ default:
+ continue
+ }
+ }
+ net := crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point))
+ if net != 0 {
+ perLabel[crapsRollLabel(bet, a, b)] = net
+ }
+ }
+ }
+ for label, net := range perLabel {
+ agg[label] += net
+ }
+ }
+ if len(agg) == 0 {
+ if len(p.bets) == 0 {
+ return "No open wagers."
+ }
+ return "{casino_action}Your place, buy and hardway bets are OFF on the come-out — nothing pays until a point is set.{/}"
+ }
+ labels := []string{}
+ for label := range agg {
+ labels = append(labels, label)
+ }
+ sort.Slice(labels, func(i, j int) bool {
+ ni, nj := agg[labels[i]], agg[labels[j]]
+ if (ni > 0) != (nj > 0) {
+ return ni > 0
+ }
+ if ni != nj {
+ return ni > nj
+ }
+ return labels[i] < labels[j]
+ })
+ var sb strings.Builder
+ sb.WriteString("{casino_emphasis}ROLL RESULTS ON YOUR BETS{/}\n")
+ for _, label := range labels {
+ net := agg[label]
+ tag, amount := "casino_result_loss", fmt.Sprint(net)
+ if net > 0 {
+ tag, amount = "casino_result_win", "+"+amount
+ }
+ fmt.Fprintf(&sb, " {casino_payout}%s{/}%s {%s}%s{/}\n", label, strings.Repeat(" ", max(1, 10-len(label))), tag, amount)
+ }
+ sb.WriteString("{casino_action}Hardways and hops name their pair; totals cover both orders.{/}")
+ return sb.String()
+}
diff --git a/internal/casino/craps_projection_test.go b/internal/casino/craps_projection_test.go
new file mode 100644
index 0000000..be48691
--- /dev/null
+++ b/internal/casino/craps_projection_test.go
@@ -0,0 +1,83 @@
+package casino
+
+import (
+ "fmt"
+ "testing"
+)
+
+// TestCrapsProjectionMatchesSettlement pins the player-facing roll table to
+// the real settlement path: for every pair, at several points, the projected
+// net equals what resolveRoll would actually pay.
+func TestCrapsProjectionMatchesSettlement(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 10000}
+ tb.bet("alice", "pass", 10, w)
+ tb.bet("alice", "field", 10, w)
+ tb.bet("alice", "hard 6", 10, w)
+ tb.bet("alice", "place 4", 10, w)
+ tb.bet("alice", "hop 2 3", 10, w)
+ tb.bet("alice", "horn", 4, w)
+ tb.resolveRoll(3, 3)
+ tb.bet("alice", "odds pass", 20, w)
+ tb.bet("alice", "dontcome", 10, w)
+ tb.bet("alice", "fire", 1, w)
+ for _, point := range []int{0, 4, 6, 10} {
+ tb.point = point
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ projected := 99999
+ for _, row := range tb.projectionFor(tb.players.get("alice")) {
+ if row.Total == a+b && row.Rolls == fmt.Sprintf("%d+%d", min(a, b), max(a, b)) {
+ projected = row.Net
+ }
+ }
+ settled := tb.simulateNet("alice", a, b)
+ if projected != settled {
+ t.Fatalf("pair %d+%d point %d: projected %d, settled %d", a, b, point, projected, settled)
+ }
+ }
+ }
+ }
+}
+
+// simulateNet resolves one pair on a deep copy of the table (fresh test
+// wallets) and returns alice's true net through the ordinary settlement path.
+func (t *crapsTable) simulateNet(name string, a, b int) int {
+ clone := &crapsTable{tableCore: newTableCore[*crapsPlayer](&tableBase{RoomID: t.RoomID, Config: t.Config}), rng: t.rng}
+ wallets := map[string]*testWallet{}
+ for _, seat := range t.players.ordered() {
+ wallet := &testWallet{}
+ for _, wager := range seat.Wallet.(*testWallet).open {
+ wallet.open = append(wallet.open, wager)
+ }
+ wallets[seat.Name] = wallet
+ cp := &crapsPlayer{Participant: Participant{Name: seat.Name, Connected: true, HasBet: seat.HasBet, Wallet: wallet}}
+ for _, bet := range seat.bets {
+ cp.bets = append(cp.bets, &crapsBet{kind: bet.kind, number: bet.number, stake: bet.stake, odds: bet.odds})
+ }
+ clone.players.add(seat.Name, cp)
+ }
+ clone.point = t.point
+ clone.made = map[int]bool{}
+ for n := range t.made {
+ clone.made[n] = true
+ }
+ clone.seen = map[int]bool{}
+ for n := range t.seen {
+ clone.seen[n] = true
+ }
+ before := walletWealth(wallets[name])
+ clone.resolveRoll(a, b)
+ return walletWealth(wallets[name]) - before
+}
+
+// walletWealth is cash in hand plus the cash value of every pending stake,
+// so a resolved win/loss reports true settlement profit.
+func walletWealth(w *testWallet) int {
+ pending := 0
+ for _, wager := range w.open {
+ pending += wager.Total
+ }
+ return w.chips + w.paid + pending
+}
diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go
new file mode 100644
index 0000000..86aa9bb
--- /dev/null
+++ b/internal/casino/craps_rules.go
@@ -0,0 +1,205 @@
+package casino
+
+import "fmt"
+
+// CrapsConfig intentionally supports bank craps only. Other games must supply
+// their own outcome rules, not silently inherit bank-craps probabilities.
+type CrapsConfig struct {
+ Odds int `yaml:"odds,omitempty"` // 0 = 3-4-5x; positive = uniform Nx
+ Field12 int `yaml:"field_12,omitempty"`
+ Variant string `yaml:"variant,omitempty"`
+}
+
+func (c CrapsConfig) Normalize() CrapsConfig {
+ if c.Field12 == 0 {
+ c.Field12 = 2
+ }
+ return c
+}
+func validateCraps(c TableConfig) error {
+ if c.Craps.Odds < 0 || c.Craps.Odds > 1000000 {
+ return fmt.Errorf("craps odds must be 0 (3-4-5x) or 1..1000000")
+ }
+ if c.Craps.Field12 != 0 && c.Craps.Field12 != 2 && c.Craps.Field12 != 3 {
+ return fmt.Errorf("craps field_12 must be 2 or 3")
+ }
+ if c.Craps.Variant != "" && c.Craps.Variant != "bank" {
+ return fmt.Errorf("only bank craps is implemented")
+ }
+ return nil
+}
+func crapsNumber(n int) bool { return n == 4 || n == 5 || n == 6 || n == 8 || n == 9 || n == 10 }
+func crapsTrueOdds(n int) (int, int) {
+ switch n {
+ case 4, 10:
+ return 2, 1
+ case 5, 9:
+ return 3, 2
+ default:
+ return 6, 5
+ }
+}
+func (c CrapsConfig) oddsLimit(n int, against bool) int {
+ if c.Odds > 0 {
+ return c.Odds
+ }
+ if against {
+ return 6
+ }
+ switch n {
+ case 4, 10:
+ return 3
+ case 5, 9:
+ return 4
+ default:
+ return 5
+ }
+}
+func (c TableConfig) CrapsRulesText() string {
+ r := c.Craps.Normalize()
+ odds := "3-4-5x (don't: 6x laid)"
+ if r.Odds > 0 {
+ odds = fmt.Sprintf("%dx", r.Odds)
+ }
+ return fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+
+ "bet <amount> on pass / dontpass / come / dontcome / field\n"+
+ "bet <amount> on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+
+ "bet <amount> on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+
+ "bet <amount> on odds pass / odds come 6 (also dontpass/dontcome)\n"+
+ "bet <amount> on fire / small / tall / all (before shooter's first roll only)\n"+
+ "Pass/come 1:1; don't bars 12. Place 4/10 9:5, 5/9 7:5, 6/8 7:6.\n"+
+ "Buy/lay true odds; 5%% commission on buy STAKE / lay WIN, rounded up, wins only.\n"+
+ "Field 2 pays 2:1; 3/4/9/10/11 pay 1:1. Hard 4/10 7:1; 6/8 9:1.\n"+
+ "Seven 4:1; craps 7:1; 11/3 15:1; 2/12 30:1; hop easy 15:1, hard 30:1.\n"+
+ "Horn splits equally across 2/3/11/12. Fire: 4/5/6 distinct made points pay 24/249/999:1.\n"+
+ "Small (2-6) / Tall (8-12) pay 34:1; All pays 175:1. Any 7 resets ATS.\n"+
+ "Place/buy/hard and come odds are OFF on come-out; lay and don't odds work.\n"+
+ "Exact payout units required; no fractional-credit rounding except commission.\n"+
+ "Solo: roll anytime. Multiplayer: 10 seconds to bet, then 60 seconds for the shooter.\n"+
+ "Bets remain open until the roll. Dice rotate on seven-out.\n"+
+ "autoroll: repeat every 3 seconds on your turn (multiplayer waits 10 seconds).\n"+
+ "unbet <target>: remove non-contract bets/odds; pass/come contracts stay.\n"+
+ "unbet all: return every optional bet and odds at once.\n"+
+ "stop cancels autoroll without leaving; in manual mode stop/sitout wait for bets.\n"+
+ "Disconnect forfeits live bets.\n"+
+ "Winning bets return stake and are removed (no automatic pressing).", odds, r.Field12)
+}
+
+// outcome returns resolved and profit numerator/denominator; -1 means loss.
+func crapsOutcome(kind string, n, a, b, point, field12 int) (bool, int, int) {
+ s := a + b
+ hard := a == b
+ switch kind {
+ case "pass", "come", "dontpass", "dontcome":
+ against := kind == "dontpass" || kind == "dontcome"
+ if n == 0 {
+ if against {
+ switch s {
+ case 2, 3:
+ return true, 1, 1
+ case 7, 11:
+ return true, -1, 1
+ case 12:
+ return true, 0, 1
+ }
+ } else {
+ switch s {
+ case 7, 11:
+ return true, 1, 1
+ case 2, 3, 12:
+ return true, -1, 1
+ }
+ }
+ return false, 0, 1
+ }
+ if s != 7 && s != n {
+ return false, 0, 1
+ }
+ if (s == n) != against {
+ return true, 1, 1
+ }
+ return true, -1, 1
+ case "place", "buy", "lay":
+ if point == 0 && kind != "lay" {
+ return false, 0, 1
+ }
+ if s != 7 && s != n {
+ return false, 0, 1
+ }
+ if (s == 7) != (kind == "lay") {
+ return true, -1, 1
+ }
+ x, y := crapsTrueOdds(n)
+ if kind == "lay" {
+ x, y = y, x
+ }
+ if kind == "place" {
+ switch n {
+ case 4, 10:
+ x, y = 9, 5
+ case 5, 9:
+ x, y = 7, 5
+ default:
+ x, y = 7, 6
+ }
+ }
+ return true, x, y
+ case "hard":
+ if point == 0 {
+ return false, 0, 1
+ }
+ if s == 7 || (s == n && !hard) {
+ return true, -1, 1
+ }
+ if s == n {
+ if n == 4 || n == 10 {
+ return true, 7, 1
+ }
+ return true, 9, 1
+ }
+ return false, 0, 1
+ case "field":
+ switch s {
+ case 2:
+ return true, 2, 1
+ case 12:
+ return true, field12, 1
+ case 3, 4, 9, 10, 11:
+ return true, 1, 1
+ }
+ return true, -1, 1
+ case "seven":
+ if s == 7 {
+ return true, 4, 1
+ }
+ case "craps":
+ if s == 2 || s == 3 || s == 12 {
+ return true, 7, 1
+ }
+ case "eleven", "ace-deuce":
+ if (kind == "eleven" && s == 11) || (kind == "ace-deuce" && s == 3) {
+ return true, 15, 1
+ }
+ case "aces", "boxcars":
+ if (kind == "aces" && s == 2) || (kind == "boxcars" && s == 12) {
+ return true, 30, 1
+ }
+ case "hop":
+ if a*10+b == n || b*10+a == n {
+ if hard {
+ return true, 30, 1
+ }
+ return true, 15, 1
+ }
+ case "horn":
+ switch s {
+ case 2, 12:
+ return true, 27, 4
+ case 3, 11:
+ return true, 3, 1
+ }
+ default:
+ return false, 0, 1
+ }
+ return true, -1, 1
+}
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
new file mode 100644
index 0000000..5c7b0df
--- /dev/null
+++ b/internal/casino/craps_test.go
@@ -0,0 +1,403 @@
+package casino
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+ "testing"
+ "time"
+)
+
+func crapsFixture() *crapsTable {
+ return newCrapsTable(1, TableConfig{ID: "craps", Game: GameCraps, BettingTicks: 2}, rand.New(rand.NewSource(7)))
+}
+func TestCrapsAll36ComeOut(t *testing.T) {
+ counts := map[string][3]int{}
+ for _, kind := range []string{"pass", "dontpass", "come", "dontcome"} {
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ done, x, _ := crapsOutcome(kind, 0, a, b, 0, 2)
+ c := counts[kind]
+ if done {
+ if x > 0 {
+ c[0]++
+ } else if x < 0 {
+ c[1]++
+ } else {
+ c[2]++
+ }
+ }
+ counts[kind] = c
+ }
+ }
+ }
+ if counts["pass"] != [3]int{8, 4, 0} || counts["come"] != counts["pass"] || counts["dontpass"] != [3]int{3, 8, 1} || counts["dontcome"] != counts["dontpass"] {
+ t.Fatal(counts)
+ }
+ for _, n := range []int{4, 5, 6, 8, 9, 10} {
+ for _, kind := range []string{"pass", "dontpass", "come", "dontcome"} {
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ done, x, _ := crapsOutcome(kind, n, a, b, 6, 2)
+ if done != (a+b == n || a+b == 7) {
+ t.Fatal(kind, n, a, b)
+ }
+ if done && ((x > 0) != ((a+b == n) != strings.HasPrefix(kind, "dont"))) {
+ t.Fatal("wrong winner", kind, n, a, b)
+ }
+ }
+ }
+ }
+ }
+}
+func TestCrapsPayouts(t *testing.T) {
+ cases := []struct {
+ spot string
+ amount, a, b, point, want int
+ }{
+ {"field", 10, 6, 6, 6, 30}, {"field", 10, 1, 1, 6, 30}, {"field", 10, 3, 4, 6, 0},
+ {"place 4", 10, 2, 2, 6, 28}, {"place 5", 10, 2, 3, 6, 24}, {"place 6", 12, 3, 3, 6, 26},
+ {"buy 4", 20, 2, 2, 6, 59}, {"buy 5", 20, 2, 3, 6, 49}, {"buy 6", 20, 3, 3, 6, 43},
+ {"lay 4", 40, 3, 4, 6, 59}, {"lay 5", 30, 3, 4, 6, 49}, {"lay 6", 30, 3, 4, 6, 53},
+ {"hard 4", 10, 2, 2, 6, 80}, {"hard 6", 10, 3, 3, 6, 100}, {"hard 8", 10, 2, 6, 6, 0},
+ {"seven", 10, 3, 4, 6, 50}, {"craps", 10, 1, 1, 6, 80}, {"eleven", 10, 5, 6, 6, 160},
+ {"ace-deuce", 10, 1, 2, 6, 160}, {"aces", 10, 1, 1, 6, 310}, {"boxcars", 10, 6, 6, 6, 310},
+ {"hop 2 3", 10, 3, 2, 6, 160}, {"hop 3 3", 10, 3, 3, 6, 310}, {"horn", 4, 1, 1, 6, 31}, {"horn", 4, 5, 6, 6, 16},
+ }
+ for _, c := range cases {
+ t.Run(fmt.Sprintf("%s-%d%d", c.spot, c.a, c.b), func(t *testing.T) {
+ tb := crapsFixture()
+ tb.point = c.point
+ w := &testWallet{chips: 10000}
+ tb.join("a")
+ tb.bet("a", c.spot, c.amount, w)
+ if len(tb.players.get("a").bets) != 1 {
+ t.Fatal("rejected")
+ }
+ tb.resolveRoll(c.a, c.b)
+ if w.paid != c.want {
+ t.Fatalf("paid %d want %d", w.paid, c.want)
+ }
+ if len(w.open) != 0 {
+ t.Fatal("stake not consumed")
+ }
+ })
+ }
+ tb := crapsFixture()
+ tb.Config.Craps.Field12 = 3
+ w := &testWallet{chips: 100}
+ tb.join("a")
+ tb.bet("a", "field", 10, w)
+ tb.resolveRoll(6, 6)
+ if w.paid != 40 {
+ t.Fatal(w.paid)
+ }
+}
+func TestCrapsLineOddsAndQueue(t *testing.T) {
+ tb := crapsFixture()
+ w := &recordingWallet{testWallet: testWallet{chips: 1000}}
+ tb.join("a")
+ tb.join("b")
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(2, 2)
+ tb.bet("a", "odds pass", 31, w)
+ if len(w.open) != 1 {
+ t.Fatal("over limit odds accepted")
+ }
+ tb.bet("a", "odds pass", 30, w)
+ tb.resolveRoll(2, 2)
+ if w.paid != 110 || len(w.payStakes) != 2 || tb.point != 0 || tb.queue[0] != "a" {
+ t.Fatal(w.paid, w.payStakes, tb.point, tb.queue)
+ }
+ tb.join("c")
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(3, 3)
+ tb.resolveRoll(3, 4)
+ if strings.Join(tb.queue, ",") != "b,c,a" {
+ t.Fatal(tb.queue)
+ }
+ tb.leave("c")
+ tb.join("c")
+ if strings.Join(tb.queue, ",") != "b,a,c" {
+ t.Fatal(tb.queue)
+ }
+}
+func TestCrapsComeTravelAndComeOutOddsOff(t *testing.T) {
+ for _, win := range []bool{false, true} {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ tb.point = 4
+ tb.bet("a", "come", 10, w)
+ tb.resolveRoll(3, 3)
+ tb.bet("a", "odds come 6", 50, w)
+ tb.resolveRoll(2, 2)
+ if win {
+ tb.resolveRoll(3, 3)
+ if w.paid != 70 {
+ t.Fatal("come odds should be returned, not win", w.paid)
+ }
+ } else {
+ tb.resolveRoll(3, 4)
+ if w.paid != 50 {
+ t.Fatal("come odds should not lose", w.paid)
+ }
+ }
+ if len(w.open) != 0 {
+ t.Fatal(w.open)
+ }
+ }
+}
+func TestCrapsWorkingRules(t *testing.T) {
+ for _, spot := range []string{"place 6", "buy 6", "hard 6"} {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", spot, 30, w)
+ tb.resolveRoll(3, 4)
+ if w.paid != 0 || len(tb.players.get("a").bets) != 1 {
+ t.Fatal(spot)
+ }
+ tb.resolveRoll(3, 3)
+ if len(tb.players.get("a").bets) != 1 {
+ t.Fatal("come-out hit must be off", spot)
+ }
+ tb.resolveRoll(3, 4)
+ if len(tb.players.get("a").bets) != 0 {
+ t.Fatal("seven out", spot)
+ }
+ }
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "dontpass", 10, w)
+ tb.resolveRoll(2, 2)
+ tb.bet("a", "odds dontpass", 60, w)
+ tb.resolveRoll(3, 4)
+ if w.paid != 110 {
+ t.Fatal(w.paid)
+ }
+}
+func TestCrapsFireDistinctPoints(t *testing.T) {
+ for count := 3; count <= 6; count++ {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "fire", 1, w)
+ points := [][2]int{{2, 2}, {2, 3}, {3, 3}, {4, 4}, {4, 5}, {5, 5}}
+ for _, dice := range points[:count] {
+ tb.resolveRoll(dice[0], dice[1])
+ tb.resolveRoll(dice[0], dice[1])
+ }
+ // Repeating an already made point must not increase Fire count.
+ tb.resolveRoll(2, 2)
+ tb.resolveRoll(2, 2)
+ tb.resolveRoll(3, 3)
+ tb.resolveRoll(3, 4)
+ want := map[int]int{3: 0, 4: 25, 5: 250, 6: 1000}[count]
+ if w.paid != want {
+ t.Fatalf("%d points: %d want %d", count, w.paid, want)
+ }
+ }
+}
+func TestCrapsATSAndSevenReset(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ for _, spot := range []string{"small", "tall", "all"} {
+ tb.bet("a", spot, 1, w)
+ }
+ for _, d := range [][2]int{{1, 1}, {1, 2}, {2, 2}, {2, 3}, {3, 3}, {4, 4}, {4, 5}, {5, 5}, {5, 6}, {6, 6}} {
+ tb.resolveRoll(d[0], d[1])
+ }
+ if w.paid != 246 || len(w.open) != 0 {
+ t.Fatal(w.paid, w.open)
+ }
+ tb = crapsFixture()
+ tb.join("a")
+ w = &testWallet{chips: 100}
+ tb.bet("a", "small", 1, w)
+ tb.resolveRoll(1, 1)
+ tb.resolveRoll(3, 4)
+ if w.paid != 0 || len(w.open) != 0 || len(tb.seen) != 0 {
+ t.Fatal("ATS must lose on come-out seven")
+ }
+ tb.bet("a", "small", 1, w)
+ if len(w.open) != 0 {
+ t.Fatal("late bonus allowed")
+ }
+}
+func TestCrapsTimersControlsAndRefunds(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ tb.join("b")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ tb.action("a", "roll")
+ if tb.dice != [2]int{} {
+ t.Fatal("early roll")
+ }
+ tb.tickElapsed(10 * time.Second)
+ if tb.phase != phasePlaying {
+ t.Fatal(tb.phase)
+ }
+ tb.action("b", "roll")
+ if tb.dice != [2]int{} {
+ t.Fatal("nonshooter roll")
+ }
+ tb.bet("a", "field", 10, w)
+ if len(w.open) != 2 {
+ t.Fatal("bets must remain open until the roll")
+ }
+ tb.action("a", "roll")
+ if tb.dice == [2]int{} {
+ t.Fatal("no roll")
+ }
+ tb = crapsFixture()
+ tb.join("a")
+ w = &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ tb.tickElapsed(120 * time.Second)
+ if tb.dice != [2]int{} {
+ t.Fatal("solo players must never be forced to roll")
+ }
+ tb.action("a", "roll")
+ if tb.dice == [2]int{} {
+ t.Fatal("solo manual roll stalled")
+ }
+ tb = crapsFixture()
+ tb.join("a")
+ w = &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(3, 3)
+ tb.action("a", "unbet pass")
+ if len(w.open) != 1 {
+ t.Fatal("contract removed")
+ }
+ tb.markDisconnected("a")
+ if len(w.open) != 0 || w.paid != 0 {
+ t.Fatal("disconnect refunded")
+ }
+ tb = crapsFixture()
+ tb.join("a")
+ w = &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ tb.refundAll()
+ tb.refundAll()
+ if w.chips != 100 || len(w.open) != 0 {
+ t.Fatal("refund not idempotent")
+ }
+ tb.bet("a", "place 6", 12, w)
+ tb.action("a", "unbet place 6")
+ if w.paid != 12 || tb.players.get("a").HasBet {
+ t.Fatal("optional removal")
+ }
+ tb.bet("a", "pass", 10, w)
+ tb.action("a", "leave_after")
+ tb.resolveRoll(3, 4)
+ if tb.hasParticipant("a") {
+ t.Fatal("queued departure stuck")
+ }
+}
+func TestCrapsValidation(t *testing.T) {
+ for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "crapless"}} {
+ if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err == nil {
+ t.Fatal("accepted", r)
+ }
+ }
+ for _, r := range []CrapsConfig{{}, {Odds: 10, Field12: 3}, {Odds: 100}} {
+ if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := (TableConfig{ID: "r", Game: GameRoulette, Craps: CrapsConfig{Field12: 3}}).Validate(); err == nil {
+ t.Fatal("wrong rule block accepted")
+ }
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ for _, spot := range []string{"place 7", "hard 5", "hop 0 6", "horn", "place 6", "come", "odds pass", "unknown"} {
+ tb.bet("a", spot, 1, w)
+ }
+ if len(w.open) != 0 {
+ t.Fatal("invalid bets debited", w.open)
+ }
+}
+
+func TestCrapsOddsLimitsAndReturns(t *testing.T) {
+ for _, uniform := range []int{0, 2, 10} {
+ for _, n := range []int{4, 5, 6, 8, 9, 10} {
+ for _, against := range []bool{false, true} {
+ tb := crapsFixture()
+ tb.Config.Craps.Odds = uniform
+ tb.join("a")
+ w := &testWallet{chips: 10000}
+ kind := "pass"
+ if against {
+ kind = "dontpass"
+ }
+ tb.bet("a", kind, 10, w)
+ a, b := n/2, n-n/2
+ tb.resolveRoll(a, b)
+ limit := tb.Config.Craps.oddsLimit(n, against) * 10
+ x, y := crapsTrueOdds(n)
+ if against {
+ x, y = y, x
+ }
+ amount := limit - limit%y
+ tb.bet("a", "odds "+kind, limit+y, w)
+ if len(w.open) != 1 {
+ t.Fatal("overlimit", uniform, n, against)
+ }
+ tb.bet("a", "odds "+kind, amount, w)
+ if len(w.open) != 2 {
+ t.Fatal("odds rejected", uniform, n, against)
+ }
+ if against {
+ tb.resolveRoll(3, 4)
+ } else {
+ tb.resolveRoll(a, b)
+ }
+ if w.paid != 20+amount+amount/y*x {
+ t.Fatal("wrong odds", uniform, n, against, w.paid)
+ }
+ }
+ }
+ }
+}
+func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &failingPayoutWallet{testWallet: testWallet{chips: 100}}
+ tb.bet("a", "pass", 10, w)
+ snap := tb.view()
+ snap.Queue[0] = "changed"
+ snap.Bets[0].Amount = 999
+ if tb.queue[0] != "a" || tb.players.get("a").bets[0].stake.Total != 10 {
+ t.Fatal("snapshot aliases live state")
+ }
+ events := tb.resolveRoll(3, 4)
+ found := false
+ for _, e := range events {
+ if strings.Contains(e.Message, payoutFailedMessage()) {
+ found = true
+ }
+ }
+ if !found || len(w.open) != 1 || w.paid != 0 || tb.players.get("a").HasBet {
+ t.Fatal("failed payout handling", events, w)
+ }
+ tb.refundAll()
+ tb.refundAll()
+ if w.chips != 100 || len(w.open) != 0 {
+ t.Fatal("pending recovery", w)
+ }
+ tb = crapsFixture()
+ tb.join("a")
+ good := &testWallet{chips: 100}
+ tb.bet("a", "place 6", 12, good)
+ tb.action("a", "leave_after")
+ tb.action("a", "unbet place 6")
+ if tb.hasParticipant("a") {
+ t.Fatal("unbet stranded queued departure")
+ }
+}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
new file mode 100644
index 0000000..41f671a
--- /dev/null
+++ b/internal/casino/craps_ux.go
@@ -0,0 +1,84 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+)
+
+func (t *crapsTable) commands() []MenuCommand {
+ return []MenuCommand{
+ {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"},
+ {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"},
+ {Command: "unbet <target> | unbet all", Desc: "Remove one bet / every optional bet"},
+ {Command: "table", Desc: "View the felt"},
+ }
+}
+func crapsBetLabel(b *crapsBet) string {
+ names := map[string]string{"pass": "Pass", "dontpass": "Don't pass", "come": "Come", "dontcome": "Don't come", "ace-deuce": "Ace-deuce", "small": "All Small", "tall": "All Tall", "all": "All", "fire": "Fire"}
+ label := names[b.kind]
+ if label == "" {
+ label = gameTitle(GameName(b.kind))
+ }
+ if b.kind == "hop" {
+ return fmt.Sprintf("Hop [%d + %d]", b.number/10, b.number%10)
+ }
+ if b.number != 0 {
+ label += fmt.Sprintf(" [%d]", b.number)
+ }
+ return label
+}
+
+// Numbers describe covered targets, not a promise that every listed roll wins
+// (don't/lay bets oppose their number; bonus bets need complete sequences).
+// betSummary lists each open wager, then the dice pairs that beat them. The
+// roll table shares payout math with settlement (crapsNet), so the numbers
+// never promise a win a real roll cannot pay.
+func (t *crapsTable) betSummary(p *crapsPlayer) Event {
+ text := "{casino_emphasis}YOUR BETS{/}"
+ if len(p.bets) == 0 {
+ text += " No active bets."
+ } else {
+ for _, b := range p.bets {
+ label := crapsBetLabel(b)
+ if strings.HasPrefix(b.kind, "dont") || b.kind == "lay" {
+ label += " (against)"
+ }
+ if t.point == 0 && (b.kind == "place" || b.kind == "buy" || b.kind == "hard") {
+ label += " (off)"
+ }
+ text += fmt.Sprintf("\n {casino_action}%-24s{/} {casino_chips}%d{/}", label, b.stake.Total)
+ if b.odds.Total > 0 {
+ text += fmt.Sprintf(" + %d odds", b.odds.Total)
+ if t.point == 0 && b.kind == "come" {
+ text += " (odds off)"
+ }
+ }
+ }
+ }
+ e := t.private(p.Name, text+"\n"+t.rollTable(p))
+ e.ShowBalance = true
+ return e
+}
+func (t *crapsTable) reminder(name string) Event {
+ e := t.private(name, "Your move")
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Heading: "Your move", Commands: t.commands()}
+ return e
+}
+func (t *crapsTable) settlementEvent(p *crapsPlayer, lines []settlementLine, failed bool) Event {
+ text := ""
+ if len(lines) > 0 {
+ text = settlementText(lines)
+ }
+ if failed {
+ if text != "" {
+ text += "\n"
+ }
+ text += payoutFailedMessage()
+ }
+ e := t.private(p.Name, text)
+ if len(lines) > 0 {
+ e.Color = settlementTag(lines)
+ }
+ return e
+}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index f880463..2162656 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
)
type Manager struct {
@@ -161,7 +162,12 @@ func (m *Manager) snapshotMachines() []*Machine {
return machines
}
-func (m *Manager) Tick() []Event {
+func (m *Manager) Tick() []Event { return m.TickElapsed(600 * time.Millisecond) }
+
+// TickElapsed supplies the engine cadence to games with real-time deadlines.
+// Legacy games retain their tick-based rules. No wall clock or goroutine is
+// introduced, so owner ordering and deterministic tests remain intact.
+func (m *Manager) TickElapsed(elapsed time.Duration) []Event {
tables := m.snapshotTables()
sort.Slice(tables, func(i, j int) bool {
if tables[i].RoomID != tables[j].RoomID {
@@ -171,7 +177,11 @@ func (m *Manager) Tick() []Event {
})
var events []Event
for _, table := range tables {
- events = append(events, table.tick()...)
+ if game, ok := table.game.(interface{ tickElapsed(time.Duration) []Event }); ok {
+ events = append(events, game.tickElapsed(elapsed)...)
+ } else {
+ events = append(events, table.tick()...)
+ }
}
machines := m.snapshotMachines()
sort.Slice(machines, func(i, j int) bool {
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index a832d53..43bd67f 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -1,10 +1,14 @@
package casino
-import "testing"
+import (
+ "fmt"
+ "testing"
+)
type testWallet struct {
chips, credits, paid int
open []Wager
+ seq int
}
func (w *testWallet) Wager(amount int) (Wager, error) {
@@ -18,7 +22,8 @@ func (w *testWallet) Wager(amount int) (Wager, error) {
w.chips -= chips
credits := amount - chips
w.credits -= credits
- wager := Wager{Total: amount, Chips: chips, Credits: credits, ID: string(rune('a' + len(w.open)))}
+ w.seq++
+ wager := Wager{Total: amount, Chips: chips, Credits: credits, ID: fmt.Sprintf("stake-%02d", w.seq)}
w.open = append(w.open, wager)
return wager, nil
}
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index 7b5de83..c53fe53 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -34,6 +34,13 @@ var tableGames map[GameName]tableGameDef
func init() {
tableGames = map[GameName]tableGameDef{
+ GameCraps: {
+ validate: validateCraps,
+ normalize: func(c TableConfig) TableConfig { c.Craps = c.Craps.Normalize(); return c },
+ ruleBlockName: "craps",
+ hasRuleBlock: func(c TableConfig) bool { return c.Craps != (CrapsConfig{}) },
+ new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newCrapsTable(room, c, rng) },
+ },
GameRoulette: {
validate: func(c TableConfig) error {
if c.Roulette.SpinTicks < 0 || c.Roulette.SpinTicks > maxSpinTicks {
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index b4a13ab..9590baf 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -12,6 +12,8 @@ func (c TableConfig) RulesText() string {
return c.BlackjackRulesText()
case GameBaccarat:
return c.BaccaratRulesText()
+ case GameCraps:
+ return c.CrapsRulesText()
case GameRoulette:
rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" +
"1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" +
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 518af02..56a09d8 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -18,6 +18,7 @@ const (
GameBlackjack GameName = "blackjack"
GameBaccarat GameName = "baccarat"
GameRoulette GameName = "roulette"
+ GameCraps GameName = "craps"
)
type BlackjackConfig struct {
@@ -129,6 +130,7 @@ type TableConfig struct {
Blackjack BlackjackConfig `yaml:"blackjack,omitempty"`
Baccarat BaccaratConfig `yaml:"baccarat,omitempty"`
Roulette RouletteConfig `yaml:"roulette,omitempty"`
+ Craps CrapsConfig `yaml:"craps,omitempty"`
}
type MachineConfig struct {
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index bab996c..afd8e4a 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -16,6 +16,10 @@ func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameBaccarat)
}
+func (g *Game) lookCrapsTable(sess *net.Session, target string) bool {
+ return g.lookCardTable(sess, target, casino.GameCraps)
+}
+
func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameRoulette)
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 08695e7..e9334fc 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -6,6 +6,7 @@ import (
"os"
"strconv"
"strings"
+ "time"
"unicode/utf8"
"thehouseoficarus/internal/cardart"
@@ -431,10 +432,11 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
// sets the wager and any other token is the bet target; AFTER "on", the
// next token is always the target. That makes numeric targets reachable
// ("bet 5 on 17"), which roulette, craps, and keno need.
- // Roulette combination bets need a multi-token target ("split 1 2",
- // "corner 1 2 4 5"), so a roulette table joins every extra target token.
- // Every other game keeps the stricter one-token target and its usage error.
- joinTarget := table.Config.Game == casino.GameRoulette
+ // Roulette combinations and craps bets need multi-token targets. Once
+ // an amount and target keyword exist, subsequent numbers belong to the
+ // target even without "on" (bet 10 split 1 2 / bet 10 buy 4).
+ // Other games keep the stricter one-token target.
+ joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps
amount, spot, hasAmount, sawOn := 0, "", false, false
for i, arg := range args {
if strings.EqualFold(arg, "on") {
@@ -445,7 +447,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sawOn = true
continue
}
- if !sawOn {
+ if !sawOn && !(joinTarget && hasAmount && spot != "") {
if strings.EqualFold(arg, "min") {
if hasAmount {
sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
@@ -538,7 +540,11 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
func (g *Game) CasinoTick() {
if g.Casino != nil {
- g.emitCasinoEvents(g.Casino.Tick())
+ ms := 600
+ if g.Ticks != nil {
+ ms = g.Ticks.TickLengthMilliseconds()
+ }
+ g.emitCasinoEvents(g.Casino.TickElapsed(time.Duration(ms) * time.Millisecond))
}
}
@@ -608,18 +614,19 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
}
}
-// casinoBoardReminder splits a roulette menu so its wager instructions can be
+// casinoBoardReminder splits roulette and craps menus so instructions can be
// printed under the betting board, which is the table the reminder refers to.
// Every other game, and compact displays, keep their menu in one block.
func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) {
if category != "casino_menu" || menu == nil || sess.Player == nil {
return "", "", false
}
+ _, isCraps := snapshot.(*casino.CrapsSnapshot)
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isRoulette || s.Phase == "playing" || s.Phase == "result" {
+ if !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", "", false
}
- if sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", "", false
}
@@ -741,6 +748,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
// how verbose the command menus are.
size := sess.Player.OptionInt("card_size")
switch s := snapshot.(type) {
+ case *casino.CrapsSnapshot:
+ return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style"))
case *casino.RouletteSnapshot:
return renderRouletteSnapshot(s, mode, unicode, size)
case *casino.SlotSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 6e146d0..450480f 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -172,7 +172,7 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) {
}
c, ok := resolveCasinoChoice(choices, input)
if !ok {
- sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, or watch roulette.")
+ sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, watch roulette, or watch craps.")
return
}
t, ok := g.casinoTable(sess.Player.RoomID, c.address)
@@ -199,6 +199,12 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool {
// Settlement may have removed a queued departure before these events
// are delivered. Their own final cards must still be visible.
switch s := event.Snapshot.(type) {
+ case *casino.CrapsSnapshot:
+ for _, name := range s.Queue {
+ if name == sess.Player.Name {
+ return true
+ }
+ }
case *casino.RouletteSnapshot:
for _, name := range s.Players {
if name == sess.Player.Name {
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index d5676d5..853fb6a 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -16,16 +16,21 @@ type commandDef struct {
}
var commandRegistry = map[string]commandDef{
- "get": {(*Game).executeGet, ClassActive},
- "take": {(*Game).executeGet, ClassActive},
- "grab": {(*Game).executeGet, ClassActive},
- "pick": {(*Game).executeGet, ClassActive},
- "drop": {(*Game).executeDrop, ClassActive},
- "attack": {(*Game).executeAttack, ClassActive},
- "kill": {(*Game).executeAttack, ClassActive},
- "work": {(*Game).executeWork, ClassActive},
- "play": {(*Game).executePlay, ClassActive},
- "rebet": {(*Game).executeBet, ClassActive},
+ "get": {(*Game).executeGet, ClassActive},
+ "take": {(*Game).executeGet, ClassActive},
+ "grab": {(*Game).executeGet, ClassActive},
+ "pick": {(*Game).executeGet, ClassActive},
+ "drop": {(*Game).executeDrop, ClassActive},
+ "attack": {(*Game).executeAttack, ClassActive},
+ "kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
+ "play": {(*Game).executePlay, ClassActive},
+ "rebet": {(*Game).executeBet, ClassActive},
+ "roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive},
+ "autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant},
+ "unbet": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "unbet "+strings.Join(args, " "))
+ }, ClassActive},
"table": {(*Game).executeTableStatus, ClassInstant},
"status": {(*Game).executeTableStatus, ClassInstant},
"watch": {(*Game).executeWatch, ClassInstant},
diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go
index 2f2a7ee..7075d01 100644
--- a/internal/game/cmd_stop.go
+++ b/internal/game/cmd_stop.go
@@ -11,6 +11,10 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) {
g.cancelRest(p.Name)
if _, playing := g.Casino.Participation(p.Name); playing {
if table, _ := g.Casino.Participation(p.Name); table != nil {
+ if events, stopped := table.StopAutoRoll(p.Name); stopped {
+ g.emitCasinoEvents(events)
+ return
+ }
// Normal leave refuses committed wagers. Only an explicit stop
// queues a departure; movement/quit retain their existing policy.
events := g.Casino.Leave(table.RoomID, table.Config.ID, p.Name)
diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go
new file mode 100644
index 0000000..569b4db
--- /dev/null
+++ b/internal/game/craps_test.go
@@ -0,0 +1,79 @@
+package game
+
+import (
+ "strings"
+ "testing"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestCrapsCommandsAndRendering(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "craps", Game: casino.GameCraps}
+ g.Casino.Join(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{{"12", "on", "place", "6"}, {"10", "on", "hop", "2", "3"}, {"10", "buy", "4"}, {"10", "on", "fire"}} {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("%v: %s", args, conn.data)
+ }
+ }
+ conn.data = nil
+ g.executeTableStatus(sess, []string{"rules"}, "table rules")
+ if !strings.Contains(string(conn.data), "Bank Craps") || !strings.Contains(string(conn.data), "PASS LINE") {
+ t.Fatal(string(conn.data))
+ }
+ for _, cmd := range []string{"roll", "autoroll", "unbet"} {
+ if _, ok := commandRegistry[cmd]; !ok {
+ t.Fatal("missing", cmd)
+ }
+ }
+ snap := &casino.CrapsSnapshot{Phase: "betting", Point: 6, Dice: [2]int{3, 4}, Queue: []string{"alice", "bob"}, Made: []int{4, 6}, Seen: []int{2, 4, 6}}
+ if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "craps", Snapshot: snap}) {
+ t.Fatal("invisible board")
+ }
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ art := renderCrapsSnapshot(snap, mode, unicode)
+ plain := plainRoulette(art)
+ for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "ALL SMALL", "Shooter alice", "Total: 7", "POINT 6"} {
+ if !strings.Contains(plain, word) {
+ t.Fatal("missing", word)
+ }
+ }
+ for _, r := range plain {
+ if !unicode && r > 127 {
+ t.Fatalf("nonASCII %q", r)
+ }
+ }
+ for _, line := range strings.Split(plain, "\n") {
+ if color.VisibleLen(line) > 78 {
+ t.Fatalf("too wide: %s", line)
+ }
+ }
+ if mode == "none" && art != plain {
+ t.Fatal("unexpected ANSI")
+ }
+ if mode != "none" && !strings.Contains(art, "\x1b[") {
+ t.Fatal("missing felt color")
+ }
+ }
+ }
+ // Snapshots and nil/empty states render safely before the first roll.
+ if renderCrapsSnapshot(nil, "none", false) != "" {
+ t.Fatal("nil")
+ }
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ snap.Dice = [2]int{a, b}
+ if !strings.Contains(renderCrapsSnapshot(snap, "none", false), "Total:") {
+ t.Fatal(a, b)
+ }
+ }
+ }
+}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 8d970e4..2311710 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) {
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) {
return
}
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
new file mode 100644
index 0000000..19688ed
--- /dev/null
+++ b/internal/game/render_craps.go
@@ -0,0 +1,328 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// A half-table: numbered boxes and the come/field/line rails on the left;
+// the shared centre-action bets on the right. Every region is a real box,
+// and shared edges are joined rather than pasted together as broken borders.
+const crapsFelt = 22 // #005f00: darker than ANSI green / the roulette felt.
+const (
+ crapsInk = iota
+ crapsGold
+ crapsRed
+ crapsMuted
+ crapsMarked
+ crapsPuckOn
+ crapsPuckOff
+)
+
+var crapsPaint = []color.ColorSpec{
+ {Fg: 15, Bg: crapsFelt},
+ {Fg: 229, Bg: crapsFelt, Bold: true},
+ {Fg: 217, Bg: crapsFelt, Bold: true},
+ {Fg: 108, Bg: crapsFelt},
+ {Fg: 0, Bg: 220, Bold: true},
+ {Fg: 0, Bg: 15, Bold: true},
+ {Fg: 15, Bg: 0, Bold: true},
+}
+
+type crapsCell struct {
+ glyph rune
+ edges uint8
+ ink int
+}
+type crapsCanvas struct{ cells [][]crapsCell }
+
+func newCrapsCanvas(w, h int) *crapsCanvas {
+ c := &crapsCanvas{cells: make([][]crapsCell, h)}
+ for y := range c.cells {
+ c.cells[y] = make([]crapsCell, w)
+ }
+ return c
+}
+func (c *crapsCanvas) box(x1, y1, x2, y2 int) {
+ // N=1, E=2, S=4, W=8. Unioning edges makes all T/cross joints exact.
+ for x := x1; x < x2; x++ {
+ c.cells[y1][x].edges |= 2
+ c.cells[y1][x+1].edges |= 8
+ c.cells[y2][x].edges |= 2
+ c.cells[y2][x+1].edges |= 8
+ }
+ for y := y1; y < y2; y++ {
+ c.cells[y][x1].edges |= 4
+ c.cells[y+1][x1].edges |= 1
+ c.cells[y][x2].edges |= 4
+ c.cells[y+1][x2].edges |= 1
+ }
+}
+func (c *crapsCanvas) text(x, y int, s string, ink int) {
+ for _, r := range s {
+ if x >= 0 && x < len(c.cells[y]) {
+ c.cells[y][x] = crapsCell{glyph: r, ink: ink}
+ }
+ x++
+ }
+}
+func (c *crapsCanvas) center(x1, x2, y int, s string, ink int) {
+ c.text(x1+(x2-x1+1-color.VisibleLen(s))/2, y, s, ink)
+}
+func (c *crapsCanvas) render(mode string, unicode bool) string {
+ joints := map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'}
+ var out strings.Builder
+ for _, row := range c.cells {
+ var run strings.Builder
+ ink := row[0].ink
+ flush := func() {
+ if run.Len() > 0 {
+ out.WriteString(color.Render(mode, crapsPaint[ink], run.String()))
+ run.Reset()
+ }
+ }
+ for _, cell := range row {
+ if cell.ink != ink {
+ flush()
+ ink = cell.ink
+ }
+ r := cell.glyph
+ if r == 0 {
+ r = ' '
+ if cell.edges != 0 {
+ switch {
+ case cell.edges&5 == 0:
+ r = '─'
+ case cell.edges&10 == 0:
+ r = '│'
+ default:
+ r = joints[cell.edges]
+ }
+ if !unicode {
+ switch r {
+ case '─':
+ r = '-'
+ case '│':
+ r = '|'
+ default:
+ r = '+'
+ }
+ }
+ }
+ }
+ run.WriteRune(r)
+ }
+ flush()
+ out.WriteByte('\n')
+ }
+ return out.String()
+}
+func crapsHas(ns []int, n int) bool {
+ for _, v := range ns {
+ if v == n {
+ return true
+ }
+ }
+ return false
+}
+func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(77, 7)
+ c.box(0, 0, 76, 3)
+ c.box(0, 3, 38, 6)
+ c.box(38, 3, 76, 6)
+ c.text(3, 1, "FIRE", crapsGold)
+ for i, n := range []int{4, 5, 6, 8, 9, 10} {
+ ink, mark := crapsMuted, " "
+ if crapsHas(s.Made, n) {
+ ink, mark = crapsMarked, "*"
+ }
+ c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink)
+ }
+ c.center(1, 75, 2, "MADE POINTS 4: 24 to 1 5: 249 to 1 6: 999 to 1", crapsInk)
+ c.text(2, 4, "ALL SMALL", crapsGold)
+ c.text(40, 4, "TALL", crapsGold)
+ for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} {
+ x := 12 + i*5
+ if i >= 5 {
+ x = 48 + (i-5)*5
+ }
+ ink, mark := crapsMuted, " "
+ if crapsHas(s.Seen, n) {
+ ink, mark = crapsMarked, "*"
+ }
+ c.text(x, 4, fmt.Sprintf("%2d%s ", n, mark), ink)
+ }
+ c.center(1, 37, 5, "SMALL 34:1 | ALL 175:1", crapsInk)
+ c.center(39, 75, 5, "TALL 34:1 | * = rolled", crapsInk)
+ return c.render(mode, unicode)
+}
+func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(77, 26)
+ c.box(0, 0, 14, 6)
+ c.center(1, 13, 4, "DON'T COME", crapsInk)
+ c.center(1, 13, 5, "BAR 12", crapsMuted)
+ nums := []int{4, 5, 6, 8, 9, 10}
+ labels := []string{"4", "5", "SIX", "8", "NINE", "10"}
+ pays := []string{"9:5", "7:5", "7:6", "7:6", "7:5", "9:5"}
+ puck := func(x int, on bool) {
+ ink, word := crapsPuckOff, "OFF"
+ if on {
+ ink, word = crapsPuckOn, "ON "
+ }
+ a, b, d := "+---+", "|"+word+"|", "+---+"
+ if unicode {
+ a, b, d = "╭───╮", "│"+word+"│", "╰───╯"
+ }
+ c.text(x, 1, a, ink)
+ c.text(x, 2, b, ink)
+ c.text(x, 3, d, ink)
+ }
+ if s.Point == 0 {
+ puck(5, false)
+ }
+ for i, n := range nums {
+ x := 14 + i*6
+ c.box(x, 0, x+6, 6)
+ c.center(x+1, x+5, 4, labels[i], crapsGold)
+ c.center(x+1, x+5, 5, pays[i], crapsMuted)
+ if s.Point == n {
+ puck(x+1, true)
+ }
+ }
+ c.box(0, 6, 50, 10)
+ c.center(1, 49, 7, "PLACE / BUY / LAY ON THE NUMBERS ABOVE", crapsMuted)
+ c.center(1, 49, 9, "C O M E", crapsRed)
+ c.box(0, 10, 50, 16)
+ c.center(1, 49, 11, "FIELD", crapsGold)
+ c.center(1, 49, 13, "( 2 ) 3 4 9 10 11 ( 12 )", crapsInk)
+ twelve := s.Field12
+ if twelve == 0 {
+ twelve = 2
+ }
+ c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %d:1", twelve), crapsGold)
+ c.box(0, 16, 50, 19)
+ c.center(1, 49, 18, "DON'T PASS / BAR 12", crapsInk)
+ c.box(0, 19, 50, 22)
+ c.center(1, 49, 21, "PASS LINE", crapsGold)
+ c.box(0, 22, 50, 25)
+ c.center(1, 49, 24, "FREE ODDS / BEHIND THE LINE", crapsInk)
+ // Centre action: four bonus spots, four hardways, six propositions, horn/hop.
+ bonus := []struct {
+ label, pays string
+ }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}}
+ xs := []int{50, 56, 63, 69, 76}
+ for i, spot := range bonus {
+ c.box(xs[i], 0, xs[i+1], 5)
+ c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsGold)
+ c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsMuted)
+ }
+ for i, n := range []int{4, 6, 8, 10} {
+ x, y := 50+(i%2)*13, 5+(i/2)*4
+ c.box(x, y, x+13, y+4)
+ c.center(x+1, x+12, y+1, fmt.Sprintf("HARD %d", n), crapsInk)
+ pay := 7
+ if n == 6 || n == 8 {
+ pay = 9
+ }
+ c.center(x+1, x+12, y+3, fmt.Sprintf("%d+%d %d:1", n/2, n/2, pay), crapsGold)
+ }
+ props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"}
+ odds := []string{"4:1", "7:1", "15:1", "30:1", "15:1", "30:1"}
+ px := []int{50, 59, 67, 76}
+ for i, label := range props {
+ col, y := i%3, 13+(i/3)*4
+ x, x2 := px[col], px[col+1]
+ c.box(x, y, x2, y+4)
+ c.center(x+1, x2-1, y+1, label, crapsRed)
+ c.center(x+1, x2-1, y+3, odds[i], crapsInk)
+ }
+ c.box(50, 21, 63, 25)
+ c.center(51, 62, 22, "HORN", crapsGold)
+ c.center(51, 62, 24, "2 3 11 12", crapsMuted)
+ c.box(63, 21, 76, 25)
+ c.center(64, 75, 22, "HOP", crapsGold)
+ c.center(64, 75, 24, "15:1 / 30:1", crapsMuted)
+ return c.render(mode, unicode)
+}
+func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string {
+ if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 {
+ return ""
+ }
+ patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}}
+ top, bottom, v, pip := "+-------+", "+-------+", "|", "o"
+ if unicode {
+ top, bottom, v, pip = "╭───────╮", "╰───────╯", "│", "●"
+ }
+ spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true}
+ if style == "light" {
+ spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ }
+ var out strings.Builder
+ for row := 0; row < 5; row++ {
+ out.WriteString(" ")
+ for i, n := range dice {
+ line := top
+ if row == 4 {
+ line = bottom
+ } else if row > 0 {
+ line = v + " " + strings.ReplaceAll(patterns[n][row-1], "o", pip) + " " + v
+ }
+ out.WriteString(color.Render(mode, spec, line))
+ if i == 0 {
+ out.WriteString(" ")
+ }
+ }
+ if row == 2 {
+ out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1])))
+ }
+ out.WriteByte('\n')
+ }
+ return out.String()
+}
+func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, styles ...string) string {
+ if s == nil {
+ return ""
+ }
+ style := "dark"
+ if len(styles) > 0 {
+ style = styles[0]
+ }
+ var out strings.Builder
+ title := func(text string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, text)
+ }
+ subtle := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: 250, Bg: -1}, text) }
+ point := "COME-OUT"
+ if s.Point != 0 {
+ point = fmt.Sprintf("POINT %d", s.Point)
+ }
+ out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n")
+ out.WriteString(renderCrapsTrackers(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsFelt(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ if len(s.Queue) > 0 {
+ out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n")
+ if len(s.Queue) > 1 {
+ out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n")
+ }
+ }
+ hint := "Place a bet, then roll when ready."
+ if s.Phase != "waiting" && s.Phase != "" {
+ hint = "Your pace. Roll when ready."
+ if s.ShootIn > 0 {
+ hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn)
+ } else if s.DealerIn > 0 {
+ hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn)
+ }
+ }
+ if s.AutoRoll {
+ hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn)
+ }
+ out.WriteString(subtle(" "+hint) + "\n")
+ return out.String()
+}
diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go
new file mode 100644
index 0000000..9d75630
--- /dev/null
+++ b/internal/game/render_craps_test.go
@@ -0,0 +1,130 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func TestCrapsHalfTableGeometryAndPuck(t *testing.T) {
+ for _, unicode := range []bool{false, true} {
+ for _, point := range []int{0, 4, 5, 6, 8, 9, 10} {
+ s := &casino.CrapsSnapshot{Point: point, Field12: 3}
+ art := renderCrapsFelt(s, "none", unicode)
+ rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n")
+ if len(rows) != 26 {
+ t.Fatal(len(rows))
+ }
+ for _, row := range rows {
+ if color.VisibleLen(row) != 77 {
+ t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row)
+ }
+ }
+ // Check connected box edges at the main rail and centre-action partition.
+ if unicode {
+ for _, r := range []rune{'┌', '┬', '┼', '├', '┤', '┴', '┘'} {
+ if !strings.ContainsRune(art, r) {
+ t.Fatalf("missing joint %c", r)
+ }
+ }
+ }
+ v := "|"
+ if unicode {
+ v = "│"
+ }
+ want := v + "OFF" + v
+ if point != 0 {
+ want = v + "ON " + v
+ }
+ if strings.Count(art, want) != 1 {
+ t.Fatalf("missing/duplicate puck %d: %s", point, art)
+ }
+ if !strings.Contains(art, "12 PAYS 3:1") {
+ t.Fatal("field variant invisible")
+ }
+ for _, bet := range []string{"DON'T COME", "C O M E", "FIELD", "DON'T PASS", "PASS LINE", "HARD 4", "HARD 6", "HARD 8", "HARD 10", "SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS", "HORN", "HOP"} {
+ if strings.Count(art, bet) != 1 {
+ t.Fatalf("missing or duplicate %q", bet)
+ }
+ }
+ }
+ }
+ t.Log("\n" + renderCrapsSnapshot(&casino.CrapsSnapshot{Point: 6, Field12: 3, Dice: [2]int{3, 3}, Queue: []string{"Alice", "Bob"}, Made: []int{4, 5}, Seen: []int{2, 4, 5, 6, 9}, Phase: "betting", ShootIn: 10, DealerIn: 70}, "none", true))
+}
+func TestCrapsTrackersAndDiceStyles(t *testing.T) {
+ s := &casino.CrapsSnapshot{Made: []int{4, 6}, Seen: []int{2, 12}}
+ art := renderCrapsTrackers(s, "xterm256", true)
+ plain := plainRoulette(art)
+ for _, token := range []string{"[ 4*]", "[ 5 ]", "[ 6*]", " 2*", "12*"} {
+ if !strings.Contains(plain, token) {
+ t.Fatal("tracker missing", token)
+ }
+ }
+ if strings.Count(art, color.BgCode("xterm256", 220)) != 4 {
+ t.Fatal("wrong number of filled tracker tokens")
+ }
+ if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) {
+ t.Fatal("felt not dark green")
+ }
+ g := &Game{}
+ p := player.New("Alice")
+ p.Options = map[string]any{"card_style": "light", "unicode": true}
+ sess := &net.Session{Player: p}
+ s.Dice = [2]int{2, 5}
+ light := g.renderCasinoSnapshot(sess, s)
+ p.Options["card_style"] = "dark"
+ dark := g.renderCasinoSnapshot(sess, s)
+ if light == dark {
+ t.Fatal("card_style not wired to craps")
+ }
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ for _, unicode := range []bool{false, true} {
+ lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "light")
+ dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "dark")
+ if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 0)) {
+ t.Fatal("incorrect dice background")
+ }
+ pip := "o"
+ if unicode {
+ pip = "●"
+ }
+ if strings.Count(strings.ReplaceAll(plainRoulette(lit), "Total:", ""), pip) != a+b {
+ t.Fatal("wrong pip count", a, b)
+ }
+ if plainRoulette(lit) != plainRoulette(dim) {
+ t.Fatal("style changed dice outcome")
+ }
+ }
+ }
+ }
+}
+func TestCrapsRemindersFollowArt(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
+ g.Casino.Join(1, casino.TableConfig{ID: "c", Game: casino.GameCraps}, "alice")
+ conn := &casinoTestConn{}
+ p := player.New("alice")
+ sess := &net.Session{Player: p, Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ for _, display := range []string{"full", "compact"} {
+ p.Options = map[string]any{"casino_display": display}
+ conn.data = nil
+ g.emitCasinoEvents(g.Casino.Table(1, "c").Status("alice"))
+ text := plainRoulette(string(conn.data))
+ felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet <amount> on <target>")
+ if felt < 0 || commands < felt {
+ t.Fatal("reminder above graphic", text)
+ }
+ for _, old := range []string{"Next in order:", "Fire points made:", "ATS numbers:", "Phase:", "Timer: 0"} {
+ if strings.Contains(text, old) {
+ t.Fatal("raw status leaked", old)
+ }
+ }
+ }
+}
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index d69b5bf..1e7f971 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -598,6 +598,20 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int
func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
rules := g.colorize(sess, "casino_action", cfg.RulesText())
+ if cfg.Game == casino.GameCraps {
+ snap := &casino.CrapsSnapshot{Phase: "waiting"}
+ if g.Casino != nil && sess.Player != nil {
+ if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil {
+ for _, event := range table.Status(sess.Player.Name) {
+ if view, ok := event.Snapshot.(*casino.CrapsSnapshot); ok {
+ snap = view
+ break
+ }
+ }
+ }
+ }
+ return rules + "\n" + g.renderCasinoSnapshot(sess, snap)
+ }
if cfg.Game == casino.GameRoulette {
snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
// Rules views draw a betting board too; use the live table's history
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index 7b83807..0c5b62e 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -56,6 +56,7 @@ func TestRouletteCombinationTargetParsing(t *testing.T) {
{"10", "on", "split", "1", "2"},
{"10", "on", "split", "1-2"},
{"10", "split", "1-2"},
+ {"10", "split", "1", "2"},
{"10", "on", "street", "1", "2", "3"},
{"10", "on", "street", "3", "1", "2"},
{"10", "on", "corner", "1", "2", "4", "5"},