diff options
Diffstat (limited to 'internal/casino/slots.go')
| -rw-r--r-- | internal/casino/slots.go | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/internal/casino/slots.go b/internal/casino/slots.go new file mode 100644 index 0000000..9d53bb1 --- /dev/null +++ b/internal/casino/slots.go @@ -0,0 +1,278 @@ +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 +} + +// slotResult preserves the small rules-engine hook used by the unfinished +// generic table implementation while slots move onto SlotMachine. +type slotSpin struct { + Display string + Payout int +} + +func slotResult(rng *rand.Rand, bet int) slotSpin { + spin := generateSlotSpin(rng, bet) + return slotSpin{Display: spin.render(5), Payout: int(math.Floor(spin.Payout + 0.5))} +} + +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 generateSlotSpin(rng *rand.Rand, bet int) *SlotSpin { + return generateSlotSpinWithProfile(rng, bet, DefaultHuffAndPuffProfile()) +} + +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) / 243.0 + 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 +} + +func (s *SlotSpin) render(stoppedReels int) string { + var out string + out += "+---------+---------+---------+---------+---------+\n" + for row := 0; row < 3; row++ { + out += "|" + for reel := 0; reel < 5; reel++ { + value := "..." + if reel < stoppedReels { + value = s.Grid[row][reel] + } + out += fmt.Sprintf(" %-7s |", value) + } + out += "\n" + } + out += "+---------+---------+---------+---------+---------+" + return out +} + +// 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 +} |
