aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-02 17:51:46 -0400
committerhistoria <[not public]>2026-10-02 17:51:46 -0400
commitcac16d1725c46c82a2e62b84c93f7090b2ce0746 (patch)
treee54f6624bdeb47421587656757ef3ce4fd7ebb3f /internal
parent4f40f116ba884111c9a66849e620ff9fbc67530a (diff)
downloadthehouseoficarus-cac16d1725c46c82a2e62b84c93f7090b2ce0746.tar.gz
feat: craps table color improvements, die improvements
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/command_hints_test.go3
-rw-r--r--internal/casino/craps.go72
-rw-r--r--internal/casino/craps_clock.go92
-rw-r--r--internal/casino/craps_clock_test.go75
-rw-r--r--internal/casino/craps_projection.go35
-rw-r--r--internal/casino/craps_review_test.go51
-rw-r--r--internal/casino/craps_rules.go4
-rw-r--r--internal/casino/craps_simplified_test.go23
-rw-r--r--internal/casino/craps_test.go26
-rw-r--r--internal/casino/craps_ux.go6
-rw-r--r--internal/casino/craps_variants.go8
-rw-r--r--internal/casino/manager.go17
-rw-r--r--internal/game/casino.go7
-rw-r--r--internal/game/casino_ux.go12
-rw-r--r--internal/game/casino_ux_test.go15
-rw-r--r--internal/game/craps_test.go2
-rw-r--r--internal/game/craps_variants_test.go6
-rw-r--r--internal/game/render_craps.go203
-rw-r--r--internal/game/render_craps_dice_test.go82
-rw-r--r--internal/game/render_craps_test.go58
20 files changed, 510 insertions, 287 deletions
diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go
index 353d292..bd15883 100644
--- a/internal/casino/command_hints_test.go
+++ b/internal/casino/command_hints_test.go
@@ -3,7 +3,6 @@ package casino
import (
"strings"
"testing"
- "time"
)
func hintHas(commands []MenuCommand, command string) bool {
@@ -68,7 +67,7 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) {
}
tb.players.get("a").autoRoll = false
tb.bet("b", "pass", 10, &testWallet{chips: 1000})
- tb.tickElapsed(10 * time.Second)
+ tb.advance(10)
if !hintHas(tb.commands("a"), "roll") || hintHas(tb.commands("b"), "roll") {
t.Fatal(v, "wrong shooter hints")
}
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 3bbb08f..286fe0a 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -5,7 +5,6 @@ import (
"math/rand"
"strconv"
"strings"
- "time"
)
type crapsBet struct {
@@ -16,29 +15,29 @@ type crapsBet struct {
}
type crapsPlayer struct {
Participant
- bets []*crapsBet
- lastSpot string
- lastBet int
- autoRoll bool
- autoElapsed time.Duration
+ bets []*crapsBet
+ lastSpot string
+ lastBet int
+ autoRoll bool
+ autoTicks int
}
func (p *crapsPlayer) participantPtr() *Participant { return &p.Participant }
type crapsTable struct {
tableCore[*crapsPlayer]
- rng *rand.Rand
- queue []string
- point int
- dice [2]int
- rolled bool
- made map[int]bool
- seen map[int]bool
- roundElapsed time.Duration
- multiplayer bool
- lastCall bool
- pendingRoll time.Duration // manual roll: time left before the dice resolve
- countdown int // simplified craps: automatic-roll countdown lines emitted
+ rng *rand.Rand
+ queue []string
+ point int
+ dice [2]int
+ rolled bool
+ made map[int]bool
+ seen map[int]bool
+ roundTicks int
+ multiplayer bool
+ lastCall bool
+ pendingRollTicks int // manual roll: ticks left before the dice resolve
+ countdown int // simplified craps: automatic-roll countdown lines emitted
}
type CrapsBetView struct {
Name, Target string
@@ -52,7 +51,7 @@ type CrapsSnapshot struct {
Queue []string
Made, Seen []int
Bets []CrapsBetView
- Remaining int // legacy snapshot field; presentation uses seconds below
+ Remaining int // legacy snapshot field; presentation uses ticks below
ShootIn int
DealerIn int
AutoRoll bool
@@ -78,15 +77,15 @@ func (t *crapsTable) view() *CrapsSnapshot {
if t.hasLiveBets() {
if t.Config.Craps.variant() == "simplified" {
// One automatic roll: the snapshot counts down the shared window.
- s.ShootIn = crapsSeconds(crapsSimplifiedDelay - t.roundElapsed)
+ s.ShootIn = max(0, crapsSimplifiedTicks-t.roundTicks)
} else if t.multiplayer {
- s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed)
- s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed)
+ s.ShootIn = max(0, crapsBetTicks-t.roundTicks)
+ s.DealerIn = max(0, crapsBetTicks+crapsDealerTicks-t.roundTicks)
}
}
if p := t.shooter(); p != nil && p.autoRoll {
s.AutoRoll = true
- s.AutoIn = max(s.ShootIn, crapsSeconds(crapsAutoDelay-p.autoElapsed))
+ s.AutoIn = max(s.ShootIn, max(0, crapsAutoTicks-p.autoTicks))
}
for n := 2; n <= 12; n++ {
if t.made[n] {
@@ -133,10 +132,9 @@ func (t *crapsTable) join(name string) []Event {
return append([]Event{e, t.menu(name)}, timing...)
}
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.pendingRollTicks = 0
}
t.removeSeatLocked(name)
for i, n := range t.queue {
@@ -356,7 +354,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event {
}
var results []Event
for _, p := range t.players.ordered() {
- p.autoElapsed = 0
+ p.autoTicks = 0
var lines []settlementLine
failed := false
var live []*crapsBet
@@ -437,7 +435,7 @@ func (t *crapsTable) finishCrapsControls() []Event {
}
if p.autoRoll {
p.autoRoll = false
- p.autoElapsed = 0
+ p.autoTicks = 0
events = append(events, t.private(p.Name, "{casino_emphasis}Autoroll complete{/} — all your bets have settled. Manual play resumed."))
}
if p.LeaveAfter {
@@ -457,7 +455,7 @@ func (t *crapsTable) finishCrapsControls() []Event {
}
return events
}
-func (t *crapsTable) tick() []Event { return t.tickElapsed(600 * time.Millisecond) }
+func (t *crapsTable) tick() []Event { return t.advance(1) }
func (t *crapsTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -476,7 +474,7 @@ func (t *crapsTable) action(name, action string) []Event {
return append(events, t.syncRoundClock()...)
}
if t.Config.Craps.variant() == "simplified" && (action == "roll" || action == "autoroll") {
- return []Event{t.private(name, "Simplified craps rolls by itself 3 seconds after a bet; there is no manual roll.")}
+ return []Event{t.private(name, "Simplified craps rolls by itself 3 ticks after a bet; there is no manual roll.")}
}
if action == "autoroll" {
if !p.HasBet {
@@ -484,9 +482,9 @@ func (t *crapsTable) action(name, action string) []Event {
}
if !p.autoRoll {
p.autoRoll = true
- p.autoElapsed = 0
+ p.autoTicks = 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."), t.reminder(name)}
+ return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 ticks on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play."), t.reminder(name)}
}
if action == "roll" {
events := t.syncRoundClock()
@@ -496,13 +494,13 @@ func (t *crapsTable) action(name, action string) []Event {
if !t.hasLiveBets() {
return append(events, t.private(name, "Place a bet, then roll whenever you're ready."))
}
- 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))))
+ if t.multiplayer && t.roundTicks < crapsBetTicks {
+ return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d ticks{/}. Bets remain open until you roll.", crapsBetTicks-t.roundTicks)))
}
- if t.pendingRoll > 0 {
+ if t.pendingRollTicks > 0 {
return append(events, t.private(name, "The dice are already in the air."))
}
- t.pendingRoll = crapsManualRollDelay
+ t.pendingRollTicks = crapsManualRollTicks
shake := t.playerAction(name, fmt.Sprintf("{casino_emphasis}%s shakes the dice...{/}", name), "{casino_emphasis}You shake the dice...{/}")
return append(events, shake)
}
@@ -607,12 +605,12 @@ func (t *crapsTable) refundAll() []Event {
p.bets = nil
p.HasBet = false
p.autoRoll = false
- p.autoElapsed = 0
+ p.autoTicks = 0
}
t.point = 0
t.rolled = false
t.countdown = 0
- t.pendingRoll = 0
+ t.pendingRollTicks = 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 ab28329..5357c93 100644
--- a/internal/casino/craps_clock.go
+++ b/internal/casino/craps_clock.go
@@ -1,30 +1,26 @@
package casino
-import (
- "fmt"
- "time"
-)
+import "fmt"
+// Every craps deadline is a tick count; the engine tick length decides the
+// wall-clock duration.
const (
- crapsBetDelay = 10 * time.Second
- crapsDealerDelay = 60 * time.Second
- crapsAutoDelay = 3 * time.Second
+ crapsBetTicks = 10
+ crapsDealerTicks = 60
+ crapsAutoTicks = 3
// Simplified craps has no manual roll: one shared automatic countdown
// starts with the first live bet and resolves every wager.
- crapsSimplifiedDelay = 3 * time.Second
+ crapsSimplifiedTicks = 3
// 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
+ // then resolves the dice three ticks later.
+ crapsManualRollTicks = 3
+
+ // Last-call warning fires this many ticks before the shooter may roll.
+ crapsLastCallTicks = 3
)
-func crapsSeconds(d time.Duration) int {
- if d <= 0 {
- return 0
- }
- return int((d + time.Second - 1) / time.Second)
-}
func (t *crapsTable) shooter() *crapsPlayer {
if len(t.queue) == 0 {
return nil
@@ -53,13 +49,18 @@ func (t *crapsTable) activePlayers() int {
// multiplayer or starting a new round opens a window; adding bets never
// extends it. A solo player has no forced deadline at all.
func (t *crapsTable) syncRoundClock() []Event {
+ // Cancel immediately, not on the next tick: a new bet must not inherit
+ // a roll requested for wagers that have since been taken down.
+ if !t.hasLiveBets() {
+ t.pendingRollTicks = 0
+ }
multi := t.activePlayers() > 1
if t.Config.Craps.variant() == "simplified" {
// Simplified craps never opens a manual shooting window; the first
// live bet starts one shared automatic countdown for everyone.
if !t.hasLiveBets() {
t.phase = phaseWaiting
- t.roundElapsed = 0
+ t.roundTicks = 0
t.countdown = 0
t.lastCall = false
t.multiplayer = multi
@@ -68,18 +69,18 @@ func (t *crapsTable) syncRoundClock() []Event {
fresh := t.phase == phaseWaiting
t.multiplayer = multi
if fresh {
- t.roundElapsed = 0
+ t.roundTicks = 0
t.countdown = 0
}
t.phase = phasePlaying
if fresh && multi {
- return []Event{t.event("{casino_emphasis}Bets open{/} — the dice roll in 3 seconds.", true)}
+ return []Event{t.event("{casino_emphasis}Bets open{/} — the dice roll in 3 ticks.", true)}
}
return nil
}
if !t.hasLiveBets() {
t.phase = phaseWaiting
- t.roundElapsed = 0
+ t.roundTicks = 0
t.lastCall = false
t.multiplayer = multi
return nil
@@ -87,54 +88,54 @@ func (t *crapsTable) syncRoundClock() []Event {
fresh := t.phase == phaseWaiting || (multi && !t.multiplayer)
t.multiplayer = multi
if fresh {
- t.roundElapsed = 0
+ t.roundTicks = 0
t.lastCall = false
}
t.phase = phasePlaying
- if multi && t.roundElapsed < crapsBetDelay {
+ if multi && t.roundTicks < crapsBetTicks {
t.phase = phaseBetting
}
if fresh && multi {
- return []Event{t.event("{casino_emphasis}Bets open{/} — 10 seconds before the shooter can roll. Betting stays open until the dice roll.", true)}
+ return []Event{t.event("{casino_emphasis}Bets open{/} — 10 ticks before the shooter can roll. Betting stays open until the dice roll.", true)}
}
return nil
}
-func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event {
+func (t *crapsTable) advance(ticks int) []Event {
t.mu.Lock()
defer t.mu.Unlock()
events := t.syncRoundClock()
+ if ticks <= 0 {
+ return events
+ }
if t.Config.Craps.variant() == "simplified" {
- if elapsed <= 0 || !t.hasLiveBets() {
+ if !t.hasLiveBets() {
return events
}
- t.roundElapsed += elapsed
+ t.roundTicks += ticks
shooter := t.shooter()
if shooter == nil {
return events
}
- for t.countdown < len(crapsCountdownFlavor) && t.roundElapsed >= time.Duration(t.countdown+1)*time.Second {
+ for t.countdown < len(crapsCountdownFlavor) && t.roundTicks >= t.countdown+1 {
events = append(events, crapsCountdownEvent(t, shooter.Name, t.countdown))
t.countdown++
}
- if t.roundElapsed >= crapsSimplifiedDelay {
+ if t.roundTicks >= crapsSimplifiedTicks {
events = append(events, t.roll()...)
}
return events
}
- if elapsed <= 0 {
- return events
- }
- if t.pendingRoll > 0 {
+ if t.pendingRollTicks > 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
+ t.pendingRollTicks = 0
return events
}
- t.pendingRoll -= elapsed
- if t.pendingRoll <= 0 {
- t.pendingRoll = 0
+ t.pendingRollTicks -= ticks
+ if t.pendingRollTicks <= 0 {
+ t.pendingRollTicks = 0
return append(events, t.roll()...)
}
return events
@@ -142,32 +143,33 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event {
if !t.hasLiveBets() {
return events
}
- t.roundElapsed += elapsed
+ t.roundTicks += ticks
p := t.shooter()
if p == nil {
return events
}
if p.autoRoll && p.HasBet {
- p.autoElapsed += elapsed
+ p.autoTicks += ticks
}
- if t.multiplayer && !t.lastCall && t.roundElapsed >= crapsBetDelay-3*time.Second {
+ if t.multiplayer && !t.lastCall && t.roundTicks >= crapsBetTicks-crapsLastCallTicks {
t.lastCall = true
- events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! 3 seconds until %s can shoot!{/}", p.Name), true))
+ remaining := max(1, crapsBetTicks-t.roundTicks)
+ events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! %d ticks until %s can shoot!{/}", remaining, p.Name), true))
}
- if t.multiplayer && t.roundElapsed < crapsBetDelay {
+ if t.multiplayer && t.roundTicks < crapsBetTicks {
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), t.reminder(p.Name))
+ events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in %d ticks if needed.", p.Name, crapsDealerTicks), true), t.reminder(p.Name))
}
t.phase = phasePlaying
- if (p.autoRoll && p.HasBet && p.autoElapsed >= crapsAutoDelay) || (t.multiplayer && t.roundElapsed >= crapsBetDelay+crapsDealerDelay) {
+ if (p.autoRoll && p.HasBet && p.autoTicks >= crapsAutoTicks) || (t.multiplayer && t.roundTicks >= crapsBetTicks+crapsDealerTicks) {
return append(events, t.roll()...)
}
return events
}
-// crapsCountdownFlavor broadcasts one line per second before the automatic
+// crapsCountdownFlavor broadcasts one line per tick before the automatic
// simplified roll. Each step carries a few variants: the public line names
// the shooter, the private line speaks to them.
var crapsCountdownFlavor = [][3][2]string{
@@ -207,6 +209,6 @@ func (t *Table) StopAutoRoll(name string) ([]Event, bool) {
return nil, false
}
p.autoRoll = false
- p.autoElapsed = 0
+ p.autoTicks = 0
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 f9ad445..97758f8 100644
--- a/internal/casino/craps_clock_test.go
+++ b/internal/casino/craps_clock_test.go
@@ -3,7 +3,6 @@ package casino
import (
"strings"
"testing"
- "time"
)
func crapsEventText(events []Event) string {
@@ -24,11 +23,11 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) {
if tb.view().ShootIn != 10 || tb.view().DealerIn != 70 {
t.Fatal(tb.view())
}
- if text := crapsEventText(tb.tickElapsed(6 * time.Second)); strings.Contains(text, "Last call") {
+ if text := crapsEventText(tb.advance(6)); strings.Contains(text, "Last call") {
t.Fatal("early warning")
}
- events := tb.tickElapsed(time.Second)
- if !strings.Contains(crapsEventText(events), "Last call for bets! 3 seconds until alice can shoot!") {
+ events := tb.advance(1)
+ if !strings.Contains(crapsEventText(events), "Last call for bets! 3 ticks until alice can shoot!") {
t.Fatal(events)
}
if !events[0].Public {
@@ -38,7 +37,7 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) {
if tb.dice != [2]int{} {
t.Fatal("early shoot")
}
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.view().ShootIn != 0 || tb.view().DealerIn != 60 {
t.Fatal(tb.view())
}
@@ -50,15 +49,15 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) {
if len(tb.players.get("alice").bets) != 1 {
t.Fatal("late removal rejected")
}
- if strings.Contains(crapsEventText(tb.tickElapsed(59*time.Second)), "Last call") {
+ if strings.Contains(crapsEventText(tb.advance(59)), "Last call") {
t.Fatal("warning repeated")
}
if tb.dice != [2]int{} {
t.Fatal("early forced roll")
}
- tb.tickElapsed(time.Second)
+ tb.advance(1)
if tb.dice == [2]int{} {
- t.Fatal("60 second dealer fallback missing")
+ t.Fatal("dealer fallback missing")
}
}
func TestCrapsSoloAndRosterTransitions(t *testing.T) {
@@ -66,12 +65,12 @@ func TestCrapsSoloAndRosterTransitions(t *testing.T) {
tb.join("alice")
w := &testWallet{chips: 1000}
tb.bet("alice", "fire", 1, w)
- tb.tickElapsed(500 * time.Second)
+ tb.advance(500)
if tb.dice != [2]int{} || tb.view().DealerIn != 0 || tb.view().ShootIn != 0 {
t.Fatal("solo deadline", tb.view())
}
tb.action("alice", "roll")
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.dice == [2]int{} {
t.Fatal("solo could not roll")
}
@@ -79,7 +78,7 @@ func TestCrapsSoloAndRosterTransitions(t *testing.T) {
if tb.view().ShootIn != 10 {
t.Fatal("join did not give bettors a window")
}
- tb.tickElapsed(4 * time.Second)
+ tb.advance(4)
tb.bet("alice", "field", 10, w)
if tb.view().ShootIn != 6 {
t.Fatal("bet extended timer")
@@ -104,15 +103,15 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) {
w := &testWallet{chips: 1000}
tb.bet("alice", "fire", 1, w)
tb.action("alice", "autoroll")
- tb.tickElapsed(2999 * time.Millisecond)
+ tb.advance(2)
if tb.dice != [2]int{} {
t.Fatal("autoroll fired early")
}
- tb.tickElapsed(time.Millisecond)
+ tb.advance(1)
if tb.dice == [2]int{} {
t.Fatal("autoroll did not fire")
}
- if p := tb.players.get("alice"); p.HasBet && p.autoElapsed != 0 {
+ if p := tb.players.get("alice"); p.HasBet && p.autoTicks != 0 {
t.Fatal("pace not reset")
}
handle := &Table{tableBase: tb.tableBase, game: tb}
@@ -121,7 +120,7 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) {
t.Fatal("stop failed")
}
before := tb.dice
- tb.tickElapsed(500 * time.Second)
+ tb.advance(500)
if tb.dice != before || !tb.hasParticipant("alice") {
t.Fatal("stop rolled or departed")
}
@@ -134,12 +133,12 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) {
w = &testWallet{chips: 100}
tb.bet("alice", "field", 10, w)
tb.action("alice", "autoroll")
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.players.get("alice").autoRoll || tb.players.get("alice").HasBet {
t.Fatal("autoroll survived last bet")
}
before = tb.dice
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.dice != before {
t.Fatal("extra roll after completion")
}
@@ -151,11 +150,11 @@ func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) {
w := &testWallet{chips: 100}
tb.bet("alice", "field", 10, w)
tb.action("alice", "autoroll")
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.dice != [2]int{} {
t.Fatal("autoroll stole multiplayer betting time")
}
- tb.tickElapsed(7 * time.Second)
+ tb.advance(7)
if tb.dice == [2]int{} {
t.Fatal("autoroll failed when ready")
}
@@ -166,38 +165,36 @@ func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) {
w = &testWallet{chips: 100}
tb.bet("bob", "field", 10, w)
tb.action("bob", "autoroll")
- tb.tickElapsed(11 * time.Second)
+ tb.advance(11)
if tb.dice != [2]int{} {
t.Fatal("non-shooter autorolled")
}
}
-func TestCrapsManagerUsesElapsedCadence(t *testing.T) {
- for _, step := range []time.Duration{100 * time.Millisecond, 600 * time.Millisecond, time.Second} {
- m := NewManager(4)
- cfg := TableConfig{ID: "c", Game: GameCraps}
- m.Join(1, cfg, "alice")
- w := &testWallet{chips: 100}
- m.Bet(1, "c", "alice", "field", 10, w)
- m.Action(1, "c", "alice", "autoroll")
- tb := m.Table(1, "c").game.(*crapsTable)
- for elapsed := step; elapsed < 3*time.Second; elapsed += step {
- m.TickElapsed(step)
- if tb.dice != [2]int{} {
- t.Fatal("early", step)
- }
- }
- m.TickElapsed(step)
- if tb.dice == [2]int{} {
- t.Fatal("wrong cadence", step)
+func TestCrapsManagerTickCadence(t *testing.T) {
+ m := NewManager(4)
+ cfg := TableConfig{ID: "c", Game: GameCraps}
+ m.Join(1, cfg, "alice")
+ w := &testWallet{chips: 100}
+ m.Bet(1, "c", "alice", "field", 10, w)
+ m.Action(1, "c", "alice", "autoroll")
+ tb := m.Table(1, "c").game.(*crapsTable)
+ for tick := 1; tick < crapsAutoTicks; tick++ {
+ m.Tick()
+ if tb.dice != [2]int{} {
+ t.Fatal("early tick", tick)
}
}
+ m.Tick()
+ if tb.dice == [2]int{} {
+ t.Fatal("autoroll did not fire on schedule")
+ }
}
func TestCrapsConsolidatedResultsAndBetNumbers(t *testing.T) {
tb := crapsFixture()
tb.join("alice")
w := &testWallet{chips: 1000}
events := tb.bet("alice", "pass", 10, w)
- if !strings.Contains(crapsEventText(events), "ROLL OUTCOMES") {
+ if !strings.Contains(crapsEventText(events), "OUTCOMES") {
t.Fatal("no post-bet roll table")
}
tb.bet("alice", "dontpass", 10, w)
diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go
index 1377452..65f09ce 100644
--- a/internal/casino/craps_projection.go
+++ b/internal/casino/craps_projection.go
@@ -99,7 +99,9 @@ func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool {
// 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.
+// specific, so the best pair outcome stands in for that total. The line wraps
+// on token boundaries with a hanging indent so the continuation lines line up
+// under the first total.
func (t *crapsTable) rollTable(p *crapsPlayer) string {
if len(p.bets) == 0 {
return ""
@@ -116,8 +118,11 @@ func (t *crapsTable) rollTable(p *crapsPlayer) string {
if t.Config.Craps.variant() == "die_rich" {
lo, hi = 1, 6
}
- var sb strings.Builder
- sb.WriteString("{casino_emphasis}ROLL OUTCOMES{/}:")
+ type outcomeToken struct {
+ tagged string
+ width int
+ }
+ tokens := make([]outcomeToken, 0, hi-lo+1)
for total := lo; total <= hi; total++ {
token := fmt.Sprintf("%d(-)", total)
tag := "dim"
@@ -127,7 +132,29 @@ func (t *crapsTable) rollTable(p *crapsPlayer) string {
case found[total] && best[total] < 0:
token, tag = fmt.Sprintf("%d(%d)", total, best[total]), "casino_result_loss"
}
- fmt.Fprintf(&sb, " {%s}%s{/}", tag, token)
+ tokens = append(tokens, outcomeToken{fmt.Sprintf("{%s}%s{/}", tag, token), len(token)})
+ }
+ const (
+ head = "OUTCOMES:"
+ width = 80
+ )
+ indent := strings.Repeat(" ", len(head)+1)
+ var sb strings.Builder
+ sb.WriteString("{casino_emphasis}" + head + "{/}")
+ lineLen := len(head)
+ for i, tk := range tokens {
+ // The first token always follows the heading; later tokens wrap whole,
+ // never splitting a total from its payout.
+ if i > 0 && lineLen+1+tk.width > width {
+ sb.WriteString("\n" + indent)
+ lineLen = len(indent)
+ sb.WriteString(tk.tagged)
+ lineLen += tk.width
+ continue
+ }
+ sb.WriteByte(' ')
+ sb.WriteString(tk.tagged)
+ lineLen += 1 + tk.width
}
return sb.String()
}
diff --git a/internal/casino/craps_review_test.go b/internal/casino/craps_review_test.go
new file mode 100644
index 0000000..04f72b2
--- /dev/null
+++ b/internal/casino/craps_review_test.go
@@ -0,0 +1,51 @@
+package casino
+
+import "testing"
+
+func TestCrapsTakenDownRollDoesNotCarryIntoNewBet(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("alice")
+ w := &testWallet{chips: 100}
+ tb.bet("alice", "field", 10, w)
+ tb.action("alice", "roll")
+ tb.tick()
+ tb.action("alice", "unbet")
+ if tb.pendingRollTicks != 0 {
+ t.Fatal("taking down the last bet did not cancel the pending roll")
+ }
+ tb.bet("alice", "field", 10, w)
+ tb.advance(10)
+ if tb.dice != [2]int{} || !tb.hasLiveBets() {
+ t.Fatal("new wager inherited a canceled roll")
+ }
+ tb.action("alice", "roll")
+ tb.advance(crapsManualRollTicks)
+ if tb.dice == [2]int{} || tb.hasLiveBets() {
+ t.Fatal("fresh manual roll did not settle")
+ }
+}
+
+func TestSimplifiedCrapsDeparturePreservesCountdown(t *testing.T) {
+ for _, departing := range []string{"alice", "bob"} {
+ t.Run(departing, func(t *testing.T) {
+ tb := variantFixture("simplified")
+ tb.join("alice")
+ tb.join("bob")
+ tb.join("carol")
+ tb.bet("carol", "pass", 10, &testWallet{chips: 100})
+ tb.tick()
+ tb.leave(departing)
+ if tb.countdown != 1 || tb.view().ShootIn != 2 {
+ t.Fatal("departure reset the shared countdown")
+ }
+ events := tb.tick()
+ if len(events) != 1 || tb.countdown != 2 {
+ t.Fatal("countdown repeated after departure", events)
+ }
+ tb.tick()
+ if tb.dice == [2]int{} || tb.hasLiveBets() {
+ t.Fatal("shared roll did not settle on schedule")
+ }
+ })
+ }
+}
diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go
index 88649a2..a9e52af 100644
--- a/internal/casino/craps_rules.go
+++ b/internal/casino/craps_rules.go
@@ -93,9 +93,9 @@ func (c TableConfig) CrapsRulesText() string {
"Small (2-6) / Tall (8-12) pay 34:1; All pays 175:1. Any 7 resets ATS.\n"+
"Place/buy/hard and come odds are OFF on come-out; lay and don't odds work.\n"+
"Exact payout units required; no fractional-credit rounding except commission.\n"+
- "Solo: roll anytime. Multiplayer: 10 seconds to bet, then 60 seconds for the shooter.\n"+
+ "Solo: roll anytime. Multiplayer: 10 ticks to bet, then 60 ticks for the shooter.\n"+
"Bets remain open until the roll. Dice rotate on seven-out.\n"+
- "autoroll: repeat every 3 seconds on your turn (multiplayer waits 10 seconds).\n"+
+ "autoroll: repeat every 3 ticks on your turn (multiplayer waits 10 ticks).\n"+
"unbet: remove every non-contract bet and all free odds; pass/come contracts stay.\n"+
"stop cancels autoroll without leaving; in manual mode stop waits for live bets.\n"+
"Disconnect forfeits live bets.\n"+
diff --git a/internal/casino/craps_simplified_test.go b/internal/casino/craps_simplified_test.go
index b900c4f..705204b 100644
--- a/internal/casino/craps_simplified_test.go
+++ b/internal/casino/craps_simplified_test.go
@@ -3,7 +3,6 @@ package casino
import (
"strings"
"testing"
- "time"
)
func TestSimplifiedCrapsAll36SettlementsAndHouseEdge(t *testing.T) {
@@ -47,12 +46,9 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) {
t.Fatal("countdown started before the clock", text)
}
if v := tb.view(); v.ShootIn != 3 || v.DealerIn != 0 {
- t.Fatal("no 3-second automatic window", v.ShootIn, v.DealerIn)
+ t.Fatal("no 3-tick automatic window", v.ShootIn, v.DealerIn)
}
- if text := crapsEventText(tb.tickElapsed(600 * time.Millisecond)); strings.Contains(strings.ToLower(text), "dice") {
- t.Fatal("countdown fired early", text)
- }
- events = tb.tickElapsed(600 * time.Millisecond) // 1.2s
+ events = tb.advance(1)
text := crapsEventText(events)
if !strings.Contains(text, "dice") || !strings.Contains(text, "alice") {
t.Fatal("first countdown line missing", text)
@@ -60,17 +56,14 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) {
if !events[0].Public || events[0].PrivateMessage == "" {
t.Fatal("countdown must broadcast and speak to the shooter", events[0])
}
- if text := crapsEventText(tb.tickElapsed(600 * time.Millisecond)); strings.Contains(strings.ToLower(text), "luck") {
- t.Fatal("second countdown fired early", text)
- }
- events = tb.tickElapsed(600 * time.Millisecond) // 2.4s
+ events = tb.advance(1)
if text := crapsEventText(events); !strings.Contains(text, "luck") {
t.Fatal("second countdown line missing", text)
}
if tb.dice != [2]int{} {
- t.Fatal("rolled before 3 seconds", tb.dice)
+ t.Fatal("rolled before the final tick", tb.dice)
}
- events = tb.tickElapsed(600 * time.Millisecond) // 3.0s
+ events = tb.advance(1)
text = strings.ToLower(crapsEventText(events))
if !strings.Contains(text, "shake") {
t.Fatal("third countdown line missing", crapsEventText(events))
@@ -98,10 +91,10 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) {
tb.join("bob")
w = &testWallet{chips: 1000}
tb.bet("alice", "pass", 10, w)
- if text := crapsEventText(tb.syncRoundClock()); strings.Contains(text, "10 seconds") {
+ if text := crapsEventText(tb.syncRoundClock()); strings.Contains(text, "10 ticks") {
t.Fatal("simplified opened a manual betting window", text)
}
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.dice == [2]int{} {
t.Fatal("multiplayer countdown did not roll")
}
@@ -112,7 +105,7 @@ func TestSimplifiedCrapsCommandsHideRollAndRebet(t *testing.T) {
tb.join("alice")
w := &testWallet{chips: 100}
tb.bet("alice", "pass", 10, w)
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
cmds := tb.commands("alice")
for _, c := range cmds {
if c.Command == "roll" || c.Command == "autoroll" {
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
index 3d92e5e..7f3bcfa 100644
--- a/internal/casino/craps_test.go
+++ b/internal/casino/craps_test.go
@@ -5,7 +5,6 @@ import (
"math/rand"
"strings"
"testing"
- "time"
)
func crapsFixture() *crapsTable {
@@ -227,6 +226,23 @@ func TestCrapsATSAndSevenReset(t *testing.T) {
t.Fatal("late bonus allowed")
}
}
+
+// TestCrapsATSSeenTracksWithoutBonusBet: the Small/Tall tracker records every
+// roll even when nobody holds an All/Small/Tall wager, so the display always
+// shows progress and entices a bet.
+func TestCrapsATSSeenTracksWithoutBonusBet(t *testing.T) {
+ tb := crapsFixture()
+ tb.join("a")
+ w := &testWallet{chips: 100}
+ tb.bet("a", "pass", 10, w)
+ tb.resolveRoll(2, 3) // point 5
+ tb.resolveRoll(3, 3) // 6
+ tb.resolveRoll(4, 4) // 8
+ snap := tb.board("a").(*CrapsSnapshot)
+ if len(snap.Seen) != 3 {
+ t.Fatalf("ATS tracker did not track without a bonus bet: %v", snap.Seen)
+ }
+}
func TestCrapsTimersControlsAndRefunds(t *testing.T) {
tb := crapsFixture()
tb.join("a")
@@ -237,7 +253,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
if tb.dice != [2]int{} {
t.Fatal("early roll")
}
- tb.tickElapsed(10 * time.Second)
+ tb.advance(10)
if tb.phase != phasePlaying {
t.Fatal(tb.phase)
}
@@ -253,7 +269,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
if tb.dice != [2]int{} {
t.Fatal("roll resolved before the shake")
}
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
if tb.dice == [2]int{} {
t.Fatal("no roll")
}
@@ -261,12 +277,12 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
tb.join("a")
w = &testWallet{chips: 100}
tb.bet("a", "pass", 10, w)
- tb.tickElapsed(120 * time.Second)
+ tb.advance(120)
if tb.dice != [2]int{} {
t.Fatal("solo players must never be forced to roll")
}
tb.action("a", "roll")
- tb.tickElapsed(3 * time.Second)
+ tb.advance(3)
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 f41ad67..7e44e39 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -17,9 +17,9 @@ func (t *crapsTable) commands(name string) []MenuCommand {
if p.lastBet > 0 {
commands = append(commands,
MenuCommand{Command: "bet", Desc: fmt.Sprintf("Rebet %d on pass (also: rebet)", p.lastBet)},
- MenuCommand{Command: "bet <amount>", Desc: "New amount on pass; the dice roll 3 seconds after a bet"})
+ MenuCommand{Command: "bet <amount>", Desc: "New amount on pass; the dice roll 3 ticks after a bet"})
} else {
- commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 seconds after a bet"})
+ commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 ticks after a bet"})
}
} else {
target := "<target>"
@@ -31,7 +31,7 @@ func (t *crapsTable) commands(name string) []MenuCommand {
}
if p.HasBet {
if !simplified {
- if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) && t.pendingRoll == 0 {
+ if t.shooter() == p && (!t.multiplayer || t.roundTicks >= crapsBetTicks) && t.pendingRollTicks == 0 {
commands = append(commands, MenuCommand{Command: "roll", Desc: "Shoot now"})
}
if !p.autoRoll {
diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go
index 096bfc5..8331d70 100644
--- a/internal/casino/craps_variants.go
+++ b/internal/casino/craps_variants.go
@@ -199,7 +199,7 @@ func (c CrapsConfig) variantRulesText() string {
rules := ""
switch c.variant() {
case "simplified":
- rules = "Pass only, one roll of two dice. Payouts: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. Totals 5/6/7/8/9 lose. Winning bets also return their stake. No point, odds, come, place, side bets or multi-roll wagers. The dice roll automatically 3 seconds after the first bet; there is no manual roll. House edge: 1/36 (approximately 2.78%). A loss passes the dice."
+ rules = "Pass only, one roll of two dice. Payouts: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. Totals 5/6/7/8/9 lose. Winning bets also return their stake. No point, odds, come, place, side bets or multi-roll wagers. The dice roll automatically 3 ticks after the first bet; there is no manual roll. House edge: 1/36 (approximately 2.78%). 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":
@@ -218,13 +218,13 @@ func (c CrapsConfig) variantRulesText() string {
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."
}
- cadence := "Solo roll anytime; multiplayer waits 10 seconds, then dealer rolls after 60 seconds."
- autoroll := "Bets stay open until the roll. autoroll repeats every 3 seconds, respecting the betting window."
+ cadence := "Solo roll anytime; multiplayer waits 10 ticks, then dealer rolls after 60 ticks."
+ autoroll := "Bets stay open until the roll. autoroll repeats every 3 ticks, respecting the betting window."
betLine := "Bet: bet <amount> on pass; the felt is authoritative for this edition."
stopLine := "stop cancels autoroll; manual stop waits for live bets."
if c.variant() == "simplified" {
betLine = "Bet: bet <amount> — always on pass; bare bet rebets your last wager."
- cadence = "The dice roll automatically 3 seconds after the first bet."
+ cadence = "The dice roll automatically 3 ticks after the first bet."
autoroll = ""
stopLine = "stop waits for live bets."
}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 5fa6439..c9dde5a 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"sync"
- "time"
)
type Manager struct {
@@ -162,12 +161,10 @@ func (m *Manager) snapshotMachines() []*Machine {
return machines
}
-func (m *Manager) Tick() []Event { return m.TickElapsed(600 * time.Millisecond) }
-
-// TickElapsed supplies the engine cadence to games with real-time deadlines.
-// Legacy games retain their tick-based rules. No wall clock or goroutine is
-// introduced, so owner ordering and deterministic tests remain intact.
-func (m *Manager) TickElapsed(elapsed time.Duration) []Event {
+// Tick advances every table and machine by one engine tick. Game rules are
+// tick-based, so no wall-clock duration crosses this boundary; owner ordering
+// and deterministic tests remain intact.
+func (m *Manager) Tick() []Event {
tables := m.snapshotTables()
sort.Slice(tables, func(i, j int) bool {
if tables[i].RoomID != tables[j].RoomID {
@@ -177,11 +174,7 @@ func (m *Manager) TickElapsed(elapsed time.Duration) []Event {
})
var events []Event
for _, table := range tables {
- if game, ok := table.game.(interface{ tickElapsed(time.Duration) []Event }); ok {
- events = append(events, game.tickElapsed(elapsed)...)
- } else {
- events = append(events, table.tick()...)
- }
+ events = append(events, table.tick()...)
}
machines := m.snapshotMachines()
sort.Slice(machines, func(i, j int) bool {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 21f9627..dc9f8f0 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -6,7 +6,6 @@ import (
"os"
"strconv"
"strings"
- "time"
"unicode/utf8"
"thehouseoficarus/internal/cardart"
@@ -542,11 +541,7 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
func (g *Game) CasinoTick() {
if g.Casino != nil {
- ms := 600
- if g.Ticks != nil {
- ms = g.Ticks.TickLengthMilliseconds()
- }
- g.emitCasinoEvents(g.Casino.TickElapsed(time.Duration(ms) * time.Millisecond))
+ g.emitCasinoEvents(g.Casino.Tick())
}
}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index d65cfb9..2650d47 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -260,16 +260,10 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool {
var casinoTicksPattern = regexp.MustCompile(`([0-9]+) ticks`)
-// Casino logic counts engine ticks; present deadlines in actual configured seconds.
+// Casino logic counts engine ticks; deadlines are presented in ticks too, so
+// the configured tick cadence never rewrites a deadline the player can count.
func (g *Game) casinoTimeText(text string) string {
- ms := 600
- if g.Ticks != nil {
- ms = g.Ticks.TickLengthMilliseconds()
- }
- return casinoTicksPattern.ReplaceAllStringFunc(text, func(s string) string {
- n, _ := strconv.Atoi(strings.Fields(s)[0])
- return fmt.Sprintf("%g seconds", float64(n)*float64(ms)/1000)
- })
+ return casinoTicksPattern.ReplaceAllString(text, "${1}t")
}
// betRangeWords renders the wager range in words for one-line hints.
diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go
index 1a06f89..c83db27 100644
--- a/internal/game/casino_ux_test.go
+++ b/internal/game/casino_ux_test.go
@@ -228,3 +228,18 @@ func TestRebetRepeatsWager(t *testing.T) {
t.Fatalf("rebet did not target banker: %q", stripANSI(string(conn.data)))
}
}
+
+// TestCasinoTimeTextUsesTicks pins the presentation unit: authored "N ticks"
+// deadlines display as "Nt" rather than being rewritten into seconds.
+func TestCasinoTimeTextUsesTicks(t *testing.T) {
+ g := &Game{}
+ for in, want := range map[string]string{
+ "Your turn | 25 ticks remaining": "Your turn | 25t remaining",
+ "Betting closes in 5 ticks.": "Betting closes in 5t.",
+ "no deadline here": "no deadline here",
+ } {
+ if got := g.casinoTimeText(in); got != want {
+ t.Fatalf("casinoTimeText(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go
index 27610bd..6712455 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", "Total: 7", "POINT 6"} {
+ for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "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 46f5be6..c538caa 100644
--- a/internal/game/craps_variants_test.go
+++ b/internal/game/craps_variants_test.go
@@ -34,7 +34,7 @@ func TestCrapsVariantRendering(t *testing.T) {
t.Fatal("dice rendered instead of cards")
}
} else {
- if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") {
+ if strings.Contains(plain, "FIRE") || strings.Contains(plain, "SMALL") {
t.Fatal(v, "unsupported bonus display")
}
}
@@ -59,8 +59,8 @@ func TestCrapsVariantRendering(t *testing.T) {
}
for n := 1; n <= 6; n++ {
text := renderCrapsDice([2]int{n, 0}, "none", false)
- if strings.Count(text, "+-------+") != 2 {
- t.Fatal("single die needs a top and bottom outline", text)
+ if strings.Count(text, "\n") != 5 {
+ t.Fatal("single die needs three pip rows and two caps", 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/render_craps.go b/internal/game/render_craps.go
index 7ec015a..ce9825f 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -20,8 +20,7 @@ const (
crapsMarked
crapsPuckOn
crapsPuckOff
- crapsFire
- crapsAts
+ crapsBonus
)
var crapsPaint = []color.ColorSpec{
@@ -32,8 +31,7 @@ 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
+ {Fg: 229, Bg: 24, Bold: true}, // Fire/ATS tracker: yellow on blue
}
type crapsCell struct {
@@ -94,8 +92,39 @@ func (c *crapsCanvas) text(x, y int, s string, ink int) {
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)
}
+
+// crapsJoints maps a union of box edges (N=1, E=2, S=4, W=8) to its glyph.
+var crapsJoints = map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'}
+
+// crapsEdgeRune is the box glyph for a cell's edges, downgraded to ASCII when
+// unicode is off.
+func crapsEdgeRune(cell crapsCell, unicode bool) rune {
+ if cell.edges == 0 {
+ return ' '
+ }
+ var r rune
+ switch {
+ case cell.edges&5 == 0:
+ r = '─'
+ case cell.edges&10 == 0:
+ r = '│'
+ default:
+ r = crapsJoints[cell.edges]
+ }
+ if !unicode {
+ switch r {
+ case '─':
+ r = '-'
+ case '│':
+ r = '|'
+ default:
+ r = '+'
+ }
+ }
+ return r
+}
+
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
@@ -113,27 +142,7 @@ func (c *crapsCanvas) render(mode string, unicode bool) string {
}
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 = '+'
- }
- }
- }
+ r = crapsEdgeRune(cell, unicode)
}
run.WriteRune(r)
}
@@ -174,48 +183,38 @@ func crapsDrawPuck(c *crapsCanvas, x, y int, on, unicode bool) {
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 := newCrapsCanvas(7)
+ // The whole Fire/ATS tracker sits on one blue field.
+ c.fill(0, 0, 76, 6, crapsBonus)
c.box(0, 0, 76, 2)
- c.box(0, 5, 38, 8)
- c.box(38, 5, 76, 8)
- c.text(3, 1, "FIRE", crapsFire)
+ c.box(0, 4, 38, 6)
+ c.box(38, 4, 76, 6)
+ c.text(3, 1, "FIRE", crapsBonus)
for i, n := range []int{4, 5, 6, 8, 9, 10} {
- ink, mark := crapsFire, " "
+ ink := crapsBonus
if crapsHas(s.Made, n) {
- ink, mark = crapsMarked, "*"
+ ink = crapsMarked
}
- c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink)
+ c.text(15+i*9, 1, fmt.Sprintf("[%2d]", n), ink)
}
bounds := []int{0, 25, 50, 76}
for i, payout := range []string{"24:1", "249:1", "999:1"} {
lo, hi := bounds[i], bounds[i+1]
- c.box(lo, 2, hi, 5)
- 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.box(lo, 2, hi, 4)
+ c.center(lo+1, hi-1, 3, fmt.Sprintf("%d POINTS %s", i+4, payout), crapsBonus)
}
- c.text(2, 6, "ALL SMALL", crapsAts)
- c.text(40, 6, "TALL", crapsAts)
+ c.text(2, 5, "SMALL", crapsBonus)
+ c.text(40, 5, "TALL", crapsBonus)
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 := crapsAts, " "
+ ink := crapsBonus
if crapsHas(s.Seen, n) {
- ink, mark = crapsMarked, "*"
+ ink = crapsMarked
}
- c.text(x, 6, fmt.Sprintf("%2d%s ", n, mark), ink)
- }
- 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], crapsAts)
- c.center(lo+1, hi-1, 10, row[1], crapsAts)
+ c.text(x, 5, fmt.Sprintf("[%2d]", n), ink)
}
return c.render(mode, unicode)
}
@@ -261,14 +260,16 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
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"}}
+ // The Fire and ATS spots share one blue field.
+ bonus := []struct{ label, pays string }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}}
xs := []int{50, 56, 63, 69, 76}
- for i, spot := range bonus {
+ for i := 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)
+ }
+ c.fill(50, 0, 76, 5, crapsBonus)
+ for i, spot := range bonus {
+ c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsBonus)
+ c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsBonus)
}
for i, n := range []int{4, 6, 8, 10} {
x, y := 50+(i%2)*13, 5+(i/2)*4
@@ -315,12 +316,12 @@ func renderHighPointFelt(s *casino.CrapsSnapshot, mode string, unicode bool) str
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"}
+ nums := []int{4, 5, 6, 7, 8, 9, 10}
+ labels := []string{"4", "5", "SIX", "7", "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)
+ x := 3 + i*10
+ c.box(x, 5, x+10, 10)
+ c.center(x+1, x+9, 9, labels[i], crapsGold)
if s.Point == n {
crapsDrawPuck(c, x+3, 6, true, unicode)
}
@@ -392,47 +393,59 @@ func renderDieRichFelt(s *casino.CrapsSnapshot, mode string, unicode bool) strin
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.
+// renderCrapsDice uses half-block caps and white backgrounds to make a
+// square tile in a terminal's roughly 1:2 character cells. Pips are inset;
+// monochrome clients get an outlined face rather than disconnected dots.
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"}}
- pip := "o"
- if unicode {
- pip = "●"
- }
- top, bottom, v := "+-------+", "+-------+", "|"
- if unicode {
- top, bottom, v = "╭───────╮", "╰───────╯", "│"
- }
- spec := color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ patterns := [7][3]string{
+ {},
+ {" ", " ● ", " "},
+ {" ● ", " ", " ● "},
+ {" ● ", " ● ", " ● "},
+ {" ● ● ", " ", " ● ● "},
+ {" ● ● ", " ● ", " ● ● "},
+ {" ● ● ", " ● ● ", " ● ● "},
+ }
+ colored := mode != "" && mode != "none"
+ face := color.ColorSpec{Fg: 0, Bg: 15}
+ cap := color.ColorSpec{Fg: 15, Bg: -1}
var out strings.Builder
- for row := 0; row < 7; row++ {
+ for row := 0; row < 5; row++ {
out.WriteString(" ")
for i, n := range dice {
if n == 0 {
continue
}
- var line string
- switch row {
- case 0:
- line = top
- case 6:
- line = bottom
- 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 {
+ if i > 0 {
out.WriteString(" ")
}
+ line := ""
+ spec := face
+ if row == 0 || row == 4 {
+ line = " "
+ if unicode && colored {
+ line, spec = "▄▄▄▄▄▄▄", cap
+ if row == 4 {
+ line = "▀▀▀▀▀▀▀"
+ }
+ } else if !colored {
+ line = "+-----+"
+ }
+ } else {
+ line = patterns[n][row-1]
+ if !colored {
+ line = "|" + strings.TrimPrefix(strings.TrimSuffix(line, " "), " ") + "|"
+ }
+ if !unicode {
+ line = strings.ReplaceAll(line, "●", "o")
+ }
+ }
+ out.WriteString(color.Render(mode, spec, line))
}
- if row == 3 {
+ 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')
@@ -506,16 +519,16 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
hint = ""
if s.Variant == "simplified" {
if s.ShootIn > 0 {
- hint = fmt.Sprintf("The dice roll in %ds.", s.ShootIn)
+ hint = fmt.Sprintf("The dice roll in %dt.", s.ShootIn)
}
} else if s.ShootIn > 0 {
- hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn)
+ hint = fmt.Sprintf("Bets open. Shooter ready in %dt.", s.ShootIn)
} else if s.DealerIn > 0 {
- hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn)
+ hint = fmt.Sprintf("Shooter ready. Dealer rolls in %dt; bets still open.", s.DealerIn)
}
}
if s.AutoRoll {
- hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn)
+ hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %dt", s.AutoIn)
}
if hint != "" {
out.WriteString(subtle(" "+hint) + "\n")
diff --git a/internal/game/render_craps_dice_test.go b/internal/game/render_craps_dice_test.go
new file mode 100644
index 0000000..e691fd5
--- /dev/null
+++ b/internal/game/render_craps_dice_test.go
@@ -0,0 +1,82 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func TestCrapsDiceTilesAndFallbacks(t *testing.T) {
+ for _, mode := range []string{"xterm256", "ansi", "none", ""} {
+ for _, unicode := range []bool{true, false} {
+ for a := 1; a <= 6; a++ {
+ for b := 0; b <= 6; b++ {
+ art := renderCrapsDice([2]int{a, b}, mode, unicode)
+ plain := plainRoulette(art)
+ rows := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
+ if len(rows) != 5 {
+ t.Fatal("tile height", rows)
+ }
+ width := 9 // two-column indent and one seven-column die
+ if b != 0 {
+ width += 10
+ }
+ for i, row := range rows {
+ if i == 2 {
+ row = strings.TrimSuffix(row, fmt.Sprintf(" Total: %d", a+b))
+ }
+ if color.VisibleLen(row) != width {
+ t.Fatalf("uneven dice: %q", plain)
+ }
+ }
+ pip := "●"
+ if !unicode {
+ pip = "o"
+ for _, r := range plain {
+ if r > 127 {
+ t.Fatal("Unicode in ASCII fallback")
+ }
+ }
+ }
+ if strings.Count(strings.ReplaceAll(plain, "Total:", ""), pip) != a+b {
+ t.Fatal("incorrect pips", a, b, plain)
+ }
+ if mode == "none" || mode == "" {
+ if strings.Contains(art, "\x1b") || !strings.Contains(plain, "+-----+") {
+ t.Fatal("missing monochrome outline", art)
+ }
+ } else {
+ if !strings.Contains(art, color.BgCode(mode, 15)) || !strings.Contains(art, color.FgCode(mode, 0)) {
+ t.Fatal("missing black-on-white face", art)
+ }
+ if unicode && (!strings.Contains(plain, "▄▄▄▄▄▄▄") || !strings.Contains(plain, "▀▀▀▀▀▀▀")) {
+ t.Fatal("missing block caps", art)
+ }
+ }
+ }
+ }
+ }
+ }
+ for _, dice := range [][2]int{{0, 0}, {-1, 2}, {7, 1}, {1, -1}, {1, 7}} {
+ if renderCrapsDice(dice, "xterm256", true) != "" {
+ t.Fatal("rendered invalid dice", dice)
+ }
+ }
+}
+
+func TestHighPointFeltIncludesSevenPoint(t *testing.T) {
+ for n := 4; n <= 10; n++ {
+ art := plainRoulette(renderHighPointFelt(&casino.CrapsSnapshot{Point: n}, "none", true))
+ if strings.Count(art, "ON ") != 1 {
+ t.Fatalf("point %d missing its puck:\n%s", n, art)
+ }
+ for _, row := range strings.Split(strings.TrimSuffix(art, "\n"), "\n") {
+ if color.VisibleLen(row) != crapsCanvasWidth {
+ t.Fatal("felt width changed")
+ }
+ }
+ }
+}
diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go
index b3cc1d8..49bda4a 100644
--- a/internal/game/render_craps_test.go
+++ b/internal/game/render_craps_test.go
@@ -57,22 +57,70 @@ 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))
}
+
+// TestCrapsFireTrackerHighlightsMadePoints pins the Fire half of the tracker:
+// made points highlight, un-made points do not, and no asterisk is involved.
+func TestCrapsFireTrackerHighlightsMadePoints(t *testing.T) {
+ s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 9}}
+ art := renderCrapsTrackers(s, "xterm256", true)
+ fireRow := ""
+ for _, line := range strings.Split(art, "\n") {
+ if strings.Contains(line, "FIRE") {
+ fireRow = line
+ }
+ }
+ if got := strings.Count(fireRow, color.BgCode("xterm256", 220)); got != 2 {
+ t.Fatalf("expected 2 highlighted fire points, got %d: %q", got, plainRoulette(art))
+ }
+ plain := plainRoulette(art)
+ for _, want := range []string{"[ 4]", "[ 9]"} {
+ if !strings.Contains(plain, want) {
+ t.Fatalf("missing fire point %s: %q", want, plain)
+ }
+ }
+ if strings.Contains(plain, "*") {
+ t.Fatalf("fire tracker still uses an asterisk: %q", plain)
+ }
+}
func TestCrapsTrackersAndDiceStyles(t *testing.T) {
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*"} {
+ // Both trackers bracket every number; made/rolled numbers are shown by the
+ // highlight alone, with no asterisk.
+ for _, token := range []string{"[ 4]", "[ 5]", "[ 6]", "[ 2]", "[12]"} {
if !strings.Contains(plain, token) {
t.Fatal("tracker missing", token)
}
}
+ if strings.Contains(plain, "*") || strings.Contains(plain, "= rolled") {
+ t.Fatalf("tracker still uses asterisks or the rolled legend: %q", plain)
+ }
if strings.Count(art, color.BgCode("xterm256", 220)) != 4 {
- t.Fatal("wrong number of filled tracker tokens")
+ t.Fatal("wrong number of highlighted tracker tokens")
+ }
+ if !strings.Contains(art, color.BgCode("xterm256", 24)) {
+ t.Fatal("tracker background is not blue")
+ }
+ if strings.Contains(art, color.BgCode("xterm256", 124)) {
+ t.Fatal("tracker still uses a red background")
+ }
+ // The ATS payout panel now lives only on the main felt, and the fire odds
+ // share a single row.
+ if strings.Contains(plain, "DISTINCT POINTS") || strings.Contains(plain, "34:1") || strings.Contains(plain, "175:1") {
+ t.Fatalf("tracker still shows a payout panel: %q", plain)
+ }
+ if !strings.Contains(plain, "4 POINTS 24:1") {
+ t.Fatalf("fire odds not collapsed to one row: %q", plain)
+ }
+ feltArt := renderCrapsFelt(s, "xterm256", true)
+ if !strings.Contains(feltArt, color.BgCode("xterm256", 24)) {
+ t.Fatal("felt Fire/ATS spots are not blue")
}
- 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(feltArt, color.BgCode("xterm256", 124)) {
+ t.Fatal("felt still uses a red bonus background")
}
- if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) {
+ if !strings.Contains(feltArt, color.BgCode("xterm256", 22)) {
t.Fatal("felt not dark green")
}
g := &Game{}