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 }