diff options
Diffstat (limited to 'internal/casino/coin_flip.go')
| -rw-r--r-- | internal/casino/coin_flip.go | 652 |
1 files changed, 652 insertions, 0 deletions
diff --git a/internal/casino/coin_flip.go b/internal/casino/coin_flip.go new file mode 100644 index 0000000..29247fe --- /dev/null +++ b/internal/casino/coin_flip.go @@ -0,0 +1,652 @@ +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 +} |
