aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/craps.go24
-rw-r--r--internal/casino/craps_clock.go23
-rw-r--r--internal/casino/craps_clock_test.go24
-rw-r--r--internal/casino/craps_projection.go64
-rw-r--r--internal/casino/craps_test.go5
-rw-r--r--internal/casino/craps_ux.go45
-rw-r--r--internal/casino/craps_wagers.go124
-rw-r--r--internal/game/casino_look_test.go47
-rw-r--r--internal/game/casino_rules_list_test.go8
-rw-r--r--internal/game/cmd_look.go6
-rw-r--r--internal/game/craps_test.go2
-rw-r--r--internal/game/craps_variants_test.go12
-rw-r--r--internal/game/look_room.go6
-rw-r--r--internal/game/render_craps.go238
-rw-r--r--internal/game/render_craps_test.go19
15 files changed, 428 insertions, 219 deletions
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 8322192..3bbb08f 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -37,7 +37,8 @@ type crapsTable struct {
roundElapsed time.Duration
multiplayer bool
lastCall bool
- countdown int // simplified craps: automatic-roll countdown lines emitted
+ pendingRoll time.Duration // manual roll: time left before the dice resolve
+ countdown int // simplified craps: automatic-roll countdown lines emitted
}
type CrapsBetView struct {
Name, Target string
@@ -111,6 +112,9 @@ func (t *crapsTable) board(_ string) Snapshot {
}
func (t *crapsTable) menu(name string) Event {
e := t.betMenuEvent(name, t.commands(name))
+ if e.Menu != nil {
+ e.Menu.Heading = t.shooterHeading()
+ }
e.Snapshot = t.view()
return e
}
@@ -130,6 +134,10 @@ func (t *crapsTable) join(name string) []Event {
}
func (t *crapsTable) remove(name string) {
t.countdown = 0
+ if len(t.queue) > 0 && t.queue[0] == name {
+ // The queued roll belongs to the shooter; a departing shooter cancels it.
+ t.pendingRoll = 0
+ }
t.removeSeatLocked(name)
for i, n := range t.queue {
if n == name {
@@ -457,6 +465,12 @@ func (t *crapsTable) action(name, action string) []Event {
if p == nil {
return []Event{t.private(name, "You aren't playing craps.")}
}
+ if action == "leave_after" {
+ p.LeaveAfter = true
+ events := []Event{t.private(name, "You’ll leave after this round. Your live bets still need to settle.")}
+ events = append(events, t.finishCrapsControls()...)
+ return append(events, t.syncRoundClock()...)
+ }
if events, ok := t.controlLocked(name, action); ok {
events = append(events, t.finishCrapsControls()...)
return append(events, t.syncRoundClock()...)
@@ -485,7 +499,12 @@ func (t *crapsTable) action(name, action string) []Event {
if t.multiplayer && t.roundElapsed < crapsBetDelay {
return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d seconds{/}. Bets remain open until you roll.", crapsSeconds(crapsBetDelay-t.roundElapsed))))
}
- return append(events, t.roll()...)
+ if t.pendingRoll > 0 {
+ return append(events, t.private(name, "The dice are already in the air."))
+ }
+ t.pendingRoll = crapsManualRollDelay
+ shake := t.playerAction(name, fmt.Sprintf("{casino_emphasis}%s shakes the dice...{/}", name), "{casino_emphasis}You shake the dice...{/}")
+ return append(events, shake)
}
if action == "unbet" {
return append(t.unbetAllLocked(p), t.finishCrapsControls()...)
@@ -593,6 +612,7 @@ func (t *crapsTable) refundAll() []Event {
t.point = 0
t.rolled = false
t.countdown = 0
+ t.pendingRoll = 0
t.made = map[int]bool{}
t.seen = map[int]bool{}
t.phase = phaseWaiting
diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go
index bbba19e..ab28329 100644
--- a/internal/casino/craps_clock.go
+++ b/internal/casino/craps_clock.go
@@ -13,6 +13,10 @@ const (
// Simplified craps has no manual roll: one shared automatic countdown
// starts with the first live bet and resolves every wager.
crapsSimplifiedDelay = 3 * time.Second
+
+ // A requested manual roll first shows the shake, then the outcome chart,
+ // then resolves the dice five ticks (three seconds) later.
+ crapsManualRollDelay = 3 * time.Second
)
func crapsSeconds(d time.Duration) int {
@@ -118,7 +122,24 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event {
}
return events
}
- if elapsed <= 0 || !t.hasLiveBets() {
+ if elapsed <= 0 {
+ return events
+ }
+ if t.pendingRoll > 0 {
+ // A manual roll is in the air: no other deadline may fire first. Only
+ // the shake is announced now; the result follows when the dice land.
+ if !t.hasLiveBets() {
+ t.pendingRoll = 0
+ return events
+ }
+ t.pendingRoll -= elapsed
+ if t.pendingRoll <= 0 {
+ t.pendingRoll = 0
+ return append(events, t.roll()...)
+ }
+ return events
+ }
+ if !t.hasLiveBets() {
return events
}
t.roundElapsed += elapsed
diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go
index 8f606f9..f9ad445 100644
--- a/internal/casino/craps_clock_test.go
+++ b/internal/casino/craps_clock_test.go
@@ -71,6 +71,7 @@ func TestCrapsSoloAndRosterTransitions(t *testing.T) {
t.Fatal("solo deadline", tb.view())
}
tb.action("alice", "roll")
+ tb.tickElapsed(3 * time.Second)
if tb.dice == [2]int{} {
t.Fatal("solo could not roll")
}
@@ -196,7 +197,7 @@ func TestCrapsConsolidatedResultsAndBetNumbers(t *testing.T) {
tb.join("alice")
w := &testWallet{chips: 1000}
events := tb.bet("alice", "pass", 10, w)
- if !strings.Contains(crapsEventText(events), "ROLL RESULTS ON YOUR BETS") {
+ if !strings.Contains(crapsEventText(events), "ROLL OUTCOMES") {
t.Fatal("no post-bet roll table")
}
tb.bet("alice", "dontpass", 10, w)
@@ -204,7 +205,7 @@ func TestCrapsConsolidatedResultsAndBetNumbers(t *testing.T) {
events = tb.resolveRoll(3, 4)
balances, settlements := 0, 0
for _, e := range events {
- if e.ShowBalance {
+ if e.ShowBalance || (e.Menu != nil && e.Menu.ShowBalance) {
balances++
}
if strings.Contains(e.Message, "Pass [5]:") || strings.Contains(e.Message, "Pass [5]") {
@@ -258,18 +259,18 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
if w.paid != before+40 {
t.Fatal("second unbet all moved money", w.paid)
}
- // Roll table formatting: grouped totals, single-level tags only.
+ // Roll outcome line: every total once, single-level tags only.
tb2 := crapsFixture()
tb2.join("alice")
w2 := &testWallet{chips: 100}
tb2.bet("alice", "pass", 1, w2)
table := tb2.rollTable(tb2.players.get("alice"))
- for _, tag := range []string{"{casino_payout}7{/}", "{casino_result_win}+1{/}", "{casino_payout}2{/}", "{casino_result_loss}-1{/}"} {
+ for _, tag := range []string{"{casino_result_win}7(+1){/}", "{casino_result_loss}2(-1){/}", "{dim}4(-){/}"} {
if !strings.Contains(table, tag) {
t.Fatal("missing", tag, table)
}
}
- if strings.Contains(table, "{casino_muted}") || strings.Contains(table, "{casino_result_win}+{") {
+ if strings.Contains(table, "{casino_muted}") || strings.Contains(table, "{casino_result_win}+") {
t.Fatal("nested or unknown tag", table)
}
}
@@ -282,12 +283,12 @@ 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}+80{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}2+6{/}", "{casino_result_loss}-20{/}"} {
+ for _, want := range []string{"{casino_result_win}5(+150){/}", "{casino_result_win}8(+80){/}", "{casino_result_loss}7(-20){/}"} {
if !strings.Contains(table, want) {
t.Fatal("missing", want, table)
}
}
- // Bets paying on the same total combine into one row.
+ // Bets paying on the same total combine into one entry.
tb2 := crapsFixture()
tb2.join("alice")
w2 := &testWallet{chips: 100}
@@ -295,15 +296,16 @@ func TestCrapsRollTableHardPairLabels(t *testing.T) {
tb2.bet("alice", "place 8", 12, w2)
tb2.point = 8
table = tb2.rollTable(tb2.players.get("alice"))
- if strings.Count(table, "{casino_payout}8{/}") != 1 || !strings.Contains(table, "{casino_result_win}+4{/}") {
- t.Fatal("same-label payouts must combine", table)
+ if strings.Count(table, "8(") != 1 || !strings.Contains(table, "{casino_result_win}8(+4){/}") {
+ t.Fatal("same-total payouts must combine", table)
}
- // Hard bets alone on the come-out pay nothing and say so.
+ // Hard bets alone on the come-out are off: every total is dim.
tb3 := crapsFixture()
tb3.join("alice")
w3 := &testWallet{chips: 100}
tb3.bet("alice", "hard 8", 10, w3)
- if text := tb3.rollTable(tb3.players.get("alice")); !strings.Contains(text, "OFF on the come-out") {
+ text := tb3.rollTable(tb3.players.get("alice"))
+ if !strings.Contains(text, "{dim}8(-){/}") || strings.Contains(text, "casino_result_win") || strings.Contains(text, "casino_result_loss") {
t.Fatal(text)
}
}
diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go
index c36f69f..1377452 100644
--- a/internal/casino/craps_projection.go
+++ b/internal/casino/craps_projection.go
@@ -95,53 +95,39 @@ func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool {
}
return true
}
+
+// rollTable previews the next roll as one compact line of every total 2-12
+// (1-6 on the single-die table): wins are bold green, losses bold red, and
+// totals no open wager settles are dim. Hardway and hop bets are pair
+// specific, so the best pair outcome stands in for that total.
func (t *crapsTable) rollTable(p *crapsPlayer) string {
- agg := map[string]int{}
- pairsMatter := false
- for _, bet := range p.bets {
- if bet.kind == "hard" || bet.kind == "hop" {
- pairsMatter = true
- }
+ if len(p.bets) == 0 {
+ return ""
}
+ best := map[int]int{}
+ found := map[int]bool{}
for _, row := range t.projectionFor(p) {
- label := fmt.Sprint(row.Total)
- if pairsMatter {
- label = row.Rolls
- }
- if row.Net != 0 {
- agg[label] = row.Net
+ if !found[row.Total] || row.Net > best[row.Total] {
+ best[row.Total] = row.Net
+ found[row.Total] = true
}
}
- if len(agg) == 0 {
- if len(p.bets) == 0 {
- return "No open wagers."
- }
- return "{casino_action}No wager settles on the next roll; contracts remain live. Place/buy/hardways are OFF on the come-out.{/}"
+ lo, hi := 2, 12
+ if t.Config.Craps.variant() == "die_rich" {
+ lo, hi = 1, 6
}
- labels := []string{}
- for label := range agg {
- labels = append(labels, label)
- }
- sort.Slice(labels, func(i, j int) bool {
- ni, nj := agg[labels[i]], agg[labels[j]]
- if (ni > 0) != (nj > 0) {
- return ni > 0
- }
- if ni != nj {
- return ni > nj
- }
- return labels[i] < labels[j]
- })
var sb strings.Builder
- sb.WriteString("{casino_emphasis}ROLL RESULTS ON YOUR BETS{/}\n")
- for _, label := range labels {
- net := agg[label]
- tag, amount := "casino_result_loss", fmt.Sprint(net)
- if net > 0 {
- tag, amount = "casino_result_win", "+"+amount
+ sb.WriteString("{casino_emphasis}ROLL OUTCOMES{/}:")
+ for total := lo; total <= hi; total++ {
+ token := fmt.Sprintf("%d(-)", total)
+ tag := "dim"
+ switch {
+ case found[total] && best[total] > 0:
+ token, tag = fmt.Sprintf("%d(+%d)", total, best[total]), "casino_result_win"
+ case found[total] && best[total] < 0:
+ token, tag = fmt.Sprintf("%d(%d)", total, best[total]), "casino_result_loss"
}
- fmt.Fprintf(&sb, " {casino_payout}%s{/}%s {%s}%s{/}\n", label, strings.Repeat(" ", max(1, 10-len(label))), tag, amount)
+ fmt.Fprintf(&sb, " {%s}%s{/}", tag, token)
}
- sb.WriteString("{casino_action}Hardways and hops name their pair; totals cover both orders.{/}")
return sb.String()
}
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
index 603aedb..3d92e5e 100644
--- a/internal/casino/craps_test.go
+++ b/internal/casino/craps_test.go
@@ -250,6 +250,10 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
t.Fatal("bets must remain open until the roll")
}
tb.action("a", "roll")
+ if tb.dice != [2]int{} {
+ t.Fatal("roll resolved before the shake")
+ }
+ tb.tickElapsed(3 * time.Second)
if tb.dice == [2]int{} {
t.Fatal("no roll")
}
@@ -262,6 +266,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
t.Fatal("solo players must never be forced to roll")
}
tb.action("a", "roll")
+ tb.tickElapsed(3 * time.Second)
if tb.dice == [2]int{} {
t.Fatal("solo manual roll stalled")
}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
index d0d8a62..f41ad67 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -2,7 +2,6 @@ package casino
import (
"fmt"
- "strings"
)
func (t *crapsTable) commands(name string) []MenuCommand {
@@ -32,7 +31,7 @@ func (t *crapsTable) commands(name string) []MenuCommand {
}
if p.HasBet {
if !simplified {
- if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) {
+ if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) && t.pendingRoll == 0 {
commands = append(commands, MenuCommand{Command: "roll", Desc: "Shoot now"})
}
if !p.autoRoll {
@@ -87,39 +86,27 @@ func crapsBetLabel(b *crapsBet) string {
// Numbers describe covered targets, not a promise that every listed roll wins
// (don't/lay bets oppose their number; bonus bets need complete sequences).
-// betSummary lists each open wager, then the dice pairs that beat them. The
+// betSummary lists the dice pairs that settle the player's open wagers. The
// roll table shares payout math with settlement (crapsNet), so the numbers
-// never promise a win a real roll cannot pay.
+// never promise a win a real roll cannot pay. The open-bet list itself is no
+// longer shown: the settlement wager table already names every stake.
func (t *crapsTable) betSummary(p *crapsPlayer) Event {
- text := "{casino_emphasis}YOUR BETS{/}"
- if len(p.bets) == 0 {
- text += " No active bets."
- } else {
- for _, b := range p.bets {
- label := crapsBetLabel(b)
- if strings.HasPrefix(b.kind, "dont") || b.kind == "lay" {
- label += " (against)"
- }
- if t.point == 0 && (b.kind == "place" || b.kind == "buy" || b.kind == "hard") {
- label += " (off)"
- }
- text += fmt.Sprintf("\n {casino_action}%-24s{/} {casino_chips}%d{/}", label, b.stake.Total)
- if b.odds.Total > 0 {
- text += fmt.Sprintf(" + %d odds", b.odds.Total)
- if t.point == 0 && b.kind == "come" {
- text += " (odds off)"
- }
- }
- }
+ return t.private(p.Name, t.rollTable(p))
+}
+
+// shooterHeading names the current shooter for the join, status and reminder
+// menus. The board no longer carries its own Shooter line.
+func (t *crapsTable) shooterHeading() string {
+ if shooter := t.shooter(); shooter != nil {
+ return "Shooter " + shooter.Name
}
- e := t.private(p.Name, text+"\n"+t.rollTable(p))
- e.ShowBalance = true
- return e
+ return "Shooter"
}
func (t *crapsTable) reminder(name string) Event {
- e := t.private(name, "Your move")
+ heading := t.shooterHeading()
+ e := t.private(name, heading)
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: "Your move", Commands: t.commands(name)}
+ e.Menu = &MenuView{ShowBalance: true, Heading: heading, Commands: t.commands(name)}
return e
}
func (t *crapsTable) settlementEvent(p *crapsPlayer, lines []settlementLine, failed bool) Event {
diff --git a/internal/casino/craps_wagers.go b/internal/casino/craps_wagers.go
index d9af87f..1c6da2f 100644
--- a/internal/casino/craps_wagers.go
+++ b/internal/casino/craps_wagers.go
@@ -2,15 +2,13 @@ package casino
import (
"fmt"
+ "strings"
)
-// Craps wager tables are built from the live table state: a come-out roll
-// offers the line bets, the one-roll props and (before the shooter's first
-// roll) the bonus bets; a point adds come, odds, place/buy/lay and the
-// hardways. The variants substitute their own single-bet lists. Every entry
-// pairs the bet wording with its payout and a one-line win condition, so the
-// list doubles as the game's brief rule reminder. Payouts are profit odds;
-// winning stakes return alongside the profit.
+// Craps wager tables are built from the live table state. Rows are grouped
+// where two bets share the same payout (pass/don't pass, the place numbers,
+// all free odds) so the list stays compact and the Win/action column rarely
+// wraps. Payouts are profit odds; winning stakes return alongside the profit.
func (t *crapsTable) wagerRows(name string) []WagerRow {
c := t.Config.Craps
rows := []WagerRow{}
@@ -18,31 +16,30 @@ func (t *crapsTable) wagerRows(name string) []WagerRow {
case "simplified":
// One automatic roll of two dice; pass is the only target.
rows = append(rows,
- WagerRow{"pass", "up to 5:1", "One roll: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1; 5-9 lose"},
+ WagerRow{"pass", "up to 5:1", "One roll: 2 pays 3:1; 3/4 1:1; 10/11 2:1; 12 5:1; 5-9 lose"},
)
case "high_point":
rows = append(rows,
- WagerRow{"pass", "1:1", "Come-out 2/3 rolls again, 11/12 win; 4-10 make a point the next roll must EXCEED"},
+ WagerRow{"pass", "1:1", "2/3 roll again; 11/12 win; 4-10 must be beaten next roll"},
)
case "die_rich":
rows = append(rows,
- WagerRow{"pass", "1:1", "One die: come-out 6 wins, 1 loses; 2-5 make a point repeated before a 1"},
+ WagerRow{"pass", "1:1", "6 wins; 1 loses; 2-5 must repeat before a 1"},
)
case "new_york":
rows = append(rows,
- WagerRow{"pass", "1:1", "Come-out 7/11 wins; 2/3/12 loses; else the point repeats before a 7"},
+ WagerRow{"pass", "1:1", "7/11 win, 2/3/12 lose; opposite bars 12"},
WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"},
)
if t.point != 0 {
rows = append(rows, crapsOddsRows(c)...)
rows = append(rows,
- WagerRow{"buy <n>", "true odds", "True-odds bet on 4/5/6/8/9/10; 5% stake commission, wins only"},
- WagerRow{"lay <n>", "true odds laid", "Bet AGAINST the number; 5% commission on winnings"},
+ WagerRow{"buy / lay <n>", "true", "4/5/6/8/9/10; 5% win commission"},
)
}
case "crapless":
rows = append(rows,
- WagerRow{"pass", "1:1", "Come-out only 7 wins; every other total becomes the point"},
+ WagerRow{"pass", "1:1", "Come-out 7 wins; every other total makes the point"},
)
if t.point != 0 {
rows = append(rows,
@@ -51,94 +48,103 @@ func (t *crapsTable) wagerRows(name string) []WagerRow {
rows = append(rows, crapsOddsRows(c)...)
rows = append(rows, crapsPlaceRows(c)...)
rows = append(rows,
- WagerRow{"buy <n>", "true odds", "True-odds place bet; 5% stake commission, wins only"},
- WagerRow{"lay <n>", "true odds laid", "Bet against the number; 5% commission on winnings"},
- WagerRow{"field", fmt.Sprintf("12 pays %d:1", c.Field12), "One roll: 2 pays 2:1; 3/4/9/10/11 pay 1:1; 5/6/7/8 lose"},
- WagerRow{"hard 4 / hard 10", "7:1", "The point arrives as an identical pair"},
- WagerRow{"hard 6 / hard 8", "9:1", "The point arrives as an identical pair"},
- WagerRow{"seven", "4:1", "Next roll totals 7"},
- WagerRow{"craps", "7:1", "Next roll is 2, 3 or 12"},
- WagerRow{"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"},
- WagerRow{"aces / boxcars", "30:1", "Next roll is 2 / 12"},
- WagerRow{"horn", "varies", "Splits the stake equally among 2/3/11/12"},
- WagerRow{"hop <a> <b>", "15:1/30:1", "One-roll dice combination; 30:1 when both dice equal"},
+ 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"},
)
- } else {
- rows = append(rows, crapsComeOutRows(c, t.rolled)...)
}
+ rows = append(rows, crapsPropRows(c, false)...)
default: // bank and diceless
rows = append(rows,
- WagerRow{"pass", "1:1", "Come-out 7/11 wins; 2/3/12 loses; else the point repeats before a 7"},
+ WagerRow{"pass", "1:1", "7/11 win, 2/3/12 lose; opposite bars 12"},
WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"},
)
if t.point != 0 {
rows = append(rows,
WagerRow{"come", "1:1", "A personal point with pass rules"},
- WagerRow{"dontcome", "1:1", "Don't come, same as don't pass; a 12 pushes"},
+ WagerRow{"dontcome", "1:1", "Don't come; a 12 pushes"},
)
rows = append(rows, crapsOddsRows(c)...)
rows = append(rows, crapsPlaceRows(c)...)
rows = append(rows,
- WagerRow{"buy <n>", "true odds", "True-odds place bet; 5% stake commission, wins only"},
- WagerRow{"lay <n>", "true odds laid", "Bet against the number; 5% commission on winnings"},
- WagerRow{"hard 4 / hard 10", "7:1", "The point arrives as an identical pair"},
- WagerRow{"hard 6 / hard 8", "9:1", "The point arrives as an identical pair"},
+ 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"},
)
}
- rows = append(rows, crapsComeOutRows(c, t.rolled)...)
+ rows = append(rows, crapsPropRows(c, !t.rolled)...)
}
return rows
}
-// crapsComeOutRows is the one-roll part of the list, available in every
-// multi-bet variant during every phase; bonus wagers drop out once the
-// shooter has rolled.
-func crapsComeOutRows(c CrapsConfig, rolled bool) []WagerRow {
+// 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{
- WagerRow{"field", fmt.Sprintf("12 pays %d:1", c.Field12), "One roll: 2 pays 2:1; 3/4/9/10/11 pay 1:1; 5/6/7/8 lose"},
- WagerRow{"seven", "4:1", "Next roll totals 7"},
- WagerRow{"craps", "7:1", "Next roll is 2, 3 or 12"},
- WagerRow{"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"},
- WagerRow{"aces / boxcars", "30:1", "Next roll is 2 / 12"},
- WagerRow{"horn", "27:4 on 2/12", "Splits the stake equally among 2/3/11/12"},
- WagerRow{"hop <a> <b>", "15:1/30:1", "One-roll dice combination; 30:1 when both dice equal"},
+ {"field", fmt.Sprintf("12 pays %d:1", c.Field12), "2 pays 2:1; 3/4/9/10/11 1:1; 5-8 lose"},
+ {"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"},
}
- if !rolled {
- // Bonus bets close before the shooter's first roll.
+ if bonuses {
rows = append(rows,
- WagerRow{"fire", "24/249/999:1", "Shooter makes 4/5/6 distinct points before sevening out"},
- WagerRow{"small / tall", "34:1", "All of 2-6 (or 8-12) rolls before any 7"},
- WagerRow{"all", "175:1", "All of 2-6 AND 8-12 in the same hand before any 7"},
+ WagerRow{"fire", "24/249/999:1", "4/5/6 distinct points before a 7"},
+ WagerRow{"small / tall", "34:1", "All 2-6 or 8-12 before any 7"},
+ WagerRow{"all", "175:1", "All 2-6 and 8-12 before any 7"},
)
}
return rows
}
-// crapsPlaceRows lists one row per placeable number with its house payout.
-// Crapless tables also accept the "impossible" numbers at extended odds.
+// crapsPlaceRows lists one place row per distinct payout, so 4/10 share 9:5,
+// 5/9 share 7:5 and 6/8 share 7:6. Crapless tables also accept the
+// "impossible" numbers at extended odds.
func crapsPlaceRows(c CrapsConfig) []WagerRow {
numbers := []int{4, 5, 6, 8, 9, 10}
if c.variant() == "crapless" {
numbers = []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12}
}
- rows := []WagerRow{}
+ type group struct {
+ odds string
+ nums []int
+ }
+ var order []*group
+ byOdds := map[string]*group{}
for _, n := range numbers {
x, y := crapsPlaceOdds(n)
- rows = append(rows, WagerRow{fmt.Sprintf("place %d", n), fmt.Sprintf("%d:%d", x, y), fmt.Sprintf("Number %d repeats before a 7", n)})
+ key := fmt.Sprintf("%d:%d", x, y)
+ g := byOdds[key]
+ if g == nil {
+ g = &group{odds: key}
+ byOdds[key] = g
+ order = append(order, g)
+ }
+ g.nums = append(g.nums, n)
+ }
+ var rows []WagerRow
+ for _, g := range order {
+ names := make([]string, len(g.nums))
+ 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"})
}
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 (don't 6x)"
+ limit := "3-4-5x"
if c.Odds > 0 {
limit = fmt.Sprintf("%dx", c.Odds)
}
return []WagerRow{
- {"odds pass", "true odds", "Extra stake behind your pass point, up to " + limit},
- {"odds come <n>", "true odds", "Extra stake behind a travelled come bet"},
- {"odds dontpass", "true odds laid", "Extra stake against the point, laid at inverse true odds"},
- {"odds dontcome <n>", "true odds laid", "Laid odds behind a don't come bet"},
+ {"odds pass / come", "true", "Free odds behind the point, up to " + limit},
+ {"odds dont <n>", "true", "Laid against the point at inverse odds"},
}
}
diff --git a/internal/game/casino_look_test.go b/internal/game/casino_look_test.go
index 4c464f1..cdb0b59 100644
--- a/internal/game/casino_look_test.go
+++ b/internal/game/casino_look_test.go
@@ -88,3 +88,50 @@ func TestLookStandingShowsFixtureOnly(t *testing.T) {
t.Fatalf("standing look missing the play hint: %q", text)
}
}
+
+// TestLookCasinoRoomSingleBlankBeforeExits pins the room-look spacing: the
+// casino fixture listing and the exit section are separated by one blank
+// line, whether or not the mini-map supplies trailing rows of its own.
+func TestLookCasinoRoomSingleBlankBeforeExits(t *testing.T) {
+ for _, mapOpt := range []string{"off", "right"} {
+ t.Run(mapOpt, func(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, conn := h.connect("alice")
+ sess.Player.Options = map[string]any{"tiny_map": mapOpt}
+
+ room := "id: 4\nname: Bank Craps Pit\ndescription: A sweeping green felt table curves beneath warm brass lamps. Ivory dice rest beside a polished stick.\nexits:\n north:\n room: 1\ncasino_tables:\n - id: bank_craps\n game: craps\n craps:\n variant: bank\n"
+ path := filepath.Join(h.dir, "rooms", "4.yaml")
+ if err := os.WriteFile(path, []byte(room), 0644); err != nil {
+ t.Fatal(err)
+ }
+ h.g.World.AddRoomPath(4, path)
+ sess.Player.RoomID = 4
+ h.hub.EnterRoom(sess, 4)
+
+ h.g.executeLook(sess, nil, "look")
+ lines := strings.Split(lookOutput(t, conn), "\n")
+ play, exits := -1, -1
+ for i, line := range lines {
+ if strings.Contains(line, "play craps") {
+ play = i
+ }
+ if strings.TrimSpace(line) == "Exits:" {
+ exits = i
+ }
+ }
+ if play < 0 || exits < 0 || exits <= play {
+ t.Fatalf("could not locate table/exits: %q", lines)
+ }
+ for i := play; i < exits; i++ {
+ if strings.TrimSpace(lines[i]) != "" {
+ continue
+ }
+ if i+1 < exits && strings.TrimSpace(lines[i+1]) == "" {
+ t.Fatalf("consecutive blank lines before Exits: %q", lines)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/game/casino_rules_list_test.go b/internal/game/casino_rules_list_test.go
index e83c277..f0c72d2 100644
--- a/internal/game/casino_rules_list_test.go
+++ b/internal/game/casino_rules_list_test.go
@@ -129,13 +129,17 @@ func TestListFollowsGameState(t *testing.T) {
}
}
- // Establish a point: place/odds appear, bonus bets close.
+ // Establish a point: place/odds appear, bonus bets close. The roll is
+ // deliberately delayed, so advance the shared clock until it resolves.
g.Casino.Action(1, "bank", "alice", "roll")
+ for i := 0; i < 5; i++ {
+ g.Casino.Tick()
+ }
conn.data = nil
g.executeList(sess, nil, "list")
text = stripANSI(string(conn.data))
targets = listTargets(text)
- if !targets["place 6"] || !targets["odds pass"] {
+ if !targets["place 6/8"] || !targets["odds pass / come"] {
t.Fatalf("point list missing place/odds: %q", text)
}
if targets["fire"] {
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 70bc2c2..38cc83c 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -56,7 +56,11 @@ func (g *Game) doLook(sess *net.Session) {
}
}
- g.showRoomExits(sess, p, room)
+ // When the minimap is taller than the room content it already renders
+ // empty content rows below the last line, so the exit section must not
+ // add another gap on top of them: that is what read as two blank lines.
+ addExitGap := !(mapOn && len(mapLines) > len(content))
+ g.showRoomExits(sess, p, room, addExitGap)
g.showRoomPlayers(sess, p, room)
// Seated at a table: look keeps its room-first order, then appends the
// live game state and the current hint menu.
diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go
index fd2e4b9..27610bd 100644
--- a/internal/game/craps_test.go
+++ b/internal/game/craps_test.go
@@ -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", "ALL SMALL", "Shooter alice", "Total: 7", "POINT 6"} {
+ for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "ALL SMALL", "Total: 7", "POINT 6"} {
if !strings.Contains(plain, word) {
t.Fatal("missing", word)
}
diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go
index 42f3529..46f5be6 100644
--- a/internal/game/craps_variants_test.go
+++ b/internal/game/craps_variants_test.go
@@ -41,7 +41,7 @@ func TestCrapsVariantRendering(t *testing.T) {
if v == "die_rich" && !strings.Contains(plain, "Total: 4") {
t.Fatal("single die absent")
}
- if mode == "xterm256" && (v == "die_rich" || v == "diceless") {
+ if mode == "xterm256" && v == "diceless" {
bg := 0
if style == "light" {
bg = 15
@@ -50,14 +50,20 @@ func TestCrapsVariantRendering(t *testing.T) {
t.Fatal(v, "style")
}
}
+ if mode == "xterm256" && v == "die_rich" && !strings.Contains(text, color.BgCode(mode, 15)) {
+ t.Fatal(v, "die should use a white background")
+ }
}
}
}
}
for n := 1; n <= 6; n++ {
- text := renderCrapsDice([2]int{n, 0}, "none", false, "dark")
+ text := renderCrapsDice([2]int{n, 0}, "none", false)
if strings.Count(text, "+-------+") != 2 {
- t.Fatal("must render exactly one die", text)
+ t.Fatal("single die needs a top and bottom outline", text)
+ }
+ if got := strings.Count(strings.ReplaceAll(text, "Total:", ""), "o"); got != n {
+ t.Fatalf("single die %d rendered %d pips", n, got)
}
}
}
diff --git a/internal/game/look_room.go b/internal/game/look_room.go
index 3208f40..a09d66d 100644
--- a/internal/game/look_room.go
+++ b/internal/game/look_room.go
@@ -134,11 +134,13 @@ func (g *Game) formatExitLines(sess *net.Session, room *world.Room) []string {
return out
}
-func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) {
+func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room, gap bool) {
if len(room.Exits) == 0 {
return
}
- sess.WriteLine("")
+ if gap {
+ sess.WriteLine("")
+ }
if p.OptionBool("exits") {
lines := g.formatExitLines(sess, room)
if len(lines) > 0 {
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index 80f05ee..7ec015a 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -20,6 +20,8 @@ const (
crapsMarked
crapsPuckOn
crapsPuckOff
+ crapsFire
+ crapsAts
)
var crapsPaint = []color.ColorSpec{
@@ -30,6 +32,8 @@ var crapsPaint = []color.ColorSpec{
{Fg: 0, Bg: 220, Bold: true},
{Fg: 0, Bg: 15, Bold: true},
{Fg: 15, Bg: 0, Bold: true},
+ {Fg: 229, Bg: 124, Bold: true}, // fire tracker: yellow on red
+ {Fg: 229, Bg: 24, Bold: true}, // ATS tracker: yellow on blue
}
type crapsCell struct {
@@ -39,6 +43,21 @@ type crapsCell struct {
}
type crapsCanvas struct{ cells [][]crapsCell }
+// fill paints one rectangular region with an ink without touching glyphs or
+// box edges, so a whole tracker section can share one background colour.
+func (c *crapsCanvas) fill(x1, y1, x2, y2, ink int) {
+ for y := y1; y <= y2; y++ {
+ if y < 0 || y >= len(c.cells) {
+ continue
+ }
+ for x := x1; x <= x2; x++ {
+ if x >= 0 && x < len(c.cells[y]) {
+ c.cells[y][x].ink = ink
+ }
+ }
+ }
+}
+
// crapsCanvasWidth is the fixed felt width every craps render uses.
const crapsCanvasWidth = 77
@@ -138,14 +157,33 @@ func crapsHas(ns []int, n int) bool {
func crapsDie(n int) string {
return string([]rune{'⚀', '⚁', '⚂', '⚃', '⚄', '⚅'}[n-1])
}
+
+// crapsDrawPuck paints the point puck: OFF on the layout, ON in the point's
+// number box. It is shared by the bank and variant felts.
+func crapsDrawPuck(c *crapsCanvas, x, y int, on, unicode bool) {
+ ink, word := crapsPuckOff, "OFF"
+ if on {
+ ink, word = crapsPuckOn, "ON "
+ }
+ a, b, d := "+---+", "|"+word+"|", "+---+"
+ if unicode {
+ a, b, d = "╭───╮", "│"+word+"│", "╰───╯"
+ }
+ c.text(x, y, a, ink)
+ c.text(x, y+1, b, ink)
+ c.text(x, y+2, d, ink)
+}
func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string {
c := newCrapsCanvas(12)
+ // Section backgrounds first: fire red, ATS blue, yellow lettering on top.
+ c.fill(0, 0, 76, 5, crapsFire)
+ c.fill(0, 5, 76, 11, crapsAts)
c.box(0, 0, 76, 2)
c.box(0, 5, 38, 8)
c.box(38, 5, 76, 8)
- c.text(3, 1, "FIRE", crapsGold)
+ c.text(3, 1, "FIRE", crapsFire)
for i, n := range []int{4, 5, 6, 8, 9, 10} {
- ink, mark := crapsMuted, " "
+ ink, mark := crapsFire, " "
if crapsHas(s.Made, n) {
ink, mark = crapsMarked, "*"
}
@@ -155,29 +193,29 @@ func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) str
for i, payout := range []string{"24:1", "249:1", "999:1"} {
lo, hi := bounds[i], bounds[i+1]
c.box(lo, 2, hi, 5)
- c.center(lo+1, hi-1, 3, fmt.Sprintf("%d DISTINCT POINTS", i+4), crapsGold)
- c.center(lo+1, hi-1, 4, payout, crapsInk)
+ c.center(lo+1, hi-1, 3, fmt.Sprintf("%d DISTINCT POINTS", i+4), crapsFire)
+ c.center(lo+1, hi-1, 4, payout, crapsFire)
}
- c.text(2, 6, "ALL SMALL", crapsGold)
- c.text(40, 6, "TALL", crapsGold)
+ c.text(2, 6, "ALL SMALL", crapsAts)
+ c.text(40, 6, "TALL", crapsAts)
for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} {
x := 12 + i*5
if i >= 5 {
x = 48 + (i-5)*5
}
- ink, mark := crapsMuted, " "
+ ink, mark := crapsAts, " "
if crapsHas(s.Seen, n) {
ink, mark = crapsMarked, "*"
}
c.text(x, 6, fmt.Sprintf("%2d%s ", n, mark), ink)
}
- c.center(1, 37, 7, "* = rolled", crapsMuted)
- c.center(39, 75, 7, "* = rolled", crapsMuted)
+ c.center(1, 37, 7, "* = rolled", crapsAts)
+ c.center(39, 75, 7, "* = rolled", crapsAts)
for i, row := range [][2]string{{"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} {
lo, hi := bounds[i], bounds[i+1]
c.box(lo, 8, hi, 11)
- c.center(lo+1, hi-1, 9, row[0], crapsGold)
- c.center(lo+1, hi-1, 10, row[1], crapsInk)
+ c.center(lo+1, hi-1, 9, row[0], crapsAts)
+ c.center(lo+1, hi-1, 10, row[1], crapsAts)
}
return c.render(mode, unicode)
}
@@ -187,31 +225,18 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
c.center(1, 13, 4, "DON'T COME", crapsInk)
c.center(1, 13, 5, "BAR 12", crapsMuted)
nums := []int{4, 5, 6, 8, 9, 10}
- labels := []string{"4", "5", "SIX", "8", "NINE", "10"}
+ numLabels := []string{"4", "5", "SIX", "8", "NINE", "10"}
pays := []string{"9:5", "7:5", "7:6", "7:6", "7:5", "9:5"}
- puck := func(x int, on bool) {
- ink, word := crapsPuckOff, "OFF"
- if on {
- ink, word = crapsPuckOn, "ON "
- }
- a, b, d := "+---+", "|"+word+"|", "+---+"
- if unicode {
- a, b, d = "╭───╮", "│"+word+"│", "╰───╯"
- }
- c.text(x, 1, a, ink)
- c.text(x, 2, b, ink)
- c.text(x, 3, d, ink)
- }
if s.Point == 0 {
- puck(5, false)
+ crapsDrawPuck(c, 5, 1, false, unicode)
}
for i, n := range nums {
x := 14 + i*6
c.box(x, 0, x+6, 6)
- c.center(x+1, x+5, 4, labels[i], crapsGold)
+ c.center(x+1, x+5, 4, numLabels[i], crapsGold)
c.center(x+1, x+5, 5, pays[i], crapsMuted)
if s.Point == n {
- puck(x+1, true)
+ crapsDrawPuck(c, x+1, 1, true, unicode)
}
}
c.box(0, 6, 50, 10)
@@ -224,7 +249,11 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
if twelve == 0 {
twelve = 2
}
- c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %d:1", twelve), crapsGold)
+ twelvePay := "DOUBLE"
+ 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)
@@ -278,38 +307,132 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
c.center(64, 75, 24, "15:1 / 30:1", crapsMuted)
return c.render(mode, unicode)
}
-func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string {
+
+// renderHighPointFelt draws High Point's number row, point puck and pass
+// line, with the come-out special case printed on the felt.
+func renderHighPointFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(16)
+ c.box(0, 0, 76, 15)
+ c.center(1, 75, 1, "HIGH POINT CRAPS", crapsGold)
+ c.center(1, 75, 2, "COME-OUT 2 OR 3: IGNORED | 11 OR 12: WINS", crapsInk)
+ nums := []int{4, 5, 6, 8, 9, 10}
+ labels := []string{"4", "5", "SIX", "8", "NINE", "10"}
+ for i, n := range nums {
+ x := 2 + i*12
+ c.box(x, 5, x+11, 10)
+ c.center(x+1, x+10, 9, labels[i], crapsGold)
+ if s.Point == n {
+ crapsDrawPuck(c, x+3, 6, true, unicode)
+ }
+ }
+ c.box(2, 12, 74, 14)
+ c.center(3, 73, 13, "PASS LINE", crapsGold)
+ return c.render(mode, unicode)
+}
+
+// renderCraplessFelt gives every total a point box and shows the pass rail;
+// crapless has no don't bets or bonuses.
+func renderCraplessFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(16)
+ c.box(0, 0, 76, 15)
+ c.center(1, 75, 1, "CRAPLESS CRAPS", crapsGold)
+ c.center(1, 75, 2, "COME-OUT 7 WINS | ALL OTHER TOTALS BECOME POINTS", crapsInk)
+ nums := []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12}
+ for i, n := range nums {
+ x := 3 + i*7
+ c.box(x, 5, x+6, 10)
+ c.center(x+1, x+5, 9, fmt.Sprint(n), crapsGold)
+ if s.Point == n {
+ crapsDrawPuck(c, x+1, 6, true, unicode)
+ }
+ }
+ c.box(2, 12, 74, 14)
+ c.center(3, 73, 13, "PASS LINE", crapsGold)
+ return c.render(mode, unicode)
+}
+
+// renderNewYorkFelt is the pass/don't-pass, buy/lay layout.
+func renderNewYorkFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(16)
+ c.box(0, 0, 76, 15)
+ c.center(1, 75, 1, "NEW YORK CRAPS", crapsGold)
+ c.center(1, 75, 2, "PASS | DON'T PASS (BAR 12) | FREE ODDS | BUY / LAY 4 5 6 8 9 10", crapsInk)
+ nums := []int{4, 5, 6, 8, 9, 10}
+ for i, n := range nums {
+ x := 3 + i*12
+ c.box(x, 5, x+10, 10)
+ c.center(x+1, x+9, 9, fmt.Sprint(n), crapsGold)
+ if s.Point == n {
+ crapsDrawPuck(c, x+3, 6, true, unicode)
+ }
+ }
+ c.box(2, 12, 74, 14)
+ c.center(3, 73, 13, "PASS LINE / DON'T PASS", crapsGold)
+ return c.render(mode, unicode)
+}
+
+// renderDieRichFelt shows the single-die come-out targets and the four point
+// faces, with the pass line beneath them.
+func renderDieRichFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(15)
+ c.box(0, 0, 76, 14)
+ c.center(1, 75, 1, "DIE RICH CRAPS", crapsGold)
+ c.center(1, 75, 2, "ONE DIE | COME-OUT 6 WINS, 1 LOSES | 2-5 BECOME THE POINT", crapsInk)
+ nums := []int{2, 3, 4, 5}
+ for i, n := range nums {
+ x := 8 + i*16
+ c.box(x, 5, x+12, 10)
+ c.center(x+1, x+11, 9, fmt.Sprint(n), crapsGold)
+ if s.Point == n {
+ crapsDrawPuck(c, x+4, 6, true, unicode)
+ }
+ }
+ c.box(2, 11, 74, 13)
+ c.center(3, 73, 12, "PASS LINE", crapsGold)
+ return c.render(mode, unicode)
+}
+
+// renderCrapsDice paints each face as a white tile with black pips. The tile
+// is five interior rows tall (three pip rows plus white padding top and
+// bottom) inside a box outline, so the face reads as a square die.
+func renderCrapsDice(dice [2]int, mode string, unicode bool) string {
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"}}
- top, bottom, v, pip := "+-------+", "+-------+", "|", "o"
+ pip := "o"
if unicode {
- top, bottom, v, pip = "╭───────╮", "╰───────╯", "│", "●"
+ pip = "●"
}
- spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true}
- if style == "light" {
- spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ top, bottom, v := "+-------+", "+-------+", "|"
+ if unicode {
+ top, bottom, v = "╭───────╮", "╰───────╯", "│"
}
+ spec := color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
var out strings.Builder
- for row := 0; row < 5; row++ {
+ for row := 0; row < 7; row++ {
out.WriteString(" ")
for i, n := range dice {
if n == 0 {
continue
}
- line := top
- if row == 4 {
+ var line string
+ switch row {
+ case 0:
+ line = top
+ case 6:
line = bottom
- } else if row > 0 {
- line = v + " " + strings.ReplaceAll(patterns[n][row-1], "o", pip) + " " + v
+ case 1, 5:
+ line = v + " " + v
+ default:
+ line = v + " " + strings.ReplaceAll(patterns[n][row-2], "o", pip) + " " + v
}
out.WriteString(color.Render(mode, spec, line))
if i == 0 {
out.WriteString(" ")
}
}
- if row == 2 {
+ if row == 3 {
out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1])))
}
out.WriteByte('\n')
@@ -353,37 +476,30 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
} else if variant == "simplified" {
out.WriteString(renderSimplifiedCrapsFelt(mode, unicode))
} else {
- height := 7
- if variant == "high_point" || variant == "die_rich" {
- height = 6
- }
- c := newCrapsCanvas(height)
- c.box(0, 0, 76, height-1)
- c.center(1, 75, 1, "HOUSE EDITION — rules teaches this edition", crapsGold)
switch variant {
+ case "high_point":
+ out.WriteString(renderHighPointFelt(s, mode, unicode))
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)
+ out.WriteString(renderCraplessFelt(s, mode, unicode))
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)
+ out.WriteString(renderNewYorkFelt(s, mode, unicode))
+ case "die_rich":
+ out.WriteString(renderDieRichFelt(s, mode, unicode))
default:
- c.center(1, 75, 3, "PASS ONLY | WINS PAY 1:1 | NO ODDS OR BONUSES", crapsInk)
+ c := newCrapsCanvas(7)
+ c.box(0, 0, 76, 6)
+ c.center(1, 75, 3, "HOUSE EDITION — rules teaches this edition", crapsGold)
+ out.WriteString(c.render(mode, unicode))
}
- out.WriteString(c.render(mode, unicode))
}
out.WriteByte('\n')
if variant == "diceless" {
out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style))
} else {
- out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ out.WriteString(renderCrapsDice(s.Dice, mode, unicode))
}
- 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")
- }
+ if len(s.Queue) > 1 {
+ out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n")
}
hint := ""
if s.Phase != "waiting" && s.Phase != "" {
diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go
index 9d75630..b3cc1d8 100644
--- a/internal/game/render_craps_test.go
+++ b/internal/game/render_craps_test.go
@@ -45,7 +45,7 @@ func TestCrapsHalfTableGeometryAndPuck(t *testing.T) {
if strings.Count(art, want) != 1 {
t.Fatalf("missing/duplicate puck %d: %s", point, art)
}
- if !strings.Contains(art, "12 PAYS 3:1") {
+ 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"} {
@@ -58,7 +58,7 @@ 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))
}
func TestCrapsTrackersAndDiceStyles(t *testing.T) {
- s := &casino.CrapsSnapshot{Made: []int{4, 6}, Seen: []int{2, 12}}
+ s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 6}, Seen: []int{2, 12}}
art := renderCrapsTrackers(s, "xterm256", true)
plain := plainRoulette(art)
for _, token := range []string{"[ 4*]", "[ 5 ]", "[ 6*]", " 2*", "12*"} {
@@ -69,6 +69,9 @@ func TestCrapsTrackersAndDiceStyles(t *testing.T) {
if strings.Count(art, color.BgCode("xterm256", 220)) != 4 {
t.Fatal("wrong number of filled tracker tokens")
}
+ if !strings.Contains(art, color.BgCode("xterm256", 124)) || !strings.Contains(art, color.BgCode("xterm256", 24)) {
+ t.Fatal("fire/ATS tracker backgrounds are not red/blue")
+ }
if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) {
t.Fatal("felt not dark green")
}
@@ -80,16 +83,16 @@ func TestCrapsTrackersAndDiceStyles(t *testing.T) {
light := g.renderCasinoSnapshot(sess, s)
p.Options["card_style"] = "dark"
dark := g.renderCasinoSnapshot(sess, s)
- if light == dark {
- t.Fatal("card_style not wired to craps")
+ if light != dark {
+ t.Fatal("craps dice are intentionally style-independent")
}
for a := 1; a <= 6; a++ {
for b := 1; b <= 6; b++ {
for _, unicode := range []bool{false, true} {
- lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "light")
- dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "dark")
- if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 0)) {
- t.Fatal("incorrect dice background")
+ lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode)
+ dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode)
+ if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 15)) {
+ t.Fatal("craps dice should always use a white background")
}
pip := "o"
if unicode {