tetro-tui 3.5.2

A terminal-based but modern tetromino-stacking game that is very customizable and cross-platform.
use std::num::{NonZeroU32, NonZeroUsize};

use crate::core_game_engine::{
    BOARD_WIDTH, Game, GameAccess, GameBuilder, GameEndCause, GameModifier, GameRng, Line,
    MiscPceRots, MiscTetGens, NotificationFeed, Phase, TileType,
};

use rand::seq::SliceRandom;

use crate::savefile_logic::to_savefile_string;

#[derive(
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Clone,
    Debug,
    Default,
    serde::Serialize,
    serde::Deserialize,
)]
pub struct Cheese {
    // Configuration/reproducibility fields.
    config: CheeseConfig,

    // Stateful fields.
    cheese_eaten: u32,
    temp_last_clear_actual_cheese_lines: usize,
    cheese_generated: u32,
    last_hole_pattern_generated: Vec<usize>,
    cached_display_values: [(String, String); 2],
}

#[derive(
    PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize,
)]
pub struct CheeseConfig {
    pub holes_per_line: NonZeroUsize,
    pub ensure_distinct_holes: bool,
    pub limit: Option<NonZeroU32>,
}

impl Default for CheeseConfig {
    fn default() -> Self {
        Self {
            holes_per_line: NonZeroUsize::MIN,
            ensure_distinct_holes: true,
            limit: Some(NonZeroU32::try_from(40).unwrap()),
        }
    }
}

impl Cheese {
    pub const MOD_ID: &str = stringify!(Cheese); // lol.

    pub fn build(builder: &GameBuilder, config: CheeseConfig) -> Game {
        let modifier = Box::new(Self {
            config,
            cheese_eaten: 0,
            temp_last_clear_actual_cheese_lines: 0,
            cheese_generated: 0,
            last_hole_pattern_generated: Vec::new(),
            cached_display_values: [
                ("Cheese eaten".to_owned(), 0.to_string()),
                ("Efficiency".to_owned(), Self::fmt_efficiency(0, 0)),
            ],
        });

        builder.build_modded(vec![modifier])
    }
}

impl GameModifier<MiscTetGens, MiscPceRots, TileType> for Cheese {
    fn id(&self) -> String {
        Self::MOD_ID.to_owned()
    }

    fn cfg(&self) -> String {
        to_savefile_string(&(self.config)).unwrap()
    }

    fn values(&self) -> &[(String, String)] {
        &self.cached_display_values
    }

    fn try_clone(
        &self,
    ) -> Result<Box<dyn GameModifier<MiscTetGens, MiscPceRots, TileType>>, String> {
        Ok(Box::new(self.clone()))
    }

    fn on_game_built(&mut self, game: GameAccess) {
        let cheese_lines = Self::cheese_lines(
            &self.config,
            &mut self.last_hole_pattern_generated,
            &mut self.cheese_generated,
            &mut game.state.rng,
        );

        const INIT_HEIGHT: usize = 10;
        game.state.board.resize(INIT_HEIGHT, Default::default());
        for ((line, _is_frozen), cheese) in game
            .state
            .board
            .iter_mut()
            .take(INIT_HEIGHT)
            .zip(cheese_lines)
        {
            *line = cheese;
        }
    }

    fn on_lock_post(&mut self, game: GameAccess, _feed: &mut NotificationFeed) {
        self.temp_last_clear_actual_cheese_lines = 0;

        // Check entire board.
        for (line, _is_frozen) in game.state.board.iter() {
            // Check if line is complete.
            if line.iter().all(|mino| mino.is_some()) {
                // Check if line is a cheese one.
                if line.contains(&Some(TileType::Generic)) {
                    // In theory would never underflow.
                    self.cheese_eaten += 1;
                    self.temp_last_clear_actual_cheese_lines += 1;
                }
            }
        }
    }

    fn on_lines_clear_post(&mut self, game: GameAccess, _feed: &mut NotificationFeed) {
        if let Some(limit) = self.config.limit
            && self.cheese_eaten >= limit.get()
        {
            *game.phase = Phase::GameEnd {
                cause: GameEndCause::Custom("All cheese devoured".to_owned()),
                is_win: true,
            };
            return;
        }

        let cheese_lines = Self::cheese_lines(
            &self.config,
            &mut self.last_hole_pattern_generated,
            &mut self.cheese_generated,
            &mut game.state.rng,
        );

        for cheese_line in cheese_lines.take(self.temp_last_clear_actual_cheese_lines) {
            game.state.board.insert(0, (cheese_line, false));
        }

        self.cached_display_values[0].1 = Self::fmt_cheese_eaten(self.cheese_eaten);
        self.cached_display_values[1].1 = Self::fmt_efficiency(
            self.cheese_eaten,
            game.state.pieces_locked.iter().sum::<u32>(),
        );
        // FIXME: Do not store this in points...
        game.state.points = self.cheese_eaten;
    }
}

impl Cheese {
    fn fmt_cheese_eaten(cheese_eaten: u32) -> String {
        format!("{}", cheese_eaten)
    }

    fn fmt_efficiency(cheese_eaten: u32, pieces: u32) -> String {
        if pieces == 0 {
            "-".to_owned()
        } else {
            format!(
                "{:.01}%",
                100.0 * f64::from(cheese_eaten) / f64::from(pieces)
            )
        }
    }

    pub(in crate::game_modding) fn cheese_lines<'a>(
        config: &'a CheeseConfig,
        last_hole_pattern_generated: &'a mut Vec<usize>,
        generated: &'a mut u32,
        rng: &'a mut GameRng,
    ) -> impl Iterator<Item = Line> + 'a {
        std::iter::from_fn(move || {
            config.limit.is_none_or(|l| *generated < l.get()).then(|| {
                *generated += 1;
                let mut line = Line::default();
                for tile in line
                    .iter_mut()
                    .take(BOARD_WIDTH.saturating_sub(config.holes_per_line.get()))
                {
                    *tile = Some(TileType::Generic);
                }
                // Currently completely random.
                loop {
                    line.shuffle(rng);
                    let hole_pattern_generated: Vec<usize> = line
                        .iter()
                        .enumerate()
                        .filter_map(|(i, x)| x.is_some().then_some(i))
                        .collect();
                    if !config.ensure_distinct_holes
                        || hole_pattern_generated != *last_hole_pattern_generated
                        || hole_pattern_generated.len() == line.len()
                    // If the lines we generate are wholly empty (and cannot possibly be different), give up.
                    {
                        *last_hole_pattern_generated = hole_pattern_generated;
                        break;
                    }
                }

                line
            })
        })
    }
}