From 0fcb03633e3008169173d76c8408f5410592b3eb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 30 Sep 2026 05:33:45 -0400 Subject: feat: bank craps added to casino --- internal/casino/craps.go | 638 +++++++++++++++++++++++++++++++ internal/casino/craps_clock.go | 120 ++++++ internal/casino/craps_clock_test.go | 309 +++++++++++++++ internal/casino/craps_projection.go | 225 +++++++++++ internal/casino/craps_projection_test.go | 83 ++++ internal/casino/craps_rules.go | 205 ++++++++++ internal/casino/craps_test.go | 403 +++++++++++++++++++ internal/casino/craps_ux.go | 84 ++++ internal/casino/manager.go | 14 +- internal/casino/manager_test.go | 9 +- internal/casino/registry.go | 7 + internal/casino/table_ux.go | 2 + internal/casino/types.go | 2 + 13 files changed, 2097 insertions(+), 4 deletions(-) create mode 100644 internal/casino/craps.go create mode 100644 internal/casino/craps_clock.go create mode 100644 internal/casino/craps_clock_test.go create mode 100644 internal/casino/craps_projection.go create mode 100644 internal/casino/craps_projection_test.go create mode 100644 internal/casino/craps_rules.go create mode 100644 internal/casino/craps_test.go create mode 100644 internal/casino/craps_ux.go (limited to 'internal/casino') 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 on pass / dontpass / come / dontcome / field\n"+ + "bet on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ + "bet on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ + "bet on odds pass / odds come 6 (also dontpass/dontcome)\n"+ + "bet 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 : 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 on ", 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 | 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 { -- cgit v1.2.3