From 43a9adb5207e1c26e5cb8664f2d4463b9426c371 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:28:33 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20read=20each=20Pok=C3=A9mon's=20batt?= =?UTF-8?q?le=20role=20from=20its=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stat spreads are classified as sweepers, glass cannons, tanks, walls, speedsters and so on, judged against the Pokémon's own average so every stage gets a role. Searches can filter by it with role:sweeper, role:special-wall or role:physical. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/dex/filters.test.ts | 6 ++ src/lib/dex/filters.ts | 21 +++++- src/lib/pokemon/role.test.ts | 37 ++++++++++ src/lib/pokemon/role.ts | 134 +++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/lib/pokemon/role.test.ts create mode 100644 src/lib/pokemon/role.ts 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/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))); +} From 90dfa18f0b42515170addc3a2b3e60f2c91d5b9d Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:28:34 +0530 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20frame=20each=20Pok=C3=A9mon=20insid?= =?UTF-8?q?e=20its=20stat=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page header now draws the Pokémon's artwork inside its own stat shape, with the six base stats around the rim. The name fills its column using Archivo's width axis, wide for short names and condensed for long ones, and the header states its role with a link to every Pokémon that shares it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/components/pokemon/fitted-name.svelte | 45 ++++++++++++++++ .../components/pokemon/pokemon-hero.svelte | 52 +++++++++++-------- .../components/pokemon/shape-portrait.svelte | 28 ++++++++++ src/lib/pokemon/name-fit.test.ts | 15 ++++++ src/lib/pokemon/name-fit.ts | 16 ++++++ 5 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 src/lib/components/pokemon/fitted-name.svelte create mode 100644 src/lib/components/pokemon/shape-portrait.svelte create mode 100644 src/lib/pokemon/name-fit.test.ts create mode 100644 src/lib/pokemon/name-fit.ts 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-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/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 }; +} From c1dfda086429349f9e144c8a32a4489c3d01f606 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:28:35 +0530 Subject: [PATCH 3/4] feat: rank stats and suggest counters and look-alikes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base stats show the share of species each one beats. New sections list counters, fully evolved everyday Pokémon that resist all of a Pokémon's same-type attacks and hit it super effectively, and the species with the closest base stats, each a click from comparing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/pokemon/counter-list.svelte | 47 +++++++ src/lib/components/pokemon/stat-bars.svelte | 19 ++- src/lib/components/pokemon/twin-list.svelte | 28 ++++ src/lib/pokemon/detail.ts | 5 + src/lib/pokemon/insights.test.ts | 75 +++++++++++ src/lib/pokemon/insights.ts | 122 ++++++++++++++++++ src/lib/server/pokemon.ts | 25 +++- src/routes/pokemon/[slug]/+page.svelte | 14 +- 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/pokemon/counter-list.svelte create mode 100644 src/lib/components/pokemon/twin-list.svelte create mode 100644 src/lib/pokemon/insights.test.ts create mode 100644 src/lib/pokemon/insights.ts 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/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/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/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 @@
@@ -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/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}. +
+