diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 23 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 1 | ||||
| -rw-r--r-- | internal/game/crazy_time_test.go | 193 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 4 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/render_crazy_time.go | 487 |
7 files changed, 702 insertions, 10 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index 2d8024c..fee8936 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -192,7 +192,7 @@ func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName str // Keep slots' spin-as-bet behavior, while Big 6 spins already committed wagers. func (g *Game) executeSpin(sess *net.Session, args []string, rawInput string) { if sess.Player != nil { - if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && table.Config.Game == casino.GameBigSix { + if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && (table.Config.Game == casino.GameBigSix || table.Config.Game == casino.GameCrazyTime) { if len(args) != 0 { sess.WriteLine("Use spin after placing your bets.") return @@ -242,11 +242,14 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " ")))) return } - // Baccarat repeats every remembered side bet and the main bet on a bare - // "bet"; games with one wager keep the generic single-rebet path below. - if table.Config.Game == casino.GameBaccarat && len(args) == 0 { - g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p})) - return + // A bare bet/rebet repeats every remembered wager for games that keep + // more than one (roulette, Big 6, Crazy Time, baccarat); games with a single + // wager keep the generic single-rebet path below. + if len(args) == 0 { + if events := g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p}); events != nil { + g.emitCasinoEvents(events) + return + } } // Table bet syntax: bet | bet N [on BET] | bet min|max [on BET]. // BEFORE an "on" keyword, a token that parses as an amount (or min/max) @@ -257,7 +260,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { // an amount and target keyword exist, subsequent numbers belong to the // target even without "on" (bet 10 split 1 2 / bet 10 buy 4). // Other games keep the stricter one-token target. - joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps + joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps || table.Config.Game == casino.GameCrazyTime amount, spot, hasAmount, sawOn := 0, "", false, false for i, arg := range args { if strings.EqualFold(arg, "on") { @@ -523,8 +526,9 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" + _, isCrazyTime := snapshot.(*casino.CrazyTimeSnapshot) s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && !isCrazyTime && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { @@ -634,6 +638,9 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) case *casino.BigSixSnapshot: return renderBigSixSnapshot(s, mode, unicode) + case *casino.CrazyTimeSnapshot: + precision, showOdds := g.casinoEdgePrecision(sess) + return renderCrazyTime(s, mode, unicode, showOdds, precision) case *casino.RouletteSnapshot: return renderRouletteSnapshot(s, mode, unicode, size) case *casino.SlotSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index bdc3add..249537f 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -74,6 +74,8 @@ func casinoTableAliases(cfg casino.TableConfig) []string { names = append(names, "pachinko") case casino.GameBigSix: names = append(names, "big-six", "big_six", "big six", "big 6") + case casino.GameCrazyTime: + names = append(names, "crazy time", "crazytime", "crazy-time", "crazy") case casino.GameBlackjack: names = append(names, "bj") case casino.GameBaccarat: diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index c23f89b..14debae 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -305,6 +305,7 @@ var casinoVerbsByGame = map[casino.GameName][]string{ casino.GameVideoPoker: {"bet", "deal", "draw", "hold", "paytable", "denom", "denomination", "hands", "feature"}, casino.GameSlots: {"bet", "spin", "autospin", "autobet"}, casino.GameBigSix: {"bet", "rebet", "unbet", "spin"}, + casino.GameCrazyTime: {"bet", "rebet", "unbet", "spin"}, } // casinoShortAliases maps the registry's terse aliases onto the verb they diff --git a/internal/game/crazy_time_test.go b/internal/game/crazy_time_test.go new file mode 100644 index 0000000..2c07862 --- /dev/null +++ b/internal/game/crazy_time_test.go @@ -0,0 +1,193 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" +) + +func crazyTimeRoom() string { + return "name: Crazy Time Coliseum\ncasino_tables:\n - id: crazy_time\n game: crazy_time\n min_bet: 1\n max_bet: 1000\n" +} + +func TestCrazyTimePlayAliasAndMenu(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy", "time"}, "play crazy time") + table, ok := g.Casino.Participation(s.Player.Name) + if !ok || table == nil || table.Config.Game != casino.GameCrazyTime { + t.Fatal("Crazy Time alias did not seat the player") + } + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "CRAZY TIME") { + t.Fatalf("join output missing the game: %q", text) + } + if !strings.Contains(text, "bet N on BET") { + t.Fatalf("join output missing the bet hint: %q", text) + } + if !strings.Contains(text, "98") { + t.Fatalf("join output missing the RTP: %q", text) + } + conn.data = nil + g.executeRules(s, nil, "rules") + if help := stripANSI(string(conn.data)); !strings.Contains(help, "CRAZY TIME") || !strings.Contains(help, "TOP SLOT") { + t.Fatalf("rules did not open the Crazy Time page: %q", help) + } +} + +func TestCrazyTimeListCommand(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeList(s, nil, "list") + text := stripANSI(string(conn.data)) + for _, want := range []string{"COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "ALL"} { + if !strings.Contains(text, want) { + t.Fatalf("list missing %s: %q", want, text) + } + } +} + +func TestCrazyTimePlayAndSpin(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeBet(s, []string{"5", "on", "cash", "hunt"}, "bet 5 on cash hunt") + if len(s.Player.PendingWagers) != 1 { + t.Fatalf("bet not committed: %+v", s.Player.PendingWagers) + } + if !strings.Contains(stripANSI(string(conn.data)), "CASH HUNT") { + t.Fatalf("bet confirmation missing the target: %q", conn.data) + } + // Spin and drive the whole round (main wheel plus any bonus) to a receipt. + conn.data = nil + g.executeTableAction(s, "spin") + for i := 0; i < 2000 && len(s.Player.PendingWagers) > 0; i++ { + g.CasinoTick() + } + if len(s.Player.PendingWagers) != 0 { + t.Fatalf("round never settled: %+v", s.Player.PendingWagers) + } +} + +func TestCrazyTimeRendering(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + wheel := &casino.CrazyTimeSnapshot{ + Phase: "result", + Pockets: []string{"1", "2", "5", "10", "COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "1", "2", "5"}, + TopSymbol: "CASH HUNT", + TopMult: 5, + } + rendered := renderCrazyTime(wheel, mode, unicode, false, 0) + if mode == "none" && strings.Contains(rendered, "\x1b") { + t.Fatalf("plain wheel has ANSI escapes: %q", rendered) + } + plain := stripANSI(rendered) + if !strings.Contains(plain, "CASH") || !strings.Contains(plain, "x5") { + t.Fatalf("wheel missing symbol/top multiplier: %q", plain) + } + + bonus := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{ + Kind: casino.CrazyTimeBonusPachinko, + Phase: "result", + PachoPockets: []int{10, 20, -1, 5, 50, 100, 25, 15, 7, 10, 20, 5, 50, 100, 25, 15}, + PachoRow: 16, + PachoLane: 4, + }, + } + rendered = renderCrazyTime(bonus, mode, unicode, false, 0) + if plain := stripANSI(rendered); !strings.Contains(plain, "DBL") || !strings.Contains(plain, "100") { + t.Fatalf("pachinko bonus missing prizes: %q", plain) + } + + coin := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{Kind: casino.CrazyTimeBonusCoinFlip, Phase: "result", CoinLow: 3, CoinHigh: 25, CoinSide: "blue", CoinValue: 25, CoinResult: true}, + } + if plain := stripANSI(renderCrazyTime(coin, mode, unicode, false, 0)); !strings.Contains(plain, "25x") { + t.Fatalf("coin flip bonus missing value: %q", plain) + } + + hunt := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{Kind: casino.CrazyTimeBonusCashHunt, Phase: "result", HuntFinal: []int{2, 5, 10, 25, 50, 100, 500, 15, 20, 2, 5, 10}, HuntPick: 3}, + } + if plain := stripANSI(renderCrazyTime(hunt, mode, unicode, false, 0)); !strings.Contains(plain, "TILE 4") { + t.Fatalf("cash hunt bonus missing the pick: %q", plain) + } + + ct := &casino.CrazyTimeSnapshot{ + Phase: "bonus", + Bonus: &casino.CrazyTimeBonusView{ + Kind: casino.CrazyTimeBonusWheel, + Phase: "result", + CTWheel: []int{10, 15, 20, 25, 50, 100, -1, 10, 15, 20, 25, 50, 100, 10, 15}, + CTOffset: 0, + CTPick: 1, + CTResult: true, + CTValue: 25, + }, + } + if plain := stripANSI(renderCrazyTime(ct, mode, unicode, false, 0)); !strings.Contains(plain, "POINTER") { + t.Fatalf("crazy wheel bonus missing the result: %q", plain) + } + } + } +} + +func TestCrazyTimeBlockedVerbsDoNotLeak(t *testing.T) { + g, s, _ := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + // "sp" must abbreviate to spin, not split (blackjack) or an unknown verb. + got, ok := g.casinoAbbreviation(s, "sp") + if !ok || got != "spin" { + t.Fatalf("sp resolved to %q,%v; want spin", got, ok) + } +} + +func TestCrazyTimeBetOmitsBalanceAndResultShowsItOnce(t *testing.T) { + g, s, conn := casinoCrapsGame(t, crazyTimeRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"crazy time"}, "play crazy time") + conn.data = nil + g.executeBet(s, []string{"5", "on", "all"}, "bet 5 on all") + out := stripANSI(string(conn.data)) + if strings.Contains(out, "Available") { + t.Fatalf("bet printed the wallet: %q", out) + } + if !strings.Contains(out, "all four minigames") { + t.Fatalf("ALL bet not confirmed: %q", out) + } + conn.data = nil + g.executeTableAction(s, "spin") + for i := 0; i < 3000 && len(s.Player.PendingWagers) > 0; i++ { + g.CasinoTick() + } + if len(s.Player.PendingWagers) != 0 { + t.Fatal("round never settled") + } + out = stripANSI(string(conn.data)) + if got := strings.Count(out, "Available "); got != 1 { + t.Fatalf("result printed the wallet %d times, want 1: %q", got, out) + } +} + +func TestCrazyTimeTopSlotOnlyOnResult(t *testing.T) { + pockets := []string{"1", "2", "5", "10", "COIN FLIP", "CASH HUNT", "PACHINKO", "CRAZY TIME", "1", "2", "5"} + spin := &casino.CrazyTimeSnapshot{Phase: "spinning", Pockets: pockets, TopSymbol: "CASH HUNT", TopMult: 5} + if plain := stripANSI(renderCrazyTime(spin, "none", false, false, 0)); strings.Contains(plain, "TOP SLOT") { + t.Fatalf("spinning wheel printed TOP SLOT: %q", plain) + } + result := &casino.CrazyTimeSnapshot{Phase: "result", Pockets: pockets, TopSymbol: "CASH HUNT", TopMult: 5} + plain := stripANSI(renderCrazyTime(result, "none", false, false, 0)) + if !strings.Contains(plain, "TOP SLOT") || !strings.Contains(plain, "x5") { + t.Fatalf("result wheel omitted the Top Slot: %q", plain) + } +} diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index 500bbfb..38063d1 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi fmt.Sprintf(" Seated %s", seated), } switch cfg.Game { - case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GamePachinko, casino.GameCashHunt: + case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko, casino.GamePachinko, casino.GameCashHunt, casino.GameCrazyTime: // Look, like sitting down and the table command, shows the live // wheel: tracker, racetrack, and betting layout. if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" { @@ -111,6 +111,8 @@ func casinoTableSummary(cfg casino.TableConfig) string { return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP) case casino.GamePachinko: return "8 slots; 20 reflected rows; populated fractional board" + case casino.GameCrazyTime: + return "54 pockets; four positive bonus games; 98% RTP; one seat" case casino.GameBlackjack: b := cfg.Blackjack.Normalize() dealer := "dealer stands on soft 17" diff --git a/internal/game/look_target.go b/internal/game/look_target.go index b467ae9..43f6264 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.GameCoinFlip, 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.GameCrazyTime, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} { if g.lookCasinoTable(sess, lower, game) { return } diff --git a/internal/game/render_crazy_time.go b/internal/game/render_crazy_time.go new file mode 100644 index 0000000..5a8b49d --- /dev/null +++ b/internal/game/render_crazy_time.go @@ -0,0 +1,487 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +// Crazy Time inks: one bright colour per betting symbol, so the wheel, +// paytable and bonus boards all read consistently. +func crazyTimeInk(label string) int { + switch label { + case "1": + return 46 + case "2": + return 51 + case "5": + return 39 + case "10": + return 201 + case "COIN FLIP": + return 220 + case "CASH HUNT": + return 129 + case "PACHINKO": + return 208 + case "CRAZY TIME": + return 196 + } + return 15 +} + +// crazyTimeShortLabel fits a symbol into a wheel cell. +func crazyTimeShortLabel(label string) string { + switch label { + case "COIN FLIP": + return "COIN" + case "CASH HUNT": + return "CASH" + case "PACHINKO": + return "PACH" + case "CRAZY TIME": + return "CRAZY" + } + return label +} + +func crazyTimeRender(mode string, spec color.ColorSpec, text string) string { + return color.Render(mode, spec, text) +} + +// renderCrazyTimeWheel draws the main 54-pocket wheel as a bordered strip with +// a single pointer. The centre pocket is the landed symbol on a result. +func renderCrazyTimeWheel(s *casino.CrazyTimeSnapshot, mode string, unicode bool) string { + width := 6 + n := len(s.Pockets) + if n == 0 { + return "" + } + b := rouletteBorders(unicode) + result := s.Phase == "result" + if result { + width = 7 + } + center := n / 2 + pointer := 1 + center*(width+1) + width/2 + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240}, text) } + var out strings.Builder + if result && s.TopSymbol != "" { + top := crazyTimeRender(mode, color.ColorSpec{Fg: 244}, "TOP SLOT ") + + crazyTimeRender(mode, color.ColorSpec{Fg: crazyTimeInk(s.TopSymbol), Bold: true}, s.TopSymbol) + if s.TopMult > 1 { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf(" x%d", s.TopMult)) + } else { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 244}, " x1") + } + if s.TopSymbol == s.Pockets[center] && result { + top += crazyTimeRender(mode, color.ColorSpec{Fg: 46, Bold: true}, " HIT!") + } + out.WriteString(top) + out.WriteByte('\n') + } + tip := "\\/" + if unicode { + tip = "▼" + } + out.WriteString(strings.Repeat(" ", pointer) + crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, tip) + "\n") + top := b.tl + strings.Repeat(strings.Repeat(b.h, width)+b.teeTop, n-1) + strings.Repeat(b.h, width) + b.tr + bottom := b.bl + strings.Repeat(strings.Repeat(b.h, width)+b.teeBottom, n-1) + strings.Repeat(b.h, width) + b.br + out.WriteString(dim(top) + "\n") + out.WriteString(dim(b.v)) + for i, pocket := range s.Pockets { + label := crazyTimeShortLabel(pocket) + cell := rouletteCenter(label, width) + spec := color.ColorSpec{Fg: crazyTimeInk(pocket), Bold: true} + if result && i != center { + spec = color.ColorSpec{Fg: crazyTimeInk(pocket), Dim: true} + } + out.WriteString(crazyTimeRender(mode, spec, cell)) + out.WriteString(dim(b.v)) + } + out.WriteByte('\n') + out.WriteString(dim(bottom)) + if result { + out.WriteString("\n" + strings.Repeat(" ", pointer-3) + crazyTimeRender(mode, color.ColorSpec{Fg: crazyTimeInk(s.Pockets[center]), Bold: true}, "WINNER")) + } + return out.String() +} + +// crazyTimeFeltPanel draws one betting panel: a bordered box with a symbol +// label and a short payout line. +func crazyTimeFeltPanel(label, sub string, width, ink int, mode string, unicode bool) []string { + b := rouletteBorders(unicode) + lines := []string{ + b.tl + strings.Repeat(b.h, width) + b.tr, + b.v + rouletteCenter(label, width) + b.v, + b.v + rouletteCenter(sub, width) + b.v, + b.bl + strings.Repeat(b.h, width) + b.br, + } + for i := range lines { + lines[i] = crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, lines[i]) + } + return lines +} + +// crazyTimeFeltRow prints a row of equal-height panels side by side. +func crazyTimeFeltRow(out *strings.Builder, panels [][]string) { + for line := range panels[0] { + out.WriteString(" ") + for col, panel := range panels { + if col > 0 { + out.WriteByte(' ') + } + out.WriteString(panel[line]) + } + out.WriteByte('\n') + } +} + +// renderCrazyTimeFelt is the table command's felt: every available bet drawn +// as a panel, with ALL sitting between the four minigame squares. +func renderCrazyTimeFelt(s *casino.CrazyTimeSnapshot, mode string, unicode, showOdds bool, precision int) string { + payout := map[string]string{} + for _, sp := range s.Spots { + payout[sp.Label] = sp.Payout + } + var out strings.Builder + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: 253}, + "A giant 54 pocket wheel! Bets on numbers have odds-based payouts. Bet on minigames to play those games when they are spun!")) + out.WriteString("\n\n") + + // The four number bets, in ascending order. + const numWidth, bonusWidth = 14, 29 + var numbers [][]string + for _, label := range []string{"1", "2", "5", "10"} { + sub := "pays " + payout[label] + numbers = append(numbers, crazyTimeFeltPanel(label, sub, numWidth, crazyTimeInk(label), mode, unicode)) + } + crazyTimeFeltRow(&out, numbers) + + // The four minigames in a 2x2 grid with ALL in the middle. + coin := crazyTimeFeltPanel("COIN FLIP", "bonus", bonusWidth, crazyTimeInk("COIN FLIP"), mode, unicode) + cash := crazyTimeFeltPanel("CASH HUNT", "bonus", bonusWidth, crazyTimeInk("CASH HUNT"), mode, unicode) + pach := crazyTimeFeltPanel("PACHINKO", "bonus", bonusWidth, crazyTimeInk("PACHINKO"), mode, unicode) + crazy := crazyTimeFeltPanel("CRAZY TIME", "bonus", bonusWidth, crazyTimeInk("CRAZY TIME"), mode, unicode) + out.WriteByte('\n') + crazyTimeFeltRow(&out, [][]string{coin, cash}) + out.WriteByte('\n') + all := crazyTimeFeltPanel("ALL", "all four", 11, 226, mode, unicode) + // Center the small ALL box under the 2x2 minigame block: two bordered + // panels, one gap between them, and the leading two-space row indent. + rowWidth := 2*(bonusWidth+2) + 1 + indent := 2 + (rowWidth-13)/2 + for _, line := range all { + out.WriteString(strings.Repeat(" ", indent) + line + "\n") + } + out.WriteByte('\n') + crazyTimeFeltRow(&out, [][]string{pach, crazy}) + + rtp := "98%" + if showOdds && s.RTP > 0 { + rtp = fmt.Sprintf("%.*f%%", precision, s.RTP) + } + out.WriteString("\n") + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: 244}, "Every target returns "+rtp+" including the Top Slot multiplier.")) + return out.String() +} + +// ----- Bonus renderers ----- + +func renderCrazyBonus(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + if v == nil { + return "" + } + switch v.Kind { + case casino.CrazyTimeBonusCoinFlip: + return renderCrazyCoinFlip(v, mode, unicode) + case casino.CrazyTimeBonusCashHunt: + return renderCrazyCashHunt(v, mode, unicode) + case casino.CrazyTimeBonusPachinko: + return renderCrazyPachinko(v, mode, unicode) + case casino.CrazyTimeBonusWheel: + return renderCrazyBonusWheel(v, mode, unicode) + } + return "" +} + +// renderCrazyCoinFlip shows the two weighted faces and the flipping coin. It +// reuses the standalone cabinet's coin art. +func renderCrazyCoinFlip(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + b := rouletteBorders(unicode) + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 245}, text) } + cell := func(label string, value int, ink int) string { + text := fmt.Sprintf("%s %dx", label, value) + return crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, rouletteCenter(text, 18)) + } + switch v.Phase { + case "faces": + top := dim(b.tl + strings.Repeat(b.h, 18) + b.teeTop + strings.Repeat(b.h, 18) + b.tr) + mid := dim(b.v) + cell("RED", v.CoinLow, 196) + dim(b.v) + cell("BLUE", v.CoinHigh, 45) + dim(b.v) + bot := dim(b.bl + strings.Repeat(b.h, 18) + b.teeBottom + strings.Repeat(b.h, 18) + b.br) + return crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "COIN FLIP — two prizes, one fair coin") + "\n" + top + "\n" + mid + "\n" + bot + case "flip": + value, side := v.CoinLow, casino.CoinFlipSideRed + if v.CoinFlipAlt { + value, side = v.CoinHigh, casino.CoinFlipSideBlue + } + lines := coinFlipArt(value, side, false, v.CoinFlipAlt, mode, unicode) + return strings.Join(lines, "\n") + case "result": + label := "RED" + ink := 196 + if v.CoinSide == "blue" { + label, ink = "BLUE", 45 + } + art := coinFlipArt(v.CoinValue, v.CoinSide, true, false, mode, unicode) + head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf("COIN FLIP — %s %dx", label, v.CoinValue)) + return head + "\n" + strings.Join(art, "\n") + "\n" + crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: true}, "WIN") + } + return "" +} + +// renderCrazyCashHunt draws the 108-tile board: a preview with values, a +// sealed board, then the final board with the chosen tile highlighted. +func renderCrazyCashHunt(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + const cols = 10 + const tile = 6 + boardWidth := cols*(tile+1) + 1 + title := crazyTimeRender(mode, color.ColorSpec{Fg: 220, Bold: true}, "CASH HUNT — 108 positive tiles") + if v.Phase == "hidden" { + return title + "\n" + crazyTimeRender(mode, color.ColorSpec{Fg: 245}, "The board is sealed and its positions are mixed...") + } + squares := v.HuntPreview + if v.Phase != "preview" { + squares = v.HuntFinal + } + if len(squares) == 0 { + return title + } + tl, tr, _, ml, mm, mr, bl, bm, br, hz, vt := "┌", "┐", "┬", "├", "┼", "┤", "└", "┴", "┘", "─", "│" + if !unicode { + tl, tr, _, ml, mm, mr, bl, bm, br, hz, vt = "+", "+", "+", "+", "+", "+", "+", "+", "+", "-", "|" + } + border := func(left, mid, right string) string { + var sb strings.Builder + sb.WriteString(left) + for c := 0; c < cols; c++ { + sb.WriteString(strings.Repeat(hz, tile)) + if c < cols-1 { + sb.WriteString(mid) + } + } + sb.WriteString(right) + return sb.String() + } + box := func(s string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240, Bg: 16}, s) } + cellText := func(text string) string { + if len(text) >= tile { + return text + } + left := (tile - len(text)) / 2 + return strings.Repeat(" ", left) + text + strings.Repeat(" ", tile-len(text)-left) + } + lines := []string{title, box(tl + strings.Repeat(hz, boardWidth-2) + tr)} + rows := (len(squares) + cols - 1) / cols + for row := 0; row < rows; row++ { + var cells []string + for c := 0; c < cols; c++ { + k := row*cols + c + if k >= len(squares) { + cells = append(cells, strings.Repeat(" ", tile)) + continue + } + text := fmt.Sprintf("%dx", squares[k]) + fg := cashHuntPrizeColor(squares[k]) + bg := 16 + bold := squares[k] > 0 + if v.Phase == "result" && v.HuntPick == k { + text = fmt.Sprintf(">%d<", squares[k]) + fg, bg, bold = 16, 226, true + } + cells = append(cells, crazyTimeRender(mode, color.ColorSpec{Fg: fg, Bg: bg, Bold: bold}, cellText(text))) + } + lines = append(lines, box(vt)+strings.Join(cells, box(vt))+box(vt)) + if row < rows-1 { + lines = append(lines, box(border(ml, mm, mr))) + } + } + lines = append(lines, box(border(bl, bm, br))) + if v.Phase == "result" { + value := 0 + if v.HuntPick >= 0 && v.HuntPick < len(v.HuntFinal) { + value = v.HuntFinal[v.HuntPick] + } + lines = append(lines, crazyTimeRender(mode, color.ColorSpec{Fg: 46, Bold: true}, fmt.Sprintf("TILE %d UNLOCKED — %dx", v.HuntPick+1, value))) + } + return strings.Join(lines, "\n") +} + +// ----- Pachinko bonus ----- + +// crazyPachinkoCell renders one 4-wide prize cell; landing >= 0 highlights it. +func crazyPachinkoCell(value, landing, lane, doubling int, mode string) string { + label := "DBL" + fg := 198 + if value >= 0 { + label = fmt.Sprintf("%d", value) + switch { + case value >= 100: + fg = 51 + case value >= 50: + fg = 220 + case value >= 20: + fg = 208 + case value >= 10: + fg = 46 + default: + fg = 49 + } + } + bold := value < 0 || value >= 20 || (landing == lane && lane >= 0) + if landing == lane && lane >= 0 { + fg = 226 + } + if len(label) > 4 { + label = label[:4] + } + text := fmt.Sprintf("%4s", label) + return color.Render(mode, color.ColorSpec{Fg: fg, Bold: bold}, text) +} + +// renderCrazyPachinko prints the multiplier row at the top, a short drop, then +// the multiplier row again at the bottom with the landing marked. It never +// prints the whole board twice. +func renderCrazyPachinko(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + if len(v.PachoPockets) == 0 { + return "" + } + ball := "o" + if unicode { + ball = "●" + } + row := func(landing int) string { + var sb strings.Builder + for i, value := range v.PachoPockets { + sb.WriteString(crazyPachinkoCell(value, landing, i, v.PachoDoubling, mode)) + } + return sb.String() + } + switch v.Phase { + case "top": + head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "PACHINKO — prizes") + if v.PachoDoubling > 0 { + head += crazyTimeRender(mode, color.ColorSpec{Fg: 201, Bold: true}, fmt.Sprintf(" x%d", 1<<v.PachoDoubling)) + } + return head + "\n" + row(-1) + case "drop": + // A single compact drop line: the ball slides toward its slot. + return strings.Repeat(" ", v.PachoLane*4+1) + color.Render(mode, color.ColorSpec{Fg: 231, Bold: true}, ball) + case "result": + head := crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, "PACHINKO — landed") + if v.PachoDoubling > 0 { + head += crazyTimeRender(mode, color.ColorSpec{Fg: 201, Bold: true}, fmt.Sprintf(" after %d DOUBLE", v.PachoDoubling)) + } + return head + "\n" + row(v.PachoLane) + } + return "" +} + +// ----- Crazy Time bonus wheel (three pointers) ----- + +func renderCrazyBonusWheel(v *casino.CrazyTimeBonusView, mode string, unicode bool) string { + const width = 6 + wheel := v.CTWheel + n := len(wheel) + if n == 0 { + return "" + } + // The view window is a contiguous run starting at CTOffset. + const cells = 11 + pick := v.CTPick + if pick < 0 || pick >= len(casino.CrazyBonusPointerCells) { + pick = 0 + } + window := make([]int, cells) + for i := 0; i < cells; i++ { + window[i] = wheel[((v.CTOffset+i)%n+n)%n] + } + b := rouletteBorders(unicode) + dim := func(text string) string { return crazyTimeRender(mode, color.ColorSpec{Fg: 240}, text) } + tip := "\\/" + if unicode { + tip = "▼" + } + tipRunes := []rune(tip) + var out strings.Builder + // Pointer line: three arrows over their cells, the chosen one bright. + for i := 0; i < 1+cells*(width+1); i++ { + ch := ' ' + ink, bold := 244, false + for p, cell := range casino.CrazyBonusPointerCells { + col := 1 + cell*(width+1) + width/2 + if i >= col && i < col+len(tipRunes) { + ch = tipRunes[i-col] + if p == pick { + ink, bold = 226, true + } + break + } + } + if ch == ' ' { + out.WriteByte(' ') + } else { + out.WriteString(crazyTimeRender(mode, color.ColorSpec{Fg: ink, Bold: bold}, string(ch))) + } + } + out.WriteByte('\n') + top := b.tl + strings.Repeat(strings.Repeat(b.h, width)+b.teeTop, cells-1) + strings.Repeat(b.h, width) + b.tr + bottom := b.bl + strings.Repeat(strings.Repeat(b.h, width)+b.teeBottom, cells-1) + strings.Repeat(b.h, width) + b.br + out.WriteString(dim(top) + "\n" + dim(b.v)) + for i, value := range window { + label := "x2" + ink := 201 + if value >= 0 { + label = fmt.Sprintf("x%d", value) + ink = 46 + if value >= 100 { + ink = 51 + } else if value >= 50 { + ink = 220 + } else if value >= 25 { + ink = 208 + } + } + spec := color.ColorSpec{Fg: ink, Bold: true} + // Highlight the chosen pointer's column. + if i == casino.CrazyBonusPointerCells[pick] { + spec = color.ColorSpec{Fg: 226, Bg: -1, Bold: true} + if v.CTResult { + spec.Bold = true + } + } + out.WriteString(crazyTimeRender(mode, spec, rouletteCenter(label, width))) + out.WriteString(dim(b.v)) + } + out.WriteByte('\n') + out.WriteString(dim(bottom)) + if v.CTResult { + out.WriteString("\n" + crazyTimeRender(mode, color.ColorSpec{Fg: 226, Bold: true}, fmt.Sprintf("POINTER %d PAYS %dx", pick+1, v.CTValue))) + } + return out.String() +} + +func renderCrazyTime(s *casino.CrazyTimeSnapshot, mode string, unicode bool, showOdds bool, precision int) string { + if s == nil { + return "" + } + if s.Bonus != nil { + return renderCrazyBonus(s.Bonus, mode, unicode) + } + if len(s.Pockets) > 0 { + return renderCrazyTimeWheel(s, mode, unicode) + } + return renderCrazyTimeFelt(s, mode, unicode, showOdds, precision) +} |
