From 82e571d227feeafd0af4011e701ce26b99c5e3d9 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 00:51:41 -0400 Subject: fix: craps felt and wager description cleanup --- internal/casino/baccarat.go | 6 +- internal/casino/craps.go | 53 ++++++++--- internal/casino/craps_clock.go | 11 +++ internal/casino/craps_projection.go | 4 +- internal/casino/craps_review_test.go | 119 ++++++++++++++++++++++++- internal/casino/craps_rules.go | 53 +++++++++-- internal/casino/craps_test.go | 6 +- internal/casino/craps_ux.go | 15 +++- internal/casino/craps_variants.go | 6 +- internal/casino/craps_wagers.go | 64 ++++++------- internal/casino/house_edge_craps.go | 18 ++-- internal/casino/house_edge_test.go | 35 ++++++++ internal/casino/manager.go | 13 +++ internal/casino/roulette.go | 2 +- internal/casino/table.go | 10 +++ internal/casino/table_hints.go | 4 +- internal/casino/table_ux.go | 2 +- internal/game/casino.go | 2 +- internal/game/casino_edge_list_test.go | 20 +++++ internal/game/casino_house_edge.go | 6 +- internal/game/casino_ux.go | 2 +- internal/game/craps_test.go | 4 +- internal/game/game.go | 2 +- internal/game/render_casino_settlement.go | 6 ++ internal/game/render_casino_settlement_test.go | 11 ++- internal/game/render_craps.go | 113 +++++++++++++++-------- internal/game/render_craps_test.go | 45 +++++++++- internal/game/render_roulette_test.go | 2 +- 28 files changed, 510 insertions(+), 124 deletions(-) (limited to 'internal') diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index c37870d..3140fa8 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -120,7 +120,7 @@ func (t *baccaratTable) menuEvent(name string) Event { } commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, - MenuCommand{Command: "bet on "}, + MenuCommand{Command: "bet on "}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table", Desc: "Check the table"}) commands = withTableHints(commands, t.wagerRows()) @@ -182,7 +182,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even spot = p.mainSpot } if spot == "" { - return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} + return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} } if !t.Rules.allowsSpot(spot) { return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie, or an enabled optional bet. Available: "+t.Rules.betChoices()+".")} @@ -262,7 +262,7 @@ func (t *baccaratTable) rebetAll(name string, wallet Wallet) []Event { return []Event{t.private(name, "You have already bet a main wager this round.")} } if !p.hasLastBet() { - return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} + return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} } var events []Event for _, spot := range t.Rules.spots() { diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 286fe0a..90c95c9 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -57,6 +57,7 @@ type CrapsSnapshot struct { AutoRoll bool AutoIn int Field12 int + Odds string // free-odds cap label, e.g. "3x-4x-5x" or "100x" } func (*CrapsSnapshot) snapshot() {} @@ -73,7 +74,7 @@ func crapsTarget(b *crapsBet) string { return fmt.Sprintf("%s %d", b.kind, b.number) } func (t *crapsTable) view() *CrapsSnapshot { - s := &CrapsSnapshot{Variant: t.Config.Craps.variant(), Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12} + s := &CrapsSnapshot{Variant: t.Config.Craps.variant(), Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12, Odds: t.Config.Craps.oddsDisplay()} if t.hasLiveBets() { if t.Config.Craps.variant() == "simplified" { // One automatic roll: the snapshot counts down the shared window. @@ -246,9 +247,11 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if b.odds.Total > 0 { continue } + requested := amount + amount = roundUpTo(amount, payoutDen) // Compare without multiplying a potentially very large stake by Nx. - if (amount-1)/limit >= b.stake.Total || amount%payoutDen != 0 { - return reject("Odds already placed, above the free-odds limit, or not in exact payout units.") + if (amount-1)/limit >= b.stake.Total { + return reject("Odds already placed or above the free-odds limit.") } stake, err := w.Wager(amount) if err != nil { @@ -257,7 +260,11 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { 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), t.reminder(name)} + msg := fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target) + if amount != requested { + msg += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact odds units.{/}", requested) + } + return []Event{t.private(name, msg), t.betSummary(p), t.reminder(name)} } return reject("Place an established line/come bet before taking odds.") } @@ -283,15 +290,19 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { switch kind { case "place": _, unit = crapsPlaceOdds(n) - case "buy": - _, unit = crapsTrueOdds(n) - case "lay": - unit, _ = crapsTrueOdds(n) + case "buy", "lay": + // Weird odds must land on whole-credit payouts and a whole-credit vig. + unit = crapsVigUnit(kind, n) case "horn": unit = 4 } - if amount%unit != 0 { - return reject(fmt.Sprintf("Use multiples of %d for exact payouts.", unit)) + requested := amount + amount = roundUpTo(amount, unit) + if amount > t.Config.MaxBet { + return reject(fmt.Sprintf("That wager rounds up to %d, above the table maximum.", amount)) + } + if _, err := MulPayout(amount, 1000); err != nil { + return reject("That potential payout is too large.") } stake, err := w.Wager(amount) if err != nil { @@ -302,7 +313,11 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { 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))} + private := fmt.Sprintf("You bet {casino_chips}%d{/} on %s.", amount, spot) + if amount != requested { + private += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact payout units.{/}", requested) + } + events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), private)} events = append(events, t.syncRoundClock()...) return append(events, t.betSummary(p), t.reminder(name)) } @@ -348,7 +363,9 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { 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)} + // The dice tile already shows the total, so the roll leads with the felt + // itself. Diceless has no dice tile to carry the cards, so it keeps its line. + events := []Event{t.event("", true)} if r.variant() == "diceless" { events[0].Message = fmt.Sprintf("{casino_emphasis}%s draws cards{/} [%d] [%d] = %d", shooter, a, b, s) } @@ -393,7 +410,17 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { } if oldPoint == 0 && r.pointNumber(s) { t.point = s - events = append(events, t.event(fmt.Sprintf("{casino_emphasis}POINT %d{/} / Puck ON. Make your point!", s), true)) + events = append(events, t.event(fmt.Sprintf("{casino_emphasis}POINT %d{/} / Puck ON.", s), true)) + // "Make your point!" only speaks to the shooter and players with a + // pass-line contract; everyone else just sees the puck move. + for _, p := range t.players.ordered() { + if !p.Connected || p.SittingOut { + continue + } + if p.Name == shooter || crapsHasPassLine(p) { + events = append(events, t.private(p.Name, "{casino_emphasis}Make your point!{/}")) + } + } } else if pointMade || sevenOut { if pointMade { events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", oldPoint), true)) diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go index 5357c93..30f56e5 100644 --- a/internal/casino/craps_clock.go +++ b/internal/casino/craps_clock.go @@ -27,6 +27,17 @@ func (t *crapsTable) shooter() *crapsPlayer { } return t.players.get(t.queue[0]) } + +// busy reports whether the named player's dice are still in the air. The +// command loop uses it to hold the prompt until the roll resolves, so the +// shake message is not split from its result by a stray prompt. Other players +// may still bet (and be prompted) during the same window. +func (t *crapsTable) busy(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + s := t.shooter() + return t.pendingRollTicks > 0 && s != nil && s.Name == name +} func (t *crapsTable) hasLiveBets() bool { for _, p := range t.players.ordered() { if p.HasBet { diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go index 65f09ce..7e7c305 100644 --- a/internal/casino/craps_projection.go +++ b/internal/casino/craps_projection.go @@ -16,7 +16,9 @@ func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int if b.kind == "buy" { base = b.stake.Total } - payout -= (base + 19) / 20 + // The house rounds its 5% vig down to whole credits; the bet units + // above keep the common stakes exact so this rarely bites. + payout -= base / 20 } } if b.odds.Total > 0 { diff --git a/internal/casino/craps_review_test.go b/internal/casino/craps_review_test.go index 04f72b2..3a518fc 100644 --- a/internal/casino/craps_review_test.go +++ b/internal/casino/craps_review_test.go @@ -1,6 +1,123 @@ package casino -import "testing" +import ( + "strings" + "testing" +) + +func TestCrapsPointPromptOnlyForPassBettors(t *testing.T) { + tb := crapsFixture() + tb.join("alice") // shooter + tb.join("bob") + w := &testWallet{chips: 1000} + tb.bet("alice", "pass", 10, w) + tb.bet("bob", "field", 10, w) + events := tb.resolveRoll(2, 2) // establishes point 4 + publicPoint := false + var privateTo []string + for _, e := range events { + if e.Public && strings.Contains(e.Message, "POINT 4") { + publicPoint = true + if strings.Contains(e.Message, "Make your point") { + t.Fatalf("public point event still urges the whole room: %q", e.Message) + } + } + if e.PrivateTo != "" && strings.Contains(e.Message, "Make your point") { + privateTo = append(privateTo, e.PrivateTo) + } + } + if !publicPoint { + t.Fatal("no public point event") + } + if len(privateTo) != 1 || privateTo[0] != "alice" { + t.Fatalf("Make your point targeted %v, want [alice]", privateTo) + } +} + +func TestCrapsVigOnlyOnWinsRoundedDown(t *testing.T) { + buy := &crapsBet{kind: "buy", number: 4, stake: Wager{Total: 20}} + if got := crapsReturn(buy, 2, 1, 0, false); got != 59 { + t.Fatalf("winning buy = %d, want 59", got) + } + if got := crapsReturn(buy, -1, 1, 0, false); got != 0 { + t.Fatalf("losing buy returned %d, want 0", got) + } + // A directly-constructed odd stake proves the vig rounds down, not up: + // 5% of 21 is 1.05, so the house keeps 1 credit, not 2. + odd := &crapsBet{kind: "buy", number: 4, stake: Wager{Total: 21}} + if got := crapsReturn(odd, 2, 1, 0, false); got != 62 { // 21 + 42 - 1 + t.Fatalf("buy vig rounded up: %d, want 62", got) + } +} + +func TestCrapsVigUnitsRoundUp(t *testing.T) { + for _, tc := range []struct { + kind string + n int + want int + }{ + {"buy", 4, 20}, {"buy", 6, 20}, + {"lay", 4, 40}, {"lay", 5, 30}, {"lay", 6, 24}, + {"lay", 2, 120}, {"lay", 3, 60}, {"lay", 12, 120}, + } { + if got := crapsVigUnit(tc.kind, tc.n); got != tc.want { + t.Fatalf("%s %d unit = %d, want %d", tc.kind, tc.n, got, tc.want) + } + } + for _, tc := range []struct { + spot string + amount int + want int + }{ + {"buy 6", 3, 20}, {"buy 6", 5, 20}, {"buy 6", 20, 20}, + {"buy 4", 3, 20}, {"buy 4", 21, 40}, + {"lay 6", 30, 48}, {"lay 6", 24, 24}, + } { + tb := crapsFixture() + tb.point = 6 + tb.join("a") + w := &testWallet{chips: 1000} + events := tb.bet("a", tc.spot, tc.amount, w) + bets := tb.players.get("a").bets + if len(bets) != 1 { + t.Fatalf("%s %d was rejected", tc.spot, tc.amount) + } + if got := bets[0].stake.Total; got != tc.want { + t.Fatalf("%s %d rounded to %d, want %d", tc.spot, tc.amount, got, tc.want) + } + if tc.amount != tc.want { + notice := false + for _, e := range events { + if e.PrivateTo == "a" && strings.Contains(e.PrivateMessage, "Rounded up") { + notice = true + } + } + if !notice { + t.Fatalf("%s %d rounded without a notice", tc.spot, tc.amount) + } + } + } +} + +func TestCrapsBusyHoldsAcrossPendingRoll(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + tb.bet("alice", "pass", 10, &testWallet{chips: 100}) + if tb.busy("alice") || tb.busy("bob") { + t.Fatal("table busy before a roll") + } + tb.action("alice", "roll") + if !tb.busy("alice") { + t.Fatal("shooter not busy while the dice are in the air") + } + if tb.busy("bob") { + t.Fatal("non-shooter marked busy") + } + tb.advance(crapsManualRollTicks) + if tb.busy("alice") { + t.Fatal("still busy after the roll resolved") + } +} func TestCrapsTakenDownRollDoesNotCarryIntoNewBet(t *testing.T) { tb := crapsFixture() diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go index a9e52af..60a7c0f 100644 --- a/internal/casino/craps_rules.go +++ b/internal/casino/craps_rules.go @@ -47,6 +47,36 @@ func crapsTrueOdds(n int) (int, int) { return 6, 5 } } + +// crapsVigUnit is the smallest stake whose true-odds profit AND 5% commission +// are both whole credits. Buy bets pay 5% of the stake, so 20 is the base; +// lay bets pay 5% of the profit, so the unit scales with the lay odds. +func crapsVigUnit(kind string, n int) int { + if kind == "buy" { + return 20 + } + num, den := crapsTrueOdds(n) + // Lay profit is stake*den/num; commission is stake*den/(20*num). + d := 20 * num + return d / gcd(den, d) +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + return a +} + +// roundUpTo returns the smallest multiple of unit that is at least n. A stake +// that misses the exact payout/commission unit is nudged up rather than +// rejected, since single-credit differences are noise at table stakes. +func roundUpTo(n, unit int) int { + if unit <= 1 { + return n + } + return ((n + unit - 1) / unit) * unit +} func (c CrapsConfig) oddsLimit(n int, against bool) int { if c.Odds > 0 { return c.Odds @@ -66,6 +96,15 @@ func (c CrapsConfig) oddsLimit(n int, against bool) int { return 5 } } + +// oddsDisplay is the felt label for the free-odds cap: a uniform Nx cap, or +// the classic 3-4-5x schedule when the config leaves Odds unset. +func (c CrapsConfig) oddsDisplay() string { + if c.Odds > 0 { + return fmt.Sprintf("%dx", c.Odds) + } + return "3x-4x-5x" +} func (c TableConfig) CrapsRulesText() string { r := c.Craps.Normalize() if r.variant() != "bank" && r.variant() != "diceless" { @@ -80,19 +119,19 @@ func (c TableConfig) CrapsRulesText() string { odds = fmt.Sprintf("%dx", r.Odds) } return prefix + fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+ - "bet on pass / dontpass / come / dontcome / field\n"+ - "bet on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ - "bet on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ - "bet on odds pass / odds come 6 (also dontpass/dontcome)\n"+ - "bet on fire / small / tall / all (before shooter's first roll only)\n"+ + "bet on pass / dontpass / come / dontcome / field\n"+ + "bet on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ + "bet on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ + "bet on odds pass / odds come 6 (also dontpass/dontcome)\n"+ + "bet 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"+ + "Buy/lay true odds; 5%% commission on buy STAKE / lay WIN, rounded down, 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"+ + "Stakes round up to the next exact payout/vig unit; commission never rounds up.\n"+ "Solo: roll anytime. Multiplayer: 10 ticks to bet, then 60 ticks for the shooter.\n"+ "Bets remain open until the roll. Dice rotate on seven-out.\n"+ "autoroll: repeat every 3 ticks on your turn (multiplayer waits 10 ticks).\n"+ diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 7f3bcfa..8e6e669 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -57,7 +57,7 @@ func TestCrapsPayouts(t *testing.T) { {"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}, + {"lay 4", 40, 3, 4, 6, 59}, {"lay 5", 30, 3, 4, 6, 49}, {"lay 6", 24, 3, 4, 6, 43}, {"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}, @@ -152,7 +152,7 @@ func TestCrapsWorkingRules(t *testing.T) { tb := crapsFixture() tb.join("a") w := &testWallet{chips: 100} - tb.bet("a", spot, 30, w) + tb.bet("a", spot, 60, w) tb.resolveRoll(3, 4) if w.paid != 0 || len(tb.players.get("a").bets) != 1 { t.Fatal(spot) @@ -337,7 +337,7 @@ func TestCrapsValidation(t *testing.T) { 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"} { + for _, spot := range []string{"place 7", "hard 5", "hop 0 6", "come", "odds pass", "unknown"} { tb.bet("a", spot, 1, w) } if len(w.open) != 0 { diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index 7e44e39..0a7932d 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -22,11 +22,11 @@ func (t *crapsTable) commands(name string) []MenuCommand { commands = append(commands, MenuCommand{Command: "bet ", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) } } else { - target := "" + target := "" if !t.Config.Craps.allows("buy") { target = "pass" } - commands = append(commands, MenuCommand{Command: "bet on " + target, Desc: "Place a wager; targets: list"}) + commands = append(commands, MenuCommand{Command: "bet on " + target, Desc: "Place a wager"}) } } if p.HasBet { @@ -69,6 +69,17 @@ func crapsContract(kind string) bool { return false } } + +// crapsHasPassLine reports whether the player holds a pass-line contract, so +// the point-established prompt only nudges the people it can pay. +func crapsHasPassLine(p *crapsPlayer) bool { + for _, b := range p.bets { + if b.kind == "pass" { + return true + } + } + return false +} 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] diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go index 8331d70..bcb06d0 100644 --- a/internal/casino/craps_variants.go +++ b/internal/casino/craps_variants.go @@ -205,12 +205,12 @@ func (c CrapsConfig) variantRulesText() string { case "die_rich": rules = "Pass only, ONE six-sided die, 1:1. Come-out 6 wins, 1 loses; 2-5 establish a point. Repeat the point before 1 to win; other faces roll again. Any 1 passes the die. No free odds." case "new_york": - rules = "House New York: bank pass/dontpass (bar 12), free odds, buy and lay only. No come, place, field, propositions, hardways or bonuses. Buy/lay use true odds with 5% commission on buy stake / lay profit, rounded up, wins only. Buy is off on come-out; lay works." + rules = "House New York: bank pass/dontpass (bar 12), free odds, buy and lay only. No come, place, field, propositions, hardways or bonuses. Buy/lay use true odds with 5% commission on buy stake / lay profit, rounded down, wins only. Buy is off on come-out; lay works." case "crapless": rules = "Come-out 7 wins; ALL other totals become points, including 2/3/11/12. Pass/come only, no don't bets or bonuses. All bank side bets supported. Extra-number place: 2/12 pay 11:2; 3/11 pay 11:4. Buy and free odds: 2/12 pay 6:1; 3/11 pay 3:1. Lay pays inverse true odds. Default extra-number odds limit: 1x stake." } if c.variant() == "new_york" || c.variant() == "crapless" { - rules += "\nTrue odds on 4/10: 2:1; 5/9: 3:2; 6/8: 6:5. Lay and don't odds invert these ratios. Buy/lay commission: 5% buy STAKE / lay PROFIT, rounded up, on wins only; removals have no commission." + rules += "\nTrue odds on 4/10: 2:1; 5/9: 3:2; 6/8: 6:5. Lay and don't odds invert these ratios. Buy/lay commission: 5% buy STAKE / lay PROFIT, rounded down, on wins only; removals have no commission." } if c.variant() == "crapless" { rules += fmt.Sprintf("\nPass/come pay 1:1 on their point before 7; 7 loses. Line bets only on come-out; come requires a table point.\nTargets: pass / come / odds pass / odds come / place / buy / lay .\nPlace 4/10 pays 9:5; 5/9 7:5; 6/8 7:6.\nfield: 2 pays 2:1, 12 pays %d:1, 3/4/9/10/11 pay 1:1; others lose.\nhard 4/10 pays 7:1; hard 6/8 pays 9:1; easy number or 7 loses.\nseven 4:1; craps 7:1; eleven / ace-deuce 15:1; aces / boxcars 30:1.\nhorn splits equally among 2/3/11/12; hop pays 15:1 easy / 30:1 double, unordered.\nPlace/buy/hardways and come odds are off on come-out; established come contracts and lay always work.\nNo Fire or ATS. Making a point retains the shooter; seven-out rotates.", c.Field12) @@ -220,7 +220,7 @@ func (c CrapsConfig) variantRulesText() string { } cadence := "Solo roll anytime; multiplayer waits 10 ticks, then dealer rolls after 60 ticks." autoroll := "Bets stay open until the roll. autoroll repeats every 3 ticks, respecting the betting window." - betLine := "Bet: bet on pass; the felt is authoritative for this edition." + betLine := "Bet: bet on pass; the felt is authoritative for this edition." stopLine := "stop cancels autoroll; manual stop waits for live bets." if c.variant() == "simplified" { betLine = "Bet: bet — always on pass; bare bet rebets your last wager." diff --git a/internal/casino/craps_wagers.go b/internal/casino/craps_wagers.go index 1c6da2f..a36f43d 100644 --- a/internal/casino/craps_wagers.go +++ b/internal/casino/craps_wagers.go @@ -12,6 +12,7 @@ import ( func (t *crapsTable) wagerRows(name string) []WagerRow { c := t.Config.Craps rows := []WagerRow{} + passWin := "Come out: 7/11 wins, 2/3/12 loses / Point on: Roll the point to win." switch c.variant() { case "simplified": // One automatic roll of two dice; pass is the only target. @@ -28,14 +29,12 @@ func (t *crapsTable) wagerRows(name string) []WagerRow { ) case "new_york": rows = append(rows, - WagerRow{"pass", "1:1", "7/11 win, 2/3/12 lose; opposite bars 12"}, - WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"}, + WagerRow{"pass", "1:1", passWin}, + WagerRow{"dontpass", "1:1", "Opposite of Pass Line"}, ) if t.point != 0 { - rows = append(rows, crapsOddsRows(c)...) - rows = append(rows, - WagerRow{"buy / lay ", "true", "4/5/6/8/9/10; 5% win commission"}, - ) + rows = append(rows, crapsOddsRows()...) + rows = append(rows, crapsBuyLayRows()...) } case "crapless": rows = append(rows, @@ -45,31 +44,31 @@ func (t *crapsTable) wagerRows(name string) []WagerRow { rows = append(rows, WagerRow{"come", "1:1", "A personal point with pass rules"}, ) - rows = append(rows, crapsOddsRows(c)...) + rows = append(rows, crapsOddsRows()...) rows = append(rows, crapsPlaceRows(c)...) + rows = append(rows, crapsBuyLayRows()...) rows = append(rows, - WagerRow{"buy / lay ", "true", "Place at true odds; 5% win commission"}, - WagerRow{"hard 4/10", "7:1", "Point arrives as an identical pair"}, - WagerRow{"hard 6/8", "9:1", "Point arrives as an identical pair"}, + WagerRow{"hard 4/10", "7:1", "Roll this pair before next 7"}, + WagerRow{"hard 6/8", "9:1", "Roll this pair before next 7"}, ) } rows = append(rows, crapsPropRows(c, false)...) default: // bank and diceless rows = append(rows, - WagerRow{"pass", "1:1", "7/11 win, 2/3/12 lose; opposite bars 12"}, - WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"}, + WagerRow{"pass", "1:1", passWin}, + WagerRow{"dontpass", "1:1", "Opposite of Pass Line"}, ) if t.point != 0 { rows = append(rows, - WagerRow{"come", "1:1", "A personal point with pass rules"}, - WagerRow{"dontcome", "1:1", "Don't come; a 12 pushes"}, + WagerRow{"come", "1:1", passWin}, + WagerRow{"dontcome", "1:1", "Opposite of Come"}, ) - rows = append(rows, crapsOddsRows(c)...) + rows = append(rows, crapsOddsRows()...) rows = append(rows, crapsPlaceRows(c)...) + rows = append(rows, crapsBuyLayRows()...) rows = append(rows, - WagerRow{"buy / lay ", "true", "Place at true odds; 5% win commission"}, - WagerRow{"hard 4/10", "7:1", "Point arrives as an identical pair"}, - WagerRow{"hard 6/8", "9:1", "Point arrives as an identical pair"}, + WagerRow{"hard 4/10", "7:1", "Roll this pair before next 7"}, + WagerRow{"hard 6/8", "9:1", "Roll this pair before next 7"}, ) } rows = append(rows, crapsPropRows(c, !t.rolled)...) @@ -77,17 +76,26 @@ func (t *crapsTable) wagerRows(name string) []WagerRow { return rows } +// crapsBuyLayRows splits the commission bets so each side carries its own +// win condition and the 5% vig applies only to the winning wager. +func crapsBuyLayRows() []WagerRow { + return []WagerRow{ + {"buy ", "true", "This number rolled before 7; 5% commission on wins"}, + {"lay ", "true", "7 rolled before this number; 5% commission on wins"}, + } +} + // crapsPropRows is the one-roll section of the list: field, the proposition // bets and (when bonuses are allowed and still open) the Fire and ATS bets. func crapsPropRows(c CrapsConfig, bonuses bool) []WagerRow { rows := []WagerRow{ - {"field", fmt.Sprintf("12 pays %d:1", c.Field12), "2 pays 2:1; 3/4/9/10/11 1:1; 5-8 lose"}, + {"field", "1:1", "Next roll is 2-4 or 9-12"}, {"seven", "4:1", "Next roll totals 7"}, {"craps", "7:1", "Next roll is 2, 3 or 12"}, {"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"}, {"aces / boxcars", "30:1", "Next roll is 2 / 12"}, {"horn", "27:4", "Splits the stake over 2/3/11/12"}, - {"hop ", "15:1/30:1", "One-roll dice combination"}, + {"hop ", "15:1/30:1", "Next roll is a/b"}, } if bonuses { rows = append(rows, @@ -130,21 +138,17 @@ func crapsPlaceRows(c CrapsConfig) []WagerRow { for i, n := range g.nums { names[i] = fmt.Sprint(n) } - rows = append(rows, WagerRow{"place " + strings.Join(names, "/"), g.odds, "Repeats before a 7"}) + rows = append(rows, WagerRow{"place " + strings.Join(names, "/"), g.odds, "This number rolled before 7"}) } return rows } -// crapsOddsRows describes the free-odds wagers with this table's odds cap. -// Pass and come odds are one group and don't odds another; the list column -// says simply "true" to keep the payout column narrow. -func crapsOddsRows(c CrapsConfig) []WagerRow { - limit := "3-4-5x" - if c.Odds > 0 { - limit = fmt.Sprintf("%dx", c.Odds) - } +// crapsOddsRows describes the free-odds wagers. Pass and come odds are one +// group and don't odds another; the list column says simply "true" to keep +// the payout column narrow. +func crapsOddsRows() []WagerRow { return []WagerRow{ - {"odds pass / come", "true", "Free odds behind the point, up to " + limit}, + {"odds pass / come", "true", "Free odds behind the point"}, {"odds dont ", "true", "Laid against the point at inverse odds"}, } } diff --git a/internal/casino/house_edge_craps.go b/internal/casino/house_edge_craps.go index 06a6629..6b86571 100644 --- a/internal/casino/house_edge_craps.go +++ b/internal/casino/house_edge_craps.go @@ -10,10 +10,10 @@ func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge { switch bet { case "pass", "come", "dontpass", "dontcome": return edge(-crapsContractEV(c, bet, 0)) - case "odds pass", "odds come ", "odds dontpass", "odds dontcome ": + case "odds pass", "odds pass / come", "odds come ", "odds dontpass", "odds dont ", "odds dontcome ": return edge(0) case "buy ", "lay ": - e := HouseEdge{Note: "5% commission on wins, before upward credit rounding; small stakes cost more"} + e := HouseEdge{Note: "5% commission on wins only; bet multiples keep the vig whole"} for _, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} { if !c.number(n) { continue @@ -25,16 +25,16 @@ func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge { e.Parts = append(e.Parts, EdgePart{strconv.Itoa(n), .05 * w / (w + 6)}) } return e - case "hard 4 / hard 10": + case "hard 4/10", "hard 4 / hard 10": return edge(-crapsResolvedEV(c, "hard", 4)) - case "hard 6 / hard 8": + case "hard 6/8", "hard 6 / hard 8": return edge(-crapsResolvedEV(c, "hard", 6)) case "eleven / ace-deuce": return edge(-crapsResolvedEV(c, "eleven", 0)) case "aces / boxcars": return edge(-crapsResolvedEV(c, "aces", 0)) case "hop ": - return HouseEdge{Parts: []EdgePart{{"unequal dice", -crapsResolvedEV(c, "hop", 12)}, {"doubles", -crapsResolvedEV(c, "hop", 11)}}} + return HouseEdge{Parts: []EdgePart{{"", -crapsResolvedEV(c, "hop", 12)}, {"(doubles)", -crapsResolvedEV(c, "hop", 11)}}} case "small / tall": return edge(1 - 35*crapsCollectProbability([]int{2, 3, 4, 5, 6})) case "all": @@ -45,7 +45,13 @@ func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge { return edge(-crapsResolvedEV(c, bet, 0)) } if strings.HasPrefix(bet, "place ") { - n, err := strconv.Atoi(strings.TrimPrefix(bet, "place ")) + // Place rows group equal-payout numbers (4/10, 5/9, 6/8, and the + // crapless extras); any number in the group carries the same edge. + field := strings.TrimPrefix(bet, "place ") + if at := strings.IndexByte(field, '/'); at >= 0 { + field = field[:at] + } + n, err := strconv.Atoi(field) if err == nil && c.number(n) { return edge(-crapsResolvedEV(c, "place", n)) } diff --git a/internal/casino/house_edge_test.go b/internal/casino/house_edge_test.go index 5f75e44..0704edc 100644 --- a/internal/casino/house_edge_test.go +++ b/internal/casino/house_edge_test.go @@ -108,6 +108,41 @@ func TestUnrecognizedTargetNeverInherits(t *testing.T) { } } +// TestCrapsGroupedTargetsHaveRTP: the list names grouped rows (4/10, buy , +// odds pass / come). Each must resolve to a real edge, not the unknown dash. +func TestCrapsGroupedTargetsHaveRTP(t *testing.T) { + for _, bet := range []string{ + "place 4/10", "place 5/9", "place 6/8", + "hard 4/10", "hard 6/8", + "odds pass / come", "odds dont ", + "buy ", "lay ", + } { + got := (TableConfig{Game: GameCraps}).HouseEdge(bet) + if len(got.Parts) == 0 { + t.Fatalf("%s has no RTP: %s", bet, got.Note) + } + } + crapless := TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "crapless"}} + for _, bet := range []string{"place 2/12", "place 3/11"} { + got := crapless.HouseEdge(bet) + if len(got.Parts) == 0 { + t.Fatalf("crapless %s has no RTP: %s", bet, got.Note) + } + } + // The rows expand to every legal number, including the crapless extras. + for _, want := range []string{"2", "3", "11", "12"} { + found := false + for _, part := range crapless.HouseEdge("lay ").Parts { + if part.Label == want { + found = true + } + } + if !found { + t.Fatalf("crapless lay missing number %s", want) + } + } +} + // TestHopBetsTwoParts pins the split hop analysis (easy vs doubles). func TestHopBetsTwoParts(t *testing.T) { got := (TableConfig{Game: GameCraps}).HouseEdge("hop ") diff --git a/internal/casino/manager.go b/internal/casino/manager.go index c9dde5a..8a33df6 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -338,6 +338,19 @@ func (m *Manager) MachineBusy(name string) bool { return false } +// TableBusy reports whether any table has an action in flight for the named +// player. The command loop uses it to hold the prompt until the action lands. +func (m *Manager) TableBusy(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, table := range m.tables { + if table.Busy(name) { + return true + } + } + return false +} + // RefundAll resolves every outstanding wager by refunding committed stakes // to their players and then closing each game's round. Graceful shutdown and // room eviction use this: rounds cannot settle, so no player may lose a diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index de6536f..fa87800 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -513,7 +513,7 @@ func (t *rouletteTable) menuEvent(name string) Event { commands := []MenuCommand{} p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { - commands = append(commands, MenuCommand{Command: "bet on ", Desc: "Place one wager; targets: list"}) + commands = append(commands, MenuCommand{Command: "bet on ", Desc: "Place one wager"}) if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) } diff --git a/internal/casino/table.go b/internal/casino/table.go index 08c18bf..d93d91e 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -142,6 +142,16 @@ func (t *Table) Board(name string) Snapshot { } func (t *Table) Overview() string { return t.game.overview() } +// Busy reports whether a table action for the named player is still in +// flight (for craps, the dice are in the air). The command loop skips the +// prompt while busy so a pending result is not split by a stray prompt. +func (t *Table) Busy(name string) bool { + if b, ok := t.game.(interface{ busy(name string) bool }); ok { + return b.busy(name) + } + return false +} + // Seats lists the seated players in seat order for the look command. func (t *Table) Seats() []string { return t.game.seats() } diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index d44deb7..98eaced 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -19,7 +19,7 @@ func (t *rouletteTable) hints(name string) []MenuCommand { } commands := []MenuCommand{} if t.phase != phasePlaying && !p.HasBet { - commands = append(commands, MenuCommand{Command: "bet on ", Desc: "Place one wager; targets: list"}) + commands = append(commands, MenuCommand{Command: "bet on ", Desc: "Place one wager"}) if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) } @@ -82,7 +82,7 @@ func baccaratHintCommands(t *baccaratTable, name string) []MenuCommand { } return append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, - MenuCommand{Command: "bet on "}, + MenuCommand{Command: "bet on "}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table", Desc: "Check the table"}, ) diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index d16d434..b570b36 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -6,7 +6,7 @@ import ( ) // WagerRow is one line of a table's "list" of available bets, in the game's -// current state: the bet wording (what follows "bet on"), its +// current state: the bet wording (what follows "bet on"), its // payout, and a brief description of the win condition. type WagerRow struct { Bet string diff --git a/internal/game/casino.go b/internal/game/casino.go index dc9f8f0..5e09d48 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -438,7 +438,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { for i, arg := range args { if strings.EqualFold(arg, "on") { if sawOn || spot != "" || i == len(args)-1 { - sess.WriteLine("Use bet on . No wager was placed.") + sess.WriteLine("Use bet on . No wager was placed.") return } sawOn = true diff --git a/internal/game/casino_edge_list_test.go b/internal/game/casino_edge_list_test.go index ff0b965..902d178 100644 --- a/internal/game/casino_edge_list_test.go +++ b/internal/game/casino_edge_list_test.go @@ -34,6 +34,26 @@ func TestEdgeListAllTableGames(t *testing.T) { } } +// TestCrapsListEveryBetHasRTP: once a reference item is carried, every +// craps wager the list can name must resolve to a real percentage. An +// unknown target would render the dash, which is the bug this guards. +func TestCrapsListEveryBetHasRTP(t *testing.T) { + for _, variant := range []string{"bank", "crapless", "new_york"} { + t.Run(variant, func(t *testing.T) { + room := "name: CR\ncasino_tables:\n - id: cr\n game: craps\n craps:\n variant: " + variant + "\n" + g, sess, conn := houseEdgeGame(t, room) + writeHouseEdgeManual(t, g, sess, 2) + g.executePlay(sess, []string{"craps"}, "play craps") + g.executeBet(sess, []string{"10", "pass"}, "bet 10 pass") + conn.data = nil + g.executeList(sess, nil, "list") + if text := stripANSI(string(conn.data)); strings.Contains(text, "—") { + t.Fatalf("%s come-out list left a bet without RTP:\n%s", variant, text) + } + }) + } +} + func TestEdgeListBlackjackActionsOmitRTP(t *testing.T) { g, sess, conn := houseEdgeGame(t, "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n") writeHouseEdgeManual(t, g, sess, 2) diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go index 19d8cc2..1abbcac 100644 --- a/internal/game/casino_house_edge.go +++ b/internal/game/casino_house_edge.go @@ -78,8 +78,10 @@ func (g *Game) casinoListRows(sess *net.Session, t *casino.Table, wagers []casin entry := row if strings.Contains(wager.Bet, "") { entry.bet = strings.ReplaceAll(wager.Bet, "", part.Label) - } else { - entry.win = part.Label + "; " + entry.win + } else if part.Label != "" { + // The label refines the shared win text (hop's doubles), so append + // it: the base phrase stays "Next roll is a/b (doubles)". + entry.win = entry.win + " " + part.Label } entry.edge = casino.HouseEdge{Parts: []casino.EdgePart{part}} rows = append(rows, entry) diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 2650d47..4c9accb 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -410,7 +410,7 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { g.showCasinoListTable(sess, "Bet", list) return } - g.showCasinoListTable(sess, "bet on", g.casinoListRows(sess, t, rows)) + g.showCasinoListTable(sess, "bet on", g.casinoListRows(sess, t, rows)) } // showCasinoGameLook appends the live game state and hint menu for the diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go index 6712455..1e9ff6b 100644 --- a/internal/game/craps_test.go +++ b/internal/game/craps_test.go @@ -18,7 +18,7 @@ func TestCrapsCommandsAndRendering(t *testing.T) { 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"}} { + for _, args := range [][]string{{"12", "on", "place", "6"}, {"10", "on", "hop", "2", "3"}, {"20", "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") { @@ -43,7 +43,7 @@ func TestCrapsCommandsAndRendering(t *testing.T) { 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", "SMALL", "Total: 7", "POINT 6"} { + for _, word := range []string{"PASS LINE", "DON'T COME", "F I E L D", "FIRE", "SMALL", "Total: 7", "POINT 6"} { if !strings.Contains(plain, word) { t.Fatal("missing", word) } diff --git a/internal/game/game.go b/internal/game/game.go index f1855dc..3357077 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -750,7 +750,7 @@ func (g *Game) ProcessQueuedCommands() { ss, _ := g.safespot.Get(p.Name) isHiding := ss.Active isBusy := p.Action != nil || len(p.WalkSequence) > 0 || g.Combat.Get(p.Name) != nil || p.MoveTicks > 0 || isHiding - isCasinoSpin := g.Casino != nil && g.Casino.MachineBusy(p.Name) + isCasinoSpin := g.Casino != nil && (g.Casino.MachineBusy(p.Name) || g.Casino.TableBusy(p.Name)) _, isResting := g.restTimers[p.Name] if !isResting && !isBusy && !isCasinoSpin && qc.Session.State() == net.StateGame { g.writePrompt(qc.Session) diff --git a/internal/game/render_casino_settlement.go b/internal/game/render_casino_settlement.go index bfe101c..2b5acc9 100644 --- a/internal/game/render_casino_settlement.go +++ b/internal/game/render_casino_settlement.go @@ -149,6 +149,12 @@ func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint f } } } + if settled == 0 { + // Nothing settled yet: the whole totals footer is noise, so end the + // receipt at the wager rows with the flat bottom border. + out = append(out, paint("dim", bl+strings.Repeat(h, inner)+br)) + return strings.Join(out, "\n") + } out = append(out, border(lj, bj, rj)) footer := func(text, tag string) { out = append(out, paint("dim", v)+" "+paint(tag, pad(text, inner-2, false))+" "+paint("dim", v)) diff --git a/internal/game/render_casino_settlement_test.go b/internal/game/render_casino_settlement_test.go index f26a174..15441ac 100644 --- a/internal/game/render_casino_settlement_test.go +++ b/internal/game/render_casino_settlement_test.go @@ -129,10 +129,15 @@ func TestCasinoReceiptIsPrivateEvenOnPublicResult(t *testing.T) { } } -func TestCasinoReceiptUnresolvedIsNotPush(t *testing.T) { +// TestCasinoReceiptUnresolvedOmitsTotals: while every wager is still live, +// the receipt shows only the rows; the settled/active totals and NET PENDING +// footer are omitted entirely. +func TestCasinoReceiptUnresolvedOmitsTotals(t *testing.T) { got := renderCasinoSettlement([]casino.SettlementLine{{Label: "Pass line", Stake: 100, Unresolved: true}}, false, plainReceipt) - if !strings.Contains(got, "NET PENDING") || strings.Contains(got, "NET EVEN") || strings.Contains(got, "PUSH") { - t.Fatal(got) + for _, gone := range []string{"NET PENDING", "NET EVEN", "PUSH", "Settled:", "Active:", "Staked:"} { + if strings.Contains(got, gone) { + t.Fatalf("unresolved receipt still shows %q:\n%s", gone, got) + } } } diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index ce9825f..e4e6618 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -21,17 +21,23 @@ const ( crapsPuckOn crapsPuckOff crapsBonus + crapsGoldInk + crapsBonusInk + crapsBonusDim ) var crapsPaint = []color.ColorSpec{ {Fg: 15, Bg: crapsFelt}, {Fg: 229, Bg: crapsFelt, Bold: true}, - {Fg: 217, Bg: crapsFelt, Bold: true}, + {Fg: 160, Bg: crapsFelt, Bold: true}, // deep red for come/pass/props {Fg: 108, Bg: crapsFelt}, {Fg: 0, Bg: 220, Bold: true}, {Fg: 0, Bg: 15, Bold: true}, {Fg: 15, Bg: 0, Bold: true}, {Fg: 229, Bg: 24, Bold: true}, // Fire/ATS tracker: yellow on blue + {Fg: 229, Bg: crapsFelt}, // regular-weight gold, used for field notes + {Fg: 229, Bg: 24}, // regular-weight tracker text and brackets + {Fg: 108, Bg: 24}, // dim tracker odds, matching the place/lay payouts } type crapsCell struct { @@ -93,6 +99,26 @@ 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) } +// crapsSpan is one color run inside a centered line. centerSpans lays the runs +// end to end and centers their combined width, so a label can stay regular +// weight while its payout is dim, or a heading can pair two inks on one row. +type crapsSpan struct { + text string + ink int +} + +func (c *crapsCanvas) centerSpans(x1, x2, y int, spans ...crapsSpan) { + total := 0 + for _, s := range spans { + total += color.VisibleLen(s.text) + } + x := x1 + (x2-x1+1-total)/2 + for _, s := range spans { + c.text(x, y, s.text, s.ink) + x += color.VisibleLen(s.text) + } +} + // crapsJoints maps a union of box edges (N=1, E=2, S=4, W=8) to its glyph. var crapsJoints = map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} @@ -191,17 +217,15 @@ func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) str c.box(38, 4, 76, 6) c.text(3, 1, "FIRE", crapsBonus) for i, n := range []int{4, 5, 6, 8, 9, 10} { - ink := crapsBonus - if crapsHas(s.Made, n) { - ink = crapsMarked - } - c.text(15+i*9, 1, fmt.Sprintf("[%2d]", n), ink) + crapsTrackerPoint(c, 15+i*9, 1, n, crapsHas(s.Made, n)) } bounds := []int{0, 25, 50, 76} for i, payout := range []string{"24:1", "249:1", "999:1"} { lo, hi := bounds[i], bounds[i+1] c.box(lo, 2, hi, 4) - c.center(lo+1, hi-1, 3, fmt.Sprintf("%d POINTS %s", i+4, payout), crapsBonus) + c.centerSpans(lo+1, hi-1, 3, + crapsSpan{fmt.Sprintf("%d POINTS", i+4), crapsBonusInk}, + crapsSpan{" " + payout, crapsBonusDim}) } c.text(2, 5, "SMALL", crapsBonus) c.text(40, 5, "TALL", crapsBonus) @@ -210,16 +234,27 @@ func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) str if i >= 5 { x = 48 + (i-5)*5 } - ink := crapsBonus - if crapsHas(s.Seen, n) { - ink = crapsMarked - } - c.text(x, 5, fmt.Sprintf("[%2d]", n), ink) + crapsTrackerPoint(c, x, 5, n, crapsHas(s.Seen, n)) } return c.render(mode, unicode) } + +// crapsTrackerPoint draws one bracketed total for the Fire/ATS tracker. The +// brackets stay regular weight while the number itself is bold; a made or +// seen point keeps its solid highlight across the whole token. +func crapsTrackerPoint(c *crapsCanvas, x, y, n int, marked bool) { + if marked { + c.text(x, y, fmt.Sprintf("[%2d]", n), crapsMarked) + return + } + c.text(x, y, "[", crapsBonusInk) + c.text(x+1, y, fmt.Sprintf("%2d", n), crapsBonus) + c.text(x+3, y, "]", crapsBonusInk) +} func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string { - c := newCrapsCanvas(26) + // Compact half-table: every rail keeps its label and value on adjacent + // rows, so the felt is five rows shorter than the spaced-out layout. + c := newCrapsCanvas(21) c.box(0, 0, 14, 6) c.center(1, 13, 4, "DON'T COME", crapsInk) c.center(1, 13, 5, "BAR 12", crapsMuted) @@ -241,9 +276,9 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string 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) + c.box(0, 10, 50, 14) + c.center(1, 49, 11, "F I E L D", crapsGold) + c.center(1, 49, 12, "( 2 ) 3 4 9 10 11 ( 12 )", crapsInk) twelve := s.Field12 if twelve == 0 { twelve = 2 @@ -252,13 +287,17 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string if twelve == 3 { twelvePay = "TRIPLE" } - c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %s", twelvePay), 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) + c.center(1, 49, 13, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %s", twelvePay), crapsGoldInk) + c.box(0, 14, 50, 16) + c.centerSpans(1, 49, 15, crapsSpan{"DON'T PASS", crapsInk}, crapsSpan{" BAR 12", crapsMuted}) + c.box(0, 16, 50, 18) + c.center(1, 49, 17, "PASS LINE", crapsRed) + c.box(0, 18, 50, 20) + odds := s.Odds + if odds == "" { + odds = "3x-4x-5x" + } + c.centerSpans(1, 49, 19, crapsSpan{"FREE ODDS", crapsInk}, crapsSpan{" (" + odds + ")", crapsMuted}) // Centre action: four bonus spots, four hardways, six propositions, horn/hop. // The Fire and ATS spots share one blue field. bonus := []struct{ label, pays string }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} @@ -269,11 +308,11 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string c.fill(50, 0, 76, 5, crapsBonus) for i, spot := range bonus { c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsBonus) - c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsBonus) + c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsBonusDim) } 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) + x, y := 50+(i%2)*13, 5+(i/2)*3 + c.box(x, y, x+13, y+3) c.center(x+1, x+12, y+1, fmt.Sprintf("HARD %d", n), crapsInk) pay := 7 if n == 6 || n == 8 { @@ -283,29 +322,29 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string if unicode { pair = crapsDie(n/2) + "+" + crapsDie(n/2) } - c.center(x+1, x+12, y+3, fmt.Sprintf("%s %d:1", pair, pay), crapsGold) + c.centerSpans(x+1, x+12, y+2, crapsSpan{pair + " ", crapsGold}, crapsSpan{fmt.Sprintf("%d:1", pay), crapsMuted}) } props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"} - odds := []string{"4:1", "7:1", "15:1", "30:1", "15:1", "30:1"} + oddsRows := []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 + col, y := i%3, 11+(i/3)*3 x, x2 := px[col], px[col+1] - c.box(x, y, x2, y+4) + c.box(x, y, x2, y+3) c.center(x+1, x2-1, y+1, label, crapsRed) - c.center(x+1, x2-1, y+3, odds[i], crapsInk) + c.center(x+1, x2-1, y+2, oddsRows[i], crapsMuted) } - c.box(50, 21, 63, 25) - c.center(51, 62, 22, "HORN", crapsGold) + c.box(50, 17, 63, 20) + c.center(51, 62, 18, "HORN", crapsGold) horn := "2 3 11 12" if unicode { // Horn combinations as painted dice pairs: 2, 3, 11, 12. horn = strings.Join([]string{crapsDie(1) + crapsDie(1), crapsDie(1) + crapsDie(2), crapsDie(5) + crapsDie(6), crapsDie(6) + crapsDie(6)}, " ") } - c.center(51, 62, 24, horn, crapsMuted) - c.box(63, 21, 76, 25) - c.center(64, 75, 22, "HOP", crapsGold) - c.center(64, 75, 24, "15:1 / 30:1", crapsMuted) + c.center(51, 62, 19, horn, crapsMuted) + c.box(63, 17, 76, 20) + c.center(64, 75, 18, "HOP", crapsGold) + c.center(64, 75, 19, "15:1 / 30:1", crapsMuted) return c.render(mode, unicode) } diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go index 49bda4a..b6a70be 100644 --- a/internal/game/render_craps_test.go +++ b/internal/game/render_craps_test.go @@ -18,7 +18,7 @@ func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { s := &casino.CrapsSnapshot{Point: point, Field12: 3} art := renderCrapsFelt(s, "none", unicode) rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n") - if len(rows) != 26 { + if len(rows) != 21 { t.Fatal(len(rows)) } for _, row := range rows { @@ -48,7 +48,7 @@ func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { if !strings.Contains(art, "12 PAYS TRIPLE") { 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"} { + for _, bet := range []string{"DON'T COME", "C O M E", "F I E L D", "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) } @@ -58,6 +58,45 @@ func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { 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)) } +// TestCrapsFeltOddsLabelAndCompaction pins the compacted felt copy: the +// free-odds cap is printed in parentheses, the old behind-the-line suffix is +// gone, and FIELD is letter-spaced. +func TestCrapsFeltOddsLabelAndCompaction(t *testing.T) { + base := &casino.CrapsSnapshot{Point: 6, Field12: 3} + art := plainRoulette(renderCrapsFelt(base, "none", true)) + for _, want := range []string{"F I E L D", "FREE ODDS (3x-4x-5x)", "DON'T PASS", "BAR 12"} { + if !strings.Contains(art, want) { + t.Fatalf("felt missing %q", want) + } + } + if strings.Count(art, "BAR 12") != 2 { + t.Fatalf("expected BAR 12 on both don't rails: %q", art) + } + for _, gone := range []string{"BEHIND THE LINE", "DON'T PASS /", "FREE ODDS /"} { + if strings.Contains(art, gone) { + t.Fatalf("felt still shows %q", gone) + } + } + custom := &casino.CrapsSnapshot{Point: 6, Field12: 3, Odds: "100x"} + if !strings.Contains(plainRoulette(renderCrapsFelt(custom, "none", true)), "FREE ODDS (100x)") { + t.Fatal("custom odds cap not shown") + } +} + +// TestCrapsTrackerBracketsRegularNumbersBold pins the mixed tracker weights: +// the bracket glyphs are regular while the total itself stays bold. +func TestCrapsTrackerBracketsRegularNumbersBold(t *testing.T) { + art := renderCrapsTrackers(&casino.CrapsSnapshot{}, "xterm256", true) + regular := color.Render("xterm256", color.ColorSpec{Fg: 229, Bg: 24}, "[") + bold := color.Render("xterm256", color.ColorSpec{Fg: 229, Bg: 24, Bold: true}, " 4") + if !strings.Contains(art, regular) { + t.Fatal("tracker brackets are not regular weight") + } + if !strings.Contains(art, bold) { + t.Fatal("tracker numbers are not bold") + } +} + // TestCrapsFireTrackerHighlightsMadePoints pins the Fire half of the tracker: // made points highlight, un-made points do not, and no asterisk is involved. func TestCrapsFireTrackerHighlightsMadePoints(t *testing.T) { @@ -168,7 +207,7 @@ func TestCrapsRemindersFollowArt(t *testing.T) { 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 on ") + felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet on ") if felt < 0 || commands < felt { t.Fatal("reminder above graphic", text) } diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index 72da225..90cee0c 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -606,7 +606,7 @@ func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) { out := strings.ReplaceAll(string(conn.data), "\r\n", "\n") tracker := strings.Index(out, "History: last 0 spins") board := strings.Index(out, "R = red, B = black, G = green") - reminder := strings.Index(out, "bet on ") + reminder := strings.Index(out, "bet on ") if tracker < 0 || board < 0 || reminder < 0 { t.Fatalf("missing tracker, board, or reminder:\n%s", out) } -- cgit v1.2.3