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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Team-level type analysis.
//!
//! `typechart` answers a question about one species: what is *this* weak to?
//! A party of six asks a different one. What actually decides whether a team
//! holds together is not any single member's weaknesses but the overlap
//! between them:
//!
//! - an attacking type that hits **several** members hard is a hole an
//!   opponent will aim at, while one that hits a single member is just that
//!   member's problem;
//! - an attacking type **nobody** resists is a hit the team has to take on the
//!   chin every time;
//! - a defending type nobody hits back hard is a wall the team cannot break.
//!
//! All three fall straight out of the members' typings, so this stays as
//! offline and as instant as the single-species card.

use std::cmp::Reverse;

use crate::models::PokemonDetail;
use crate::typechart::{self, TYPES};

/// Largest party the analyser accepts, matching the games.
pub const MAX_MEMBERS: usize = 6;

/// How many members an attacking type must hit super-effectively before it
/// counts as a *shared* weakness rather than one member's own business.
const SHARED_WEAKNESS_MIN: usize = 2;

/// Abilities that grant an outright immunity to a whole damage type, and the
/// type each one answers.
///
/// Only true immunities are listed. Abilities that merely soften a type (Thick
/// Fat halving Fire and Ice, say) belong to the multipliers the chart already
/// knows nothing about, and abilities keyed to a *class* of move rather than a
/// type (Soundproof, Bulletproof) cannot be expressed as a type at all.
const ABILITY_IMMUNITIES: [(&str, &str); 11] = [
    ("levitate", "ground"),
    ("earth-eater", "ground"),
    ("flash-fire", "fire"),
    ("well-baked-body", "fire"),
    ("water-absorb", "water"),
    ("storm-drain", "water"),
    ("dry-skin", "water"),
    ("volt-absorb", "electric"),
    ("lightning-rod", "electric"),
    ("motor-drive", "electric"),
    ("sap-sipper", "grass"),
];

/// The type `ability` grants immunity to, if any.
fn immunity_from(ability: &str) -> Option<&'static str> {
    ABILITY_IMMUNITIES
        .iter()
        .find(|(slug, _)| *slug == ability)
        .map(|(_, immune_to)| *immune_to)
}

/// One attacking type, weighed against the whole team.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreatRow {
    pub attacker: &'static str,
    /// Members taking super-effective damage (×2 or ×4) from it.
    pub weak: usize,
}

/// An immunity a Pokemon owes to an ability rather than to its typing.
///
/// These are deliberately kept out of the team numbers above. A species has
/// one of its listed abilities, not all of them, so an immunity is only
/// guaranteed when there is nothing else it could have had — anything else
/// would make the chart claim a certainty the data does not support.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbilityImmunity {
    /// API name of the Pokemon that has it. A party member on the team card,
    /// the species the card is about on the single-species one.
    pub pokemon: String,
    /// API slug of the ability granting it.
    pub ability: String,
    pub immune_to: &'static str,
    /// True when the ability is the species' only one, so it cannot not have
    /// it. False when the species could have had a different ability instead.
    pub certain: bool,
}

/// What the team card reports. Every list is in canonical type order, and
/// empty lists are the good news: nothing to worry about in that category.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TeamAnalysis {
    /// Attacking types hitting at least [`SHARED_WEAKNESS_MIN`] members
    /// super-effectively, most members first.
    pub shared_weaknesses: Vec<ThreatRow>,
    /// Attacking types no member resists or is immune to.
    pub unresisted: Vec<&'static str>,
    /// Defending types no member hits super-effectively with a same-type move.
    pub offense_gaps: Vec<&'static str>,
    /// Immunities the members' abilities grant, which the type chart cannot
    /// see. Reported alongside the lists above rather than folded into them.
    pub ability_immunities: Vec<AbilityImmunity>,
}

/// The immunities `species` owes to its abilities, in the order PokeAPI lists
/// them. A species whose abilities grant none yields an empty list.
///
/// Both cards read the table through this one function on purpose. They used
/// to answer "what is this immune to?" separately, and only the team card knew
/// abilities existed, so the card that was *about* one species was the less
/// accurate of the two. One mapping is what keeps them from drifting apart
/// again.
pub fn ability_immunities(species: &PokemonDetail) -> Vec<AbilityImmunity> {
    // Certain only when the species has nowhere else to land: with one
    // possible ability it must have this one.
    let certain = species.abilities.len() == 1;
    species
        .abilities
        .iter()
        .filter_map(|ability| {
            Some(AbilityImmunity {
                pokemon: species.name.clone(),
                ability: ability.name.clone(),
                immune_to: immunity_from(&ability.name)?,
                certain,
            })
        })
        .collect()
}

/// Analyses a party. An empty team yields an empty analysis rather than
/// "weak to everything": with nothing to defend, there is nothing to report.
pub fn analyse(team: &[&PokemonDetail]) -> TeamAnalysis {
    if team.is_empty() {
        return TeamAnalysis::default();
    }

    let mut shared_weaknesses = Vec::new();
    let mut unresisted = Vec::new();

    for attacker in TYPES {
        let multipliers = team.iter().map(|m| typechart::combined(attacker, &m.types));

        let mut weak = 0;
        let mut resisted_by_anyone = false;
        for multiplier in multipliers {
            // The same thresholds `typechart` uses: every value the chart can
            // produce is an exact binary fraction, so these never sit near a
            // boundary.
            if multiplier > 1.5 {
                weak += 1;
            } else if multiplier < 0.9 {
                resisted_by_anyone = true;
            }
        }

        if weak >= SHARED_WEAKNESS_MIN {
            shared_weaknesses.push(ThreatRow { attacker, weak });
        }
        if !resisted_by_anyone {
            unresisted.push(attacker);
        }
    }

    // Stable, so types sharing a count keep canonical order.
    shared_weaknesses.sort_by_key(|row| Reverse(row.weak));

    let offense_gaps = TYPES
        .into_iter()
        .filter(|defender| {
            !team.iter().any(|member| {
                member
                    .types
                    .iter()
                    .any(|attacker| typechart::effectiveness(attacker, defender) > 1.5)
            })
        })
        .collect();

    // Every member's, in party order, through the same mapping the
    // single-species card reads. Spelled out as a path because the binding
    // below shares its name.
    let ability_immunities = team
        .iter()
        .copied()
        .flat_map(self::ability_immunities)
        .collect();

    TeamAnalysis {
        shared_weaknesses,
        unresisted,
        offense_gaps,
        ability_immunities,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{Ability, FieldData};
    use std::collections::HashMap;

    fn member(name: &str, types: &[&str]) -> PokemonDetail {
        with_abilities(name, types, &[])
    }

    fn with_abilities(name: &str, types: &[&str], abilities: &[&str]) -> PokemonDetail {
        PokemonDetail {
            name: name.to_string(),
            species: name.to_string(),
            forms: Vec::new(),
            dex_number: 0,
            is_legendary: false,
            is_mythical: false,
            is_baby: false,
            types: types.iter().map(|t| t.to_string()).collect(),
            abilities: abilities
                .iter()
                .map(|a| Ability {
                    name: a.to_string(),
                    is_hidden: false,
                })
                .collect(),
            stats: Vec::new(),
            height: 0,
            weight: 0,
            sprite_url: None,
            shiny_sprite_url: None,
            genera: HashMap::new(),
            flavors: HashMap::new(),
            moves: Vec::new(),
            learnset_games: None,
            field: FieldData::default(),
        }
    }

    fn weakness_count(analysis: &TeamAnalysis, attacker: &str) -> usize {
        analysis
            .shared_weaknesses
            .iter()
            .find(|row| row.attacker == attacker)
            .map_or(0, |row| row.weak)
    }

    #[test]
    fn an_empty_team_reports_nothing() {
        assert_eq!(analyse(&[]), TeamAnalysis::default());
    }

    #[test]
    fn one_members_weakness_is_not_shared() {
        let charizard = member("charizard", &["fire", "flying"]);
        let analysis = analyse(&[&charizard]);
        // Rock is ×4 on Charizard, but a party of one has nothing to share it
        // with, so it is that member's problem and not the team's.
        assert!(analysis.shared_weaknesses.is_empty());
    }

    #[test]
    fn a_weakness_two_members_share_is_reported() {
        // Both are hit ×2 by Electric and neither resists it.
        let gyarados = member("gyarados", &["water", "flying"]);
        let pelipper = member("pelipper", &["water", "flying"]);
        let analysis = analyse(&[&gyarados, &pelipper]);

        assert_eq!(weakness_count(&analysis, "electric"), 2);
        assert!(analysis.unresisted.contains(&"electric"));
    }

    #[test]
    fn the_worst_shared_weakness_comes_first() {
        // Three Water/Flying bodies: Electric hits all three, Rock only ×2 on
        // each of them as well — but Electric is ×4, and more to the point
        // every member is weak to both, so ordering falls back to the count.
        let team: Vec<PokemonDetail> = ["gyarados", "pelipper", "mantine"]
            .iter()
            .map(|n| member(n, &["water", "flying"]))
            .collect();
        let refs: Vec<&PokemonDetail> = team.iter().collect();
        let analysis = analyse(&refs);

        let counts: Vec<usize> = analysis.shared_weaknesses.iter().map(|r| r.weak).collect();
        let mut sorted = counts.clone();
        sorted.sort_by(|a, b| b.cmp(a));
        assert_eq!(counts, sorted, "rows must be ordered worst-first");
        assert_eq!(weakness_count(&analysis, "electric"), 3);
    }

    #[test]
    fn a_type_someone_resists_is_not_unresisted() {
        let magnezone = member("magnezone", &["electric", "steel"]);
        let gyarados = member("gyarados", &["water", "flying"]);
        let analysis = analyse(&[&magnezone, &gyarados]);

        // Magnezone resists Electric (Steel ×½ · Electric ×½), so the team has
        // an answer to it even though Gyarados is weak.
        assert!(!analysis.unresisted.contains(&"electric"));
        // And with only one member weak to it, it is not a *shared* weakness.
        assert_eq!(weakness_count(&analysis, "electric"), 0);
    }

    #[test]
    fn offense_gaps_are_what_nobody_hits_hard() {
        let charizard = member("charizard", &["fire", "flying"]);
        let analysis = analyse(&[&charizard]);

        // Fire hits Grass/Ice/Bug/Steel; Flying hits Grass/Fighting/Bug.
        for covered in ["grass", "ice", "bug", "steel", "fighting"] {
            assert!(
                !analysis.offense_gaps.contains(&covered),
                "{covered} is covered"
            );
        }
        // Nothing it carries is strong against Water or Dragon.
        assert!(analysis.offense_gaps.contains(&"water"));
        assert!(analysis.offense_gaps.contains(&"dragon"));
    }

    #[test]
    fn a_sole_ability_grants_a_certain_immunity() {
        // Rotom has nothing but Levitate, so the Ground immunity is a fact.
        let rotom = with_abilities("rotom", &["electric", "ghost"], &["levitate"]);
        let analysis = analyse(&[&rotom]);

        assert_eq!(
            analysis.ability_immunities,
            vec![AbilityImmunity {
                pokemon: "rotom".to_string(),
                ability: "levitate".to_string(),
                immune_to: "ground",
                certain: true,
            }]
        );
    }

    #[test]
    fn one_of_several_abilities_is_only_a_possibility() {
        // Vaporeon can have Water Absorb — or Hydration instead.
        let vaporeon = with_abilities("vaporeon", &["water"], &["water-absorb", "hydration"]);
        let analysis = analyse(&[&vaporeon]);

        assert_eq!(analysis.ability_immunities.len(), 1);
        assert_eq!(analysis.ability_immunities[0].immune_to, "water");
        assert!(!analysis.ability_immunities[0].certain);
    }

    #[test]
    fn abilities_that_grant_no_immunity_are_ignored() {
        let bulbasaur = with_abilities("bulbasaur", &["grass"], &["overgrow", "chlorophyll"]);
        let analysis = analyse(&[&bulbasaur]);
        assert!(analysis.ability_immunities.is_empty());
    }

    #[test]
    fn an_ability_immunity_does_not_touch_the_type_numbers() {
        // Levitate answers Ground, but the chart-level lists stay chart-level:
        // Gengar's typing alone still has no Ground resistance to report.
        let levitator = with_abilities("gengar", &["ghost", "poison"], &["levitate"]);
        let analysis = analyse(&[&levitator]);

        assert!(analysis.unresisted.contains(&"ground"));
        assert_eq!(analysis.ability_immunities[0].immune_to, "ground");
    }

    #[test]
    fn both_cards_read_the_same_immunities() {
        // The regression this guards: the single-species card once answered
        // from the type chart alone and disagreed with the party card about
        // the very same Rotom.
        let rotom = with_abilities("rotom", &["electric", "ghost"], &["levitate"]);
        assert_eq!(
            ability_immunities(&rotom),
            analyse(&[&rotom]).ability_immunities
        );
    }

    #[test]
    fn a_certain_immunity_answers_the_chart_on_the_single_species_card() {
        // What the `T` card draws: the certain immunities, folded into the
        // rows, leave nothing claiming Ground still lands on Rotom.
        let rotom = with_abilities("rotom", &["electric", "ghost"], &["levitate"]);
        let certain: Vec<&str> = ability_immunities(&rotom)
            .iter()
            .filter(|immunity| immunity.certain)
            .map(|immunity| immunity.immune_to)
            .collect();
        let groups = typechart::defensive_groups(&rotom.types, &certain);

        for group in &groups {
            assert_eq!(
                group.types.contains(&"ground"),
                group.label == "×0",
                "ground belongs in ×0 and nowhere else"
            );
        }
    }

    #[test]
    fn a_possible_immunity_is_left_out_of_the_rows() {
        // Vaporeon might have Hydration instead, so the chart keeps its say:
        // Water stays where the typing puts it, and the card annotates it.
        let vaporeon = with_abilities("vaporeon", &["water"], &["water-absorb", "hydration"]);
        let certain: Vec<&str> = ability_immunities(&vaporeon)
            .iter()
            .filter(|immunity| immunity.certain)
            .map(|immunity| immunity.immune_to)
            .collect();

        assert!(certain.is_empty());
        assert_eq!(
            typechart::defensive_groups(&vaporeon.types, &certain)
                .iter()
                .map(|g| (g.label, g.types.clone()))
                .collect::<Vec<_>>(),
            typechart::defensive_groups(&vaporeon.types, &[])
                .iter()
                .map(|g| (g.label, g.types.clone()))
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn a_broad_team_closes_most_gaps() {
        let team = [
            member("charizard", &["fire", "flying"]),
            member("gyarados", &["water", "flying"]),
            member("magnezone", &["electric", "steel"]),
            member("gengar", &["ghost", "poison"]),
            member("garchomp", &["dragon", "ground"]),
            member("machamp", &["fighting"]),
        ];
        let refs: Vec<&PokemonDetail> = team.iter().collect();
        let analysis = analyse(&refs);

        assert_eq!(refs.len(), MAX_MEMBERS);
        // Six well-spread typings should leave few types untouched offensively.
        assert!(
            analysis.offense_gaps.len() <= 3,
            "unexpected gaps: {:?}",
            analysis.offense_gaps
        );
    }
}