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 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 internal/casino/craps.go (limited to 'internal/casino/craps.go') 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 +} -- cgit v1.2.3