diff --git a/src/lib/components/pokemon/counter-list.svelte b/src/lib/components/pokemon/counter-list.svelte new file mode 100644 index 0000000..5d71514 --- /dev/null +++ b/src/lib/components/pokemon/counter-list.svelte @@ -0,0 +1,47 @@ + + +{#if counters.length} + +{:else} +

+ No fully evolved Pokémon both resists every one of its same-type attacks and hits it super effectively. +

+{/if} diff --git a/src/lib/components/pokemon/fitted-name.svelte b/src/lib/components/pokemon/fitted-name.svelte new file mode 100644 index 0000000..742b557 --- /dev/null +++ b/src/lib/components/pokemon/fitted-name.svelte @@ -0,0 +1,45 @@ + + +
+ {#key name} +

+ {name} +

+ {/key} +
+ + diff --git a/src/lib/components/pokemon/pokemon-about.svelte b/src/lib/components/pokemon/pokemon-about.svelte index c288f3e..798ca7d 100644 --- a/src/lib/components/pokemon/pokemon-about.svelte +++ b/src/lib/components/pokemon/pokemon-about.svelte @@ -1,13 +1,10 @@
@@ -30,18 +27,15 @@
-
-
-
Height
-
{height.metric} {height.imperial}
-
-
-
Weight
-
{weight.metric} {weight.imperial}
-
-
-
Gender
-
-
-
+
+ {#key detail.entry.id} + + {/key} +
+
+
Gender
+
+
+
+
diff --git a/src/lib/components/pokemon/pokemon-hero.svelte b/src/lib/components/pokemon/pokemon-hero.svelte index 8dac091..b554a18 100644 --- a/src/lib/components/pokemon/pokemon-hero.svelte +++ b/src/lib/components/pokemon/pokemon-hero.svelte @@ -8,18 +8,23 @@ import { Toggle } from "$lib/components/ui/toggle/index.js"; import type { PokemonDetail } from "$lib/pokemon/detail.js"; import { dexNumber } from "$lib/pokemon/format.js"; + import { statRole } from "$lib/pokemon/role.js"; import CollectionButtons from "./collection-buttons.svelte"; import CryButton from "./cry-button.svelte"; + import FittedName from "./fitted-name.svelte"; import PokemonArt from "./pokemon-art.svelte"; + import ShapePortrait from "./shape-portrait.svelte"; import TypeBadge from "./type-badge.svelte"; let { detail }: { detail: PokemonDetail } = $props(); let shiny = $state(false); const entry = $derived(detail.entry); + const role = $derived(statRole(entry.stats)); + const roleQuery = $derived(`role:${role.label.toLowerCase().replaceAll(" ", "-")}`); -
+
-
-
+
+

{dexNumber(entry.speciesId)}

-

- {entry.name} -

+ {#if detail.genus}

{detail.genus}

{/if} @@ -55,7 +58,12 @@ {/each}
-
+

+ {role.label}. + {role.description} +

+ +
-
- {#key `${entry.id}-${shiny}`} - - {/key} -
+
+ + {#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 @@