Skip to main content

rsnaker/game_logic/
random_helper.rs

1//! # Random Game Options Helper
2//!
3//! This module provides weighted ("ponderated alea") random generation for [`GameOptions`],
4//! ensuring that randomized parameters span broad, exciting ranges while remaining
5//! balanced and playable.
6
7use crate::controls::speed::Speed;
8use crate::game_logic::game_options::{DISPLAYABLE_EMOJI, GameOptions};
9use crate::game_logic::logger::log_configuration::LogLevel;
10use rand::{RngExt, rng};
11use std::ops::RangeInclusive;
12
13/// Picks a random value from a slice of weighted ranges.
14///
15/// Each tuple contains `(weight, range)`. A higher weight increases the probability
16/// of choosing a value from that range.
17#[must_use]
18pub fn weighted_range(choices: &[(u32, RangeInclusive<u16>)]) -> u16 {
19    let total_weight: u32 = choices.iter().map(|(w, _)| *w).sum();
20    let roll: u32 = rng().random_range(1..=total_weight);
21    let mut cumulative = 0;
22    for (weight, range) in choices {
23        cumulative += *weight;
24        if roll <= cumulative {
25            return rng().random_range(range.clone());
26        }
27    }
28    *choices[0].1.start()
29}
30
31/// Generates a new [`GameOptions`] instance with randomized parameters using weighted distributions.
32///
33/// The resulting options span larger parameter ranges while favoring balanced, playable values.
34/// The `random` field on the returned struct is set to `true`.
35#[must_use]
36pub fn generate_random_game_options() -> GameOptions {
37    let speed = match rng().random_range(1..=100) {
38        1..=20 => Speed::Slow,
39        21..=60 => Speed::Normal,
40        61..=90 => Speed::Fast,
41        _ => Speed::Crazy,
42    };
43
44    let head_idx = rng().random_range(0..DISPLAYABLE_EMOJI.len());
45    let mut body_idx = rng().random_range(0..DISPLAYABLE_EMOJI.len());
46    while body_idx == head_idx {
47        body_idx = rng().random_range(0..DISPLAYABLE_EMOJI.len());
48    }
49    let head_symbol = DISPLAYABLE_EMOJI[head_idx].to_string();
50    let body_symbol = DISPLAYABLE_EMOJI[body_idx].to_string();
51
52    // Weighted distributions spanning broader ranges across the parameter spectrum
53    let snake_length = weighted_range(&[
54        (45, 3..=12),   // Classic / short
55        (30, 13..=30),  // Medium
56        (15, 31..=75),  // Long
57        (7, 76..=150),  // Very long
58        (3, 151..=300), // Epic snake
59    ]);
60
61    let life = weighted_range(&[
62        (40, 2..=5),   // Standard
63        (30, 6..=12),  // Moderate
64        (15, 1..=1),   // Hardcore 1-life
65        (10, 13..=25), // Generous
66        (5, 26..=50),  // Extra lives
67    ]);
68
69    let nb_of_fruits = weighted_range(&[
70        (40, 3..=8),   // Standard
71        (30, 9..=20),  // Bountiful
72        (15, 1..=2),   // Scarce
73        (10, 21..=50), // Fruit frenzy
74        (5, 51..=100), // Mega harvest
75    ]);
76
77    let fruit_duration_seconds = weighted_range(&[
78        (50, 15..=30), // Balanced
79        (25, 5..=14),  // Fast expiry
80        (20, 31..=50), // Relaxed
81        (5, 51..=60),  // Max duration
82    ]);
83
84    let snake_growth_factor = weighted_range(&[
85        (60, 1..=1), // Classic fruit effects
86        (30, 2..=3), // Noticeably faster growth
87        (10, 4..=6), // Chaotic games
88    ]);
89
90    let fruit_timer = rng().random_bool(0.8);
91    let negative_size_fruits = rng().random_bool(0.5);
92    let caps_fps = true;
93
94    let mut options = GameOptions {
95        speed,
96        head_symbol,
97        body_symbol,
98        snake_length,
99        life,
100        nb_of_fruits,
101        no_fruit_timer: !fruit_timer,
102        fruit_timer,
103        fruit_duration_seconds,
104        snake_growth_factor,
105        no_negative_size_fruits: !negative_size_fruits,
106        negative_size_fruits,
107        log_level: LogLevel::Off,
108        no_caps_fps: !caps_fps,
109        caps_fps,
110        load: None,
111        random: true,
112    };
113    options.validate();
114    options
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use unicode_segmentation::UnicodeSegmentation;
121
122    #[test]
123    fn test_weighted_range_bounds() {
124        let ranges = [(50, 5..=10), (50, 20..=30)];
125        for _ in 0..100 {
126            let val = weighted_range(&ranges);
127            assert!((5..=10).contains(&val) || (20..=30).contains(&val));
128        }
129    }
130
131    #[test]
132    fn test_generate_random_game_options() {
133        for _ in 0..100 {
134            let opt = generate_random_game_options();
135            assert!(opt.snake_length >= 2 && opt.snake_length <= 999);
136            assert!(opt.life >= 1 && opt.life <= 99);
137            assert!(opt.nb_of_fruits >= 1 && opt.nb_of_fruits <= 999);
138            assert!(opt.fruit_duration_seconds >= 1 && opt.fruit_duration_seconds <= 60);
139            assert!(opt.snake_growth_factor >= 1 && opt.snake_growth_factor <= 10);
140            assert_eq!(opt.head_symbol.graphemes(true).count(), 1);
141            assert_eq!(opt.body_symbol.graphemes(true).count(), 1);
142            assert_ne!(opt.head_symbol, opt.body_symbol);
143            assert!(opt.caps_fps);
144            assert_eq!(opt.log_level, LogLevel::Off);
145            assert!(
146                opt.random,
147                "Generated options must have random flag set to true"
148            );
149            assert_eq!(opt.no_fruit_timer, !opt.fruit_timer);
150            assert_eq!(opt.no_negative_size_fruits, !opt.negative_size_fruits);
151        }
152    }
153}