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; 3] = ["load", "no-", "random"];
22#[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# snake_growth_factor: multiplier applied to each fruit's base snake-size effect
31# caps_fps: Enable frame limiting (default to true, false = no limit)
32# life: starting lives
33# nb_of_fruits: number of fruits available in the game at once
34# body_symbol: character for the snake's body
35# head_symbol: character for the snake's head
36# snake_length: initial length of the snake
37# speed: speed of the snake (Slow, Normal, Fast, Crazy)
38# log_level: logging level (off, error, warn, info, debug, trace)
39"#;
40
41macro_rules! define_args_withs {
43 (
44 $( $field_name:ident: $min:expr => $max:expr ),* $(,)?
45 ) => {
46 $(const $field_name: &str = stringify!($field_name);)*
48 #[must_use] pub fn get_parameter_range(param_name: &str) -> Option<std::ops::RangeInclusive<u16>> {
50 let idiomatic =param_name.to_string().replace("-","_").to_uppercase();
51 match idiomatic.as_str() {
52 $(
53 stringify!($field_name) => Some($min..=$max)
54 ,
55 )*
56 _ => None,
57 }
58 }
59 #[must_use] fn get_parameter_range_parser(param_name: &str) -> clap::builder::RangedI64ValueParser<u16> {
61 match param_name {
62 $(
63 stringify!($field_name) =>
64 clap::value_parser!(u16).range($min as i64..=$max as i64)
65 ,
66 )*
67 _ => clap::value_parser!(u16).range(1_i64..=99_i64),
68 }
69 }
70};
71}
72define_args_withs! {
75SNAKE_LENGTH: 2 => 999,
76LIFE: 1 => 99,
77NB_OF_FRUITS : 1 => 999,
78FRUIT_DURATION_SECONDS: 1 => 60,
79SNAKE_GROWTH_FACTOR: 1 => 10,
80PRESETS: 1 => 7,
81}
82const MAX_EMOJI_BY_LINE_COUNT: u16 = 19;
83pub const DISPLAYABLE_EMOJI: [&str; 38] = [
86 "๐", "๐", "๐ฅ", "๐พ", "๐ข", "๐ฆ", "๐ชฝ", "๐ฅ", "๐ฃ", "๐ฆ ", "๐ฆด", "๐ฃ", "๐ฅ", "๐ฅฎ", "๐ช", "๐ฉ",
87 "๐ง", "๐ด", "๐งจ", "๐ฆ", "๐", "๐", "๐ค ", "๐คก", "๐ฅณ", "๐ฅธ", "๐บ", "๐น", "๐พ", "๐ผ", "๐", "๐",
88 "๐ฆ", "๐ณ", "๐", "โ๏ธ", "๐ฝ", "@",
89];
90#[derive(Parser, Serialize, Deserialize, Debug, Clone, Default)]
92#[serde(default)]
93#[command(
94 author,
95 version,
96 long_version = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
97 env!("CARGO_PKG_DESCRIPTION"),
98 "\nRepository: ", env!("CARGO_PKG_REPOSITORY"),
99 "\nBuilt with Rust ", env!("CARGO_PKG_RUST_VERSION")),
100 about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
101 "\nSnake Game in terminal with CLI arguments.\nQuick custom run: cargo run -- -z ๐พ -b ๐ชฝ -l 10 "),
102 long_about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"), "\n",
103 env!("CARGO_PKG_DESCRIPTION"), " where you can configure the velocity, \
104 snake appearance, and more using command-line arguments.\nExample for asian vibes: rsnake -z ๐ผ -b ๐ฅ")
105)]
106#[allow(clippy::struct_excessive_bools)]
107pub struct GameOptions {
108 #[arg(
113 short,
114 long,
115 value_enum, default_value_t = Speed::Normal,
116 help = "Sets the movement speed of the snake.",
117 ignore_case = true
118 )]
119 pub speed: Speed,
120
121 #[arg(
126 short = 'z',
127 long,
128 default_value = DISPLAYABLE_EMOJI[34],
129 help = format!("Symbol used to represent the snake's head.\nHint:{}"
130 ,GameOptions::emojis_with_news_line()),
131 long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
132 \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
133 value_parser = |s: &str| -> Result<String, String>{
134 if s.graphemes(true).count() != 1 {
135 return Err(String::from("Head symbol must be exactly one grapheme / character"));
136 }
137 Ok(s.to_string())
138 }
139 )]
140 pub head_symbol: String,
141
142 #[arg(
148 short,
149 long,
150 default_value = DISPLAYABLE_EMOJI[35],
151 help = format!("Symbol used to represent the snake's body/trail.\
152 \nHint:{}",GameOptions::emojis_with_news_line()),
153 long_help = format!("Symbol used to represent the snake's body/trail.\
154 \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
155 value_parser = |s: &str| -> Result<String, String>{
156 if s.graphemes(true).count() != 1 {
157 return Err(String::from("Head symbol must be exactly one grapheme / character"));
158 }
159 Ok(s.to_string())
160 }
161 )]
162 pub body_symbol: String,
163
164 #[arg(
166 short = 'n',
167 long, default_value_t = 10,
169 value_parser = get_parameter_range_parser(SNAKE_LENGTH),
170 help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
171 )]
172 #[serde(alias = "SNAKE_LENGTH")]
173 pub snake_length: u16,
174
175 #[arg(
177 short,
178 long,
179 default_value_t = 3,
180 value_parser = get_parameter_range_parser(LIFE),
181 help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
182 )]
183 #[serde(alias = "LIFE")]
184 pub life: u16,
185
186 #[arg(
188 short = 'f',
189 long,
190 default_value_t = 5,
191 value_parser = get_parameter_range_parser(NB_OF_FRUITS),
192 help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
193 )]
194 #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
195 pub nb_of_fruits: u16,
196
197 #[arg(
199 long = "fruit-timer",
200 overrides_with = "fruit_timer",
201 help = "Enable the fruit lifetime countdown and automatic replacement [default]"
202 )]
203 #[serde(skip, default = "default_false")]
204 pub(crate) no_fruit_timer: bool,
205 #[arg(
206 long = "no-fruit-timer",
207 default_value_t = true,
208 action = ArgAction::SetFalse,
209 )]
210 pub fruit_timer: bool,
211
212 #[arg(
214 long,
215 default_value_t = 20,
216 value_parser = get_parameter_range_parser(FRUIT_DURATION_SECONDS),
217 help = format!(
218 "Defines the base lifetime used to derive fruit durations {}",
219 pretty(get_parameter_range(FRUIT_DURATION_SECONDS).unwrap())
220 )
221 )]
222 pub fruit_duration_seconds: u16,
223
224 #[arg(
226 long,
227 default_value_t = 1,
228 value_parser = get_parameter_range_parser(SNAKE_GROWTH_FACTOR),
229 help = format!(
230 "Multiplies each fruit's base snake growth or shrink effect {}",
231 pretty(get_parameter_range(SNAKE_GROWTH_FACTOR).unwrap())
232 )
233 )]
234 pub snake_growth_factor: u16,
235
236 #[arg(
238 long = "negative-size-fruits",
239 overrides_with = "negative_size_fruits",
240 help = "Allow fruits that can shrink the snake [default]"
241 )]
242 #[serde(skip, default = "default_false")]
243 pub(crate) no_negative_size_fruits: bool,
244 #[arg(
245 long = "no-negative-size-fruits",
246 default_value_t = true,
247 action = ArgAction::SetFalse,
248 )]
249 pub negative_size_fruits: bool,
250
251 #[arg(
253 long,
254 value_enum,
255 default_value_t = LogLevel::Off,
256 help = "Sets the logging level (off, error, warn, info, debug, trace).",
257 ignore_case = true
258 )]
259 pub log_level: LogLevel,
260 #[arg(
268 long = "caps-fps",
269 overrides_with = "caps_fps",
270 help = "Set to caps FPS limit (max 60 FPS) [default] "
271 )]
272 #[serde(skip, default = "default_false")]
273 pub(crate) no_caps_fps: bool,
274 #[arg(
275 long = "no-caps-fps",
276 default_value_t = true,
277 action = ArgAction::SetFalse,
278 )]
279 pub caps_fps: bool,
280 #[arg(
282 long,
283 default_missing_value = None,
284 value_parser = get_parameter_range_parser(PRESETS),
285 help = format!("Load game parameters PRESETS configuration from {}.\
286 Files are searched in the same folder as the executable.\
287 Save from edit menu to get the template or get one from best configuration example on the repository.\
288 Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
289 )]
290 #[serde(skip)]
291 pub load: Option<u16>,
292 #[arg(
294 short,
295 long,
296 help = "Randomize all game parameters with balanced, playable values"
297 )]
298 #[serde(skip)]
299 pub random: bool,
300}
301
302impl GameOptions {
303 #[must_use]
305 pub fn initial_position() -> Position {
306 INI_POSITION
307 }
308 #[must_use]
309 pub fn emojis_with_news_line() -> String {
310 DISPLAYABLE_EMOJI
311 .iter()
312 .enumerate()
313 .map(|(i, e)| {
314 if i == MAX_EMOJI_BY_LINE_COUNT as usize {
315 "\n".to_string() + e
316 } else {
317 (*e).to_string()
318 }
319 })
320 .collect::<String>()
321 }
322 pub fn emojis_iterator() -> impl Iterator<Item = String> {
323 DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
324 }
325
326 #[must_use]
330 pub fn to_structured_toml(&self) -> Table {
331 let toml_string =
332 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
333 toml_string.parse::<Table>().expect("invalid doc")
334 }
335
336 pub fn validate(&mut self) {
341 clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
342 clamp_to(&mut self.life, get_parameter_range(LIFE));
343 clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
344 clamp_to(
345 &mut self.fruit_duration_seconds,
346 get_parameter_range(FRUIT_DURATION_SECONDS),
347 );
348 clamp_to(
349 &mut self.snake_growth_factor,
350 get_parameter_range(SNAKE_GROWTH_FACTOR),
351 );
352 if let Some(preset) = &mut self.load {
353 clamp_to(preset, get_parameter_range(PRESETS));
354 }
356 if self.head_symbol.graphemes(true).count() != 1 {
359 self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
360 }
361 if self.body_symbol.graphemes(true).count() != 1 {
362 self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
363 }
364 }
365
366 pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
372 let path = format!("snake_preset_{preset}.toml");
373 let mut params = Self::load_from_toml(path)?;
374 params.load = Some(preset);
375 Ok(params)
376 }
377 fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
387 let mut file = File::open(path)?;
388 let mut contents = String::new();
389 file.read_to_string(&mut contents)?;
390 let mut params: Self =
391 toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
392 params.validate();
393 Ok(params)
394 }
395 pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
401 let path = format!("snake_preset_{preset}.toml");
402 self.save_to_toml(path)
403 }
404 fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
414 let toml_string =
415 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
416 let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
417 let mut file = File::create(path)?;
418 file.write_all(full_output.as_bytes())?;
419 Ok(())
420 }
421
422 #[must_use]
424 pub fn random() -> Self {
425 crate::game_logic::random_helper::generate_random_game_options()
426 }
427}
428
429fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
430 if let Some(range) = range {
431 let (min, max) = range.into_inner();
432 *value = (*value).clamp(min, max);
433 }
434}
435fn pretty(r: RangeInclusive<u16>) -> String {
436 format!("[{}-{}]", r.start(), r.end()).to_string()
437}
438fn default_false() -> bool {
440 false
441}
442
443impl ActionParameter for GameOptions {
444 fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
445 let command = GameOptions::command();
446 let prog_name = command.get_name().to_string();
447 let mut new_args = vec![prog_name];
448 for row in rows {
449 for cell in &row.cells {
450 if let CellValue::Options {
451 option_name,
452 values,
453 index,
454 ..
455 } = cell
456 {
457 let value = &values[*index];
458 match value.parse::<bool>() {
459 Ok(bv) => {
460 let bv_name: String = if bv {
465 option_name.clone()
466 } else {
467 option_name.replace("--", "--no-")
468 };
469 new_args.push(bv_name);
470 }
471 Err(_) => {
472 new_args.extend([option_name.clone(), value.clone()]);
474 }
475 }
476 }
477 }
478 }
479 self.update_from(new_args);
485 self.load = current_preset;
486 if let Some(preset) = current_preset {
488 let _ = self.save_to_toml_preset(preset);
489 }
490 }
491}
492
493#[cfg(test)]
494#[allow(clippy::field_reassign_with_default)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_validates() {
500 let mut options = GameOptions::default();
501 options.snake_length = 1000;
503 options.life = 100;
504 options.nb_of_fruits = 1000;
505 options.snake_growth_factor = 11;
506 options.load = Some(8);
507 options.validate();
508 assert_eq!(options.snake_length, 999);
509 assert_eq!(options.life, 99);
510 assert_eq!(options.nb_of_fruits, 999);
511 assert_eq!(options.snake_growth_factor, 10);
512 assert_eq!(options.load, Some(7));
513
514 options.snake_length = 1;
516 options.life = 0;
517 options.nb_of_fruits = 0;
518 options.snake_growth_factor = 0;
519 options.load = Some(0);
520 options.validate();
521 assert_eq!(options.snake_length, 2);
522 assert_eq!(options.life, 1);
523 assert_eq!(options.nb_of_fruits, 1);
524 assert_eq!(options.snake_growth_factor, 1);
525 assert_eq!(options.load, Some(1));
526 }
527
528 #[test]
529 fn test_validate_symbols() {
530 let mut options = GameOptions::default();
531 options.head_symbol = "invalid".to_string();
532 options.body_symbol = String::new();
533 options.validate();
534 assert_eq!(options.head_symbol.graphemes(true).count(), 1);
535 assert_eq!(options.body_symbol.graphemes(true).count(), 1);
536 }
537
538 #[test]
539 fn test_save_load_preset() {
540 let mut options = GameOptions::default();
541 options.snake_length = 42;
542 let preset_idx = 7;
543 let filename = format!("snake_preset_{preset_idx}.toml");
544 options.save_to_toml_preset(preset_idx).unwrap();
546
547 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
549 assert_eq!(loaded.snake_length, 42);
550 assert_eq!(loaded.load, Some(preset_idx));
551
552 let _ = std::fs::remove_file(filename);
554 }
555
556 #[test]
557 fn test_load_invalid_toml_clamped() {
558 let preset_idx = 6;
559 let filename = format!("snake_preset_{preset_idx}.toml");
560 let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
561 std::fs::write(&filename, content).unwrap();
562
563 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
564 assert_eq!(loaded.snake_length, 999);
565 assert_eq!(loaded.life, 1);
566
567 let _ = std::fs::remove_file(filename);
568 }
569}