aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 16:16:17 -0400
committerhistoria <[not public]>2026-09-30 16:16:17 -0400
commita4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73 (patch)
tree00c9567e620f331a59dd44f1f1fa779ecd94a845 /internal/game
parent724079f8de1ae69965c6267dca3d7fc3cc86afa2 (diff)
downloadthehouseoficarus-a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73.tar.gz
feat: additional craps variants added, standardizard casino command hints
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go43
-rw-r--r--internal/game/casino_commands.go83
-rw-r--r--internal/game/casino_commands_test.go118
-rw-r--r--internal/game/casino_ux.go12
-rw-r--r--internal/game/craps_variants_test.go103
-rw-r--r--internal/game/render_craps.go55
-rw-r--r--internal/game/render_craps_cards.go44
7 files changed, 400 insertions, 58 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index e9334fc..c2c4c4f 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -695,18 +695,8 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
commands[i].Desc += " [unavailable: insufficient funds]"
}
}
- if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat {
- var repeat string
- for _, c := range commands {
- if c.Command == "bet" {
- repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc
- break
- }
- }
- return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop")
- }
if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance {
- var hints []string
+ var hints []casino.MenuCommand
for _, c := range commands {
label := c.Command
switch c.Command {
@@ -722,14 +712,9 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
if c.Cost > 0 {
label += fmt.Sprintf(" (+%d)", c.Cost)
}
- category := "command"
- if c.Cost > casinoAffordable(sess.Player) {
- label += " [unavailable]"
- category = "casino_warning"
- }
- hints = append(hints, g.colorize(sess, category, label))
+ hints = append(hints, casino.MenuCommand{Command: label, Cost: c.Cost})
}
- return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ")
+ return g.renderCasinoCommands(sess, balance, heading, hints)
}
return g.renderCasinoCommands(sess, balance, heading, commands)
}
@@ -796,27 +781,7 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri
// casinoMenuCommands formats an aligned, colored command list.
func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string {
- width := 0
- for _, c := range commands {
- if n := utf8.RuneCountInString(c.Command); n > width {
- width = n
- }
- }
- var sb strings.Builder
- for _, c := range commands {
- padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command))
- category := "command"
- if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) {
- category = "casino_warning"
- }
- cmd := g.colorize(sess, category, padded)
- if c.Desc != "" {
- fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc)
- } else {
- fmt.Fprintf(&sb, " %s\n", cmd)
- }
- }
- return strings.TrimRight(sb.String(), "\n")
+ return g.casinoCommandColumns(sess, commands)
}
func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go
new file mode 100644
index 0000000..f4ca79f
--- /dev/null
+++ b/internal/game/casino_commands.go
@@ -0,0 +1,83 @@
+package game
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+// Wrap before Session.WriteLine: generic prose wrapping strips alignment from
+// overlong lines. Every casino menu uses the same two-space command indent and
+// hanging description column, capped at 80 even when prose wrapping is off.
+func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCommand) string {
+ limit := 80
+ if sess.Player != nil {
+ if wrap := sess.Player.OptionInt("wrap_width"); wrap > 0 {
+ limit = min(limit, wrap)
+ }
+ }
+ width := 0
+ for _, c := range commands {
+ n := utf8.RuneCountInString(c.Command)
+ // Unusually long commands get their own lines instead of squeezing every
+ // other command's description into a tiny column.
+ if n <= limit/2 {
+ width = max(width, n)
+ }
+ }
+ var lines []string
+ for _, c := range commands {
+ if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) {
+ continue
+ }
+ n := utf8.RuneCountInString(c.Command)
+ if n > limit/2 {
+ for _, line := range casinoWrapWords(c.Command, max(1, limit-2)) {
+ lines = append(lines, " "+g.colorize(sess, "command", line))
+ }
+ for _, line := range casinoWrapWords(c.Desc, max(1, limit-4)) {
+ lines = append(lines, " "+line)
+ }
+ continue
+ }
+ if strings.TrimSpace(c.Desc) == "" {
+ lines = append(lines, " "+g.colorize(sess, "command", c.Command))
+ continue
+ }
+ indent := width + 4
+ desc := casinoWrapWords(c.Desc, max(1, limit-indent))
+ cmd := c.Command + strings.Repeat(" ", width-n)
+ lines = append(lines, " "+g.colorize(sess, "command", cmd)+" "+desc[0])
+ for _, line := range desc[1:] {
+ lines = append(lines, strings.Repeat(" ", indent)+line)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// Input is uncolored command/description text; paint only after measuring.
+func casinoWrapWords(text string, width int) []string {
+ var lines []string
+ line := ""
+ for _, word := range strings.Fields(text) {
+ if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) > width {
+ lines = append(lines, line)
+ line = ""
+ }
+ runes := []rune(word)
+ for len(runes) > width {
+ lines = append(lines, string(runes[:width]))
+ runes = runes[width:]
+ }
+ if line != "" {
+ line += " "
+ }
+ line += string(runes)
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ return lines
+}
diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go
new file mode 100644
index 0000000..7820650
--- /dev/null
+++ b/internal/game/casino_commands_test.go
@@ -0,0 +1,118 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) {
+ commands := []casino.MenuCommand{
+ {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"},
+ {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"},
+ {Command: "unbet <target> / unbet all", Desc: "Remove one bet / every optional bet"},
+ {Command: "table", Desc: "View the felt"},
+ {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"},
+ {Command: "decline", Desc: "Continue without insurance (also the timeout default)"},
+ {Command: "bet <amount> on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"},
+ {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"},
+ {Command: "spin", Desc: "Spin using your current wager"},
+ {Command: "autospin", Desc: "Start automatic betting"},
+ {Command: "unicode", Desc: strings.Repeat("é", 100)},
+ }
+ for _, wrap := range []int{0, 40, 60, 80, 120} {
+ p := player.New("a")
+ p.Options = map[string]any{"wrap_width": wrap}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{}
+ text := g.casinoMenuCommands(sess, commands)
+ sess.WriteLine(text)
+ limit := 80
+ if wrap > 0 {
+ limit = min(limit, wrap)
+ }
+ for _, line := range strings.Split(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") {
+ if line == "" {
+ continue
+ }
+ if color.VisibleLen(line) > limit {
+ t.Fatalf("wrap %d overlong %d: %q", wrap, color.VisibleLen(line), line)
+ }
+ if !strings.HasPrefix(line, " ") {
+ t.Fatalf("wrap %d lost indent: %q", wrap, line)
+ }
+ }
+ expected := strings.ReplaceAll(plainRoulette(text), "\r\n", "\n")
+ actual := strings.TrimRight(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n")
+ if expected != actual {
+ t.Fatalf("session reflowed prewrapped menu at %d", wrap)
+ }
+ }
+}
+func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) {
+ sess := &net.Session{Player: player.New("a")}
+ g := &Game{}
+ commands := []casino.MenuCommand{
+ {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: table rules"},
+ {Command: "table", Desc: "View the felt"},
+ }
+ text := plainRoulette(g.casinoMenuCommands(sess, commands))
+ lines := strings.Split(text, "\n")
+ if len(lines) != 2 {
+ t.Fatal("short commands should not wrap", text)
+ }
+ col := strings.Index(lines[0], "Place")
+ if col != strings.Index(lines[1], "View") {
+ t.Fatal("descriptions not aligned", text)
+ }
+ commands[0].Desc = strings.Repeat("long description ", 10)
+ text = plainRoulette(g.casinoMenuCommands(sess, commands))
+ lines = strings.Split(text, "\n")
+ for _, line := range lines[1 : len(lines)-1] {
+ if !strings.HasPrefix(line, strings.Repeat(" ", col)) {
+ t.Fatal("missing hanging indent", text)
+ }
+ }
+}
+func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) {
+ p := player.New("a")
+ p.Options = map[string]any{"casino_display": "compact"}
+ p.Credits = 10
+ sess := &net.Session{Player: p}
+ g := &Game{}
+ menu := &casino.MenuView{Heading: "Your turn", Commands: []casino.MenuCommand{
+ {Command: "hit", Desc: "Take a card"}, {Command: "stand", Desc: "Hold"}, {Command: "double", Cost: 20},
+ }}
+ text := plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, ""))
+ if strings.Contains(text, "double") || !strings.Contains(text, " h:hit") || !strings.Contains(text, " st:stand") {
+ t.Fatal(text)
+ }
+ menu.ShowBalance = true
+ menu.Repeat = true
+ menu.Commands = []casino.MenuCommand{{Command: "table", Desc: "View the felt"}}
+ text = plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, ""))
+ if strings.Contains(text, "bet") || strings.Contains(text, "sitout") {
+ t.Fatal("compact mode invented actions", text)
+ }
+}
+func TestSimplifiedFeltSpacingAndNoIdlePrompt(t *testing.T) {
+ text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: "simplified"}, "none", true)
+ if strings.Contains(text, "Place a bet, then roll when ready.") {
+ t.Fatal(text)
+ }
+ rows := strings.Split(text, "\n")
+ for i, row := range rows {
+ if strings.Contains(row, "PASS ONLY") {
+ if i+2 >= len(rows) || strings.Trim(rows[i+1], "│ ") != "" || !strings.HasPrefix(rows[i+2], "└") {
+ t.Fatal("expected exactly one blank felt row", text)
+ }
+ return
+ }
+ }
+ t.Fatal("missing pass label")
+}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 450480f..a2e7330 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -129,20 +129,14 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
}
}
if len(args) > 0 {
+ // Reuse the game's current-state menu instead of advertising actions
+ // (insurance, split, rebet, etc.) that may not be legal right now.
+ g.emitCasinoEvents(t.Status(sess.Player.Name))
commands := []casino.MenuCommand{
- {Command: "bet / rebet", Desc: "Repeat your previous wager"},
- {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"},
- {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"},
- {Command: "stop", Desc: "Leave now, or after your committed round"},
{Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"},
{Command: "table rules", Desc: "Review this table’s rules"},
{Command: "help casino", Desc: "Quick guides to all casino games"},
}
- if t.Config.Game == casino.GameBlackjack {
- commands = append(commands,
- casino.MenuCommand{Command: "h / st / dd / sp", Desc: "Hit / stand / double / split (s and d still move)"},
- casino.MenuCommand{Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"})
- }
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands))
}
}
diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go
new file mode 100644
index 0000000..76d1424
--- /dev/null
+++ b/internal/game/craps_variants_test.go
@@ -0,0 +1,103 @@
+package game
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/world"
+)
+
+func TestCrapsVariantRendering(t *testing.T) {
+ for _, v := range []string{"simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} {
+ for _, unicode := range []bool{false, true} {
+ for _, mode := range []string{"none", "xterm256"} {
+ for _, style := range []string{"dark", "light"} {
+ dice := [2]int{4, 2}
+ if v == "die_rich" {
+ dice[1] = 0
+ }
+ text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: v, Point: 4, Dice: dice}, mode, unicode, style)
+ plain := plainRoulette(text)
+ if !strings.Contains(plain, strings.ToUpper(strings.ReplaceAll(v, "_", " "))+" CRAPS") {
+ t.Fatal(v, "title")
+ }
+ if v == "diceless" {
+ for _, token := range []string{"PACK A", "PACK B", "Cards total: 6", "FIRE"} {
+ if !strings.Contains(plain, token) {
+ t.Fatal(v, token)
+ }
+ }
+ if strings.Contains(plain, "Total:") {
+ t.Fatal("dice rendered instead of cards")
+ }
+ } else {
+ if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") {
+ t.Fatal(v, "unsupported bonus display")
+ }
+ }
+ if v == "die_rich" && !strings.Contains(plain, "Total: 4") {
+ t.Fatal("single die absent")
+ }
+ if mode == "xterm256" && (v == "die_rich" || v == "diceless") {
+ bg := 0
+ if style == "light" {
+ bg = 15
+ }
+ if !strings.Contains(text, color.BgCode(mode, bg)) {
+ t.Fatal(v, "style")
+ }
+ }
+ }
+ }
+ }
+ }
+ for n := 1; n <= 6; n++ {
+ text := renderCrapsDice([2]int{n, 0}, "none", false, "dark")
+ if strings.Count(text, "+-------+") != 2 {
+ t.Fatal("must render exactly one die", text)
+ }
+ }
+}
+
+func TestShippedCrapsGallery(t *testing.T) {
+ w := world.New(filepath.Join("..", "..", "data"))
+ variants := []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"}
+ for i, v := range variants {
+ id := 6185 + i
+ room, err := w.LoadRoom(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(room.CasinoTables) != 1 {
+ t.Fatal(id, "missing table")
+ }
+ cfg := room.CasinoTables[0]
+ if cfg.Game != casino.GameCraps || cfg.Craps.Variant != v {
+ t.Fatal(id, cfg)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(id, err)
+ }
+ if i > 0 && room.Exits["south"].Room != id-1 {
+ t.Fatal(id, "missing return exit")
+ }
+ if i < len(variants)-1 && room.Exits["north"].Room != id+1 {
+ t.Fatal(id, "unreachable next table")
+ }
+ m := casino.NewManager(1)
+ m.Join(id, cfg, "a")
+ if m.Table(id, cfg.ID) == nil {
+ t.Fatal(id, "table registration")
+ }
+ }
+ room, err := w.LoadRoom(6182)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if room.Exits["west"].Room != 6185 {
+ t.Fatal("gallery unreachable from roulette")
+ }
+}
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index 9f73b20..2270659 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -264,7 +264,7 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string
return c.render(mode, unicode)
}
func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string {
- if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 {
+ if dice[0] < 1 || dice[0] > 6 || dice[1] < 0 || dice[1] > 6 {
return ""
}
patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}}
@@ -280,6 +280,9 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) strin
for row := 0; row < 5; row++ {
out.WriteString(" ")
for i, n := range dice {
+ if n == 0 {
+ continue
+ }
line := top
if row == 4 {
line = bottom
@@ -315,21 +318,51 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
if s.Point != 0 {
point = fmt.Sprintf("POINT %d", s.Point)
}
- out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n")
- out.WriteString(renderCrapsTrackers(s, mode, unicode))
- out.WriteByte('\n')
- out.WriteString(renderCrapsFelt(s, mode, unicode))
+ variant := s.Variant
+ if variant == "" {
+ variant = "bank"
+ }
+ out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS") + subtle(" / "+point) + "\n")
+ if variant == "bank" || variant == "diceless" {
+ out.WriteString(renderCrapsTrackers(s, mode, unicode))
+ out.WriteByte('\n')
+ out.WriteString(renderCrapsFelt(s, mode, unicode))
+ } else {
+ height := 7
+ if variant == "simplified" || variant == "high_point" || variant == "die_rich" {
+ height = 6
+ }
+ c := newCrapsCanvas(77, height)
+ c.box(0, 0, 76, height-1)
+ c.center(1, 75, 1, "HOUSE EDITION — table rules for payouts", crapsGold)
+ switch variant {
+ case "crapless":
+ c.center(1, 75, 3, "POINTS: 2 3 4 5 6 8 9 10 11 12 | PASS / COME / ODDS", crapsInk)
+ c.center(1, 75, 4, "PLACE / BUY / LAY / FIELD / HARD / HOP / PROPOSITIONS", crapsInk)
+ c.center(1, 75, 5, "NO DON'T BETS OR BONUSES", crapsMuted)
+ case "new_york":
+ c.center(1, 75, 3, "PASS / DON'T PASS (BAR 12) / ODDS / BUY / LAY", crapsInk)
+ c.center(1, 75, 4, "BUY / LAY: 4 5 6 8 9 10 | 5% WIN-ONLY COMMISSION", crapsMuted)
+ default:
+ c.center(1, 75, 3, "PASS ONLY | WINS PAY 1:1 | NO ODDS OR BONUSES", crapsInk)
+ }
+ out.WriteString(c.render(mode, unicode))
+ }
out.WriteByte('\n')
- out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ if variant == "diceless" {
+ out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style))
+ } else {
+ out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style))
+ }
if len(s.Queue) > 0 {
out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n")
if len(s.Queue) > 1 {
out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n")
}
}
- hint := "Place a bet, then roll when ready."
+ hint := ""
if s.Phase != "waiting" && s.Phase != "" {
- hint = "Your pace. Roll when ready."
+ hint = ""
if s.ShootIn > 0 {
hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn)
} else if s.DealerIn > 0 {
@@ -337,8 +370,10 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
}
}
if s.AutoRoll {
- hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn)
+ hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn)
+ }
+ if hint != "" {
+ out.WriteString(subtle(" "+hint) + "\n")
}
- out.WriteString(subtle(" "+hint) + "\n")
return out.String()
}
diff --git a/internal/game/render_craps_cards.go b/internal/game/render_craps_cards.go
new file mode 100644
index 0000000..343f16f
--- /dev/null
+++ b/internal/game/render_craps_cards.go
@@ -0,0 +1,44 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "thehouseoficarus/internal/color"
+)
+
+// The two packs have ranks 1-6, no suits. Distinct pack labels make the
+// replacement model visible without suggesting a conventional poker shoe.
+func renderCrapsCards(cards [2]int, mode string, unicode bool, style string) string {
+ if cards[0] < 1 || cards[0] > 6 || cards[1] < 1 || cards[1] > 6 {
+ return ""
+ }
+ top, bottom, bar := "+-------+", "+-------+", "|"
+ if unicode {
+ top, bottom, bar = "╭───────╮", "╰───────╯", "│"
+ }
+ spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true}
+ if style == "light" {
+ spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true}
+ }
+ var out strings.Builder
+ for row := 0; row < 5; row++ {
+ out.WriteString(" ")
+ for i, n := range cards {
+ line := top
+ switch row {
+ case 1:
+ line = fmt.Sprintf("%s PACK %c%s", bar, 'A'+i, bar)
+ case 2:
+ line = fmt.Sprintf("%s %d %s", bar, n, bar)
+ case 3:
+ line = bar + " " + bar
+ case 4:
+ line = bottom
+ }
+ out.WriteString(color.Render(mode, spec, line) + " ")
+ }
+ out.WriteByte('\n')
+ }
+ fmt.Fprintf(&out, " Cards total: %d | Both packs replaced and reshuffled each draw.\n", cards[0]+cards[1])
+ return out.String()
+}