+
+
+ {#key `${entry.id}-${shiny}`}
+
+ {/key}
+
+
Shiny
diff --git a/src/lib/components/pokemon/shape-portrait.svelte b/src/lib/components/pokemon/shape-portrait.svelte
new file mode 100644
index 0000000..72cd70c
--- /dev/null
+++ b/src/lib/components/pokemon/shape-portrait.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+ {@render children()}
+
+ {#each SHAPE_ORDER as stat, i (stat)}
+
+ {STAT_LABELS[STAT_KEYS[stat]].short}
+ {stats[stat]}
+
+ {/each}
+
diff --git a/src/lib/components/pokemon/size-comparison.svelte b/src/lib/components/pokemon/size-comparison.svelte
new file mode 100644
index 0000000..4d9e756
--- /dev/null
+++ b/src/lib/components/pokemon/size-comparison.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ {heightText.metric}
+ {heightText.imperial}
+ tall, beside a {PERSON} m person.
+
+
+ {weightText.metric}
+ {weightText.imperial},
+ {weightComparison(weight).replace("About", "about")}.
+
+ {name} is {heightText.metric} tall and weighs {weightText.metric}.
+
+
diff --git a/src/lib/components/pokemon/stat-bars.svelte b/src/lib/components/pokemon/stat-bars.svelte
index dab28be..e83b625 100644
--- a/src/lib/components/pokemon/stat-bars.svelte
+++ b/src/lib/components/pokemon/stat-bars.svelte
@@ -3,7 +3,7 @@
import { MAX_BASE_STAT, statBand, statRanges } from "$lib/pokemon/stats.js";
import { STAT_KEYS, STAT_LABELS, type BaseStats } from "$lib/pokemon/types.js";
- let { stats }: { stats: BaseStats } = $props();
+ let { stats, percentiles }: { stats: BaseStats; percentiles?: number[] } = $props();
let level = $state("100");
const ranges = $derived(statRanges(stats, Number(level)));
@@ -32,6 +32,7 @@
Stat
Base
Distribution
+ {#if percentiles}Share of species with a lower base stat {/if}
Range at level {level}
@@ -51,6 +52,12 @@
>
+ {#if percentiles}
+
+ beats
+ {percentiles[i]}%
+
+ {/if}
{ranges[i][0]}–{ranges[i][1]}
@@ -59,11 +66,19 @@
Total
{total}
-
+
+ {#if percentiles}
+
+ beats
+ {percentiles[6]}%
+
+ {/if}
+
+ {#if percentiles}“Beats” is the share of all species with a lower base stat.{/if}
Ranges assume 0 IVs, 0 EVs and a hindering nature at the low end, and 31 IVs, 252 EVs and a
helpful nature at the high end.
diff --git a/src/lib/components/pokemon/twin-list.svelte b/src/lib/components/pokemon/twin-list.svelte
new file mode 100644
index 0000000..5e02d41
--- /dev/null
+++ b/src/lib/components/pokemon/twin-list.svelte
@@ -0,0 +1,28 @@
+
+
+
+ {#each twins as { entry, gap } (entry.id)}
+
+
+
+
+
+
+ {entry.name}
+
+
+ {gap === 0 ? "Identical base stats" : `About ${gap} apart per stat`}
+
+ Compare
+
+
+
+ {/each}
+
diff --git a/src/lib/dex/filters.test.ts b/src/lib/dex/filters.test.ts
index 2437203..b61ce8c 100644
--- a/src/lib/dex/filters.test.ts
+++ b/src/lib/dex/filters.test.ts
@@ -110,6 +110,12 @@ describe("matchesFilters", () => {
expect(pick("ability:roughskin")).toEqual([garchomp]);
});
+ it("filters by role from base stats", () => {
+ expect(pick("role:physical-sweeper")).toEqual([garchomp]);
+ expect(pick("role:special")).toEqual([gengar, megaGengar]);
+ expect(parseSearch("role:healer").invalid).toEqual(["role:healer"]);
+ });
+
it("negates filters with a leading minus", () => {
expect(pick("-type:ghost")).toEqual([alolanRaichu, garchomp]);
expect(pick("!is:form")).toEqual([gengar, garchomp]);
diff --git a/src/lib/dex/filters.ts b/src/lib/dex/filters.ts
index ebd35d1..728cbe3 100644
--- a/src/lib/dex/filters.ts
+++ b/src/lib/dex/filters.ts
@@ -1,4 +1,5 @@
import { effectiveness } from "$lib/pokemon/matchups.js";
+import { isRoleTerm, roleMatches, statRole } from "$lib/pokemon/role.js";
import { GENERATIONS } from "$lib/pokemon/generations.js";
import { titleCase } from "$lib/pokemon/format.js";
import {
@@ -35,7 +36,8 @@ type FilterBody =
| { kind: "weak" | "resist" | "immune"; type: TypeName }
| { kind: "gen"; from: number; to: number }
| { kind: "is"; flag: IsFlag }
- | { kind: "ability"; slug: string };
+ | { kind: "ability"; slug: string }
+ | { kind: "role"; term: string };
/** A filter read from a search, with the token it came from. */
export type Filter = FilterBody & { raw: string; negate: boolean };
@@ -70,7 +72,9 @@ const MEASURES: Record
= {
wt: "weight",
};
-const KEYS: Record = {
+type Key = "type" | "weak" | "resist" | "immune" | "gen" | "is" | "ability" | "role";
+
+const KEYS: Record = {
type: "type",
t: "type",
weak: "weak",
@@ -84,6 +88,7 @@ const KEYS: Record = {
@@ -167,6 +172,8 @@ function parseToken(token: string): FilterBody | null | undefined {
}
case "ability":
return { kind: "ability", slug: value.replace(/[\s_]+/g, "-") };
+ case "role":
+ return isRoleTerm(value) ? { kind: "role", term: value.replace(/[\s_]+/g, "-") } : null;
}
}
@@ -249,6 +256,8 @@ function test(entry: DexListEntry, filter: Filter): boolean {
return hasFlag(entry, filter.flag);
case "ability":
return entry.abilities.some((slug) => normalize(slug) === normalize(filter.slug));
+ case "role":
+ return roleMatches(statRole(entry.stats), filter.term);
}
}
@@ -323,6 +332,10 @@ export function describeFilter(filter: Filter): string {
return not ? `Not ${FLAG_LABELS[filter.flag].toLowerCase()}` : FLAG_LABELS[filter.flag];
case "ability":
return `${not ? "Without" : "Has"} ${titleCase(filter.slug)}`;
+ case "role": {
+ const role = filter.term.replace(/-/g, " ");
+ return not ? `Not ${role}` : `${role[0].toUpperCase()}${role.slice(1)}`;
+ }
}
}
@@ -344,6 +357,10 @@ export const SEARCH_EXAMPLES = [
query: "is:legendary",
description: `Also ${IS_FLAGS.filter((f) => f !== "legendary").join(", ")}`,
},
+ {
+ query: "role:sweeper",
+ description: "Role from base stats, like wall, tank or special-sweeper",
+ },
{ query: "height>2", description: "Height in metres or weight in kilograms" },
{ query: "-type:flying", description: "Put - before any filter to exclude it" },
] as const;
diff --git a/src/lib/pokemon/detail.ts b/src/lib/pokemon/detail.ts
index d6d1488..6f5c98e 100644
--- a/src/lib/pokemon/detail.ts
+++ b/src/lib/pokemon/detail.ts
@@ -1,6 +1,7 @@
import type { DexEntry } from "$lib/data/types.js";
import type { DexListEntry } from "$lib/dex/list.js";
import type { EvolutionNode } from "./evolution-tree.js";
+import type { Counter, StatTwin } from "./insights.js";
import type { Learnset } from "./learnset.js";
export interface FlavorEntry {
@@ -32,4 +33,8 @@ export interface PokemonDetail {
cries: { latest: string | null; legacy: string | null };
prev: DexListEntry | null;
next: DexListEntry | null;
+ /** Percent of species each base stat beats, in STAT_KEYS order, then the total. */
+ percentiles: number[];
+ twins: StatTwin[];
+ counters: Counter[];
}
diff --git a/src/lib/pokemon/insights.test.ts b/src/lib/pokemon/insights.test.ts
new file mode 100644
index 0000000..ebb6e6a
--- /dev/null
+++ b/src/lib/pokemon/insights.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ closestStats,
+ findCounters,
+ percentBelow,
+ statDistribution,
+ statPercentiles,
+ weightComparison,
+} from "./insights.js";
+import type { BaseStats, TypeName } from "./types.js";
+
+const mon = (speciesId: number, types: TypeName[], stats: BaseStats) => ({
+ speciesId,
+ types,
+ stats,
+});
+
+describe("percentiles", () => {
+ it("counts the share strictly below a value", () => {
+ expect(percentBelow(5, [1, 2, 5, 5, 9])).toBe(40);
+ expect(percentBelow(0, [1, 2])).toBe(0);
+ expect(percentBelow(10, [1, 2])).toBe(100);
+ expect(percentBelow(1, [])).toBe(0);
+ });
+
+ it("ranks each stat and the total against a population", () => {
+ const population = [
+ mon(1, ["normal"], [50, 50, 50, 50, 50, 50]),
+ mon(2, ["normal"], [100, 100, 100, 100, 100, 100]),
+ ];
+ const ranks = statPercentiles([100, 40, 100, 100, 100, 60], statDistribution(population));
+ expect(ranks).toEqual([50, 0, 50, 50, 50, 50, 50]);
+ });
+});
+
+describe("closestStats", () => {
+ it("finds other species with the nearest stats", () => {
+ const target = mon(1, ["normal"], [80, 80, 80, 80, 80, 80]);
+ const population = [
+ mon(1, ["normal"], [80, 80, 80, 80, 80, 80]),
+ mon(2, ["normal"], [90, 70, 80, 80, 80, 80]),
+ mon(3, ["normal"], [120, 120, 120, 120, 120, 120]),
+ mon(4, ["normal"], [85, 85, 80, 80, 80, 80]),
+ ];
+ const twins = closestStats(target, population, 2);
+ expect(twins.map((t) => t.entry.speciesId)).toEqual([4, 2]);
+ expect(twins[0].gap).toBe(2);
+ });
+});
+
+describe("findCounters", () => {
+ it("keeps Pokémon that resist every same-type attack and hit back hard", () => {
+ const charizard = { types: ["fire", "flying"] as TypeName[] };
+ const pool = [
+ mon(1, ["rock", "ground"], [90, 110, 130, 50, 60, 50]), // resists both, Rock 4×
+ mon(2, ["water"], [90, 90, 90, 90, 90, 90]), // weak to nothing but only resists Fire
+ mon(3, ["electric", "steel"], [80, 80, 80, 80, 80, 80]), // resists Flying, neutral to Fire
+ mon(4, ["rock", "water"], [70, 100, 110, 80, 80, 40]), // resists both, Rock 4×, lower total
+ mon(5, ["electric"], [60, 60, 60, 60, 60, 60]), // resists Flying only
+ ];
+ const counters = findCounters(charizard, pool);
+ expect(counters.map((c) => c.entry.speciesId)).toEqual([1, 4]);
+ expect(counters[0].attack).toEqual({ type: "rock", multiplier: 4 });
+ });
+});
+
+describe("weightComparison", () => {
+ it("compares weights with everyday things", () => {
+ expect(weightComparison(1)).toBe("Lighter than an apple");
+ expect(weightComparison(60)).toBe("About as heavy as a bowling ball");
+ expect(weightComparison(905)).toBe("About as heavy as an adult person");
+ expect(weightComparison(4600)).toBe("About as heavy as a grand piano");
+ expect(weightComparison(120)).toBe("About as heavy as 2 bowling balls");
+ });
+});
diff --git a/src/lib/pokemon/insights.ts b/src/lib/pokemon/insights.ts
new file mode 100644
index 0000000..4524ebc
--- /dev/null
+++ b/src/lib/pokemon/insights.ts
@@ -0,0 +1,122 @@
+import type { DexListEntry } from "$lib/dex/list.js";
+import { effectiveness } from "./matchups.js";
+import type { BaseStats, TypeName } from "./types.js";
+
+type Stats = Pick;
+
+function total(stats: BaseStats): number {
+ return stats.reduce((sum, s) => sum + s, 0);
+}
+
+/** Sorted values of each stat (and the total, last) across a population, for percentile lookups. */
+export function statDistribution(population: readonly Stats[]): number[][] {
+ const columns = Array.from({ length: 7 }, () => [] as number[]);
+ for (const { stats } of population) {
+ stats.forEach((value, i) => columns[i].push(value));
+ columns[6].push(total(stats));
+ }
+ return columns.map((column) => column.sort((a, b) => a - b));
+}
+
+/** Share of the population, 0–100, with a strictly lower value. */
+export function percentBelow(value: number, sorted: readonly number[]): number {
+ let low = 0;
+ let high = sorted.length;
+ while (low < high) {
+ const mid = (low + high) >> 1;
+ if (sorted[mid] < value) low = mid + 1;
+ else high = mid;
+ }
+ return sorted.length ? Math.round((low / sorted.length) * 100) : 0;
+}
+
+/** Percent of the population each stat beats, in STAT_KEYS order, then the total. */
+export function statPercentiles(stats: BaseStats, distribution: readonly number[][]): number[] {
+ return [...stats, total(stats)].map((value, i) => percentBelow(value, distribution[i]));
+}
+
+export interface StatTwin {
+ entry: T;
+ /** Average gap per base stat. */
+ gap: number;
+}
+
+/** Pokémon of other species whose six base stats sit closest to these. */
+export function closestStats>(
+ target: Stats & Pick,
+ population: readonly T[],
+ count = 6,
+): StatTwin[] {
+ return population
+ .filter((other) => other.speciesId !== target.speciesId)
+ .map((entry) => {
+ const squares = entry.stats.reduce((sum, s, i) => sum + (s - target.stats[i]) ** 2, 0);
+ const gap = entry.stats.reduce((sum, s, i) => sum + Math.abs(s - target.stats[i]), 0) / 6;
+ return { entry, squares, gap: Math.round(gap) };
+ })
+ .sort((a, b) => a.squares - b.squares || a.entry.speciesId - b.entry.speciesId)
+ .slice(0, count)
+ .map(({ entry, gap }) => ({ entry, gap }));
+}
+
+export interface Counter {
+ entry: T;
+ /** The counter's best same-type attack against the target. */
+ attack: { type: TypeName; multiplier: number };
+ /** Worst multiplier the target's same-type attacks deal to the counter. */
+ takes: number;
+}
+
+type Typed = Pick;
+
+/**
+ * Pokémon that resist or ignore every one of the target's same-type attacks and hit it super
+ * effectively with one of their own. Judged on types and base stats only.
+ */
+export function findCounters(
+ target: Pick,
+ pool: readonly T[],
+ count = 6,
+): Counter[] {
+ const found: (Counter & { power: number })[] = [];
+ for (const entry of pool) {
+ const takes = Math.max(...target.types.map((t) => effectiveness(t, entry.types)));
+ if (takes >= 1) continue;
+ let attack = { type: entry.types[0], multiplier: 0 };
+ for (const type of entry.types) {
+ const multiplier = effectiveness(type, target.types);
+ if (multiplier > attack.multiplier) attack = { type, multiplier };
+ }
+ if (attack.multiplier < 2) continue;
+ found.push({ entry, attack, takes, power: total(entry.stats) });
+ }
+ return found
+ .sort(
+ (a, b) => b.attack.multiplier - a.attack.multiplier || a.takes - b.takes || b.power - a.power,
+ )
+ .slice(0, count)
+ .map(({ entry, attack, takes }) => ({ entry, attack, takes }));
+}
+
+const WEIGHTS = [
+ { kg: 0.15, one: "an apple", many: "apples" },
+ { kg: 1, one: "a bag of sugar", many: "bags of sugar" },
+ { kg: 4, one: "a house cat", many: "house cats" },
+ { kg: 7, one: "a bowling ball", many: "bowling balls" },
+ { kg: 30, one: "a Labrador", many: "Labradors" },
+ { kg: 70, one: "an adult person", many: "adult people" },
+ { kg: 190, one: "a lion", many: "lions" },
+ { kg: 400, one: "a grand piano", many: "grand pianos" },
+ { kg: 700, one: "a dairy cow", many: "dairy cows" },
+ { kg: 1000, one: "a small car", many: "small cars" },
+] as const;
+
+/** An everyday comparison for a weight in hectograms, e.g. "About as heavy as a house cat". */
+export function weightComparison(hectograms: number): string {
+ const kg = hectograms / 10;
+ const reference = WEIGHTS.findLast((w) => w.kg <= kg * 1.25);
+ if (!reference) return `Lighter than ${WEIGHTS[0].one}`;
+ const times = Math.round(kg / reference.kg);
+ if (times <= 1) return `About as heavy as ${reference.one}`;
+ return `About as heavy as ${times} ${reference.many}`;
+}
diff --git a/src/lib/pokemon/name-fit.test.ts b/src/lib/pokemon/name-fit.test.ts
new file mode 100644
index 0000000..a2cdab7
--- /dev/null
+++ b/src/lib/pokemon/name-fit.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vite-plus/test";
+import { nameFit } from "./name-fit.js";
+
+describe("nameFit", () => {
+ it("widens short names and condenses long ones", () => {
+ expect(nameFit("Mew").stretch).toBe(125);
+ expect(nameFit("Charizard").stretch).toBe(100);
+ expect(nameFit("Crabominable").stretch).toBeLessThan(85);
+ expect(nameFit("Gigantamax Single Strike Urshifu").stretch).toBe(75);
+ });
+
+ it("counts characters, not UTF-16 units", () => {
+ expect(nameFit("Flabébé").chars).toBe(7);
+ });
+});
diff --git a/src/lib/pokemon/name-fit.ts b/src/lib/pokemon/name-fit.ts
new file mode 100644
index 0000000..699b441
--- /dev/null
+++ b/src/lib/pokemon/name-fit.ts
@@ -0,0 +1,16 @@
+/**
+ * How to set a Pokémon's name so it fills its column: short names use Archivo's widest
+ * setting, long names its narrowest. `em` is the average advance per character at that
+ * width, measured on Archivo Bold, used to size the name in container units before any
+ * script runs.
+ */
+const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
+
+export function nameFit(name: string): { stretch: number; em: number; chars: number } {
+ const chars = Array.from(graphemes.segment(name)).length;
+ const t = Math.min(1, Math.max(0, (chars - 5) / 8));
+ const stretch = Math.round(125 - t * 50);
+ // Short names lean on wide capitals, so they run wider per character.
+ const em = 0.4 + ((stretch - 75) / 50) * 0.26 + (chars <= 4 ? 0.12 : 0);
+ return { stretch, em: Math.round(em * 1000) / 1000, chars };
+}
diff --git a/src/lib/pokemon/role.test.ts b/src/lib/pokemon/role.test.ts
new file mode 100644
index 0000000..0c5c7ce
--- /dev/null
+++ b/src/lib/pokemon/role.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vite-plus/test";
+import { roleMatches, statRole } from "./role.js";
+import type { BaseStats } from "./types.js";
+
+const label = (stats: BaseStats) => statRole(stats).label;
+
+describe("statRole", () => {
+ it("spots sweepers and glass cannons", () => {
+ expect(label([108, 130, 95, 80, 85, 102])).toBe("Physical sweeper"); // Garchomp
+ expect(label([78, 84, 78, 109, 85, 100])).toBe("Special sweeper"); // Charizard
+ expect(label([70, 110, 70, 115, 70, 90])).toBe("Mixed sweeper"); // Lucario
+ expect(label([55, 50, 45, 135, 95, 120])).toBe("Special glass cannon"); // Alakazam
+ });
+
+ it("spots walls by their stronger defense", () => {
+ expect(label([255, 10, 10, 75, 135, 55])).toBe("Special wall"); // Blissey
+ expect(label([65, 80, 140, 40, 70, 70])).toBe("Physical wall"); // Skarmory
+ expect(label([50, 63, 152, 53, 142, 35])).toBe("Wall"); // Toxapex
+ });
+
+ it("spots tanks, speedsters and all-rounders", () => {
+ expect(label([100, 134, 110, 95, 100, 61])).toBe("Physical tank"); // Tyranitar
+ expect(label([85, 90, 80, 70, 80, 130])).toBe("Speedster"); // Crobat
+ expect(label([100, 100, 100, 100, 100, 100])).toBe("All-rounder"); // Mew
+ });
+});
+
+describe("roleMatches", () => {
+ const sweeper = statRole([108, 130, 95, 80, 85, 102]);
+ it("matches the whole label, its side or its kind", () => {
+ expect(roleMatches(sweeper, "physical-sweeper")).toBe(true);
+ expect(roleMatches(sweeper, "sweeper")).toBe(true);
+ expect(roleMatches(sweeper, "physical")).toBe(true);
+ expect(roleMatches(sweeper, "wall")).toBe(false);
+ expect(roleMatches(sweeper, "")).toBe(false);
+ });
+});
diff --git a/src/lib/pokemon/role.ts b/src/lib/pokemon/role.ts
new file mode 100644
index 0000000..dd0ddf4
--- /dev/null
+++ b/src/lib/pokemon/role.ts
@@ -0,0 +1,134 @@
+import { normalize } from "$lib/dex/search.js";
+import type { BaseStats } from "./types.js";
+
+export type RoleKind =
+ | "sweeper"
+ | "glass-cannon"
+ | "tank"
+ | "slow-hitter"
+ | "attacker"
+ | "wall"
+ | "speedster"
+ | "all-rounder";
+
+export type Side = "physical" | "special" | "mixed";
+
+export interface Role {
+ kind: RoleKind;
+ /** Which attacking or defensive stat the role leans on; absent for balanced roles. */
+ side?: Side;
+ label: string;
+ description: string;
+}
+
+const SIDE_WORD: Record = {
+ physical: "Physical",
+ special: "Special",
+ mixed: "Mixed",
+};
+const ATTACK_STAT: Record = {
+ physical: "Attack",
+ special: "Sp. Atk",
+ mixed: "both Attack and Sp. Atk",
+};
+
+function role(kind: RoleKind, side: Side | undefined, name: string, description: string): Role {
+ const label = side ? `${SIDE_WORD[side]} ${name.toLowerCase()}` : name;
+ return { kind, side, label, description };
+}
+
+/**
+ * Classify a stat spread into a battle role, judged against the Pokémon's own average stat so
+ * unevolved Pokémon get a role too. A heuristic from base stats alone; abilities and moves
+ * can change how a Pokémon is really used.
+ */
+export function statRole(stats: BaseStats): Role {
+ const [, atk, , spa] = stats;
+ const mean = stats.reduce((sum, s) => sum + s, 0) / 6;
+ const [hp, attack, defense, spAtk, spDef, speed] = stats.map((s) => s / mean);
+ const offense = Math.max(attack, spAtk);
+ const bulk = (hp + defense + spDef) / 3;
+ const physicalBulk = (hp + 2 * defense) / 3;
+ const specialBulk = (hp + 2 * spDef) / 3;
+ const guard = Math.max(physicalBulk, specialBulk);
+
+ if (guard >= 1.15 && guard >= offense + 0.1) {
+ if (physicalBulk >= 1.1 && specialBulk >= 1.1 && Math.abs(physicalBulk - specialBulk) < 0.2) {
+ return role("wall", undefined, "Wall", "Soaks up physical and special hits alike.");
+ }
+ return physicalBulk > specialBulk
+ ? role("wall", "physical", "Wall", "Built to absorb physical hits with HP and Defense.")
+ : role("wall", "special", "Wall", "Built to absorb special hits with HP and Sp. Def.");
+ }
+
+ if (offense >= 1.15) {
+ const mixed =
+ Math.min(atk, spa) / Math.max(atk, spa) >= 0.88 && Math.min(attack, spAtk) >= 1.05;
+ const side: Side = mixed ? "mixed" : atk >= spa ? "physical" : "special";
+ const stat = ATTACK_STAT[side];
+ if (bulk <= 0.8 && offense >= 1.3 && speed >= 0.9) {
+ return role(
+ "glass-cannon",
+ side,
+ "Glass cannon",
+ `Fast, with a huge ${stat}, but it can’t take many hits.`,
+ );
+ }
+ if (speed >= 1) {
+ return role("sweeper", side, "Sweeper", `Fast and strong in ${stat}, built to strike first.`);
+ }
+ if (bulk >= 1) {
+ return role(
+ "tank",
+ side,
+ "Tank",
+ `Hits hard with ${stat} and takes hits well, but isn’t fast.`,
+ );
+ }
+ if (speed <= 0.7) {
+ return role(
+ "slow-hitter",
+ side,
+ "Slow hitter",
+ `Hits hard with ${stat} but usually moves last.`,
+ );
+ }
+ return role("attacker", side, "Attacker", `Leans on ${stat} more than bulk or speed.`);
+ }
+
+ if (speed >= 1.35) {
+ return role(
+ "speedster",
+ undefined,
+ "Speedster",
+ "Its Speed stands out more than its power or bulk.",
+ );
+ }
+ return role("all-rounder", undefined, "All-rounder", "No stat stands far above the rest.");
+}
+
+/** Whether a role matches a search term such as "sweeper", "physical" or "special-wall". */
+export function roleMatches(role: Role, term: string): boolean {
+ const label = normalize(role.label);
+ const query = normalize(term);
+ return query.length > 0 && (label === query || label.startsWith(query) || label.endsWith(query));
+}
+
+const KINDS = [
+ "sweeper",
+ "glasscannon",
+ "tank",
+ "slowhitter",
+ "attacker",
+ "wall",
+ "speedster",
+ "allrounder",
+];
+const SIDES = ["physical", "special", "mixed"];
+
+/** Whether a search term names a role, a side or both, e.g. "wall", "special" or "physical-tank". */
+export function isRoleTerm(term: string): boolean {
+ const query = normalize(term);
+ if (SIDES.includes(query) || KINDS.includes(query)) return true;
+ return SIDES.some((side) => query.startsWith(side) && KINDS.includes(query.slice(side.length)));
+}
diff --git a/src/lib/server/pokemon.ts b/src/lib/server/pokemon.ts
index 77bb331..eb1511c 100644
--- a/src/lib/server/pokemon.ts
+++ b/src/lib/server/pokemon.ts
@@ -7,12 +7,32 @@ import { buildEvolutionTree } from "$lib/pokemon/evolution-tree.js";
import { buildLearnset, type Learnset } from "$lib/pokemon/learnset.js";
import { cleanFlavorText, titleCase, versionName } from "$lib/pokemon/format.js";
import type { FlavorEntry, PokemonDetail } from "$lib/pokemon/detail.js";
+import {
+ closestStats,
+ findCounters,
+ statDistribution,
+ statPercentiles,
+} from "$lib/pokemon/insights.js";
import { STAT_KEYS } from "$lib/pokemon/types.js";
-import { abilitiesBySlug, formsOfSpecies, movesBySlug, pokemon, pokemonBySlug } from "./dex.js";
+import {
+ abilitiesBySlug,
+ formsOfSpecies,
+ movesBySlug,
+ pokemon,
+ pokemonBySlug,
+ pokemonList,
+} from "./dex.js";
const defaults = pokemon.filter((p) => p.isDefault);
const defaultsBySpecies = new Map(defaults.map((p) => [p.speciesId, p]));
+const allSpecies = pokemonList.filter((p) => p.isDefault);
+const distribution = statDistribution(allSpecies);
+// Counters are drawn from fully evolved, everyday Pokémon that someone could actually bring.
+const counterPool = allSpecies.filter(
+ (p) => p.final && !p.legendary && !p.mythical && p.stats.reduce((a, b) => a + b, 0) >= 450,
+);
+
function flavorEntries(species: ApiPokemonSpecies): FlavorEntry[] {
const byText = new Map();
for (const entry of species.flavor_text_entries) {
@@ -84,6 +104,9 @@ export async function getPokemonDetail(
cries: api.cries,
prev: neighbour(-1),
next: neighbour(1),
+ percentiles: statPercentiles(entry.stats, distribution),
+ twins: closestStats(entry, allSpecies),
+ counters: findCounters(entry, counterPool),
};
}
diff --git a/src/routes/pokemon/[slug]/+page.svelte b/src/routes/pokemon/[slug]/+page.svelte
index 2269d0c..448b6f9 100644
--- a/src/routes/pokemon/[slug]/+page.svelte
+++ b/src/routes/pokemon/[slug]/+page.svelte
@@ -1,4 +1,5 @@