package casino import ( "fmt" "math/rand" "strings" "sync" ) // Coin Flip is Crazy Time's always-positive bonus reimagined as a standalone // wager. A fair coin picks one of two spinning faces: the red face carries a // negative multiplier (0 to -100) and the blue face a positive one (0 to // +100). The multiplier is a net swing on the player's stake, so a -100 red // busts the whole wager and a +100 blue doubles it. Because the swing can // reach 100x the stake, the house requires the player to hold 100x the stake // before the bet is accepted and reserves exactly that much up front; the // round then pays back (100 + swing) x stake, which is never negative. // // The coin is fair 50/50. The 2% house edge comes entirely from the two // multiplier distributions. Red is uniform over 0..100 (mean magnitude 50). // Blue keeps the full 0..100 range, including the 100x, but drops a middle // band (63..77 and 90) so its mean magnitude is exactly 46. Averaged over the // fair coin that is a net swing of -2 per stake, i.e. exactly 98% RTP. const ( coinFlipReserve = 100 // stake multiplier the wallet reserves up front coinFlipRedMax = 100 // red magnitude ceiling coinFlipBlueMax = 100 // blue magnitude ceiling ) // coinFlipBlueValues is the blue face's value set: every integer 0..100 except // a removed middle band. Removing 63..77 (15 values) and 90 (1 value) drops 16 // values summing to 1140, leaving 85 values summing to 3910: a mean of exactly // 46 while 100x stays reachable. var coinFlipBlueValues = func() []int { removed := make(map[int]bool, 16) for v := 63; v <= 77; v++ { removed[v] = true } removed[90] = true values := make([]int, 0, 85) for v := 0; v <= coinFlipBlueMax; v++ { if !removed[v] { values = append(values, v) } } return values }() // Coin Flip lifecycle phases. The table itself is only idle or playing; the // snapshot's Phase carries the finer red-spin / blue-spin / results / flip // stage the renderer needs. const ( coinFlipIdle = "idle" coinFlipPlaying = "playing" ) const ( coinFlipRed = "red" coinFlipBlue = "blue" ) // Exported phase and face names for the presentation layer. const ( CoinFlipPhaseIdle = "idle" CoinFlipPhaseRedSpin = "redspin" CoinFlipPhaseBlueSpin = "bluespin" CoinFlipPhaseResults = "results" CoinFlipPhaseFlipping = "flipping" CoinFlipPhaseResult = "result" CoinFlipSideRed = coinFlipRed CoinFlipSideBlue = coinFlipBlue ) // Wheel geometry, shared by the engine (which picks the landing offset) and // the renderer (which slices the same strip). A cell is a centred label plus // one separator; the strip scrolls by two characters a frame, so the pointer // can sit anywhere inside the landed label, not just its centre. const ( CoinFlipCellWidth = 6 CoinFlipPitch = CoinFlipCellWidth + 1 CoinFlipViewCells = 5 CoinFlipViewWidth = CoinFlipViewCells * CoinFlipPitch CoinFlipPointerCol = CoinFlipViewWidth / 2 ) // coinFlipSpinStep is how many characters the strip moves each frame. const coinFlipSpinStep = 2 // coinFlipExpectedSwing is the mean net swing of the two distributions over // the fair coin. It is exact: (blueMean - redMean) / 2. func coinFlipExpectedSwing() float64 { redSum := 0 for v := 0; v <= coinFlipRedMax; v++ { redSum += v } redMean := float64(redSum) / float64(coinFlipRedMax+1) blueSum := 0 for _, v := range coinFlipBlueValues { blueSum += v } blueMean := float64(blueSum) / float64(len(coinFlipBlueValues)) return 0.5 * (blueMean - redMean) } // coinFlipRTP is the return per reserved credit: (reserve + swing) / reserve. func coinFlipRTP() float64 { return (float64(coinFlipReserve) + coinFlipExpectedSwing()) / float64(coinFlipReserve) } func coinFlipHouseEdge(TableConfig, string) HouseEdge { return edge(1 - coinFlipRTP()) } func validateCoinFlip(c TableConfig) error { if c.MaxPlayers > 1 { return fmt.Errorf("coin flip has one seat") } // The reserve is coinFlipReserve times the stated stake; reject a stake // whose reserve cannot be represented. if c.MaxBet > MaxCredits/coinFlipReserve { return fmt.Errorf("coin flip max_bet must not exceed %d", MaxCredits/coinFlipReserve) } return nil } func coinFlipRules(TableConfig) string { return fmt.Sprintf("Coin Flip: a fair 50/50 coin with two spinning faces.\n"+ "The RED face lands a negative multiplier from 0 to -%d; the BLUE face lands a positive multiplier from 0 to +%d.\n"+ "The multiplier is a NET swing on your stake: red -50 loses half your stake, blue +50 wins half your stake, red -%d loses it all, blue +%d doubles it.\n"+ "Because the swing can reach %dx your stake, you must hold %dx your stake to bet and the house reserves exactly that. Your payout is (100 + swing) x stake and is never negative.\n"+ "RTP is exactly %.0f%%: the coin is fair, and the blue face drops a middle band so its mean is +46 against red's -50.\n"+ "bet N places a stake of N (reserving %dN). You cannot leave mid-flip. Shutdown refunds an unsettled flip; a failed payout keeps its stake pending for recovery.", coinFlipRedMax, coinFlipBlueMax, coinFlipRedMax, coinFlipBlueMax, coinFlipRedMax, coinFlipReserve, 100*coinFlipRTP(), coinFlipReserve) } type CoinFlipSnapshot struct { Phase string // SpinSide, SpinValues and SpinOffset describe the scrolling multiplier // strip: SpinValues are the wheel cells, SpinOffset the character offset // of the visible window, and Set marks the landed frame that prints SET. SpinSide string SpinValues []int SpinOffset int Set bool // Red and Blue are the two faces drawn for this flip. Red int Blue int // Side, Multiplier, Result and FlipAlt describe the coin itself. // Multiplier is the signed net swing on the landed face; Result marks the // settled coin; FlipAlt swaps the whoosh/arrow marks on alternating frames. Side string Multiplier int Result bool FlipAlt bool // Stake is the stated stake, Wager the reserved amount debited from the // wallet (Stake x 100). Stake int Wager int } func (*CoinFlipSnapshot) snapshot() {} // coinFlipFrame is one printed coin in the flip animation. alt flips the // whoosh/arrow marks from one side to the other on alternating frames. type coinFlipFrame struct { side string multiplier int result bool alt bool } // coinFlipPrint is one already-planned animation frame: either a structured // snapshot (a spin, the results panel, or a coin) or a plain message (a blank // spacer line). type coinFlipPrint struct { delay int msg string snap *CoinFlipSnapshot last bool } type coinFlipTable struct { *tableBase mu sync.Mutex rng *rand.Rand name string betSize int wager int wallet Wallet stake Wager phase string pending bool red int blue int result string swing int prints []coinFlipPrint print int wait int } func newCoinFlipTable(room int, c TableConfig, rng *rand.Rand) tableGame { return &coinFlipTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: coinFlipIdle} } func (t *coinFlipTable) base() *tableBase { return t.tableBase } func (t *coinFlipTable) idle() bool { t.mu.Lock() defer t.mu.Unlock() return t.name == "" } func (t *coinFlipTable) hasParticipant(n string) bool { t.mu.Lock() defer t.mu.Unlock() return t.name != "" && t.name == n } func (t *coinFlipTable) busy(n string) bool { t.mu.Lock() defer t.mu.Unlock() return t.name == n && t.phase == coinFlipPlaying } func (t *coinFlipTable) overview() string { return "Coin Flip / red vs blue swing" } func (t *coinFlipTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() if t.name == "" { return nil } return []string{t.name} } func (t *coinFlipTable) state() string { t.mu.Lock() defer t.mu.Unlock() if t.pending { return "payout pending" } if t.phase == coinFlipPlaying { return "spinning" } return "waiting" } func (t *coinFlipTable) wagers(string) []WagerRow { return nil } // stakeMultiple is the optional interface Table uses to size "bet max" and // the affordability preview. Coin Flip reserves coinFlipReserve credits per // stated stake. func (t *coinFlipTable) stakeMultiple() int { return coinFlipReserve } func (t *coinFlipTable) hintsLocked() []MenuCommand { if t.phase == coinFlipPlaying { return nil } if t.pending { return []MenuCommand{ {Command: "settle", Desc: "Retry the pending payout"}, {Command: "rules", Desc: "Coin Flip rules"}, } } return []MenuCommand{ {Command: "bet N", Desc: fmt.Sprintf("Stake N; the house reserves %dN", coinFlipReserve)}, {Command: "rules", Desc: "Coin Flip rules"}, {Command: "stop", Desc: "Leave Coin Flip"}, } } func (t *coinFlipTable) hints(n string) []MenuCommand { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } return t.hintsLocked() } func (t *coinFlipTable) statusLocked(n string) []Event { e := t.private(n, "") e.Color = "casino_menu" // The sitting-down menu carries no coin: only an in-flight spin or flip // has a graphic worth printing. if t.phase == coinFlipPlaying && t.print >= 0 && t.print < len(t.prints) { e.Snapshot = t.prints[t.print].snap } if n == t.name { e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} } return []Event{e} } func (t *coinFlipTable) status(n string) []Event { t.mu.Lock() defer t.mu.Unlock() return t.statusLocked(n) } func (t *coinFlipTable) join(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if t.name != "" { return []Event{t.private(n, "Coin Flip is occupied.")} } t.clearLocked() t.name = n e := t.private(n, "") e.Type, e.Player = EventJoined, n return append([]Event{e}, t.statusLocked(n)...) } func (t *coinFlipTable) clearLocked() { t.name, t.betSize, t.wager, t.phase, t.pending = "", 0, 0, coinFlipIdle, false t.wallet, t.stake = nil, Wager{} t.red, t.blue, t.result, t.swing = 0, 0, "", 0 t.prints, t.print, t.wait = nil, 0, 0 } func (t *coinFlipTable) leave(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.phase == coinFlipPlaying { return []Event{t.private(n, "The coin is in the air. Wait for it to land.")} } if t.pending { return []Event{t.private(n, "A payout is still pending. Use settle before leaving.")} } t.clearLocked() e := t.playerAction(n, n+" leaves Coin Flip.", "You leave Coin Flip.") e.Type, e.Player = EventLeft, n return []Event{e} } func (t *coinFlipTable) rebet(n string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if n == t.name && t.phase != coinFlipPlaying && !t.pending { return "", t.betSize } return "", 0 } // faceValueLocked draws one face's value: red is negative 0..coinFlipRedMax, // blue is drawn from coinFlipBlueValues (0..coinFlipBlueMax with a middle band // removed). func (t *coinFlipTable) faceValueLocked(side string) int { if side == coinFlipRed { return -t.rng.Intn(coinFlipRedMax + 1) } return coinFlipBlueValues[t.rng.Intn(len(coinFlipBlueValues))] } // planSpinLocked builds one face's scrolling wheel. It draws the frame count // (three guaranteed, then a 60% chance each further tick), picks the landing // cell so the fixed pointer sits inside the result's label, and pads the wheel // with random neighbours. If the pointer would land on a separator it ticks one // more frame, as required. func (t *coinFlipTable) planSpinLocked(side string, result int) []*CoinFlipSnapshot { frames := 3 for frames < 12 && t.rng.Intn(100) < 75 { frames++ } finalOffset := coinFlipSpinStep * (frames - 1) cellIdx := (finalOffset + CoinFlipPointerCol) / CoinFlipPitch for (finalOffset+CoinFlipPointerCol)%CoinFlipPitch == 0 { // The pointer is on a separator; one more frame moves it two // characters into the label. frames++ finalOffset += coinFlipSpinStep cellIdx = (finalOffset + CoinFlipPointerCol) / CoinFlipPitch } values := make([]int, cellIdx+CoinFlipViewCells+2) for i := range values { values[i] = t.faceValueLocked(side) } values[cellIdx] = result phase := CoinFlipPhaseRedSpin if side == coinFlipBlue { phase = CoinFlipPhaseBlueSpin } snaps := make([]*CoinFlipSnapshot, 0, frames) for f := 0; f < frames; f++ { snaps = append(snaps, &CoinFlipSnapshot{ Phase: phase, SpinSide: side, SpinValues: values, SpinOffset: coinFlipSpinStep * f, Set: f == frames-1, Red: t.red, Blue: t.blue, Stake: t.betSize, Wager: t.wager, }) } return snaps } // planCoinFramesLocked builds the coin flip animation. It opens with three // printed coins (red, blue, red) and then keeps flipping with a 50% chance // each step. The coin never settles on the colour it is already showing: if // the last flip already matches the landed face it settles in place, otherwise // one final flip to the landed colour is appended. func (t *coinFlipTable) planCoinFramesLocked() []coinFlipFrame { frames := []coinFlipFrame{ {side: coinFlipRed, multiplier: t.red}, {side: coinFlipBlue, multiplier: t.blue, alt: true}, {side: coinFlipRed, multiplier: t.red}, } for len(frames) < 16 && t.rng.Intn(2) != 0 { last := frames[len(frames)-1].side next := coinFlipBlue if last == coinFlipBlue { next = coinFlipRed } multiplier := t.red if next == coinFlipBlue { multiplier = t.blue } frames = append(frames, coinFlipFrame{side: next, multiplier: multiplier, alt: len(frames)%2 == 0}) } if frames[len(frames)-1].side == t.result { // Already showing the landed colour: settle this very frame, so the // result never repeats the previous colour. frames[len(frames)-1].result = true frames[len(frames)-1].multiplier = t.swing return frames } frames = append(frames, coinFlipFrame{side: t.result, multiplier: t.swing, result: true, alt: len(frames)%2 == 0}) return frames } func (t *coinFlipTable) coinSnapLocked(f coinFlipFrame) *CoinFlipSnapshot { phase := CoinFlipPhaseFlipping if f.result { phase = CoinFlipPhaseResult } return &CoinFlipSnapshot{ Phase: phase, Red: t.red, Blue: t.blue, Side: f.side, Multiplier: f.multiplier, Result: f.result, FlipAlt: f.alt, Stake: t.betSize, Wager: t.wager, } } // planPrintsLocked lays out the whole round: the red wheel, a spacer, the blue // wheel, the landed multipliers, then the coin flip. The final coin is marked // last so the tick that prints it also settles. func (t *coinFlipTable) planPrintsLocked() { redSpin := t.planSpinLocked(coinFlipRed, t.red) blueSpin := t.planSpinLocked(coinFlipBlue, t.blue) prints := make([]coinFlipPrint, 0, len(redSpin)+len(blueSpin)+8) for _, snap := range redSpin { prints = append(prints, coinFlipPrint{delay: 1, snap: snap}) } prints = append(prints, coinFlipPrint{delay: 1, msg: " "}) for _, snap := range blueSpin { prints = append(prints, coinFlipPrint{delay: 1, snap: snap}) } prints = append(prints, coinFlipPrint{ delay: 2, snap: &CoinFlipSnapshot{Phase: CoinFlipPhaseResults, Red: t.red, Blue: t.blue, Stake: t.betSize, Wager: t.wager}, }) frames := t.planCoinFramesLocked() for i, f := range frames { prints = append(prints, coinFlipPrint{delay: 2, snap: t.coinSnapLocked(f), last: i == len(frames)-1}) } t.prints = prints } func (t *coinFlipTable) printEventLocked(p coinFlipPrint) Event { if p.snap != nil { e := t.private(t.name, "") e.Color = "casino_action" e.Snapshot = p.snap return e } return t.private(t.name, p.msg) } func (t *coinFlipTable) 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 == coinFlipPlaying { return []Event{t.private(n, "The coin is already in the air.")} } if t.pending { return []Event{t.private(n, "A payout is still pending. Use settle first.")} } if spot != "" && spot != "main" { return []Event{t.private(n, "Use bet N; the coin is the only wager.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet { return []Event{t.private(n, "Bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} } if amount > MaxCredits/coinFlipReserve { return []Event{t.private(n, "That stake is too large.")} } if w == nil { return []Event{t.private(n, "No wallet is available.")} } reserved := amount * coinFlipReserve stake, err := w.Wager(reserved) if err != nil { // A wager this large is a normal rejected bet: the player did not hold // the 100x reserve the swing can consume. if err == ErrInsufficientFunds { return []Event{t.private(n, fmt.Sprintf("Coin Flip needs %dx your stake (%d) on hand. You don't have enough chips and credits.", coinFlipReserve, reserved))} } return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))} } t.wallet, t.stake, t.betSize, t.wager = w, stake, amount, reserved t.red = t.faceValueLocked(coinFlipRed) t.blue = t.faceValueLocked(coinFlipBlue) t.result, t.swing = coinFlipRed, t.red if t.rng.Intn(2) == 0 { t.result, t.swing = coinFlipBlue, t.blue } t.planPrintsLocked() t.phase = coinFlipPlaying // The first frame is deliberately held back one tick so the stake line and // the opening spin do not land in the same output burst. t.print = -1 t.wait = 1 open := t.private(n, fmt.Sprintf("Stake %d. The house reserves %d.", amount, reserved)) open.Color = "casino_menu" return []Event{open} } func (t *coinFlipTable) action(n, action string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.phase == coinFlipPlaying { return []Event{t.private(n, "The coin is in the air. Wait for it to land.")} } f := strings.Fields(strings.ToLower(action)) if len(f) == 0 { return t.statusLocked(n) } if f[0] == "settle" { // A failed payout leaves the round un-settled; the outcome is already // known, so settle re-pays the same landed face. Only a persistence // failure can reach this state. if t.pending { return t.settleLocked() } } return []Event{t.private(n, "Use bet N to flip the coin.")} } func (t *coinFlipTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if t.phase != coinFlipPlaying { return nil } t.wait-- if t.wait > 0 { return nil } t.print++ if t.print >= len(t.prints) { return t.settleLocked() } p := t.prints[t.print] e := t.printEventLocked(p) if p.last { // The settled coin was the previous frame; settle without reprinting it. return append([]Event{e}, t.settleLocked()...) } t.wait = p.delay return []Event{e} } // settleLocked pays the landed face. The outcome is fixed before the flip, so // a failed bank save leaves the same face and stake for an idempotent retry. // It never reprints the coin: the last flip frame already carried the settled // fill. func (t *coinFlipTable) settleLocked() []Event { side, swing := t.result, t.swing payout := (coinFlipReserve + swing) * t.betSize if err := t.wallet.PayCredits(payout, t.stake.ID); err != nil { t.phase = coinFlipIdle t.pending = true return []Event{t.private(t.name, payoutFailedMessage()+" The same face is locked; settle retries it.")} } t.phase = coinFlipIdle t.pending = false t.stake = Wager{} label := fmt.Sprintf("Blue +%d", swing) if side == coinFlipRed { label = fmt.Sprintf("Red %d", swing) } receipt := t.private(t.name, "") receipt.Settlement = []SettlementLine{{ Label: label, Odds: "Varies", Stake: t.wager, Returned: payout, Result: returnResult(t.wager, payout), }} receipt.Color = "casino_payout" if swing >= 50 { receipt.Color = "casino_big_win" } // A short public line names the landed face; the receipt stays private. A // leading blank line separates it from the final coin frame. announce := t.event(fmt.Sprintf("\n%s flips %s.", t.name, side), true) return []Event{announce, receipt, t.betMenuEvent(t.name, t.hintsLocked())} } func (t *coinFlipTable) markDisconnected(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } var events []Event if (t.phase == coinFlipPlaying || t.pending) && t.wallet != nil { // The coin was already flipped and the player has seen the faces; // match the exposed-game convention and settle the landed face. events = t.settleLocked() } t.clearLocked() e := t.event(n+" leaves Coin Flip.", true) e.Type, e.Player = EventLeft, n return append(events, e) } func (t *coinFlipTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() var events []Event switch { case t.pending && t.wallet != nil: // A known face is waiting on a failed payout; retry it so the shutdown // pays the same result rather than refunding a decided round. events = t.settleLocked() case t.phase == coinFlipPlaying && t.wallet != nil: chips, credits, err := t.wallet.RefundOutstanding() message := fmt.Sprintf("Coin Flip closes; %d credits/chips refunded.", chips+credits) if err != nil { message = "Coin Flip refund could not be persisted; your stake remains pending for recovery." } events = []Event{t.private(t.name, message)} } t.clearLocked() return events }