diff --git a/.claude/skills/commit-format/SKILL.md b/.claude/skills/commit-format/SKILL.md new file mode 100644 index 0000000..2a36eb0 --- /dev/null +++ b/.claude/skills/commit-format/SKILL.md @@ -0,0 +1,101 @@ +--- +name: commit-format +description: Use when authoring, amending, squashing, fixup-ing, rebasing, or cherry-picking any git commit message in the pokemax repo — covers subject line, bullet body, backtick rules, and the ban on AI attribution. +--- + +# Commit format + +Every commit is a subject line plus a bullet body. Measured across the last 60 commits: 188 bullets, **zero** ending in a period, **zero** prose paragraphs in a body. + +Match `git log`. It is the specification. + +## Subject + +- **`PMX: ` prefix, always** — repo code, colon, one space, then the subject +- Imperative verb first after the prefix — `Add`, `Drop`, `Fold`, `Make`, `Give` +- Sentence case; capitalize the first word after the prefix +- Backtick every identifier, path, script, and package name +- No second prefix — no `feat:`, no `chore:`, no ticket id +- No trailing period +- Target 72 characters including the prefix; the subject body median is 53 + +```text +Good: PMX: Give the region model one owner in `generations.ts` +Good: PMX: Make `isObtainFile` validate the shape its consumers index +Bad: Give the region model one owner in `generations.ts` +Bad: PMX: feat: refactored the region stuff. +``` + +The prefix is on every hand-written commit, version bumps included — `PMX: 0.3.4`. + +`(#5)` suffixes are appended by GitHub on squash-merge; never type one yourself. Commits made before this convention landed keep their bare subjects. + +## Body + +**The body is bullets. Nothing else.** No lede, no prose paragraph, no closing summary. + +- Every line starts `- ` or is a continuation +- Start lowercase — unless the first token is an identifier or proper noun +- No trailing period, ever +- Wrap at ~72 characters; indent continuations 2 spaces +- One fact per bullet + +```text +PMX: Drop the nullable expansion state from `ObtainRegions` + +- `Set | null` existed only because the default region was unknown + until the file loaded — inside `ObtainRegions` it is known at mount +- plain `useState>` with a lazy initializer replaces it +- not routed through `useExpandedRegions` on purpose: its persisted value + would carry one Pokémon's open region onto the next +``` + +## What the bullets say + +The subject says what. The bullets say **why**, and prove it. + +- Lead with the thing that changed, then what happened to it +- Use ` — ` to join a fact to its consequence +- Numbers, not adjectives — `410 lines, ~250 of them static label tables` +- Cite verification inline — e.g. verified all 1351 files in `public/obtain/` still pass +- State what was deliberately not done, and why — the `not routed through` bullet above +- Name the wrong assumption a fix corrects, not just the fix + +## Backticks + +This repo backticks aggressively. Sister repos do not — do not carry their lighter style here. + +Backtick: identifiers, type names, file paths, directories, npm scripts, packages, CSS custom properties and class names, branch names, config keys, literal values. + +## Writing the message + +Use `-F -` with a quoted heredoc. Backticks inside a double-quoted `-m` string are executed by the shell. + +```bash +git add +git commit -F - <<'EOF' +PMX: Subject line here + +- first bullet +- second bullet +EOF +``` + +## Hard rules + +- **Zero AI attribution.** No `Co-Authored-By: Claude`, no "generated with", no agent or model name anywhere in subject or body. +- **One logical change per commit.** Unrelated work gets its own commit. +- **No bullet-per-changed-file.** A file list is not a rationale. + +## Quick reference + +| Element | Rule | +|---|---| +| Prefix | `PMX: ` — mandatory, every commit | +| Subject | Imperative, sentence case, backticked, no period, ≤72 chars | +| Body form | Bullets only — no prose lines | +| Bullet case | Lowercase, unless it opens with an identifier | +| Bullet period | Never | +| Wrap | ~72 chars, continuations indented 2 spaces | +| Content | Why over what; numbers over adjectives | +| Attribution | None, ever | diff --git a/.claude/skills/create-pull-request/SKILL.md b/.claude/skills/create-pull-request/SKILL.md new file mode 100644 index 0000000..c1d9681 --- /dev/null +++ b/.claude/skills/create-pull-request/SKILL.md @@ -0,0 +1,98 @@ +--- +name: create-pull-request +description: Use when opening a pull request in the pokemax repo, writing or rewriting a PR title or body, or editing an existing PR's description — including when the user says "open a PR", "raise a PR", "push this for review", or asks to fix up a PR description. +--- + +# Creating a pull request + +Every pokemax PR reads the same way: a reviewer scans the title, gets the shape from the lede, and finds the detail in bullets. Match the existing PRs (#3, #5, #6) — they are the reference. + +**Bullets are the default. Prose is the exception.** + +## Title + +- **`PMX: ` prefix, always** — repo code, colon, one space, then the title +- Sentence case; no second prefix — no `feat:`, no `chore:`, no ticket id +- Names the outcome, not the mechanics +- Backticks on every identifier, path, and package name +- No trailing period; target 72 characters including the prefix +- Same voice as the commit subjects in `git log` — the title is a commit subject for the whole branch + +```text +Good: PMX: Swap lint, format, and test toolchains for `oxlint`, `oxfmt` +Bad: Swap lint, format, and test toolchains for `oxlint`, `oxfmt` +Bad: PMX: chore: various fixes and improvements. +``` + +## Body contract + +The body is these parts, in this order: + +1. **Lede** — 1–2 sentences, no heading. What the PR does and why. Link prior work as `#5` when it is a follow-up. +2. **`## What changed`** — bullets, one fact each. +3. **`## Verification`** — bullets. Commands actually run, and their results. +4. **`## Notes`** — bullets. Include only when there is something to say. + +**If the change spans more than one subsystem,** replace `## What changed` with one `##` section per subsystem (`## Formatting`, `## Linting`, `## Tests`), each holding its own bullets. + +**If work was deliberately left undone,** it goes in `## Notes` — never omitted. + +## Bullet style + +- One fact per bullet, two lines maximum +- Lead with the thing that changed, then what happened to it +- Backticks on identifiers, paths, commands, scripts, CSS vars, branch names +- Numbers, not adjectives — `130 → 143 tests`, not `more tests` +- No trailing period on a single-clause bullet; use periods when a bullet runs to two sentences +- Cite the evidence inline — e.g. all 1351 files in `public/obtain/` still pass the tightened guard + +## Verification section + +Report what was run, not what should pass: + +- `bun run system-check` green — `format:check`, `tsc -b`, `oxlint`, 143 tests, `vite build` +- Test count before → after +- Any dataset or generated-file impact, including "none" + +A red or skipped check is stated plainly, with the output. Never claim a check passed without running it. + +## Notes section + +The place for judgment the diff cannot show: + +- Deviations from the plan or from review feedback, and why +- Adjacent problems found and deliberately not fixed +- Known limitations shipping with the change +- Why a branch exists at all, given `CLAUDE.md` defaults to `main` + +## Creating it + +Write the body to a file first — backticks inside a double-quoted `--body` string get executed by the shell. + +```bash +cat > /tmp/pr-body.md <<'EOF' + +EOF +gh pr create --base main --title "" --body-file /tmp/pr-body.md +``` + +Editing an existing PR: `gh pr edit <n> --body-file /tmp/pr-body.md`. + +## Hard rules + +- **Zero AI attribution** anywhere in the title or body — no `Co-Authored-By: Claude`, no "generated with", no agent mentions +- **No test plan checkboxes**, no `🤖` footers, no emoji section headers +- **No restating the diff** — a bullet per changed file is not a summary + +## Quick reference + +| Element | Rule | +|---|---| +| Prefix | `PMX: ` — mandatory, every PR title | +| Title | Sentence case, backticked identifiers, ≤72 chars | +| Lede | 1–2 sentences, no heading | +| Sections | `What changed` → `Verification` → `Notes` | +| Body form | Bullets; prose only in the lede | +| Identifiers | Always backticked | +| Evidence | Numbers and command output, never adjectives | +| Attribution | None, ever | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b46cde9..e2e90f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,28 @@ -name: CI +name: 🔁 CI on: pull_request: push: - branches: ['**'] + branches: [main] + workflow_dispatch: +# `pull_request` already covers branch pushes; `branches: ['**']` ran every +# PR commit twice. concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + +env: + BUN_VERSION: 1.4.0 + jobs: checks: name: 🔍 Checks runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: 📥 Checkout uses: actions/checkout@v4 @@ -20,7 +30,14 @@ jobs: - name: 🥟 Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.BUN_VERSION }} + + - name: ♻️ Restore dependency cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-bun- - name: 📦 Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 346af70..66f6910 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to GitHub Pages +name: 🚀 Deploy to GitHub Pages on: push: @@ -14,10 +14,14 @@ concurrency: group: pages cancel-in-progress: true +env: + BUN_VERSION: 1.4.0 + jobs: build: name: 🏗️ Build runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: 📥 Checkout uses: actions/checkout@v4 @@ -25,11 +29,20 @@ jobs: - name: 🥟 Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.BUN_VERSION }} + + - name: ♻️ Restore dependency cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-bun- - name: 📦 Install dependencies run: bun install --frozen-lockfile + # Kept despite CI covering `main` — `workflow_dispatch` can deploy a + # commit CI never gated. - name: 🧪 Test run: bun run test @@ -48,6 +61,7 @@ jobs: name: 🚀 Deploy needs: build runs-on: ubuntu-latest + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.husky/pre-commit b/.husky/pre-commit index fa9eba6..dba7519 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,7 +3,10 @@ staged=$(git diff --cached --name-only --diff-filter=ACMR) [ -n "$staged" ] || exit 0 -unformatted=$(printf '%s\n' "$staged" | tr '\n' '\0' | xargs -0 bunx oxfmt --list-different) +# `|| true`: oxfmt exits 2 when no staged file is a type it formats (a +# docs-only commit). husky runs hooks under `set -e`, so the bare +# assignment would abort the commit. +unformatted=$(printf '%s\n' "$staged" | tr '\n' '\0' | xargs -0 bunx oxfmt --list-different || true) [ -n "$unformatted" ] || exit 0 printf '%s\n' "$unformatted" | tr '\n' '\0' | xargs -0 bunx oxfmt diff --git a/AGENTS.md b/AGENTS.md index 7413a53..01c2e45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ Short, opinionated rules. When something here conflicts with a default behavior, - Commit and push **directly to `main`** after every discrete change. No feature branches, no PRs. - One logical change = one commit. Finish the change → `git add <specific files>` → `git commit` → `git push origin main` → move on. - Write commit messages that explain **why**, not just what. -- Follow the `pokemax-commit-format` skill (`~/.Codex/skills/pokemax-commit-format/SKILL.md`) for subject/body/backtick rules. +- Follow the `commit-format` skill (`.claude/skills/commit-format/SKILL.md`) for subject/body/backtick rules. ### Don't - Don't open pull requests. Don't create branches. - Don't bundle unrelated changes into one commit "to save time" — the user explicitly wants tight, change-per-commit history. - Don't pause to confirm the push each time. Standing authorization is granted for `git push origin main` in this repo. - Don't run `git push --force`, `git reset --hard`, or anything destructive without explicit per-action approval. -- Don't append `Co-Authored-By: Codex` (or any AI/agent attribution) to commits. See the `pokemax-commit-format` skill — zero AI mentions anywhere in the message. +- Don't append `Co-Authored-By: Codex` (or any AI/agent attribution) to commits. See the `commit-format` skill — zero AI mentions anywhere in the message. ## Imports diff --git a/CLAUDE.md b/CLAUDE.md index 1450c17..221de00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,15 @@ Short, opinionated rules. When something here conflicts with a default behavior, - **Branch and open a PR when the user asks for one.** Branch off `main`, keep one-logical-change-per-commit inside the branch, then `gh pr create --base main`. - Prefer one branch over several when the changes touch the same files — parallel branches that rewrite the same file just conflict. - Write commit messages that explain **why**, not just what. -- Follow the `pokemax-commit-format` skill (`~/.claude/skills/pokemax-commit-format/SKILL.md`) for subject/body/backtick rules — PR titles and bodies too, including the zero-AI-attribution rule. +- Follow the `commit-format` skill (`.claude/skills/commit-format/SKILL.md`) for commit subject/body/backtick rules, including the zero-AI-attribution rule. +- Follow the `create-pull-request` skill (`.claude/skills/create-pull-request/SKILL.md`) for every PR title and body — it owns the section order, bullet style, and the same zero-AI-attribution rule. ### Don't - Don't open a PR for routine work the user didn't ask to review — branches are permitted, not the default. - Don't bundle unrelated changes into one commit "to save time" — the user explicitly wants tight, change-per-commit history. - Don't pause to confirm the push each time. Standing authorization is granted for `git push origin main` in this repo. - Don't run `git push --force`, `git reset --hard`, or anything destructive without explicit per-action approval. -- Don't append `Co-Authored-By: Claude` (or any AI/agent attribution) to commits. See the `pokemax-commit-format` skill — zero AI mentions anywhere in the message. +- Don't append `Co-Authored-By: Claude` (or any AI/agent attribution) to commits. See the `commit-format` skill — zero AI mentions anywhere in the message. ## Imports diff --git a/src/__tests__/ObtainMethods.test.tsx b/src/__tests__/ObtainMethods.test.tsx index 91c0d7a..3de5c79 100644 --- a/src/__tests__/ObtainMethods.test.tsx +++ b/src/__tests__/ObtainMethods.test.tsx @@ -2,8 +2,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import ObtainMethods from '@/components/ObtainMethods'; +import type { ObtainState } from '@/hooks/useObtainData'; import type { ObtainFile } from '@/obtain/types'; +const ready = (file: ObtainFile): ObtainState => ({ status: 'ready', file }); + const FILE: ObtainFile = { pokemonId: 25, name: 'pikachu', @@ -52,9 +55,7 @@ const FILE: ObtainFile = { describe('ObtainMethods', () => { it('shows breeding info and the current gen expanded', () => { - const { container } = render( - <ObtainMethods data={FILE} loading={false} error={null} currentGen={1} enabled />, - ); + const { container } = render(<ObtainMethods state={ready(FILE)} currentGen={1} />); expect(screen.getByText(/FIELD\/FAIRY/)).toBeInTheDocument(); expect(screen.getByText(/2,560 STEPS/)).toBeInTheDocument(); // Gen 1 expanded: entries visible @@ -72,13 +73,63 @@ describe('ObtainMethods', () => { }); it('gives a weather condition chip its icon', () => { - render(<ObtainMethods data={FILE} loading={false} error={null} currentGen={1} enabled />); + render(<ObtainMethods state={ready(FILE)} currentGen={1} />); // weather-intense-sun renders with the sun-specific weather glyph // (U+2600 + U+FE0E text-presentation selector) const sunIcon = '☀︎'; expect(screen.getByText(`${sunIcon} INTENSE SUN`)).toBeInTheDocument(); }); + it('renders each condition family with its own icon and label', () => { + const file: ObtainFile = { + pokemonId: 1, + name: 'testmon', + breeding: null, + games: [ + { + gen: 1, + versionGroup: 'red-blue', + versions: ['red'], + entries: [ + { + method: 'grass', + location: 'Route 1', + conditions: [ + 'time-night', + 'season-winter', + 'weather-snow', + 'old-rod', + 'story-progress-hall-of-fame', + 'trade-machoke', + 'slot2-emerald', + 'radio-hoenn', + 'starter-bulbasaur', + 'weekday-tuesday', + 'no-such-family-here', + ], + }, + ], + }, + ], + }; + render(<ObtainMethods state={ready(file)} currentGen={1} />); + // Prefix-stripping families + expect(screen.getByText('◔︎ NIGHT')).toBeInTheDocument(); + expect(screen.getByText('✿︎ WINTER')).toBeInTheDocument(); + expect(screen.getByText('❄︎ SNOW')).toBeInTheDocument(); + expect(screen.getByText('⚑︎ AFTER HALL OF FAME')).toBeInTheDocument(); + expect(screen.getByText('✧︎ TUESDAY')).toBeInTheDocument(); + // Prefix-replacing families + expect(screen.getByText('⇄︎ GIVE MACHOKE')).toBeInTheDocument(); + expect(screen.getByText('◎︎ GBA: EMERALD')).toBeInTheDocument(); + expect(screen.getByText('✧︎ RADIO: HOENN')).toBeInTheDocument(); + expect(screen.getByText('✧︎ STARTER: BULBASAUR')).toBeInTheDocument(); + // Exact-slug family with no common prefix + expect(screen.getByText('≈︎ OLD ROD')).toBeInTheDocument(); + // Unknown slug falls back to the generic chip, slug preserved + expect(screen.getByText('✧︎ NO SUCH FAMILY HERE')).toBeInTheDocument(); + }); + it('labels the special method OTHER, not SPECIAL', () => { const file: ObtainFile = { ...FILE, @@ -91,9 +142,7 @@ describe('ObtainMethods', () => { }, ], }; - const { container } = render( - <ObtainMethods data={file} loading={false} error={null} currentGen={1} enabled />, - ); + const { container } = render(<ObtainMethods state={ready(file)} currentGen={1} />); // The legend also glosses OTHER for `special`, so scope to the entry chip. const entryTag = container.querySelector('.crt-obtain-entries .crt-obtain-tag'); expect(entryTag).toHaveTextContent('OTHER'); @@ -101,7 +150,7 @@ describe('ObtainMethods', () => { }); it('renders a collapsible legend that expands to show glosses', async () => { - render(<ObtainMethods data={FILE} loading={false} error={null} currentGen={1} enabled />); + render(<ObtainMethods state={ready(FILE)} currentGen={1} />); const summary = screen.getByText(/LEGEND/); expect(summary).not.toHaveTextContent('?'); const details = summary.closest('details'); @@ -139,7 +188,7 @@ describe('ObtainMethods', () => { }, ], }; - render(<ObtainMethods data={mixed} loading={false} error={null} currentGen={8} enabled />); + render(<ObtainMethods state={ready(mixed)} currentGen={8} />); // Region tabs, no generation numbers; Hisui is its own tab right after // Sinnoh, labeled as ancient Sinnoh expect(screen.getByRole('button', { name: /GALAR/ })).toBeInTheDocument(); @@ -157,7 +206,7 @@ describe('ObtainMethods', () => { }); it('collapses other gens until toggled', async () => { - render(<ObtainMethods data={FILE} loading={false} error={null} currentGen={1} enabled />); + render(<ObtainMethods state={ready(FILE)} currentGen={1} />); expect(screen.queryByText(/Trade\/migrate/)).not.toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /UNOVA/ })); expect(screen.getByText(/Trade\/migrate/)).toBeInTheDocument(); @@ -165,32 +214,19 @@ describe('ObtainMethods', () => { it('renders the unavailable state on error', () => { render( - <ObtainMethods - data={null} - loading={false} - error="No obtain data (404)" - currentGen={1} - enabled - />, + <ObtainMethods state={{ status: 'error', message: 'No obtain data (404)' }} currentGen={1} />, ); expect(screen.getByText('OBTAIN DATA UNAVAILABLE')).toBeInTheDocument(); }); it('renders a loading line', () => { - render(<ObtainMethods data={null} loading error={null} currentGen={1} enabled />); - expect(screen.getByText(/LOADING/)).toBeInTheDocument(); - }); - - it('shows loading, not unavailable, the instant it is enabled but data has not arrived', () => { - render(<ObtainMethods data={null} loading={false} error={null} currentGen={1} enabled />); + render(<ObtainMethods state={{ status: 'loading' }} currentGen={1} />); expect(screen.getByText(/LOADING/)).toBeInTheDocument(); expect(screen.queryByText('OBTAIN DATA UNAVAILABLE')).not.toBeInTheDocument(); }); - it('stays unavailable (not loading) when disabled with no data', () => { - render( - <ObtainMethods data={null} loading={false} error={null} currentGen={1} enabled={false} />, - ); + it('renders nothing useful before anything has been asked for', () => { + render(<ObtainMethods state={{ status: 'idle' }} currentGen={1} />); expect(screen.getByText('OBTAIN DATA UNAVAILABLE')).toBeInTheDocument(); }); @@ -210,7 +246,7 @@ describe('ObtainMethods', () => { }; // Alolan Vulpix: currentGen is 1 (its national dex gen) but the file // only has gen-7-and-later games. - render(<ObtainMethods data={gen7Only} loading={false} error={null} currentGen={1} enabled />); + render(<ObtainMethods state={ready(gen7Only)} currentGen={1} />); expect(screen.getByText('Mount Lanakila')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/obtainLabels.test.ts b/src/__tests__/obtainLabels.test.ts new file mode 100644 index 0000000..e0dc5a7 --- /dev/null +++ b/src/__tests__/obtainLabels.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { LEGEND_METHODS, METHOD_COLOR, METHOD_LABEL, levelRate } from '@/obtain/labels'; +import { OBTAIN_METHODS } from '@/obtain/types'; + +describe('obtain labels', () => { + it('labels, colors, and glosses every method in OBTAIN_METHODS', () => { + const glossed = LEGEND_METHODS.map(([m]) => m); + for (const m of OBTAIN_METHODS) { + expect(METHOD_LABEL[m], m).toBeTruthy(); + expect(METHOD_COLOR[m], m).toBeTruthy(); + expect(glossed, m).toContain(m); + } + expect(glossed).toHaveLength(OBTAIN_METHODS.length); + }); + + it('formats level and rate, omitting what is unknown', () => { + expect(levelRate({ method: 'grass', minLevel: 3, maxLevel: 5, chance: 45 })).toBe('L3–5 · 45%'); + expect(levelRate({ method: 'grass', minLevel: 7, maxLevel: 7 })).toBe('L7'); + expect(levelRate({ method: 'grass', chance: 10 })).toBe('10%'); + expect(levelRate({ method: 'egg' })).toBe(''); + }); +}); diff --git a/src/__tests__/obtainTypes.test.ts b/src/__tests__/obtainTypes.test.ts index 4a36dee..71056d2 100644 --- a/src/__tests__/obtainTypes.test.ts +++ b/src/__tests__/obtainTypes.test.ts @@ -33,4 +33,35 @@ describe('isObtainFile', () => { expect(isObtainFile(null)).toBe(false); expect(isObtainFile({ pokemonId: 'x' })).toBe(false); }); + + it('accepts a file whose games carry known methods', () => { + const game = { + gen: 1, + versionGroup: 'red-blue', + versions: ['red'], + entries: [{ method: 'grass', location: 'Route 1' }], + }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + true, + ); + }); + + it('rejects a file whose entries carry an unknown method', () => { + const game = { + gen: 1, + versionGroup: 'red-blue', + versions: ['red'], + entries: [{ method: 'teleported-in', location: 'Route 1' }], + }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + false, + ); + }); + + it('rejects a file whose games are missing required fields', () => { + const game = { versionGroup: 'red-blue', entries: [] }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + false, + ); + }); }); diff --git a/src/__tests__/regions.test.ts b/src/__tests__/regions.test.ts new file mode 100644 index 0000000..39dc933 --- /dev/null +++ b/src/__tests__/regions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { GENERATIONS, REGIONS, REGION_OF_VERSION_GROUP } from '@/generations'; +import { GAMES_BY_REGION, GAME_LABELS } from '@/trainers'; + +describe('REGIONS', () => { + it('assigns every version group in GENERATIONS to exactly one region', () => { + const assigned = REGIONS.flatMap((r) => r.versionGroups); + expect(new Set(assigned).size).toBe(assigned.length); + for (const g of GENERATIONS) { + for (const vg of g.versionGroups) { + expect(REGION_OF_VERSION_GROUP[vg], vg).toBeDefined(); + } + } + }); + + it('lists no version group that GENERATIONS does not know about', () => { + const known = new Set(GENERATIONS.flatMap((g) => g.versionGroups)); + for (const vg of REGIONS.flatMap((r) => r.versionGroups)) { + expect(known.has(vg), vg).toBe(true); + } + }); + + it('places Hisui directly after Sinnoh', () => { + const names = REGIONS.map((r) => r.name); + expect(names.indexOf('Hisui')).toBe(names.indexOf('Sinnoh') + 1); + expect(REGIONS.find((r) => r.name === 'Hisui')?.note).toBe('Ancient Sinnoh'); + }); +}); + +describe('GAMES_BY_REGION', () => { + it('derives from REGIONS in the same order', () => { + expect(GAMES_BY_REGION.map((r) => r.region)).toEqual(REGIONS.map((r) => r.name)); + }); + + it('covers every GameId exactly once', () => { + const games = GAMES_BY_REGION.flatMap((r) => r.games); + expect(new Set(games).size).toBe(games.length); + expect(games.sort()).toEqual(Object.keys(GAME_LABELS).sort()); + }); +}); diff --git a/src/__tests__/useObtainData.test.ts b/src/__tests__/useObtainData.test.ts index f593bda..083bf45 100644 --- a/src/__tests__/useObtainData.test.ts +++ b/src/__tests__/useObtainData.test.ts @@ -16,28 +16,58 @@ describe('useObtainData', () => { it('does not fetch until enabled', () => { const spy = vi.fn(); vi.stubGlobal('fetch', spy); - renderHook(() => useObtainData(25, false)); + const { result } = renderHook(() => useObtainData(25, false)); + expect(result.current.status).toBe('idle'); expect(spy).not.toHaveBeenCalled(); }); + it('reports loading on the very render that enables it', () => { + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + const { result } = renderHook(() => useObtainData(4242, true)); + // The fetch effect has not run yet — this must not read as an error state. + expect(result.current.status).toBe('loading'); + }); + it('fetches once enabled and caches per id', async () => { const spy = vi.fn().mockResolvedValue(okResponse(FILE)); vi.stubGlobal('fetch', spy); const { result } = renderHook(() => useObtainData(25, true)); - await waitFor(() => expect(result.current.data).not.toBeNull()); - expect(result.current.data?.name).toBe('pikachu'); + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.status === 'ready' && result.current.file.name).toBe('pikachu'); expect(spy).toHaveBeenCalledTimes(1); expect(String(spy.mock.calls[0][0])).toContain('obtain/25.json'); const again = renderHook(() => useObtainData(25, true)); - await waitFor(() => expect(again.result.current.data).not.toBeNull()); + await waitFor(() => expect(again.result.current.status).toBe('ready')); expect(spy).toHaveBeenCalledTimes(1); // served from module cache }); + it('serves a cached file even while disabled', () => { + vi.stubGlobal('fetch', vi.fn()); + // Id 25 was cached by the previous test. + const { result } = renderHook(() => useObtainData(25, false)); + expect(result.current.status).toBe('ready'); + }); + it('reports an error on 404', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 }))); const { result } = renderHook(() => useObtainData(31337, true)); - await waitFor(() => expect(result.current.error).not.toBeNull()); - expect(result.current.data).toBeNull(); + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current.status === 'error' && result.current.message).toContain('404'); + }); + + it('never surfaces one id error under another id', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValue(okResponse({ ...FILE, pokemonId: 77, name: 'seaking' })); + vi.stubGlobal('fetch', spy); + const { result, rerender } = renderHook(({ id }) => useObtainData(id, true), { + initialProps: { id: 6001 }, + }); + await waitFor(() => expect(result.current.status).toBe('error')); + rerender({ id: 6002 }); + expect(result.current.status).not.toBe('error'); + await waitFor(() => expect(result.current.status).toBe('ready')); }); }); diff --git a/src/components/ObtainMethods.tsx b/src/components/ObtainMethods.tsx index 7703e30..883cbca 100644 --- a/src/components/ObtainMethods.tsx +++ b/src/components/ObtainMethods.tsx @@ -1,245 +1,37 @@ import { useState, type SyntheticEvent } from 'react'; -import { getGen } from '@/generations'; +import { getGen, REGIONS, REGION_OF_VERSION_GROUP, type RegionMeta } from '@/generations'; +import type { ObtainState } from '@/hooks/useObtainData'; +import { + conditionMeta, + LEGEND_CONDITIONS, + LEGEND_METHODS, + levelRate, + METHOD_COLOR, + METHOD_LABEL, + prettyCondition, +} from '@/obtain/labels'; import type { ObtainEntry, ObtainFile, ObtainGame } from '@/obtain/types'; -import { TYPE_COLORS } from '@/typeChart'; interface Props { - data: ObtainFile | null; - loading: boolean; - error: string | null; + state: ObtainState; currentGen: number; - /** Whether the HOW TO OBTAIN section is open (data fetch is gated on this). */ - enabled: boolean; } -const METHOD_LABEL: Record<ObtainEntry['method'], string> = { - grass: 'GRASS', - surf: 'SURF', - fish: 'FISH', - cave: 'CAVE', - wild: 'WILD', - static: 'STATIC', - gift: 'GIFT', - trade: 'TRADE', - egg: 'EGG', - evolve: 'EVOLVE', - transfer: 'TRANSFER', - unavailable: 'N/A', - special: 'OTHER', -}; - -// Reuse the same type-color palette the type pills use elsewhere in the card -// (see `PokemonCard.tsx`'s `crt-type` styling) instead of the flat 4-tint set. -const METHOD_COLOR: Record<ObtainEntry['method'], string> = { - grass: TYPE_COLORS.grass, - surf: TYPE_COLORS.water, - fish: TYPE_COLORS.ice, - cave: TYPE_COLORS.ground, - wild: TYPE_COLORS.normal, - static: TYPE_COLORS.electric, - gift: TYPE_COLORS.fairy, - trade: TYPE_COLORS.psychic, - egg: TYPE_COLORS.poison, - evolve: TYPE_COLORS.dragon, - transfer: 'var(--dim)', - unavailable: 'var(--dim)', - special: TYPE_COLORS.steel, -}; - -// Version groups whose home region differs from their generation's default -// region (see `generations.ts`) — remakes, spin-offs, and DLC pairs that -// don't share their gen-mates' setting. -const VG_REGION: Record<string, string> = { - 'firered-leafgreen': 'Kanto', - 'heartgold-soulsilver': 'Johto', - 'omega-ruby-alpha-sapphire': 'Hoenn', - 'lets-go-pikachu-lets-go-eevee': 'Kanto', - 'brilliant-diamond-shining-pearl': 'Sinnoh', - 'legends-arceus': 'Hisui', -}; - // Groups are by REGION, not generation — BDSP belongs with the other Sinnoh -// games regardless of when it was released. -// Hisui sits directly under Sinnoh (it's the same land, ancient era). -const REGION_ORDER = [ - 'Kanto', - 'Johto', - 'Hoenn', - 'Sinnoh', - 'Hisui', - 'Unova', - 'Kalos', - 'Alola', - 'Galar', - 'Paldea', -]; - -const REGION_LABELS: Record<string, string> = { - Hisui: 'HISUI · ANCIENT SINNOH', -}; - -function regionLabel(region: string): string { - return REGION_LABELS[region] ?? region.toUpperCase(); +// games regardless of when it shipped, and Hisui sits under Sinnoh. Both facts +// live in the canonical `REGIONS` model. +function regionLabel({ name, note }: RegionMeta): string { + return note ? `${name.toUpperCase()} · ${note.toUpperCase()}` : name.toUpperCase(); } function regionOf(game: ObtainGame): string { - return VG_REGION[game.versionGroup] ?? getGen(game.gen).region; + return REGION_OF_VERSION_GROUP[game.versionGroup]; } function prettyVersions(versions: string[]): string { return versions.map((v) => v.toUpperCase().replace(/-/g, ' ')).join(' / '); } -// Raw PokéAPI condition slugs read badly on chips ("weather-intense-sun", -// "story-progress-hall-of-fame"); shorten the noisy families before the -// generic uppercase fallback. -const CONDITION_LABELS: Record<string, string> = { - 'weather-normal': 'CLEAR WEATHER', - 'slot2-none': 'NO GBA CART', - 'swarm-yes': 'DAILY SWARM', - 'swarm-no': 'NO SWARM ACTIVE', - 'radar-on': 'POKéRADAR', - 'radar-off': 'NO POKéRADAR', - 'radio-off': 'NO RADIO', - 'bug-catching-contest-yes': 'BUG CONTEST', - 'bug-catching-contest-no': 'NO BUG CONTEST', - 'max-den-rarity-common': 'COMMON DEN', - 'max-den-rarity-rare': 'RARE DEN', - 'max-den-rarity-special': 'SPECIAL DEN', - 'story-progress-before-hall-of-fame': 'BEFORE HALL OF FAME', - 'story-progress-hall-of-fame': 'AFTER HALL OF FAME', - overworld: 'VISIBLE SPAWN', - 'overworld-water': 'VISIBLE ON WATER', - 'overworld-flying': 'VISIBLE FLYING', - 'overworld-dirt': 'VISIBLE ON DIRT', - 'overworld-special': 'RARE VISIBLE SPAWN', - 'overworld-flying-special': 'RARE VISIBLE FLYING', - 'overworld-water-special': 'RARE VISIBLE ON WATER', - wanderer: 'WANDERING SPAWN', - 'wanderer-water': 'WANDERING ON WATER', - 'bubbling-spots': 'BUBBLING SPOT', - 'super-rod-spots': 'SUPER ROD SPOT', - 'surf-spots': 'SURF SPOT', - 'grass-spots': 'GRASS SPOT', - 'cave-spots': 'CAVE SPOT', - 'bridge-spots': 'BRIDGE SPOT', - 'ceiling-ambush': 'CEILING AMBUSH', - 'ground-ambush': 'GROUND AMBUSH', - 'sky-ambush': 'SKY AMBUSH', - 'rustling-bush-ambush': 'RUSTLING BUSH', - 'trash-can-ambush': 'TRASH CAN AMBUSH', - 'berry-trees': 'SHAKING BERRY TREE', - 'feebas-tile-fishing': 'SPECIAL FISHING TILES', - static: 'FIXED ENCOUNTER', - 'hidden-grotto': 'HIDDEN GROTTO', - horde: 'HORDE ENCOUNTER', - sos: 'SOS CALL', - 'sos-from-bubbling-spot': 'SOS AT BUBBLING SPOT', - 'island-scan': 'ISLAND SCAN', - 'honey-tree': 'HONEY TREE', - headbutt: 'HEADBUTT TREE', - 'headbutt-low': 'HEADBUTT TREE (LOW RATE)', - 'headbutt-normal': 'HEADBUTT TREE (NORMAL RATE)', - 'headbutt-high': 'HEADBUTT TREE (HIGH RATE)', - 'headbutt-tree-common': 'HEADBUTT TREE (COMMON)', - 'headbutt-tree-rare': 'HEADBUTT TREE (RARE)', - 'headbutt-tree-secret': 'HEADBUTT TREE (SECRET)', - 'johto-safari-blocks-inactive': 'SAFARI ZONE: NO BLOCKS', - 'backlot-mentioned': 'MENTIONED BY MR. BACKLOT', - 'backlot-not-mentioned': 'NOT MENTIONED BY MR. BACKLOT', - pokeflute: 'POKé FLUTE', - 'squirt-bottle': 'SQUIRT BOTTLE', - 'wailmer-pail': 'WAILMER PAIL', - 'devon-scope': 'DEVON SCOPE', - 'colosseum-bonus-disc-jpn': 'COLOSSEUM BONUS DISC (JP)', - 'colosseum-bonus-disc-us': 'COLOSSEUM BONUS DISC', - 'tv-option-red': 'TV SET TO RED', - 'tv-option-blue': 'TV SET TO BLUE', - 'save-data-from-lets-go-pikachu': "LET'S GO PIKACHU SAVE DATA", - 'save-data-from-lets-go-eevee': "LET'S GO EEVEE SAVE DATA", - 'max-raid': 'MAX RAID DEN', -}; - -const CONDITION_PREFIXES: [RegExp, string][] = [ - [/^time-/, ''], - [/^season-/, ''], - [/^weather-/, ''], - [/^weekday-/, ''], - [/^story-progress-/, ''], - [/^other-/, ''], - [/^item-/, ''], - [/^slot2-/, 'GBA: '], - [/^radio-/, 'RADIO: '], - [/^trade-/, 'GIVE '], - [/^starter-/, 'STARTER: '], -]; - -function prettyCondition(slug: string): string { - const mapped = CONDITION_LABELS[slug]; - if (mapped) return mapped; - const star = slug.match(/^max-den-rating-(\d)-star$/); - if (star) return `${star[1]}★ DEN`; - const coins = slug.match(/^coins-(\d+)$/); - if (coins) return `${parseInt(coins[1], 10).toLocaleString('en-US')} COINS`; - const berryType = slug.match(/^berry-tree-type-(\w+)$/); - if (berryType) return `${berryType[1].toUpperCase()} BERRY TREE`; - const honeyGroup = slug.match(/^honey-tree-group-(\w)$/); - if (honeyGroup) return `HONEY TREE (GROUP ${honeyGroup[1].toUpperCase()})`; - const safariSlot = slug.match(/^friend-safari-slot-(\d)$/); - if (safariSlot) return `FRIEND SAFARI SLOT ${safariSlot[1]}`; - if (slug.startsWith('great-marsh-daily-slot-')) return 'GREAT MARSH DAILY ROTATION'; - const johtoBlocks = slug.match(/^johto-safari-blocks-(\w+)-min-(\d+)$/); - if (johtoBlocks) return `SAFARI ZONE: ${johtoBlocks[2]}+ ${johtoBlocks[1].toUpperCase()} BLOCKS`; - let s = slug; - for (const [re, repl] of CONDITION_PREFIXES) { - if (re.test(s)) { - s = s.replace(re, repl); - break; - } - } - return s.toUpperCase().replace(/-/g, ' '); -} - -const RODS = new Set(['old-rod', 'good-rod', 'super-rod', 'super-rod-spots']); - -// Text-presentation selector — keeps these glyphs flat/monochrome instead of -// letting a platform render an emoji-colored version. -const VS = '︎'; - -interface ConditionMeta { - icon: string; - color: string; -} - -function conditionMeta(slug: string): ConditionMeta { - if (slug.startsWith('time-')) return { icon: `◔${VS}`, color: TYPE_COLORS.electric }; - if (slug.startsWith('season-')) return { icon: `✿${VS}`, color: TYPE_COLORS.fairy }; - if (slug.startsWith('weather-')) { - let icon = `☂${VS}`; - if (slug.includes('intense-sun')) icon = `☀${VS}`; - else if (slug.includes('snow')) icon = `❄${VS}`; - else if (slug.includes('thunderstorm')) icon = `⚡${VS}`; - return { icon, color: TYPE_COLORS.water }; - } - if (RODS.has(slug)) return { icon: `≈${VS}`, color: TYPE_COLORS.ice }; - if (slug.startsWith('max-raid') || slug.startsWith('max-den-')) { - return { icon: `★${VS}`, color: TYPE_COLORS.fire }; - } - if (slug.startsWith('story-progress-')) return { icon: `⚑${VS}`, color: TYPE_COLORS.psychic }; - if (slug.startsWith('trade-')) return { icon: `⇄${VS}`, color: TYPE_COLORS.psychic }; - if (slug.startsWith('slot2-')) return { icon: `◎${VS}`, color: TYPE_COLORS.steel }; - return { icon: `✧${VS}`, color: 'var(--dim)' }; -} - -function levelRate(e: ObtainEntry): string { - const bits: string[] = []; - if (e.minLevel !== undefined && e.maxLevel !== undefined) { - bits.push(e.minLevel === e.maxLevel ? `L${e.minLevel}` : `L${e.minLevel}–${e.maxLevel}`); - } - if (e.chance !== undefined) bits.push(`${e.chance}%`); - return bits.join(' · '); -} - // Sanctioned colorful-chip pattern from `PokemonCard.tsx`'s type pills: // palette color as text + border, plus a matching low-opacity glow. function tintStyle(color: string): React.CSSProperties { @@ -281,39 +73,6 @@ function GameRow({ game }: { game: ObtainGame }) { ); } -const LEGEND_METHODS: [ObtainEntry['method'], string][] = [ - ['grass', 'wild grass'], - ['surf', 'while surfing'], - ['fish', 'fishing rod'], - ['cave', 'cave/rock smash'], - ['wild', 'wild (method unknown)'], - ['static', 'fixed encounter'], - ['gift', 'from an NPC'], - ['trade', 'in-game NPC trade'], - ['egg', 'breed & hatch'], - ['evolve', 'evolve pre-evolution'], - ['transfer', 'from another game'], - ['unavailable', 'not available'], - ['special', 'special method (headbutt, island scan, honey tree…)'], -]; - -const LEGEND_CONDITIONS: { icon: string; color: string; label: string }[] = [ - { icon: `◔${VS}`, color: TYPE_COLORS.electric, label: 'time of day' }, - { icon: `✿${VS}`, color: TYPE_COLORS.fairy, label: 'season' }, - { icon: `☂${VS}`, color: TYPE_COLORS.water, label: 'weather' }, - { icon: `≈${VS}`, color: TYPE_COLORS.ice, label: 'fishing rod' }, - { icon: `★${VS}`, color: TYPE_COLORS.fire, label: 'raid den' }, - { icon: `⚑${VS}`, color: TYPE_COLORS.psychic, label: 'story progress' }, - { icon: `⇄${VS}`, color: TYPE_COLORS.psychic, label: 'required trade' }, - { icon: `◎${VS}`, color: TYPE_COLORS.steel, label: 'GBA cartridge' }, - { - icon: `✧${VS}`, - color: 'var(--dim)', - label: - 'special requirement — the chip says which (swarms, honey trees, safari zones, SOS calls, hidden grottoes…)', - }, -]; - function Legend() { const [open, setOpen] = useState(false); return ( @@ -352,26 +111,23 @@ function Legend() { ); } -export default function ObtainMethods({ data, loading, error, currentGen, enabled }: Props) { - const [userExpanded, setUserExpanded] = useState<Set<string> | null>(null); - // Between `enabled` flipping true and the fetch effect's first state update, - // `loading` is still false — treat that gap as loading too so there's no - // one-frame "UNAVAILABLE" flash before the request even starts. - if (loading || (enabled && !data && !error)) { - return <div className="crt-obtain-status">LOADING OBTAIN DATA…</div>; - } - if (error || !data) return <div className="crt-obtain-status">OBTAIN DATA UNAVAILABLE</div>; - - const regions = REGION_ORDER.filter((r) => data.games.some((g) => regionOf(g) === r)); +// Mounted only once the file is loaded, so the default open region is known at +// mount and expansion is plain, non-nullable state. Deliberately NOT persisted +// via `useExpandedRegions` — that hook's stored value would carry one Pokémon's +// open region onto the next, whose regions are a different set entirely. +function ObtainRegions({ file, currentGen }: { file: ObtainFile; currentGen: number }) { + const regions = REGIONS.filter((r) => file.games.some((g) => regionOf(g) === r.name)); // Regional forms can carry a `currentGen` whose home region the file's // games never reach (e.g. Alolan Vulpix is gen 1, but its file starts in // Alola) — default to the first region actually present. const homeRegion = getGen(currentGen).region; - const defaultRegion = regions.includes(homeRegion) ? homeRegion : regions[0]; - const expanded = userExpanded ?? new Set(defaultRegion === undefined ? [] : [defaultRegion]); + const defaultRegion = regions.some((r) => r.name === homeRegion) ? homeRegion : regions[0]?.name; + const [expanded, setExpanded] = useState<Set<string>>( + () => new Set(defaultRegion === undefined ? [] : [defaultRegion]), + ); const toggle = (region: string) => - setUserExpanded((prev) => { - const next = new Set(prev ?? expanded); + setExpanded((prev) => { + const next = new Set(prev); if (next.has(region)) next.delete(region); else next.add(region); return next; @@ -379,25 +135,25 @@ export default function ObtainMethods({ data, loading, error, currentGen, enable return ( <div className="crt-obtain"> - {data.breeding && ( + {file.breeding && ( <div className="crt-obtain-breeding"> - EGG GROUPS: {data.breeding.eggGroups.map((g) => g.toUpperCase()).join('/')} - {data.breeding.breedable - ? ` · HATCH: ${data.breeding.hatchCycles} CYCLES (${data.breeding.steps.toLocaleString('en-US')} STEPS)` + EGG GROUPS: {file.breeding.eggGroups.map((g) => g.toUpperCase()).join('/')} + {file.breeding.breedable + ? ` · HATCH: ${file.breeding.hatchCycles} CYCLES (${file.breeding.steps.toLocaleString('en-US')} STEPS)` : ' · CANNOT BREED'} </div> )} <Legend /> {regions.map((region) => { - const open = expanded.has(region); - const gamesInRegion = data.games.filter((g) => regionOf(g) === region); + const open = expanded.has(region.name); + const gamesInRegion = file.games.filter((g) => regionOf(g) === region.name); return ( - <div key={region} className="crt-obtain-gen"> + <div key={region.name} className="crt-obtain-region"> <button type="button" - className="crt-obtain-gen-toggle" + className="crt-obtain-region-toggle" aria-expanded={open} - onClick={() => toggle(region)} + onClick={() => toggle(region.name)} > {open ? '▼' : '▶'} {regionLabel(region)} </button> @@ -408,3 +164,13 @@ export default function ObtainMethods({ data, loading, error, currentGen, enable </div> ); } + +export default function ObtainMethods({ state, currentGen }: Props) { + if (state.status === 'loading') { + return <div className="crt-obtain-status">LOADING OBTAIN DATA…</div>; + } + if (state.status !== 'ready') { + return <div className="crt-obtain-status">OBTAIN DATA UNAVAILABLE</div>; + } + return <ObtainRegions file={state.file} currentGen={currentGen} />; +} diff --git a/src/components/PokemonCard.tsx b/src/components/PokemonCard.tsx index d79f2be..bcad508 100644 --- a/src/components/PokemonCard.tsx +++ b/src/components/PokemonCard.tsx @@ -609,18 +609,11 @@ export default function PokemonCard({ <Section label="HOW TO OBTAIN" - count={obtain.data ? obtain.data.games.length : undefined} + count={obtain.status === 'ready' ? obtain.file.games.length : undefined} defaultOpen={false} onToggle={setObtainOpen} > - <ObtainMethods - key={pokemon.id} - data={obtain.data} - loading={obtain.loading} - error={obtain.error} - currentGen={gen} - enabled={obtainOpen} - /> + <ObtainMethods key={pokemon.id} state={obtain} currentGen={gen} /> </Section> <Section label={`MOVES (${movesLabel})`} count={moveCount}> diff --git a/src/generations.ts b/src/generations.ts index 9ae78b5..4cab174 100644 --- a/src/generations.ts +++ b/src/generations.ts @@ -25,3 +25,39 @@ export const GENERATIONS: GenerationMeta[] = [ export function getGen(num: number): GenerationMeta { return GENERATIONS.find((g) => g.num === num) ?? GENERATIONS[7]; } + +export interface RegionMeta { + name: string; + /** Shown beside the name when the region needs context ("Ancient Sinnoh"). */ + note?: string; + /** Version groups set in this region, in release order. */ + versionGroups: string[]; +} + +/** + * The canonical region model: every version group grouped by the region it is + * SET in, regions in first-appearance order. This is deliberately not the same + * as `GenerationMeta.region` — a remake belongs to its setting, not to the + * generation that shipped it (BDSP is Sinnoh, not Galar). Hisui is its own + * region placed right after Sinnoh, since it's Sinnoh's ancient past. + * + * `GAMES_BY_REGION` (`trainers.ts`) and the obtain panel both derive from this + * — `obtainTypes.test.ts` pins the coverage against `GENERATIONS`. + */ +// prettier-ignore +export const REGIONS: RegionMeta[] = [ + { name: 'Kanto', versionGroups: ['red-blue', 'yellow', 'firered-leafgreen', 'lets-go-pikachu-lets-go-eevee'] }, + { name: 'Johto', versionGroups: ['gold-silver', 'crystal', 'heartgold-soulsilver'] }, + { name: 'Hoenn', versionGroups: ['ruby-sapphire', 'emerald', 'omega-ruby-alpha-sapphire'] }, + { name: 'Sinnoh', versionGroups: ['diamond-pearl', 'platinum', 'brilliant-diamond-shining-pearl'] }, + { name: 'Hisui', note: 'Ancient Sinnoh', versionGroups: ['legends-arceus'] }, + { name: 'Unova', versionGroups: ['black-white', 'black-2-white-2'] }, + { name: 'Kalos', versionGroups: ['x-y'] }, + { name: 'Alola', versionGroups: ['sun-moon', 'ultra-sun-ultra-moon'] }, + { name: 'Galar', versionGroups: ['sword-shield'] }, + { name: 'Paldea', versionGroups: ['scarlet-violet'] }, +]; + +export const REGION_OF_VERSION_GROUP: Record<string, string> = Object.fromEntries( + REGIONS.flatMap((r) => r.versionGroups.map((vg) => [vg, r.name])), +); diff --git a/src/hooks/useObtainData.ts b/src/hooks/useObtainData.ts index fac6291..60a6308 100644 --- a/src/hooks/useObtainData.ts +++ b/src/hooks/useObtainData.ts @@ -4,61 +4,64 @@ import { isObtainFile, type ObtainFile } from '@/obtain/types'; const cache = new Map<number, ObtainFile>(); const inflight = new Map<number, Promise<ObtainFile>>(); -interface ObtainState { - data: ObtainFile | null; - loading: boolean; - error: string | null; +/** + * `idle` means nobody has asked yet (the section is closed) — distinct from + * `error`, which means we asked and it didn't work. + */ +export type ObtainState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; file: ObtainFile }; + +function load(pokemonId: number): Promise<ObtainFile> { + const existing = inflight.get(pokemonId); + if (existing) return existing; + const p = fetch(`${import.meta.env.BASE_URL}obtain/${pokemonId}.json`) + .then((r) => { + if (!r.ok) throw new Error(`No obtain data (${r.status})`); + return r.json(); + }) + .then((json: unknown) => { + if (!isObtainFile(json)) throw new Error('Malformed obtain data'); + cache.set(pokemonId, json); + inflight.delete(pokemonId); + return json; + }); + inflight.set(pokemonId, p); + return p; } export function useObtainData(pokemonId: number, enabled: boolean): ObtainState { - const [state, setState] = useState<ObtainState>(() => ({ - data: cache.get(pokemonId) ?? null, - loading: false, - error: null, - })); + // A success lands in `cache`, which the render path below reads directly, so + // state only records THAT a fetch settled and whether it failed. `id` scopes + // that to the Pokémon it happened to, so a change of `pokemonId` can never + // surface a stale outcome. Always a fresh object — same-value `setState` + // would bail out of the re-render that reveals the cached file. + const [settled, setSettled] = useState<{ id: number; message: string | null } | null>(null); useEffect(() => { - if (!enabled) return; - const cached = cache.get(pokemonId); - if (cached) { - setState({ data: cached, loading: false, error: null }); - return; - } + if (!enabled || cache.has(pokemonId)) return; let active = true; - setState({ data: null, loading: true, error: null }); - - let p = inflight.get(pokemonId); - if (!p) { - p = fetch(`${import.meta.env.BASE_URL}obtain/${pokemonId}.json`) - .then((r) => { - if (!r.ok) throw new Error(`No obtain data (${r.status})`); - return r.json(); - }) - .then((json: unknown) => { - if (!isObtainFile(json)) throw new Error('Malformed obtain data'); - cache.set(pokemonId, json); - inflight.delete(pokemonId); - return json; - }); - inflight.set(pokemonId, p); - } - - p.then((data) => { - if (active) setState({ data, loading: false, error: null }); - }).catch((e: Error) => { - inflight.delete(pokemonId); - if (active) setState({ data: null, loading: false, error: e.message }); - }); - + load(pokemonId) + .then(() => { + if (active) setSettled({ id: pokemonId, message: null }); + }) + .catch((e: Error) => { + inflight.delete(pokemonId); + if (active) setSettled({ id: pokemonId, message: e.message }); + }); return () => { active = false; }; }, [pokemonId, enabled]); - // `state.data` can lag a `pokemonId` change by a frame (or forever, when - // `enabled` is false and the fetch effect never ran) — never surface a - // stale Pokémon's data under the current id. - const data = - state.data && state.data.pokemonId === pokemonId ? state.data : (cache.get(pokemonId) ?? null); - return { ...state, data }; + const cached = cache.get(pokemonId); + if (cached) return { status: 'ready', file: cached }; + if (settled?.id === pokemonId && settled.message !== null) { + return { status: 'error', message: settled.message }; + } + // `enabled` flipping true and the effect's first run are a frame apart — + // report loading from the render that turned it on, not the one after. + return enabled ? { status: 'loading' } : { status: 'idle' }; } diff --git a/src/obtain/labels.ts b/src/obtain/labels.ts new file mode 100644 index 0000000..b8ea8be --- /dev/null +++ b/src/obtain/labels.ts @@ -0,0 +1,271 @@ +// Display vocabulary for the obtain dataset: how each method, condition slug, +// and level/rate reads on screen. Kept out of `ObtainMethods.tsx` so the +// component is rendering logic and this is the table it renders from. +import type { ObtainEntry } from '@/obtain/types'; +import { ROD_METHODS } from '@/obtain/pokeapi'; +import { TYPE_COLORS } from '@/typeChart'; + +export const METHOD_LABEL: Record<ObtainEntry['method'], string> = { + grass: 'GRASS', + surf: 'SURF', + fish: 'FISH', + cave: 'CAVE', + wild: 'WILD', + static: 'STATIC', + gift: 'GIFT', + trade: 'TRADE', + egg: 'EGG', + evolve: 'EVOLVE', + transfer: 'TRANSFER', + unavailable: 'N/A', + special: 'OTHER', +}; + +// Reuse the same type-color palette the type pills use elsewhere in the card +// (see `PokemonCard.tsx`'s `crt-type` styling) instead of the flat 4-tint set. +export const METHOD_COLOR: Record<ObtainEntry['method'], string> = { + grass: TYPE_COLORS.grass, + surf: TYPE_COLORS.water, + fish: TYPE_COLORS.ice, + cave: TYPE_COLORS.ground, + wild: TYPE_COLORS.normal, + static: TYPE_COLORS.electric, + gift: TYPE_COLORS.fairy, + trade: TYPE_COLORS.psychic, + egg: TYPE_COLORS.poison, + evolve: TYPE_COLORS.dragon, + transfer: 'var(--dim)', + unavailable: 'var(--dim)', + special: TYPE_COLORS.steel, +}; + +// Text-presentation selector — keeps these glyphs flat/monochrome instead of +// letting a platform render an emoji-colored version. +const VS = '︎'; + +export interface ConditionMeta { + icon: string; + color: string; +} +// Raw PokéAPI condition slugs read badly on chips ("weather-intense-sun", +// "story-progress-hall-of-fame"); shorten the noisy families before the +// generic uppercase fallback. +export const CONDITION_LABELS: Record<string, string> = { + 'weather-normal': 'CLEAR WEATHER', + 'slot2-none': 'NO GBA CART', + 'swarm-yes': 'DAILY SWARM', + 'swarm-no': 'NO SWARM ACTIVE', + 'radar-on': 'POKéRADAR', + 'radar-off': 'NO POKéRADAR', + 'radio-off': 'NO RADIO', + 'bug-catching-contest-yes': 'BUG CONTEST', + 'bug-catching-contest-no': 'NO BUG CONTEST', + 'max-den-rarity-common': 'COMMON DEN', + 'max-den-rarity-rare': 'RARE DEN', + 'max-den-rarity-special': 'SPECIAL DEN', + 'story-progress-before-hall-of-fame': 'BEFORE HALL OF FAME', + 'story-progress-hall-of-fame': 'AFTER HALL OF FAME', + overworld: 'VISIBLE SPAWN', + 'overworld-water': 'VISIBLE ON WATER', + 'overworld-flying': 'VISIBLE FLYING', + 'overworld-dirt': 'VISIBLE ON DIRT', + 'overworld-special': 'RARE VISIBLE SPAWN', + 'overworld-flying-special': 'RARE VISIBLE FLYING', + 'overworld-water-special': 'RARE VISIBLE ON WATER', + wanderer: 'WANDERING SPAWN', + 'wanderer-water': 'WANDERING ON WATER', + 'bubbling-spots': 'BUBBLING SPOT', + 'super-rod-spots': 'SUPER ROD SPOT', + 'surf-spots': 'SURF SPOT', + 'grass-spots': 'GRASS SPOT', + 'cave-spots': 'CAVE SPOT', + 'bridge-spots': 'BRIDGE SPOT', + 'ceiling-ambush': 'CEILING AMBUSH', + 'ground-ambush': 'GROUND AMBUSH', + 'sky-ambush': 'SKY AMBUSH', + 'rustling-bush-ambush': 'RUSTLING BUSH', + 'trash-can-ambush': 'TRASH CAN AMBUSH', + 'berry-trees': 'SHAKING BERRY TREE', + 'feebas-tile-fishing': 'SPECIAL FISHING TILES', + static: 'FIXED ENCOUNTER', + 'hidden-grotto': 'HIDDEN GROTTO', + horde: 'HORDE ENCOUNTER', + sos: 'SOS CALL', + 'sos-from-bubbling-spot': 'SOS AT BUBBLING SPOT', + 'island-scan': 'ISLAND SCAN', + 'honey-tree': 'HONEY TREE', + headbutt: 'HEADBUTT TREE', + 'headbutt-low': 'HEADBUTT TREE (LOW RATE)', + 'headbutt-normal': 'HEADBUTT TREE (NORMAL RATE)', + 'headbutt-high': 'HEADBUTT TREE (HIGH RATE)', + 'headbutt-tree-common': 'HEADBUTT TREE (COMMON)', + 'headbutt-tree-rare': 'HEADBUTT TREE (RARE)', + 'headbutt-tree-secret': 'HEADBUTT TREE (SECRET)', + 'johto-safari-blocks-inactive': 'SAFARI ZONE: NO BLOCKS', + 'backlot-mentioned': 'MENTIONED BY MR. BACKLOT', + 'backlot-not-mentioned': 'NOT MENTIONED BY MR. BACKLOT', + pokeflute: 'POKé FLUTE', + 'squirt-bottle': 'SQUIRT BOTTLE', + 'wailmer-pail': 'WAILMER PAIL', + 'devon-scope': 'DEVON SCOPE', + 'colosseum-bonus-disc-jpn': 'COLOSSEUM BONUS DISC (JP)', + 'colosseum-bonus-disc-us': 'COLOSSEUM BONUS DISC', + 'tv-option-red': 'TV SET TO RED', + 'tv-option-blue': 'TV SET TO BLUE', + 'save-data-from-lets-go-pikachu': "LET'S GO PIKACHU SAVE DATA", + 'save-data-from-lets-go-eevee': "LET'S GO EEVEE SAVE DATA", + 'max-raid': 'MAX RAID DEN', +}; + +interface ConditionFamily { + /** Slug prefixes this family owns. */ + prefixes?: string[]; + /** Exact slugs, for families with no common prefix. */ + slugs?: ReadonlySet<string>; + icon?: string; + /** Swaps the icon when the slug contains one of these fragments. */ + variants?: { when: string; icon: string }[]; + color?: string; + /** Replaces the matched prefix in the display label; `''` strips it. */ + strip?: string; + /** Legend gloss. Families without one get no legend row. */ + legend?: string; +} + +// One table per condition family — the display label, the chip icon/color, and +// the legend row all read from here, so a new family is one entry, not three. +// Order matters: the first matching family wins. +const CONDITION_FAMILIES: ConditionFamily[] = [ + { + prefixes: ['time-'], + icon: `◔${VS}`, + color: TYPE_COLORS.electric, + strip: '', + legend: 'time of day', + }, + { prefixes: ['season-'], icon: `✿${VS}`, color: TYPE_COLORS.fairy, strip: '', legend: 'season' }, + { + prefixes: ['weather-'], + icon: `☂${VS}`, + variants: [ + { when: 'intense-sun', icon: `☀${VS}` }, + { when: 'snow', icon: `❄${VS}` }, + { when: 'thunderstorm', icon: `⚡${VS}` }, + ], + color: TYPE_COLORS.water, + strip: '', + legend: 'weather', + }, + { slugs: ROD_METHODS, icon: `≈${VS}`, color: TYPE_COLORS.ice, legend: 'fishing rod' }, + { + prefixes: ['max-raid', 'max-den-'], + icon: `★${VS}`, + color: TYPE_COLORS.fire, + legend: 'raid den', + }, + { + prefixes: ['story-progress-'], + icon: `⚑${VS}`, + color: TYPE_COLORS.psychic, + strip: '', + legend: 'story progress', + }, + { + prefixes: ['trade-'], + icon: `⇄${VS}`, + color: TYPE_COLORS.psychic, + strip: 'GIVE ', + legend: 'required trade', + }, + { + prefixes: ['slot2-'], + icon: `◎${VS}`, + color: TYPE_COLORS.steel, + strip: 'GBA: ', + legend: 'GBA cartridge', + }, + { prefixes: ['weekday-'], strip: '' }, + { prefixes: ['other-'], strip: '' }, + { prefixes: ['item-'], strip: '' }, + { prefixes: ['radio-'], strip: 'RADIO: ' }, + { prefixes: ['starter-'], strip: 'STARTER: ' }, +]; + +const DEFAULT_CONDITION: ConditionMeta = { icon: `✧${VS}`, color: 'var(--dim)' }; + +function familyOf(slug: string): ConditionFamily | undefined { + return CONDITION_FAMILIES.find( + (f) => f.slugs?.has(slug) ?? f.prefixes?.some((p) => slug.startsWith(p)), + ); +} + +export function prettyCondition(slug: string): string { + const mapped = CONDITION_LABELS[slug]; + if (mapped) return mapped; + const star = slug.match(/^max-den-rating-(\d)-star$/); + if (star) return `${star[1]}★ DEN`; + const coins = slug.match(/^coins-(\d+)$/); + if (coins) return `${parseInt(coins[1], 10).toLocaleString('en-US')} COINS`; + const berryType = slug.match(/^berry-tree-type-(\w+)$/); + if (berryType) return `${berryType[1].toUpperCase()} BERRY TREE`; + const honeyGroup = slug.match(/^honey-tree-group-(\w)$/); + if (honeyGroup) return `HONEY TREE (GROUP ${honeyGroup[1].toUpperCase()})`; + const safariSlot = slug.match(/^friend-safari-slot-(\d)$/); + if (safariSlot) return `FRIEND SAFARI SLOT ${safariSlot[1]}`; + if (slug.startsWith('great-marsh-daily-slot-')) return 'GREAT MARSH DAILY ROTATION'; + const johtoBlocks = slug.match(/^johto-safari-blocks-(\w+)-min-(\d+)$/); + if (johtoBlocks) return `SAFARI ZONE: ${johtoBlocks[2]}+ ${johtoBlocks[1].toUpperCase()} BLOCKS`; + const family = familyOf(slug); + if (family?.strip !== undefined) { + const prefix = family.prefixes?.find((p) => slug.startsWith(p)); + if (prefix !== undefined) { + return (family.strip + slug.slice(prefix.length)).toUpperCase().replace(/-/g, ' '); + } + } + return slug.toUpperCase().replace(/-/g, ' '); +} + +export function conditionMeta(slug: string): ConditionMeta { + const family = familyOf(slug); + if (!family?.icon || !family.color) return DEFAULT_CONDITION; + const variant = family.variants?.find((v) => slug.includes(v.when)); + return { icon: variant?.icon ?? family.icon, color: family.color }; +} + +export function levelRate(e: ObtainEntry): string { + const bits: string[] = []; + if (e.minLevel !== undefined && e.maxLevel !== undefined) { + bits.push(e.minLevel === e.maxLevel ? `L${e.minLevel}` : `L${e.minLevel}–${e.maxLevel}`); + } + if (e.chance !== undefined) bits.push(`${e.chance}%`); + return bits.join(' · '); +} + +export const LEGEND_METHODS: [ObtainEntry['method'], string][] = [ + ['grass', 'wild grass'], + ['surf', 'while surfing'], + ['fish', 'fishing rod'], + ['cave', 'cave/rock smash'], + ['wild', 'wild (method unknown)'], + ['static', 'fixed encounter'], + ['gift', 'from an NPC'], + ['trade', 'in-game NPC trade'], + ['egg', 'breed & hatch'], + ['evolve', 'evolve pre-evolution'], + ['transfer', 'from another game'], + ['unavailable', 'not available'], + ['special', 'special method (headbutt, island scan, honey tree…)'], +]; + +// Derived from `CONDITION_FAMILIES` so a new family shows up in the legend for +// free — the fallback row is appended last because it matches nothing directly. +export const LEGEND_CONDITIONS: { icon: string; color: string; label: string }[] = [ + ...CONDITION_FAMILIES.flatMap((f) => + f.legend && f.icon && f.color ? [{ icon: f.icon, color: f.color, label: f.legend }] : [], + ), + { + ...DEFAULT_CONDITION, + label: + 'special requirement — the chip says which (swarms, honey trees, safari zones, SOS calls, hidden grottoes…)', + }, +]; diff --git a/src/obtain/pokeapi.ts b/src/obtain/pokeapi.ts index b9982ba..b19a5da 100644 --- a/src/obtain/pokeapi.ts +++ b/src/obtain/pokeapi.ts @@ -27,7 +27,13 @@ const METHOD_MAP: Record<string, ObtainMethod> = { 'npc-trade': 'trade', }; -const ROD_METHODS = new Set(['old-rod', 'good-rod', 'super-rod', 'super-rod-spots']); +/** Fishing-rod encounter methods — also drives the rod chip in the obtain view. */ +export const ROD_METHODS: ReadonlySet<string> = new Set([ + 'old-rod', + 'good-rod', + 'super-rod', + 'super-rod-spots', +]); export function prettyLocation(slug: string): string { return slug @@ -58,7 +64,9 @@ export function encountersToEntries(areas: ApiEncounterArea[]): Map<string, Obta const method = mapMethod(d.method.name); const conditions = d.condition_values.map((c) => c.name).sort(); if (ROD_METHODS.has(d.method.name)) conditions.unshift(d.method.name); - if (method === 'special' && !METHOD_MAP[d.method.name]) conditions.unshift(d.method.name); + // Nothing in `METHOD_MAP` maps to 'special', so this is exactly the + // unmapped case — keep the raw slug so the UI can still name it. + if (method === 'special') conditions.unshift(d.method.name); const key = `${location}|${method}|${conditions.join(',')}`; const prev = slots.get(key); if (prev) { diff --git a/src/obtain/types.ts b/src/obtain/types.ts index c130e3a..c652495 100644 --- a/src/obtain/types.ts +++ b/src/obtain/types.ts @@ -1,19 +1,24 @@ import { GENERATIONS } from '@/generations'; -export type ObtainMethod = - | 'grass' - | 'surf' - | 'fish' - | 'cave' - | 'wild' - | 'static' - | 'gift' - | 'trade' - | 'egg' - | 'evolve' - | 'transfer' - | 'unavailable' - | 'special'; +// Single source for the method vocabulary — the union, the runtime guard, and +// the label/color tables in `@/obtain/labels` all derive from this list. +export const OBTAIN_METHODS = [ + 'grass', + 'surf', + 'fish', + 'cave', + 'wild', + 'static', + 'gift', + 'trade', + 'egg', + 'evolve', + 'transfer', + 'unavailable', + 'special', +] as const; + +export type ObtainMethod = (typeof OBTAIN_METHODS)[number]; export interface ObtainEntry { method: ObtainMethod; @@ -86,6 +91,26 @@ export const GROUP_GEN: Record<string, number> = Object.fromEntries( GENERATIONS.flatMap((g) => g.versionGroups.map((vg) => [vg, g.num])), ); +const METHOD_SET: ReadonlySet<string> = new Set(OBTAIN_METHODS); + +function isObtainEntry(v: unknown): v is ObtainEntry { + if (typeof v !== 'object' || v === null) return false; + const e: Record<string, unknown> = { ...v }; + return typeof e.method === 'string' && METHOD_SET.has(e.method); +} + +function isObtainGame(v: unknown): v is ObtainGame { + if (typeof v !== 'object' || v === null) return false; + const g: Record<string, unknown> = { ...v }; + return ( + typeof g.gen === 'number' && + typeof g.versionGroup === 'string' && + Array.isArray(g.versions) && + Array.isArray(g.entries) && + g.entries.every(isObtainEntry) + ); +} + export function isObtainFile(v: unknown): v is ObtainFile { if (typeof v !== 'object' || v === null) return false; const o: Record<string, unknown> = { ...v }; @@ -93,6 +118,7 @@ export function isObtainFile(v: unknown): v is ObtainFile { typeof o.pokemonId === 'number' && typeof o.name === 'string' && Array.isArray(o.games) && + o.games.every(isObtainGame) && 'breeding' in o ); } diff --git a/src/styles/crt.css b/src/styles/crt.css index 068c048..eedc849 100644 --- a/src/styles/crt.css +++ b/src/styles/crt.css @@ -1710,7 +1710,7 @@ button.crt-type:hover, font-size: 0.75rem; margin-bottom: 8px; } -.crt-obtain-gen-toggle { +.crt-obtain-region-toggle { background: none; border: none; color: var(--primary); diff --git a/src/trainers.ts b/src/trainers.ts index e3fa227..a9b8fe4 100644 --- a/src/trainers.ts +++ b/src/trainers.ts @@ -1,3 +1,5 @@ +import { REGIONS } from '@/generations'; + // Trainer browser data — types + curated fixture set. // // All fields below are the canonical contract. Other modules (`TrainerGrid`, @@ -51,26 +53,36 @@ export const GAME_LABELS: Record<GameId, string> = { 'scarlet-violet': 'Scarlet / Violet', }; +const GAME_IDS: ReadonlySet<string> = new Set(Object.keys(GAME_LABELS)); + +function isGameId(v: string): v is GameId { + return GAME_IDS.has(v); +} + +// Every `GameId` is already its PokéAPI version-group slug except the Let's Go +// pair, which the trainer browser shortened before the obtain dataset existed. +const VERSION_GROUP_GAME: Record<string, GameId> = { + 'lets-go-pikachu-lets-go-eevee': 'lets-go', +}; + +function gameIdForVersionGroup(vg: string): GameId | null { + const aliased = VERSION_GROUP_GAME[vg]; + if (aliased) return aliased; + return isGameId(vg) ? vg : null; +} + /** - * Games grouped by the region they take place in — regions in first-appearance - * order, games within a region in release order. Hisui gets its own group - * (with a note) placed right after Sinnoh, since it's Sinnoh's ancient past. + * Games grouped by the region they take place in. Derived from the canonical + * `REGIONS` model in `generations.ts` so the trainer browser, the teams + * browser, and the obtain panel can never disagree on region order. */ -export const GAMES_BY_REGION: { region: string; note?: string; games: GameId[] }[] = [ - { region: 'Kanto', games: ['red-blue', 'yellow', 'firered-leafgreen', 'lets-go'] }, - { region: 'Johto', games: ['gold-silver', 'crystal', 'heartgold-soulsilver'] }, - { region: 'Hoenn', games: ['ruby-sapphire', 'emerald', 'omega-ruby-alpha-sapphire'] }, - { - region: 'Sinnoh', - games: ['diamond-pearl', 'platinum', 'brilliant-diamond-shining-pearl'], - }, - { region: 'Hisui', note: 'Ancient Sinnoh', games: ['legends-arceus'] }, - { region: 'Unova', games: ['black-white', 'black-2-white-2'] }, - { region: 'Kalos', games: ['x-y'] }, - { region: 'Alola', games: ['sun-moon', 'ultra-sun-ultra-moon'] }, - { region: 'Galar', games: ['sword-shield'] }, - { region: 'Paldea', games: ['scarlet-violet'] }, -]; +export const GAMES_BY_REGION: { region: string; note?: string; games: GameId[] }[] = REGIONS.map( + ({ name, note, versionGroups }) => ({ + region: name, + note, + games: versionGroups.map(gameIdForVersionGroup).filter((g): g is GameId => g !== null), + }), +).filter(({ games }) => games.length > 0); /** Best available portrait — animated APNG when the trainer has one, else the static VS sprite. */ export function trainerPortraitUrl(t: Trainer): string | undefined {