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;
18pub const INI_POSITION: Position = Position { x: 50, y: 5 };
20pub const ONLY_FOR_CLI_PARAMETERS: [&str; 2] = ["load", "no-"];
22#[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
38macro_rules! define_args_withs {
40 (
41 $( $field_name:ident: $min:expr => $max:expr ),* $(,)?
42 ) => {
43 $(const $field_name: &str = stringify!($field_name);)*
45 #[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 #[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}
69define_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;
78pub const DISPLAYABLE_EMOJI: [&str; 38] = [
81 "๐", "๐", "๐ฅ", "๐พ", "๐ข", "๐ฆ", "๐ชฝ", "๐ฅ", "๐ฃ", "๐ฆ ", "๐ฆด", "๐ฃ", "๐ฅ", "๐ฅฎ", "๐ช", "๐ฉ",
82 "๐ง", "๐ด", "๐งจ", "๐ฆ", "๐", "๐", "๐ค ", "๐คก", "๐ฅณ", "๐ฅธ", "๐บ", "๐น", "๐พ", "๐ผ", "๐", "๐",
83 "๐ฆ", "๐ณ", "๐", "โ๏ธ", "๐ฝ", "@",
84];
85#[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 #[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 #[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 #[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 #[arg(
162 short = 'n',
163 long, 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 }
293 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 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 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 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 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}
369fn 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 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 new_args.extend([option_name.clone(), value.clone()]);
408 }
409 }
410 }
411 }
412 }
413 self.update_from(new_args);
419 self.load = current_preset;
420 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 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 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 options.save_to_toml_preset(preset_idx).unwrap();
476
477 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 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}