aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/craps.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 05:33:45 -0400
committerhistoria <[not public]>2026-09-30 05:33:45 -0400
commit0fcb03633e3008169173d76c8408f5410592b3eb (patch)
tree814797fb33a129b1c49f8ae309c6647027f5e528 /internal/casino/craps.go
parent735428f0667f67c12fdf3caca872d7dc65e2ce26 (diff)
downloadthehouseoficarus-0fcb03633e3008169173d76c8408f5410592b3eb.tar.gz
feat: bank craps added to casino
Diffstat (limited to 'internal/casino/craps.go')
-rw-r--r--internal/casino/craps.go638
1 files changed, 638 insertions, 0 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
+}