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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
//! Core domain layer: clean, API-agnostic data structures.
//!
//! Nothing in this module knows about PokeAPI's JSON wire format; the API
//! client (see `api.rs`) is responsible for translating raw responses into
//! these types. This keeps the rest of the application decoupled from the
//! quirks of the upstream service.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// A single entry in the master Pokemon list shown in the sidebar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PokemonEntry {
    /// API identifier, e.g. `"pikachu"` (lowercase, possibly hyphenated).
    pub name: String,
    /// PokeAPI's numeric id, read straight out of the list response's URL so
    /// the sidebar can show a dex number without fetching anything. For the
    /// default form of a species this *is* its National Pokedex number;
    /// alternate forms (Alolan Raichu and friends) are numbered from 10001 up.
    pub id: u32,
}

impl PokemonEntry {
    /// The National Pokedex number to display, or `None` for an alternate form
    /// whose id carries no dex meaning.
    pub fn dex_number(&self) -> Option<u32> {
        (self.id <= MAX_DEX_NUMBER).then_some(self.id)
    }

    /// Which generation introduced this species, derived from its dex number.
    ///
    /// Alternate forms return `None`: their ids sit in a separate range that
    /// says nothing about the species they belong to, and resolving that would
    /// cost a request per form.
    pub fn generation(&self) -> Option<u8> {
        let dex = self.dex_number()?;
        GENERATION_RANGES
            .iter()
            .position(|&last| dex <= last)
            .map(|idx| idx as u8 + 1)
    }
}

/// A property of a species that the master list cannot answer on its own.
///
/// The list response carries a name and an id and nothing else, so a filter
/// like `type:ghost` has to be resolved against a *roster*: the membership
/// list an endpoint returns when asked which species carry that property. One
/// request answers a whole roster, and rosters only ever grow with a new
/// generation, so each is fetched at most once per install.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RosterKind {
    /// `type:ghost` — every Pokemon of that type.
    Type,
    /// `ability:levitate` — every Pokemon that can have that ability, hidden
    /// slots included.
    Ability,
    /// `egg:dragon` — every species in that breeding group.
    EggGroup,
}

/// One roster filter: a kind and the value asked of it.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RosterTerm {
    pub kind: RosterKind,
    /// The API slug, lowercased — `"ghost"`, `"levitate"`, `"monster"`.
    pub value: String,
}

impl RosterTerm {
    pub fn new(kind: RosterKind, value: impl Into<String>) -> Self {
        Self {
            kind,
            value: value.into(),
        }
    }
}

/// Highest National Pokedex number PokeAPI currently carries.
const MAX_DEX_NUMBER: u32 = 1025;

/// Last dex number of each generation, in order. These boundaries are fixed
/// history — a released generation never gains or loses species — so deriving
/// the generation locally beats spending a request on it.
const GENERATION_RANGES: [u32; 9] = [151, 251, 386, 493, 649, 721, 809, 905, MAX_DEX_NUMBER];

/// The six canonical base stats every Pokemon has.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StatKind {
    Hp,
    Attack,
    Defense,
    SpecialAttack,
    SpecialDefense,
    Speed,
}

impl StatKind {
    /// Maps PokeAPI's stat slug (`"special-attack"`, etc.) to a [`StatKind`].
    pub fn from_api(slug: &str) -> Option<Self> {
        match slug {
            "hp" => Some(Self::Hp),
            "attack" => Some(Self::Attack),
            "defense" => Some(Self::Defense),
            "special-attack" => Some(Self::SpecialAttack),
            "special-defense" => Some(Self::SpecialDefense),
            "speed" => Some(Self::Speed),
            _ => None,
        }
    }

    /// Stable display order so stats always render top-to-bottom consistently.
    pub fn order(&self) -> u8 {
        match self {
            Self::Hp => 0,
            Self::Attack => 1,
            Self::Defense => 2,
            Self::SpecialAttack => 3,
            Self::SpecialDefense => 4,
            Self::Speed => 5,
        }
    }
}

/// A single base stat value (0..=255 in practice).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Stat {
    pub kind: StatKind,
    pub base: u16,
}

/// One of a species' possible abilities.
///
/// A Pokemon lists every ability it *can* have; in a given game it carries
/// exactly one of them. The hidden one is only obtainable by special means,
/// which is why it is worth flagging separately.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ability {
    /// API slug, e.g. `"cursed-body"`.
    pub name: String,
    pub is_hidden: bool,
}

/// The localized text for one ability, fetched on demand from its own endpoint.
///
/// Mirrors the shape of [`PokemonDetail`]'s genus/flavor maps: keyed by PokeAPI
/// language code, with English as the universal fallback.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbilityInfo {
    /// API slug, matching [`Ability::name`].
    pub name: String,
    /// Display name per language, e.g. `de -> "Schwebe"`.
    pub names: HashMap<String, String>,
    /// Short description per language.
    pub flavors: HashMap<String, String>,
}

impl AbilityInfo {
    /// Display name in the requested language, falling back to English and
    /// then to a title-cased slug.
    pub fn name_for(&self, code: &str) -> String {
        self.names
            .get(code)
            .or_else(|| self.names.get("en"))
            .cloned()
            .unwrap_or_else(|| title_case(&self.name))
    }

    /// Description in the requested language, falling back to English.
    pub fn flavor_for(&self, code: &str) -> Option<&str> {
        self.flavors
            .get(code)
            .or_else(|| self.flavors.get("en"))
            .map(String::as_str)
    }
}

/// Which palette of a species' front artwork to show.
///
/// This is app-wide state rather than a property of a species: the shiny toggle
/// stays on while the user moves through the list, so a whole evolution chain
/// can be inspected in its shiny colours.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum SpriteVariant {
    #[default]
    Normal,
    Shiny,
}

impl SpriteVariant {
    /// The other palette, for the toggle key.
    pub fn toggled(self) -> Self {
        match self {
            Self::Normal => Self::Shiny,
            Self::Shiny => Self::Normal,
        }
    }

    pub fn is_shiny(self) -> bool {
        matches!(self, Self::Shiny)
    }

    /// Filename infix that keeps a shiny PNG from overwriting the normal one in
    /// the on-disk cache. Empty for [`Normal`](Self::Normal), so sprites cached
    /// before shinies existed stay valid.
    pub fn file_suffix(self) -> &'static str {
        match self {
            Self::Normal => "",
            Self::Shiny => ".shiny",
        }
    }
}

/// Fully resolved details for one Pokemon, ready to render.
/// How a Pokemon comes to know a move, in the games this app reports on.
///
/// PokeAPI names a few more — `form-change`, `train`, the transfer-only ones —
/// which say nothing about a learnset and are dropped on the way in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LearnMethod {
    /// Learned on levelling up, at [`LearnedMove::level`].
    LevelUp,
    /// Taught from a TM/HM.
    Machine,
    /// Inherited by breeding.
    Egg,
    /// Taught by a move tutor.
    Tutor,
}

impl LearnMethod {
    pub fn from_api(slug: &str) -> Option<Self> {
        match slug {
            "level-up" => Some(LearnMethod::LevelUp),
            "machine" => Some(LearnMethod::Machine),
            "egg" => Some(LearnMethod::Egg),
            "tutor" => Some(LearnMethod::Tutor),
            _ => None,
        }
    }

    /// Order the methods are grouped in on the moves card: the level-up set
    /// first, because it is the one that describes the species rather than the
    /// player's bag.
    pub fn order(self) -> u8 {
        match self {
            LearnMethod::LevelUp => 0,
            LearnMethod::Egg => 1,
            LearnMethod::Machine => 2,
            LearnMethod::Tutor => 3,
        }
    }
}

/// One move in a species' learnset, as the species record carries it: which
/// move, how it is learned, and at what level when that is the answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LearnedMove {
    /// API slug, e.g. `"shadow-ball"`.
    pub name: String,
    pub method: LearnMethod,
    /// Level it is learned at, for [`LearnMethod::LevelUp`] only. Zero
    /// elsewhere — and for the moves a species starts out knowing, which the
    /// games and PokeAPI both record as level zero.
    pub level: u32,
}

/// A move's own record, fetched per move rather than per species: a learnset
/// runs to eighty entries or more, and asking for all of them to open a card
/// nobody may scroll would cost eighty requests.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveInfo {
    /// API slug, matching [`LearnedMove::name`].
    pub name: String,
    /// Display name per language, e.g. `de -> "Spukball"`.
    pub names: HashMap<String, String>,
    /// Short description per language.
    pub flavors: HashMap<String, String>,
    /// Elemental type, lowercased — the same vocabulary as
    /// [`PokemonDetail::types`], so the type palette applies unchanged.
    pub type_name: String,
    /// `"physical"`, `"special"` or `"status"`.
    pub damage_class: String,
    /// Absent for status moves, and for the few whose power is situational.
    pub power: Option<u16>,
    /// Absent for the moves that cannot miss.
    pub accuracy: Option<u16>,
    pub pp: Option<u16>,
}

impl MoveInfo {
    /// Display name in the requested language, falling back to English and
    /// then to a title-cased slug.
    pub fn name_for(&self, code: &str) -> String {
        self.names
            .get(code)
            .or_else(|| self.names.get("en"))
            .cloned()
            .unwrap_or_else(|| title_case(&self.name))
    }

    /// Description in the requested language, falling back to English.
    pub fn flavor_for(&self, code: &str) -> Option<&str> {
        self.flavors
            .get(code)
            .or_else(|| self.flavors.get("en"))
            .map(String::as_str)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PokemonDetail {
    /// Raw API name (lowercase). Use [`title_case`] for display.
    pub name: String,
    /// Base species slug, which can differ from `name` for alternate forms
    /// (e.g. `name = "raichu-alola"` but `species = "raichu"`). This is the key
    /// the species and evolution endpoints expect.
    pub species: String,
    /// Every variety the species ships as, in PokeAPI's order and under the
    /// names `/pokemon` files them by (`raichu`, `raichu-alola`) — so a form
    /// listed here is also an entry in the master list, and can be shown by
    /// name. A species with no alternate form lists only itself.
    pub forms: Vec<String>,
    /// National Pokedex number (from the species record), which is stable across
    /// a species' alternate forms — unlike [`id`](Self::id).
    pub dex_number: u32,
    /// Whether the species is flagged Legendary / Mythical / a baby Pokemon.
    pub is_legendary: bool,
    pub is_mythical: bool,
    pub is_baby: bool,
    pub types: Vec<String>,
    /// Every ability the species can have, in PokeAPI slot order.
    pub abilities: Vec<Ability>,
    pub stats: Vec<Stat>,
    /// Height in decimetres, as returned by the API.
    pub height: u32,
    /// Weight in hectograms, as returned by the API.
    pub weight: u32,
    /// URL of the front-facing PNG artwork, if the species has one.
    pub sprite_url: Option<String>,
    /// URL of the same artwork in the shiny palette. Absent for the handful of
    /// species PokeAPI ships no shiny sprite for.
    pub shiny_sprite_url: Option<String>,
    /// Pokedex genus (e.g. `"Seed Pokémon"`) keyed by PokeAPI language code.
    pub genera: HashMap<String, String>,
    /// Pokedex flavor-text blurbs, cleaned of control characters, keyed by
    /// PokeAPI language code.
    pub flavors: HashMap<String, String>,
    /// The learnset from the newest games this species appears in, grouped by
    /// method and then in reading order within each group. Carried on the
    /// species record because `/pokemon/{name}` already answers with it — the
    /// moves card costs no request of its own to open.
    pub moves: Vec<LearnedMove>,
    /// Which games [`moves`](Self::moves) is the learnset from, as PokeAPI's
    /// version-group slug (`"scarlet-violet"`). Shown on the card, because a
    /// learnset means little without knowing which games it belongs to.
    pub learnset_games: Option<String>,
    /// Breeding and field data, from the species record.
    pub field: FieldData,
}

/// The "field guide" half of a Pokedex entry: how a species breeds, how
/// readily it is caught, and where it lives. All of it rides along on the
/// species record the bundle already fetches, so none of it costs a request.
///
/// The numbers are kept as PokeAPI sends them and read through the methods
/// below, so the conversions — eighths of a gender ratio into percentages,
/// a catch rate into a word — live in one place and are tested there.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldData {
    /// Breeding groups as PokeAPI slugs (`plant`, `water1`). Shown through
    /// [`egg_group_label`] so they read by their in-game names.
    pub egg_groups: Vec<String>,
    /// 0-255, where higher is caught more easily. Pidgey is 255, most
    /// starters are 45, and the Legendary birds are 3.
    pub capture_rate: u8,
    /// Friendship at capture. `None` for the few species PokeAPI records no
    /// value for.
    pub base_happiness: Option<u8>,
    /// The experience curve, as PokeAPI's slug (`medium-slow`). Readable
    /// enough title-cased that it costs no request to name.
    pub growth_rate: Option<String>,
    /// Chance of being female, in eighths, with `-1` meaning genderless.
    /// Read through [`gender_split`](Self::gender_split), which is the only
    /// place the sentinel has to be known.
    pub gender_rate: i8,
    /// Where the species is found in the wild, as a slug (`mountain`). Only
    /// recorded up to Generation IV, so `None` for everything after it.
    pub habitat: Option<String>,
}

/// Where a species sits on the catch-rate scale, in words the card can show
/// next to a number that says nothing on its own about which way it runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatchEase {
    /// Up to 45: starters, pseudo-legendaries and everything rarer.
    Hard,
    Average,
    /// 150 and up: early-route species, caught with a plain Poke Ball.
    Easy,
}

impl FieldData {
    /// Male and female percentages, or `None` for a genderless species.
    ///
    /// PokeAPI counts in eighths, so the split is always a multiple of 12.5
    /// and the sum is always exactly 100.
    pub fn gender_split(&self) -> Option<(f32, f32)> {
        match self.gender_rate {
            rate @ 0..=8 => {
                let female = f32::from(rate) * 12.5;
                Some((100.0 - female, female))
            }
            _ => None,
        }
    }

    /// The catch rate in words, for the card to say beside the number.
    pub fn catch_ease(&self) -> CatchEase {
        match self.capture_rate {
            0..=45 => CatchEase::Hard,
            46..=149 => CatchEase::Average,
            _ => CatchEase::Easy,
        }
    }
}

/// The in-game name of a breeding group, from the slug PokeAPI files it
/// under. The inverse of the alias table the `egg:` search term goes
/// through: the Grass group is `plant` on the wire, Field is `ground`, and a
/// card saying "Ground" about a Grass type is misinformation rather than a
/// spelling. Slugs outside the table are title-cased.
pub fn egg_group_label(slug: &str) -> String {
    match slug {
        "plant" => "Grass".to_string(),
        "ground" => "Field".to_string(),
        "humanshape" => "Human-Like".to_string(),
        "indeterminate" => "Amorphous".to_string(),
        "water1" => "Water 1".to_string(),
        "water2" => "Water 2".to_string(),
        "water3" => "Water 3".to_string(),
        "no-eggs" => "Undiscovered".to_string(),
        other => title_case(other),
    }
}

impl PokemonDetail {
    /// Sum of all base stats — a common "power level" heuristic.
    pub fn stat_total(&self) -> u32 {
        self.stats.iter().map(|s| s.base as u32).sum()
    }

    /// Artwork URL in the requested palette, falling back to the normal one for
    /// a species with no shiny art — a familiar sprite beats an empty card.
    pub fn sprite_url_for(&self, variant: SpriteVariant) -> Option<&str> {
        match variant {
            SpriteVariant::Normal => self.sprite_url.as_deref(),
            SpriteVariant::Shiny => self
                .shiny_sprite_url
                .as_deref()
                .or(self.sprite_url.as_deref()),
        }
    }

    /// Genus in the requested language, falling back to English when that
    /// language has no entry (PokeAPI has no Turkish text, for instance).
    pub fn genus_for(&self, code: &str) -> Option<&str> {
        self.genera
            .get(code)
            .or_else(|| self.genera.get("en"))
            .map(String::as_str)
    }

    /// The species' *other* varieties: everything in [`forms`](Self::forms)
    /// that is not this record. Empty for a species with a single form, which
    /// is what the card checks before giving the row its line.
    pub fn other_forms(&self) -> Vec<&str> {
        self.forms
            .iter()
            .map(String::as_str)
            .filter(|form| *form != self.name)
            .collect()
    }
}

/// A form's name with the species it belongs to taken off the front:
/// `raichu-alola` under `raichu` reads as "Alola", which is the half of the
/// name a row sitting beside Raichu is about.
///
/// The default variety usually *is* the species name, leaving nothing to show,
/// and a few forms are named without the prefix at all. Both keep the whole
/// name title-cased, since a blank is not a label.
pub fn form_label(form: &str, species: &str) -> String {
    form.strip_prefix(species)
        .and_then(|rest| rest.strip_prefix('-'))
        .filter(|rest| !rest.is_empty())
        .map(title_case)
        .unwrap_or_else(|| title_case(form))
}

/// What sets an evolution in motion. PokeAPI has a long tail of one-off
/// triggers (spin, three-critical-hits, ...), so anything beyond the four
/// common ones is carried through verbatim and displayed as-is.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvolutionTrigger {
    LevelUp,
    Trade,
    UseItem,
    Shed,
    Other(String),
}

impl EvolutionTrigger {
    pub fn from_api(slug: &str) -> Self {
        match slug {
            "level-up" => Self::LevelUp,
            "trade" => Self::Trade,
            "use-item" => Self::UseItem,
            "shed" => Self::Shed,
            other => Self::Other(other.to_string()),
        }
    }
}

/// The requirements for one species to evolve into another.
///
/// Every field is optional because the games layer conditions freely: Umbreon
/// needs happiness *and* night, Milotic needs beauty, Shedinja needs a free
/// party slot. The renderer turns whichever fields are set into readable text.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvolutionCondition {
    pub trigger: Option<EvolutionTrigger>,
    pub min_level: Option<u32>,
    /// Item used on the Pokemon (e.g. `"water-stone"`).
    pub item: Option<String>,
    /// Item the Pokemon must be holding.
    pub held_item: Option<String>,
    pub known_move: Option<String>,
    pub known_move_type: Option<String>,
    pub min_happiness: Option<u32>,
    pub min_affection: Option<u32>,
    pub min_beauty: Option<u32>,
    /// `"day"`, `"night"` or `"dusk"`.
    pub time_of_day: Option<String>,
    pub location: Option<String>,
    /// PokeAPI gender id: 1 = female, 2 = male.
    pub gender: Option<u8>,
    pub needs_overworld_rain: bool,
    pub turn_upside_down: bool,
    /// The species that must be traded for (Karrablast ↔ Shelmet).
    pub trade_species: Option<String>,
    pub party_species: Option<String>,
    pub party_type: Option<String>,
    /// Attack compared to Defense: 1 = greater, 0 = equal, -1 = less (Tyrogue).
    pub relative_physical_stats: Option<i8>,
}

/// A node in a parsed evolution chain.
///
/// PokeAPI returns evolution data as a recursively nested structure where each
/// species can evolve into zero or more others. We mirror that as an n-ary
/// tree so branching evolutions (Eevee, Tyrogue, Wurmple, ...) are represented
/// naturally.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionTree {
    /// Raw API name of the species at this node.
    pub name: String,
    /// How the *parent* evolves into this node. `None` at the root of a chain,
    /// which nothing evolves into.
    pub condition: Option<EvolutionCondition>,
    pub children: Vec<EvolutionTree>,
}

impl EvolutionTree {
    /// Collects every species name in the chain (depth-first) into `out`.
    pub fn collect_names(&self, out: &mut Vec<String>) {
        out.push(self.name.clone());
        for child in &self.children {
            child.collect_names(out);
        }
    }

    /// Number of leaf species — i.e. how many vertical lanes a sprite layout
    /// needs to give every branch its own row.
    pub fn leaf_count(&self) -> usize {
        if self.children.is_empty() {
            1
        } else {
            self.children.iter().map(EvolutionTree::leaf_count).sum()
        }
    }

    /// Finds the node for `name` anywhere in the chain. Species appear at most
    /// once per chain, so the first match is the only match.
    pub fn find(&self, name: &str) -> Option<&EvolutionTree> {
        if self.name == name {
            return Some(self);
        }
        self.children.iter().find_map(|child| child.find(name))
    }

    /// Length of the longest evolution path (number of stages), e.g. 3 for
    /// Bulbasaur → Ivysaur → Venusaur.
    pub fn depth(&self) -> usize {
        1 + self
            .children
            .iter()
            .map(EvolutionTree::depth)
            .max()
            .unwrap_or(0)
    }
}

/// A decoded Pokemon sprite, stored as raw RGBA pixels ready to be rendered
/// in the terminal with Unicode half-blocks.
///
/// Sprites are tiny (PokeAPI's `front_default` is 96×96), so we keep the full
/// image in memory and downsample at draw time to whatever space is available.
/// The fields are private because [`bounds`](Self::content_bounds) is derived
/// from the pixels: letting anything rewrite them after the fact would leave a
/// crop box describing an image that no longer exists.
#[derive(Debug, Clone)]
pub struct Sprite {
    width: u32,
    height: u32,
    /// Row-major RGBA, four bytes per pixel.
    pixels: Vec<[u8; 4]>,
    /// Tight bounding box of the opaque pixels, computed once here rather than
    /// on every frame. See [`content_bounds`](Self::content_bounds).
    bounds: (u32, u32, u32, u32),
}

impl Sprite {
    /// Decodes into a sprite, working out the crop box as it goes.
    ///
    /// The box is a property of the pixels and nothing else, so computing it
    /// here costs one pass over an image we have just finished decoding
    /// anyway — against a full 96x96 scan per sprite per frame, which is what
    /// it replaces.
    pub fn new(width: u32, height: u32, pixels: Vec<[u8; 4]>) -> Self {
        let bounds = compute_content_bounds(width, height, &pixels);
        Sprite {
            width,
            height,
            pixels,
            bounds,
        }
    }

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    /// Row-major RGBA, for re-encoding the image on its way to the cache.
    pub fn pixels(&self) -> &[[u8; 4]] {
        &self.pixels
    }

    /// Average RGBA over the source box `[x0..=x1] × [y0..=y1]`, weighting color
    /// by alpha so transparent pixels don't muddy the result. The returned alpha
    /// is the box's mean coverage. Averaging (rather than nearest-neighbour point
    /// sampling) is what keeps downscaled sprites smooth instead of leaving the
    /// hard black outline pixels as ragged lines.
    pub fn box_average(&self, x0: u32, y0: u32, x1: u32, y1: u32) -> [u8; 4] {
        let x1 = x1.min(self.width.saturating_sub(1)).max(x0);
        let y1 = y1.min(self.height.saturating_sub(1)).max(y0);
        let (mut r, mut g, mut b, mut a, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
        for y in y0..=y1 {
            for x in x0..=x1 {
                let p = self.pixels[(y * self.width + x) as usize];
                let pa = p[3] as u32;
                r += p[0] as u32 * pa;
                g += p[1] as u32 * pa;
                b += p[2] as u32 * pa;
                a += pa;
                n += 1;
            }
        }
        if a == 0 || n == 0 {
            return [0, 0, 0, 0];
        }
        [(r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8]
    }

    /// Tight bounding box `(x0, y0, x1, y1)` (inclusive) of the non-transparent
    /// pixels. PokeAPI artwork sits in a large transparent margin; cropping to
    /// this box lets the visible Pokemon fill its on-screen cell.
    ///
    /// Read straight off the struct. It used to be a full scan of the image,
    /// run afresh every time the sprite was drawn — which meant once per frame
    /// per sprite, and a frame showing an evolution chain draws ten of them.
    /// Nothing about the answer depends on anything that changes between
    /// frames, so it is worked out once in [`Sprite::new`] instead.
    pub fn content_bounds(&self) -> (u32, u32, u32, u32) {
        self.bounds
    }
}

/// The scan behind [`Sprite::content_bounds`], as a free function so it can run
/// before there is a `Sprite` to call it on. Falls back to the whole image when
/// nothing is opaque, which keeps a fully transparent sprite renderable rather
/// than making it a special case downstream.
fn compute_content_bounds(width: u32, height: u32, pixels: &[[u8; 4]]) -> (u32, u32, u32, u32) {
    let (mut x0, mut y0, mut x1, mut y1) = (width, height, 0u32, 0u32);
    let mut found = false;
    for y in 0..height {
        for x in 0..width {
            if pixels[(y * width + x) as usize][3] >= 128 {
                found = true;
                x0 = x0.min(x);
                y0 = y0.min(y);
                x1 = x1.max(x);
                y1 = y1.max(y);
            }
        }
    }
    if found {
        (x0, y0, x1, y1)
    } else {
        (0, 0, width.saturating_sub(1), height.saturating_sub(1))
    }
}

/// Turns a raw API name like `"mr-mime"` into a display label `"Mr Mime"`.
pub fn title_case(raw: &str) -> String {
    raw.split(['-', ' '])
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

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

    /// A sprite with one opaque rectangle inside a transparent margin, which is
    /// the shape every PokeAPI sprite has.
    fn framed_sprite(width: u32, height: u32, box_: (u32, u32, u32, u32)) -> Sprite {
        let (x0, y0, x1, y1) = box_;
        let pixels = (0..width * height)
            .map(|i| {
                let (x, y) = (i % width, i / width);
                let opaque = (x0..=x1).contains(&x) && (y0..=y1).contains(&y);
                [10, 20, 30, if opaque { 255 } else { 0 }]
            })
            .collect();
        Sprite::new(width, height, pixels)
    }

    #[test]
    fn the_stored_crop_box_is_the_one_a_scan_would_have_found() {
        for box_ in [(18, 13, 77, 82), (0, 0, 95, 95), (40, 40, 41, 41)] {
            let sprite = framed_sprite(96, 96, box_);
            assert_eq!(sprite.content_bounds(), box_);
            assert_eq!(
                sprite.content_bounds(),
                compute_content_bounds(sprite.width(), sprite.height(), sprite.pixels()),
                "the box on the struct must not drift from the pixels it describes"
            );
        }
    }

    #[test]
    fn a_sprite_with_nothing_opaque_falls_back_to_the_whole_image() {
        let sprite = Sprite::new(4, 3, vec![[0, 0, 0, 0]; 12]);
        assert_eq!(sprite.content_bounds(), (0, 0, 3, 2));
        assert_eq!(
            sprite.content_bounds(),
            compute_content_bounds(4, 3, sprite.pixels())
        );
    }

    #[test]
    fn half_transparent_pixels_do_not_count_towards_the_crop() {
        // The scan's threshold is alpha >= 128, so a faint edge does not drag
        // the box back out to the margin it was cropped away from.
        let mut pixels = vec![[0u8, 0, 0, 0]; 16];
        pixels[5] = [10, 20, 30, 255];
        pixels[0] = [10, 20, 30, 127];
        let sprite = Sprite::new(4, 4, pixels);
        assert_eq!(sprite.content_bounds(), (1, 1, 1, 1));
    }

    fn entry(id: u32) -> PokemonEntry {
        PokemonEntry {
            name: String::new(),
            id,
        }
    }

    #[test]
    fn generation_boundaries_land_on_the_right_side() {
        // First and last species of every generation.
        for (id, gen) in [
            (1, 1),
            (151, 1),
            (152, 2),
            (251, 2),
            (252, 3),
            (386, 3),
            (387, 4),
            (493, 4),
            (494, 5),
            (649, 5),
            (650, 6),
            (721, 6),
            (722, 7),
            (809, 7),
            (810, 8),
            (905, 8),
            (906, 9),
            (1025, 9),
        ] {
            assert_eq!(entry(id).generation(), Some(gen), "dex #{id}");
        }
    }

    fn detail_with_sprites(normal: Option<&str>, shiny: Option<&str>) -> PokemonDetail {
        PokemonDetail {
            name: "pikachu".into(),
            species: "pikachu".into(),
            forms: Vec::new(),
            dex_number: 25,
            is_legendary: false,
            is_mythical: false,
            is_baby: false,
            types: Vec::new(),
            abilities: Vec::new(),
            stats: Vec::new(),
            height: 0,
            weight: 0,
            sprite_url: normal.map(str::to_string),
            shiny_sprite_url: shiny.map(str::to_string),
            genera: HashMap::new(),
            flavors: HashMap::new(),
            moves: Vec::new(),
            learnset_games: None,
            field: FieldData::default(),
        }
    }

    fn field(gender_rate: i8, capture_rate: u8) -> FieldData {
        FieldData {
            gender_rate,
            capture_rate,
            ..FieldData::default()
        }
    }

    #[test]
    fn a_gender_ratio_reads_as_percentages_and_minus_one_as_genderless() {
        // The sentinel must never reach the card as a ratio: -1 eighths
        // would print as a negative percentage.
        assert_eq!(field(-1, 0).gender_split(), None);
        assert_eq!(field(0, 0).gender_split(), Some((100.0, 0.0)));
        assert_eq!(field(8, 0).gender_split(), Some((0.0, 100.0)));
        assert_eq!(field(1, 0).gender_split(), Some((87.5, 12.5)));
        assert_eq!(field(4, 0).gender_split(), Some((50.0, 50.0)));
    }

    #[test]
    fn the_catch_rate_reads_the_right_way_round() {
        // Higher is easier, which the raw number does not say.
        assert_eq!(field(0, 3).catch_ease(), CatchEase::Hard);
        assert_eq!(field(0, 45).catch_ease(), CatchEase::Hard);
        assert_eq!(field(0, 90).catch_ease(), CatchEase::Average);
        assert_eq!(field(0, 255).catch_ease(), CatchEase::Easy);
    }

    #[test]
    fn breeding_groups_read_by_their_in_game_names() {
        assert_eq!(egg_group_label("plant"), "Grass");
        assert_eq!(egg_group_label("ground"), "Field");
        assert_eq!(egg_group_label("water1"), "Water 1");
        assert_eq!(egg_group_label("no-eggs"), "Undiscovered");
        // Outside the table, the slug is readable as it is.
        assert_eq!(egg_group_label("monster"), "Monster");
        assert_eq!(egg_group_label("dragon"), "Dragon");
    }

    #[test]
    fn shiny_artwork_falls_back_to_the_normal_palette() {
        let both = detail_with_sprites(Some("front.png"), Some("shiny.png"));
        assert_eq!(both.sprite_url_for(SpriteVariant::Shiny), Some("shiny.png"));
        assert_eq!(
            both.sprite_url_for(SpriteVariant::Normal),
            Some("front.png")
        );

        // No shiny art: show the normal sprite rather than an empty card.
        let normal_only = detail_with_sprites(Some("front.png"), None);
        assert_eq!(
            normal_only.sprite_url_for(SpriteVariant::Shiny),
            Some("front.png")
        );

        // No art at all stays "no art" in either palette.
        let neither = detail_with_sprites(None, None);
        assert_eq!(neither.sprite_url_for(SpriteVariant::Shiny), None);
    }

    #[test]
    fn alternate_forms_have_no_dex_number_or_generation() {
        let alolan_raichu = entry(10100);
        assert_eq!(alolan_raichu.dex_number(), None);
        assert_eq!(alolan_raichu.generation(), None);
    }

    #[test]
    fn a_form_is_named_by_what_it_adds_to_the_species() {
        assert_eq!(form_label("raichu-alola", "raichu"), "Alola");
        assert_eq!(
            form_label("urshifu-single-strike", "urshifu"),
            "Single Strike"
        );
        // The default variety is usually the species itself, leaving nothing
        // to add; a blank is not a label, so the whole name stands.
        assert_eq!(form_label("raichu", "raichu"), "Raichu");
        assert_eq!(form_label("giratina-altered", "giratina"), "Altered");
        // And a form whose name does not start with the species keeps all of
        // it rather than being cut somewhere arbitrary.
        assert_eq!(form_label("odd-form", "bulbasaur"), "Odd Form");
    }

    #[test]
    fn a_species_lists_the_forms_that_are_not_the_one_in_hand() {
        let mut raichu = detail_with_sprites(None, None);
        raichu.name = "raichu".into();
        raichu.species = "raichu".into();
        raichu.forms = vec!["raichu".into(), "raichu-alola".into()];
        assert_eq!(raichu.other_forms(), ["raichu-alola"]);

        // Seen from the Alolan form, the base species is the other form —
        // the row is about where else to go, whichever one you are on.
        let mut alolan = raichu.clone();
        alolan.name = "raichu-alola".into();
        assert_eq!(alolan.other_forms(), ["raichu"]);

        // A species with a single variety has no other form to list, so the
        // card gives the row no line at all.
        let mut dialga = raichu.clone();
        dialga.name = "dialga".into();
        dialga.forms = vec!["dialga".into()];
        assert!(dialga.other_forms().is_empty());
    }
}