From 7f12f087187806b85a48fa214762fc8588015477 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 9 Sep 2026 19:22:32 -0400 Subject: [PATCH] Forms in the team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team slots are now { id, form } instead of bare ids (old saves migrate on read via slot()). The detail page's team button adds whatever mechanical form is selected — "+ Mega X" / "✓ Mega X in team" — and TeamView merges that form's baked snapshot types/stats/BST into the member so Coverage, Compare and the Calc all analyse the form (Mega Charizard X shows as Fire/Dragon, BST 634). Lineup shows the form name and sprite; remove targets the exact slot. The Add picker still adds base forms — Megas etc. come from their own page. CompareTable takes an optional spriteId; addToTeam/removeFromTeam/inTeam gain a form argument. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 3 ++- src/components/CompareTable.js | 2 +- src/store/team.js | 35 ++++++++++++++++++++++-------- src/views/PokemonDetail.js | 13 ++++++++---- src/views/TeamView.js | 39 ++++++++++++++++++++++++++-------- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6f3d250..0bccae9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ framework. - **Detail data** (stats, abilities, flavor text, evolution) → fetched lazily from `pokeapi.co` per Pokémon, then cached by the service worker (stale-while-revalidate, 30-day TTL). - **Sprites** → cache-first with a capped LRU. -Export / import of `settings` + `selection` as JSON lives in **Settings**. +Export / import of all local state (settings, tracking, team, form +tracking, shiny hunts) as JSON lives in **Settings**. ## Scripts diff --git a/src/components/CompareTable.js b/src/components/CompareTable.js index 6a2a109..3b5bcad 100644 --- a/src/components/CompareTable.js +++ b/src/components/CompareTable.js @@ -31,7 +31,7 @@ export function CompareTable(members, { gen = 9, style } = {}) { el( 'a', { class: 'cmp__mem', href: `#/pokemon/${sp.id}` }, - Sprite(sp.id, { style, size: 44, alt: sp.name }), + Sprite(sp.spriteId ?? sp.id, { style, size: 44, alt: sp.name }), el('span', {}, sp.name.replace(/-/g, ' ')), ), ), diff --git a/src/store/team.js b/src/store/team.js index 3bed1ea..ba8538d 100644 --- a/src/store/team.js +++ b/src/store/team.js @@ -1,28 +1,45 @@ import { createStore } from './createStore.js'; /** - * The working "lineup" — up to 6 Pokémon (national dex ids) analysed by the - * Team view (coverage) and Compare view. Persisted. + * The working "lineup" — up to 6 slots analysed by the Team view (coverage + * / compare / calc). A slot is `{ id, form }` where `id` is the national + * dex id and `form` is a snapshot form slug (Mega, regional, alt forme) or + * null for the base Pokémon. Persisted; old saves stored bare ids and are + * migrated on read. */ export const team = createStore('pdx.team', { members: [] }); export const MAX_TEAM = 6; -export function addToTeam(id) { +/** Normalise a stored member (number | {id,form}) to {id, form}. */ +export function slot(m) { + return typeof m === 'number' ? { id: m, form: null } : { id: m.id, form: m.form || null }; +} +const sameSlot = (a, b) => a.id === b.id && (a.form || null) === (b.form || null); + +export function members() { + return team.get().members.map(slot); +} + +export function addToTeam(id, form = null) { team.set((s) => { - if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s; - return { ...s, members: [...s.members, id] }; + const list = s.members.map(slot); + if (list.length >= MAX_TEAM || list.some((m) => sameSlot(m, { id, form }))) return s; + return { ...s, members: [...list, { id, form: form || null }] }; }); } -export function removeFromTeam(id) { - team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) })); +export function removeFromTeam(id, form = null) { + team.set((s) => ({ + ...s, + members: s.members.map(slot).filter((m) => !sameSlot(m, { id, form: form || null })), + })); } export function clearTeam() { team.set({ members: [] }); } -export function inTeam(id) { - return team.get().members.includes(id); +export function inTeam(id, form = null) { + return team.get().members.map(slot).some((m) => sameSlot(m, { id, form: form || null })); } diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 1db732a..09c7776 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -254,19 +254,23 @@ export async function PokemonDetail(nationalId) { return b; } + // The active mechanical form (Mega, regional, alt forme) is what gets + // added — its baked types/stats drive the Team coverage/compare/calc. + const teamForm = () => activeForm?.slug || null; const teamBtn = el('button', { class: 'ptrack__btn ptrack__btn--team', type: 'button', onclick: () => { - if (inTeam(nationalId)) removeFromTeam(nationalId); - else addToTeam(nationalId); + if (inTeam(nationalId, teamForm())) removeFromTeam(nationalId, teamForm()); + else addToTeam(nationalId, teamForm()); syncTeamBtn(); }, }); function syncTeamBtn() { - const on = inTeam(nationalId); + const on = inTeam(nationalId, teamForm()); const full = team.get().members.length >= MAX_TEAM; - teamBtn.textContent = on ? '✓ In team' : full ? 'Team full' : '+ Team'; + const label = activeForm ? `${activeForm.name} in team` : 'In team'; + teamBtn.textContent = on ? `✓ ${label}` : full ? 'Team full' : activeForm ? `+ ${activeForm.name}` : '+ Team'; teamBtn.classList.toggle('is-on', on); teamBtn.disabled = !on && full; } @@ -662,6 +666,7 @@ export async function PokemonDetail(nationalId) { activeCosmetic = null; if (cosmeticSelect) cosmeticSelect.value = ''; syncFormPills(); + syncTeamBtn(); heroForm.textContent = form ? form.name : ''; try { pk = form ? await getPokemon(form.slug) : pokemon; diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 0a9a63f..7c7094c 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -2,7 +2,7 @@ import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { settings } from '../store/settings.js'; import { ui } from '../store/ui.js'; -import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js'; +import { team, addToTeam, removeFromTeam, clearTeam, slot, MAX_TEAM } from '../store/team.js'; import { TYPES, defenseVector, multiplier } from '../data/type-chart.js'; import { typesForGen } from '../lib/type-resolve.js'; import { forGeneration } from '../components/MovesList.js'; @@ -80,7 +80,28 @@ export async function TeamView() { ); function members() { - return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean); + return team + .get() + .members.map((m) => { + const s = slot(m); + const base = snap.speciesById.get(s.id); + if (!base) return null; + const f = s.form && (base.forms || []).find((x) => x.slug === s.form); + if (!f) return { ...base, _slotForm: null }; + // Merge the snapshot form's baked types / stats so coverage, + // compare and the calc all analyse the form, not the base. + return { + ...base, + _slotForm: s.form, + name: `${base.name} (${f.name})`, + spriteId: f.id, + types: f.types || base.types, + pastTypes: [], + stats: f.stats || base.stats, + bst: f.bst ?? base.bst, + }; + }) + .filter(Boolean); } function renderLineup() { @@ -99,7 +120,7 @@ export async function TeamView() { type: 'button', 'aria-label': `Remove ${sp.name}`, onclick: () => { - removeFromTeam(sp.id); + removeFromTeam(sp.id, sp._slotForm); }, }, '✕', @@ -107,7 +128,7 @@ export async function TeamView() { el( 'a', { href: `#/pokemon/${sp.id}`, class: 'lineup__link' }, - Sprite(sp.id, { style, alt: sp.name, size: 64 }), + Sprite(sp.spriteId ?? sp.id, { style, alt: sp.name, size: 64 }), el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')), ), ), @@ -120,7 +141,7 @@ export async function TeamView() { { class: 'lineup__add', type: 'button', - onclick: () => openPokemonPicker(addToTeam), + onclick: () => openPokemonPicker((id) => addToTeam(id), { closeAfterPick: true }), }, '+', el('span', {}, 'Add'), @@ -215,7 +236,7 @@ export async function TeamView() { href: `#/pokemon/${sp.id}`, title: `${prettify(sp.name)} takes ${m}×`, }, - Sprite(sp.id, { style, size: 26, alt: sp.name }), + Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }), ), ), ), @@ -234,7 +255,7 @@ export async function TeamView() { el( 'a', { class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) }, - Sprite(sp.id, { style, size: 34, alt: sp.name }), + Sprite(sp.spriteId ?? sp.id, { style, size: 34, alt: sp.name }), ), ), el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'), @@ -410,7 +431,7 @@ export async function TeamView() { ? el( 'button', { type: 'button', class: 'calc__pick', onclick: () => pick() }, - Sprite(sp.id, { style, size: 48, alt: sp.name }), + Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: sp.name }), el('span', {}, prettify(sp.name)), ) : el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}…`), @@ -441,7 +462,7 @@ export async function TeamView() { recalc(); }, }, - Sprite(sp.id, { style, size: 26, alt: sp.name }), + Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }), ), ), )