rsnaker/game_logic/
game_options.rs

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