package casino import ( "fmt" "math/rand" "strconv" "strings" ) type crapsBet struct { kind string number int stake Wager odds Wager } type crapsPlayer struct { Participant bets []*crapsBet lastSpot string lastBet int autoRoll bool autoTicks int } 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 roundTicks int multiplayer bool lastCall bool pendingRollTicks int // manual roll: ticks left before the dice resolve countdown int // simplified craps: automatic-roll countdown lines emitted } type CrapsBetView struct { Name, Target string Amount, Odds int } type CrapsSnapshot struct { Variant string Phase string Point int Dice [2]int Queue []string Made, Seen []int Bets []CrapsBetView ShootIn int DealerIn int AutoRoll bool AutoIn int Field12 int Odds string // free-odds cap label, e.g. "3x-4x-5x" or "100x" } 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{Variant: t.Config.Craps.variant(), Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12, Odds: t.Config.Craps.oddsDisplay()} if t.hasLiveBets() { if t.Config.Craps.variant() == "simplified" { // One automatic roll: the snapshot counts down the shared window. s.ShootIn = max(0, crapsSimplifiedTicks-t.roundTicks) } else if t.multiplayer { s.ShootIn = max(0, crapsBetTicks-t.roundTicks) s.DealerIn = max(0, crapsBetTicks+crapsDealerTicks-t.roundTicks) } } if p := t.shooter(); p != nil && p.autoRoll { s.AutoRoll = true s.AutoIn = max(s.ShootIn, max(0, crapsAutoTicks-p.autoTicks)) } 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 } // board is the table-only view for the table command. func (t *crapsTable) board(_ string) Snapshot { t.mu.Lock() defer t.mu.Unlock() return t.view() } func (t *crapsTable) menu(name string) Event { e := t.betMenuEvent(name, t.commands(name)) if e.Menu != nil { e.Menu.Heading = t.shooterHeading() } 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) { wasShooter := len(t.queue) > 0 && t.queue[0] == name if wasShooter { // The queued roll belongs to the shooter; a departing shooter cancels it. t.pendingRollTicks = 0 } 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.countdown = 0 t.made = map[int]bool{} t.seen = map[int]bool{} t.phase = phaseWaiting t.window.close() } else if wasShooter { // The next shooter starts a fresh come-out. Without this he inherits // the previous shooter's point (blocking pass/dontpass and bonus // bets) and made/seen bonus progress. The shared countdown is left // alone: it belongs to the table, not the shooter. t.point = 0 t.rolled = false t.made = map[int]bool{} t.seen = map[int]bool{} } } 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 = n >= 2 && n <= 12 && n != 7 && (kind != "hard" || (crapsNumber(n) && n%2 == 0)) return case "hop": if len(f) != 3 { return } a, _ := strconv.Atoi(f[1]) b, _ := strconv.Atoi(f[2]) if a > b { a, b = b, a } 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 } if spot == "" && t.Config.Craps.variant() == "simplified" { // Pass is the only target, so "bet N" needs no target at all. spot = "pass" } 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 ") { if !t.Config.Craps.allows("odds") { return reject("This variant has no free 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") // The odds payout is a whole-ratio return on the stake, so the wager // must divide evenly by the ratio's denominator. Don't odds invert // the pass ratio, so against the same number the other side of the // true odds is the denominator. (crapsReturn applies the full // ratio at payout time.) num, den := crapsTrueOdds(b.number) payoutDen := den if against { payoutDen = num } limit := t.Config.Craps.oddsLimit(b.number, against) if b.odds.Total > 0 { return reject("Odds already placed on that bet.") } requested := amount amount = roundUpTo(amount, payoutDen) if amount > t.Config.MaxBet { return reject(fmt.Sprintf("That wager rounds up to %d, above the table maximum.", amount)) } // Compare without multiplying a potentially very large stake by Nx. if (amount-1)/limit >= b.stake.Total { return reject("Odds already placed or above the free-odds limit.") } stake, err := w.Wager(amount) if err != nil { return reject(walletErrorMessage(err)) } b.odds = stake p.Wallet = w p.lastSpot, p.lastBet = spot, amount msg := fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target) if amount != requested { msg += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact odds units.{/}", requested) } return []Event{t.private(name, msg), t.betSummary(p), t.reminder(name)} } return reject("Place an established line/come bet before taking odds.") } kind, n, ok := parseCrapsTarget(spot) if !ok || !t.Config.Craps.allows(kind) || ((kind == "place" || kind == "buy" || kind == "lay") && !t.Config.Craps.number(n)) { return reject("Unknown or unavailable craps target. Use table to see the felt or rules for the guide.") } 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": _, unit = crapsPlaceOdds(n) case "buy", "lay": // Weird odds must land on whole-credit payouts and a whole-credit vig. unit = crapsVigUnit(kind, n) case "horn": unit = 4 } requested := amount amount = roundUpTo(amount, unit) if amount > t.Config.MaxBet { return reject(fmt.Sprintf("That wager rounds up to %d, above the table maximum.", amount)) } if _, err := MulPayout(amount, 1000); err != nil { return reject("That potential payout is too large.") } 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 private := fmt.Sprintf("You bet {casino_chips}%d{/} on %s.", amount, spot) if amount != requested { private += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact payout units.{/}", requested) } events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), private)} events = append(events, t.syncRoundClock()...) return append(events, t.betSummary(p), t.reminder(name)) } func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) bool { payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) ids := []string{b.stake.ID} if b.odds.Total > 0 { ids = append(ids, b.odds.ID) } return p.Wallet.PayCredits(payout, ids...) != nil } func (t *crapsTable) roll() []Event { a, b := 0, 0 switch t.Config.Craps.variant() { case "die_rich": a = t.rng.Intn(6) + 1 case "diceless": // Each independent pack is replaced and reshuffled for every draw. a = t.rng.Perm(6)[0] + 1 b = t.rng.Perm(6)[0] + 1 default: 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 pointMade := oldPoint != 0 && oldPoint == s r := t.Config.Craps switch r.variant() { case "simplified", "high_point", "die_rich": done, profit, _ := r.outcome("pass", oldPoint, a, b, oldPoint) sevenOut = done && profit < 0 pointMade = done && profit > 0 && oldPoint != 0 } t.dice = [2]int{a, b} t.rolled = true shooter := "Dealer" if len(t.queue) > 0 { shooter = t.queue[0] } // The dice tile already shows the total, so the roll leads with the felt // itself. Diceless has no dice tile to carry the cards, so it keeps its line. events := []Event{t.event("", true)} if r.variant() == "diceless" { events[0].Message = fmt.Sprintf("{casino_emphasis}%s draws cards{/} [%d] [%d] = %d", shooter, a, b, s) } var results []Event for _, p := range t.players.ordered() { p.autoTicks = 0 var lines []settlementLine failed := false var live []*crapsBet for _, bet := range p.bets { done, x, y := t.outcomeFor(bet, a, b) if done { refundOdds := oldPoint == 0 && bet.kind == "come" if t.pay(p, bet, x, y, x, refundOdds) { failed = true } else { lines = append(lines, t.crapsSettlementLines(bet, x, y, refundOdds, false)...) } continue } if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && r.pointNumber(s) { bet.number = s } live = append(live, bet) } p.bets = live p.HasBet = len(live) > 0 for _, bet := range live { lines = append(lines, t.crapsSettlementLines(bet, 0, 1, false, true)...) } if len(lines) > 0 || failed { results = append(results, t.settlementEvent(p, lines, failed)) } } if pointMade { t.made[oldPoint] = true } if s == 7 { t.seen = map[int]bool{} } else { t.seen[s] = true } if oldPoint == 0 && r.pointNumber(s) { t.point = s events = append(events, t.event(fmt.Sprintf("{casino_emphasis}POINT %d{/} / Puck ON.", s), true)) // "Make your point!" only speaks to the shooter and players with a // pass-line contract; everyone else just sees the puck move. for _, p := range t.players.ordered() { if !p.Connected || p.SittingOut { continue } if p.Name == shooter || crapsHasPassLine(p) { events = append(events, t.private(p.Name, "{casino_emphasis}Make your point!{/}")) } } } else if pointMade || sevenOut { if pointMade { events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", oldPoint), 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]) } message := "{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter." if r.variant() == "simplified" || r.variant() == "high_point" || r.variant() == "die_rich" { message = "{casino_warning}SHOOTER OUT{/} / Puck OFF. Next shooter." } events = append(events, t.event(message, 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.autoTicks = 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.advance(1) } 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 action == "leave_after" { p.LeaveAfter = true events := []Event{t.private(name, "You’ll leave after this round. Your live bets still need to settle.")} events = append(events, t.finishCrapsControls()...) return append(events, t.syncRoundClock()...) } if events, ok := t.controlLocked(name, action); ok { events = append(events, t.finishCrapsControls()...) return append(events, t.syncRoundClock()...) } if t.Config.Craps.variant() == "simplified" && (action == "roll" || action == "autoroll") { return []Event{t.private(name, "Simplified craps rolls by itself 3 ticks after a bet; there is no manual roll.")} } if action == "autoroll" { if !p.HasBet { return []Event{t.private(name, "Place a bet before enabling autoroll.")} } if !p.autoRoll { p.autoRoll = true p.autoTicks = 0 } return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 ticks on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play."), t.reminder(name)} } 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.roundTicks < crapsBetTicks { return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d ticks{/}. Bets remain open until you roll.", crapsBetTicks-t.roundTicks))) } if t.pendingRollTicks > 0 { return append(events, t.private(name, "The dice are already in the air.")) } t.pendingRollTicks = crapsManualRollTicks shake := t.playerAction(name, fmt.Sprintf("{casino_emphasis}%s shakes the dice...{/}", name), "{casino_emphasis}You shake the dice...{/}") return append(events, shake) } if action == "unbet" { return append(t.unbetAllLocked(p), t.finishCrapsControls()...) } return []Event{t.private(name, "Use table for the felt or rules for the craps guide.")} } 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) } 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.")} } // Keep the live wagers intact until the atomic refund succeeds. On a // failed save they still belong to the next roll, or a later unbet retry. if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{t.private(p.Name, "The house could not take down your bets. All wagers remain live; try unbet again.")} } for _, b := range live { b.odds = Wager{} } 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)) 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. // Every live bet resolves as a loss covered by ONE wallet save: PayCredits // with a zero return consumes every listed stake in that one save the same // way a losing round does, instead of one save per bet. var ids []string var lines []settlementLine for _, b := range p.bets { ids = append(ids, b.stake.ID) if b.odds.Total > 0 { ids = append(ids, b.odds.ID) } lines = append(lines, t.crapsSettlementLines(b, -1, 1, false, false)...) } failed := false if p.Wallet != nil { failed = p.Wallet.PayCredits(0, ids...) != nil } if failed { lines = nil } 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.autoTicks = 0 } t.point = 0 t.rolled = false t.countdown = 0 t.pendingRollTicks = 0 t.made = map[int]bool{} t.seen = map[int]bool{} t.phase = phaseWaiting t.window.close() return events }