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