diff options
| author | historia <[not public]> | 2026-10-10 04:08:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-10 04:08:09 -0400 |
| commit | 9fb1f9010526100e12dd1ab1b0ca98401bb6ed75 (patch) | |
| tree | 4a128211bfb0dd429bbb559a591d6c4be25c7757 /internal | |
| parent | ced77d7c89129da6ba4001ab8e3dad265aa41396 (diff) | |
| download | thehouseoficarus-9fb1f9010526100e12dd1ab1b0ca98401bb6ed75.tar.gz | |
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/big_six.go | 58 | ||||
| -rw-r--r-- | internal/casino/command_hints_test.go | 2 | ||||
| -rw-r--r-- | internal/casino/core.go | 3 | ||||
| -rw-r--r-- | internal/casino/craps.go | 4 | ||||
| -rw-r--r-- | internal/casino/craps_test.go | 14 | ||||
| -rw-r--r-- | internal/casino/crazy_time.go | 1440 | ||||
| -rw-r--r-- | internal/casino/crazy_time_test.go | 323 | ||||
| -rw-r--r-- | internal/casino/pai_gow.go | 4 | ||||
| -rw-r--r-- | internal/casino/registry.go | 9 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 148 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 12 | ||||
| -rw-r--r-- | internal/casino/table.go | 9 | ||||
| -rw-r--r-- | internal/casino/table_hints.go | 4 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 19 | ||||
| -rw-r--r-- | internal/casino/types.go | 1 | ||||
| -rw-r--r-- | internal/casino/video_poker.go | 2 | ||||
| -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 |
23 files changed, 2707 insertions, 57 deletions
diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go index 36e9c1d..0f4f764 100644 --- a/internal/casino/big_six.go +++ b/internal/casino/big_six.go @@ -99,6 +99,7 @@ type bigSixBet struct { type bigSixPlayer struct { Participant bets []bigSixBet + lastBets []rebetWager lastSpot string lastBet int } @@ -192,8 +193,8 @@ func (t *bigSixTable) commands(name string) []MenuCommand { var c []MenuCommand if p := t.players.get(name); p != nil && t.phase != phasePlaying { c = append(c, MenuCommand{Command: "bet N on BET", Desc: "Add a wager"}) - if p.lastBet > 0 { - c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last wager"}) + if len(p.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } if p.HasBet { c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) @@ -249,31 +250,39 @@ func (t *bigSixTable) leave(name string) []Event { func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *bigSixTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing Big 6.")} + return []Event{t.private(name, "You aren't playing Big 6.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot } resolved, ok := bigSixResolveTarget(spot) if !ok { - return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")}, false } spot = resolved if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Keep the original aggregate representable for shared menus and refunds. if amount > int(^uint(0)>>1)-p.Wager.Total { - return []Event{t.private(name, "Your total stake is too large.")} + return []Event{t.private(name, "Your total stake is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } p.bets = append(p.bets, bigSixBet{spot, wager}) p.HasBet = true @@ -281,7 +290,34 @@ func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event { p.Wager.Total += amount p.lastSpot, p.lastBet, p.SittingOut = spot, amount, false events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} - return append(events, t.openRound()...) + return append(events, t.openRound()...), true +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole spread. +func (t *bigSixTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing Big 6.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + // A rejected stake ends the replay rather than partially betting. + break + } + } + return events } // Motion is planned backwards from an independently sampled uniform outcome. @@ -452,6 +488,10 @@ func (t *bigSixTable) reset() { t.frames = nil t.multiplier.SetInt64(1) for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } p.bets = nil p.HasBet = false p.Wager = Wager{} diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go index b391526..32bdbfa 100644 --- a/internal/casino/command_hints_test.go +++ b/internal/casino/command_hints_test.go @@ -134,7 +134,7 @@ func TestOtherCasinoHintsRespectCommitment(t *testing.T) { t.Fatal("roulette spinning bets advertised") } r.phase = phaseWaiting - r.players.get("a").HasBet = false + r.resetRound() if !hintHas(r.menuEvent("a", true).Menu.Commands, "bet / rebet") { t.Fatal("roulette repeat missing") } diff --git a/internal/casino/core.go b/internal/casino/core.go index 3ba9765..ed56a97 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -199,6 +199,9 @@ func gameTitle(game GameName) string { if game == GameBigSix { return "Big 6" } + if game == GameCrazyTime { + return "Crazy Time" + } name := string(game) if name == "" { return "Table" diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 208799e..2b0243b 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -276,7 +276,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if amount != requested { msg += fmt.Sprintf(" {casino_emphasis}Rounded up from %d for exact odds units.{/}", requested) } - return []Event{t.private(name, msg), t.betSummary(p), t.reminder(name)} + return stripMenuBalance([]Event{t.private(name, msg), t.betSummary(p), t.reminder(name)}) } return reject("Place an established line/come bet before taking odds.") } @@ -331,7 +331,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { } events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), private)} events = append(events, t.syncRoundClock()...) - return append(events, t.betSummary(p), t.reminder(name)) + return stripMenuBalance(append(events, t.betSummary(p), t.reminder(name))) } func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) bool { payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 622e541..d453e06 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -422,3 +422,17 @@ func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) { t.Fatal("unbet stranded queued departure") } } + +func TestCrapsBetOmitsWallet(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + w := &testWallet{chips: 1000} + for _, e := range tb.bet("alice", "pass", 10, w) { + if e.ShowBalance || (e.Menu != nil && e.Menu.ShowBalance) { + t.Fatalf("craps bet printed the wallet: %+v", e) + } + } + if e := tb.reminder("alice"); e.Menu == nil || !e.Menu.ShowBalance { + t.Fatal("live craps state should still show the wallet") + } +} diff --git a/internal/casino/crazy_time.go b/internal/casino/crazy_time.go new file mode 100644 index 0000000..72b04c3 --- /dev/null +++ b/internal/casino/crazy_time.go @@ -0,0 +1,1440 @@ +package casino + +import ( + "fmt" + "math/big" + "math/rand" + "strings" + "sync" +) + +// Crazy Time is a single-seat betting wheel modelled on Evolution's game of +// the same name. The main wheel reuses Big 6's physical-wheel structure and +// animation; it can stop on a number (1, 2, 5 or 10) or on one of four bonus +// games (Coin Flip, Cash Hunt, Pachinko and the Crazy Time wheel). Every +// bonus is a pure multiplier generator: it only ever returns a positive +// multiple of the bonus stake, because the house edge lives in the main +// wheel's probabilities, not in the bonuses. +// +// RTP is exactly 98% for every betting target. The main wheel alone would +// return far less, so each round also spins a "Top Slot": it picks one of the +// eight symbols uniformly and a multiplier from that symbol's own +// distribution. If the main wheel then stops on that symbol, the win is +// multiplied. Each symbol's multiplier distribution is tuned so that the +// combined return is exactly 98%. +const ( + ctBonusCoinFlip = "coin_flip" + ctBonusCashHunt = "cash_hunt" + ctBonusPachinko = "pachinko" + ctBonusWheel = "crazy_time" + + // ctAll is the convenience target that places one wager on each of the + // four minigames. + ctAll = "ALL" + + // Exported bonus identifiers for the presentation layer. + CrazyTimeBonusCoinFlip = ctBonusCoinFlip + CrazyTimeBonusCashHunt = ctBonusCashHunt + CrazyTimeBonusPachinko = ctBonusPachinko + CrazyTimeBonusWheel = ctBonusWheel + + crazyTimeRTPTarget = 0.98 + crazyTimePockets = 54 +) + +// crazyTimeSpot is one main-wheel symbol and its betting payouts. Pays is the +// profit odds for the number symbols (1:1 for "1", 2:1 for "2", ...); Bonus +// names the bonus game for the four bonus symbols. +type crazyTimeSpot struct { + Label string + Count int + Pays int + Bonus string +} + +func crazyTimeSpots() []crazyTimeSpot { + return []crazyTimeSpot{ + {"1", 21, 1, ""}, + {"2", 13, 2, ""}, + {"5", 7, 5, ""}, + {"10", 4, 10, ""}, + {"COIN FLIP", 4, 0, ctBonusCoinFlip}, + {"CASH HUNT", 2, 0, ctBonusCashHunt}, + {"PACHINKO", 2, 0, ctBonusPachinko}, + {"CRAZY TIME", 1, 0, ctBonusWheel}, + } +} + +// crazyTimeLabels lists the eight betting symbols in wheel display order. +func crazyTimeLabels() []string { + spots := crazyTimeSpots() + labels := make([]string, len(spots)) + for i, s := range spots { + labels[i] = s.Label + } + return labels +} + +// crazyTimeWheel is the fixed, interleaved 54-pocket physical wheel. It is +// never shuffled during a round; a spin picks a pocket index uniformly. +var crazyTimeWheel = func() []string { + var wheel []string + for _, spot := range crazyTimeSpots() { + for i := 0; i < spot.Count; i++ { + wheel = append(wheel, spot.Label) + } + } + rand.New(rand.NewSource(603)).Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + return wheel +}() + +// crazyTimeResolveSpot maps a written target to a betting symbol. An exact +// label always wins over a longer label that shares its prefix, so "1" is the +// $1 symbol and never a prefix of "10". Otherwise an unambiguous prefix is +// accepted, so "co" names COIN FLIP, "ca" CASH HUNT, "cr" CRAZY TIME and "p" +// PACHINKO. It reports false when nothing or more than one symbol matches. +func crazyTimeResolveSpot(s string) (string, bool) { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + t = strings.Join(strings.Fields(t), " ") + if t == "" { + return "", false + } + compact := strings.ReplaceAll(t, " ", "") + match := "" + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if label == t || clabel == compact { + return label, true + } + } + for _, spot := range crazyTimeSpots() { + label := spot.Label + clabel := strings.ReplaceAll(label, " ", "") + if strings.HasPrefix(label, t) || strings.HasPrefix(clabel, compact) { + if match != "" { + return "", false + } + match = label + } + } + return match, match != "" +} + +// crazyTimeIsAll reports whether a written target asks for the convenience +// ALL bet (one wager on each minigame). +func crazyTimeIsAll(s string) bool { + t := strings.ToUpper(strings.TrimSpace(s)) + t = strings.ReplaceAll(t, "_", " ") + t = strings.ReplaceAll(t, "-", " ") + return strings.Join(strings.Fields(t), " ") == ctAll +} + +// ----- Bonus probability tables ----- + +// Coin Flip picks one of two weighted prize distributions and flips a fair +// coin for it. Low averages 3.265625 and high 15.296875 over a 128-ticket +// wheel each, so the bonus averages 9.28125. +var ( + ctCoinLowValues = []int{2, 3, 4, 5} + ctCoinLowWeights = []int{37, 43, 25, 23} + ctCoinHighValues = []int{7, 8, 9, 10, 12, 15, 20, 25, 50, 100} + ctCoinHighWeights = []int{16, 17, 14, 15, 17, 18, 13, 10, 7, 1} +) + +// Cash Hunt is 108 tiles, each carrying a positive multiplier. Weights are +// tickets out of 108; the board averages about 21.85. +const ctHuntSquares = 108 + +var ( + ctHuntValues = []int{2, 5, 10, 15, 25, 50, 100, 500} + ctHuntWeights = []int{30, 25, 20, 15, 8, 5, 3, 2} +) + +// Pachinko is sixteen bottom slots: fifteen fixed prizes drawn from the +// weighted table and one guaranteed DOUBLE. A drop lands uniformly, so the +// doubling chain has expected value sum(fixed)/14. +const ctPachinkoSlots = 16 + +var ( + ctPachinkoValues = []int{2, 5, 7, 10, 15, 20, 25, 50, 100} + ctPachinkoWeights = []int{1, 2, 2, 2, 2, 2, 2, 1, 1} +) + +// The Crazy Time wheel is 64 stops built from the observed Wheel 1: twelve +// 10s, thirteen 15s, seven 20s, eight 25s, six 50s, two 100s and sixteen +// DOUBLEs. A DOUBLE doubles every fixed prize and respins, so its expected +// value is (sum fixed / 64) / (1 - 16/32). +const ( + ctBonusWheelSize = 64 + ctBonusDoubleCount = 16 +) + +var ( + ctBonusWheelValues = []int{10, 15, 20, 25, 50, 100} + ctBonusWheelCounts = []int{12, 13, 7, 8, 6, 2} +) + +func ctWeightedMean(values, weights []int) float64 { + sum, total := 0, 0 + for i := range values { + sum += values[i] * weights[i] + total += weights[i] + } + if total == 0 { + return 0 + } + return float64(sum) / float64(total) +} + +func ctCoinFlipMean() float64 { + return (ctWeightedMean(ctCoinLowValues, ctCoinLowWeights) + ctWeightedMean(ctCoinHighValues, ctCoinHighWeights)) / 2 +} + +func ctCashHuntMean() float64 { return ctWeightedMean(ctHuntValues, ctHuntWeights) } + +func ctPachinkoMean() float64 { + // Fifteen fixed prizes plus one DOUBLE: V = sum(fixed)/16 + (1/16)*2V. + return 15 * ctWeightedMean(ctPachinkoValues, ctPachinkoWeights) / 14 +} + +func ctBonusWheelMean() float64 { + sum := 0 + for i := range ctBonusWheelValues { + sum += ctBonusWheelValues[i] * ctBonusWheelCounts[i] + } + return (float64(sum) / ctBonusWheelSize) / (1 - 2*float64(ctBonusDoubleCount)/ctBonusWheelSize) +} + +func crazyTimeBonusMean(kind string) float64 { + switch kind { + case ctBonusCoinFlip: + return ctCoinFlipMean() + case ctBonusCashHunt: + return ctCashHuntMean() + case ctBonusPachinko: + return ctPachinkoMean() + case ctBonusWheel: + return ctBonusWheelMean() + } + return 0 +} + +// crazyTimeBaseRTP is the return of a single wager before the Top Slot +// multiplier: the probability the wheel lands on the target times the +// target's total-return multiplier. +func crazyTimeBaseRTP(spot string) float64 { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return float64(s.Count) * float64(s.Pays+1) / crazyTimePockets + } + return float64(s.Count) / crazyTimePockets * crazyTimeBonusMean(s.Bonus) + } + return 0 +} + +// crazyTimeWinMultiplier is the total-return multiplier of a winning number +// bet, used by the house-edge reference and settlement. +func crazyTimeWinMultiplier(spot string, bonusValue int) int { + for _, s := range crazyTimeSpots() { + if s.Label != spot { + continue + } + if s.Pays > 0 { + return s.Pays + 1 + } + return bonusValue + } + return 0 +} + +// ----- Top Slot ----- + +// ctTopDist is a weighted integer multiplier distribution: mostly 1x misses +// with occasional larger multipliers. +type ctTopDist struct { + values []int + weights []float64 + total float64 +} + +// ctBuildTopDist builds a multiplier distribution whose mean is exactly +// target. A fixed base distribution over 2..50 is used, and the weight of a +// 1x multiplier is solved so the mean lands on target. +func ctBuildTopDist(target float64) ctTopDist { + baseValues := []int{2, 3, 4, 5, 7, 10, 15, 20, 25, 50} + baseWeights := []float64{10, 8, 8, 6, 5, 4, 2, 2, 1, 1} + sum, weight := 0.0, 0.0 + for i := range baseValues { + sum += float64(baseValues[i]) * baseWeights[i] + weight += baseWeights[i] + } + if target <= 1 { + return ctTopDist{values: []int{1}, weights: []float64{1}, total: 1} + } + ones := (sum - target*weight) / (target - 1) + if ones < 0 { + ones = 0 + } + values := append([]int{1}, baseValues...) + weights := append([]float64{ones}, baseWeights...) + total := ones + weight + return ctTopDist{values: values, weights: weights, total: total} +} + +func (d ctTopDist) draw(rng *rand.Rand) int { + if d.total <= 0 { + return 1 + } + x := rng.Float64() * d.total + for i, w := range d.weights { + x -= w + if x < 0 { + return d.values[i] + } + } + return d.values[len(d.values)-1] +} + +// crazyTimeTopTarget is the mean multiplier a symbol's Top Slot distribution +// must have for its combined return to be exactly crazyTimeRTPTarget. The Top +// Slot symbol is uniform, so only one eighth of the time does it boost a win. +func crazyTimeTopTarget(spot string) float64 { + base := crazyTimeBaseRTP(spot) + if base <= 0 { + return 1 + } + return 1 + 8*(crazyTimeRTPTarget/base-1) +} + +var crazyTimeTopDists = func() map[string]ctTopDist { + dists := make(map[string]ctTopDist, len(crazyTimeSpots())) + for _, s := range crazyTimeSpots() { + dists[s.Label] = ctBuildTopDist(crazyTimeTopTarget(s.Label)) + } + return dists +}() + +// crazyTimeTopSlotSymbol picks the Top Slot symbol uniformly from the eight +// betting targets. +func crazyTimeTopSlotSymbol(rng *rand.Rand) string { + labels := crazyTimeLabels() + return labels[rng.Intn(len(labels))] +} + +// ----- Snapshot types ----- + +// CrazyTimeBonusView carries the live state of whatever bonus game is running. +// One struct covers all four bonuses; Kind selects which fields the renderer +// reads. +type CrazyTimeBonusView struct { + Kind string + Phase string + + // Coin Flip. + CoinLow int + CoinHigh int + CoinSide string + CoinValue int + CoinFlipAlt bool + CoinResult bool + + // Cash Hunt. + HuntPreview []int + HuntFinal []int + HuntPick int + + // Pachinko. + PachoPockets []int + PachoRow int + PachoLane int + PachoDoubling int + PachoTop bool + + // Crazy Time wheel. + CTWheel []int + CTOffset int + CTPick int + CTDoubling int + CTResult bool + CTValue int +} + +// CrazyTimeBetView is one live wager shown by status/look. +type CrazyTimeBetView struct { + Spot string + Amount int +} + +// CrazyTimeSpotView is one paytable row. +type CrazyTimeSpotView struct { + Label string + Payout string + Bonus bool +} + +type CrazyTimeSnapshot struct { + Phase string + Pockets []string + TopSymbol string + TopMult int + Multiplier int + Wager int + Bets []CrazyTimeBetView + Bonus *CrazyTimeBonusView + Spots []CrazyTimeSpotView + RTP float64 +} + +func (*CrazyTimeSnapshot) snapshot() {} + +// ----- Engine ----- + +const ( + ctIdle = "idle" + ctSpinning = "spinning" + ctBonusing = "bonus" + ctPending = "pending" +) + +// ctPrint is one already-planned bonus animation frame: a structured view, +// a plain message, or both. +type ctPrint struct { + delay int + msg string + view *CrazyTimeBonusView + last bool +} + +type crazyTimeBet struct { + spot string + wager Wager +} + +type crazyTimeTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + bets []crazyTimeBet + lastSpot string + lastAmount int + // lastBets remembers the most recent stake on each symbol so a bare bet or + // rebet can restore a whole spread at once. + lastBets map[string]int + wallet Wallet + phase string + + // Top Slot. + topSymbol string + topMult int + + // Main wheel. + winIndex int + winSymbol string + position int + frames []bigSixFrame + frame int + wait int + + // Bonus. + bonusKind string + bonusValue int + prints []ctPrint + print int + printWait int +} + +func newCrazyTimeTable(room int, c TableConfig, rng *rand.Rand) *crazyTimeTable { + return &crazyTimeTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: ctIdle} +} + +func validateCrazyTime(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("crazy time has one seat") + } + return nil +} + +func crazyTimeRules(TableConfig) string { + return fmt.Sprintf("Crazy Time: a 54-pocket wheel. Bet on 1 (1:1), 2 (2:1), 5 (5:1), 10 (10:1) or one of the four bonuses: COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.\n"+ + "Place any number of wagers, then spin. A Top Slot picks one of the eight symbols and a multiplier; if the wheel stops on that same symbol, your win is multiplied.\n"+ + "Bonuses are always pure multipliers: COIN FLIP (fair coin, 2-100x), CASH HUNT (108 positive tiles, up to 500x), PACHINKO (positive prizes with chained DOUBLEs) and CRAZY TIME (a 64-stop three-pointer wheel with DOUBLE respins).\n"+ + "Every target returns exactly %.0f%% including the Top Slot; there is no house edge inside any bonus.\n"+ + "One seat only. Bets are committed at spin. Shutdown refunds an unsettled round; a failed payout keeps its stakes pending, and settle retries the same outcome.", + 100*crazyTimeRTPTarget) +} + +func crazyTimeHouseEdge(_ TableConfig, bet string) HouseEdge { + if bet == "" || bet == "main" || crazyTimeIsAll(bet) { + return edge(1 - crazyTimeRTPTarget) + } + spot, ok := crazyTimeResolveSpot(bet) + if !ok { + return unknownEdge("unrecognized Crazy Time target") + } + return edge(1 - crazyTimeRTPTargetFor(spot)) +} + +// crazyTimeRTPTargetFor returns the exact combined return of a target, +// including the Top Slot and the target's bonus multiplier distribution. +func crazyTimeRTPTargetFor(spot string) float64 { + base := crazyTimeBaseRTP(spot) + dist, ok := crazyTimeTopDists[spot] + if !ok { + return base + } + mean := 1.0 + if dist.total > 0 { + mean = 0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + } + return base * (1 + (mean-1)/8) +} + +// ----- tableGame plumbing ----- + +func (t *crazyTimeTable) base() *tableBase { return t.tableBase } + +func (t *crazyTimeTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == "" +} + +func (t *crazyTimeTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} + +func (t *crazyTimeTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && (t.phase == ctSpinning || t.phase == ctBonusing) +} + +func (t *crazyTimeTable) overview() string { return "Crazy Time / 54-pocket bonus wheel" } + +func (t *crazyTimeTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} + +func (t *crazyTimeTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return "spinning" + case ctBonusing: + return "bonus: " + t.bonusKind + case ctPending: + return "payout pending" + default: + return "waiting" + } +} + +func (t *crazyTimeTable) wagers(string) []WagerRow { + var rows []WagerRow + for _, s := range crazyTimeSpots() { + if s.Pays > 0 { + rows = append(rows, WagerRow{Bet: s.Label, Payout: fmt.Sprintf("%d:1", s.Pays), Win: "Wheel stops on " + s.Label}) + continue + } + rows = append(rows, WagerRow{Bet: s.Label, Payout: "varies", Win: "Wheel stops on " + s.Label + "; a positive bonus multiplier"}) + } + rows = append(rows, WagerRow{Bet: ctAll, Payout: "varies", Win: "Wheel stops on any minigame; plays that bonus"}) + return rows +} + +func (t *crazyTimeTable) hintsLocked() []MenuCommand { + if t.phase == ctSpinning { + return []MenuCommand{ + {Command: "table", Desc: "Show the wheel"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctBonusing { + return []MenuCommand{ + {Command: "table", Desc: "Show the bonus board"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + if t.phase == ctPending { + return []MenuCommand{ + {Command: "settle", Desc: "Retry the pending payout"}, + {Command: "rules", Desc: "Crazy Time rules"}, + } + } + c := []MenuCommand{ + {Command: "bet N on BET", Desc: "Wager on 1, 2, 5, 10, ALL or a bonus"}, + } + if len(t.lastBets) > 0 { + c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) + } + if len(t.bets) > 0 { + c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"}) + } + c = append(c, MenuCommand{Command: "spin", Desc: "Start the wheel"}) + c = append(c, MenuCommand{Command: "table", Desc: "Show the felt"}, MenuCommand{Command: "stop", Desc: "Leave after your bets settle"}) + return withTableHints(c, t.wagers("")) +} + +func (t *crazyTimeTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} + +func (t *crazyTimeTable) viewLocked(phase string) *CrazyTimeSnapshot { + s := &CrazyTimeSnapshot{ + Phase: phase, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Wager: t.wagerTotalLocked(), + Multiplier: t.topMult, + RTP: 100 * crazyTimeRTPTarget, + } + for _, b := range t.bets { + s.Bets = append(s.Bets, CrazyTimeBetView{Spot: b.spot, Amount: b.wager.Total}) + } + if phase == "paytable" { + for _, s2 := range crazyTimeSpots() { + if s2.Pays > 0 { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: fmt.Sprintf("%d:1", s2.Pays)}) + } else { + s.Spots = append(s.Spots, CrazyTimeSpotView{Label: s2.Label, Payout: "bonus", Bonus: true}) + } + } + } + return s +} + +func (t *crazyTimeTable) wagerTotalLocked() int { + total := 0 + for _, b := range t.bets { + total += b.wager.Total + } + return total +} + +// stripLocked returns the wheel pockets around the current position, centred +// on the position when a result is being shown. +func (t *crazyTimeTable) stripLocked(result bool) []string { + count := 11 + if result { + count = 9 + } + pockets := make([]string, 0, count) + for i := 0; i < count; i++ { + pockets = append(pockets, crazyTimeWheel[(t.position+i-count/2+len(crazyTimeWheel))%len(crazyTimeWheel)]) + } + return pockets +} + +func (t *crazyTimeTable) snapshotLocked(phase string, result bool) *CrazyTimeSnapshot { + s := t.viewLocked(phase) + s.Pockets = t.stripLocked(result) + return s +} + +// board is the table command's reference view: the full paytable plus the +// live wheel only while one is in flight. +func (t *crazyTimeTable) board(string) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase == ctSpinning { + return t.snapshotLocked(ctSpinning, false) + } + if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + return t.printSnapshotLocked(t.prints[i]) + } + return t.viewLocked("paytable") +} + +func (t *crazyTimeTable) statusLocked(n string) []Event { + phase := t.phase + if phase == "" { + phase = ctIdle + } + e := t.private(n, "") + e.Color = "casino_menu" + if t.phase == ctSpinning { + e.Snapshot = t.snapshotLocked(ctSpinning, false) + } else if t.phase == ctBonusing && len(t.prints) > 0 { + i := t.print + if i >= len(t.prints) { + i = len(t.prints) - 1 + } + e.Snapshot = t.printSnapshotLocked(t.prints[i]) + } else { + e.Snapshot = t.viewLocked("paytable") + } + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} + +func (t *crazyTimeTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} + +func (t *crazyTimeTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Crazy Time is occupied.")} + } + t.resetLocked() + t.name = n + e := t.private(n, "") + e.Type, e.Player = EventJoined, n + return append([]Event{e}, t.statusLocked(n)...) +} + +func (t *crazyTimeTable) resetLocked() { + t.bets = nil + t.wallet = nil + t.phase = ctIdle + t.topSymbol, t.topMult = "", 0 + t.winIndex, t.winSymbol, t.position = 0, "", 0 + t.frames, t.frame, t.wait = nil, 0, 0 + t.bonusKind, t.bonusValue = "", 0 + t.prints, t.print, t.printWait = nil, 0, 0 +} + +func (t *crazyTimeTable) clearLocked() { + t.name = "" + t.lastSpot, t.lastAmount = "", 0 + t.lastBets = nil + t.resetLocked() +} + +func (t *crazyTimeTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "Your Crazy Time wagers are committed until the round ends.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle before leaving.")} + } + t.clearLocked() + e := t.playerAction(n, n+" leaves Crazy Time.", "You leave Crazy Time.") + e.Type, e.Player = EventLeft, n + return []Event{e} +} + +func (t *crazyTimeTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name && t.phase == ctIdle && t.lastAmount > 0 { + return t.lastSpot, t.lastAmount + } + return "", 0 +} + +// ----- Betting ----- + +func (t *crazyTimeTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + if strings.TrimSpace(spot) == "" { + spot = t.lastSpot + } + if crazyTimeIsAll(spot) { + return t.betAllLocked(n, amount, w) + } + resolved, ok := crazyTimeResolveSpot(spot) + if !ok { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME — an unambiguous prefix is fine.")} + } + events, _ := t.placeBetLocked(n, resolved, amount, w) + return events +} + +// placeBetLocked commits one wager on an already-resolved symbol. It reports +// false when no wager was placed, so a multi-bet replay can stop on the first +// rejected stake. +func (t *crazyTimeTable) placeBetLocked(n, resolved string, amount int, w Wallet) ([]Event, bool) { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false + } + if amount > int(^uint(0)>>1)-t.wagerTotalLocked() { + return []Event{t.private(n, "Your total stake is too large.")}, false + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")}, false + } + wager, err := w.Wager(amount) + if err != nil { + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))}, false + } + t.wallet = w + t.bets = append(t.bets, crazyTimeBet{spot: resolved, wager: wager}) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + t.lastBets[resolved] = amount + t.lastSpot, t.lastAmount = resolved, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on %s.", n, amount, resolved), fmt.Sprintf("You bet %d on %s.", amount, resolved))}, true +} + +// betAllLocked treats ALL as one wager on each of the four minigames, so a +// single command covers every bonus and each stake keeps its own 98% return. +func (t *crazyTimeTable) betAllLocked(n string, amount int, w Wallet) []Event { + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Crazy Time bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")} + } + if amount > (int(^uint(0)>>1)-t.wagerTotalLocked())/4 { + return []Event{t.private(n, "Your total stake is too large.")} + } + var placed []crazyTimeBet + var ids []string + for _, s := range crazyTimeSpots() { + if s.Bonus == "" { + continue + } + wager, err := w.Wager(amount) + if err != nil { + if len(ids) > 0 { + _ = w.RefundStakes(ids...) + } + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for the ALL bet."))} + } + placed = append(placed, crazyTimeBet{spot: s.Label, wager: wager}) + ids = append(ids, wager.ID) + } + t.wallet = w + t.bets = append(t.bets, placed...) + if t.lastBets == nil { + t.lastBets = map[string]int{} + } + for _, b := range placed { + t.lastBets[b.spot] = amount + } + t.lastSpot, t.lastAmount = ctAll, amount + return []Event{t.playerAction(n, fmt.Sprintf("%s bets %d on ALL.", n, amount), fmt.Sprintf("You bet %d on all four minigames.", amount))} +} + +// rebetAll repeats every remembered wager from the previous round in one +// command, so a bare bet/rebet restores a whole multi-symbol spread. +func (t *crazyTimeTable) rebetAll(n string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return []Event{t.private(n, "You aren't playing Crazy Time.")} + } + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "No more bets! The wheel is spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + var events []Event + placed := 0 + for _, s := range crazyTimeSpots() { + amount := t.lastBets[s.Label] + if amount <= 0 { + continue + } + ev, ok := t.placeBetLocked(n, s.Label, amount, w) + events = append(events, ev...) + if !ok { + return events + } + placed++ + } + if placed == 0 { + return []Event{t.private(n, "Bet on 1, 2, 5, 10, ALL, COIN FLIP, CASH HUNT, PACHINKO or CRAZY TIME.")} + } + return events +} + +func (t *crazyTimeTable) menuEventLocked(n string) Event { + e := t.private(n, "") + e.Color = "casino_menu" + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + return e +} + +func (t *crazyTimeTable) action(n, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + f := strings.Fields(strings.ToLower(strings.TrimSpace(action))) + if len(f) == 0 { + return t.statusLocked(n) + } + switch f[0] { + case "spin": + if t.phase == ctSpinning || t.phase == ctBonusing { + return []Event{t.private(n, "The wheel is already spinning.")} + } + if t.phase == ctPending { + return []Event{t.private(n, "Use settle to retry the pending payout.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Place your bets before spinning.")} + } + return t.startSpinLocked() + case "unbet": + if t.phase != ctIdle { + return []Event{t.private(n, "You cannot take back bets after the wheel starts.")} + } + if len(t.bets) == 0 { + return []Event{t.private(n, "Nothing to take back.")} + } + ids := make([]string, 0, len(t.bets)) + total := 0 + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + total += b.wager.Total + } + if t.wallet != nil { + if err := t.wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(n, payoutFailedMessage())} + } + } + t.bets = nil + return []Event{t.private(n, fmt.Sprintf("You take back %d credits.", total))} + case "settle": + if t.phase == ctPending { + return t.settleLocked() + } + return []Event{t.private(n, "There is no pending payout.")} + } + return []Event{t.private(n, "Place bets, then spin. Use rules for Crazy Time help.")} +} + +// ----- Main wheel animation ----- + +func (t *crazyTimeTable) startSpinLocked() []Event { + t.phase = ctSpinning + t.topSymbol = crazyTimeTopSlotSymbol(t.rng) + t.topMult = crazyTimeTopDists[t.topSymbol].draw(t.rng) + t.winIndex = t.rng.Intn(len(crazyTimeWheel)) + t.winSymbol = crazyTimeWheel[t.winIndex] + t.frames = bigSixAnimation() + t.frame = 0 + distance := 0 + for _, f := range t.frames { + distance += f.move + } + t.position = (t.winIndex - distance%len(crazyTimeWheel) + len(crazyTimeWheel)) % len(crazyTimeWheel) + t.wait = t.frames[0].delay + 1 + e := t.event("No more bets! The Crazy Time wheel begins to turn...", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +func (t *crazyTimeTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case ctSpinning: + return t.tickSpinLocked() + case ctBonusing: + return t.tickBonusLocked() + } + return nil +} + +func (t *crazyTimeTable) tickSpinLocked() []Event { + t.wait-- + if t.wait > 0 { + return nil + } + t.position = (t.position + t.frames[t.frame].move) % len(crazyTimeWheel) + t.frame++ + if t.frame == len(t.frames) { + return t.revealLocked() + } + t.wait = t.frames[t.frame].delay + e := t.event("", true) + e.Snapshot = t.snapshotLocked(ctSpinning, false) + return []Event{e} +} + +// revealLocked stops the main wheel. A number settles immediately; a bonus +// starts its own animation and settles on its final frame. +func (t *crazyTimeTable) revealLocked() []Event { + t.position = t.winIndex + topMatch := t.topSymbol == t.winSymbol + msg := "The pointer stops on {casino_emphasis}" + t.winSymbol + "{/}!" + if topMatch && t.topMult > 1 { + msg += fmt.Sprintf(" The Top Slot multiplies it by %d!", t.topMult) + } + e := t.event(msg, true) + e.Snapshot = t.snapshotLocked("result", true) + events := []Event{e} + if t.isBonusLocked() { + return append(events, t.beginBonusLocked()...) + } + return append(events, t.settleLocked()...) +} + +func (t *crazyTimeTable) isBonusLocked() bool { + return crazyTimeWinMultiplier(t.winSymbol, 0) == 0 +} + +func (t *crazyTimeTable) tickBonusLocked() []Event { + if t.printWait > 0 { + t.printWait-- + return nil + } + if t.print >= len(t.prints) { + return t.settleLocked() + } + p := t.prints[t.print] + t.print++ + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } + } + if p.delay > 0 { + t.printWait = p.delay + } + if p.last { + return append([]Event{e}, t.settleLocked()...) + } + return []Event{e} +} + +func (t *crazyTimeTable) printSnapshotLocked(p ctPrint) *CrazyTimeSnapshot { + return &CrazyTimeSnapshot{ + Phase: ctBonusing, + TopSymbol: t.topSymbol, + TopMult: t.topMult, + Multiplier: t.topMult, + Bonus: p.view, + } +} + +// ----- Bonuses ----- + +func (t *crazyTimeTable) beginBonusLocked() []Event { + kind := "" + for _, s := range crazyTimeSpots() { + if s.Label == t.winSymbol { + kind = s.Bonus + } + } + t.bonusKind = kind + t.bonusValue = 0 + switch kind { + case ctBonusCoinFlip: + t.buildCoinFlipLocked() + case ctBonusCashHunt: + t.buildCashHuntLocked() + case ctBonusPachinko: + t.buildPachinkoLocked() + case ctBonusWheel: + t.buildBonusWheelLocked() + } + t.phase = ctBonusing + sym := t.winSymbol + announce := t.event("{casino_emphasis}"+sym+"{/} bonus! The game is played now.", true) + if len(t.prints) == 0 { + return []Event{announce} + } + first := t.prints[0] + t.print = 1 + if first.delay > 0 { + t.printWait = first.delay + } + events := []Event{announce, t.nextBonusEventLocked(first)} + if first.last { + events = append(events, t.settleLocked()...) + } + return events +} + +func (t *crazyTimeTable) nextBonusEventLocked(p ctPrint) Event { + e := t.private(t.name, p.msg) + if p.view != nil { + e.Snapshot = t.printSnapshotLocked(p) + } + return e +} + +func ctWeightedPick(rng *rand.Rand, values, weights []int) int { + total := 0 + for _, w := range weights { + total += w + } + ticket := rng.Intn(total) + for i, w := range weights { + if ticket < w { + return values[i] + } + ticket -= w + } + return values[len(values)-1] +} + +func (t *crazyTimeTable) buildCoinFlipLocked() { + low := ctWeightedPick(t.rng, ctCoinLowValues, ctCoinLowWeights) + high := ctWeightedPick(t.rng, ctCoinHighValues, ctCoinHighWeights) + side := "red" + value := low + if t.rng.Intn(2) == 0 { + side, value = "blue", high + } + t.bonusValue = value + view := &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "faces", CoinLow: low, CoinHigh: high} + t.prints = append(t.prints, ctPrint{delay: 2, view: view}) + frames := 4 + t.rng.Intn(4) + for i := 0; i < frames; i++ { + alt := i%2 == 0 + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "flip", CoinLow: low, CoinHigh: high, CoinFlipAlt: alt}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCoinFlip, Phase: "result", CoinLow: low, CoinHigh: high, CoinSide: side, CoinValue: value, CoinResult: true}, last: true}) +} + +func (t *crazyTimeTable) buildCashHuntLocked() { + board := make([]int, ctHuntSquares) + for i := range board { + board[i] = ctWeightedPick(t.rng, ctHuntValues, ctHuntWeights) + } + preview := append([]int(nil), board...) + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + pick := t.rng.Intn(ctHuntSquares) + t.bonusValue = board[pick] + t.prints = append(t.prints, + ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "preview", HuntPreview: preview}}, + ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "hidden"}}, + ) + for i := 0; i < 2+t.rng.Intn(2); i++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "shuffle", HuntFinal: board}}) + } + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusCashHunt, Phase: "result", HuntFinal: board, HuntPick: pick}, last: true}) +} + +// crazyPachinkoPath builds a plausible drop path that ends on landing. Each +// row is one lane; the path eases from a random start toward the landing. +func crazyPachinkoPath(rng *rand.Rand, landing int) []int { + start := rng.Intn(ctPachinkoSlots) + path := make([]int, ctPachinkoSlots) + for row := 0; row < ctPachinkoSlots; row++ { + frac := float64(row) / float64(ctPachinkoSlots-1) + lane := int(float64(start)*(1-frac) + float64(landing)*frac + 0.5) + if row > 0 && row < ctPachinkoSlots-1 { + lane += rng.Intn(3) - 1 + } + if lane < 0 { + lane = 0 + } + if lane >= ctPachinkoSlots { + lane = ctPachinkoSlots - 1 + } + path[row] = lane + } + path[ctPachinkoSlots-1] = landing + return path +} + +// newPachinkoBoard deals fifteen fixed prizes plus one DOUBLE, then shuffles. +func (t *crazyTimeTable) newPachinkoBoardLocked() []int { + board := make([]int, ctPachinkoSlots) + for i := 0; i < ctPachinkoSlots-1; i++ { + board[i] = ctWeightedPick(t.rng, ctPachinkoValues, ctPachinkoWeights) + } + board[ctPachinkoSlots-1] = -1 // DOUBLE + t.rng.Shuffle(len(board), func(i, j int) { board[i], board[j] = board[j], board[i] }) + return board +} + +func (t *crazyTimeTable) buildPachinkoLocked() { + board := t.newPachinkoBoardLocked() + doubling := 0 + for drops := 0; drops < 20; drops++ { + topCopy := append([]int(nil), board...) + t.prints = append(t.prints, ctPrint{delay: 2, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: topCopy, PachoTop: true, PachoDoubling: doubling}}) + landing := t.rng.Intn(ctPachinkoSlots) + path := crazyPachinkoPath(t.rng, landing) + for row := 1; row < ctPachinkoSlots; row++ { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "drop", PachoPockets: board, PachoRow: row, PachoLane: path[row], PachoDoubling: doubling}}) + } + if board[landing] == -1 { + doubling++ + for i := range board { + if board[i] > 0 { + board[i] *= 2 + } + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: "DOUBLE! Every prize is doubled and the puck drops again.", view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "top", PachoPockets: append([]int(nil), board...), PachoTop: true, PachoDoubling: doubling}}) + continue + } + t.bonusValue = board[landing] + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: landing, PachoDoubling: doubling}, last: true}) + return + } + // Safety valve: an astronomically unlikely 20 doubles. Pay the current board. + t.bonusValue = 1 + for _, v := range board { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusPachinko, Phase: "result", PachoPockets: board, PachoRow: ctPachinkoSlots, PachoLane: 0, PachoDoubling: doubling}, last: true}) +} + +// ctBonusWheelStops builds the 64-stop wheel: -1 marks a DOUBLE, otherwise +// the value is a fixed prize. The caller shuffles it for layout variety. +func ctBonusWheelStops() []int { + var wheel []int + for i := range ctBonusWheelValues { + for n := 0; n < ctBonusWheelCounts[i]; n++ { + wheel = append(wheel, ctBonusWheelValues[i]) + } + } + for i := 0; i < ctBonusDoubleCount; i++ { + wheel = append(wheel, -1) + } + return wheel +} + +// CrazyBonusPointerCells are the three pointer columns (as cell index within +// the rendered strip). The engine and renderer share them so the chosen +// pointer always shows the winning stop. +var CrazyBonusPointerCells = [3]int{2, 5, 8} + +func (t *crazyTimeTable) buildBonusWheelLocked() { + wheel := ctBonusWheelStops() + t.rng.Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] }) + pick := t.rng.Intn(3) + doubling := 0 + for spins := 0; spins < 20; spins++ { + winIndex := t.rng.Intn(ctBonusWheelSize) + value := wheel[winIndex] + pcol := CrazyBonusPointerCells[pick] + finalOffset := ((winIndex-pcol)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + t.appendBonusSpinLocked(wheel, finalOffset, pick, doubling, value == -1) + if value == -1 { + doubling++ + for i := range wheel { + if wheel[i] > 0 { + wheel[i] *= 2 + } + } + continue + } + t.bonusValue = value + t.prints = append(t.prints, ctPrint{delay: 3, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: value}, last: true}) + return + } + t.bonusValue = 1 + for _, v := range wheel { + if v > 0 { + t.bonusValue = v + break + } + } + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "result", CTWheel: wheel, CTOffset: 0, CTPick: pick, CTDoubling: doubling, CTResult: true, CTValue: t.bonusValue}, last: true}) +} + +// appendBonusSpinLocked appends the frames of one Crazy Time wheel spin, +// ending on finalOffset. The spin is deliberately longer than the main +// wheel's. +func (t *crazyTimeTable) appendBonusSpinLocked(wheel []int, finalOffset, pick, doubling int, double bool) { + steps := make([]int, 0, 24) + step := 5 + t.rng.Intn(4) + for n := 4 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + if step > 2 && t.rng.Intn(2) == 0 { + step-- + } + } + step = min(step, 2+t.rng.Intn(2)) + for n := 2 + t.rng.Intn(3); n > 0; n-- { + steps = append(steps, step) + } + for i := 0; i < 5+t.rng.Intn(4); i++ { + steps = append(steps, 1) + } + if t.rng.Intn(2) == 0 { + steps = append(steps, 0) + } + sum := 0 + for _, s := range steps { + sum += s + } + for sum < ctBonusWheelSize*3 { + steps = append([]int{8}, steps...) + sum += 8 + } + offset := ((finalOffset-sum)%ctBonusWheelSize + ctBonusWheelSize) % ctBonusWheelSize + for _, s := range steps { + t.prints = append(t.prints, ctPrint{delay: 1, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: offset, CTPick: pick, CTDoubling: doubling}}) + offset = (offset + s) % ctBonusWheelSize + } + label := "SPIN" + if double { + label = "DOUBLE — respin!" + } + t.prints = append(t.prints, ctPrint{delay: 2, msg: label, view: &CrazyTimeBonusView{Kind: ctBonusWheel, Phase: "spin", CTWheel: append([]int(nil), wheel...), CTOffset: finalOffset, CTPick: pick, CTDoubling: doubling}}) +} + +// ----- Settlement ----- + +// settleLocked pays every committed wager from the fixed outcome. It is +// idempotent: a failed bank save keeps the stakes and the outcome, so settle +// retries the same payout. +func (t *crazyTimeTable) settleLocked() []Event { + if t.wallet == nil || len(t.bets) == 0 { + t.resetLocked() + return nil + } + mult := 1 + if t.winSymbol == t.topSymbol && t.topMult > 0 { + mult = t.topMult + } + winMult := crazyTimeWinMultiplier(t.winSymbol, t.bonusValue) + total := new(big.Int) + representable := true + var ids []string + var lines []SettlementLine + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + returned := new(big.Int) + result := "loss" + odds := "0:1" + if b.spot == t.winSymbol { + returned.SetInt64(int64(b.wager.Total)) + returned.Mul(returned, big.NewInt(int64(winMult))) + returned.Mul(returned, big.NewInt(int64(mult))) + result = "win" + odds = fmt.Sprintf("%d:1", winMult*mult-1) + } + total.Add(total, returned) + if !returned.IsInt64() || returned.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 { + representable = false + } + lines = append(lines, SettlementLine{ + Label: b.spot, + Odds: odds, + Stake: b.wager.Total, + Returned: int(returned.Int64()), + Result: result, + }) + } + failed := !representable || total.Cmp(big.NewInt(MaxCredits)) > 0 + if !failed { + failed = t.wallet.PayCredits(int(total.Int64()), ids...) != nil + } + e := t.playerAction(t.name, t.name+" settles their Crazy Time wagers.", "") + if failed { + t.phase = ctPending + e.PrivateMessage = payoutFailedMessage() + " Use settle to retry the same outcome." + e.Message = t.name + "'s settlement is pending recovery." + e.ShowBalance = true + return []Event{e} + } + e.PrivateMessage = settlementText(lines) + e.Settlement = lines + e.Color = settlementTag(lines) + events := []Event{e} + t.resetLocked() + events = append(events, t.menuEventLocked(t.name)) + return events +} + +func (t *crazyTimeTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + var events []Event + switch { + case t.phase == ctPending: + // Leave the stakes pending for recovery; the seat clears. + case t.phase == ctSpinning || t.phase == ctBonusing: + if t.wallet != nil { + ids := make([]string, 0, len(t.bets)) + for _, b := range t.bets { + ids = append(ids, b.wager.ID) + } + if len(ids) > 0 { + t.wallet.ResolveStakesByIDs(ids...) + } + } + default: + if t.wallet != nil { + _, _, _ = t.wallet.RefundOutstanding() + } + } + t.clearLocked() + e := t.event(n+" leaves Crazy Time.", true) + e.Type, e.Player = EventLeft, n + return append(events, e) +} + +func (t *crazyTimeTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + if t.phase == ctPending { + // A decided outcome whose payout could not be persisted: retry it so + // shutdown pays the same result rather than refunding a decided round. + events = append(events, t.settleLocked()...) + } else if (t.phase == ctSpinning || t.phase == ctBonusing) && t.wallet != nil { + chips, credits, err := t.wallet.RefundOutstanding() + message := fmt.Sprintf("Crazy Time closes; %d credits/chips refunded.", chips+credits) + if err != nil { + message = "Crazy Time refund could not be persisted; your stake remains pending for recovery." + } + events = append(events, t.private(t.name, message)) + } + t.clearLocked() + return events +} diff --git a/internal/casino/crazy_time_test.go b/internal/casino/crazy_time_test.go new file mode 100644 index 0000000..d783cc1 --- /dev/null +++ b/internal/casino/crazy_time_test.go @@ -0,0 +1,323 @@ +package casino + +import ( + "math" + "math/rand" + "strings" + "testing" +) + +func newCrazyTimeTest() *crazyTimeTable { + return newCrazyTimeTable(1, TableConfig{ID: "crazy", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(99))) +} + +func TestCrazyTimeRegistrationAndRTP(t *testing.T) { + cfg := TableConfig{ID: "crazy", Game: GameCrazyTime} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Normalize().MaxPlayers != 1 { + t.Fatal("crazy time must be single-seat") + } + if cfg.HelpTopic() != "crazy time" { + t.Fatalf("help topic %q", cfg.HelpTopic()) + } + if newTable(1, cfg, newTestRand()) == nil { + t.Fatal("registration failed") + } + for _, spot := range crazyTimeSpots() { + rtp := crazyTimeRTPTargetFor(spot.Label) + if math.Abs(rtp-crazyTimeRTPTarget) > 1e-9 { + t.Fatalf("%s RTP = %.12f, want %.12f", spot.Label, rtp, crazyTimeRTPTarget) + } + e := cfg.HouseEdge(spot.Label) + if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-crazyTimeRTPTarget)) > 1e-9 { + t.Fatalf("%s edge %+v", spot.Label, e) + } + dist := crazyTimeTopDists[spot.Label] + if dist.total <= 0 { + t.Fatalf("%s empty top dist", spot.Label) + } + for _, w := range dist.weights { + if w < 0 { + t.Fatalf("%s negative weight", spot.Label) + } + } + mean := 0.0 + for i, w := range dist.weights { + mean += float64(dist.values[i]) * w + } + mean /= dist.total + if math.Abs(mean-crazyTimeTopTarget(spot.Label)) > 1e-9 { + t.Fatalf("%s top mean %.9f target %.9f", spot.Label, mean, crazyTimeTopTarget(spot.Label)) + } + } +} + +func TestCrazyTimeWheelCountsAndPositiveBonuses(t *testing.T) { + counts := map[string]int{} + for _, s := range crazyTimeWheel { + counts[s]++ + } + if len(crazyTimeWheel) != crazyTimePockets { + t.Fatalf("wheel has %d pockets", len(crazyTimeWheel)) + } + total := 0 + for _, spot := range crazyTimeSpots() { + if counts[spot.Label] != spot.Count { + t.Fatalf("%s count %d want %d", spot.Label, counts[spot.Label], spot.Count) + } + total += spot.Count + } + if total != crazyTimePockets { + t.Fatalf("counts sum to %d", total) + } + // Every bonus distribution is strictly positive. + for _, v := range ctHuntValues { + if v < 1 { + t.Fatalf("cash hunt value %d is not positive", v) + } + } + for _, v := range ctPachinkoValues { + if v < 1 { + t.Fatalf("pachinko value %d is not positive", v) + } + } + for _, v := range ctCoinLowValues { + if v < 1 { + t.Fatalf("coin low value %d is not positive", v) + } + } + for _, v := range ctCoinHighValues { + if v < 1 { + t.Fatalf("coin high value %d is not positive", v) + } + } + for _, v := range ctBonusWheelValues { + if v < 1 { + t.Fatalf("crazy wheel value %d is not positive", v) + } + } +} + +func TestCrazyTimeBetSpinSettle(t *testing.T) { + table := newCrazyTimeTest() + w := &recordingWallet{testWallet: testWallet{credits: 1000}} + table.join("a") + table.bet("a", "1", 10, w) + table.bet("a", "crazy time", 5, w) + if len(table.bets) != 2 || len(w.open) != 2 { + t.Fatalf("bets not committed: %+v open=%d", table.bets, len(w.open)) + } + // Capture the fixed outcome before the wheel settles and resets it. + table.topSymbol, table.topMult = "2", 7 + events := table.action("a", "spin") + if table.phase != ctSpinning || len(events) == 0 { + t.Fatal("spin did not start") + } + win := table.winSymbol + topSymbol, topMult := table.topSymbol, table.topMult + if win == "" { + t.Fatal("no winning symbol") + } + // Drive the wheel to completion. + for i := 0; i < 400 && table.phase == ctSpinning; i++ { + table.tick() + } + // A bonus may still be animating; drive it out too. + for i := 0; i < 1000 && table.phase == ctBonusing; i++ { + table.tick() + } + mult := 1 + if topSymbol == win { + mult = topMult + } + want := 0 + if win == "1" { + want += 10 * 2 * mult + } + if win == "CRAZY TIME" { + want += 5 * table.bonusValue * mult + } + if w.paid != want { + t.Fatalf("paid %d want %d (win=%s top=%s x%d)", w.paid, want, win, topSymbol, topMult) + } + if len(w.open) != 0 { + t.Fatalf("stakes left open: %d", len(w.open)) + } +} + +func TestCrazyTimeAllBonusesOnlyPayPositive(t *testing.T) { + for _, kind := range []string{ctBonusCoinFlip, ctBonusCashHunt, ctBonusPachinko, ctBonusWheel} { + for seed := int64(0); seed < 40; seed++ { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(seed+1))) + table.name = "a" + table.phase = ctBonusing + table.winSymbol = labelForBonus(kind) + table.topSymbol, table.topMult = "10", 1 + switch kind { + case ctBonusCoinFlip: + table.buildCoinFlipLocked() + case ctBonusCashHunt: + table.buildCashHuntLocked() + case ctBonusPachinko: + table.buildPachinkoLocked() + case ctBonusWheel: + table.buildBonusWheelLocked() + } + if table.bonusValue < 1 { + t.Fatalf("%s produced %d", kind, table.bonusValue) + } + if len(table.prints) == 0 { + t.Fatalf("%s produced no frames", kind) + } + } + } +} + +func labelForBonus(kind string) string { + for _, s := range crazyTimeSpots() { + if s.Bonus == kind { + return s.Label + } + } + return "" +} + +func TestCrazyTimeUnbetRefunds(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 100} + table.join("a") + table.bet("a", "5", 10, w) + if w.credits != 90 { + t.Fatalf("stake not debited: %d", w.credits) + } + table.action("a", "unbet") + if w.paid != 10 || len(w.open) != 0 { + t.Fatalf("unbet did not refund: paid=%d open=%d", w.paid, len(w.open)) + } +} + +func TestCrazyTimeAllBetAndRebet(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + + events := table.bet("a", "all", 10, w) + if len(table.bets) != 4 { + t.Fatalf("ALL placed %d wagers, want 4", len(table.bets)) + } + if w.credits != 960 || len(w.open) != 4 { + t.Fatalf("ALL debit: credits=%d open=%d", w.credits, len(w.open)) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("bet returned a menu (wallet would print): %+v", e) + } + } + + // Settle on a losing number, then rebet every remembered minigame wager. + table.winSymbol = "10" + table.topSymbol, table.topMult = "1", 1 + table.settleLocked() + if len(table.bets) != 0 { + t.Fatal("settlement did not clear the round") + } + before := w.credits + events = table.rebetAll("a", w) + if len(table.bets) != 4 { + t.Fatalf("rebet placed %d wagers, want 4", len(table.bets)) + } + if w.credits != before-40 { + t.Fatalf("rebet debit: credits=%d want %d", w.credits, before-40) + } + for _, e := range events { + if e.Menu != nil { + t.Fatalf("rebet returned a menu: %+v", e) + } + } +} + +func TestCrazyTimeResolveSpot(t *testing.T) { + cases := map[string]string{ + "1": "1", "2": "2", "5": "5", "10": "10", + "co": "COIN FLIP", "coin flip": "COIN FLIP", "coinflip": "COIN FLIP", + "ca": "CASH HUNT", "cash hunt": "CASH HUNT", + "p": "PACHINKO", "pach": "PACHINKO", + "cr": "CRAZY TIME", "crazy time": "CRAZY TIME", + } + for in, want := range cases { + got, ok := crazyTimeResolveSpot(in) + if !ok || got != want { + t.Fatalf("%q -> %q,%v want %q", in, got, ok, want) + } + } + if _, ok := crazyTimeResolveSpot("c"); ok { + t.Fatal("ambiguous c should not resolve") + } +} + +func TestCrazyTimeRulesText(t *testing.T) { + rules := crazyTimeRules(TableConfig{}) + if !strings.Contains(rules, "98%") || strings.Contains(rules, "%%") { + t.Fatalf("rules text: %q", rules) + } + if !strings.Contains(rules, "CASH HUNT") || !strings.Contains(rules, "Top Slot") { + t.Fatalf("rules text missing content: %q", rules) + } +} + +// TestCrazyTimeSettlementMath pins the payout arithmetic: a winning target +// pays stake * total-return-multiplier * Top Slot multiplier, and a losing +// target returns nothing. +func TestCrazyTimeSettlementMath(t *testing.T) { + cases := []struct { + spot, win, topSym string + bonus, topMult int + stake, want int + }{ + {"1", "1", "1", 0, 5, 10, 10 * 2 * 5}, + {"1", "10", "1", 0, 5, 10, 0}, + {"CASH HUNT", "CASH HUNT", "CASH HUNT", 3, 4, 7, 7 * 3 * 4}, + {"CASH HUNT", "CASH HUNT", "COIN FLIP", 500, 9, 2, 2 * 500}, + {"CRAZY TIME", "CRAZY TIME", "CRAZY TIME", 64, 50, 1, 64 * 50}, + } + for _, tc := range cases { + table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(3))) + w := &testWallet{credits: MaxCredits / 2} + table.name = "a" + table.wallet = w + wager, err := w.Wager(tc.stake) + if err != nil { + t.Fatal(err) + } + table.bets = []crazyTimeBet{{spot: tc.spot, wager: wager}} + table.winSymbol = tc.win + table.bonusValue = tc.bonus + table.topSymbol, table.topMult = tc.topSym, tc.topMult + before := w.paid + table.settleLocked() + if got := w.paid - before; got != tc.want { + t.Fatalf("%s win=%s bonus=%d top=%s x%d stake=%d paid %d want %d", tc.spot, tc.win, tc.bonus, tc.topSym, tc.topMult, tc.stake, got, tc.want) + } + } +} + +func TestCrazyTimeSpinDelaysFirstMove(t *testing.T) { + table := newCrazyTimeTest() + w := &testWallet{credits: 1000} + table.join("a") + table.bet("a", "1", 10, w) + events := table.action("a", "spin") + if len(events) == 0 || events[0].Snapshot == nil { + t.Fatal("spin did not print the opening wheel") + } + // The spin command and the first engine tick share a cycle, so the opening + // position must survive that tick before the wheel advances. + if ev := table.tick(); len(ev) != 0 { + t.Fatalf("first tick advanced the wheel immediately: %+v", ev) + } + if ev := table.tick(); len(ev) == 0 { + t.Fatal("second tick did not advance the wheel") + } +} diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 8a7ddf3..ad0693d 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -417,9 +417,9 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { // The main wager is the commitment point: when every active player has // one, the cards come out immediately rather than waiting on the timer. if spot == "main" && t.allBetLocked() { - return append(events, t.dealLocked()...) + return stripMenuBalance(append(events, t.dealLocked()...)) } - return append(events, t.statusLocked(name, false)...) + return stripMenuBalance(append(events, t.statusLocked(name, false)...)) } func (t *paiGowTable) allReady() bool { found := false diff --git a/internal/casino/registry.go b/internal/casino/registry.go index e87b45a..9d6017d 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -135,6 +135,15 @@ func init() { ruleText: bigSixRules, helpTopic: func(TableConfig) string { return "big6" }, }, + GameCrazyTime: { + houseEdge: crazyTimeHouseEdge, + validate: validateCrazyTime, + normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newCrazyTimeTable(room, c, rng) }, + ruleText: crazyTimeRules, + helpTopic: func(TableConfig) string { return "crazy time" }, + }, GameRoulette: { houseEdge: rouletteHouseEdge, validate: func(c TableConfig) error { diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 71a0916..254a472 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -459,8 +459,15 @@ func rouletteBasket(nums []string, double bool) ([]string, int, bool) { return nil, 0, false } +type roulettePlacedBet struct { + spot string + wager Wager +} + type roulettePlayer struct { Participant + bets []roulettePlacedBet + lastBets []rebetWager lastSpot string lastBet int } @@ -519,8 +526,8 @@ func (t *rouletteTable) menuEvent(name string, board bool) Event { p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } if p != nil { @@ -564,15 +571,20 @@ func (t *rouletteTable) leave(name string) []Event { func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() + events, _ := t.betLocked(name, spot, amount, w) + return events +} + +// betLocked is the locked implementation of bet, shared with the multi-wager +// replay so a rebet holds the table lock across every stake. It reports false +// when no wager was placed. +func (t *rouletteTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { - return []Event{t.private(name, "You aren't playing roulette.")} + return []Event{t.private(name, "You aren't playing roulette.")}, false } if t.phase == phasePlaying { - return []Event{t.private(name, "No more bets! The wheel is spinning.")} - } - if p.HasBet { - return []Event{t.private(name, "You have already bet this spin.")} + return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot @@ -580,21 +592,24 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { spot = normalizeRouletteSpot(spot) resolved, ok := rouletteWagerFor(spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) if !ok { - return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")} + return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")}, false } if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}, false } // Check every possible winning return before committing even an unlimited // wager: a call bet's components pay different multiples. if err := resolved.validate(amount); err != nil { - return []Event{t.private(name, "That wager's potential payout is too large.")} + return []Event{t.private(name, "That wager's potential payout is too large.")}, false } wager, err := w.Wager(amount) if err != nil { - return []Event{t.private(name, walletErrorMessage(err))} + return []Event{t.private(name, walletErrorMessage(err))}, false } - p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false + p.bets = append(p.bets, roulettePlacedBet{spot: spot, wager: wager}) + p.HasBet, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, w, spot, amount, false + p.Wager.Total += amount + p.Wager.ID = wager.ID events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} // With two or more players a timed betting window lets the table bet // together. A lone player has no time limit: once their wager lands, @@ -611,7 +626,7 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { events = append(events, t.startSpin()) } } - return events + return events, true } func (t *rouletteTable) tick() []Event { t.mu.Lock() @@ -729,23 +744,39 @@ func (t *rouletteTable) settle() []Event { if !p.HasBet { continue } - wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) - payout := 0 + totalPayout := 0 result := "loss" + var ids []string + var lines []SettlementLine var err error - if resolved { - if share, ok := wager.covers(pocket); ok { + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + wager, resolved := rouletteWagerFor(b.spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) + payout := 0 + res := "loss" + if resolved { + if share, ok := wager.covers(pocket); ok { + res = "win" + payout, err = share.payout(b.wager.Total) + } + } + if err != nil { + break + } + totalPayout += payout + if res == "win" { result = "win" - payout, err = share.payout(p.Wager.Total) } + lines = append(lines, SettlementLine{Label: b.spot, Odds: rouletteSettlementOdds(wager, pocket), Stake: b.wager.Total, Returned: payout, Result: res}) } - // PayCredits supports a zero return and atomically consumes a losing - // stake too, surfacing persistence errors instead of announcing success. + // PayCredits supports a zero return and atomically consumes every + // losing stake too, surfacing persistence errors instead of + // announcing success. failed := err != nil if !failed { - failed = p.Wallet.PayCredits(payout, p.Wager.ID) != nil + failed = p.Wallet.PayCredits(totalPayout, ids...) != nil } - text := resultTextLabel(p.lastSpot, result, p.Wager.Total, payout) + text := settlementText(lines) public := p.Name + " " + result + "." if failed { text = payoutFailedMessage() @@ -756,7 +787,7 @@ func (t *rouletteTable) settle() []Event { // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = p.SettlementBalanceNeeded(failed) if !failed { - event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}} + event.Settlement = lines if result == "win" { event.Color = "casino_result_win" } else { @@ -778,6 +809,13 @@ func (t *rouletteTable) resetRound() { t.phase = phaseWaiting t.window.close() t.spinRemaining = 0 + for _, p := range t.players.ordered() { + p.lastBets = p.lastBets[:0] + for _, b := range p.bets { + p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) + } + p.bets = nil + } clearBets(t.players.participants()) } func (t *rouletteTable) action(name, action string) []Event { @@ -795,6 +833,38 @@ func (t *rouletteTable) action(name, action string) []Event { } return []Event{t.private(name, "Roulette has no player actions after betting.")} } + +// unbetLocked returns every stake the seat committed this round. Roulette +// allows several wagers in one spin, so it shadows the shared single-stake +// helper. +func (t *rouletteTable) unbetLocked(name string) ([]Event, bool) { + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't seated at this table.")}, true + } + if t.phase != phaseWaiting && t.phase != phaseBetting { + return []Event{t.private(name, "You can only take back bets before the round starts.")}, true + } + if !p.HasBet || len(p.bets) == 0 { + return []Event{t.private(name, "Nothing to take back.")}, true + } + total := 0 + var ids []string + for _, b := range p.bets { + total += b.wager.Total + if b.wager.ID != "" { + ids = append(ids, b.wager.ID) + } + } + if p.Wallet != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { + return []Event{t.private(name, payoutFailedMessage())}, true + } + } + p.HasBet, p.Wager, p.bets = false, Wager{}, nil + return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))}, true +} + func (t *rouletteTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() @@ -803,6 +873,32 @@ func (t *rouletteTable) rebet(name string) (string, int) { } return "", t.Config.MinBet } + +// rebetAll repeats every remembered wager from the previous spin in one +// command, so a bare bet/rebet restores a whole spread. +func (t *rouletteTable) rebetAll(name string, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players.get(name) + if p == nil { + return []Event{t.private(name, "You aren't playing roulette.")} + } + if t.phase == phasePlaying { + return []Event{t.private(name, "No more bets! The wheel is spinning.")} + } + if len(p.lastBets) == 0 { + return []Event{t.private(name, "Bet on a number, color, or combination. Use table to see all available bets.")} + } + var events []Event + for _, b := range p.lastBets { + ev, ok := t.betLocked(name, b.spot, b.amount, w) + events = append(events, ev...) + if !ok { + break + } + } + return events +} func (t *rouletteTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() @@ -830,7 +926,11 @@ func (t *rouletteTable) markDisconnected(name string) []Event { if p.Wallet != nil { var err error if p.HasBet && t.phase == phasePlaying { - err = p.Wallet.PayCredits(0, p.Wager.ID) + var ids []string + for _, b := range p.bets { + ids = append(ids, b.wager.ID) + } + err = p.Wallet.PayCredits(0, ids...) } else { _, _, err = p.Wallet.RefundOutstanding() } diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go index a0e04d4..3425f94 100644 --- a/internal/casino/roulette_test.go +++ b/internal/casino/roulette_test.go @@ -281,9 +281,15 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { w := &testWallet{chips: 100} table.bet("a", wheel[index], 10, w) table.bet("a", "red", 10, w) - if w.chips != 90 || table.phase != phaseBetting { + if w.chips != 80 || table.phase != phaseBetting { t.Fatal("duplicate or early spin") } + // Both wagers are on the table: the straight-up wins its 36x total + // return, and the red wager adds 20 when the winning pocket is red. + wantPaid := 360 + if n, err := strconv.Atoi(wheel[index]); err == nil && rouletteRed(n) { + wantPaid += 20 + } table.tick() if table.phase != phaseBetting { t.Fatal("betting ended early") @@ -304,7 +310,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.action("a", "leave_after") events = table.tick() - if w.paid != 360 || len(w.open) != 0 || table.hasParticipant("a") { + if w.paid != wantPaid || len(w.open) != 0 || table.hasParticipant("a") { t.Fatalf("bad settlement: %+v", w) } s := events[0].Snapshot.(*RouletteSnapshot) @@ -318,7 +324,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) { } table.tick() table.refundAll() - if w.paid != 360 { + if w.paid != wantPaid { t.Fatal("double paid") } } diff --git a/internal/casino/table.go b/internal/casino/table.go index 4ab4e3a..f0d5b3d 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -108,10 +108,13 @@ func (t *Table) action(name, action string) []Event { return t.game.action(name, func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) } // RebetAll repeats every remembered wager for a game that keeps more than one -// (currently baccarat's side bets plus main bet). It returns nil for games -// whose bare "bet" is a single-wager repeat. +// (a spread of roulette, Big 6, Crazy Time or baccarat wagers). It returns nil +// for games whose bare "bet" is a single-wager repeat, so the caller falls +// back to the singular rebet path. func (t *Table) RebetAll(name string, wallet Wallet) []Event { - if b, ok := t.game.(*baccaratTable); ok { + if b, ok := t.game.(interface { + rebetAll(string, Wallet) []Event + }); ok { return b.rebetAll(name, wallet) } return nil diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index 3a87c94..ea6aef1 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -20,8 +20,8 @@ func (t *rouletteTable) hints(name string) []MenuCommand { commands := []MenuCommand{} if t.phase != phasePlaying && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) - if p.lastBet > 0 { - commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + if len(p.lastBets) > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } } commands = append(commands, MenuCommand{Command: "stop", Desc: "Leave when your wager settles"}) diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index d23c8a2..5613a81 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -14,6 +14,13 @@ type WagerRow struct { Win string } +// rebetWager is one remembered wager a multi-bet game can replay on a bare +// bet/rebet. +type rebetWager struct { + spot string + amount int +} + const ( hintCommandRules = "rules" hintCommandList = "list" @@ -36,6 +43,18 @@ func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand return commands } +// stripMenuBalance clears the wallet display from a bet response's menus, so +// placing a wager never prints the balance. Explicit status/look and later +// settlements keep their balance. +func stripMenuBalance(events []Event) []Event { + for i := range events { + if events[i].Menu != nil { + events[i].Menu.ShowBalance = false + } + } + return events +} + // RulesText is the shared rules view for table commands and room fixtures. func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } diff --git a/internal/casino/types.go b/internal/casino/types.go index 411b58d..33b5386 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -25,6 +25,7 @@ const ( GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" GameBigSix GameName = "big6" + GameCrazyTime GameName = "crazy_time" GameCraps GameName = "craps" ) diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index 98bfda1..10ef03a 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -343,7 +343,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { } p.remaining = append([]card(nil), deck.cards...) p.active, p.drawn, p.held, p.result = true, false, [5]bool{}, "" - return t.statusLocked(name, false) + return stripMenuBalance(t.statusLocked(name, false)) } func (t *videoPokerTable) rebet(name string) (string, int) { return "", 0 // Coin loading is session state handled by the cabinet's own commands. 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) +} |
