use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PokemonEntry {
pub name: String,
pub id: u32,
}
impl PokemonEntry {
pub fn dex_number(&self) -> Option<u32> {
(self.id <= MAX_DEX_NUMBER).then_some(self.id)
}
pub fn generation(&self) -> Option<u8> {
let dex = self.dex_number()?;
GENERATION_RANGES
.iter()
.position(|&last| dex <= last)
.map(|idx| idx as u8 + 1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RosterKind {
Type,
Ability,
EggGroup,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RosterTerm {
pub kind: RosterKind,
pub value: String,
}
impl RosterTerm {
pub fn new(kind: RosterKind, value: impl Into<String>) -> Self {
Self {
kind,
value: value.into(),
}
}
}
const MAX_DEX_NUMBER: u32 = 1025;
const GENERATION_RANGES: [u32; 9] = [151, 251, 386, 493, 649, 721, 809, 905, MAX_DEX_NUMBER];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StatKind {
Hp,
Attack,
Defense,
SpecialAttack,
SpecialDefense,
Speed,
}
impl 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,
}
}
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,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Stat {
pub kind: StatKind,
pub base: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ability {
pub name: String,
pub is_hidden: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbilityInfo {
pub name: String,
pub names: HashMap<String, String>,
pub flavors: HashMap<String, String>,
}
impl AbilityInfo {
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))
}
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, Copy, Default, PartialEq, Eq, Hash)]
pub enum SpriteVariant {
#[default]
Normal,
Shiny,
}
impl SpriteVariant {
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)
}
pub fn file_suffix(self) -> &'static str {
match self {
Self::Normal => "",
Self::Shiny => ".shiny",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LearnMethod {
LevelUp,
Machine,
Egg,
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,
}
}
pub fn order(self) -> u8 {
match self {
LearnMethod::LevelUp => 0,
LearnMethod::Egg => 1,
LearnMethod::Machine => 2,
LearnMethod::Tutor => 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LearnedMove {
pub name: String,
pub method: LearnMethod,
pub level: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveInfo {
pub name: String,
pub names: HashMap<String, String>,
pub flavors: HashMap<String, String>,
pub type_name: String,
pub damage_class: String,
pub power: Option<u16>,
pub accuracy: Option<u16>,
pub pp: Option<u16>,
}
impl MoveInfo {
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))
}
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 {
pub name: String,
pub species: String,
pub forms: Vec<String>,
pub dex_number: u32,
pub is_legendary: bool,
pub is_mythical: bool,
pub is_baby: bool,
pub types: Vec<String>,
pub abilities: Vec<Ability>,
pub stats: Vec<Stat>,
pub height: u32,
pub weight: u32,
pub sprite_url: Option<String>,
pub shiny_sprite_url: Option<String>,
pub genera: HashMap<String, String>,
pub flavors: HashMap<String, String>,
pub moves: Vec<LearnedMove>,
pub learnset_games: Option<String>,
pub field: FieldData,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldData {
pub egg_groups: Vec<String>,
pub capture_rate: u8,
pub base_happiness: Option<u8>,
pub growth_rate: Option<String>,
pub gender_rate: i8,
pub habitat: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatchEase {
Hard,
Average,
Easy,
}
impl FieldData {
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,
}
}
pub fn catch_ease(&self) -> CatchEase {
match self.capture_rate {
0..=45 => CatchEase::Hard,
46..=149 => CatchEase::Average,
_ => CatchEase::Easy,
}
}
}
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 {
pub fn stat_total(&self) -> u32 {
self.stats.iter().map(|s| s.base as u32).sum()
}
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()),
}
}
pub fn genus_for(&self, code: &str) -> Option<&str> {
self.genera
.get(code)
.or_else(|| self.genera.get("en"))
.map(String::as_str)
}
pub fn other_forms(&self) -> Vec<&str> {
self.forms
.iter()
.map(String::as_str)
.filter(|form| *form != self.name)
.collect()
}
}
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))
}
#[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()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvolutionCondition {
pub trigger: Option<EvolutionTrigger>,
pub min_level: Option<u32>,
pub item: Option<String>,
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>,
pub time_of_day: Option<String>,
pub location: Option<String>,
pub gender: Option<u8>,
pub needs_overworld_rain: bool,
pub turn_upside_down: bool,
pub trade_species: Option<String>,
pub party_species: Option<String>,
pub party_type: Option<String>,
pub relative_physical_stats: Option<i8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionTree {
pub name: String,
pub condition: Option<EvolutionCondition>,
pub children: Vec<EvolutionTree>,
}
impl EvolutionTree {
pub fn collect_names(&self, out: &mut Vec<String>) {
out.push(self.name.clone());
for child in &self.children {
child.collect_names(out);
}
}
pub fn leaf_count(&self) -> usize {
if self.children.is_empty() {
1
} else {
self.children.iter().map(EvolutionTree::leaf_count).sum()
}
}
pub fn find(&self, name: &str) -> Option<&EvolutionTree> {
if self.name == name {
return Some(self);
}
self.children.iter().find_map(|child| child.find(name))
}
pub fn depth(&self) -> usize {
1 + self
.children
.iter()
.map(EvolutionTree::depth)
.max()
.unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct Sprite {
width: u32,
height: u32,
pixels: Vec<[u8; 4]>,
bounds: (u32, u32, u32, u32),
}
impl Sprite {
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
}
pub fn pixels(&self) -> &[[u8; 4]] {
&self.pixels
}
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]
}
pub fn content_bounds(&self) -> (u32, u32, u32, u32) {
self.bounds
}
}
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))
}
}
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::*;
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() {
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() {
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() {
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() {
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");
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")
);
let normal_only = detail_with_sprites(Some("front.png"), None);
assert_eq!(
normal_only.sprite_url_for(SpriteVariant::Shiny),
Some("front.png")
);
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"
);
assert_eq!(form_label("raichu", "raichu"), "Raichu");
assert_eq!(form_label("giratina-altered", "giratina"), "Altered");
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"]);
let mut alolan = raichu.clone();
alolan.name = "raichu-alola".into();
assert_eq!(alolan.other_forms(), ["raichu"]);
let mut dialga = raichu.clone();
dialga.name = "dialga".into();
dialga.forms = vec!["dialga".into()];
assert!(dialga.other_forms().is_empty());
}
}