aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 16:16:17 -0400
committerhistoria <[not public]>2026-09-30 16:16:17 -0400
commita4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73 (patch)
tree00c9567e620f331a59dd44f1f1fa779ecd94a845 /internal
parent724079f8de1ae69965c6267dca3d7fc3cc86afa2 (diff)
downloadthehouseoficarus-a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73.tar.gz
feat: additional craps variants added, standardizard casino command hints
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go5
-rw-r--r--internal/casino/blackjack.go9
-rw-r--r--internal/casino/command_hints_test.go155
-rw-r--r--internal/casino/craps.go157
-rw-r--r--internal/casino/craps_clock.go4
-rw-r--r--internal/casino/craps_clock_test.go2
-rw-r--r--internal/casino/craps_projection.go141
-rw-r--r--internal/casino/craps_regression_test.go51
-rw-r--r--internal/casino/craps_rules.go39
-rw-r--r--internal/casino/craps_test.go2
-rw-r--r--internal/casino/craps_ux.go53
-rw-r--r--internal/casino/craps_variants.go184
-rw-r--r--internal/casino/craps_variants_test.go308
-rw-r--r--internal/casino/machine.go20
-rw-r--r--internal/casino/roulette.go24
-rw-r--r--internal/casino/table_ux.go12
-rw-r--r--internal/game/casino.go43
-rw-r--r--internal/game/casino_commands.go83
-rw-r--r--internal/game/casino_commands_test.go118
-rw-r--r--internal/game/casino_ux.go12
-rw-r--r--internal/game/craps_variants_test.go103
-rw-r--r--internal/game/render_craps.go55
-rw-r--r--internal/game/render_craps_cards.go44
23 files changed, 1332 insertions, 292 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 56677b2..0890d59 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -47,11 +47,14 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
}
func (t *baccaratTable) menuEvent(name string) Event {
+ if p := t.players.get(name); p != nil && p.HasBet {
+ return t.committedMenu(name)
+ }
commands := []MenuCommand{}
if p := t.players.get(name); p != nil && p.lastBet > 0 {
commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
}
- commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t); min/max accepted"}, MenuCommand{Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "One wager per round; 'on' is optional"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: table rules"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
e := t.betMenuEvent(name, commands)
if p := t.players.get(name); p != nil {
e.Menu.Repeat = p.lastBet > 0
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 5fb8b0b..925b7ef 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -101,6 +101,15 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
}
func (t *blackjackTable) menuEvent(name string) Event {
+ if p := t.players.get(name); p != nil && p.HasBet {
+ if t.phase == blackjackPlaying && t.activeName() == name {
+ return t.turnMenuLocked(name)
+ }
+ if t.phase == blackjackInsurance && !p.declinedInsurance && p.insurance == 0 {
+ return t.insuranceMenuLocked(p)
+ }
+ return t.committedMenu(name)
+ }
if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.")
}
diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go
new file mode 100644
index 0000000..c570e15
--- /dev/null
+++ b/internal/casino/command_hints_test.go
@@ -0,0 +1,155 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func hintHas(commands []MenuCommand, command string) bool {
+ for _, c := range commands {
+ if c.Command == command {
+ return true
+ }
+ }
+ return false
+}
+func hintBet(commands []MenuCommand) bool {
+ for _, c := range commands {
+ if strings.HasPrefix(c.Command, "bet") {
+ return true
+ }
+ }
+ return false
+}
+func TestCrapsHintsFollowPlayerState(t *testing.T) {
+ for _, v := range crapsVariants {
+ tb := variantFixture(v)
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ commands := tb.commands("a")
+ if !hintBet(commands) || !hintHas(commands, "stop") {
+ t.Fatal(v, "missing idle actions", commands)
+ }
+ for _, c := range []string{"roll", "autoroll", "unbet <target> / unbet all"} {
+ if hintHas(commands, c) {
+ t.Fatal(v, "idle", c)
+ }
+ }
+ tb.bet("a", "pass", 10, w)
+ commands = tb.commands("a")
+ if !hintHas(commands, "roll") || !hintHas(commands, "autoroll") || hintHas(commands, "unbet <target> / unbet all") {
+ t.Fatal(v, "contract actions", commands)
+ }
+ if !tb.Config.Craps.allows("buy") && hintBet(commands) {
+ t.Fatal(v, "duplicate pass advertised")
+ }
+ tb.action("a", "autoroll")
+ if hintHas(tb.commands("a"), "autoroll") {
+ t.Fatal(v, "already enabled")
+ }
+ tb.join("b")
+ if hintHas(tb.commands("a"), "roll") {
+ t.Fatal(v, "roll before betting window ends")
+ }
+ tb.players.get("a").autoRoll = false
+ tb.bet("b", "pass", 10, &testWallet{chips: 1000})
+ tb.tickElapsed(10 * time.Second)
+ if !hintHas(tb.commands("a"), "roll") || hintHas(tb.commands("b"), "roll") {
+ t.Fatal(v, "wrong shooter hints")
+ }
+ watch := tb.commands("watcher")
+ if len(watch) != 1 || watch[0].Command != "table" {
+ t.Fatal(v, "spectator actions", watch)
+ }
+ }
+}
+func TestCrapsHintsOnlyOfferRemovableWagers(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(2, 2)
+ if hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ t.Fatal("contract removable")
+ }
+ tb.bet("a", "odds pass", 10, w)
+ if !hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ t.Fatal("odds not removable")
+ }
+ tb.action("a", "unbet odds pass")
+ if hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ t.Fatal("removed odds still advertised")
+ }
+ tb.bet("a", "place 6", 12, w)
+ if !hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ t.Fatal("place removal missing")
+ }
+}
+func TestOtherCasinoHintsRespectCommitment(t *testing.T) {
+ bj := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack, BettingTicks: 10}, newTestRand())
+ bj.join("a")
+ bj.join("b")
+ bj.bet("a", "", 10, &testWallet{chips: 1000})
+ if hintBet(bj.menuEvent("a").Menu.Commands) {
+ t.Fatal("blackjack duplicate bet advertised")
+ }
+ if !hintBet(bj.menuEvent("b").Menu.Commands) {
+ t.Fatal("blackjack missing eligible bet")
+ }
+ bac := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 10}, newTestRand())
+ bac.join("a")
+ bac.join("b")
+ bac.bet("a", "banker", 10, &testWallet{chips: 1000})
+ if hintBet(bac.menuEvent("a").Menu.Commands) {
+ t.Fatal("baccarat duplicate bet advertised")
+ }
+ if !hintBet(bac.menuEvent("b").Menu.Commands) {
+ t.Fatal("baccarat missing eligible bet")
+ }
+ r := newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 10}, newTestRand())
+ r.join("a")
+ r.join("b")
+ if hintHas(r.menuEvent("a").Menu.Commands, "bet / rebet") {
+ t.Fatal("roulette no wager to repeat")
+ }
+ r.bet("a", "red", 10, &testWallet{chips: 1000})
+ if hintBet(r.menuEvent("a").Menu.Commands) {
+ t.Fatal("roulette duplicate bet advertised")
+ }
+ if !hintBet(r.menuEvent("b").Menu.Commands) {
+ t.Fatal("roulette missing eligible bet")
+ }
+ r.phase = phasePlaying
+ if hintBet(r.menuEvent("b").Menu.Commands) {
+ t.Fatal("roulette spinning bets advertised")
+ }
+ r.phase = phaseWaiting
+ r.players.get("a").HasBet = false
+ if !hintHas(r.menuEvent("a").Menu.Commands, "bet / rebet") {
+ t.Fatal("roulette repeat missing")
+ }
+}
+func TestSlotHintsRequirePriorWager(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ m.join("a")
+ for _, c := range []string{"spin", "autospin"} {
+ if hintHas(m.commands(), c) {
+ t.Fatal("fresh slots", c)
+ }
+ }
+ m.bet = 10
+ for _, c := range []string{"spin", "autospin"} {
+ if !hintHas(m.commands(), c) {
+ t.Fatal("configured slots", c)
+ }
+ }
+ m.autoBet = true
+ if commands := m.commands(); len(commands) != 1 || commands[0].Command != "stop" {
+ t.Fatal("autospin hints", commands)
+ }
+ m.phase = machineSpinning
+ if events := m.privateMenu(""); events[0].Menu != nil {
+ t.Fatal("manual commands during spin")
+ }
+}
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 5e5e876..9e48667 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -43,6 +43,7 @@ type CrapsBetView struct {
Amount, Odds int
}
type CrapsSnapshot struct {
+ Variant string
Phase string
Point int
Dice [2]int
@@ -71,7 +72,7 @@ func crapsTarget(b *crapsBet) string {
return fmt.Sprintf("%s %d", b.kind, b.number)
}
func (t *crapsTable) view() *CrapsSnapshot {
- s := &CrapsSnapshot{Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12}
+ 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}
if t.hasLiveBets() && t.multiplayer {
s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed)
s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed)
@@ -96,7 +97,7 @@ func (t *crapsTable) view() *CrapsSnapshot {
return s
}
func (t *crapsTable) menu(name string) Event {
- e := t.betMenuEvent(name, t.commands())
+ e := t.betMenuEvent(name, t.commands(name))
e.Snapshot = t.view()
return e
}
@@ -159,7 +160,7 @@ func parseCrapsTarget(spot string) (kind string, n int, ok bool) {
return
}
n, _ = strconv.Atoi(f[1])
- ok = crapsNumber(n) && (kind != "hard" || n%2 == 0)
+ ok = n >= 2 && n <= 12 && n != 7 && (kind != "hard" || (crapsNumber(n) && n%2 == 0))
return
case "hop":
if len(f) != 3 {
@@ -167,6 +168,9 @@ func parseCrapsTarget(spot string) (kind string, n int, ok bool) {
}
a, _ := strconv.Atoi(f[1])
b, _ := strconv.Atoi(f[2])
+ if a > b {
+ a, b = b, a
+ }
return kind, a*10 + b, a >= 1 && a <= 6 && b >= 1 && b <= 6
}
return
@@ -191,6 +195,9 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
return reject("That potential payout is too large.")
}
if strings.HasPrefix(spot, "odds ") {
+ if !t.Config.Craps.allows("odds") {
+ return reject("This variant has no free odds.")
+ }
target := strings.TrimPrefix(spot, "odds ")
for _, b := range p.bets {
if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) {
@@ -209,7 +216,8 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
if b.odds.Total > 0 {
continue
}
- if int64(amount) > int64(b.stake.Total)*int64(limit) || amount%y != 0 {
+ // Compare without multiplying a potentially very large stake by Nx.
+ if (amount-1)/limit >= b.stake.Total || amount%y != 0 {
return reject("Odds already placed, above the free-odds limit, or not in exact payout units.")
}
stake, err := w.Wager(amount)
@@ -219,13 +227,13 @@ 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)}
+ return []Event{t.private(name, fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target)), t.betSummary(p), t.reminder(name)}
}
return reject("Place an established line/come bet before taking odds.")
}
kind, n, ok := parseCrapsTarget(spot)
- if !ok {
- return reject("Unknown craps target. Use table rules.")
+ if !ok || !t.Config.Craps.allows(kind) || ((kind == "place" || kind == "buy" || kind == "lay") && !t.Config.Craps.number(n)) {
+ return reject("Unknown or unavailable craps target. Use table rules.")
}
if (kind == "pass" || kind == "dontpass") && t.point != 0 {
return reject("Line bets are accepted only on come-out.")
@@ -244,12 +252,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
unit := 1
switch kind {
case "place":
- switch n {
- case 4, 10, 5, 9:
- unit = 5
- default:
- unit = 6
- }
+ _, unit = crapsPlaceOdds(n)
case "buy":
_, unit = crapsTrueOdds(n)
case "lay":
@@ -271,35 +274,13 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
p.lastSpot, p.lastBet = spot, amount
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet {casino_chips}%d{/} on %s.", amount, spot))}
events = append(events, t.syncRoundClock()...)
- return append(events, t.betSummary(p))
+ return append(events, t.betSummary(p), t.reminder(name))
}
func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) (settlementLine, bool) {
- payout := 0
- if profit >= 0 {
- payout = b.stake.Total + b.stake.Total/den*profit
- if b.kind == "buy" || b.kind == "lay" {
- commissionBase := payout - b.stake.Total
- if b.kind == "buy" {
- commissionBase = b.stake.Total
- }
- // Removals (zero profit) return the full stake without commission.
- if profit > 0 {
- payout -= (commissionBase + 19) / 20
- }
- }
- }
+ payout := crapsReturn(b, profit, den, oddsProfit, refundOdds)
ids := []string{b.stake.ID}
if b.odds.Total > 0 {
ids = append(ids, b.odds.ID)
- if refundOdds {
- payout += b.odds.Total
- } else if oddsProfit >= 0 {
- x, y := crapsTrueOdds(b.number)
- if strings.HasPrefix(b.kind, "dont") {
- x, y = y, x
- }
- payout += b.odds.Total + b.odds.Total/y*x
- }
}
line := settlementLine{Label: crapsBetLabel(b), Stake: b.stake.Total + b.odds.Total, Returned: payout, Result: "push"}
if payout > line.Stake {
@@ -310,7 +291,17 @@ func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfi
return line, p.Wallet.PayCredits(payout, ids...) != nil
}
func (t *crapsTable) roll() []Event {
- a, b := t.rng.Intn(6)+1, t.rng.Intn(6)+1
+ a, b := 0, 0
+ switch t.Config.Craps.variant() {
+ case "die_rich":
+ a = t.rng.Intn(6) + 1
+ case "diceless":
+ // Each independent pack is replaced and reshuffled for every draw.
+ a = t.rng.Perm(6)[0] + 1
+ b = t.rng.Perm(6)[0] + 1
+ default:
+ a, b = t.rng.Intn(6)+1, t.rng.Intn(6)+1
+ }
return t.resolveRoll(a, b)
}
@@ -319,19 +310,24 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
s := a + b
oldPoint := t.point
sevenOut := oldPoint != 0 && s == 7
+ pointMade := oldPoint != 0 && oldPoint == s
+ r := t.Config.Craps
+ switch r.variant() {
+ case "simplified", "high_point", "die_rich":
+ done, profit, _ := r.outcome("pass", oldPoint, a, b, oldPoint)
+ sevenOut = done && profit < 0
+ pointMade = done && profit > 0 && oldPoint != 0
+ }
t.dice = [2]int{a, b}
t.rolled = true
- if oldPoint == s {
- t.made[s] = true
- }
- if s != 7 {
- t.seen[s] = true
- }
shooter := "Dealer"
if len(t.queue) > 0 {
shooter = t.queue[0]
}
events := []Event{t.event(fmt.Sprintf("{casino_emphasis}%s shoots{/} {casino_payout}%d{/}", shooter, s), true)}
+ if r.variant() == "diceless" {
+ events[0].Message = fmt.Sprintf("{casino_emphasis}%s draws cards{/} [%d] [%d] = %d", shooter, a, b, s)
+ }
var results []Event
for _, p := range t.players.ordered() {
p.autoElapsed = 0
@@ -339,46 +335,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
failed := false
var live []*crapsBet
for _, bet := range p.bets {
- done, x, y := crapsOutcome(bet.kind, bet.number, a, b, oldPoint, t.Config.Craps.Field12)
- switch bet.kind {
- case "fire":
- done = sevenOut
- y = 1
- x = -1
- if done {
- switch len(t.made) {
- case 4:
- x = 24
- case 5:
- x = 249
- case 6:
- x = 999
- }
- }
- case "small", "tall", "all":
- lo, hi := 2, 12
- if bet.kind == "small" {
- hi = 6
- }
- if bet.kind == "tall" {
- lo = 8
- }
- hit := true
- for n := lo; n <= hi; n++ {
- if n != 7 && !t.seen[n] {
- hit = false
- }
- }
- done = hit || s == 7
- x = -1
- y = 1
- if hit {
- x = 34
- if bet.kind == "all" {
- x = 175
- }
- }
- }
+ done, x, y := t.outcomeFor(bet, a, b)
if done {
refundOdds := oldPoint == 0 && bet.kind == "come"
line, err := t.pay(p, bet, x, y, x, refundOdds)
@@ -389,7 +346,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
}
continue
}
- if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && crapsNumber(s) {
+ if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && r.pointNumber(s) {
bet.number = s
}
live = append(live, bet)
@@ -400,15 +357,20 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
results = append(results, t.settlementEvent(p, lines, failed))
}
}
+ if pointMade {
+ t.made[oldPoint] = true
+ }
if s == 7 {
t.seen = map[int]bool{}
+ } else {
+ t.seen[s] = true
}
- if oldPoint == 0 && crapsNumber(s) {
+ 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))
- } else if oldPoint == s || sevenOut {
- if oldPoint == s {
- events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", s), true))
+ } else if pointMade || sevenOut {
+ if pointMade {
+ events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", oldPoint), true))
}
t.point = 0
}
@@ -418,7 +380,11 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
if len(t.queue) > 1 {
t.queue = append(t.queue[1:], t.queue[0])
}
- events = append(events, t.event("{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter.", true))
+ message := "{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter."
+ if r.variant() == "simplified" || r.variant() == "high_point" || r.variant() == "die_rich" {
+ message = "{casino_warning}SHOOTER OUT{/} / Puck OFF. Next shooter."
+ }
+ events = append(events, t.event(message, true))
}
events = append(events, t.finishCrapsControls()...)
t.phase = phaseWaiting
@@ -483,7 +449,7 @@ func (t *crapsTable) action(name, action string) []Event {
p.autoRoll = true
p.autoElapsed = 0
}
- return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 seconds on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play.")}
+ return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 seconds on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play."), t.reminder(name)}
}
if action == "roll" {
events := t.syncRoundClock()
@@ -505,6 +471,9 @@ func (t *crapsTable) action(name, action string) []Event {
}
oddsOnly := strings.HasPrefix(target, "odds ")
target = strings.TrimPrefix(target, "odds ")
+ if kind, n, ok := parseCrapsTarget(target); ok && kind == "hop" {
+ target = crapsTarget(&crapsBet{kind: kind, number: n})
+ }
for i, b := range p.bets {
if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) {
continue
@@ -518,9 +487,9 @@ func (t *crapsTable) action(name, action string) []Event {
e.Message = payoutFailedMessage()
}
b.odds = Wager{}
- return []Event{e, t.betSummary(p)}
+ return []Event{e, t.betSummary(p), t.reminder(name)}
}
- if b.kind == "pass" || b.kind == "come" || b.kind == "fire" || b.kind == "small" || b.kind == "tall" || b.kind == "all" {
+ if crapsContract(b.kind) {
return []Event{t.private(name, "That contract bet cannot be removed.")}
}
line, failed := t.pay(p, b, 0, 1, 0, true)
@@ -534,7 +503,7 @@ func (t *crapsTable) action(name, action string) []Event {
events := append([]Event{e}, t.finishCrapsControls()...)
events = append(events, t.syncRoundClock()...)
if t.players.has(name) {
- events = append(events, t.betSummary(p))
+ events = append(events, t.betSummary(p), t.reminder(name))
}
return events
}
diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go
index cb43731..f5cb690 100644
--- a/internal/casino/craps_clock.go
+++ b/internal/casino/craps_clock.go
@@ -92,7 +92,7 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event {
return events
}
if t.phase == phaseBetting {
- events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in 60 seconds if needed.", p.Name), true))
+ events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in 60 seconds if needed.", p.Name), true), t.reminder(p.Name))
}
t.phase = phasePlaying
if (p.autoRoll && p.HasBet && p.autoElapsed >= crapsAutoDelay) || (t.multiplayer && t.roundElapsed >= crapsBetDelay+crapsDealerDelay) {
@@ -116,5 +116,5 @@ func (t *Table) StopAutoRoll(name string) ([]Event, bool) {
}
p.autoRoll = false
p.autoElapsed = 0
- return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table.")}, true
+ return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table."), game.reminder(name)}, true
}
diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go
index 0e68e6d..518d18d 100644
--- a/internal/casino/craps_clock_test.go
+++ b/internal/casino/craps_clock_test.go
@@ -282,7 +282,7 @@ func TestCrapsRollTableHardPairLabels(t *testing.T) {
tb.bet("alice", "hop 2 3", 10, w)
tb.point = 8
table := tb.rollTable(tb.players.get("alice"))
- for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+90{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}easy 8{/}", "{casino_payout}any other{/}"} {
+ for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+80{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}2+6{/}", "{casino_result_loss}-20{/}"} {
if !strings.Contains(table, want) {
t.Fatal("missing", want, table)
}
diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go
index 5be2f28..96d64b7 100644
--- a/internal/casino/craps_projection.go
+++ b/internal/casino/craps_projection.go
@@ -6,22 +6,17 @@ import (
"strings"
)
-// crapsReturn is the pure payout math shared by settlement and by the
-// player-facing "what could win on the next roll" projection, so the two can
-// never disagree. It mirrors the money path of crapsTable.pay exactly.
+// crapsReturn is shared by wallet settlement and next-roll previews.
func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int {
payout := 0
if profit >= 0 {
payout = b.stake.Total + b.stake.Total/den*profit
- if b.kind == "buy" || b.kind == "lay" {
+ if (b.kind == "buy" || b.kind == "lay") && profit > 0 {
base := payout - b.stake.Total
if b.kind == "buy" {
base = b.stake.Total
}
- // Removals (zero profit) return the full stake without commission.
- if profit > 0 {
- payout -= (base + 19) / 20
- }
+ payout -= (base + 19) / 20
}
}
if b.odds.Total > 0 {
@@ -37,69 +32,50 @@ func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int
}
return payout
}
-
-// crapsNet returns settlement profit: returns (stake included) less commit.
func crapsNet(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int {
- return crapsReturn(b, profit, den, oddsProfit, refundOdds) - (b.stake.Total + b.odds.Total)
+ return crapsReturn(b, profit, den, oddsProfit, refundOdds) - b.stake.Total - b.odds.Total
}
-
-// crapsRefundOdds mirrors come bets' come-out refundOdds policy.
func crapsRefundOdds(b *crapsBet, point int) bool { return point == 0 && b.kind == "come" }
-// crapsRollView is one possible dice pair: its total, the pair shown, and the
-// player's combined settlement profit if it rolled next.
type CrapsRollView struct {
Total int
Rolls string
Net int
}
-// projectionFor evaluates one player's live bets as if the listed pair rolled
-// with the current table point, via the same rules engine used for settlement.
+func (t *crapsTable) rollPairs() [][2]int {
+ var pairs [][2]int
+ for a := 1; a <= 6; a++ {
+ if t.Config.Craps.variant() == "die_rich" {
+ pairs = append(pairs, [2]int{a, 0})
+ continue
+ }
+ for b := a; b <= 6; b++ {
+ pairs = append(pairs, [2]int{a, b})
+ }
+ }
+ return pairs
+}
func (t *crapsTable) projectionFor(p *crapsPlayer) []CrapsRollView {
var rows []CrapsRollView
- for total := 2; total <= 12; total++ {
- for a := 1; a <= 6; a++ {
- b := total - a
- if b < 1 || b > 6 || a > b {
- continue
- }
- net := 0
- for _, bet := range p.bets {
- done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12)
- if !done {
- // An unresolved contract keeps its stake; only bonuses
- // can complete within a single non-settling roll.
- if bet.kind == "fire" {
- x = crapsFireProfit(len(t.made))
- } else if bet.kind == "small" || bet.kind == "tall" || bet.kind == "all" {
- lo, hi := 2, 12
- if bet.kind == "small" {
- hi = 6
- }
- if bet.kind == "tall" {
- lo = 8
- }
- if crapsBonusComplete(lo, hi, t.seen, a+b) {
- x = 34
- if bet.kind == "all" {
- x = 175
- }
- } else {
- continue
- }
- } else {
- continue
- }
- }
+ for _, pair := range t.rollPairs() {
+ a, b := pair[0], pair[1]
+ net := 0
+ for _, bet := range p.bets {
+ done, x, y := t.outcomeFor(bet, a, b)
+ if done {
net += crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point))
}
- rows = append(rows, CrapsRollView{total, fmt.Sprintf("%d+%d", a, b), net})
}
+ label := fmt.Sprintf("%d+%d", a, b)
+ if b == 0 {
+ label = fmt.Sprint(a)
+ }
+ rows = append(rows, CrapsRollView{a + b, label, net})
}
+ sort.SliceStable(rows, func(i, j int) bool { return rows[i].Total < rows[j].Total })
return rows
}
-
func crapsFireProfit(made int) int {
switch made {
case 4:
@@ -111,7 +87,6 @@ func crapsFireProfit(made int) int {
}
return 0
}
-
func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool {
for n := lo; n <= hi; n++ {
if n != 7 && !seen[n] && n != next {
@@ -120,9 +95,6 @@ func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool {
}
return true
}
-
-// crapsRollLabel names the roll result a bet cares about: hardways and hop
-// bets distinguish their exact pair ("4+4"); every other bet reads by total.
func crapsRollLabel(bet *crapsBet, a, b int) string {
s := a + b
switch bet.kind {
@@ -142,59 +114,28 @@ func crapsRollLabel(bet *crapsBet, a, b int) string {
}
return fmt.Sprint(s)
}
-
-// rollTable lists each roll result and its combined payout across the
-// player's bets, one row per roll. Dice-distinguishing bets (hardways, hop)
-// name their pair; everything else reads by total. Pairs that share a
-// label and outcome collapse; across bets, same-roll payouts add. Tags
-// stay single-level: the tag expander does not nest.
func (t *crapsTable) rollTable(p *crapsPlayer) string {
agg := map[string]int{}
+ pairsMatter := false
for _, bet := range p.bets {
- perLabel := map[string]int{}
- for a := 1; a <= 6; a++ {
- for b := a; b <= 6; b++ {
- done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12)
- if !done {
- // Unresolved contracts stay put; only bonuses can
- // complete within a single non-settling roll.
- switch bet.kind {
- case "fire":
- x = crapsFireProfit(len(t.made))
- case "small", "tall", "all":
- lo, hi := 2, 12
- if bet.kind == "small" {
- hi = 6
- }
- if bet.kind == "tall" {
- lo = 8
- }
- if !crapsBonusComplete(lo, hi, t.seen, a+b) {
- continue
- }
- x = 34
- if bet.kind == "all" {
- x = 175
- }
- default:
- continue
- }
- }
- net := crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point))
- if net != 0 {
- perLabel[crapsRollLabel(bet, a, b)] = net
- }
- }
+ if bet.kind == "hard" || bet.kind == "hop" {
+ pairsMatter = true
+ }
+ }
+ for _, row := range t.projectionFor(p) {
+ label := fmt.Sprint(row.Total)
+ if pairsMatter {
+ label = row.Rolls
}
- for label, net := range perLabel {
- agg[label] += net
+ if row.Net != 0 {
+ agg[label] = row.Net
}
}
if len(agg) == 0 {
if len(p.bets) == 0 {
return "No open wagers."
}
- return "{casino_action}Your place, buy and hardway bets are OFF on the come-out — nothing pays until a point is set.{/}"
+ return "{casino_action}No wager settles on the next roll; contracts remain live. Place/buy/hardways are OFF on the come-out.{/}"
}
labels := []string{}
for label := range agg {
diff --git a/internal/casino/craps_regression_test.go b/internal/casino/craps_regression_test.go
new file mode 100644
index 0000000..322325c
--- /dev/null
+++ b/internal/casino/craps_regression_test.go
@@ -0,0 +1,51 @@
+package casino
+
+import (
+ "testing"
+)
+
+func TestCrapsLargestStakeAndOddsLimit(t *testing.T) {
+ tb := crapsFixture()
+ tb.Config.MaxBet = MaxCredits / 1000
+ tb.Config.Craps.Odds = 1000000
+ tb.join("a")
+ w := &testWallet{chips: MaxCredits}
+ amount := tb.Config.MaxBet
+ tb.bet("a", "pass", amount, w)
+ tb.resolveRoll(2, 2)
+ tb.bet("a", "odds pass", amount, w)
+ if len(w.open) != 2 {
+ t.Fatal("valid large odds rejected")
+ }
+}
+func TestCrapsHopIsUnordered(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "hop 3 2", 10, w)
+ tb.bet("a", "hop 2 3", 10, w)
+ if len(w.open) != 1 || w.chips != 90 {
+ t.Fatal("duplicate unordered hop accepted")
+ }
+ tb.action("a", "unbet hop 3 2")
+ if len(w.open) != 0 || w.paid != 10 || w.chips != 90 {
+ t.Fatal("reverse-order removal failed")
+ }
+}
+func TestCrapsMixedPairPreviewCombinesAllBets(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ tb.point = 8
+ tb.bet("a", "hard 8", 10, w)
+ tb.bet("a", "hop 2 3", 10, w)
+ tb.bet("a", "field", 10, w)
+ for _, row := range tb.projectionFor(tb.players.get("a")) {
+ if row.Rolls == "4+4" && row.Net != 70 {
+ t.Fatal("hard win minus hop and field losses", row)
+ }
+ if row.Rolls == "2+3" && row.Net != 140 {
+ t.Fatal("hop win minus field loss", row)
+ }
+ }
+}
diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go
index 86aa9bb..8d2f7d4 100644
--- a/internal/casino/craps_rules.go
+++ b/internal/casino/craps_rules.go
@@ -2,8 +2,7 @@ package casino
import "fmt"
-// CrapsConfig intentionally supports bank craps only. Other games must supply
-// their own outcome rules, not silently inherit bank-craps probabilities.
+// CrapsConfig selects explicit house rules and the shared wager limits.
type CrapsConfig struct {
Odds int `yaml:"odds,omitempty"` // 0 = 3-4-5x; positive = uniform Nx
Field12 int `yaml:"field_12,omitempty"`
@@ -23,14 +22,23 @@ func validateCraps(c TableConfig) error {
if c.Craps.Field12 != 0 && c.Craps.Field12 != 2 && c.Craps.Field12 != 3 {
return fmt.Errorf("craps field_12 must be 2 or 3")
}
- if c.Craps.Variant != "" && c.Craps.Variant != "bank" {
- return fmt.Errorf("only bank craps is implemented")
+ switch c.Craps.variant() {
+ case "bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless":
+ default:
+ return fmt.Errorf("unknown craps variant %q", c.Craps.Variant)
+ }
+ if !c.Craps.allows("odds") && c.Craps.Odds != 0 {
+ return fmt.Errorf("this craps variant does not support odds")
}
return nil
}
func crapsNumber(n int) bool { return n == 4 || n == 5 || n == 6 || n == 8 || n == 9 || n == 10 }
func crapsTrueOdds(n int) (int, int) {
switch n {
+ case 2, 12:
+ return 6, 1
+ case 3, 11:
+ return 3, 1
case 4, 10:
return 2, 1
case 5, 9:
@@ -43,6 +51,9 @@ func (c CrapsConfig) oddsLimit(n int, against bool) int {
if c.Odds > 0 {
return c.Odds
}
+ if c.variant() == "crapless" && !crapsNumber(n) {
+ return 1
+ }
if against {
return 6
}
@@ -57,11 +68,18 @@ func (c CrapsConfig) oddsLimit(n int, against bool) int {
}
func (c TableConfig) CrapsRulesText() string {
r := c.Craps.Normalize()
+ if r.variant() != "bank" && r.variant() != "diceless" {
+ return r.variantRulesText()
+ }
+ prefix := ""
+ if r.variant() == "diceless" {
+ prefix = "Diceless Craps — bank rules using cards, not dice. Two independent six-card packs labelled 1-6; draw one from each, replace and reshuffle BOTH after EVERY roll. All 36 ordered pairs are equally likely. No finite shoe or card counting.\n"
+ }
odds := "3-4-5x (don't: 6x laid)"
if r.Odds > 0 {
odds = fmt.Sprintf("%dx", r.Odds)
}
- return fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+
+ 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"+
@@ -79,7 +97,7 @@ func (c TableConfig) CrapsRulesText() string {
"Bets remain open until the roll. Dice rotate on seven-out.\n"+
"autoroll: repeat every 3 seconds on your turn (multiplayer waits 10 seconds).\n"+
"unbet <target>: remove non-contract bets/odds; pass/come contracts stay.\n"+
- "unbet all: return every optional bet and odds at once.\n"+
+ "unbet all: return every optional bet and odds at once.\n"+
"stop cancels autoroll without leaving; in manual mode stop/sitout wait for bets.\n"+
"Disconnect forfeits live bets.\n"+
"Winning bets return stake and are removed (no automatic pressing).", odds, r.Field12)
@@ -134,14 +152,7 @@ func crapsOutcome(kind string, n, a, b, point, field12 int) (bool, int, int) {
x, y = y, x
}
if kind == "place" {
- switch n {
- case 4, 10:
- x, y = 9, 5
- case 5, 9:
- x, y = 7, 5
- default:
- x, y = 7, 6
- }
+ x, y = crapsPlaceOdds(n)
}
return true, x, y
case "hard":
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
index 5c7b0df..f49b5d0 100644
--- a/internal/casino/craps_test.go
+++ b/internal/casino/craps_test.go
@@ -300,7 +300,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
}
}
func TestCrapsValidation(t *testing.T) {
- for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "crapless"}} {
+ for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "unknown"}} {
if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err == nil {
t.Fatal("accepted", r)
}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
index 41f671a..948ab99 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -5,12 +5,51 @@ import (
"strings"
)
-func (t *crapsTable) commands() []MenuCommand {
- return []MenuCommand{
- {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"},
- {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"},
- {Command: "unbet <target> | unbet all", Desc: "Remove one bet / every optional bet"},
- {Command: "table", Desc: "View the felt"},
+func (t *crapsTable) commands(name string) []MenuCommand {
+ commands := []MenuCommand{}
+ p := t.players.get(name)
+ if p != nil {
+ // Pass-only editions cannot accept another contract while a point or
+ // the player's existing pass bet is live.
+ if t.Config.Craps.allows("come") || t.Config.Craps.allows("buy") || (t.point == 0 && !p.HasBet) {
+ target := "<target>"
+ if !t.Config.Craps.allows("buy") {
+ target = "pass"
+ }
+ commands = append(commands, MenuCommand{Command: "bet <amount> on " + target, Desc: "Place a wager; targets: table rules"})
+ }
+ if p.HasBet {
+ if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) {
+ commands = append(commands, MenuCommand{Command: "roll", Desc: "Shoot now"})
+ }
+ if !p.autoRoll {
+ commands = append(commands, MenuCommand{Command: "autoroll", Desc: "Roll automatically on your turn"})
+ }
+ for _, b := range p.bets {
+ if !crapsContract(b.kind) || b.odds.Total > 0 {
+ commands = append(commands, MenuCommand{Command: "unbet <target> / unbet all", Desc: "Return optional bets or odds"})
+ break
+ }
+ }
+ }
+ stop := "Leave the table"
+ if p.HasBet {
+ stop = "Leave after your live bets settle"
+ }
+ if p.autoRoll {
+ stop = "Cancel autoroll; keep your bets"
+ }
+ commands = append(commands, MenuCommand{Command: "stop", Desc: stop})
+ }
+ return append(commands, MenuCommand{Command: "table", Desc: "View the felt"})
+}
+
+func crapsContract(kind string) bool {
+ switch kind {
+ case "pass", "come", "fire", "small", "tall", "all":
+ return true
+ default:
+ return false
}
}
func crapsBetLabel(b *crapsBet) string {
@@ -62,7 +101,7 @@ func (t *crapsTable) betSummary(p *crapsPlayer) Event {
func (t *crapsTable) reminder(name string) Event {
e := t.private(name, "Your move")
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: "Your move", Commands: t.commands()}
+ e.Menu = &MenuView{Heading: "Your move", Commands: t.commands(name)}
return e
}
func (t *crapsTable) settlementEvent(p *crapsPlayer, lines []settlementLine, failed bool) Event {
diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go
new file mode 100644
index 0000000..b3be51b
--- /dev/null
+++ b/internal/casino/craps_variants.go
@@ -0,0 +1,184 @@
+package casino
+
+import "fmt"
+
+// Variants are explicit house editions; regional games with the same names
+// need not use these rules. Random sources never affect wallet/queue handling.
+func (c CrapsConfig) variant() string {
+ if c.Variant == "" {
+ return "bank"
+ }
+ return c.Variant
+}
+func (c CrapsConfig) title() string {
+ switch c.variant() {
+ case "simplified":
+ return "Simplified Craps"
+ case "high_point":
+ return "High Point Craps"
+ case "crapless":
+ return "Crapless Craps"
+ case "new_york":
+ return "New York Craps"
+ case "die_rich":
+ return "Die Rich Craps"
+ case "diceless":
+ return "Diceless Craps"
+ default:
+ return "Bank Craps"
+ }
+}
+func (c CrapsConfig) allows(kind string) bool {
+ switch c.variant() {
+ case "simplified", "high_point", "die_rich":
+ return kind == "pass"
+ case "new_york":
+ return kind == "pass" || kind == "dontpass" || kind == "buy" || kind == "lay" || kind == "odds"
+ case "crapless":
+ return kind != "dontpass" && kind != "dontcome" && kind != "fire" && kind != "small" && kind != "tall" && kind != "all"
+ default:
+ return true
+ }
+}
+func (c CrapsConfig) pointNumber(n int) bool {
+ switch c.variant() {
+ case "simplified":
+ return false
+ case "high_point":
+ return n >= 4 && n <= 10
+ case "die_rich":
+ return n >= 2 && n <= 5
+ case "crapless":
+ return n >= 2 && n <= 12 && n != 7
+ default:
+ return crapsNumber(n)
+ }
+}
+func (c CrapsConfig) number(n int) bool {
+ return crapsNumber(n) || (c.variant() == "crapless" && n >= 2 && n <= 12 && n != 7)
+}
+func crapsPlaceOdds(n int) (int, int) {
+ switch n {
+ case 2, 12:
+ return 11, 2
+ case 3, 11:
+ return 11, 4
+ case 4, 10:
+ return 9, 5
+ case 5, 9:
+ return 7, 5
+ default:
+ return 7, 6
+ }
+}
+func (c CrapsConfig) outcome(kind string, n, a, b, point int) (bool, int, int) {
+ s := a + b
+ switch c.variant() {
+ case "simplified":
+ if s == 2 || s == 3 || s == 4 || s == 10 || s == 11 || s == 12 {
+ return true, 1, 1
+ }
+ return true, -1, 1
+ case "high_point":
+ if n == 0 {
+ if s == 2 || s == 3 {
+ return false, 0, 1
+ }
+ if s >= 11 {
+ return true, 1, 1
+ }
+ return false, 0, 1
+ }
+ if s > n {
+ return true, 1, 1
+ }
+ return true, -1, 1
+ case "die_rich":
+ if n == 0 {
+ if s == 6 {
+ return true, 1, 1
+ }
+ if s == 1 {
+ return true, -1, 1
+ }
+ return false, 0, 1
+ }
+ if s == n {
+ return true, 1, 1
+ }
+ if s == 1 {
+ return true, -1, 1
+ }
+ return false, 0, 1
+ case "crapless":
+ if (kind == "pass" || kind == "come") && n == 0 {
+ if s == 7 {
+ return true, 1, 1
+ }
+ return false, 0, 1
+ }
+ }
+ return crapsOutcome(kind, n, a, b, point, c.Field12)
+}
+
+// outcomeFor includes bonus progress without mutating it. Both previews and
+// actual settlement call this before recording the new roll.
+func (t *crapsTable) outcomeFor(bet *crapsBet, a, b int) (bool, int, int) {
+ s := a + b
+ switch bet.kind {
+ case "fire":
+ if t.point == 0 || s != 7 {
+ return false, 0, 1
+ }
+ x := crapsFireProfit(len(t.made))
+ if x == 0 {
+ x = -1
+ }
+ return true, x, 1
+ case "small", "tall", "all":
+ if s == 7 {
+ return true, -1, 1
+ }
+ lo, hi := 2, 12
+ if bet.kind == "small" {
+ hi = 6
+ }
+ if bet.kind == "tall" {
+ lo = 8
+ }
+ if !crapsBonusComplete(lo, hi, t.seen, s) {
+ return false, 0, 1
+ }
+ if bet.kind == "all" {
+ return true, 175, 1
+ }
+ return true, 34, 1
+ default:
+ return t.Config.Craps.outcome(bet.kind, bet.number, a, b, t.point)
+ }
+}
+func (c CrapsConfig) variantRulesText() string {
+ rules := ""
+ switch c.variant() {
+ case "simplified":
+ rules = "Pass only, one roll: 2/3/4/10/11/12 win 1:1; 5/6/7/8/9 lose. No point. A loss passes the dice."
+ case "high_point":
+ rules = "Pass only, 1:1. Come-out 2/3 roll again, 11/12 win; 4-10 establish a point. The very next roll wins only if HIGHER than the point; equal/lower loses. Loss passes the dice."
+ 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."
+ 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."
+ }
+ 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)
+ }
+ if c.variant() == "new_york" {
+ rules += "\nPass come-out 7/11 wins, 2/3/12 loses; 4/5/6/8/9/10 establish a point. Make it before 7 to win, all 1:1. Don't pass is opposite, except come-out 12 pushes. Line bets only on come-out. Making a point retains the shooter; seven-out rotates."
+ }
+ return fmt.Sprintf("%s — house rules\n%s\nBet: bet <amount> on pass; table rules is authoritative for this edition.\nFree odds: %d (0 = 3-4-5x on ordinary points, don't 6x laid). No odds on Simplified, High Point or Die Rich.\nEach wager obeys table limits and exact payout units. Wins return stake and remove the bet.\nPass/come contracts cannot be removed; optional bets/odds can: unbet <target> / unbet all.\nSolo roll anytime; multiplayer waits 10 seconds, then dealer rolls after 60 seconds.\nBets stay open until the roll. autoroll repeats every 3 seconds, respecting the betting window.\nstop cancels autoroll; manual stop/sitout waits for live bets. Disconnect forfeits; shutdown refunds.", c.title(), rules, c.Odds)
+}
diff --git a/internal/casino/craps_variants_test.go b/internal/casino/craps_variants_test.go
new file mode 100644
index 0000000..687a496
--- /dev/null
+++ b/internal/casino/craps_variants_test.go
@@ -0,0 +1,308 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+var crapsVariants = []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"}
+
+func variantFixture(v string) *crapsTable {
+ t := crapsFixture()
+ t.Config.Craps.Variant = v
+ return t
+}
+func TestCrapsVariantValidationAndMenus(t *testing.T) {
+ for _, v := range crapsVariants {
+ tb := variantFixture(v)
+ if err := tb.Config.Validate(); err != nil {
+ t.Fatal(v, err)
+ }
+ if !strings.Contains(tb.Config.CrapsRulesText(), tb.Config.Craps.title()) {
+ t.Fatal(v, "missing title")
+ }
+ if tb.view().Variant != v {
+ t.Fatal(v, "snapshot")
+ }
+ }
+ for _, v := range []string{"simplified", "high_point", "die_rich"} {
+ tb := variantFixture(v)
+ tb.Config.Craps.Odds = 2
+ if tb.Config.Validate() == nil {
+ t.Fatal(v, "accepted unsupported odds")
+ }
+ }
+}
+func TestVariantAllOutcomes(t *testing.T) {
+ for _, v := range crapsVariants {
+ c := CrapsConfig{Variant: v, Field12: 2}
+ for n := 0; n <= 12; n++ {
+ if n != 0 && !c.pointNumber(n) {
+ continue
+ }
+ for a := 1; a <= 6; a++ {
+ for b := 0; b <= 6; b++ {
+ if (v == "die_rich") != (b == 0) {
+ continue
+ }
+ s := a + b
+ done, profit := false, 0
+ switch v {
+ case "simplified":
+ done = true
+ profit = -1
+ if s <= 4 || s >= 10 {
+ profit = 1
+ }
+ case "high_point":
+ if n == 0 {
+ done = s >= 11
+ profit = 1
+ } else {
+ done = true
+ profit = -1
+ if s > n {
+ profit = 1
+ }
+ }
+ case "die_rich":
+ done = s == 1 || (n == 0 && s == 6) || (n != 0 && s == n)
+ profit = 1
+ if s == 1 {
+ profit = -1
+ }
+ case "crapless":
+ if n == 0 {
+ done = s == 7
+ profit = 1
+ } else {
+ done = s == 7 || s == n
+ profit = 1
+ if s == 7 {
+ profit = -1
+ }
+ }
+ default:
+ if n == 0 {
+ done = s == 2 || s == 3 || s == 7 || s == 11 || s == 12
+ profit = -1
+ if s == 7 || s == 11 {
+ profit = 1
+ }
+ } else {
+ done = s == n || s == 7
+ profit = 1
+ if s == 7 {
+ profit = -1
+ }
+ }
+ }
+ got, x, y := c.outcome("pass", n, a, b, n)
+ if got != done || (done && (x != profit || y != 1)) {
+ t.Fatalf("%s point %d roll %d,%d: %v %d/%d want %v %d", v, n, a, b, got, x, y, done, profit)
+ }
+ }
+ }
+ }
+ }
+}
+func TestVariantBetRestrictionsNoDebit(t *testing.T) {
+ for _, v := range crapsVariants {
+ for _, spot := range []string{"pass", "dontpass", "come", "dontcome", "field", "place 6", "buy 4", "lay 10", "hard 8", "hop 2 3", "seven", "craps", "eleven", "ace-deuce", "aces", "boxcars", "horn", "fire", "small", "tall", "all", "place 2", "buy 11", "lay 12"} {
+ tb := variantFixture(v)
+ tb.join("a")
+ w := &testWallet{chips: 10000}
+ kind, n, _ := parseCrapsTarget(spot)
+ if kind == "come" || kind == "dontcome" {
+ tb.point = 6
+ }
+ allowed := tb.Config.Craps.allows(kind)
+ if kind == "place" || kind == "buy" || kind == "lay" {
+ allowed = allowed && tb.Config.Craps.number(n)
+ }
+ tb.bet("a", spot, 120, w)
+ if (len(w.open) > 0) != allowed {
+ t.Fatal(v, spot, "acceptance", allowed)
+ }
+ if !allowed && w.chips != 10000 {
+ t.Fatal("rejected wager debited")
+ }
+ }
+ }
+}
+func TestCraplessExtraPointOddsPayouts(t *testing.T) {
+ for _, n := range []int{2, 3, 11, 12} {
+ t.Run(fmt.Sprint(n), func(t *testing.T) {
+ tb := variantFixture("crapless")
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ a, b := 1, n-1
+ if n > 7 {
+ a, b = n-6, 6
+ }
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(a, b)
+ if tb.point != n || tb.players.get("a").bets[0].number != n {
+ t.Fatal("point not established")
+ }
+ tb.bet("a", "odds pass", 11, w)
+ if len(w.open) != 1 {
+ t.Fatal("extra point default odds limit")
+ }
+ tb.bet("a", "odds pass", 10, w)
+ projection := tb.projectionFor(tb.players.get("a"))
+ x, _ := crapsTrueOdds(n)
+ want := 20 + 10*(x+1)
+ for _, r := range projection {
+ if r.Total == n && r.Net != want-20 {
+ t.Fatal("preview", r)
+ }
+ }
+ tb.resolveRoll(a, b)
+ if w.paid != want || len(w.open) != 0 || tb.point != 0 {
+ t.Fatal(w.paid, want, tb.point)
+ }
+ })
+ }
+ for _, c := range []struct {
+ spot string
+ amount, a, b, want int
+ }{
+ {"place 2", 2, 1, 1, 13}, {"place 3", 4, 1, 2, 15}, {"place 11", 4, 5, 6, 15}, {"place 12", 2, 6, 6, 13},
+ {"buy 2", 20, 1, 1, 139}, {"buy 11", 20, 5, 6, 79}, {"lay 12", 120, 3, 4, 139}, {"lay 3", 60, 3, 4, 79},
+ } {
+ tb := variantFixture("crapless")
+ tb.point = 6
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ tb.bet("a", c.spot, c.amount, w)
+ tb.resolveRoll(c.a, c.b)
+ if w.paid != c.want {
+ t.Fatal(c.spot, w.paid, c.want)
+ }
+ }
+}
+func TestVariantShooterLifecycleAndWallets(t *testing.T) {
+ for _, v := range crapsVariants {
+ t.Run(v, func(t *testing.T) {
+ tb := variantFixture(v)
+ tb.join("a")
+ tb.join("b")
+ w := &testWallet{chips: 1000}
+ tb.bet("a", "pass", 10, w)
+ a, b := 2, 2
+ if v == "die_rich" {
+ a, b = 4, 0
+ }
+ if v == "simplified" {
+ a, b = 2, 3
+ }
+ tb.resolveRoll(a, b)
+ if v != "simplified" {
+ if tb.point != 4 {
+ t.Fatal("point", tb.point)
+ }
+ a, b = 3, 4
+ if v == "high_point" {
+ a, b = 1, 2
+ }
+ if v == "die_rich" {
+ a, b = 1, 0
+ }
+ tb.resolveRoll(a, b)
+ }
+ if tb.point != 0 || tb.queue[0] != "b" || len(w.open) != 0 || w.chips != 990 {
+ t.Fatal("loss lifecycle", tb.point, tb.queue, w)
+ }
+ tb.bet("b", "pass", 10, w)
+ tb.refundAll()
+ tb.refundAll()
+ if w.chips != 990 || len(w.open) != 0 {
+ t.Fatal("shutdown refund")
+ }
+ tb.bet("b", "pass", 10, w)
+ tb.markDisconnected("b")
+ if w.chips != 980 || len(w.open) != 0 {
+ t.Fatal("disconnect")
+ }
+ })
+ }
+}
+func TestHighPointAndDieRichPointWin(t *testing.T) {
+ for _, v := range []string{"high_point", "die_rich"} {
+ tb := variantFixture(v)
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ if v == "high_point" {
+ tb.resolveRoll(2, 2)
+ tb.resolveRoll(2, 3)
+ } else {
+ tb.resolveRoll(4, 0)
+ tb.resolveRoll(6, 0)
+ if tb.point != 4 {
+ t.Fatal("six must not win established point")
+ }
+ tb.resolveRoll(4, 0)
+ }
+ if w.paid != 20 || tb.point != 0 || tb.queue[0] != "a" {
+ t.Fatal(v, w.paid, tb.point)
+ }
+ }
+}
+func TestCrapsBonusPreviewMatchesSettlement(t *testing.T) {
+ for _, point := range []int{0, 6} {
+ for a := 1; a <= 6; a++ {
+ for b := a; b <= 6; b++ {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 1000}
+ for _, spot := range []string{"fire", "small", "tall", "all"} {
+ tb.bet("a", spot, 1, w)
+ }
+ tb.point = point
+ tb.made = map[int]bool{4: true, 5: true, 6: true, 8: true}
+ tb.seen = map[int]bool{2: true, 3: true, 4: true, 5: true, 8: true, 9: true, 10: true, 11: true, 12: true}
+ // Four made points must NOT advertise Fire profit except at seven-out.
+ for _, row := range tb.projectionFor(tb.players.get("a")) {
+ if row.Rolls != fmt.Sprintf("%d+%d", a, b) {
+ continue
+ }
+ tb.resolveRoll(a, b)
+ // Net = wallet change excluding returned unresolved commitments.
+ live := 0
+ for _, bet := range tb.players.get("a").bets {
+ live += bet.stake.Total
+ }
+ actual := w.paid - (4 - live)
+ if row.Net != actual {
+ t.Fatalf("point %d roll %d+%d: preview %d actual %d", point, a, b, row.Net, actual)
+ }
+ break
+ }
+ }
+ }
+ }
+}
+func TestVariantRandomSources(t *testing.T) {
+ for _, v := range []string{"diceless", "die_rich"} {
+ tb := variantFixture(v)
+ seen := map[[2]int]bool{}
+ for i := 0; i < 2000; i++ {
+ tb.roll()
+ d := tb.dice
+ seen[d] = true
+ if d[0] < 1 || d[0] > 6 || (v == "die_rich" && d[1] != 0) || (v == "diceless" && (d[1] < 1 || d[1] > 6)) {
+ t.Fatal(v, d)
+ }
+ }
+ want := 36
+ if v == "die_rich" {
+ want = 6
+ }
+ if len(seen) != want {
+ t.Fatal(v, "source support", len(seen))
+ }
+ }
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index f8730be..36576d0 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -499,19 +499,25 @@ func formatWins(wins []SlotWin, ways int) string {
return message[:len(message)-1]
}
-var slotCommands = []MenuCommand{
- {Command: "bet <amount>", Desc: "Set your wager"},
- {Command: "bet max", Desc: "Set the maximum wager"},
- {Command: "spin", Desc: "Spin using your current wager"},
- {Command: "autospin", Desc: "Start automatic betting"},
- {Command: "stop", Desc: "Leave the slot machine"},
+func (m *Machine) commands() []MenuCommand {
+ if m.autoBet {
+ return []MenuCommand{{Command: "stop", Desc: "Stop automatic betting"}}
+ }
+ commands := []MenuCommand{
+ {Command: "bet <amount>", Desc: "Set your wager and spin"},
+ {Command: "bet max", Desc: "Bet the maximum and spin"},
+ }
+ if m.bet > 0 {
+ commands = append(commands, MenuCommand{Command: "spin", Desc: "Spin using your current wager", Cost: m.bet}, MenuCommand{Command: "autospin", Desc: "Start automatic betting", Cost: m.bet})
+ }
+ return append(commands, MenuCommand{Command: "stop", Desc: "Leave the slot machine"})
}
func (m *Machine) privateMenu(prefix string) []Event {
events := m.private(prefix)
events[0].Color = "casino_menu"
if m.phase == machineReady {
- events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: slotCommands}
+ events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: m.commands()}
}
return events
}
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index b4dcdf0..2453fed 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -490,11 +490,19 @@ func (t *rouletteTable) view() *RouletteSnapshot {
return s
}
func (t *rouletteTable) menuEvent(name string) Event {
- e := t.betMenuEvent(name, []MenuCommand{
- {Command: "bet <amount> on <target>", Desc: "Place one wager per spin (help roulette)"},
- {Command: "bet / rebet", Desc: "Repeat your previous wager"},
- {Command: "table / sitout / stop", Desc: "View layout / sit out / leave after this spin"},
- })
+ 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: help roulette"})
+ if p.lastBet > 0 {
+ commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"})
+ }
+ }
+ if p != nil {
+ commands = append(commands, MenuCommand{Command: "sitout / stop", Desc: "Sit out / leave when your wager settles"})
+ }
+ commands = append(commands, MenuCommand{Command: "table", Desc: "View the layout"})
+ e := t.betMenuEvent(name, commands)
e.Snapshot = t.view()
return e
}
@@ -701,8 +709,10 @@ func (t *rouletteTable) status(name string) []Event {
if text := t.seatStatusLocked(name); text != "" {
message += "\n" + text
}
- e := t.private(name, message)
- e.Snapshot = t.view()
+ e := t.menuEvent(name)
+ e.Message = message
+ e.Menu.Heading = message
+ e.Menu.ShowBalance = false
events := []Event{e}
if t.phase == phaseWaiting && t.lastResult != nil {
last := t.private(name, "Last spin:")
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 9590baf..f4255be 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -79,6 +79,18 @@ func (c *tableCore[P]) warning(name, text string) Event {
return e
}
+// committedMenu never advertises another bet over an already committed wager.
+func (c *tableCore[P]) committedMenu(name string) Event {
+ e := c.private(name, "Your wager is committed; waiting for the round.")
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Heading: e.Message, Commands: []MenuCommand{
+ {Command: "table", Desc: "View the table"},
+ {Command: "sitout", Desc: "Sit out after this round"},
+ {Command: "stop", Desc: "Leave after this round"},
+ }}
+ return e
+}
+
// controlLocked queues departures without changing committed hands or stakes.
func (c *tableCore[P]) controlLocked(name, action string) ([]Event, bool) {
if action != "leave_after" && action != "sitout" {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index e9334fc..c2c4c4f 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -695,18 +695,8 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
commands[i].Desc += " [unavailable: insufficient funds]"
}
}
- if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat {
- var repeat string
- for _, c := range commands {
- if c.Command == "bet" {
- repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc
- break
- }
- }
- return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop")
- }
if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance {
- var hints []string
+ var hints []casino.MenuCommand
for _, c := range commands {
label := c.Command
switch c.Command {
@@ -722,14 +712,9 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
if c.Cost > 0 {
label += fmt.Sprintf(" (+%d)", c.Cost)
}
- category := "command"
- if c.Cost > casinoAffordable(sess.Player) {
- label += " [unavailable]"
- category = "casino_warning"
- }
- hints = append(hints, g.colorize(sess, category, label))
+ hints = append(hints, casino.MenuCommand{Command: label, Cost: c.Cost})
}
- return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ")
+ return g.renderCasinoCommands(sess, balance, heading, hints)
}
return g.renderCasinoCommands(sess, balance, heading, commands)
}
@@ -796,27 +781,7 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri
// casinoMenuCommands formats an aligned, colored command list.
func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string {
- width := 0
- for _, c := range commands {
- if n := utf8.RuneCountInString(c.Command); n > width {
- width = n
- }
- }
- var sb strings.Builder
- for _, c := range commands {
- padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command))
- category := "command"
- if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) {
- category = "casino_warning"
- }
- cmd := g.colorize(sess, category, padded)
- if c.Desc != "" {
- fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc)
- } else {
- fmt.Fprintf(&sb, " %s\n", cmd)
- }
- }
- return strings.TrimRight(sb.String(), "\n")
+ return g.casinoCommandColumns(sess, commands)
}
func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go
new file mode 100644
index 0000000..f4ca79f
--- /dev/null
+++ b/internal/game/casino_commands.go
@@ -0,0 +1,83 @@
+package game
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+// Wrap before Session.WriteLine: generic prose wrapping strips alignment from
+// overlong lines. Every casino menu uses the same two-space command indent and
+// hanging description column, capped at 80 even when prose wrapping is off.
+func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCommand) string {
+ limit := 80
+ if sess.Player != nil {
+ if wrap := sess.Player.OptionInt("wrap_width"); wrap > 0 {
+ limit = min(limit, wrap)
+ }
+ }
+ width := 0
+ for _, c := range commands {
+ n := utf8.RuneCountInString(c.Command)
+ // Unusually long commands get their own lines instead of squeezing every
+ // other command's description into a tiny column.
+ if n <= limit/2 {
+ width = max(width, n)
+ }
+ }
+ var lines []string
+ for _, c := range commands {
+ if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) {
+ continue
+ }
+ n := utf8.RuneCountInString(c.Command)
+ if n > limit/2 {
+ for _, line := range casinoWrapWords(c.Command, max(1, limit-2)) {
+ lines = append(lines, " "+g.colorize(sess, "command", line))
+ }
+ for _, line := range casinoWrapWords(c.Desc, max(1, limit-4)) {
+ lines = append(lines, " "+line)
+ }
+ continue
+ }
+ if strings.TrimSpace(c.Desc) == "" {
+ lines = append(lines, " "+g.colorize(sess, "command", c.Command))
+ continue
+ }
+ indent := width + 4
+ desc := casinoWrapWords(c.Desc, max(1, limit-indent))
+ cmd := c.Command + strings.Repeat(" ", width-n)
+ lines = append(lines, " "+g.colorize(sess, "command", cmd)+" "+desc[0])
+ for _, line := range desc[1:] {
+ lines = append(lines, strings.Repeat(" ", indent)+line)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// Input is uncolored command/description text; paint only after measuring.
+func casinoWrapWords(text string, width int) []string {
+ var lines []string
+ line := ""
+ for _, word := range strings.Fields(text) {
+ if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) > width {
+ lines = append(lines, line)
+ line = ""
+ }
+ runes := []rune(word)
+ for len(runes) > width {
+ lines = append(lines, string(runes[:width]))
+ runes = runes[width:]
+ }
+ if line != "" {
+ line += " "
+ }
+ line += string(runes)
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ return lines
+}
diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go
new file mode 100644
index 0000000..7820650
--- /dev/null
+++ b/internal/game/casino_commands_test.go
@@ -0,0 +1,118 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) {
+ commands := []casino.MenuCommand{
+ {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"},
+ {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"},
+ {Command: "unbet <target> / unbet all", Desc: "Remove one bet / every optional bet"},
+ {Command: "table", Desc: "View the felt"},
+ {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"},
+ {Command: "decline", Desc: "Continue without insurance (also the timeout default)"},
+ {Command: "bet <amount> on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"},
+ {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"},
+ {Command: "spin", Desc: "Spin using your current wager"},
+ {Command: "autospin", Desc: "Start automatic betting"},
+ {Command: "unicode", Desc: strings.Repeat("é", 100)},
+ }
+ for _, wrap := range []int{0, 40, 60, 80, 120} {
+ p := player.New("a")
+ p.Options = map[string]any{"wrap_width": wrap}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{}
+ text := g.casinoMenuCommands(sess, commands)
+ sess.WriteLine(text)
+ limit := 80
+ if wrap > 0 {
+ limit = min(limit, wrap)
+ }
+ for _, line := range strings.Split(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") {
+ if line == "" {
+ continue
+ }
+ if color.VisibleLen(line) > limit {
+ t.Fatalf("wrap %d overlong %d: %q", wrap, color.VisibleLen(line), line)
+ }
+ if !strings.HasPrefix(line, " ") {
+ t.Fatalf("wrap %d lost indent: %q", wrap, line)
+ }
+ }
+ expected := strings.ReplaceAll(plainRoulette(text), "\r\n", "\n")
+ actual := strings.TrimRight(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n")
+ if expected != actual {
+ t.Fatalf("session reflowed prewrapped menu at %d", wrap)
+ }
+ }
+}
+func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) {
+ sess := &net.Session{Player: player.New("a")}
+ g := &Game{}
+ commands := []casino.MenuCommand{
+ {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: table rules"},
+ {Command: "table", Desc: "View the felt"},
+ }
+ text := plainRoulette(g.casinoMenuCommands(sess, commands))
+ lines := strings.Split(text, "\n")
+ if len(lines) != 2 {
+ t.Fatal("short commands should not wrap", text)
+ }
+ col := strings.Index(lines[0], "Place")
+ if col != strings.Index(lines[1], "View") {
+ t.Fatal("descriptions not aligned", text)
+ }
+ commands[0].Desc = strings.Repeat("long description ", 10)
+ text = plainRoulette(g.casinoMenuCommands(sess, commands))
+ lines = strings.Split(text, "\n")
+ for _, line := range lines[1 : len(lines)-1] {
+ if !strings.HasPrefix(line, strings.Repeat(" ", col)) {
+ t.Fatal("missing hanging indent", text)
+ }
+ }
+}
+func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) {
+ p := player.New("a")
+ p.Options = map[string]any{"casino_display": "compact"}
+ p.Credits = 10
+ sess := &net.Session{Player: p}
+ g := &Game{}
+ menu := &casino.MenuView{Heading: "Your turn", Commands: []casino.MenuCommand{
+ {Command: "hit", Desc: "Take a card"}, {Command: "stand", Desc: "Hold"}, {Command: "double", Cost: 20},
+ }}
+ text := plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, ""))
+ if strings.Contains(text, "double") || !strings.Contains(text, " h:hit") || !strings.Contains(text, " st:stand") {
+ t.Fatal(text)
+ }
+ menu.ShowBalance = true
+ menu.Repeat = true
+ menu.Commands = []casino.MenuCommand{{Command: "table", Desc: "View the felt"}}
+ text = plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, ""))
+ if strings.Contains(text, "bet") || strings.Contains(text, "sitout") {
+ t.Fatal("compact mode invented actions", text)
+ }
+}
+func TestSimplifiedFeltSpacingAndNoIdlePrompt(t *testing.T) {
+ text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: "simplified"}, "none", true)
+ if strings.Contains(text, "Place a bet, then roll when ready.") {
+ t.Fatal(text)
+ }
+ rows := strings.Split(text, "\n")
+ for i, row := range rows {
+ if strings.Contains(row, "PASS ONLY") {
+ if i+2 >= len(rows) || strings.Trim(rows[i+1], "│ ") != "" || !strings.HasPrefix(rows[i+2], "└") {
+ t.Fatal("expected exactly one blank felt row", text)
+ }
+ return
+ }
+ }
+ t.Fatal("missing pass label")
+}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 450480f..a2e7330 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -129,20 +129,14 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
}
}
if len(args) > 0 {
+ // Reuse the game's current-state menu instead of advertising actions
+ // (insurance, split, rebet, etc.) that may not be legal right now.
+ g.emitCasinoEvents(t.Status(sess.Player.Name))
commands := []casino.MenuCommand{
- {Command: "bet / rebet", Desc: "Repeat your previous wager"},
- {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"},
- {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"},
- {Command: "stop", Desc: "Leave now, or after your committed round"},
{Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"},
{Command: "table rules", Desc: "Review this table’s rules"},
{Command: "help casino", Desc: "Quick guides to all casino games"},
}
- if t.Config.Game == casino.GameBlackjack {
- commands = append(commands,
- casino.MenuCommand{Command: "h / st / dd / sp", Desc: "Hit / stand / double / split (s and d still move)"},
- casino.MenuCommand{Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"})
- }
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands))
}
}
diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go
new file mode 100644
index 0000000..76d1424
--- /dev/null
+++ b/internal/game/craps_variants_test.go
@@ -0,0 +1,103 @@
+package game
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/world"
+)
+
+func TestCrapsVariantRendering(t *testing.T) {
+ for _, v := range []string{"simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} {
+ for _, unicode := range []bool{false, true} {
+ for _, mode := range []string{"none", "xterm256"} {
+ for _, style := range []string{"dark", "light"} {
+ dice := [2]int{4, 2}
+ if v == "die_rich" {
+ dice[1] = 0
+ }
+ text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: v, Point: 4, Dice: dice}, mode, unicode, style)
+ plain := plainRoulette(text)
+ if !strings.Contains(plain, strings.ToUpper(strings.ReplaceAll(v, "_", " "))+" CRAPS") {
+ t.Fatal(v, "title")
+ }
+ if v == "diceless" {
+ for _, token := range []string{"PACK A", "PACK B", "Cards total: 6", "FIRE"} {
+ if !strings.Contains(plain, token) {
+ t.Fatal(v, token)
+ }
+ }
+ if strings.Contains(plain, "Total:") {
+ t.Fatal("dice rendered instead of cards")
+ }
+ } else {
+ if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") {
+ t.Fatal(v, "unsupported bonus display")
+ }
+ }
+ if v == "die_rich" && !strings.Contains(plain, "Total: 4") {
+ t.Fatal("single die absent")
+ }
+ if mode == "xterm256" && (v == "die_rich" || v == "diceless") {
+ bg := 0
+ if style == "light" {
+ bg = 15
+ }
+ if !strings.Contains(text, color.BgCode(mode, bg)) {
+ t.Fatal(v, "style")
+ }
+ }
+ }
+ }
+ }
+ }
+ for n := 1; n <= 6; n++ {
+ text := renderCrapsDice([2]int{n, 0}, "none", false, "dark")
+ if strings.Count(text, "+-------+") != 2 {
+ t.Fatal("must render exactly one die", text)
+ }
+ }
+}
+
+func TestShippedCrapsGallery(t *testing.T) {
+ w := world.New(filepath.Join("..", "..", "data"))
+ variants := []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"}
+ for i, v := range variants {
+ id := 6185 + i
+ room, err := w.LoadRoom(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(room.CasinoTables) != 1 {
+ t.Fatal(id, "missing table")
+ }
+ cfg := room.CasinoTables[0]
+ if cfg.Game != casino.GameCraps || cfg.Craps.Variant != v {
+ t.Fatal(id, cfg)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(id, err)
+ }
+ if i > 0 && room.Exits["south"].Room != id-1 {
+ t.Fatal(id, "missing return exit")
+ }
+ if i < len(variants)-1 && room.Exits["north"].Room != id+1 {
+ t.Fatal(id, "unreachable next table")
+ }
+ m := casino.NewManager(1)
+ m.Join(id, cfg, "a")
+ if m.Table(id, cfg.ID) == nil {
+ t.Fatal(id, "table registration")
+ }
+ }
+ room, err := w.LoadRoom(6182)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if room.Exits["west"].Room != 6185 {
+ t.Fatal("gallery unreachable from roulette")
+ }
+}
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index 9f73b20..2270659 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -264,7 +264,7 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
return c.render(mode, unicode)
}
func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string {
- if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 {
+ if dice[0] < 1 || dice[0] > 6 || dice[1] < 0 || dice[1] > 6 {
return ""
}
patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}}
@@ -280,6 +280,9 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) strin
for row := 0; row < 5; row++ {
out.WriteString(" ")
for i, n := range dice {
+ if n == 0 {
+ continue
+ }
line := top
if row == 4 {
line = bottom
@@ -315,21 +318,51 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
if s.Point != 0 {
point = fmt.Sprintf("POINT %d", s.Point)
}
- out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n")
- out.WriteString(renderCrapsTrackers(s, mode, unicode))
- out.WriteByte('\n')
- out.WriteString(renderCrapsFelt(s, mode, unicode))
+ variant := s.Variant
+ if variant == "" {
+ variant = "bank"
+ }
+ out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS") + subtle(" / "+point) + "\n")
+ if variant == "bank" || variant == "diceless" {
+ out.WriteString(renderCrapsTrackers(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsFelt(s, mode, unicode))
+ } else {
+ height := 7
+ if variant == "simplified" || variant == "high_point" || variant == "die_rich" {
+ height = 6
+ }
+ c := newCrapsCanvas(77, height)
+ c.box(0, 0, 76, height-1)
+ c.center(1, 75, 1, "HOUSE EDITION — table rules for payouts", crapsGold)
+ switch variant {
+ case "crapless":
+ c.center(1, 75, 3, "POINTS: 2 3 4 5 6 8 9 10 11 12 | PASS / COME / ODDS", crapsInk)
+ c.center(1, 75, 4, "PLACE / BUY / LAY / FIELD / HARD / HOP / PROPOSITIONS", crapsInk)
+ c.center(1, 75, 5, "NO DON'T BETS OR BONUSES", crapsMuted)
+ case "new_york":
+ c.center(1, 75, 3, "PASS / DON'T PASS (BAR 12) / ODDS / BUY / LAY", crapsInk)
+ c.center(1, 75, 4, "BUY / LAY: 4 5 6 8 9 10 | 5% WIN-ONLY COMMISSION", crapsMuted)
+ default:
+ c.center(1, 75, 3, "PASS ONLY | WINS PAY 1:1 | NO ODDS OR BONUSES", crapsInk)
+ }
+ out.WriteString(c.render(mode, unicode))
+ }
out.WriteByte('\n')
- out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ if variant == "diceless" {
+ out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style))
+ } else {
+ out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ }
if len(s.Queue) > 0 {
out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n")
if len(s.Queue) > 1 {
out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n")
}
}
- hint := "Place a bet, then roll when ready."
+ hint := ""
if s.Phase != "waiting" && s.Phase != "" {
- hint = "Your pace. Roll when ready."
+ hint = ""
if s.ShootIn > 0 {
hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn)
} else if s.DealerIn > 0 {
@@ -337,8 +370,10 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
}
}
if s.AutoRoll {
- hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn)
+ hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn)
+ }
+ if hint != "" {
+ out.WriteString(subtle(" "+hint) + "\n")
}
- out.WriteString(subtle(" "+hint) + "\n")
return out.String()
}
diff --git a/internal/game/render_craps_cards.go b/internal/game/render_craps_cards.go
new file mode 100644
index 0000000..343f16f
--- /dev/null
+++ b/internal/game/render_craps_cards.go
@@ -0,0 +1,44 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "thehouseoficarus/internal/color"
+)
+
+// The two packs have ranks 1-6, no suits. Distinct pack labels make the
+// replacement model visible without suggesting a conventional poker shoe.
+func renderCrapsCards(cards [2]int, mode string, unicode bool, style string) string {
+ if cards[0] < 1 || cards[0] > 6 || cards[1] < 1 || cards[1] > 6 {
+ return ""
+ }
+ top, bottom, bar := "+-------+", "+-------+", "|"
+ if unicode {
+ top, bottom, bar = "╭───────╮", "╰───────╯", "│"
+ }
+ spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true}
+ if style == "light" {
+ spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ }
+ var out strings.Builder
+ for row := 0; row < 5; row++ {
+ out.WriteString(" ")
+ for i, n := range cards {
+ line := top
+ switch row {
+ case 1:
+ line = fmt.Sprintf("%s PACK %c%s", bar, 'A'+i, bar)
+ case 2:
+ line = fmt.Sprintf("%s %d %s", bar, n, bar)
+ case 3:
+ line = bar + " " + bar
+ case 4:
+ line = bottom
+ }
+ out.WriteString(color.Render(mode, spec, line) + " ")
+ }
+ out.WriteByte('\n')
+ }
+ fmt.Fprintf(&out, " Cards total: %d | Both packs replaced and reshuffled each draw.\n", cards[0]+cards[1])
+ return out.String()
+}