diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/craps.go | 24 | ||||
| -rw-r--r-- | internal/casino/craps_clock.go | 23 | ||||
| -rw-r--r-- | internal/casino/craps_clock_test.go | 24 | ||||
| -rw-r--r-- | internal/casino/craps_projection.go | 64 | ||||
| -rw-r--r-- | internal/casino/craps_test.go | 5 | ||||
| -rw-r--r-- | internal/casino/craps_ux.go | 45 | ||||
| -rw-r--r-- | internal/casino/craps_wagers.go | 124 | ||||
| -rw-r--r-- | internal/game/casino_look_test.go | 47 | ||||
| -rw-r--r-- | internal/game/casino_rules_list_test.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 6 | ||||
| -rw-r--r-- | internal/game/craps_test.go | 2 | ||||
| -rw-r--r-- | internal/game/craps_variants_test.go | 12 | ||||
| -rw-r--r-- | internal/game/look_room.go | 6 | ||||
| -rw-r--r-- | internal/game/render_craps.go | 238 | ||||
| -rw-r--r-- | internal/game/render_craps_test.go | 19 |
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 { |
