Skip to main content

rsnaker/game_logic/
game_options.rs

1use crate::controls::speed::Speed;
2use crate::game_logic::logger::log_configuration::LogLevel;
3use crate::graphics::graphic_block::Position;
4use crate::graphics::menus::retro_parameter_table::generic_logic::{
5    ActionParameter, CellValue, RowData,
6};
7use clap::Parser;
8use clap::{ArgAction, CommandFactory};
9use serde::{Deserialize, Serialize};
10use std::fs::File;
11use std::io;
12use std::io::{Read, Write};
13use std::iter::Iterator;
14use std::ops::RangeInclusive;
15use std::path::Path;
16use toml::Table;
17use unicode_segmentation::UnicodeSegmentation;
18/// Initial position of the snake's head at the start of the game
19pub const INI_POSITION: Position = Position { x: 50, y: 5 };
20//Options to not display in the table menu in-game parameters
21pub const ONLY_FOR_CLI_PARAMETERS: [&str; 3] = ["load", "no-", "random"];
22//Later auto generates the header based on the help message as the in-game table menu
23#[allow(clippy::needless_raw_string_hashes)]
24const PARAMS_HEADER: &str = r#"
25# Snake Game Configuration
26# ---------------------------
27# negative_size_fruits: allow fruits that can shrink the snake
28# fruit_timer:      enable fruit lifetime countdown and auto-replacement
29# fruit_duration_seconds: base lifetime of a fruit before multipliers are applied
30# snake_growth_factor: multiplier applied to each fruit's base snake-size effect
31# caps_fps:         Enable frame limiting (default to true, false = no limit)
32# life:             starting lives
33# nb_of_fruits:     number of fruits available in the game at once
34# body_symbol:      character for the snake's body  
35# head_symbol:      character for the snake's head
36# snake_length:     initial length of the snake
37# speed:            speed of the snake (Slow, Normal, Fast, Crazy)
38# log_level:        logging level (off, error, warn, info, debug, trace)
39"#;
40
41///To be able to iterate over range in a meta-way, see in table parameter very useful
42macro_rules! define_args_withs {
43   (
44       $( $field_name:ident: $min:expr => $max:expr ),* $(,)?
45   ) => {
46       /// define a const to avoid str errors
47       $(const $field_name: &str = stringify!($field_name);)*
48       /// Returns the valid range for the parameter in O(1) or None
49       #[must_use] pub fn get_parameter_range(param_name: &str) -> Option<std::ops::RangeInclusive<u16>> {
50           let idiomatic =param_name.to_string().replace("-","_").to_uppercase();
51           match idiomatic.as_str() {
52               $(
53                stringify!($field_name) => Some($min..=$max)
54                ,
55            )*
56            _ => None,
57        }
58    }
59    /// Get a clap value parser for a specific parameter or the default range of 1..99
60    #[must_use] fn get_parameter_range_parser(param_name: &str) -> clap::builder::RangedI64ValueParser<u16> {
61        match param_name {
62            $(
63                stringify!($field_name) =>
64                    clap::value_parser!(u16).range($min as i64..=$max as i64)
65                ,
66            )*
67            _ => clap::value_parser!(u16).range(1_i64..=99_i64),
68        }
69    }
70};
71}
72//Define all arguments and ranges in one place
73//Snake length begins at 2 to have a head and a body different
74define_args_withs! {
75SNAKE_LENGTH: 2 => 999,
76LIFE: 1 => 99,
77NB_OF_FRUITS : 1 => 999,
78FRUIT_DURATION_SECONDS: 1 => 60,
79SNAKE_GROWTH_FACTOR: 1 => 10,
80PRESETS: 1 => 7,
81}
82const MAX_EMOJI_BY_LINE_COUNT: u16 = 19;
83//split in 2 arrays representing max emoji on one line because easier to display
84// (the main use case of the const)
85pub const DISPLAYABLE_EMOJI: [&str; 38] = [
86    "๐Ÿ", "๐Ÿ˜‹", "๐Ÿฅ‘", "๐Ÿพ", "๐Ÿข", "๐ŸฆŽ", "๐Ÿชฝ", "๐Ÿฅ", "๐Ÿฃ", "๐Ÿฆ ", "๐Ÿฆด", "๐Ÿ‘ฃ", "๐Ÿฅ", "๐Ÿฅฎ", "๐Ÿช", "๐Ÿฉ",
87    "๐ŸงŠ", "๐Ÿด", "๐Ÿงจ", "๐Ÿฆ‘", "๐ŸŸ", "๐Ÿ˜", "๐Ÿค ", "๐Ÿคก", "๐Ÿฅณ", "๐Ÿฅธ", "๐Ÿ‘บ", "๐Ÿ‘น", "๐Ÿ‘พ", "๐Ÿผ", "๐Ÿ‰", "๐Ÿ",
88    "๐Ÿฆ€", "๐Ÿณ", "๐ŸŽ„", "โ„๏ธ", "๐Ÿ‘ฝ", "@",
89];
90/// Structure holding all the configuration parameters for the game
91#[derive(Parser, Serialize, Deserialize, Debug, Clone, Default)]
92#[serde(default)]
93#[command(
94    author,
95    version,
96    long_version = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
97    env!("CARGO_PKG_DESCRIPTION"),
98    "\nRepository: ", env!("CARGO_PKG_REPOSITORY"),
99    "\nBuilt with Rust ", env!("CARGO_PKG_RUST_VERSION")),
100    about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
101    "\nSnake Game in terminal with CLI arguments.\nQuick custom run: cargo run -- -z ๐Ÿ‘พ -b ๐Ÿชฝ -l 10 "),
102    long_about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"), "\n",
103    env!("CARGO_PKG_DESCRIPTION"), " where you can configure the velocity, \
104    snake appearance, and more using command-line arguments.\nExample for asian vibes: rsnake -z ๐Ÿผ -b ๐Ÿฅ")
105)]
106#[allow(clippy::struct_excessive_bools)]
107pub struct GameOptions {
108    /// Speed of the snake (Slow, Normal, Fast, Crazy)
109    /// Derives `ValueEnum` on the enum Speed and enforces the type
110    /// `clap::ValueEnum`, which automatically handles possible values and displays them in the help message.
111    /// Now, clap enforces valid inputs without requiring a manual `FromStr` implementation.
112    #[arg(
113        short,
114        long,
115        value_enum, default_value_t = Speed::Normal,
116        help = "Sets the movement speed of the snake.",
117        ignore_case = true
118    )]
119    pub speed: Speed,
120
121    /// Snake symbol (emoji or character)
122    /// Defines short value because doublon, as short and long,
123    /// are by default based on the name of the variable
124    /// Default is a Christmas tree
125    #[arg(
126        short = 'z',
127        long,
128        default_value = DISPLAYABLE_EMOJI[34],
129        help = format!("Symbol used to represent the snake's head.\nHint:{}"
130        ,GameOptions::emojis_with_news_line()),
131        long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
132        \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
133        value_parser = |s: &str| -> Result<String, String>{
134            if s.graphemes(true).count() != 1 {
135                return Err(String::from("Head symbol must be exactly one grapheme / character"));
136            }
137            Ok(s.to_string())
138        }
139    )]
140    pub head_symbol: String,
141
142    /// Snake trail symbol (emoji or character)
143    /// need to operate over graphene not chars
144    /// see <https://crates.io/crates/unicode-segmentation/> /
145    /// Or deep explanation:<https://docs.rs/bstr/1.12.0/bstr/#when-should-i-use-byte-strings>
146    /// Default is snow emoji
147    #[arg(
148        short,
149        long,
150        default_value = DISPLAYABLE_EMOJI[35],
151        help = format!("Symbol used to represent the snake's body/trail.\
152        \nHint:{}",GameOptions::emojis_with_news_line()),
153        long_help = format!("Symbol used to represent the snake's body/trail.\
154        \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
155        value_parser = |s: &str| -> Result<String, String>{
156            if s.graphemes(true).count() != 1 {
157                return Err(String::from("Head symbol must be exactly one grapheme / character"));
158            }
159            Ok(s.to_string())
160        }
161    )]
162    pub body_symbol: String,
163
164    /// Initial length of the snake
165    #[arg(
166        short = 'n',
167        long, // = SNAKE_LENGTH
168        default_value_t = 10,
169        value_parser = get_parameter_range_parser(SNAKE_LENGTH),
170        help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
171    )]
172    #[serde(alias = "SNAKE_LENGTH")]
173    pub snake_length: u16,
174
175    /// Number of lives
176    #[arg(
177        short,
178        long,
179        default_value_t = 3,
180        value_parser = get_parameter_range_parser(LIFE),
181        help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
182    )]
183    #[serde(alias = "LIFE")]
184    pub life: u16,
185
186    /// Number of fruits in the game
187    #[arg(
188        short = 'f',
189        long,
190        default_value_t = 5,
191        value_parser = get_parameter_range_parser(NB_OF_FRUITS),
192        help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
193    )]
194    #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
195    pub nb_of_fruits: u16,
196
197    /// Enables the fruit timer and automatic replacement when a fruit expires.
198    #[arg(
199        long = "fruit-timer",
200        overrides_with = "fruit_timer",
201        help = "Enable the fruit lifetime countdown and automatic replacement [default]"
202    )]
203    #[serde(skip, default = "default_false")]
204    pub(crate) no_fruit_timer: bool,
205    #[arg(
206        long = "no-fruit-timer",
207        default_value_t = true,
208        action = ArgAction::SetFalse,
209    )]
210    pub fruit_timer: bool,
211
212    /// Base fruit lifetime in seconds before per-fruit multipliers are applied.
213    #[arg(
214        long,
215        default_value_t = 20,
216        value_parser = get_parameter_range_parser(FRUIT_DURATION_SECONDS),
217        help = format!(
218            "Defines the base lifetime used to derive fruit durations {}",
219            pretty(get_parameter_range(FRUIT_DURATION_SECONDS).unwrap())
220        )
221    )]
222    pub fruit_duration_seconds: u16,
223
224    /// Multiplier applied to the hardcoded snake-size effect of each fruit.
225    #[arg(
226        long,
227        default_value_t = 1,
228        value_parser = get_parameter_range_parser(SNAKE_GROWTH_FACTOR),
229        help = format!(
230            "Multiplies each fruit's base snake growth or shrink effect {}",
231            pretty(get_parameter_range(SNAKE_GROWTH_FACTOR).unwrap())
232        )
233    )]
234    pub snake_growth_factor: u16,
235
236    /// Allow fruits with negative size effects.
237    #[arg(
238        long = "negative-size-fruits",
239        overrides_with = "negative_size_fruits",
240        help = "Allow fruits that can shrink the snake [default]"
241    )]
242    #[serde(skip, default = "default_false")]
243    pub(crate) no_negative_size_fruits: bool,
244    #[arg(
245        long = "no-negative-size-fruits",
246        default_value_t = true,
247        action = ArgAction::SetFalse,
248    )]
249    pub negative_size_fruits: bool,
250
251    /// Logging level
252    #[arg(
253        long,
254        value_enum,
255        default_value_t = LogLevel::Off,
256        help = "Sets the logging level (off, error, warn, info, debug, trace).",
257        ignore_case = true
258    )]
259    pub log_level: LogLevel,
260    /// Modern way to do CLI, two dedicated flag to set/unset the value, beginning with --no- (for false)
261    /// UX better than --feature false / --feature true, better than default (no flag = false).
262    /// If you want the possibility to set both values,
263    /// as no clear default value or want to be able to easily programmatically change the value (as there)
264    /// or to have a default at true <hr>
265    /// See: <https://jwodder.github.io/kbits/posts/clap-bool-negate/>
266    /// As default is true, no and value are swaped
267    #[arg(
268        long = "caps-fps",
269        overrides_with = "caps_fps",
270        help = "Set to caps FPS limit (max 60 FPS) [default] "
271    )]
272    #[serde(skip, default = "default_false")]
273    pub(crate) no_caps_fps: bool,
274    #[arg(
275        long = "no-caps-fps",
276        default_value_t = true,
277        action = ArgAction::SetFalse,
278    )]
279    pub caps_fps: bool,
280    /// Load game parameters
281    #[arg(
282        long,
283        default_missing_value = None,
284        value_parser = get_parameter_range_parser(PRESETS),
285        help = format!("Load game parameters PRESETS configuration from {}.\
286        Files are searched in the same folder as the executable.\
287        Save from edit menu to get the template or get one from best configuration example on the repository.\
288        Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
289    )]
290    #[serde(skip)]
291    pub load: Option<u16>,
292    /// Randomize all game parameters with playable values
293    #[arg(
294        short,
295        long,
296        help = "Randomize all game parameters with balanced, playable values"
297    )]
298    #[serde(skip)]
299    pub random: bool,
300}
301
302impl GameOptions {
303    /// Returns the initial snake position
304    #[must_use]
305    pub fn initial_position() -> Position {
306        INI_POSITION
307    }
308    #[must_use]
309    pub fn emojis_with_news_line() -> String {
310        DISPLAYABLE_EMOJI
311            .iter()
312            .enumerate()
313            .map(|(i, e)| {
314                if i == MAX_EMOJI_BY_LINE_COUNT as usize {
315                    "\n".to_string() + e
316                } else {
317                    (*e).to_string()
318                }
319            })
320            .collect::<String>()
321    }
322    pub fn emojis_iterator() -> impl Iterator<Item = String> {
323        DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
324    }
325
326    /// To be editable easily
327    /// # Panics
328    /// if self cannot be parsed (not possible)
329    #[must_use]
330    pub fn to_structured_toml(&self) -> Table {
331        let toml_string =
332            toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
333        toml_string.parse::<Table>().expect("invalid doc")
334    }
335
336    /// Validates and clamps the parameters to their allowed ranges.
337    /// Ensures that numeric values are within the bounds defined in the CLI macro
338    /// and that symbols are exactly one grapheme long.
339    /// Enums are already checked automatically during deserialization
340    pub fn validate(&mut self) {
341        clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
342        clamp_to(&mut self.life, get_parameter_range(LIFE));
343        clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
344        clamp_to(
345            &mut self.fruit_duration_seconds,
346            get_parameter_range(FRUIT_DURATION_SECONDS),
347        );
348        clamp_to(
349            &mut self.snake_growth_factor,
350            get_parameter_range(SNAKE_GROWTH_FACTOR),
351        );
352        if let Some(preset) = &mut self.load {
353            clamp_to(preset, get_parameter_range(PRESETS));
354            //self.load = Some(preset.clamp(*PRESETS.start(), *PRESETS.end()));
355        }
356        // Symbols check: Head and body must be exactly one grapheme.
357        // If invalid, they are reset to default emojis.
358        if self.head_symbol.graphemes(true).count() != 1 {
359            self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
360        }
361        if self.body_symbol.graphemes(true).count() != 1 {
362            self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
363        }
364    }
365
366    /// Load parameters from a preset TOML configuration
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the preset file cannot be opened or read, or if it contains invalid TOML.
371    pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
372        let path = format!("snake_preset_{preset}.toml");
373        let mut params = Self::load_from_toml(path)?;
374        params.load = Some(preset);
375        Ok(params)
376    }
377    /// Load parameters from a TOML file
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the file cannot be opened or read.
382    ///
383    /// # Panics
384    ///
385    /// Panic if the file contents cannot be deserialized as valid TOML.
386    fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
387        let mut file = File::open(path)?;
388        let mut contents = String::new();
389        file.read_to_string(&mut contents)?;
390        let mut params: Self =
391            toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
392        params.validate();
393        Ok(params)
394    }
395    /// Save parameters to a preset TOML configuration
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if the preset file cannot be created or written to.
400    pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
401        let path = format!("snake_preset_{preset}.toml");
402        self.save_to_toml(path)
403    }
404    /// Save the current parameters to a TOML file
405    /// NB: non-serializable parameters are marked skip with serde annotation and will not be included
406    /// # Errors
407    ///
408    /// Returns an error if the file cannot be created or written to.
409    ///
410    /// # Panics
411    ///
412    /// Panics if the game parameters cannot be serialized to TOML.
413    fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
414        let toml_string =
415            toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
416        let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
417        let mut file = File::create(path)?;
418        file.write_all(full_output.as_bytes())?;
419        Ok(())
420    }
421
422    /// Returns a new `GameOptions` with randomized playable parameters using weighted distributions.
423    #[must_use]
424    pub fn random() -> Self {
425        crate::game_logic::random_helper::generate_random_game_options()
426    }
427}
428
429fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
430    if let Some(range) = range {
431        let (min, max) = range.into_inner();
432        *value = (*value).clamp(min, max);
433    }
434}
435fn pretty(r: RangeInclusive<u16>) -> String {
436    format!("[{}-{}]", r.start(), r.end()).to_string()
437}
438// Serde trick
439fn default_false() -> bool {
440    false
441}
442
443impl ActionParameter for GameOptions {
444    fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
445        let command = GameOptions::command();
446        let prog_name = command.get_name().to_string();
447        let mut new_args = vec![prog_name];
448        for row in rows {
449            for cell in &row.cells {
450                if let CellValue::Options {
451                    option_name,
452                    values,
453                    index,
454                    ..
455                } = cell
456                {
457                    let value = &values[*index];
458                    match value.parse::<bool>() {
459                        Ok(bv) => {
460                            // Modern way to do CLI, two dedicated flag to set/unset the value, beginning with --no- (for false)
461                            // UX better than --feature false / --feature true, better than default (no flag = false). If you want the possibility to set both values
462                            // as no clear default value or want to be able to easily programmatically change the value (as there)
463                            // or to have a default at true
464                            let bv_name: String = if bv {
465                                option_name.clone()
466                            } else {
467                                option_name.replace("--", "--no-")
468                            };
469                            new_args.push(bv_name);
470                        }
471                        Err(_) => {
472                            //not a boolean value
473                            new_args.extend([option_name.clone(), value.clone()]);
474                        }
475                    }
476                }
477            }
478        }
479        // Update all the game options as a reparsing (only one way to update value to check).
480        // Some debate over the utility of this feature for clap, but widely used to update from env / configuration
481        // Allows keeping the struct for cli parameter as a model object, feeding it with different streams of data.
482        // The backup solution is to serialize all the current values in TOML and load them in game_options as already done for the file saving
483        // (safe as constraints in-game value)
484        self.update_from(new_args);
485        self.load = current_preset;
486        //If we are on a custom preset, save it (before resetting values)
487        if let Some(preset) = current_preset {
488            let _ = self.save_to_toml_preset(preset);
489        }
490    }
491}
492
493#[cfg(test)]
494#[allow(clippy::field_reassign_with_default)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_validates() {
500        let mut options = GameOptions::default();
501        // Values above max
502        options.snake_length = 1000;
503        options.life = 100;
504        options.nb_of_fruits = 1000;
505        options.snake_growth_factor = 11;
506        options.load = Some(8);
507        options.validate();
508        assert_eq!(options.snake_length, 999);
509        assert_eq!(options.life, 99);
510        assert_eq!(options.nb_of_fruits, 999);
511        assert_eq!(options.snake_growth_factor, 10);
512        assert_eq!(options.load, Some(7));
513
514        // Values below or at min
515        options.snake_length = 1;
516        options.life = 0;
517        options.nb_of_fruits = 0;
518        options.snake_growth_factor = 0;
519        options.load = Some(0);
520        options.validate();
521        assert_eq!(options.snake_length, 2);
522        assert_eq!(options.life, 1);
523        assert_eq!(options.nb_of_fruits, 1);
524        assert_eq!(options.snake_growth_factor, 1);
525        assert_eq!(options.load, Some(1));
526    }
527
528    #[test]
529    fn test_validate_symbols() {
530        let mut options = GameOptions::default();
531        options.head_symbol = "invalid".to_string();
532        options.body_symbol = String::new();
533        options.validate();
534        assert_eq!(options.head_symbol.graphemes(true).count(), 1);
535        assert_eq!(options.body_symbol.graphemes(true).count(), 1);
536    }
537
538    #[test]
539    fn test_save_load_preset() {
540        let mut options = GameOptions::default();
541        options.snake_length = 42;
542        let preset_idx = 7;
543        let filename = format!("snake_preset_{preset_idx}.toml");
544        // Save
545        options.save_to_toml_preset(preset_idx).unwrap();
546
547        // Load
548        let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
549        assert_eq!(loaded.snake_length, 42);
550        assert_eq!(loaded.load, Some(preset_idx));
551
552        // Cleanup
553        let _ = std::fs::remove_file(filename);
554    }
555
556    #[test]
557    fn test_load_invalid_toml_clamped() {
558        let preset_idx = 6;
559        let filename = format!("snake_preset_{preset_idx}.toml");
560        let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
561        std::fs::write(&filename, content).unwrap();
562
563        let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
564        assert_eq!(loaded.snake_length, 999);
565        assert_eq!(loaded.life, 1);
566
567        let _ = std::fs::remove_file(filename);
568    }
569}