Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/lib/components/team/suggested-teammates.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import PlusIcon from "@lucide/svelte/icons/plus";
import PokemonArt from "$lib/components/pokemon/pokemon-art.svelte";
import TypeBadge from "$lib/components/pokemon/type-badge.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import type { DexListEntry } from "$lib/dex/list.js";
import { titleCase } from "$lib/pokemon/format.js";
import type { Suggestion } from "$lib/team/analysis.js";

let {
suggestions,
onadd,
}: { suggestions: Suggestion<DexListEntry>[]; onadd: (entry: DexListEntry) => void } = $props();

const list = (types: string[]) => types.map(titleCase).join(", ");
</script>

<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each suggestions as { entry, covers, hits } (entry.id)}
<li class="flex gap-3 rounded-lg border bg-card p-2 pr-3" style:--tint="var(--type-{entry.types[0]})">
<span class="tint-field grid size-16 shrink-0 place-items-center self-start rounded-md p-1">
<PokemonArt id={entry.id} name="" width={96} widths={[96, 192]} sizes="64px" />
</span>
<div class="min-w-0 flex-1 py-0.5">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<a href="/pokemon/{entry.slug}" class="block truncate font-medium hover:underline hover:underline-offset-4">{entry.name}</a>
<div class="mt-1 flex gap-1">
{#each entry.types as type (type)}
<TypeBadge {type} size="sm" />
{/each}
</div>
</div>
<Button variant="outline" size="icon-sm" onclick={() => onadd(entry)} aria-label="Add {entry.name} to your team" title="Add to team">
<PlusIcon />
</Button>
</div>
<dl class="mt-2 grid gap-0.5 text-sm">
{#if covers.length}
<div class="flex gap-1.5">
<dt class="shrink-0 text-muted-foreground">Resists</dt>
<dd>{list(covers)}</dd>
</div>
{/if}
{#if hits.length}
<div class="flex gap-1.5">
<dt class="shrink-0 text-muted-foreground">Hits</dt>
<dd>{list(hits)}</dd>
</div>
{/if}
</dl>
</div>
</li>
{/each}
</ul>
27 changes: 27 additions & 0 deletions src/lib/team/analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseTeam,
sharedWeaknesses,
teamDefense,
suggestTeammates,
teamOffense,
type TeamMember,
} from "./analysis.js";
Expand Down Expand Up @@ -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([]);
});
});
72 changes: 72 additions & 0 deletions src/lib/team/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,75 @@ export function parseTeam(value: string | null): string[] {
.filter(Boolean)
.slice(0, TEAM_SIZE);
}

export interface Suggestion<T> {
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<T extends TeamMember>(
team: readonly TeamMember[],
pool: readonly T[],
count = 6,
): Suggestion<T>[] {
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<T> & { 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<T>[] = [];
const seen = new Map<TypeName, number>();
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;
}
22 changes: 22 additions & 0 deletions src/routes/team/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@
averageStats,
parseTeam,
sharedWeaknesses,
suggestTeammates,
teamDefense,
teamOffense,
type TeamMember,
Expand All @@ -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 {
Expand Down Expand Up @@ -195,6 +205,18 @@
</div>
</section>

{#if suggestions.length}
<section aria-labelledby="suggest-title">
<h2 id="suggest-title" class="section-title">Suggested teammates</h2>
<p class="mt-1 max-w-prose text-sm text-muted-foreground">
Fully evolved Pokémon that resist the attacks your team is most exposed to or hit types it can’t, judged on types.
</p>
<div class="mt-5">
<SuggestedTeammates {suggestions} onadd={add} />
</div>
</section>
{/if}

<section aria-labelledby="defense-title">
<h2 id="defense-title" class="section-title">Defensive matchups</h2>
<p class="mt-1 max-w-prose text-sm text-muted-foreground">
Expand Down
Loading