pokeductor 0.5.0

A terminal Pokedex and evolution analyzer with sprite rendering, offline type and party analysis, and an on-disk cache for offline use
//! Static type-effectiveness chart (Generation VI onwards).
//!
//! This is pure, offline data: no PokeAPI round-trip is needed to answer "what
//! is this Pokemon weak to?", so the matchup card opens instantly for any
//! species whose types are already known.
//!
//! The chart is expressed from the *attacker's* point of view — [`effectiveness`]
//! answers "how much damage does a move of type A deal to a Pokemon of type B?"
//! — and the defensive view is derived by multiplying across the defender's
//! types, exactly as the games do.

/// Every type slug, in the canonical Pokedex order PokeAPI uses.
pub const TYPES: [&str; 18] = [
    "normal", "fire", "water", "electric", "grass", "ice", "fighting", "poison", "ground",
    "flying", "psychic", "bug", "rock", "ghost", "dragon", "dark", "steel", "fairy",
];

/// Damage multiplier for a move of type `attacker` hitting a *single* type
/// `defender`. Only the non-neutral pairings are listed; everything else is 1×.
pub fn effectiveness(attacker: &str, defender: &str) -> f32 {
    match (attacker, defender) {
        ("normal", "rock" | "steel") => 0.5,
        ("normal", "ghost") => 0.0,

        ("fire", "fire" | "water" | "rock" | "dragon") => 0.5,
        ("fire", "grass" | "ice" | "bug" | "steel") => 2.0,

        ("water", "water" | "grass" | "dragon") => 0.5,
        ("water", "fire" | "ground" | "rock") => 2.0,

        ("electric", "electric" | "grass" | "dragon") => 0.5,
        ("electric", "ground") => 0.0,
        ("electric", "water" | "flying") => 2.0,

        ("grass", "fire" | "grass" | "poison" | "flying" | "bug" | "dragon" | "steel") => 0.5,
        ("grass", "water" | "ground" | "rock") => 2.0,

        ("ice", "fire" | "water" | "ice" | "steel") => 0.5,
        ("ice", "grass" | "ground" | "flying" | "dragon") => 2.0,

        ("fighting", "poison" | "flying" | "psychic" | "bug" | "fairy") => 0.5,
        ("fighting", "ghost") => 0.0,
        ("fighting", "normal" | "ice" | "rock" | "dark" | "steel") => 2.0,

        ("poison", "poison" | "ground" | "rock" | "ghost") => 0.5,
        ("poison", "steel") => 0.0,
        ("poison", "grass" | "fairy") => 2.0,

        ("ground", "grass" | "bug") => 0.5,
        ("ground", "flying") => 0.0,
        ("ground", "fire" | "electric" | "poison" | "rock" | "steel") => 2.0,

        ("flying", "electric" | "rock" | "steel") => 0.5,
        ("flying", "grass" | "fighting" | "bug") => 2.0,

        ("psychic", "psychic" | "steel") => 0.5,
        ("psychic", "dark") => 0.0,
        ("psychic", "fighting" | "poison") => 2.0,

        ("bug", "fire" | "fighting" | "poison" | "flying" | "ghost" | "steel" | "fairy") => 0.5,
        ("bug", "grass" | "psychic" | "dark") => 2.0,

        ("rock", "fighting" | "ground" | "steel") => 0.5,
        ("rock", "fire" | "ice" | "flying" | "bug") => 2.0,

        ("ghost", "dark") => 0.5,
        ("ghost", "normal") => 0.0,
        ("ghost", "psychic" | "ghost") => 2.0,

        ("dragon", "steel") => 0.5,
        ("dragon", "fairy") => 0.0,
        ("dragon", "dragon") => 2.0,

        ("dark", "fighting" | "dark" | "fairy") => 0.5,
        ("dark", "psychic" | "ghost") => 2.0,

        ("steel", "fire" | "water" | "electric" | "steel") => 0.5,
        ("steel", "ice" | "rock" | "fairy") => 2.0,

        ("fairy", "fire" | "poison" | "steel") => 0.5,
        ("fairy", "fighting" | "dragon" | "dark") => 2.0,

        _ => 1.0,
    }
}

/// Multiplier a move of type `attacker` deals to a Pokemon carrying
/// `defender_types` — the product of the per-type multipliers, so a dual type
/// can land anywhere from 0× to 4×.
pub fn combined(attacker: &str, defender_types: &[String]) -> f32 {
    defender_types
        .iter()
        .map(|d| effectiveness(attacker, d))
        .product()
}

/// One row of the matchup card: every attacking type that lands on the same
/// multiplier, e.g. `×4 → [rock]`.
#[derive(Debug, Clone)]
pub struct MatchupGroup {
    /// Display label for the multiplier, e.g. `"×½"`. Language-neutral.
    pub label: &'static str,
    /// Attacking types that hit for this multiplier, in canonical order.
    pub types: Vec<&'static str>,
}

/// Multipliers a dual type can produce, in the order the card lists them:
/// worst news for the defender first.
const BUCKETS: [(f32, &str); 5] = [
    (4.0, "×4"),
    (2.0, "×2"),
    (0.5, "×½"),
    (0.25, "×¼"),
    (0.0, "×0"),
];

/// Groups every attacking type by how much damage it deals to a Pokemon with
/// `defender_types`. Neutral (1×) matchups are omitted — they are the default
/// and listing them would bury the interesting rows. Empty groups are dropped,
/// so the caller can render the result directly.
///
/// Every type in `immune_to` lands in the ×0 row whatever the chart makes of
/// it. The chart knows typings and nothing else, and an ability can overrule
/// it outright: Ground is ×2 on an Electric/Ghost body and 0× on Rotom, which
/// carries that body and Levitate. Taking the overrides here rather than
/// letting callers patch the rows afterwards is what guarantees a type moved
/// into ×0 also leaves the row it came from, instead of being listed twice
/// with two different answers.
///
/// Only immunities the Pokemon certainly has belong in `immune_to`. One it
/// might not have is not a multiplier at all, and saying so is the caller's
/// job.
pub fn defensive_groups(defender_types: &[String], immune_to: &[&str]) -> Vec<MatchupGroup> {
    BUCKETS
        .iter()
        .filter_map(|&(multiplier, label)| {
            let types: Vec<&'static str> = TYPES
                .iter()
                .copied()
                .filter(|attacker| match immune_to.contains(attacker) {
                    true => same_multiplier(multiplier, 0.0),
                    false => same_multiplier(combined(attacker, defender_types), multiplier),
                })
                .collect();
            (!types.is_empty()).then_some(MatchupGroup { label, types })
        })
        .collect()
}

/// Display label for a multiplier, e.g. `"×½"` — the same wording the matchup
/// card's rows carry, and language-neutral like them.
///
/// Neutral is spelled out here, unlike in [`defensive_groups`], which omits it:
/// a group of neutral matchups is the default and says nothing, but a
/// head-to-head reports the number its two species land on whatever it is.
pub fn multiplier_label(multiplier: f32) -> &'static str {
    BUCKETS
        .iter()
        .find(|&&(value, _)| same_multiplier(multiplier, value))
        .map_or("×1", |&(_, label)| label)
}

/// Types this Pokemon hits for super-effective damage with a same-type move —
/// the union over its own types, since it can carry a move of each.
pub fn offensive_coverage(attacker_types: &[String]) -> Vec<&'static str> {
    TYPES
        .iter()
        .copied()
        .filter(|defender| {
            attacker_types
                .iter()
                .any(|attacker| effectiveness(attacker, defender) > 1.5)
        })
        .collect()
}

/// Compares two multipliers. Every value the chart can produce is an exact
/// binary fraction, so the comparison is safe, but a tolerance keeps it robust
/// if the bucket list ever grows a value that isn't.
fn same_multiplier(a: f32, b: f32) -> bool {
    (a - b).abs() < f32::EPSILON
}

#[cfg(test)]
mod tests {
    use super::*;

    fn types(list: &[&str]) -> Vec<String> {
        list.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn chart_is_symmetric_where_the_games_are() {
        // Spot-checks of the classic starter triangle.
        assert_eq!(effectiveness("water", "fire"), 2.0);
        assert_eq!(effectiveness("fire", "grass"), 2.0);
        assert_eq!(effectiveness("grass", "water"), 2.0);
        assert_eq!(effectiveness("grass", "fire"), 0.5);
    }

    #[test]
    fn immunities_are_zero() {
        assert_eq!(effectiveness("normal", "ghost"), 0.0);
        assert_eq!(effectiveness("ghost", "normal"), 0.0);
        assert_eq!(effectiveness("electric", "ground"), 0.0);
        assert_eq!(effectiveness("dragon", "fairy"), 0.0);
        assert_eq!(effectiveness("poison", "steel"), 0.0);
    }

    #[test]
    fn unlisted_pairings_are_neutral() {
        assert_eq!(effectiveness("normal", "normal"), 1.0);
        assert_eq!(effectiveness("water", "steel"), 1.0);
    }

    #[test]
    fn dual_types_stack_multiplicatively() {
        // Charizard (fire/flying): rock hits both halves for 2× → 4×.
        assert_eq!(combined("rock", &types(&["fire", "flying"])), 4.0);
        // Grass is resisted by fire *and* flying → ¼×.
        assert_eq!(combined("grass", &types(&["fire", "flying"])), 0.25);
        // Ground is 2× on fire but 0× on flying → immune overall.
        assert_eq!(combined("ground", &types(&["fire", "flying"])), 0.0);
    }

    #[test]
    fn defensive_groups_cover_charizard() {
        let groups = defensive_groups(&types(&["fire", "flying"]), &[]);
        let find = |label: &str| {
            groups
                .iter()
                .find(|g| g.label == label)
                .map(|g| g.types.clone())
                .unwrap_or_default()
        };
        assert_eq!(find("×4"), vec!["rock"]);
        assert_eq!(find("×2"), vec!["water", "electric"]);
        assert_eq!(find("×¼"), vec!["grass", "bug"]);
        assert_eq!(find("×0"), vec!["ground"]);
    }

    #[test]
    fn a_forced_immunity_moves_its_type_out_of_the_row_it_was_in() {
        // Rotom's Electric/Ghost body takes ×2 from Ground. Rotom does not.
        let rotom = types(&["electric", "ghost"]);
        let groups = defensive_groups(&rotom, &["ground"]);
        let find = |label: &str| {
            groups
                .iter()
                .find(|g| g.label == label)
                .map(|g| g.types.clone())
                .unwrap_or_default()
        };

        assert!(find("×0").contains(&"ground"));
        assert!(
            !find("×2").contains(&"ground"),
            "a type cannot be both ×2 and immune"
        );
        // The rest of the card is the chart's business and stays untouched.
        assert_eq!(find("×2"), vec!["ghost", "dark"]);
        assert!(find("×0").contains(&"normal"), "ghost still ignores normal");
    }

    #[test]
    fn a_forced_immunity_the_chart_already_knew_is_not_listed_twice() {
        // Flying is immune to Ground on its own; Levitate on top changes
        // nothing, least of all the number of rows Ground appears in.
        let groups = defensive_groups(&types(&["flying"]), &["ground"]);
        let ground_rows: usize = groups
            .iter()
            .filter(|g| g.types.contains(&"ground"))
            .count();
        assert_eq!(ground_rows, 1);
    }

    #[test]
    fn every_multiplier_the_chart_can_produce_has_a_label() {
        assert_eq!(multiplier_label(4.0), "×4");
        assert_eq!(multiplier_label(2.0), "×2");
        assert_eq!(multiplier_label(1.0), "×1");
        assert_eq!(multiplier_label(0.5), "×½");
        assert_eq!(multiplier_label(0.25), "×¼");
        assert_eq!(multiplier_label(0.0), "×0");
    }

    #[test]
    fn neutral_matchups_are_omitted() {
        let groups = defensive_groups(&types(&["normal"]), &[]);
        let listed: usize = groups.iter().map(|g| g.types.len()).sum();
        // Normal only cares about fighting (2×) and ghost (0×).
        assert_eq!(listed, 2);
    }

    #[test]
    fn offensive_coverage_is_the_union_of_both_types() {
        let coverage = offensive_coverage(&types(&["fire", "flying"]));
        assert!(coverage.contains(&"grass")); // 2× from both
        assert!(coverage.contains(&"steel")); // 2× from fire only
        assert!(coverage.contains(&"fighting")); // 2× from flying only
        assert!(!coverage.contains(&"water"));
    }
}