diff options
30 files changed, 2890 insertions, 29 deletions
diff --git a/building_guide/craps.md b/building_guide/craps.md new file mode 100644 index 0000000..92a4a7f --- /dev/null +++ b/building_guide/craps.md @@ -0,0 +1,100 @@ +# Bank craps + +The playable bank-craps table is in room 6185, west of single-zero roulette. +`play craps`, `watch craps`, `table`, `table rules`, and `help craps` use the +existing casino interfaces. `roll`, `autoroll` and `unbet <target>` are table actions; +`down` remains the movement command. + +```yaml +casino_tables: + - id: bank_craps + game: craps + max_players: 12 + min_bet: 1 + max_bet: 10000 + craps: + variant: bank + odds: 0 + field_12: 3 +``` + +`odds: 0` (default) means 3x on 4/10, 4x on 5/9, and 5x on 6/8. +Don't-side odds permit 6x the underlying wager **laid**, producing the same +3/4/5x maximum win. A positive integer selects uniform Nx **stake** limits +on both sides; supported values are 1 through 1,000,000. Odds are free of +commission. `field_12` is 2 (default) or 3. Field 2 always pays 2:1. +Only omitted/`bank` variants are accepted; unavailable variants fail validation. +Shared min/max limits apply to each individual wager, including odds. + +## Engine policy + +- Pass, don't pass (bar 12), come, don't come, true odds; place, buy, lay; + hardways; field; individual propositions, horn, and unordered hop bets. +- Multiple wagers have independent durable stake handles. Wins return the + stake and remove the wager; no automatic renewal, pressing or parlaying. +- Place/buy/hardways and come odds are off on come-out. Established come + contracts still work. Lay bets and don't-side odds always work. +- Exact payout units are required. Buy commission is 5% of the stake; lay + commission is 5% of profit. Commission is rounded up and charged only on + wins. No commission is charged for removing a wager. +- Fire pays 24/249/999:1 on 4/5/6 distinct made points, settled at seven-out. + Repeated made points do not advance it. Come-out sevens do not end Fire. +- Small (2–6) and Tall (8–12) pay 34:1; All pays 175:1. Each pays immediately + when complete. **Any** seven loses unresolved ATS and resets its tracker. + All bonus wagers must be made before the first roll of a shooter hand; + they cannot be removed or added midway, even following a come-out seven. +- Solo play has no betting delay or forced rolls. In multiplayer, every + round gives players 10 seconds before the shooter may roll, with a public + last call at 3 seconds remaining. **Bets and removals remain open until + the actual roll.** Once eligible, the shooter has 60 seconds before the + dealer rolls for them. Only connected, non-sitting-out seats count. + Without live bets the table pauses. A second active player joining gives + the table a fresh 10-second window; additional wagers never extend it. +- `autoroll` opts the player into a roll every 3 seconds while it is their + turn and they still have live bets. It respects multiplayer's 10-second + minimum and stops automatically once their bets finish. `stop` cancels + autoroll atomically and keeps them seated with their bets unchanged; a + second stop in manual mode uses the normal leave behavior. +- These durations use the configured engine cadence through + `Manager.TickElapsed`; there are no independent timers or goroutines. + `betting_ticks` is not used by craps (other games retain their rules). +- New arrivals enter at the tail of the queue. A made point retains the + shooter; seven-out rotates them to the tail. Sit-outs are skipped. + If a shooter departs, the next player completes the existing table hand; + points and bonus progress are not reset to grant free options. +- Pass/come and bonus bets are contracts. Other bets and odds may be removed + with `unbet` (or all at once with `unbet all`) before the dice roll. + In manual mode `stop`/`sitout` wait for the player's + last live bet, not merely the next roll. +- Disconnect forfeits live bets; shutdown/room eviction refunds outstanding + stakes. Failed settlements are reported as pending recovery, never as paid. + Like other casino games, table state is not persisted across restart: + the pending-stake wallet ledger provides recovery. + +## Implementation and verification + +`internal/casino/craps_rules.go` contains configuration, payout rules and the +pure per-bet outcome function. `craps.go` handles phases, shooter order, +bonus tracking, wallets and detached snapshots. Registration uses the existing +`tableGame`/`tableCore` seam. `internal/game/render_craps.go` draws a dark-green boxed half-table, ON/OFF +puck, and filled Fire/ATS number trackers. Pip dice honor `card_style` dark/light, +ASCII/Unicode, and terminal color preferences. Command reminders follow the art. +Each player gets a numbered-target wager summary after bets and rolls, and one +consolidated, colored settlement (not a balance repeated after every wager). + +Tests cover all 36 line/come outcomes, payout examples, configurable odds, +come-out working rules, bonus sequences, shooter rotation, deadlines, removal, +queued departures, disconnect/shutdown, payout failure recovery, command target +parsing, and rendering. Run `go test ./...` and +`go test -race ./internal/casino ./internal/game ./internal/admin ./internal/validate`. + +## Follow-on variants (not implemented) + +Simplified, High Point, Crapless, New York, Die Rich and Diceless Craps require +separate, explicit rules and acceptance tests; names alone are not adequate +specifications because regional versions differ. Do not map them onto bank +craps silently. Before adding each, pin its come-out/point rules, supported +bets, payouts, commissions, odds limits and bonus eligibility. Diceless also +needs an explicit card model (replacement vs. finite shoe, card mapping, +shuffle policy, and card visuals). Preserve the shared wallet and queue +lifecycle while isolating these outcome and random-source differences. diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 6f9e957..8d4e8de 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -448,7 +448,8 @@ casino configurations. Raw file editing is not a validated save endpoint. - IDs must be nonempty, have no surrounding whitespace, and be unique across the room's tables and machines. -- Supported table games are `blackjack` and `baccarat`; slots are machines only +- Supported table games are `blackjack`, `baccarat`, `roulette`, and `craps` + (see [Bank craps](craps.md) for rules and configuration); slots are machines only and must be declared under `casino_machines`. A `slots` entry under `casino_tables` is rejected. Machines use `slots` with the optional variant `huff_and_even_more_puff`. A machine variant is diff --git a/data/help/casino.yaml b/data/help/casino.yaml index e5ec558..1ff11a7 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,13 +1,13 @@ name: casino aliases: [play, bet, rebet, table, watch] category: Interaction -related: [roulette, racetrack, blackjack, baccarat, slots] +related: [roulette, racetrack, blackjack, baccarat, craps, slots] description: | Casino quick guide play <game> Join a table; use play to list nearby games. bet <amount> Wager chips first, then credits. - bet 10 on <target> Choose a roulette or baccarat bet. + bet 10 on <target> Choose a roulette, baccarat or craps bet. bet / rebet Repeat your last table wager. table Show the table; roulette also shows payout tables. table help Learn the current game and its controls. @@ -17,12 +17,14 @@ description: | Profit odds exclude your returned stake: a 10-credit win at 2:1 returns 30 total (20 profit + 10 stake). A push returns the stake; a loss returns nothing. Slots report total returns, not profit odds. - Fractional table-game returns round down to whole credits. + Fractional table-game returns round down to whole credits, except + craps which requires exact payout units (commission rounds up). help roulette Number, color and combination bets. help racetrack Voisins, Tiers, Orphelins, Zero and Neighbors. help blackjack Beat the dealer without going over 21. help baccarat Bet which hand finishes closest to 9. + help craps Multiplayer dice, free odds, Fire and ATS bets. help slots Matching symbols, ways and free spins. Rules and limits vary by table. No bet guarantees a profit. diff --git a/data/help/craps.yaml b/data/help/craps.yaml new file mode 100644 index 0000000..75ca87d --- /dev/null +++ b/data/help/craps.yaml @@ -0,0 +1,68 @@ +name: craps +aliases: [roll, autoroll, unbet] +category: Interaction +related: [casino, roulette] +description: | + Bank Craps — multiplayer dice at the Domita Fortuna casino. + From single-zero roulette, west reaches the craps pit. + + play craps Join the tail of the shooting queue + watch craps Watch without betting + table / table rules Felt, dice, wagers / exact rules and payouts + bet 10 on pass Come-out 7/11 wins; 2/3/12 loses + bet 10 on dontpass Opposite side, with 12 a push + bet 10 on come A personal point (table point must be ON) + bet 10 on dontcome Don't come; 12 pushes + bet 30 on odds pass Add true odds after your point is established + bet 10 on odds come 6 Add odds behind your travelled come bet + bet 12 on place 6 Also 4, 5, 8, 9, 10 + bet 20 on buy 4 True odds, 5% stake commission on a win + bet 40 on lay 10 Against the number; 5% winnings commission + bet 10 on hard 8 Hard 4/6/8/10; loses on easy number or seven + bet 10 on field One-roll 2/3/4/9/10/11/12 + bet 4 on horn Split among 2/3/11/12 + bet 10 on hop 2 3 One-roll dice combination, either order + bet 1 on fire Four/five/six distinct made points: 24/249/999:1 + bet 1 on small All 2-6 before ANY seven: 34:1 + bet 1 on tall All 8-12 before ANY seven: 34:1 + bet 1 on all Both small and tall: 175:1 + + Other one-roll targets: seven, craps, eleven, ace-deuce, aces, boxcars. + Bonus bets must precede the shooter's first roll. Fire settles on + seven-out; ATS pays on completion and loses on any seven. + + roll Shoot now (solo) or after the 10-second call + autoroll Repeat on your turn until your bets finish + stop Cancel autoroll and resume manual play + unbet place 6 Return an optional wager before bets close + unbet odds come 6 Return free odds, retaining the contract + unbet all Take down every optional bet at once + stop / sitout In manual mode, leave / sit out once bets resolve + + New players join last in shooting order. Making a point keeps the + dice; seven-out passes them. Solo play has no forced timers. + Multiplayer gives everyone 10 seconds to bet, with a room-wide last + call 3 seconds before the shooter can roll. Bets remain open until + the actual roll. If the shooter waits another 60 seconds, the dealer + rolls for them (multiplayer only). + + Autoroll rolls every 3 seconds on your turn, but always respects the + 10-second multiplayer window. It stops when your last bet resolves. + Stop cancels autoroll immediately without removing bets or leaving. + A second stop in manual mode uses the normal leave behavior. + + Fire and All/Tall/Small trackers sit above the felt. Filled numbers + (marked * without color) show completed points / rolled totals. + The ON/OFF puck marks the table point. Dice follow card_style: + option card_style dark / option card_style light. + + Multiple simultaneous bets are supported. Winning bets return their + stakes and are removed, not automatically pressed. Place/buy/hardways + and come odds are OFF on come-out. Lay and don't odds always work. + Exact payout units are required. Commission rounds up to a credit. + Default odds: 3-4-5x, with 6x laid on don't bets. Field 12 pays 2:1 + or 3:1 according to table rules. Pass/come and bonus contracts cannot + be removed. Disconnecting forfeits live bets; shutdown refunds them. + + Simplified, High Point, Crapless, New York, Die Rich and Diceless + Craps are planned, not yet playable. This table uses bank-craps rules. diff --git a/data/rooms/casino/6182.yaml b/data/rooms/casino/6182.yaml index 398d8f2..c298be1 100644 --- a/data/rooms/casino/6182.yaml +++ b/data/rooms/casino/6182.yaml @@ -7,6 +7,8 @@ exits: room: 6183 east: room: 6184 + west: + room: 6185 casino_tables: - id: roulette_single game: roulette diff --git a/data/rooms/casino/6185.yaml b/data/rooms/casino/6185.yaml new file mode 100644 index 0000000..3dcfca5 --- /dev/null +++ b/data/rooms/casino/6185.yaml @@ -0,0 +1,15 @@ +name: Bank Craps Pit +description: A sweeping green felt table curves beneath warm brass lamps. Ivory dice rest beside a polished stick, while a bright puck marks the point above the numbered boxes. Inlaid plaques advertise Fire and All Tall Small bets. +exits: + east: + room: 6182 +casino_tables: + - id: bank_craps + game: craps + max_players: 12 + min_bet: 1 + max_bet: 10000 + craps: + variant: bank + odds: 0 + field_12: 3 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 <amount> on pass / dontpass / come / dontcome / field\n"+ + "bet <amount> on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ + "bet <amount> on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ + "bet <amount> on odds pass / odds come 6 (also dontpass/dontcome)\n"+ + "bet <amount> 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 <target>: 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 <amount> on <target>", 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 <target> | 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 { diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go index bab996c..afd8e4a 100644 --- a/internal/game/blackjack_rules.go +++ b/internal/game/blackjack_rules.go @@ -16,6 +16,10 @@ func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool { return g.lookCardTable(sess, target, casino.GameBaccarat) } +func (g *Game) lookCrapsTable(sess *net.Session, target string) bool { + return g.lookCardTable(sess, target, casino.GameCraps) +} + func (g *Game) lookRouletteTable(sess *net.Session, target string) bool { return g.lookCardTable(sess, target, casino.GameRoulette) } diff --git a/internal/game/casino.go b/internal/game/casino.go index 08695e7..e9334fc 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -6,6 +6,7 @@ import ( "os" "strconv" "strings" + "time" "unicode/utf8" "thehouseoficarus/internal/cardart" @@ -431,10 +432,11 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { // sets the wager and any other token is the bet target; AFTER "on", the // next token is always the target. That makes numeric targets reachable // ("bet 5 on 17"), which roulette, craps, and keno need. - // Roulette combination bets need a multi-token target ("split 1 2", - // "corner 1 2 4 5"), so a roulette table joins every extra target token. - // Every other game keeps the stricter one-token target and its usage error. - joinTarget := table.Config.Game == casino.GameRoulette + // Roulette combinations and craps bets need multi-token targets. Once + // an amount and target keyword exist, subsequent numbers belong to the + // target even without "on" (bet 10 split 1 2 / bet 10 buy 4). + // Other games keep the stricter one-token target. + joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps amount, spot, hasAmount, sawOn := 0, "", false, false for i, arg := range args { if strings.EqualFold(arg, "on") { @@ -445,7 +447,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { sawOn = true continue } - if !sawOn { + if !sawOn && !(joinTarget && hasAmount && spot != "") { if strings.EqualFold(arg, "min") { if hasAmount { sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") @@ -538,7 +540,11 @@ func (g *Game) executeTableAction(sess *net.Session, action string) { func (g *Game) CasinoTick() { if g.Casino != nil { - g.emitCasinoEvents(g.Casino.Tick()) + ms := 600 + if g.Ticks != nil { + ms = g.Ticks.TickLengthMilliseconds() + } + g.emitCasinoEvents(g.Casino.TickElapsed(time.Duration(ms) * time.Millisecond)) } } @@ -608,18 +614,19 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { } } -// casinoBoardReminder splits a roulette menu so its wager instructions can be +// casinoBoardReminder splits roulette and craps menus so instructions can be // printed under the betting board, which is the table the reminder refers to. // Every other game, and compact displays, keep their menu in one block. func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) { if category != "casino_menu" || menu == nil || sess.Player == nil { return "", "", false } + _, isCraps := snapshot.(*casino.CrapsSnapshot) s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isRoulette || s.Phase == "playing" || s.Phase == "result" { + if !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", "", false } - if sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", "", false } @@ -741,6 +748,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) // how verbose the command menus are. size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { + case *casino.CrapsSnapshot: + return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) case *casino.RouletteSnapshot: return renderRouletteSnapshot(s, mode, unicode, size) case *casino.SlotSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 6e146d0..450480f 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -172,7 +172,7 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) { } c, ok := resolveCasinoChoice(choices, input) if !ok { - sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, or watch roulette.") + sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, watch roulette, or watch craps.") return } t, ok := g.casinoTable(sess.Player.RoomID, c.address) @@ -199,6 +199,12 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool { // Settlement may have removed a queued departure before these events // are delivered. Their own final cards must still be visible. switch s := event.Snapshot.(type) { + case *casino.CrapsSnapshot: + for _, name := range s.Queue { + if name == sess.Player.Name { + return true + } + } case *casino.RouletteSnapshot: for _, name := range s.Players { if name == sess.Player.Name { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index d5676d5..853fb6a 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -16,16 +16,21 @@ type commandDef struct { } var commandRegistry = map[string]commandDef{ - "get": {(*Game).executeGet, ClassActive}, - "take": {(*Game).executeGet, ClassActive}, - "grab": {(*Game).executeGet, ClassActive}, - "pick": {(*Game).executeGet, ClassActive}, - "drop": {(*Game).executeDrop, ClassActive}, - "attack": {(*Game).executeAttack, ClassActive}, - "kill": {(*Game).executeAttack, ClassActive}, - "work": {(*Game).executeWork, ClassActive}, - "play": {(*Game).executePlay, ClassActive}, - "rebet": {(*Game).executeBet, ClassActive}, + "get": {(*Game).executeGet, ClassActive}, + "take": {(*Game).executeGet, ClassActive}, + "grab": {(*Game).executeGet, ClassActive}, + "pick": {(*Game).executeGet, ClassActive}, + "drop": {(*Game).executeDrop, ClassActive}, + "attack": {(*Game).executeAttack, ClassActive}, + "kill": {(*Game).executeAttack, ClassActive}, + "work": {(*Game).executeWork, ClassActive}, + "play": {(*Game).executePlay, ClassActive}, + "rebet": {(*Game).executeBet, ClassActive}, + "roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive}, + "autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant}, + "unbet": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "unbet "+strings.Join(args, " ")) + }, ClassActive}, "table": {(*Game).executeTableStatus, ClassInstant}, "status": {(*Game).executeTableStatus, ClassInstant}, "watch": {(*Game).executeWatch, ClassInstant}, diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go index 2f2a7ee..7075d01 100644 --- a/internal/game/cmd_stop.go +++ b/internal/game/cmd_stop.go @@ -11,6 +11,10 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) { g.cancelRest(p.Name) if _, playing := g.Casino.Participation(p.Name); playing { if table, _ := g.Casino.Participation(p.Name); table != nil { + if events, stopped := table.StopAutoRoll(p.Name); stopped { + g.emitCasinoEvents(events) + return + } // Normal leave refuses committed wagers. Only an explicit stop // queues a departure; movement/quit retain their existing policy. events := g.Casino.Leave(table.RoomID, table.Config.ID, p.Name) diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go new file mode 100644 index 0000000..569b4db --- /dev/null +++ b/internal/game/craps_test.go @@ -0,0 +1,79 @@ +package game + +import ( + "strings" + "testing" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func TestCrapsCommandsAndRendering(t *testing.T) { + g := &Game{Casino: casino.NewManager(1)} + cfg := casino.TableConfig{ID: "craps", Game: casino.GameCraps} + g.Casino.Join(1, cfg, "alice") + conn := &casinoTestConn{} + sess := &net.Session{Player: player.New("alice"), Conn: conn} + g.loggedInChars = map[string]*net.Session{"alice": sess} + for _, args := range [][]string{{"12", "on", "place", "6"}, {"10", "on", "hop", "2", "3"}, {"10", "buy", "4"}, {"10", "on", "fire"}} { + conn.data = nil + g.executeBet(sess, args, "bet "+strings.Join(args, " ")) + if !strings.Contains(string(conn.data), "You don't have enough chips and credits") { + t.Fatalf("%v: %s", args, conn.data) + } + } + conn.data = nil + g.executeTableStatus(sess, []string{"rules"}, "table rules") + if !strings.Contains(string(conn.data), "Bank Craps") || !strings.Contains(string(conn.data), "PASS LINE") { + t.Fatal(string(conn.data)) + } + for _, cmd := range []string{"roll", "autoroll", "unbet"} { + if _, ok := commandRegistry[cmd]; !ok { + t.Fatal("missing", cmd) + } + } + snap := &casino.CrapsSnapshot{Phase: "betting", Point: 6, Dice: [2]int{3, 4}, Queue: []string{"alice", "bob"}, Made: []int{4, 6}, Seen: []int{2, 4, 6}} + if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "craps", Snapshot: snap}) { + t.Fatal("invisible board") + } + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + art := renderCrapsSnapshot(snap, mode, unicode) + plain := plainRoulette(art) + for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "ALL SMALL", "Shooter alice", "Total: 7", "POINT 6"} { + if !strings.Contains(plain, word) { + t.Fatal("missing", word) + } + } + for _, r := range plain { + if !unicode && r > 127 { + t.Fatalf("nonASCII %q", r) + } + } + for _, line := range strings.Split(plain, "\n") { + if color.VisibleLen(line) > 78 { + t.Fatalf("too wide: %s", line) + } + } + if mode == "none" && art != plain { + t.Fatal("unexpected ANSI") + } + if mode != "none" && !strings.Contains(art, "\x1b[") { + t.Fatal("missing felt color") + } + } + } + // Snapshots and nil/empty states render safely before the first roll. + if renderCrapsSnapshot(nil, "none", false) != "" { + t.Fatal("nil") + } + for a := 1; a <= 6; a++ { + for b := 1; b <= 6; b++ { + snap.Dice = [2]int{a, b} + if !strings.Contains(renderCrapsSnapshot(snap, "none", false), "Total:") { + t.Fatal(a, b) + } + } + } +} diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 8d970e4..2311710 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) { + if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) { return } diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go new file mode 100644 index 0000000..19688ed --- /dev/null +++ b/internal/game/render_craps.go @@ -0,0 +1,328 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +// A half-table: numbered boxes and the come/field/line rails on the left; +// the shared centre-action bets on the right. Every region is a real box, +// and shared edges are joined rather than pasted together as broken borders. +const crapsFelt = 22 // #005f00: darker than ANSI green / the roulette felt. +const ( + crapsInk = iota + crapsGold + crapsRed + crapsMuted + crapsMarked + crapsPuckOn + crapsPuckOff +) + +var crapsPaint = []color.ColorSpec{ + {Fg: 15, Bg: crapsFelt}, + {Fg: 229, Bg: crapsFelt, Bold: true}, + {Fg: 217, Bg: crapsFelt, Bold: true}, + {Fg: 108, Bg: crapsFelt}, + {Fg: 0, Bg: 220, Bold: true}, + {Fg: 0, Bg: 15, Bold: true}, + {Fg: 15, Bg: 0, Bold: true}, +} + +type crapsCell struct { + glyph rune + edges uint8 + ink int +} +type crapsCanvas struct{ cells [][]crapsCell } + +func newCrapsCanvas(w, h int) *crapsCanvas { + c := &crapsCanvas{cells: make([][]crapsCell, h)} + for y := range c.cells { + c.cells[y] = make([]crapsCell, w) + } + return c +} +func (c *crapsCanvas) box(x1, y1, x2, y2 int) { + // N=1, E=2, S=4, W=8. Unioning edges makes all T/cross joints exact. + for x := x1; x < x2; x++ { + c.cells[y1][x].edges |= 2 + c.cells[y1][x+1].edges |= 8 + c.cells[y2][x].edges |= 2 + c.cells[y2][x+1].edges |= 8 + } + for y := y1; y < y2; y++ { + c.cells[y][x1].edges |= 4 + c.cells[y+1][x1].edges |= 1 + c.cells[y][x2].edges |= 4 + c.cells[y+1][x2].edges |= 1 + } +} +func (c *crapsCanvas) text(x, y int, s string, ink int) { + for _, r := range s { + if x >= 0 && x < len(c.cells[y]) { + c.cells[y][x] = crapsCell{glyph: r, ink: ink} + } + x++ + } +} +func (c *crapsCanvas) center(x1, x2, y int, s string, ink int) { + c.text(x1+(x2-x1+1-color.VisibleLen(s))/2, y, s, ink) +} +func (c *crapsCanvas) render(mode string, unicode bool) string { + joints := map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} + var out strings.Builder + for _, row := range c.cells { + var run strings.Builder + ink := row[0].ink + flush := func() { + if run.Len() > 0 { + out.WriteString(color.Render(mode, crapsPaint[ink], run.String())) + run.Reset() + } + } + for _, cell := range row { + if cell.ink != ink { + flush() + ink = cell.ink + } + r := cell.glyph + if r == 0 { + r = ' ' + if cell.edges != 0 { + switch { + case cell.edges&5 == 0: + r = '─' + case cell.edges&10 == 0: + r = '│' + default: + r = joints[cell.edges] + } + if !unicode { + switch r { + case '─': + r = '-' + case '│': + r = '|' + default: + r = '+' + } + } + } + } + run.WriteRune(r) + } + flush() + out.WriteByte('\n') + } + return out.String() +} +func crapsHas(ns []int, n int) bool { + for _, v := range ns { + if v == n { + return true + } + } + return false +} +func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string { + c := newCrapsCanvas(77, 7) + c.box(0, 0, 76, 3) + c.box(0, 3, 38, 6) + c.box(38, 3, 76, 6) + c.text(3, 1, "FIRE", crapsGold) + for i, n := range []int{4, 5, 6, 8, 9, 10} { + ink, mark := crapsMuted, " " + if crapsHas(s.Made, n) { + ink, mark = crapsMarked, "*" + } + c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink) + } + c.center(1, 75, 2, "MADE POINTS 4: 24 to 1 5: 249 to 1 6: 999 to 1", crapsInk) + c.text(2, 4, "ALL SMALL", crapsGold) + c.text(40, 4, "TALL", crapsGold) + for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} { + x := 12 + i*5 + if i >= 5 { + x = 48 + (i-5)*5 + } + ink, mark := crapsMuted, " " + if crapsHas(s.Seen, n) { + ink, mark = crapsMarked, "*" + } + c.text(x, 4, fmt.Sprintf("%2d%s ", n, mark), ink) + } + c.center(1, 37, 5, "SMALL 34:1 | ALL 175:1", crapsInk) + c.center(39, 75, 5, "TALL 34:1 | * = rolled", crapsInk) + return c.render(mode, unicode) +} +func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { + c := newCrapsCanvas(77, 26) + c.box(0, 0, 14, 6) + c.center(1, 13, 4, "DON'T COME", crapsInk) + c.center(1, 13, 5, "BAR 12", crapsMuted) + nums := []int{4, 5, 6, 8, 9, 10} + labels := []string{"4", "5", "SIX", "8", "NINE", "10"} + pays := []string{"9:5", "7:5", "7:6", "7:6", "7:5", "9:5"} + puck := func(x int, on bool) { + ink, word := crapsPuckOff, "OFF" + if on { + ink, word = crapsPuckOn, "ON " + } + a, b, d := "+---+", "|"+word+"|", "+---+" + if unicode { + a, b, d = "╭───╮", "│"+word+"│", "╰───╯" + } + c.text(x, 1, a, ink) + c.text(x, 2, b, ink) + c.text(x, 3, d, ink) + } + if s.Point == 0 { + puck(5, false) + } + for i, n := range nums { + x := 14 + i*6 + c.box(x, 0, x+6, 6) + c.center(x+1, x+5, 4, labels[i], crapsGold) + c.center(x+1, x+5, 5, pays[i], crapsMuted) + if s.Point == n { + puck(x+1, true) + } + } + c.box(0, 6, 50, 10) + c.center(1, 49, 7, "PLACE / BUY / LAY ON THE NUMBERS ABOVE", crapsMuted) + c.center(1, 49, 9, "C O M E", crapsRed) + c.box(0, 10, 50, 16) + c.center(1, 49, 11, "FIELD", crapsGold) + c.center(1, 49, 13, "( 2 ) 3 4 9 10 11 ( 12 )", crapsInk) + twelve := s.Field12 + if twelve == 0 { + twelve = 2 + } + c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %d:1", twelve), crapsGold) + c.box(0, 16, 50, 19) + c.center(1, 49, 18, "DON'T PASS / BAR 12", crapsInk) + c.box(0, 19, 50, 22) + c.center(1, 49, 21, "PASS LINE", crapsGold) + c.box(0, 22, 50, 25) + c.center(1, 49, 24, "FREE ODDS / BEHIND THE LINE", crapsInk) + // Centre action: four bonus spots, four hardways, six propositions, horn/hop. + bonus := []struct { + label, pays string + }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} + xs := []int{50, 56, 63, 69, 76} + for i, spot := range bonus { + c.box(xs[i], 0, xs[i+1], 5) + c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsGold) + c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsMuted) + } + for i, n := range []int{4, 6, 8, 10} { + x, y := 50+(i%2)*13, 5+(i/2)*4 + c.box(x, y, x+13, y+4) + c.center(x+1, x+12, y+1, fmt.Sprintf("HARD %d", n), crapsInk) + pay := 7 + if n == 6 || n == 8 { + pay = 9 + } + c.center(x+1, x+12, y+3, fmt.Sprintf("%d+%d %d:1", n/2, n/2, pay), crapsGold) + } + props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"} + odds := []string{"4:1", "7:1", "15:1", "30:1", "15:1", "30:1"} + px := []int{50, 59, 67, 76} + for i, label := range props { + col, y := i%3, 13+(i/3)*4 + x, x2 := px[col], px[col+1] + c.box(x, y, x2, y+4) + c.center(x+1, x2-1, y+1, label, crapsRed) + c.center(x+1, x2-1, y+3, odds[i], crapsInk) + } + c.box(50, 21, 63, 25) + c.center(51, 62, 22, "HORN", crapsGold) + c.center(51, 62, 24, "2 3 11 12", crapsMuted) + c.box(63, 21, 76, 25) + c.center(64, 75, 22, "HOP", crapsGold) + c.center(64, 75, 24, "15:1 / 30:1", crapsMuted) + return c.render(mode, unicode) +} +func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string { + if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 { + return "" + } + patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}} + top, bottom, v, pip := "+-------+", "+-------+", "|", "o" + if unicode { + top, bottom, v, pip = "╭───────╮", "╰───────╯", "│", "●" + } + spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true} + if style == "light" { + spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true} + } + var out strings.Builder + for row := 0; row < 5; row++ { + out.WriteString(" ") + for i, n := range dice { + line := top + if row == 4 { + line = bottom + } else if row > 0 { + line = v + " " + strings.ReplaceAll(patterns[n][row-1], "o", pip) + " " + v + } + out.WriteString(color.Render(mode, spec, line)) + if i == 0 { + out.WriteString(" ") + } + } + if row == 2 { + out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1]))) + } + out.WriteByte('\n') + } + return out.String() +} +func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, styles ...string) string { + if s == nil { + return "" + } + style := "dark" + if len(styles) > 0 { + style = styles[0] + } + var out strings.Builder + title := func(text string) string { + return color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, text) + } + subtle := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: 250, Bg: -1}, text) } + point := "COME-OUT" + if s.Point != 0 { + point = fmt.Sprintf("POINT %d", s.Point) + } + out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n") + out.WriteString(renderCrapsTrackers(s, mode, unicode)) + out.WriteByte('\n') + out.WriteString(renderCrapsFelt(s, mode, unicode)) + out.WriteByte('\n') + out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style)) + if len(s.Queue) > 0 { + out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n") + if len(s.Queue) > 1 { + out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n") + } + } + hint := "Place a bet, then roll when ready." + if s.Phase != "waiting" && s.Phase != "" { + hint = "Your pace. Roll when ready." + if s.ShootIn > 0 { + hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) + } else if s.DealerIn > 0 { + hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn) + } + } + if s.AutoRoll { + hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn) + } + out.WriteString(subtle(" "+hint) + "\n") + return out.String() +} diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go new file mode 100644 index 0000000..9d75630 --- /dev/null +++ b/internal/game/render_craps_test.go @@ -0,0 +1,130 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { + for _, unicode := range []bool{false, true} { + for _, point := range []int{0, 4, 5, 6, 8, 9, 10} { + s := &casino.CrapsSnapshot{Point: point, Field12: 3} + art := renderCrapsFelt(s, "none", unicode) + rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n") + if len(rows) != 26 { + t.Fatal(len(rows)) + } + for _, row := range rows { + if color.VisibleLen(row) != 77 { + t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row) + } + } + // Check connected box edges at the main rail and centre-action partition. + if unicode { + for _, r := range []rune{'┌', '┬', '┼', '├', '┤', '┴', '┘'} { + if !strings.ContainsRune(art, r) { + t.Fatalf("missing joint %c", r) + } + } + } + v := "|" + if unicode { + v = "│" + } + want := v + "OFF" + v + if point != 0 { + want = v + "ON " + v + } + if strings.Count(art, want) != 1 { + t.Fatalf("missing/duplicate puck %d: %s", point, art) + } + if !strings.Contains(art, "12 PAYS 3:1") { + t.Fatal("field variant invisible") + } + for _, bet := range []string{"DON'T COME", "C O M E", "FIELD", "DON'T PASS", "PASS LINE", "HARD 4", "HARD 6", "HARD 8", "HARD 10", "SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS", "HORN", "HOP"} { + if strings.Count(art, bet) != 1 { + t.Fatalf("missing or duplicate %q", bet) + } + } + } + } + t.Log("\n" + renderCrapsSnapshot(&casino.CrapsSnapshot{Point: 6, Field12: 3, Dice: [2]int{3, 3}, Queue: []string{"Alice", "Bob"}, Made: []int{4, 5}, Seen: []int{2, 4, 5, 6, 9}, Phase: "betting", ShootIn: 10, DealerIn: 70}, "none", true)) +} +func TestCrapsTrackersAndDiceStyles(t *testing.T) { + s := &casino.CrapsSnapshot{Made: []int{4, 6}, Seen: []int{2, 12}} + art := renderCrapsTrackers(s, "xterm256", true) + plain := plainRoulette(art) + for _, token := range []string{"[ 4*]", "[ 5 ]", "[ 6*]", " 2*", "12*"} { + if !strings.Contains(plain, token) { + t.Fatal("tracker missing", token) + } + } + if strings.Count(art, color.BgCode("xterm256", 220)) != 4 { + t.Fatal("wrong number of filled tracker tokens") + } + if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) { + t.Fatal("felt not dark green") + } + g := &Game{} + p := player.New("Alice") + p.Options = map[string]any{"card_style": "light", "unicode": true} + sess := &net.Session{Player: p} + s.Dice = [2]int{2, 5} + light := g.renderCasinoSnapshot(sess, s) + p.Options["card_style"] = "dark" + dark := g.renderCasinoSnapshot(sess, s) + if light == dark { + t.Fatal("card_style not wired to craps") + } + for a := 1; a <= 6; a++ { + for b := 1; b <= 6; b++ { + for _, unicode := range []bool{false, true} { + lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "light") + dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "dark") + if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 0)) { + t.Fatal("incorrect dice background") + } + pip := "o" + if unicode { + pip = "●" + } + if strings.Count(strings.ReplaceAll(plainRoulette(lit), "Total:", ""), pip) != a+b { + t.Fatal("wrong pip count", a, b) + } + if plainRoulette(lit) != plainRoulette(dim) { + t.Fatal("style changed dice outcome") + } + } + } + } +} +func TestCrapsRemindersFollowArt(t *testing.T) { + g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} + g.Casino.Join(1, casino.TableConfig{ID: "c", Game: casino.GameCraps}, "alice") + conn := &casinoTestConn{} + p := player.New("alice") + sess := &net.Session{Player: p, Conn: conn} + g.loggedInChars = map[string]*net.Session{"alice": sess} + for _, display := range []string{"full", "compact"} { + p.Options = map[string]any{"casino_display": display} + conn.data = nil + g.emitCasinoEvents(g.Casino.Table(1, "c").Status("alice")) + text := plainRoulette(string(conn.data)) + felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet <amount> on <target>") + if felt < 0 || commands < felt { + t.Fatal("reminder above graphic", text) + } + for _, old := range []string{"Next in order:", "Fire points made:", "ATS numbers:", "Phase:", "Timer: 0"} { + if strings.Contains(text, old) { + t.Fatal("raw status leaked", old) + } + } + } +} diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index d69b5bf..1e7f971 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -598,6 +598,20 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { rules := g.colorize(sess, "casino_action", cfg.RulesText()) + if cfg.Game == casino.GameCraps { + snap := &casino.CrapsSnapshot{Phase: "waiting"} + if g.Casino != nil && sess.Player != nil { + if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil { + for _, event := range table.Status(sess.Player.Name) { + if view, ok := event.Snapshot.(*casino.CrapsSnapshot); ok { + snap = view + break + } + } + } + } + return rules + "\n" + g.renderCasinoSnapshot(sess, snap) + } if cfg.Game == casino.GameRoulette { snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} // Rules views draw a betting board too; use the live table's history diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 7b83807..0c5b62e 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -56,6 +56,7 @@ func TestRouletteCombinationTargetParsing(t *testing.T) { {"10", "on", "split", "1", "2"}, {"10", "on", "split", "1-2"}, {"10", "split", "1-2"}, + {"10", "split", "1", "2"}, {"10", "on", "street", "1", "2", "3"}, {"10", "on", "street", "3", "1", "2"}, {"10", "on", "corner", "1", "2", "4", "5"}, |
