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        ignore_case = true
112    )]
113    pub speed: Speed,
114
115    /// Snake symbol (emoji or character)
116    /// Defines short value because doublon, as short and long,
117    /// are by default based on the name of the variable
118    /// Default is Christmas tree
119    #[arg(
120        short = 'z',
121        long,
122        default_value = DISPLAYABLE_EMOJI[34],
123        help = format!("Symbol used to represent the snake's head.\nHint:{}"
124        ,GameOptions::emojis_with_news_line()),
125        long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
126        \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
127        value_parser = |s: &str| -> Result<String, String>{
128            if s.graphemes(true).count() != 1 {
129                return Err(String::from("Head symbol must be exactly one grapheme / character"));
130            }
131            Ok(s.to_string())
132        }
133    )]
134    pub head_symbol: String,
135
136    /// Snake trail symbol (emoji or character)
137    /// need to operate over graphene not chars
138    /// see <https://crates.io/crates/unicode-segmentation/> /
139    /// Or deep explanation:<https://docs.rs/bstr/1.12.0/bstr/#when-should-i-use-byte-strings>
140    /// Default is snow emoji
141    #[arg(
142        short,
143        long,
144        default_value = DISPLAYABLE_EMOJI[35],
145        help = format!("Symbol used to represent the snake's body/trail.\
146        \nHint:{}",GameOptions::emojis_with_news_line()),
147        long_help = format!("Symbol used to represent the snake's body/trail.\
148        \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
149        value_parser = |s: &str| -> Result<String, String>{
150            if s.graphemes(true).count() != 1 {
151                return Err(String::from("Head symbol must be exactly one grapheme / character"));
152            }
153            Ok(s.to_string())
154        }
155    )]
156    pub body_symbol: String,
157
158    /// Initial length of the snake
159    #[arg(
160        short = 'n',
161        long, // = SNAKE_LENGTH
162        default_value_t = 10,
163        value_parser = get_parameter_range_parser(SNAKE_LENGTH),
164        help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
165    )]
166    #[serde(alias = "SNAKE_LENGTH")]
167    pub snake_length: u16,
168
169    /// Number of lives
170    #[arg(
171        short,
172        long,
173        default_value_t = 3,
174        value_parser = get_parameter_range_parser(LIFE),
175        help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
176    )]
177    #[serde(alias = "LIFE")]
178    pub life: u16,
179
180    /// Number of fruits in the game
181    #[arg(
182        short = 'f',
183        long,
184        default_value_t = 5,
185        value_parser = get_parameter_range_parser(NB_OF_FRUITS),
186        help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
187    )]
188    #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
189    pub nb_of_fruits: u16,
190    /// Modern way to do CLI, two dedicated flag to set/unset the value, beginning with --no- (for false)
191    /// UX better than --feature false / --feature true, better than default (no flag = false).
192    /// If you want the possibility to set both values,
193    /// as no clear default value or want to be able to easily programmatically change the value (as there)
194    /// or to have a default at true <hr>
195    /// See: <https://jwodder.github.io/kbits/posts/clap-bool-negate/>
196    /// As default is true, no and value are swaped
197    #[arg(
198        long = "caps-fps",
199        overrides_with = "caps_fps",
200        help = "Set to caps FPS limit (max 60 FPS) [default] "
201    )]
202    #[serde(skip, default = "default_false")]
203    no_caps_fps: bool,
204    #[arg(
205        long = "no-caps-fps",
206        default_value_t = true,
207        action = ArgAction::SetFalse,
208    )]
209    pub caps_fps: bool,
210    /// As default is false, order is more logical
211    #[arg(
212        long,
213        default_value_t = false,
214        overrides_with = "no_classic_mode",
215        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 "
216    )]
217    pub classic_mode: bool,
218    #[arg(long)]
219    #[serde(skip, default = "default_true")]
220    no_classic_mode: bool,
221    /// Load game parameters
222    #[arg(
223        long,
224        default_missing_value = None,
225        value_parser = get_parameter_range_parser(PRESETS),
226        help = format!("Load game parameters PRESETS configuration from {}.\
227        Files are searched in the same folder as the executable.\
228        Save from edit menu to get the template or get one from best configuration example on the repository.\
229        Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
230    )]
231    #[serde(skip)]
232    pub load: Option<u16>,
233}
234
235impl GameOptions {
236    /// Returns the initial snake position
237    #[must_use]
238    pub fn initial_position() -> Position {
239        INI_POSITION
240    }
241    #[must_use]
242    pub fn emojis_with_news_line() -> String {
243        DISPLAYABLE_EMOJI
244            .iter()
245            .enumerate()
246            .map(|(i, e)| {
247                if i == MAX_EMOJI_BY_LINE_COUNT as usize {
248                    "\n".to_string() + e
249                } else {
250                    (*e).to_string()
251                }
252            })
253            .collect::<String>()
254    }
255    pub fn emojis_iterator() -> impl Iterator<Item = String> {
256        DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
257    }
258
259    /// To be editable easily
260    /// # Panics
261    /// if self cannot be parsed (not possible)
262    #[must_use]
263    pub fn to_structured_toml(&self) -> Table {
264        let toml_string =
265            toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
266        toml_string.parse::<Table>().expect("invalid doc")
267    }
268
269    /// Validates and clamps the parameters to their allowed ranges.
270    /// Ensures that numeric values are within the bounds defined in the CLI macro
271    /// and that symbols are exactly one grapheme long.
272    /// Enums are already checked automatically during deserialization
273    pub fn validate(&mut self) {
274        clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
275        clamp_to(&mut self.life, get_parameter_range(LIFE));
276        clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
277        if let Some(preset) = &mut self.load {
278            clamp_to(preset, get_parameter_range(PRESETS));
279            //self.load = Some(preset.clamp(*PRESETS.start(), *PRESETS.end()));
280        }
281        // Symbols check: Head and body must be exactly one grapheme.
282        // If invalid, they are reset to default emojis.
283        if self.head_symbol.graphemes(true).count() != 1 {
284            self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
285        }
286        if self.body_symbol.graphemes(true).count() != 1 {
287            self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
288        }
289    }
290
291    /// Load parameters from a preset TOML configuration
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if the preset file cannot be opened or read, or if it contains invalid TOML.
296    pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
297        let path = format!("snake_preset_{preset}.toml");
298        let mut params = Self::load_from_toml(path)?;
299        params.load = Some(preset);
300        Ok(params)
301    }
302    /// Load parameters from a TOML file
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the file cannot be opened or read.
307    ///
308    /// # Panics
309    ///
310    /// Panic if the file contents cannot be deserialized as valid TOML.
311    fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
312        let mut file = File::open(path)?;
313        let mut contents = String::new();
314        file.read_to_string(&mut contents)?;
315        let mut params: Self =
316            toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
317        params.validate();
318        Ok(params)
319    }
320    /// Save parameters to a preset TOML configuration
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the preset file cannot be created or written to.
325    pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
326        let path = format!("snake_preset_{preset}.toml");
327        self.save_to_toml(path)
328    }
329    /// Save the current parameters to a TOML file
330    /// NB: non-serializable parameters are marked skip with serde annotation and will not be included
331    /// # Errors
332    ///
333    /// Returns an error if the file cannot be created or written to.
334    ///
335    /// # Panics
336    ///
337    /// Panics if the game parameters cannot be serialized to TOML.
338    fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
339        let toml_string =
340            toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
341        let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
342        let mut file = File::create(path)?;
343        file.write_all(full_output.as_bytes())?;
344        Ok(())
345    }
346}
347
348fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
349    if let Some(range) = range {
350        let (min, max) = range.into_inner();
351        *value = (*value).clamp(min, max);
352    }
353}
354fn pretty(r: RangeInclusive<u16>) -> String {
355    format!("[{}-{}]", r.start(), r.end()).to_string()
356}
357// Serde trick
358fn default_true() -> bool {
359    true
360}
361fn default_false() -> bool {
362    false
363}
364
365impl ActionParameter for GameOptions {
366    fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
367        let command = GameOptions::command();
368        let prog_name = command.get_name().to_string();
369        let mut new_args = vec![prog_name];
370        for row in rows {
371            for cell in &row.cells {
372                if let CellValue::Options {
373                    option_name,
374                    values,
375                    index,
376                    ..
377                } = cell
378                {
379                    let value = &values[*index];
380                    match value.parse::<bool>() {
381                        Ok(bv) => {
382                            // Modern way to do CLI, two dedicated flag to set/unset the value, beginning with --no- (for false)
383                            // UX better than --feature false / --feature true, better than default (no flag = false). If you want the possibility to set both values
384                            // as no clear default value or want to be able to easily programmatically change the value (as there)
385                            // or to have a default at true
386                            let bv_name: String = if bv {
387                                option_name.clone()
388                            } else {
389                                option_name.replace("--", "--no-")
390                            };
391                            new_args.push(bv_name);
392                        }
393                        Err(_) => {
394                            //not a boolean value
395                            new_args.extend([option_name.clone(), value.clone()]);
396                        }
397                    }
398                }
399            }
400        }
401        // Update all the game options as a reparsing (only one way to update value to check).
402        // Some debate over the utility of this feature for clap, but widely used to update from env / configuration
403        // Allows keeping the struct for cli parameter as a model object, feeding it with different streams of data.
404        // 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
405        // (safe as constraints in-game value)
406        self.update_from(new_args);
407        self.load = current_preset;
408        //If we are on a custom preset, save it (before resetting values)
409        if let Some(preset) = current_preset {
410            let _ = self.save_to_toml_preset(preset);
411        }
412    }
413}
414
415#[cfg(test)]
416#[allow(clippy::field_reassign_with_default)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_validates() {
422        let mut options = GameOptions::default();
423        // Values above max
424        options.snake_length = 1000;
425        options.life = 100;
426        options.nb_of_fruits = 1000;
427        options.load = Some(8);
428        options.validate();
429        assert_eq!(options.snake_length, 999);
430        assert_eq!(options.life, 99);
431        assert_eq!(options.nb_of_fruits, 999);
432        assert_eq!(options.load, Some(7));
433
434        // Values below or at min
435        options.snake_length = 1;
436        options.life = 0;
437        options.nb_of_fruits = 0;
438        options.load = Some(0);
439        options.validate();
440        assert_eq!(options.snake_length, 2);
441        assert_eq!(options.life, 1);
442        assert_eq!(options.nb_of_fruits, 1);
443        assert_eq!(options.load, Some(1));
444    }
445
446    #[test]
447    fn test_validate_symbols() {
448        let mut options = GameOptions::default();
449        options.head_symbol = "invalid".to_string();
450        options.body_symbol = String::new();
451        options.validate();
452        assert_eq!(options.head_symbol.graphemes(true).count(), 1);
453        assert_eq!(options.body_symbol.graphemes(true).count(), 1);
454    }
455
456    #[test]
457    fn test_save_load_preset() {
458        let mut options = GameOptions::default();
459        options.snake_length = 42;
460        let preset_idx = 7;
461        let filename = format!("snake_preset_{preset_idx}.toml");
462        // Save
463        options.save_to_toml_preset(preset_idx).unwrap();
464
465        // Load
466        let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
467        assert_eq!(loaded.snake_length, 42);
468        assert_eq!(loaded.load, Some(preset_idx));
469
470        // Cleanup
471        let _ = std::fs::remove_file(filename);
472    }
473
474    #[test]
475    fn test_load_invalid_toml_clamped() {
476        let preset_idx = 6;
477        let filename = format!("snake_preset_{preset_idx}.toml");
478        let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
479        std::fs::write(&filename, content).unwrap();
480
481        let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
482        assert_eq!(loaded.snake_length, 999);
483        assert_eq!(loaded.life, 1);
484
485        let _ = std::fs::remove_file(filename);
486    }
487}