aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 00:51:41 -0400
committerhistoria <[not public]>2026-10-03 00:51:41 -0400
commit82e571d227feeafd0af4011e701ce26b99c5e3d9 (patch)
treeab4d56ddf94c3bf3b42fff5dfc2ff9a339f09113
parentcac16d1725c46c82a2e62b84c93f7090b2ce0746 (diff)
downloadthehouseoficarus-82e571d227feeafd0af4011e701ce26b99c5e3d9.tar.gz
fix: craps felt and wager description cleanup
-rw-r--r--data/help/casino.yaml2
-rw-r--r--data/help/crapless_craps.yaml2
-rw-r--r--data/help/craps.yaml3
-rw-r--r--data/help/new_york_craps.yaml2
-rw-r--r--internal/casino/baccarat.go6
-rw-r--r--internal/casino/craps.go53
-rw-r--r--internal/casino/craps_clock.go11
-rw-r--r--internal/casino/craps_projection.go4
-rw-r--r--internal/casino/craps_review_test.go119
-rw-r--r--internal/casino/craps_rules.go53
-rw-r--r--internal/casino/craps_test.go6
-rw-r--r--internal/casino/craps_ux.go15
-rw-r--r--internal/casino/craps_variants.go6
-rw-r--r--internal/casino/craps_wagers.go64
-rw-r--r--internal/casino/house_edge_craps.go18
-rw-r--r--internal/casino/house_edge_test.go35
-rw-r--r--internal/casino/manager.go13
-rw-r--r--internal/casino/roulette.go2
-rw-r--r--internal/casino/table.go10
-rw-r--r--internal/casino/table_hints.go4
-rw-r--r--internal/casino/table_ux.go2
-rw-r--r--internal/game/casino.go2
-rw-r--r--internal/game/casino_edge_list_test.go20
-rw-r--r--internal/game/casino_house_edge.go6
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/craps_test.go4
-rw-r--r--internal/game/game.go2
-rw-r--r--internal/game/render_casino_settlement.go6
-rw-r--r--internal/game/render_casino_settlement_test.go11
-rw-r--r--internal/game/render_craps.go113
-rw-r--r--internal/game/render_craps_test.go45
-rw-r--r--internal/game/render_roulette_test.go2
32 files changed, 515 insertions, 128 deletions
diff --git a/data/help/casino.yaml b/data/help/casino.yaml
index e361a47..8c0fa73 100644
--- a/data/help/casino.yaml
+++ b/data/help/casino.yaml
@@ -21,7 +21,7 @@ description: |
a loss returns nothing. Slots and video poker paytables use total-return
multipliers; the shared payout receipt uses profit odds instead.
Fractional table-game returns round down to whole credits, except
- craps which requires exact payout units (commission rounds up).
+ craps, which rounds stakes up to exact payout units (commission rounds down).
Every game uses the same payout receipt: Wager, Staked, Odds, Result, Net.
All amounts are credits. Returned includes any returned stake; Net is
diff --git a/data/help/crapless_craps.yaml b/data/help/crapless_craps.yaml
index 5621ca9..06b425d 100644
--- a/data/help/crapless_craps.yaml
+++ b/data/help/crapless_craps.yaml
@@ -23,4 +23,4 @@ description: |
No don't bets and no Fire/All/Tall/Small bonuses: with no losing
come-out, don't bets would be unplayable and bonuses unbeatable.
Place/buy/hardways and come odds are off on come-out; lay always
- works. Commission rounds up; contracts cannot be removed.
+ works. Commission rounds down; contracts cannot be removed.
diff --git a/data/help/craps.yaml b/data/help/craps.yaml
index 65c03fb..c550b4b 100644
--- a/data/help/craps.yaml
+++ b/data/help/craps.yaml
@@ -60,7 +60,8 @@ description: |
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.
+ Stakes round up to the next exact payout unit; the 5% commission is
+ charged only on a win and rounds down 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 the table felt. Pass/come and bonus contracts cannot
be removed. Disconnecting forfeits live bets; shutdown refunds them.
diff --git a/data/help/new_york_craps.yaml b/data/help/new_york_craps.yaml
index 8a16442..5f7c84a 100644
--- a/data/help/new_york_craps.yaml
+++ b/data/help/new_york_craps.yaml
@@ -19,4 +19,4 @@ description: |
No come, place, field, hardway, proposition or bonus bets — the
entire game is pass/don't pass with buy and lay behind them.
Buy is off on come-out; lay works from the start. Commission
- rounds up to a credit. Bets stay open until the roll.
+ rounds down to a credit. Bets stay open until the roll.
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 <amount> on <bet>"},
+ MenuCommand{Command: "bet <n> on <bet>"},
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 <amount> on "+t.Rules.betChoices()+".")}
+ return []Event{t.private(name, "Bet on what? Try bet <n> 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 <amount> on "+t.Rules.betChoices()+".")}
+ return []Event{t.private(name, "Bet on what? Try bet <n> 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 <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"+
+ "bet <n> on pass / dontpass / come / dontcome / field\n"+
+ "bet <n> on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+
+ "bet <n> on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+
+ "bet <n> on odds pass / odds come 6 (also dontpass/dontcome)\n"+
+ "bet <n> 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 <amount>", Desc: "Wager on pass; the dice roll 3 ticks after a bet"})
}
} else {
- target := "<target>"
+ target := "<bet>"
if !t.Config.Craps.allows("buy") {
target = "pass"
}
- commands = append(commands, MenuCommand{Command: "bet <amount> on " + target, Desc: "Place a wager; targets: list"})
+ commands = append(commands, MenuCommand{Command: "bet <n> 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 <number> / place <number> / buy <number> / lay <number>.\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 <a> <b> 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 <amount> on pass; the felt is authoritative for this edition."
+ betLine := "Bet: bet <n> 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 <amount> — 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 <n>", "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 <n>", "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 <n>", "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 <n>", "true", "This number rolled before 7; 5% commission on wins"},
+ {"lay <n>", "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 <a> <b>", "15:1/30:1", "One-roll dice combination"},
+ {"hop <a> <b>", "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 <n>", "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 <n>", "odds dontpass", "odds dontcome <n>":
+ case "odds pass", "odds pass / come", "odds come <n>", "odds dontpass", "odds dont <n>", "odds dontcome <n>":
return edge(0)
case "buy <n>", "lay <n>":
- 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 <a> <b>":
- 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 <n>,
+// 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 <n>",
+ "buy <n>", "lay <n>",
+ } {
+ 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 <n> 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 <n>").Parts {
+ if part.Label == want {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("crapless lay <n> missing number %s", want)
+ }
+ }
+}
+
// TestHopBetsTwoParts pins the split hop analysis (easy vs doubles).
func TestHopBetsTwoParts(t *testing.T) {
got := (TableConfig{Game: GameCraps}).HouseEdge("hop <a> <b>")
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 <amount> on <target>", Desc: "Place one wager; targets: list"})
+ commands = append(commands, MenuCommand{Command: "bet <n> on <bet>", 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 <amount> on <target>", Desc: "Place one wager; targets: list"})
+ commands = append(commands, MenuCommand{Command: "bet <n> on <bet>", 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 <amount> on <bet>"},
+ MenuCommand{Command: "bet <n> on <bet>"},
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 <amount> on"), its
+// current state: the bet wording (what follows "bet <n> 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 <amount> on <target>. No wager was placed.")
+ sess.WriteLine("Use bet <n> on <bet>. 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, "<n>") {
entry.bet = strings.ReplaceAll(wager.Bet, "<n>", 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 <amount> on", g.casinoListRows(sess, t, rows))
+ g.showCasinoListTable(sess, "bet <n> 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 <amount> on <target>")
+ felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet <n> on <bet>")
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 <amount> on <target>")
+ reminder := strings.Index(out, "bet <n> on <bet>")
if tracker < 0 || board < 0 || reminder < 0 {
t.Fatalf("missing tracker, board, or reminder:\n%s", out)
}