aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-09 17:05:54 -0400
committerhistoria <[not public]>2026-10-09 17:05:54 -0400
commitced77d7c89129da6ba4001ab8e3dad265aa41396 (patch)
tree2acfd429e5b4a83121565bf5f1bbc122228e9d8a /internal/game
parent6321c0a6b63061ac57a982c9ec5f93c1f731ae65 (diff)
downloadthehouseoficarus-ced77d7c89129da6ba4001ab8e3dad265aa41396.tar.gz
feat: new casino game: coin flip
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go7
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/cmd_registry.go13
-rw-r--r--internal/game/coin_flip_test.go162
-rw-r--r--internal/game/look_casino.go2
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_coin_flip.go238
7 files changed, 429 insertions, 3 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index fa78c2a..2d8024c 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -277,7 +277,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
continue
}
if strings.EqualFold(arg, "max") {
- amount = min(table.Config.MaxBet, casinoAffordable(p))
+ amount = min(table.Config.MaxBet, casinoAffordable(p)/table.StakeMultiple())
if amount < 1 {
sess.WriteLine("You don't have any chips or credits to bet.")
return
@@ -519,11 +519,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
_, isPlinko := snapshot.(*casino.PlinkoSnapshot)
_, isPachinko := snapshot.(*casino.PachinkoSnapshot)
_, isCashHunt := snapshot.(*casino.CashHuntSnapshot)
+ _, isCoinFlip := snapshot.(*casino.CoinFlipSnapshot)
_, isPaiGow := snapshot.(*casino.PaiGowSnapshot)
b, isBigSix := snapshot.(*casino.BigSixSnapshot)
isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result"
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isCashHunt && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
+ if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", false
}
if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
@@ -618,6 +619,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
rtp = casinoRTP(s.Edge, precision)
}
return renderCashHunt(s, mode, unicode, rtp)
+ case *casino.CoinFlipSnapshot:
+ return renderCoinFlip(s, mode, unicode)
case *casino.PlinkoSnapshot:
return renderPlinko(s, mode, unicode)
case *casino.PachinkoSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 7eb10dd..bdc3add 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -68,6 +68,8 @@ func casinoTableAliases(cfg casino.TableConfig) []string {
switch cfg.Game {
case casino.GameCashHunt:
names = append(names, "cash hunt", "cashhunt", "cash-hunt")
+ case casino.GameCoinFlip:
+ names = append(names, "coin flip", "coinflip", "coin-flip", "coin")
case casino.GamePachinko:
names = append(names, "pachinko")
case casino.GameBigSix:
@@ -174,6 +176,12 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
}
return
}
+ // Coin Flip has no felt or board: the spin and the coin are the whole
+ // game, so the table command answers with a one-liner instead of nothing.
+ if t.Config.Game == casino.GameCoinFlip {
+ sess.WriteLine("Coin Flip has no table. Use bet N to spin and flip the coin.")
+ return
+ }
// The table command shows only the table: the live board for roulette,
// craps and video poker, or the printed felt for blackjack, baccarat and
// Pai Gow. No balance, seat state, command menu, or reminder rides along.
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index ef17925..c23f89b 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -254,6 +254,8 @@ func (g *Game) casinoOwnedInput(sess *net.Session, cmd string) bool {
return isAllDigits(cmd) || cmd == "settle"
case casino.GamePachinko:
return isAllDigits(cmd)
+ case casino.GameCoinFlip:
+ return cmd == "settle"
case casino.GamePaiGow:
return isAllDigits(cmd) || cmd == "set" || cmd == "house" || cmd == "autoset" || cmd == "settle"
}
@@ -292,6 +294,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule
// because "split" is not a Big 6 verb and "spin" is not a blackjack verb.
var casinoVerbsByGame = map[casino.GameName][]string{
casino.GameCashHunt: {"bet", "rebet", "difficulty", "paytable", "pick", "settle"},
+ casino.GameCoinFlip: {"bet", "rebet", "settle"},
casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"},
casino.GamePachinko: {"bet", "rebet", "table", "drop"},
casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"},
@@ -420,6 +423,16 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
}
}
+ // Coin Flip owns the retry verb for a payout that could not be persisted.
+ if p != nil && g.Casino != nil {
+ if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GameCoinFlip {
+ if cmd == "settle" {
+ g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " "))
+ return
+ }
+ }
+ }
+
// Pachinko owns bare slot numbers and "drop N" while seated; a "drop"
// with any other argument still drops an item from the world.
if p != nil && g.Casino != nil {
diff --git a/internal/game/coin_flip_test.go b/internal/game/coin_flip_test.go
new file mode 100644
index 0000000..c8a5372
--- /dev/null
+++ b/internal/game/coin_flip_test.go
@@ -0,0 +1,162 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+func coinFlipRoom() string {
+ return "name: Two Moons Flip\ncasino_tables:\n - id: coin_flip\n game: coin_flip\n min_bet: 1\n max_bet: 100\n"
+}
+
+func TestCoinFlipPlayAndFlip(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, coinFlipRoom())
+ s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"}
+ g.executePlay(s, []string{"coin", "flip"}, "play coin flip")
+ table, ok := g.Casino.Participation(s.Player.Name)
+ if !ok || table == nil || table.Config.Game != casino.GameCoinFlip {
+ t.Fatal("Coin Flip alias did not seat the player")
+ }
+ if table.StakeMultiple() != 100 {
+ t.Fatalf("stake multiple %d, want 100", table.StakeMultiple())
+ }
+ // Sitting down must not print a coin or a multiplier.
+ if text := string(conn.data); strings.Contains(text, "▐") || strings.Contains(text, "BLUE") {
+ t.Fatalf("join printed a coin: %s", text)
+ }
+ // The player only holds 100 credits, so a stake of 1 reserves 100.
+ conn.data = nil
+ g.executeBet(s, []string{"1"}, "bet 1")
+ if len(s.Player.PendingWagers) != 1 || s.Player.PendingWagers[0].Credits != 100 {
+ t.Fatalf("bet did not reserve 100x: %+v", s.Player.PendingWagers)
+ }
+ if !strings.Contains(string(conn.data), "Stake 1") {
+ t.Fatalf("bet did not print the stake line: %s", conn.data)
+ }
+ // The first spin frame is held back one tick.
+ conn.data = nil
+ g.CasinoTick()
+ if !strings.Contains(string(conn.data), "▼") {
+ t.Fatalf("first tick did not start the red spin: %s", conn.data)
+ }
+ // Advance until the round lands (the spin + flip plan is at most ~40 frames).
+ for i := 0; i < 200 && len(s.Player.PendingWagers) > 0; i++ {
+ conn.data = nil
+ g.CasinoTick()
+ }
+ if len(s.Player.PendingWagers) != 0 {
+ t.Fatalf("round never settled: %+v", s.Player.PendingWagers)
+ }
+ if !strings.Contains(stripANSI(string(conn.data)), "Returned:") {
+ t.Fatalf("settlement receipt missing: %s", conn.data)
+ }
+}
+
+func TestCoinFlipStakeLimit(t *testing.T) {
+ g, s, _ := casinoCrapsGame(t, coinFlipRoom())
+ s.Player.Options = map[string]any{"color": "none"}
+ g.executePlay(s, []string{"coin flip"}, "play coin flip")
+ // 100 credits cannot cover a stake of 2 (200 reserved).
+ g.executeBet(s, []string{"2"}, "bet 2")
+ if len(s.Player.PendingWagers) != 0 {
+ t.Fatalf("accepted a stake the reserve cannot cover: %+v", s.Player.PendingWagers)
+ }
+}
+
+func TestCoinFlipBetMaxUsesReserve(t *testing.T) {
+ g, s, _ := casinoCrapsGame(t, coinFlipRoom())
+ s.Player.Options = map[string]any{"color": "none"}
+ g.executePlay(s, []string{"coin flip"}, "play coin flip")
+ // 100 credits with a 100x reserve means the largest stake is 1.
+ g.executeBet(s, []string{"max"}, "bet max")
+ if len(s.Player.PendingWagers) != 1 || s.Player.PendingWagers[0].Credits != 100 {
+ t.Fatalf("bet max did not reserve 100x the affordable stake: %+v", s.Player.PendingWagers)
+ }
+}
+
+func TestCoinFlipSettleReachesGame(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, coinFlipRoom())
+ s.Player.Options = map[string]any{"color": "none"}
+ g.executePlay(s, []string{"coin flip"}, "play coin flip")
+ conn.data = nil
+ g.handleGameCommand(s, "settle")
+ if strings.Contains(string(conn.data), "Unknown command") {
+ t.Fatalf("settle was rejected before reaching Coin Flip: %s", conn.data)
+ }
+}
+
+func TestCoinFlipWheelInk(t *testing.T) {
+ if ink, bold := coinFlipWheelInk(30, casino.CoinFlipSideRed); ink != -1 || bold {
+ t.Fatalf("30x = %d,%v; want plain", ink, bold)
+ }
+ if ink, bold := coinFlipWheelInk(60, casino.CoinFlipSideRed); ink != coinFlipRedInk || bold {
+ t.Fatalf("60x = %d,%v; want red not bold", ink, bold)
+ }
+ if ink, bold := coinFlipWheelInk(80, casino.CoinFlipSideBlue); ink != coinFlipBlueInk || !bold {
+ t.Fatalf("80x = %d,%v; want blue bold", ink, bold)
+ }
+ if ink, bold := coinFlipWheelInk(-90, casino.CoinFlipSideRed); ink != coinFlipRedInk || !bold {
+ t.Fatalf("-90x = %d,%v; want red bold", ink, bold)
+ }
+}
+
+func TestCoinFlipRendering(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ spin := &casino.CoinFlipSnapshot{
+ Phase: casino.CoinFlipPhaseRedSpin,
+ SpinSide: casino.CoinFlipSideRed,
+ SpinValues: []int{-10, -20, -30, -40, -50, -60, -70},
+ }
+ rendered := renderCoinFlip(spin, mode, unicode)
+ if mode == "none" && strings.Contains(rendered, "\x1b") {
+ t.Fatalf("plain spin has ANSI escapes: %q", rendered)
+ }
+ if !strings.Contains(rendered, "▼") && unicode {
+ t.Fatalf("spin strip missing its pointer: %q", rendered)
+ }
+
+ set := &casino.CoinFlipSnapshot{
+ Phase: casino.CoinFlipPhaseRedSpin,
+ SpinSide: casino.CoinFlipSideRed,
+ SpinValues: []int{-10, -20, -30, -40, -50, -60, -70},
+ Set: true,
+ }
+ if !strings.Contains(renderCoinFlip(set, mode, unicode), "SET") {
+ t.Fatalf("landed spin missing SET: %q", renderCoinFlip(set, mode, unicode))
+ }
+
+ results := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseResults, Red: -61, Blue: 85}
+ rendered = renderCoinFlip(results, mode, unicode)
+ if plain := stripANSI(rendered); !strings.Contains(plain, "-61x") || !strings.Contains(plain, "+85x") {
+ t.Fatalf("results panel missing a face: %q", plain)
+ }
+
+ coin := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseFlipping, Side: casino.CoinFlipSideBlue, Multiplier: 42}
+ rendered = renderCoinFlip(coin, mode, unicode)
+ if plain := stripANSI(rendered); !strings.Contains(plain, "+42x") {
+ t.Fatalf("coin missing its multiplier: %q", plain)
+ }
+ if strings.Contains(rendered, "COIN FLIP") || strings.Contains(rendered, "stake") {
+ t.Fatalf("flip printed chrome instead of just the coin: %q", rendered)
+ }
+
+ settled := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseResult, Side: casino.CoinFlipSideRed, Multiplier: -50, Result: true}
+ rendered = renderCoinFlip(settled, mode, unicode)
+ if plain := stripANSI(rendered); !strings.Contains(plain, "-50x") {
+ t.Fatalf("settled coin missing its multiplier: %q", plain)
+ }
+ if mode == "xterm256" && !strings.Contains(rendered, "48;5;196") {
+ t.Fatalf("settled red coin has no red fill: %q", rendered)
+ }
+ if mode == "ansi" && !strings.Contains(rendered, "\x1b[41m") {
+ t.Fatalf("settled red coin has no red fill: %q", rendered)
+ }
+ }
+ }
+ if got := renderCoinFlip(&casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseIdle}, "none", true); got != "" {
+ t.Fatalf("idle coin rendered %q", got)
+ }
+}
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
index e349386..500bbfb 100644
--- a/internal/game/look_casino.go
+++ b/internal/game/look_casino.go
@@ -105,6 +105,8 @@ func casinoTableSummary(cfg casino.TableConfig) string {
switch cfg.Game {
case casino.GameCashHunt:
return "100 squares; three difficulties; 98% RTP; prizes up to 50000x"
+ case casino.GameCoinFlip:
+ return "fair 50/50; red 0 to -100x, blue 0 to +100x; 98% RTP; reserves 100x the stake"
case casino.GamePlinko:
return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP)
case casino.GamePachinko:
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 97d7ecb..b467ae9 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -50,7 +50,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
// Felt tables resolve through one data-driven loop; the video-poker cabinet
// has its own cabinet-id/pay-table handling.
- for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
+ for _, game := range []casino.GameName{casino.GameCashHunt, casino.GameCoinFlip, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
if g.lookCasinoTable(sess, lower, game) {
return
}
diff --git a/internal/game/render_coin_flip.go b/internal/game/render_coin_flip.go
new file mode 100644
index 0000000..6408b26
--- /dev/null
+++ b/internal/game/render_coin_flip.go
@@ -0,0 +1,238 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// Coin Flip inks. The two faces are deliberately far apart on the xterm
+// wheel: bright red for the loss face and a deep sky blue for the win face.
+const (
+ coinFlipRedInk = 196
+ coinFlipBlueInk = 45
+ coinFlipDimInk = 245
+)
+
+// coinFlipLabel renders a signed net multiplier, e.g. "+50x" or "-100x".
+func coinFlipLabel(multiplier int) string {
+ return fmt.Sprintf("%+dx", multiplier)
+}
+
+// coinFlipWheelInk tiers a wheel value: under 50x is plain, 50-74x takes the
+// face colour, and 75x and up is bold as well.
+func coinFlipWheelInk(value int, side string) (int, bool) {
+ magnitude := value
+ if magnitude < 0 {
+ magnitude = -magnitude
+ }
+ if magnitude < 50 {
+ return -1, false
+ }
+ ink := coinFlipRedInk
+ if side == casino.CoinFlipSideBlue {
+ ink = coinFlipBlueInk
+ }
+ return ink, magnitude >= 75
+}
+
+// coinFlipRow is one scanline of the coin: padding, an outline run, the
+// interior (which alone is filled when the coin settles), the other outline
+// run, and trailing padding.
+type coinFlipRow struct {
+ pre, left, interior, right, post string
+}
+
+// coinFlipArt draws one medium coin: a nine-column, five-row circle with the
+// signed multiplier centered inside. A settled coin fills only the interior
+// cells, so the partial outline blocks keep the circle's round edge instead of
+// growing into a square. A spinning coin gets whoosh marks on either side.
+func coinFlipArt(multiplier int, side string, settled, alt bool, mode string, unicode bool) []string {
+ const inner = 7
+ label := coinFlipLabel(multiplier)
+ if len(label) > inner {
+ label = label[:inner]
+ }
+ content := rouletteCenter(label, inner)
+ ink := coinFlipRedInk
+ if side == casino.CoinFlipSideBlue {
+ ink = coinFlipBlueInk
+ }
+ plain := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: -1, Bg: -1}, text) }
+ outline := func(text string) string {
+ if text == "" {
+ return ""
+ }
+ return color.Render(mode, color.ColorSpec{Fg: ink, Bg: -1, Bold: true}, text)
+ }
+ interior := func(text string) string {
+ if text == "" {
+ return ""
+ }
+ bg, fg := -1, ink
+ if settled {
+ // Fill only the interior: the outline's partial cells stay open.
+ bg, fg = ink, 16
+ }
+ var b strings.Builder
+ for _, r := range text {
+ if r == ' ' {
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bg: bg}, " "))
+ } else {
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: bg, Bold: true}, string(r)))
+ }
+ }
+ return b.String()
+ }
+ var rows []coinFlipRow
+ if unicode {
+ rows = []coinFlipRow{
+ {" ", strings.Repeat("▄", inner-2), "", "", " "},
+ {" ", "▟", strings.Repeat(" ", inner-2), "▙", " "},
+ {"", "▐", content, "▌", ""},
+ {" ", "▜", strings.Repeat(" ", inner-2), "▛", " "},
+ {" ", strings.Repeat("▀", inner-2), "", "", " "},
+ }
+ } else {
+ rows = []coinFlipRow{
+ {" ", strings.Repeat("_", inner-2), "", "", " "},
+ {" ", "/", strings.Repeat(" ", inner-2), "\\", " "},
+ {"", "|", content, "|", ""},
+ {" ", "\\", strings.Repeat(" ", inner-2), "/", " "},
+ {" ", strings.Repeat("-", inner-2), "", "", " "},
+ }
+ }
+ lines := make([]string, 0, len(rows))
+ for _, r := range rows {
+ lines = append(lines, plain(r.pre)+outline(r.left)+interior(r.interior)+outline(r.right)+plain(r.post))
+ }
+ if !settled {
+ // Only the two flank rows carry motion marks, and they swap the whoosh
+ // and arrow each frame so the coin reads as tumbling.
+ arrow := color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, "↺")
+ whoosh := color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, "~")
+ for i := range lines {
+ var left, right string
+ switch i {
+ case 1:
+ if alt {
+ left, right = arrow, whoosh
+ } else {
+ left, right = whoosh, arrow
+ }
+ case 3:
+ if alt {
+ left, right = whoosh, arrow
+ } else {
+ left, right = arrow, whoosh
+ }
+ default:
+ continue
+ }
+ lines[i] = left + " " + lines[i] + " " + right
+ }
+ }
+ return lines
+}
+
+// coinFlipWheelChar is one character of the scrolling multiplier strip.
+type coinFlipWheelChar struct {
+ ch rune
+ value int
+ sep bool
+ label bool
+}
+
+// coinFlipWheel builds the full strip: a separator, then each value centred in
+// a fixed cell. The engine and renderer share the geometry constants, so the
+// landing offset and the drawn pointer always agree.
+func coinFlipWheel(values []int, unicode bool) []coinFlipWheelChar {
+ sep := '│'
+ if !unicode {
+ sep = '|'
+ }
+ chars := make([]coinFlipWheelChar, 0, len(values)*casino.CoinFlipPitch+1)
+ for _, v := range values {
+ chars = append(chars, coinFlipWheelChar{ch: sep, sep: true})
+ for _, r := range rouletteCenter(coinFlipLabel(v), casino.CoinFlipCellWidth) {
+ chars = append(chars, coinFlipWheelChar{ch: r, value: v, label: r != ' '})
+ }
+ }
+ chars = append(chars, coinFlipWheelChar{ch: sep, sep: true})
+ return chars
+}
+
+// renderCoinFlipSpin draws the scrolling multiplier strip, a fixed pointer, and
+// a bold SET under the landed value.
+func renderCoinFlipSpin(s *casino.CoinFlipSnapshot, mode string, unicode bool) string {
+ pointer := "▼"
+ if !unicode {
+ pointer = "V"
+ }
+ wheel := coinFlipWheel(s.SpinValues, unicode)
+ var out strings.Builder
+ out.WriteString(strings.Repeat(" ", casino.CoinFlipPointerCol))
+ out.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bold: true}, pointer))
+ out.WriteByte('\n')
+ for i := 0; i < casino.CoinFlipViewWidth; i++ {
+ idx := s.SpinOffset + i
+ cell := coinFlipWheelChar{ch: ' '}
+ if idx >= 0 && idx < len(wheel) {
+ cell = wheel[idx]
+ }
+ spec := color.ColorSpec{Fg: coinFlipDimInk}
+ switch {
+ case cell.label:
+ ink, bold := coinFlipWheelInk(cell.value, s.SpinSide)
+ spec = color.ColorSpec{Fg: ink, Bold: bold}
+ case cell.sep:
+ spec = color.ColorSpec{Fg: coinFlipDimInk}
+ }
+ out.WriteString(color.Render(mode, spec, string(cell.ch)))
+ }
+ out.WriteByte('\n')
+ if s.Set {
+ out.WriteString(strings.Repeat(" ", casino.CoinFlipPointerCol-1))
+ out.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bold: true}, "SET"))
+ }
+ return strings.TrimRight(out.String(), " \n")
+}
+
+// coinFlipResultCell centers a coloured label/value pair inside one panel cell.
+func coinFlipResultCell(label, value string, ink, width int, mode string) string {
+ text := label + " " + value
+ pad := max(0, width-len(text))
+ left := pad / 2
+ right := pad - left
+ return strings.Repeat(" ", left) +
+ color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, label) + " " +
+ color.Render(mode, color.ColorSpec{Fg: 250, Bold: true}, value) +
+ strings.Repeat(" ", right)
+}
+
+// renderCoinFlipResults draws the two landed faces as a compact coloured panel.
+func renderCoinFlipResults(s *casino.CoinFlipSnapshot, mode string, unicode bool) string {
+ const cell = 15
+ b := rouletteBorders(unicode)
+ dim := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: coinFlipDimInk}, text) }
+ top := dim(b.tl + strings.Repeat(b.h, cell) + b.teeTop + strings.Repeat(b.h, cell) + b.tr)
+ mid := dim(b.v) +
+ coinFlipResultCell("RED", coinFlipLabel(s.Red), coinFlipRedInk, cell, mode) + dim(b.v) +
+ coinFlipResultCell("BLUE", coinFlipLabel(s.Blue), coinFlipBlueInk, cell, mode) + dim(b.v)
+ bottom := dim(b.bl + strings.Repeat(b.h, cell) + b.teeBottom + strings.Repeat(b.h, cell) + b.br)
+ return top + "\n" + mid + "\n" + bottom
+}
+
+func renderCoinFlip(s *casino.CoinFlipSnapshot, mode string, unicode bool) string {
+ switch s.Phase {
+ case casino.CoinFlipPhaseRedSpin, casino.CoinFlipPhaseBlueSpin:
+ return renderCoinFlipSpin(s, mode, unicode)
+ case casino.CoinFlipPhaseResults:
+ return renderCoinFlipResults(s, mode, unicode)
+ case casino.CoinFlipPhaseFlipping, casino.CoinFlipPhaseResult:
+ return strings.Join(coinFlipArt(s.Multiplier, s.Side, s.Result, s.FlipAlt, mode, unicode), "\n")
+ }
+ return ""
+}