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