aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/slots.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/slots.go')
-rw-r--r--internal/casino/slots.go278
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
+}