From 91adfd491d15a44d9a94a5cf8ab5f23043318fe3 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:36:32 +0530 Subject: [PATCH] feat: suggest teammates that cover a team's gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a team has room, the team builder suggests fully evolved Pokémon that resist the attacks the team is most exposed to or hit types it can't, and says which. Suggestions avoid adding to an existing weakness and are spread across types, and each can be added in one click. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../team/suggested-teammates.svelte | 55 ++++++++++++++ src/lib/team/analysis.test.ts | 27 +++++++ src/lib/team/analysis.ts | 72 +++++++++++++++++++ src/routes/team/+page.svelte | 22 ++++++ 4 files changed, 176 insertions(+) create mode 100644 src/lib/components/team/suggested-teammates.svelte diff --git a/src/lib/components/team/suggested-teammates.svelte b/src/lib/components/team/suggested-teammates.svelte new file mode 100644 index 0000000..059d225 --- /dev/null +++ b/src/lib/components/team/suggested-teammates.svelte @@ -0,0 +1,55 @@ + + + diff --git a/src/lib/team/analysis.test.ts b/src/lib/team/analysis.test.ts index 5d3aa31..564dfec 100644 --- a/src/lib/team/analysis.test.ts +++ b/src/lib/team/analysis.test.ts @@ -4,6 +4,7 @@ import { parseTeam, sharedWeaknesses, teamDefense, + suggestTeammates, teamOffense, type TeamMember, } from "./analysis.js"; @@ -54,3 +55,29 @@ describe("team analysis", () => { ]); }); }); + +describe("suggestTeammates", () => { + it("prefers Pokémon that resist the team's shared weaknesses", () => { + const pool = [ + { ...member("rhydon", ["ground", "rock"], [105, 130, 120, 45, 45, 40]), speciesId: 112 }, + { ...member("magnezone", ["electric", "steel"], [70, 70, 115, 130, 90, 60]), speciesId: 462 }, + { ...member("pidgeot", ["normal", "flying"], [83, 80, 75, 70, 70, 101]), speciesId: 18 }, + ]; + const [first, ...rest] = suggestTeammates(team, pool); + expect(first.entry.slug).toBe("magnezone"); + expect(first.covers).toEqual(expect.arrayContaining(["rock", "ice"])); + // Pidgeot adds a fourth Rock weakness and patches nothing. + expect(rest.map((s) => s.entry.slug)).not.toContain("pidgeot"); + }); + + it("skips species already on the team and full teams", () => { + const pool = [{ ...member("charizard", ["fire", "flying"]), speciesId: 1 }]; + expect(suggestTeammates(team, pool)).toEqual([]); + expect( + suggestTeammates( + Array.from({ length: 6 }, () => team[0]), + pool, + ), + ).toEqual([]); + }); +}); diff --git a/src/lib/team/analysis.ts b/src/lib/team/analysis.ts index 706bded..a07e9e5 100644 --- a/src/lib/team/analysis.ts +++ b/src/lib/team/analysis.ts @@ -68,3 +68,75 @@ export function parseTeam(value: string | null): string[] { .filter(Boolean) .slice(0, TEAM_SIZE); } + +export interface Suggestion { + entry: T; + /** Types the team is exposed to that this Pokémon resists or ignores. */ + covers: TypeName[]; + /** Types no member hits super effectively that this Pokémon's own types do. */ + hits: TypeName[]; +} + +/** + * Rank Pokémon by how much they patch a team: resisting the attacks the team is most exposed + * to, hitting types the team can't, and not piling onto an existing weakness. + */ +export function suggestTeammates( + team: readonly TeamMember[], + pool: readonly T[], + count = 6, +): Suggestion[] { + if (team.length === 0 || team.length >= TEAM_SIZE) return []; + const exposure = new Map(teamDefense(team).map((row) => [row.attack, row.balance])); + const uncovered = teamOffense(team) + .filter((row) => row.hitters.length === 0) + .map((row) => row.defend); + const species = new Set(team.map((m) => m.speciesId)); + + const scored: (Suggestion & { score: number })[] = []; + for (const entry of pool) { + if (species.has(entry.speciesId)) continue; + const profile = defensiveProfile(entry.types); + let score = 0; + const covers: TypeName[] = []; + for (const attack of TYPES) { + const balance = exposure.get(attack) ?? 0; + const m = profile[attack]; + if (balance > 0 && m < 1) { + score += balance * (m === 0 ? 1.25 : 1); + covers.push(attack); + } else if (m > 1) { + // Another weakness hurts most where the team is already exposed. + score -= balance > 0 ? balance : balance === 0 ? 0.25 : 0; + } + } + const hits = uncovered.filter((defend) => + entry.types.some((t) => effectiveness(t, [defend]) > 1), + ); + score += hits.length * 0.75; + if (covers.length === 0 && hits.length === 0) continue; + const total = entry.stats.reduce((sum, s) => sum + s, 0); + scored.push({ entry, covers, hits, score: score + (total - 500) / 200 }); + } + // Pick greedily, marking down types already suggested so the list offers real alternatives. + const picked: Suggestion[] = []; + const seen = new Map(); + while (picked.length < count && scored.length) { + let best = 0; + let bestScore = -Infinity; + scored.forEach((s, i) => { + const repeat = s.entry.types.reduce((sum, t) => sum + (seen.get(t) ?? 0), 0); + const value = s.score - repeat * 0.6; + if ( + value > bestScore || + (value === bestScore && s.entry.speciesId < scored[best].entry.speciesId) + ) { + [best, bestScore] = [i, value]; + } + }); + const [{ entry, covers, hits }] = scored.splice(best, 1); + picked.push({ entry, covers, hits }); + for (const t of entry.types) seen.set(t, (seen.get(t) ?? 0) + 1); + } + return picked; +} diff --git a/src/routes/team/+page.svelte b/src/routes/team/+page.svelte index 252f7ee..89e0217 100644 --- a/src/routes/team/+page.svelte +++ b/src/routes/team/+page.svelte @@ -8,6 +8,7 @@ import TypeBadge from "$lib/components/pokemon/type-badge.svelte"; import DefenseTable from "$lib/components/team/defense-table.svelte"; import SavedTeams from "$lib/components/team/saved-teams.svelte"; + import SuggestedTeammates from "$lib/components/team/suggested-teammates.svelte"; import TeamSlot from "$lib/components/team/team-slot.svelte"; import { Button } from "$lib/components/ui/button/index.js"; import { loadDexList } from "$lib/dex/client.js"; @@ -19,6 +20,7 @@ averageStats, parseTeam, sharedWeaknesses, + suggestTeammates, teamDefense, teamOffense, type TeamMember, @@ -39,6 +41,14 @@ const averages = $derived(averageStats(team)); const slots = $derived(Array.from({ length: TEAM_SIZE }, (_, i) => team[i])); + // Suggestions come from fully evolved, everyday Pokémon, the same pool as page counters. + const pool = $derived( + [...dex.values()].filter( + (e) => e.isDefault && e.final && !e.legendary && !e.mythical && e.stats.reduce((a, b) => a + b, 0) >= 450, + ), + ); + const suggestions = $derived(suggestTeammates(team, pool)); + const STORAGE_KEY = "dexa:team"; function toMember({ id, slug, name, speciesId, types, stats }: DexListEntry): TeamMember { @@ -195,6 +205,18 @@ + {#if suggestions.length} +
+

Suggested teammates

+

+ Fully evolved Pokémon that resist the attacks your team is most exposed to or hit types it can’t, judged on types. +

+
+ +
+
+ {/if} +

Defensive matchups