aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 05:33:45 -0400
committerhistoria <[not public]>2026-09-30 05:33:45 -0400
commit0fcb03633e3008169173d76c8408f5410592b3eb (patch)
tree814797fb33a129b1c49f8ae309c6647027f5e528 /internal/game
parent735428f0667f67c12fdf3caca872d7dc65e2ce26 (diff)
downloadthehouseoficarus-0fcb03633e3008169173d76c8408f5410592b3eb.tar.gz
feat: bank craps added to casino
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/blackjack_rules.go4
-rw-r--r--internal/game/casino.go27
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/cmd_registry.go25
-rw-r--r--internal/game/cmd_stop.go4
-rw-r--r--internal/game/craps_test.go79
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_craps.go328
-rw-r--r--internal/game/render_craps_test.go130
-rw-r--r--internal/game/render_roulette.go14
-rw-r--r--internal/game/roulette_test.go1
11 files changed, 601 insertions, 21 deletions
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index bab996c..afd8e4a 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -16,6 +16,10 @@ func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameBaccarat)
}
+func (g *Game) lookCrapsTable(sess *net.Session, target string) bool {
+ return g.lookCardTable(sess, target, casino.GameCraps)
+}
+
func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameRoulette)
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 08695e7..e9334fc 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -6,6 +6,7 @@ import (
"os"
"strconv"
"strings"
+ "time"
"unicode/utf8"
"thehouseoficarus/internal/cardart"
@@ -431,10 +432,11 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
// sets the wager and any other token is the bet target; AFTER "on", the
// next token is always the target. That makes numeric targets reachable
// ("bet 5 on 17"), which roulette, craps, and keno need.
- // Roulette combination bets need a multi-token target ("split 1 2",
- // "corner 1 2 4 5"), so a roulette table joins every extra target token.
- // Every other game keeps the stricter one-token target and its usage error.
- joinTarget := table.Config.Game == casino.GameRoulette
+ // Roulette combinations and craps bets need multi-token targets. Once
+ // an amount and target keyword exist, subsequent numbers belong to the
+ // target even without "on" (bet 10 split 1 2 / bet 10 buy 4).
+ // Other games keep the stricter one-token target.
+ joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps
amount, spot, hasAmount, sawOn := 0, "", false, false
for i, arg := range args {
if strings.EqualFold(arg, "on") {
@@ -445,7 +447,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sawOn = true
continue
}
- if !sawOn {
+ if !sawOn && !(joinTarget && hasAmount && spot != "") {
if strings.EqualFold(arg, "min") {
if hasAmount {
sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
@@ -538,7 +540,11 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
func (g *Game) CasinoTick() {
if g.Casino != nil {
- g.emitCasinoEvents(g.Casino.Tick())
+ ms := 600
+ if g.Ticks != nil {
+ ms = g.Ticks.TickLengthMilliseconds()
+ }
+ g.emitCasinoEvents(g.Casino.TickElapsed(time.Duration(ms) * time.Millisecond))
}
}
@@ -608,18 +614,19 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
}
}
-// casinoBoardReminder splits a roulette menu so its wager instructions can be
+// casinoBoardReminder splits roulette and craps menus so instructions can be
// printed under the betting board, which is the table the reminder refers to.
// Every other game, and compact displays, keep their menu in one block.
func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) {
if category != "casino_menu" || menu == nil || sess.Player == nil {
return "", "", false
}
+ _, isCraps := snapshot.(*casino.CrapsSnapshot)
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isRoulette || s.Phase == "playing" || s.Phase == "result" {
+ if !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", "", false
}
- if sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", "", false
}
@@ -741,6 +748,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
// how verbose the command menus are.
size := sess.Player.OptionInt("card_size")
switch s := snapshot.(type) {
+ case *casino.CrapsSnapshot:
+ return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style"))
case *casino.RouletteSnapshot:
return renderRouletteSnapshot(s, mode, unicode, size)
case *casino.SlotSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 6e146d0..450480f 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -172,7 +172,7 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) {
}
c, ok := resolveCasinoChoice(choices, input)
if !ok {
- sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, or watch roulette.")
+ sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, watch roulette, or watch craps.")
return
}
t, ok := g.casinoTable(sess.Player.RoomID, c.address)
@@ -199,6 +199,12 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool {
// Settlement may have removed a queued departure before these events
// are delivered. Their own final cards must still be visible.
switch s := event.Snapshot.(type) {
+ case *casino.CrapsSnapshot:
+ for _, name := range s.Queue {
+ if name == sess.Player.Name {
+ return true
+ }
+ }
case *casino.RouletteSnapshot:
for _, name := range s.Players {
if name == sess.Player.Name {
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index d5676d5..853fb6a 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -16,16 +16,21 @@ type commandDef struct {
}
var commandRegistry = map[string]commandDef{
- "get": {(*Game).executeGet, ClassActive},
- "take": {(*Game).executeGet, ClassActive},
- "grab": {(*Game).executeGet, ClassActive},
- "pick": {(*Game).executeGet, ClassActive},
- "drop": {(*Game).executeDrop, ClassActive},
- "attack": {(*Game).executeAttack, ClassActive},
- "kill": {(*Game).executeAttack, ClassActive},
- "work": {(*Game).executeWork, ClassActive},
- "play": {(*Game).executePlay, ClassActive},
- "rebet": {(*Game).executeBet, ClassActive},
+ "get": {(*Game).executeGet, ClassActive},
+ "take": {(*Game).executeGet, ClassActive},
+ "grab": {(*Game).executeGet, ClassActive},
+ "pick": {(*Game).executeGet, ClassActive},
+ "drop": {(*Game).executeDrop, ClassActive},
+ "attack": {(*Game).executeAttack, ClassActive},
+ "kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
+ "play": {(*Game).executePlay, ClassActive},
+ "rebet": {(*Game).executeBet, ClassActive},
+ "roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive},
+ "autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant},
+ "unbet": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "unbet "+strings.Join(args, " "))
+ }, ClassActive},
"table": {(*Game).executeTableStatus, ClassInstant},
"status": {(*Game).executeTableStatus, ClassInstant},
"watch": {(*Game).executeWatch, ClassInstant},
diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go
index 2f2a7ee..7075d01 100644
--- a/internal/game/cmd_stop.go
+++ b/internal/game/cmd_stop.go
@@ -11,6 +11,10 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) {
g.cancelRest(p.Name)
if _, playing := g.Casino.Participation(p.Name); playing {
if table, _ := g.Casino.Participation(p.Name); table != nil {
+ if events, stopped := table.StopAutoRoll(p.Name); stopped {
+ g.emitCasinoEvents(events)
+ return
+ }
// Normal leave refuses committed wagers. Only an explicit stop
// queues a departure; movement/quit retain their existing policy.
events := g.Casino.Leave(table.RoomID, table.Config.ID, p.Name)
diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go
new file mode 100644
index 0000000..569b4db
--- /dev/null
+++ b/internal/game/craps_test.go
@@ -0,0 +1,79 @@
+package game
+
+import (
+ "strings"
+ "testing"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestCrapsCommandsAndRendering(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "craps", Game: casino.GameCraps}
+ g.Casino.Join(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ for _, args := range [][]string{{"12", "on", "place", "6"}, {"10", "on", "hop", "2", "3"}, {"10", "buy", "4"}, {"10", "on", "fire"}} {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("%v: %s", args, conn.data)
+ }
+ }
+ conn.data = nil
+ g.executeTableStatus(sess, []string{"rules"}, "table rules")
+ if !strings.Contains(string(conn.data), "Bank Craps") || !strings.Contains(string(conn.data), "PASS LINE") {
+ t.Fatal(string(conn.data))
+ }
+ for _, cmd := range []string{"roll", "autoroll", "unbet"} {
+ if _, ok := commandRegistry[cmd]; !ok {
+ t.Fatal("missing", cmd)
+ }
+ }
+ snap := &casino.CrapsSnapshot{Phase: "betting", Point: 6, Dice: [2]int{3, 4}, Queue: []string{"alice", "bob"}, Made: []int{4, 6}, Seen: []int{2, 4, 6}}
+ if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "craps", Snapshot: snap}) {
+ t.Fatal("invisible board")
+ }
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ art := renderCrapsSnapshot(snap, mode, unicode)
+ plain := plainRoulette(art)
+ for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "ALL SMALL", "Shooter alice", "Total: 7", "POINT 6"} {
+ if !strings.Contains(plain, word) {
+ t.Fatal("missing", word)
+ }
+ }
+ for _, r := range plain {
+ if !unicode && r > 127 {
+ t.Fatalf("nonASCII %q", r)
+ }
+ }
+ for _, line := range strings.Split(plain, "\n") {
+ if color.VisibleLen(line) > 78 {
+ t.Fatalf("too wide: %s", line)
+ }
+ }
+ if mode == "none" && art != plain {
+ t.Fatal("unexpected ANSI")
+ }
+ if mode != "none" && !strings.Contains(art, "\x1b[") {
+ t.Fatal("missing felt color")
+ }
+ }
+ }
+ // Snapshots and nil/empty states render safely before the first roll.
+ if renderCrapsSnapshot(nil, "none", false) != "" {
+ t.Fatal("nil")
+ }
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ snap.Dice = [2]int{a, b}
+ if !strings.Contains(renderCrapsSnapshot(snap, "none", false), "Total:") {
+ t.Fatal(a, b)
+ }
+ }
+ }
+}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 8d970e4..2311710 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) {
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) {
return
}
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
new file mode 100644
index 0000000..19688ed
--- /dev/null
+++ b/internal/game/render_craps.go
@@ -0,0 +1,328 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// A half-table: numbered boxes and the come/field/line rails on the left;
+// the shared centre-action bets on the right. Every region is a real box,
+// and shared edges are joined rather than pasted together as broken borders.
+const crapsFelt = 22 // #005f00: darker than ANSI green / the roulette felt.
+const (
+ crapsInk = iota
+ crapsGold
+ crapsRed
+ crapsMuted
+ crapsMarked
+ crapsPuckOn
+ crapsPuckOff
+)
+
+var crapsPaint = []color.ColorSpec{
+ {Fg: 15, Bg: crapsFelt},
+ {Fg: 229, Bg: crapsFelt, Bold: true},
+ {Fg: 217, Bg: crapsFelt, Bold: true},
+ {Fg: 108, Bg: crapsFelt},
+ {Fg: 0, Bg: 220, Bold: true},
+ {Fg: 0, Bg: 15, Bold: true},
+ {Fg: 15, Bg: 0, Bold: true},
+}
+
+type crapsCell struct {
+ glyph rune
+ edges uint8
+ ink int
+}
+type crapsCanvas struct{ cells [][]crapsCell }
+
+func newCrapsCanvas(w, h int) *crapsCanvas {
+ c := &crapsCanvas{cells: make([][]crapsCell, h)}
+ for y := range c.cells {
+ c.cells[y] = make([]crapsCell, w)
+ }
+ return c
+}
+func (c *crapsCanvas) box(x1, y1, x2, y2 int) {
+ // N=1, E=2, S=4, W=8. Unioning edges makes all T/cross joints exact.
+ for x := x1; x < x2; x++ {
+ c.cells[y1][x].edges |= 2
+ c.cells[y1][x+1].edges |= 8
+ c.cells[y2][x].edges |= 2
+ c.cells[y2][x+1].edges |= 8
+ }
+ for y := y1; y < y2; y++ {
+ c.cells[y][x1].edges |= 4
+ c.cells[y+1][x1].edges |= 1
+ c.cells[y][x2].edges |= 4
+ c.cells[y+1][x2].edges |= 1
+ }
+}
+func (c *crapsCanvas) text(x, y int, s string, ink int) {
+ for _, r := range s {
+ if x >= 0 && x < len(c.cells[y]) {
+ c.cells[y][x] = crapsCell{glyph: r, ink: ink}
+ }
+ x++
+ }
+}
+func (c *crapsCanvas) center(x1, x2, y int, s string, ink int) {
+ c.text(x1+(x2-x1+1-color.VisibleLen(s))/2, y, s, ink)
+}
+func (c *crapsCanvas) render(mode string, unicode bool) string {
+ joints := map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'}
+ var out strings.Builder
+ for _, row := range c.cells {
+ var run strings.Builder
+ ink := row[0].ink
+ flush := func() {
+ if run.Len() > 0 {
+ out.WriteString(color.Render(mode, crapsPaint[ink], run.String()))
+ run.Reset()
+ }
+ }
+ for _, cell := range row {
+ if cell.ink != ink {
+ flush()
+ ink = cell.ink
+ }
+ r := cell.glyph
+ if r == 0 {
+ r = ' '
+ if cell.edges != 0 {
+ switch {
+ case cell.edges&5 == 0:
+ r = '─'
+ case cell.edges&10 == 0:
+ r = '│'
+ default:
+ r = joints[cell.edges]
+ }
+ if !unicode {
+ switch r {
+ case '─':
+ r = '-'
+ case '│':
+ r = '|'
+ default:
+ r = '+'
+ }
+ }
+ }
+ }
+ run.WriteRune(r)
+ }
+ flush()
+ out.WriteByte('\n')
+ }
+ return out.String()
+}
+func crapsHas(ns []int, n int) bool {
+ for _, v := range ns {
+ if v == n {
+ return true
+ }
+ }
+ return false
+}
+func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(77, 7)
+ c.box(0, 0, 76, 3)
+ c.box(0, 3, 38, 6)
+ c.box(38, 3, 76, 6)
+ c.text(3, 1, "FIRE", crapsGold)
+ for i, n := range []int{4, 5, 6, 8, 9, 10} {
+ ink, mark := crapsMuted, " "
+ if crapsHas(s.Made, n) {
+ ink, mark = crapsMarked, "*"
+ }
+ c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink)
+ }
+ c.center(1, 75, 2, "MADE POINTS 4: 24 to 1 5: 249 to 1 6: 999 to 1", crapsInk)
+ c.text(2, 4, "ALL SMALL", crapsGold)
+ c.text(40, 4, "TALL", crapsGold)
+ for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} {
+ x := 12 + i*5
+ if i >= 5 {
+ x = 48 + (i-5)*5
+ }
+ ink, mark := crapsMuted, " "
+ if crapsHas(s.Seen, n) {
+ ink, mark = crapsMarked, "*"
+ }
+ c.text(x, 4, fmt.Sprintf("%2d%s ", n, mark), ink)
+ }
+ c.center(1, 37, 5, "SMALL 34:1 | ALL 175:1", crapsInk)
+ c.center(39, 75, 5, "TALL 34:1 | * = rolled", crapsInk)
+ return c.render(mode, unicode)
+}
+func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string {
+ c := newCrapsCanvas(77, 26)
+ c.box(0, 0, 14, 6)
+ c.center(1, 13, 4, "DON'T COME", crapsInk)
+ c.center(1, 13, 5, "BAR 12", crapsMuted)
+ nums := []int{4, 5, 6, 8, 9, 10}
+ labels := []string{"4", "5", "SIX", "8", "NINE", "10"}
+ pays := []string{"9:5", "7:5", "7:6", "7:6", "7:5", "9:5"}
+ puck := func(x int, on bool) {
+ ink, word := crapsPuckOff, "OFF"
+ if on {
+ ink, word = crapsPuckOn, "ON "
+ }
+ a, b, d := "+---+", "|"+word+"|", "+---+"
+ if unicode {
+ a, b, d = "╭───╮", "│"+word+"│", "╰───╯"
+ }
+ c.text(x, 1, a, ink)
+ c.text(x, 2, b, ink)
+ c.text(x, 3, d, ink)
+ }
+ if s.Point == 0 {
+ puck(5, false)
+ }
+ for i, n := range nums {
+ x := 14 + i*6
+ c.box(x, 0, x+6, 6)
+ c.center(x+1, x+5, 4, labels[i], crapsGold)
+ c.center(x+1, x+5, 5, pays[i], crapsMuted)
+ if s.Point == n {
+ puck(x+1, true)
+ }
+ }
+ c.box(0, 6, 50, 10)
+ c.center(1, 49, 7, "PLACE / BUY / LAY ON THE NUMBERS ABOVE", crapsMuted)
+ c.center(1, 49, 9, "C O M E", crapsRed)
+ c.box(0, 10, 50, 16)
+ c.center(1, 49, 11, "FIELD", crapsGold)
+ c.center(1, 49, 13, "( 2 ) 3 4 9 10 11 ( 12 )", crapsInk)
+ twelve := s.Field12
+ if twelve == 0 {
+ twelve = 2
+ }
+ c.center(1, 49, 15, fmt.Sprintf("2 PAYS DOUBLE 12 PAYS %d:1", twelve), crapsGold)
+ c.box(0, 16, 50, 19)
+ c.center(1, 49, 18, "DON'T PASS / BAR 12", crapsInk)
+ c.box(0, 19, 50, 22)
+ c.center(1, 49, 21, "PASS LINE", crapsGold)
+ c.box(0, 22, 50, 25)
+ c.center(1, 49, 24, "FREE ODDS / BEHIND THE LINE", crapsInk)
+ // Centre action: four bonus spots, four hardways, six propositions, horn/hop.
+ bonus := []struct {
+ label, pays string
+ }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}}
+ xs := []int{50, 56, 63, 69, 76}
+ for i, spot := range bonus {
+ c.box(xs[i], 0, xs[i+1], 5)
+ c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsGold)
+ c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsMuted)
+ }
+ for i, n := range []int{4, 6, 8, 10} {
+ x, y := 50+(i%2)*13, 5+(i/2)*4
+ c.box(x, y, x+13, y+4)
+ c.center(x+1, x+12, y+1, fmt.Sprintf("HARD %d", n), crapsInk)
+ pay := 7
+ if n == 6 || n == 8 {
+ pay = 9
+ }
+ c.center(x+1, x+12, y+3, fmt.Sprintf("%d+%d %d:1", n/2, n/2, pay), crapsGold)
+ }
+ props := []string{"SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS"}
+ odds := []string{"4:1", "7:1", "15:1", "30:1", "15:1", "30:1"}
+ px := []int{50, 59, 67, 76}
+ for i, label := range props {
+ col, y := i%3, 13+(i/3)*4
+ x, x2 := px[col], px[col+1]
+ c.box(x, y, x2, y+4)
+ c.center(x+1, x2-1, y+1, label, crapsRed)
+ c.center(x+1, x2-1, y+3, odds[i], crapsInk)
+ }
+ c.box(50, 21, 63, 25)
+ c.center(51, 62, 22, "HORN", crapsGold)
+ c.center(51, 62, 24, "2 3 11 12", crapsMuted)
+ c.box(63, 21, 76, 25)
+ c.center(64, 75, 22, "HOP", crapsGold)
+ c.center(64, 75, 24, "15:1 / 30:1", crapsMuted)
+ return c.render(mode, unicode)
+}
+func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string {
+ if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 {
+ return ""
+ }
+ patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}}
+ top, bottom, v, pip := "+-------+", "+-------+", "|", "o"
+ if unicode {
+ top, bottom, v, pip = "╭───────╮", "╰───────╯", "│", "●"
+ }
+ spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true}
+ if style == "light" {
+ spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ }
+ var out strings.Builder
+ for row := 0; row < 5; row++ {
+ out.WriteString(" ")
+ for i, n := range dice {
+ line := top
+ if row == 4 {
+ line = bottom
+ } else if row > 0 {
+ line = v + " " + strings.ReplaceAll(patterns[n][row-1], "o", pip) + " " + v
+ }
+ out.WriteString(color.Render(mode, spec, line))
+ if i == 0 {
+ out.WriteString(" ")
+ }
+ }
+ if row == 2 {
+ out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1])))
+ }
+ out.WriteByte('\n')
+ }
+ return out.String()
+}
+func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, styles ...string) string {
+ if s == nil {
+ return ""
+ }
+ style := "dark"
+ if len(styles) > 0 {
+ style = styles[0]
+ }
+ var out strings.Builder
+ title := func(text string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, text)
+ }
+ subtle := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: 250, Bg: -1}, text) }
+ point := "COME-OUT"
+ if s.Point != 0 {
+ point = fmt.Sprintf("POINT %d", s.Point)
+ }
+ out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n")
+ out.WriteString(renderCrapsTrackers(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsFelt(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ if len(s.Queue) > 0 {
+ out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n")
+ if len(s.Queue) > 1 {
+ out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n")
+ }
+ }
+ hint := "Place a bet, then roll when ready."
+ if s.Phase != "waiting" && s.Phase != "" {
+ hint = "Your pace. Roll when ready."
+ if s.ShootIn > 0 {
+ hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn)
+ } else if s.DealerIn > 0 {
+ hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn)
+ }
+ }
+ if s.AutoRoll {
+ hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn)
+ }
+ out.WriteString(subtle(" "+hint) + "\n")
+ return out.String()
+}
diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go
new file mode 100644
index 0000000..9d75630
--- /dev/null
+++ b/internal/game/render_craps_test.go
@@ -0,0 +1,130 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func TestCrapsHalfTableGeometryAndPuck(t *testing.T) {
+ for _, unicode := range []bool{false, true} {
+ for _, point := range []int{0, 4, 5, 6, 8, 9, 10} {
+ s := &casino.CrapsSnapshot{Point: point, Field12: 3}
+ art := renderCrapsFelt(s, "none", unicode)
+ rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n")
+ if len(rows) != 26 {
+ t.Fatal(len(rows))
+ }
+ for _, row := range rows {
+ if color.VisibleLen(row) != 77 {
+ t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row)
+ }
+ }
+ // Check connected box edges at the main rail and centre-action partition.
+ if unicode {
+ for _, r := range []rune{'┌', '┬', '┼', '├', '┤', '┴', '┘'} {
+ if !strings.ContainsRune(art, r) {
+ t.Fatalf("missing joint %c", r)
+ }
+ }
+ }
+ v := "|"
+ if unicode {
+ v = "│"
+ }
+ want := v + "OFF" + v
+ if point != 0 {
+ want = v + "ON " + v
+ }
+ if strings.Count(art, want) != 1 {
+ t.Fatalf("missing/duplicate puck %d: %s", point, art)
+ }
+ if !strings.Contains(art, "12 PAYS 3:1") {
+ t.Fatal("field variant invisible")
+ }
+ for _, bet := range []string{"DON'T COME", "C O M E", "FIELD", "DON'T PASS", "PASS LINE", "HARD 4", "HARD 6", "HARD 8", "HARD 10", "SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS", "HORN", "HOP"} {
+ if strings.Count(art, bet) != 1 {
+ t.Fatalf("missing or duplicate %q", bet)
+ }
+ }
+ }
+ }
+ t.Log("\n" + renderCrapsSnapshot(&casino.CrapsSnapshot{Point: 6, Field12: 3, Dice: [2]int{3, 3}, Queue: []string{"Alice", "Bob"}, Made: []int{4, 5}, Seen: []int{2, 4, 5, 6, 9}, Phase: "betting", ShootIn: 10, DealerIn: 70}, "none", true))
+}
+func TestCrapsTrackersAndDiceStyles(t *testing.T) {
+ s := &casino.CrapsSnapshot{Made: []int{4, 6}, Seen: []int{2, 12}}
+ art := renderCrapsTrackers(s, "xterm256", true)
+ plain := plainRoulette(art)
+ for _, token := range []string{"[ 4*]", "[ 5 ]", "[ 6*]", " 2*", "12*"} {
+ if !strings.Contains(plain, token) {
+ t.Fatal("tracker missing", token)
+ }
+ }
+ if strings.Count(art, color.BgCode("xterm256", 220)) != 4 {
+ t.Fatal("wrong number of filled tracker tokens")
+ }
+ if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) {
+ t.Fatal("felt not dark green")
+ }
+ g := &Game{}
+ p := player.New("Alice")
+ p.Options = map[string]any{"card_style": "light", "unicode": true}
+ sess := &net.Session{Player: p}
+ s.Dice = [2]int{2, 5}
+ light := g.renderCasinoSnapshot(sess, s)
+ p.Options["card_style"] = "dark"
+ dark := g.renderCasinoSnapshot(sess, s)
+ if light == dark {
+ t.Fatal("card_style not wired to craps")
+ }
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ for _, unicode := range []bool{false, true} {
+ lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "light")
+ dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode, "dark")
+ if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 0)) {
+ t.Fatal("incorrect dice background")
+ }
+ pip := "o"
+ if unicode {
+ pip = "●"
+ }
+ if strings.Count(strings.ReplaceAll(plainRoulette(lit), "Total:", ""), pip) != a+b {
+ t.Fatal("wrong pip count", a, b)
+ }
+ if plainRoulette(lit) != plainRoulette(dim) {
+ t.Fatal("style changed dice outcome")
+ }
+ }
+ }
+ }
+}
+func TestCrapsRemindersFollowArt(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
+ g.Casino.Join(1, casino.TableConfig{ID: "c", Game: casino.GameCraps}, "alice")
+ conn := &casinoTestConn{}
+ p := player.New("alice")
+ sess := &net.Session{Player: p, Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ for _, display := range []string{"full", "compact"} {
+ p.Options = map[string]any{"casino_display": display}
+ conn.data = nil
+ g.emitCasinoEvents(g.Casino.Table(1, "c").Status("alice"))
+ text := plainRoulette(string(conn.data))
+ felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet <amount> on <target>")
+ if felt < 0 || commands < felt {
+ t.Fatal("reminder above graphic", text)
+ }
+ for _, old := range []string{"Next in order:", "Fire points made:", "ATS numbers:", "Phase:", "Timer: 0"} {
+ if strings.Contains(text, old) {
+ t.Fatal("raw status leaked", old)
+ }
+ }
+ }
+}
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index d69b5bf..1e7f971 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -598,6 +598,20 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int
func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
rules := g.colorize(sess, "casino_action", cfg.RulesText())
+ if cfg.Game == casino.GameCraps {
+ snap := &casino.CrapsSnapshot{Phase: "waiting"}
+ if g.Casino != nil && sess.Player != nil {
+ if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil {
+ for _, event := range table.Status(sess.Player.Name) {
+ if view, ok := event.Snapshot.(*casino.CrapsSnapshot); ok {
+ snap = view
+ break
+ }
+ }
+ }
+ }
+ return rules + "\n" + g.renderCasinoSnapshot(sess, snap)
+ }
if cfg.Game == casino.GameRoulette {
snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
// Rules views draw a betting board too; use the live table's history
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index 7b83807..0c5b62e 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -56,6 +56,7 @@ func TestRouletteCombinationTargetParsing(t *testing.T) {
{"10", "on", "split", "1", "2"},
{"10", "on", "split", "1-2"},
{"10", "split", "1-2"},
+ {"10", "split", "1", "2"},
{"10", "on", "street", "1", "2", "3"},
{"10", "on", "street", "3", "1", "2"},
{"10", "on", "corner", "1", "2", "4", "5"},