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