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 [3][5]string Payout float64 ScatterCount int Wins []SlotWin } 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 (p SlotProfile) symbol(id string) *SlotSymbol { for i := range p.Symbols { if p.Symbols[i].ID == id { return &p.Symbols[i] } } return nil } func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin { p := profile.Normalize() spin := &SlotSpin{} for reel := 0; reel < p.Reels && reel < 5; reel++ { for row := 0; row < p.Rows && row < 3; 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 [3][5]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 [3][5]string, symbol string) (int, int) { ways := 1 length := 0 for reel := 0; reel < 5; reel++ { count := 0 for row := 0; row < 3; 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 } if symbol.ID == p.FreeSpinTrigger { continue } 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 { 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 for i := range profile.Symbols { for length, payout := range profile.Symbols[i].Payout { profile.Symbols[i].Payout[length] = payout * scale } } profile.TargetRTP = target return profile }