package casino import ( "fmt" "math" "math/rand" ) type SlotSymbol struct { ID string `yaml:"id"` Weight int `yaml:"weight"` Payout map[int]float64 `yaml:"payout"` } type SlotProfile struct { Symbols []SlotSymbol `yaml:"symbols"` Rows int `yaml:"rows"` Reels int `yaml:"reels"` FreeSpinTrigger string `yaml:"free_spin_trigger"` FreeSpinCount int `yaml:"free_spin_count"` Ways int `yaml:"ways"` TargetRTP float64 `yaml:"payout"` } type SlotSpin struct { Grid [slotGridRows][slotGridReels]string Payout float64 ScatterCount int Wins []SlotWin } // The runtime slot evaluator is pinned to this grid: SlotSpin/SlotSnapshot // hold it, generateSlotSpinWithProfile fills it, and matchingLength and // stoppedScatters evaluate it. CalculateRTP also assumes this geometry (fixed // 3..5 symbol runs and a five-reel no-match term), so SlotProfile.Validate // rejects any other geometry (see config_validate.go) to keep the RTP gate // honest. const ( slotGridRows = 3 slotGridReels = 5 ) type SlotWin struct { Symbol string Length int Ways int Multiplier float64 Payout float64 } func DefaultHuffAndPuffProfile() SlotProfile { return SlotProfile{ Rows: 3, Reels: 5, Ways: 243, FreeSpinTrigger: "scatter", FreeSpinCount: 8, Symbols: []SlotSymbol{ {ID: "straw", Weight: 22, Payout: map[int]float64{3: .5, 4: 1, 5: 2}}, {ID: "stick", Weight: 12, Payout: map[int]float64{3: 1, 4: 2, 5: 5}}, {ID: "brick", Weight: 7, Payout: map[int]float64{3: 2, 4: 5, 5: 15}}, {ID: "hat", Weight: 4, Payout: map[int]float64{3: 5, 4: 15, 5: 50}}, {ID: "wolf", Weight: 2, Payout: map[int]float64{3: 10, 4: 50, 5: 250}}, {ID: "scatter", Weight: 3}, }, } } func (p SlotProfile) Normalize() SlotProfile { if p.Rows <= 0 { p.Rows = 3 } if p.Reels <= 0 { p.Reels = 5 } if p.Ways <= 0 { p.Ways = 243 } if p.FreeSpinCount <= 0 { p.FreeSpinCount = 8 } if len(p.Symbols) == 0 { return DefaultHuffAndPuffProfile() } return p } func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin { // SlotProfile.Normalize supplies the defaults; Validate rejects a // geometry this grid cannot hold, so the bounds are exactly the grid. p := profile.Normalize() spin := &SlotSpin{} for reel := 0; reel < p.Reels && reel < slotGridReels; reel++ { for row := 0; row < p.Rows && row < slotGridRows; row++ { symbol := weightedSymbol(rng, p.Symbols) spin.Grid[row][reel] = symbol if symbol == p.FreeSpinTrigger { spin.ScatterCount++ } } } spin.Payout, spin.Wins = evaluateSlot(spin.Grid, bet, p) return spin } func weightedSymbol(rng *rand.Rand, symbols []SlotSymbol) string { total := 0 for _, symbol := range symbols { if symbol.Weight > 0 { total += symbol.Weight } } if total <= 0 { return "" } n := rng.Intn(total) for _, symbol := range symbols { if symbol.Weight <= 0 { continue } if n < symbol.Weight { return symbol.ID } n -= symbol.Weight } return symbols[len(symbols)-1].ID } func evaluateSlot(grid [slotGridRows][slotGridReels]string, bet int, profile SlotProfile) (float64, []SlotWin) { p := profile.Normalize() if bet <= 0 { return 0, nil } total := 0.0 var wins []SlotWin for _, symbol := range p.Symbols { length, ways := matchingLength(grid, symbol.ID) if length >= 3 && symbol.Payout[length] > 0 { payout := float64(bet) * symbol.Payout[length] * float64(ways) / float64(p.Ways) total += payout wins = append(wins, SlotWin{Symbol: symbol.ID, Length: length, Ways: ways, Multiplier: symbol.Payout[length], Payout: payout}) } } return total, wins } func matchingLength(grid [slotGridRows][slotGridReels]string, symbol string) (int, int) { ways := 1 length := 0 for reel := 0; reel < slotGridReels; reel++ { count := 0 for row := 0; row < slotGridRows; row++ { if grid[row][reel] == symbol { count++ } } if count == 0 { break } ways *= count length = reel + 1 } if length < 3 { return length, 0 } return length, ways } // CalculateRTP returns the exact mathematical RTP for the independent-cell // model used by the baseline machine, before integer-credit rounding. func CalculateRTP(profile SlotProfile) float64 { p := profile.Normalize() totalWeight := 0 for _, symbol := range p.Symbols { if symbol.Weight > 0 { totalWeight += symbol.Weight } } if totalWeight == 0 { return 0 } baseRTP := 0.0 scatterProbability := 0.0 for _, symbol := range p.Symbols { probability := float64(symbol.Weight) / float64(totalWeight) if symbol.ID == p.FreeSpinTrigger { scatterProbability = probability } for length := 3; length <= 5; length++ { ways := math.Pow(float64(p.Rows), float64(length)) nextReelNoMatch := 1.0 if length < 5 { nextReelNoMatch = math.Pow(1-probability, float64(p.Rows)) } baseRTP += symbol.Payout[length] * ways * math.Pow(probability, float64(length)) * nextReelNoMatch / float64(p.Ways) } } // The baseline feature awards a fixed number of automatic spins and does // not retrigger during those spins. Its expected value is therefore the // trigger probability multiplied by the free-spin count and base RTP. triggerProbability := 0.0 for scatters := 3; scatters <= p.Rows*p.Reels; scatters++ { triggerProbability += binomial(p.Rows*p.Reels, scatters) * math.Pow(scatterProbability, float64(scatters)) * math.Pow(1-scatterProbability, float64(p.Rows*p.Reels-scatters)) } return baseRTP * (1 + triggerProbability*float64(p.FreeSpinCount)) } func binomial(n, k int) float64 { if k < 0 || k > n { return 0 } result := 1.0 for i := 1; i <= k; i++ { result *= float64(n-k+i) / float64(i) } return result } func ValidateRTP(profile SlotProfile, tolerance float64) error { // A profile the runtime cannot play must never be certified: its RTP // would describe a different game than the one actually paid. if err := profile.Validate(); err != nil { return err } if profile.TargetRTP <= 0 { return nil } actual := CalculateRTP(profile) if math.Abs(actual-profile.TargetRTP) > tolerance { return fmt.Errorf("slot profile RTP %.6f does not match target %.6f", actual, profile.TargetRTP) } return nil } // ScaleToRTP adjusts the configured paytable, without changing reel weights or // feature probabilities, so the profile's exact mathematical RTP reaches the // requested target. Runtime payouts retain fractional credits internally until // they can be paid as whole credits. func ScaleToRTP(profile SlotProfile, target float64) SlotProfile { profile = profile.Normalize() actual := CalculateRTP(profile) if actual <= 0 || target <= 0 { return profile } scale := target / actual // SlotProfile is a value API, but its slice and paytable maps are shared // references. Clone both so tuning one machine cannot retune another. profile.Symbols = append([]SlotSymbol(nil), profile.Symbols...) for i := range profile.Symbols { payouts := make(map[int]float64, len(profile.Symbols[i].Payout)) for length, payout := range profile.Symbols[i].Payout { payouts[length] = payout * scale } profile.Symbols[i].Payout = payouts } profile.TargetRTP = target return profile }