diff --git a/.dockerignore b/.dockerignore
index 429eae81..f303ed0d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -28,4 +28,7 @@ dist/
testdata/
# Python data
-card_data/
\ No newline at end of file
+data_platform/
+
+# Rust build artifacts
+services/target/
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0325a871..15976413 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ on:
- '.github/**'
- '.dockerignore'
- 'docs/**'
- - 'card_data/**'
+ - 'data_platform/**'
- '.gitignore'
- '.gitleaksignore'
- 'demo**'
@@ -31,7 +31,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.10.3'
+ VERSION_NUMBER: 'v2.0.0'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
@@ -42,8 +42,40 @@ permissions:
security-events: write
jobs:
+ smoke-tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-22.04, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+
+ - name: Build
+ shell: bash
+ run: go build -o poke-cli${{ matrix.os == 'windows-latest' && '.exe' || '' }} .
+
+ - name: Smoke test
+ shell: bash
+ run: |
+ BIN=./poke-cli${{ matrix.os == 'windows-latest' && '.exe' || '' }}
+ "$BIN" --help
+ "$BIN" pokemon pikachu
+ "$BIN" pokemon charizard -a -d
+ "$BIN" ability overgrow
+ "$BIN" move thunderbolt
+ "$BIN" natures
+
gosec:
runs-on: ubuntu-22.04
+ needs: [smoke-tests]
steps:
- name: Checkout
@@ -61,6 +93,7 @@ jobs:
bandit:
runs-on: ubuntu-22.04
+ needs: [smoke-tests]
steps:
- name: Checkout
@@ -77,7 +110,7 @@ jobs:
- name: Run Bandit Security Scanner
run: |
uv tool run --from 'bandit[sarif,toml]' bandit \
- -r card_data/pipelines \
+ -r data_platform/pipelines \
-f sarif \
-o bandit-results.sarif \
|| true
@@ -87,6 +120,31 @@ jobs:
with:
sarif_file: bandit-results.sarif
+ rust-cache:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Build poke-cache
+ run: cargo build --release --manifest-path services/Cargo.toml --bin poke-cache
+
+ - name: Test cache module
+ run: cargo test --manifest-path services/Cargo.toml --lib cache
+
+ - name: Smoke test poke-cache
+ run: |
+ BIN=services/target/release/poke-cache
+ "$BIN" get "https://pokeapi.co/api/v2/pokemon/pikachu" > out.json
+ test -s out.json
+ "$BIN" get "https://pokeapi.co/api/v2/pokemon/pikachu" 2> hit.log > /dev/null
+ grep -q "Cache hit" hit.log
+ echo "poke-cache smoke test passed"
+
gitleaks:
runs-on: ubuntu-22.04
needs: [gosec, bandit]
@@ -111,8 +169,8 @@ jobs:
build-linux-packages:
runs-on: ubuntu-22.04
- needs: [gitleaks]
- if: needs.gitleaks.result == 'success'
+ needs: [gitleaks, rust-cache]
+ if: needs.gitleaks.result == 'success' && needs.rust-cache.result == 'success'
strategy:
matrix:
arch: [ amd64, arm64 ]
@@ -134,6 +192,24 @@ jobs:
run: |
go build -ldflags="-s -w -X main.version=${{ env.VERSION_NUMBER }}" -o poke-cli
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
+
+ - name: Install aarch64 cross-linker
+ if: matrix.arch == 'arm64'
+ run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Build poke-cache Binary
+ env:
+ RUST_TARGET: ${{ matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
+ CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
+ run: |
+ cargo build --release --manifest-path services/Cargo.toml --bin poke-cache --target "$RUST_TARGET"
+ cp "services/target/$RUST_TARGET/release/poke-cache" ./poke-cache
+
- name: Install nFPM
run: |
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.43.0
@@ -161,12 +237,6 @@ jobs:
--packager rpm \
--target dist/poke-cli_${{ env.VERSION_NUMBER }}_linux_${{ matrix.arch }}.rpm
- # Build APK package
- nfpm package \
- --config nfpm-${{ matrix.arch }}.yaml \
- --packager apk \
- --target dist/poke-cli_${{ env.VERSION_NUMBER }}_linux_${{ matrix.arch }}.apk
-
- name: Upload packages as artifacts
uses: actions/upload-artifact@v6
with:
@@ -229,37 +299,9 @@ jobs:
digitalghost-dev/poke-cli/fedora/42 \
poke-cli_${{ env.VERSION_NUMBER }}_linux_${{ matrix.arch }}.rpm
- upload-apk-packages:
- runs-on: ubuntu-22.04
- needs: [build-linux-packages]
- if: needs.build-linux-packages.result == 'success'
- strategy:
- matrix:
- arch: [ amd64, arm64 ]
- fail-fast: false
-
- steps:
- - name: Download package artifact
- uses: actions/download-artifact@v7
- with:
- name: linux-packages-${{ matrix.arch }}
- path: packages/
-
- - name: Install Cloudsmith CLI
- uses: cloudsmith-io/cloudsmith-cli-action@v2
- with:
- api-key: ${{ secrets.CLOUDSMITH_API_KEY }}
-
- - name: Upload APK to Cloudsmith
- working-directory: packages
- run: |
- cloudsmith push alpine \
- digitalghost-dev/poke-cli/alpine/v3.22 \
- poke-cli_${{ env.VERSION_NUMBER }}_linux_${{ matrix.arch }}.apk
-
upload-summary:
runs-on: ubuntu-22.04
- needs: [upload-deb-packages, upload-rpm-packages, upload-apk-packages]
+ needs: [upload-deb-packages, upload-rpm-packages]
if: always()
steps:
@@ -267,11 +309,9 @@ jobs:
run: |
echo "DEB uploads: ${{ needs.upload-deb-packages.result }}"
echo "RPM uploads: ${{ needs.upload-rpm-packages.result }}"
- echo "APK uploads: ${{ needs.upload-apk-packages.result }}"
-
+
if [ "${{ needs.upload-deb-packages.result }}" != "success" ] || \
- [ "${{ needs.upload-rpm-packages.result }}" != "success" ] || \
- [ "${{ needs.upload-apk-packages.result }}" != "success" ]; then
+ [ "${{ needs.upload-rpm-packages.result }}" != "success" ]; then
echo "⚠️ Some uploads failed"
exit 1
fi
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 5061b8c0..d5dbc20d 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -4,7 +4,7 @@ on:
pull_request:
types: [opened, reopened, synchronize]
paths-ignore:
- - 'card_data/**'
+ - 'data_platform/**'
jobs:
tests:
@@ -16,7 +16,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: 1.25
+ go-version: '1.25'
- name: Install dependencies
run: |
diff --git a/.github/workflows/go_lint.yml b/.github/workflows/go_lint.yml
index c9314a04..06591c72 100644
--- a/.github/workflows/go_lint.yml
+++ b/.github/workflows/go_lint.yml
@@ -4,7 +4,7 @@ on:
pull_request:
types: [opened, reopened, synchronize]
paths-ignore:
- - 'card_data/**'
+ - 'data_platform/**'
permissions:
contents: read
@@ -20,7 +20,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: 1.25
+ go-version: '1.25'
- name: Lint
uses: golangci/golangci-lint-action@v7
diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml
index 8b72de58..ba1daefb 100644
--- a/.github/workflows/go_test.yml
+++ b/.github/workflows/go_test.yml
@@ -4,7 +4,7 @@ on:
pull_request:
types: [opened, reopened, synchronize]
paths-ignore:
- - 'card_data/**'
+ - 'data_platform/**'
jobs:
tests:
@@ -16,7 +16,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
- go-version: 1.25
+ go-version: '1.25'
- name: Install dependencies
run: |
diff --git a/.github/workflows/python_lint.yml b/.github/workflows/python_lint.yml
index d7e3741d..e0e88116 100644
--- a/.github/workflows/python_lint.yml
+++ b/.github/workflows/python_lint.yml
@@ -4,7 +4,7 @@ on:
pull_request:
types: [opened, reopened, synchronize]
paths:
- - 'card_data/**'
+ - 'data_platform/**'
permissions:
contents: read
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-22.04
defaults:
run:
- working-directory: card_data
+ working-directory: data_platform
steps:
- name: Checkout
diff --git a/.github/workflows/python_test.yml b/.github/workflows/python_test.yml
index 806aa058..b164ceef 100644
--- a/.github/workflows/python_test.yml
+++ b/.github/workflows/python_test.yml
@@ -5,12 +5,12 @@ on:
branches:
- main
paths:
- - 'card_data/**'
+ - 'data_platform/**'
- 'web/**'
pull_request:
types: [opened, reopened, synchronize]
paths:
- - 'card_data/**'
+ - 'data_platform/**'
- 'web/**'
permissions:
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-22.04
defaults:
run:
- working-directory: card_data
+ working-directory: data_platform
steps:
- name: Checkout
@@ -42,7 +42,7 @@ jobs:
- name: Run the benchmarks
uses: CodSpeedHQ/action@v4
with:
- working-directory: card_data
+ working-directory: data_platform
mode: simulation
run: uv run pytest pipelines/tests/ -v --codspeed
diff --git a/.github/workflows/python_typing.yml b/.github/workflows/python_typing.yml
index c84923e1..bfbd1693 100644
--- a/.github/workflows/python_typing.yml
+++ b/.github/workflows/python_typing.yml
@@ -4,7 +4,7 @@ on:
pull_request:
types: [opened, reopened, synchronize]
paths:
- - 'card_data/**'
+ - 'data_platform/**'
permissions:
contents: read
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-22.04
defaults:
run:
- working-directory: card_data
+ working-directory: data_platform
steps:
- name: Checkout
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d3f3c46d..a8ce1d84 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,27 +4,104 @@ on:
push:
tags:
- '*'
+ workflow_dispatch:
permissions:
contents: write
jobs:
+ build-poke-cache:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-22.04
+ goos: linux
+ goarch: amd64
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-22.04
+ goos: linux
+ goarch: arm64
+ apt: gcc-aarch64-linux-gnu
+ cross_linker: aarch64-linux-gnu-gcc
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ goos: darwin
+ goarch: amd64
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ goos: darwin
+ goarch: arm64
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ goos: windows
+ goarch: amd64
+ ext: .exe
+ - target: aarch64-pc-windows-msvc
+ os: windows-latest
+ goos: windows
+ goarch: arm64
+ ext: .exe
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install aarch64 cross-linker
+ if: matrix.apt
+ run: sudo apt-get update && sudo apt-get install -y ${{ matrix.apt }}
+
+ - name: Build poke-cache
+ shell: bash
+ env:
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.cross_linker }}
+ CC_aarch64_unknown_linux_gnu: ${{ matrix.cross_linker }}
+ run: cargo build --release --manifest-path services/Cargo.toml --bin poke-cache --target ${{ matrix.target }}
+
+ - name: Stage binary
+ shell: bash
+ run: |
+ mkdir -p staging
+ cp "services/target/${{ matrix.target }}/release/poke-cache${{ matrix.ext }}" "staging/poke-cache${{ matrix.ext }}"
+
+ - name: Upload binary
+ uses: actions/upload-artifact@v6
+ with:
+ name: poke-cache-${{ matrix.goos }}-${{ matrix.goarch }}
+ path: staging/poke-cache${{ matrix.ext }}
+ if-no-files-found: error
+
goreleaser:
+ needs: [build-poke-cache]
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+
+ - name: Download poke-cache binaries
+ uses: actions/download-artifact@v7
with:
- go-version: 1.25
+ pattern: poke-cache-*
+ path: prebuilt
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
- distribution: goreleaser
+ distribution: goreleaser-pro
version: '~> v2'
- args: release --clean
+ args: ${{ startsWith(github.ref, 'refs/tags/') && 'release --clean' || 'release --clean --snapshot' }}
env:
- GITHUB_TOKEN: ${{ secrets.GH_TOKEN_GORELEASER }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GH_TOKEN_GORELEASER }}
+ GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
diff --git a/.gitignore b/.gitignore
index e31d41ae..e3c54012 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,7 +36,7 @@ web/.streamlit/secrets.toml
web/.coverage
# Python
-card_data/.venv
+data_platform/.venv
__pycache__/
.ruff_cache/
.coverage
@@ -53,23 +53,23 @@ crash.log
crash.*.log
# dbt
-card_data/logs/
+data_platform/logs/
dbt_packages/
logs/
target/
# Card Data
-card_data/infrastructure/supabase/access-token
-/card_data/infrastructure/supabase/access-token
+data_platform/infrastructure/supabase/access-token
+/data_platform/infrastructure/supabase/access-token
**/.terraform/
-card_data/.tmp*/**
-card_data/pipelines/poke_cli_dbt/.user.yml
-/card_data/supabase/
-/card_data/sample_scripts/
-card_data/~/
-card_data/storage/
-/card_data/.codspeed/
+data_platform/.tmp*/**
+data_platform/pipelines/poke_cli_dbt/.user.yml
+/data_platform/supabase/
+/data_platform/sample_scripts/
+data_platform/~/
+data_platform/storage/
+/data_platform/.codspeed/
# Version management
VERSION
@@ -86,4 +86,9 @@ REFACTORING.md
AGENTS.md
.agents/
.codex/
-/.ai/
\ No newline at end of file
+/.ai/
+.firecrawl/
+
+# Plans
+aggregation-service-implementation.md
+aggregation-service-plan.md
\ No newline at end of file
diff --git a/.gitleaksignore b/.gitleaksignore
index 920140f1..c2040c61 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -1,3 +1,3 @@
codecov.yml:generic-api-key:2
-card_data/sample_scripts/ebay.py:generic-api-key:6
-card_data/sample_scripts/get_data.py:generic-api-key:9
\ No newline at end of file
+data_platform/sample_scripts/ebay.py:generic-api-key:6
+data_platform/sample_scripts/get_data.py:generic-api-key:9
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 97e1b479..bbf2eb85 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -7,17 +7,32 @@ before:
- go mod tidy
builds:
- - env:
+ - id: poke-cli
+ env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.10.3
+ - -s -w -X main.version=v2.0.0
+ - id: poke-cache
+ builder: prebuilt
+ goos:
+ - linux
+ - windows
+ - darwin
+ goarch:
+ - amd64
+ - arm64
+ binary: poke-cache
+ prebuilt:
+ path: prebuilt/poke-cache-{{ .Os }}-{{ .Arch }}/poke-cache{{ .Ext }}
archives:
- - formats: [ 'zip' ]
+ - id: poke-cli
+ ids: [ poke-cli, poke-cache ]
+ formats: [ 'zip' ]
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
@@ -25,10 +40,16 @@ archives:
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
- # use zip for windows archives
- format_overrides:
- - goos: windows
- formats: [ 'zip' ]
+ - id: poke-cache
+ ids: [ poke-cache ]
+ formats: [ 'zip' ]
+ name_template: >-
+ poke-cache_
+ {{- title .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}i386
+ {{- else }}{{ .Arch }}{{ end }}
+ {{- if .Arm }}v{{ .Arm }}{{ end }}
changelog:
sort: asc
@@ -39,11 +60,10 @@ changelog:
homebrew_casks:
- name: poke-cli
+ ids: [ poke-cli ]
description: "A hybrid CLI/TUI tool written in Go for viewing Pokémon data from the terminal!"
homepage: "https://docs.poke-cli.com/"
license: MIT
- conflicts:
- - formula: poke-cli
repository:
owner: digitalghost-dev
name: homebrew-tap
@@ -52,11 +72,12 @@ homebrew_casks:
post:
install: |
if OS.mac? && system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0
- system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/poke-cli"]
+ system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}"]
end
scoops:
- name: poke-cli
+ ids: [ poke-cli ]
description: "A hybrid CLI/TUI tool written in Go for viewing Pokémon data from the terminal!"
homepage: "https://docs.poke-cli.com/"
license: MIT
diff --git a/Dockerfile b/Dockerfile
index 5877be4c..e5e9412d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# build 1
-FROM golang:1.25.10-alpine3.23 AS build
+FROM golang:1.25.11-alpine3.24 AS build
WORKDIR /app
@@ -8,21 +8,35 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.10.3" -o poke-cli .
+RUN go build -ldflags "-X main.version=v2.0.0" -o poke-cli .
# build 2
-FROM --platform=$BUILDPLATFORM alpine:3.23
+FROM rust:1-alpine AS rust-build
+
+WORKDIR /build
+
+RUN apk add --no-cache build-base
+
+COPY services/Cargo.toml services/Cargo.lock ./services/
+COPY services/src ./services/src
+
+RUN cargo build --release --manifest-path services/Cargo.toml --bin poke-cache
+
+# build 3
+FROM --platform=$BUILDPLATFORM alpine:3.24
# Installing only necessary packages and remove them after use
-RUN apk add --no-cache shadow=4.18.0-r0 && \
+RUN apk add --no-cache shadow && \
addgroup -S poke_group && adduser -S poke_user -G poke_group && \
sed -i 's/^root:.*/root:!*:0:0:root:\/root:\/sbin\/nologin/' /etc/passwd && \
apk del shadow
COPY --from=build /app/poke-cli /app/poke-cli
+COPY --from=rust-build /build/services/target/release/poke-cache /usr/local/bin/poke-cache
ENV TERM=xterm-256color
ENV COLOR_OUTPUT=true
+ENV XDG_CACHE_HOME=/app/.cache
RUN chown -R poke_user:poke_group /app
diff --git a/README.md b/README.md
index 36f0face..9ef01f1a 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -14,7 +14,7 @@
## Pokemon CLI
`poke-cli` is a hybrid of a classic CLI and a modern TUI tool for viewing VG and TCG data about Pokémon!
-View the [documentation](https://docs.poke-cli.com) on the data infrastructure in [card_data/](https://github.com/digitalghost-dev/poke-cli/tree/main/card_data) if you're interested.
+View the [documentation](https://docs.poke-cli.com) on the data infrastructure in [data_platform/](https://github.com/digitalghost-dev/poke-cli/tree/main/data_platform) if you're interested.
* [Demo](#demo)
* [Installation](#installation)
@@ -102,11 +102,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.10.3
[subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v2.0.0 [subcommand] [flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.3 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v2.0.0 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -115,13 +115,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
> The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly:
> ```bash
> # Kitty
-> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.3 card
+> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v2.0.0 card
>
> # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby
-> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.3 card
+> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v2.0.0 card
>
> # Windows Terminal (Sixel)
-> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.3 card
+> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v2.0.0 card
> ```
> If your terminal is not listed above, image rendering is not supported inside Docker.
@@ -163,6 +163,9 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
```
2. The tool should be ready to use if `$PATH` is set up.
+> [!TIP]
+> `go install` builds only the `poke-cli` binary, **not** the `poke-cache` caching helper (a separate binary that every packaged install bundles). `poke-cli` works the same without it; it just calls PokéAPI directly instead of caching responses on disk. To enable caching, download the `poke-cache` archive for your platform from the [releases](https://github.com/digitalghost-dev/poke-cli/releases/latest) page, extract it, and move the `poke-cache` binary onto your `$PATH`.
+
---
## Usage
@@ -186,8 +189,8 @@ By running `poke-cli [-h | --help]`, it'll display information on how to use the
│ berry Get details about a berry │
│ card Get details about a TCG card │
│ item Get details about an item │
+│ mechanics Get details about video game mechanics │
│ move Get details about a move │
-│ natures Get details about all natures │
│ pokemon Get details about a Pokémon │
│ search Search for a resource │
│ speed Calculate the speed of a Pokémon in battle │
@@ -218,13 +221,14 @@ Below is a list of the planned/completed commands and flags:
- [x] add sun & moon data
- [ ] add x & y data
- [x] `item`: get data about an item.
+- [x] `mechanics`: get data about game mechanics.
+ - [x] `-n | --natures`: display a table of all natures.
- [ ] `move`: get data about a move.
- [ ] `-p | --pokemon`: display Pokémon that learn this move.
-- [x] `natures`: get data about natures.
- [ ] `pokemon`: get data about a Pokémon.
- [x] `-a | --abilities`: display the Pokémon's abilities.
- [ ] `-c | --cry`: play the Pokémon's cry.
- - [x] `-d | --defense`: display the Pokémon's type defences.
+ - [x] `-d | --defenses`: display the Pokémon's type defences.
- [x] `-i | --image`: display a pixel image of the Pokémon.
- [x] `-m | --moves`: display learnable moves.
- [x] `-s | --stats`: display the Pokémon's base stats.
diff --git a/card_data/pipelines/definitions.py b/card_data/pipelines/definitions.py
deleted file mode 100644
index 20dc7786..00000000
--- a/card_data/pipelines/definitions.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from pathlib import Path
-
-from dagster import definitions, load_from_defs_folder
-
-import dagster as dg
-
-from .defs.extract.limitless.extract_standings import create_standings_dataframe
-from .defs.extract.tcgcsv.extract_pricing import build_dataframe
-from .defs.extract.tcgdex.extract_sets import extract_sets_data
-from .defs.extract.tcgdex.extract_series import extract_series_data
-from .defs.load.limitless.load_standings import load_standings_data
-from .defs.load.tcgcsv.load_pricing import (
- load_pricing_data,
- data_quality_checks_on_pricing,
-)
-from .defs.load.tcgdex.load_sets import load_sets_data, data_quality_check_on_sets
-from .defs.load.tcgdex.load_series import load_series_data, data_quality_check_on_series
-from .sensors import discord_success_sensor, discord_failure_sensor
-
-
-@definitions
-def defs() -> dg.Definitions:
- # load_from_defs_folder discovers dbt assets from transform_data.py
- folder_defs: dg.Definitions = load_from_defs_folder(
- project_root=Path(__file__).parent.parent
- )
- return dg.Definitions.merge(
- folder_defs,
- defs_discord_sensors,
- defs_pricing,
- defs_sets,
- defs_series,
- defs_standings,
- defs_champions_speed_tiers,
- )
-
-
-defs_discord_sensors: dg.Definitions = dg.Definitions(
- sensors=[discord_success_sensor, discord_failure_sensor],
-)
-
-# Pricing pipeline job
-pricing_pipeline = dg.define_asset_job(
- name="pricing_pipeline_job",
- selection=dg.AssetSelection.assets(build_dataframe).downstream(include_self=True),
-)
-
-price_schedule: dg.ScheduleDefinition = dg.ScheduleDefinition(
- name="price_schedule",
- cron_schedule="0 14 * * *",
- target=pricing_pipeline,
- execution_timezone="America/Los_Angeles",
-)
-
-defs_pricing: dg.Definitions = dg.Definitions(
- assets=[build_dataframe, load_pricing_data, data_quality_checks_on_pricing],
- jobs=[pricing_pipeline],
- schedules=[price_schedule],
-)
-
-# Series pipeline job
-series_pipeline = dg.define_asset_job(
- name="series_pipeline_job",
- selection=dg.AssetSelection.assets(extract_series_data).downstream(
- include_self=True
- ),
-)
-
-defs_series: dg.Definitions = dg.Definitions(
- assets=[extract_series_data, load_series_data, data_quality_check_on_series],
- jobs=[series_pipeline],
-)
-
-# Sets pipeline job
-sets_pipeline = dg.define_asset_job(
- name="sets_pipeline_job",
- selection=dg.AssetSelection.assets(extract_sets_data).downstream(include_self=True),
-)
-
-defs_sets: dg.Definitions = dg.Definitions(
- assets=[extract_sets_data, load_sets_data, data_quality_check_on_sets],
- jobs=[sets_pipeline],
-)
-
-# Standings pipeline job
-standings_pipeline = dg.define_asset_job(
- name="standings_pipeline_job",
- selection=dg.AssetSelection.assets(create_standings_dataframe).downstream(
- include_self=True
- ),
-)
-
-defs_standings: dg.Definitions = dg.Definitions(
- assets=[create_standings_dataframe, load_standings_data],
- jobs=[standings_pipeline],
-)
-
-# Champions speed tiers job (n8n handles extract+load; Dagster runs only the dbt model)
-champions_speed_tiers_pipeline = dg.define_asset_job(
- name="champions_speed_tiers_dbt_job",
- selection=dg.AssetSelection.assets(dg.AssetKey(["champions_speed_tiers"])),
-)
-
-defs_champions_speed_tiers: dg.Definitions = dg.Definitions(
- jobs=[champions_speed_tiers_pipeline],
-)
diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql b/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql
deleted file mode 100644
index 0bbdbda7..00000000
--- a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-{% macro enable_rls() %}
- ALTER TABLE {{ this }} ENABLE ROW LEVEL SECURITY;
- CREATE POLICY "Enable Read Access for All Users"
- ON {{ this }}
- AS PERMISSIVE
- FOR SELECT
- TO PUBLIC
- USING (true);
-{% endmacro %}
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/models/champions_speed_tiers.sql b/card_data/pipelines/poke_cli_dbt/models/champions_speed_tiers.sql
deleted file mode 100644
index e91c5abe..00000000
--- a/card_data/pipelines/poke_cli_dbt/models/champions_speed_tiers.sql
+++ /dev/null
@@ -1,29 +0,0 @@
-{{ config(
- materialized='table',
- post_hook="{{ enable_rls() }}"
-) }}
-
-WITH latest AS (
- SELECT MAX(snapshot_month) AS snapshot_month
- FROM {{ source('staging', 'champions_speed_tiers') }}
-)
-SELECT
- s.rank,
- s.pokemon,
- s.base_spe,
- s.neutral_0_sp,
- s.neutral_32_sp,
- s.max_speed,
- s.neg_spe_0_sp,
- s.max_scarf,
- s.neutral_32_scarf,
- s.pokemon ILIKE 'mega %' AS is_mega,
- CASE
- WHEN s.base_spe >= 100 THEN 'fast'
- WHEN s.base_spe >= 60 THEN 'mid'
- ELSE 'slow'
- END AS speed_bucket,
- s.snapshot_month
-FROM {{ source('staging', 'champions_speed_tiers') }} AS s
-INNER JOIN latest USING (snapshot_month)
-ORDER BY s.rank
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/models/sources.yml b/card_data/pipelines/poke_cli_dbt/models/sources.yml
deleted file mode 100644
index 59ce7b82..00000000
--- a/card_data/pipelines/poke_cli_dbt/models/sources.yml
+++ /dev/null
@@ -1,202 +0,0 @@
-version: 2
-
-sources:
- - name: staging
- description: "Staging schema containing raw data loaded from extract pipeline"
- tables:
- - name: series
- description: "Pokemon card series data"
- columns:
- - name: id
- description: "Unique series identifier"
- - name: name
- description: "Series name"
- - name: logo
- description: "Series logo URL"
-
- - name: sets
- description: "Pokemon card sets data"
- columns:
- - name: series_id
- description: "Foreign key to series"
- - name: set_id
- description: "Unique set identifier"
- - name: set_name
- description: "Set name"
- - name: official_card_count
- description: "Official number of cards in set"
- - name: total_card_count
- description: "Total number of cards including variants"
- - name: logo
- description: "Set logo URL"
- - name: symbol
- description: "Set symbol URL"
-
- - name: cards
- description: "Pokemon cards data"
- columns:
- - name: id
- description: "Unique card identifier"
- - name: name
- description: "Card name"
- - name: category
- description: "Card category (Pokemon, Trainer, Energy)"
- - name: hp
- description: "Card HP value"
- - name: image
- description: "Card image URL"
- - name: rarity
- description: "Card rarity"
- - name: types
- description: "Card types (comma-separated)"
- - name: localId
- description: "Card local ID within the set"
- - name: illustrator
- description: "Card illustrator"
- - name: regulationMark
- description: "Regulation mark"
- - name: retreat
- description: "Retreat cost (numeric)"
- - name: stage
- description: "Evolution stage"
- - name: legal_standard
- description: "Legal status in Standard"
- - name: legal_expanded
- description: "Legal status in Expanded"
- - name: set_cardCount_official
- description: "Official card count for the set"
- - name: set_cardCount_total
- description: "Total card count including variants"
- - name: set_id
- description: "Foreign key to set"
- - name: set_logo
- description: "Set logo URL"
- - name: set_name
- description: "Set name"
- - name: set_symbol
- description: "Set symbol URL"
- - name: attacks_json
- description: "Raw attacks JSON"
- - name: attack_1_name
- description: "First attack name"
- - name: attack_1_damage
- description: "First attack damage"
- - name: attack_1_effect
- description: "First attack effect"
- - name: attack_1_cost
- description: "First attack energy cost"
- - name: attack_2_name
- description: "Second attack name"
- - name: attack_2_damage
- description: "Second attack damage"
- - name: attack_2_effect
- description: "Second attack effect"
- - name: attack_2_cost
- description: "Second attack energy cost"
- - name: attack_3_name
- description: "Third attack name"
- - name: attack_3_damage
- description: "Third attack damage"
- - name: attack_3_effect
- description: "Third attack effect"
- - name: attack_3_cost
- description: "Third attack energy cost"
-
- - name: standings
- description: "Player standings data for tournaments"
- columns:
- - name: rank
- description: "Player rank in the tournament"
- - name: name
- description: "Player name"
- - name: points
- description: "Player points"
- - name: record
- description: "Player win/loss record"
- - name: opp_win_percent
- description: "Opponent win percentage"
- - name: opp_opp_win_percent
- description: "Opponent's opponent win percentage"
- - name: deck
- description: "Deck name used by player"
- - name: decklist
- description: "Decklist URL"
- - name: country
- description: "Player country code"
- - name: tournament_id
- description: "Foreign key to tournaments"
-
- - name: tournaments
- description: "Tournament metadata"
- columns:
- - name: tournament_id
- description: "Unique tournament identifier"
- - name: location
- description: "Tournament location"
- - name: start_date
- description: "Tournament start date"
- - name: end_date
- description: "Tournament end date"
- - name: type
- description: "Tournament type"
- - name: player_quantity
- description: "Number of players in the tournament"
- - name: text_date
- description: "Tournament date in text format"
- - name: country_code
- description: "Country ISO code"
- - name: logo
- description: "Tournament type logo URL"
- - name: latitude
- description: "Tournament city latitude"
- - name: longitude
- description: "Tournament city longitude"
-
- - name: country_codes
- description: "Country code to country name mapping with geographic centroids"
- columns:
- - name: code
- description: "ISO country code"
- - name: country_name
- description: "Full country name"
-
- - name: pricing_data
- description: "Card pricing data"
- meta:
- dagster:
- asset_key: ["load_pricing_data"]
- columns:
- - name: product_id
- description: "Product ID"
- - name: name
- description: "Card name"
- - name: card_number
- description: "Card number"
- - name: market_price
- description: "Market price"
-
- - name: champions_speed_tiers
- description: "Pikalytics speed tier data ingested by n8n monthly"
- columns:
- - name: snapshot_month
- description: "First-of-month date for the snapshot"
- - name: format
- description: "Format code (e.g. gen9championsvgc2026)"
- - name: rank
- description: "Speed tier rank within the format"
- - name: pokemon
- description: "Pokémon name (includes Mega prefix where applicable)"
- - name: base_spe
- description: "Base Speed stat"
- - name: neutral_0_sp
- description: "Speed at level 50, neutral nature, 0 SP"
- - name: neutral_32_sp
- description: "Speed at level 50, neutral nature, 32 SP"
- - name: max_speed
- description: "Speed at level 50, +Speed nature, 32 SP"
- - name: neg_spe_0_sp
- description: "Speed at level 50, -Speed nature, 0 SP"
- - name: max_scarf
- description: "Max speed multiplied by Choice Scarf (×1.5)"
- - name: neutral_32_scarf
- description: "Neutral 32 SP speed multiplied by Choice Scarf (×1.5)"
\ No newline at end of file
diff --git a/cli.go b/cli.go
index e22f4a52..d524f7b9 100644
--- a/cli.go
+++ b/cli.go
@@ -1,26 +1,30 @@
package main
import (
- "flag"
"fmt"
"os"
"runtime/debug"
+ "slices"
"strings"
"github.com/digitalghost-dev/poke-cli/cmd/ability"
"github.com/digitalghost-dev/poke-cli/cmd/berry"
"github.com/digitalghost-dev/poke-cli/cmd/card"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp"
"github.com/digitalghost-dev/poke-cli/cmd/item"
+ "github.com/digitalghost-dev/poke-cli/cmd/mechanics"
"github.com/digitalghost-dev/poke-cli/cmd/move"
- "github.com/digitalghost-dev/poke-cli/cmd/natures"
"github.com/digitalghost-dev/poke-cli/cmd/pokemon"
"github.com/digitalghost-dev/poke-cli/cmd/search"
"github.com/digitalghost-dev/poke-cli/cmd/speed"
- "github.com/digitalghost-dev/poke-cli/cmd/tcg"
"github.com/digitalghost-dev/poke-cli/cmd/types"
"github.com/digitalghost-dev/poke-cli/cmd/utils"
+ "github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/flags"
+ "github.com/digitalghost-dev/poke-cli/setup"
"github.com/digitalghost-dev/poke-cli/styling"
+ flag "github.com/spf13/pflag"
+ "golang.org/x/term"
)
var version = "(devel)"
@@ -32,16 +36,23 @@ var commandDescriptions = []struct {
{"ability", "Get details about an ability"},
{"berry", "Get details about a berry"},
{"card", "Get details about a TCG card"},
+ {"comp", "Get details about competitive Pokémon"},
{"item", "Get details about an item"},
+ {"mechanics", "Get details about video game mechanics"},
{"move", "Get details about a move"},
- {"natures", "Get details about all natures"},
{"pokemon", "Get details about a Pokémon"},
{"search", "Search for a resource"},
{"speed", "Calculate the speed of a Pokémon in battle"},
- {"tcg", "Get details about TCG tournaments"},
{"types", "Get details about a typing"},
}
+type mainFlags struct {
+ FlagSet *flag.FlagSet
+ config *bool
+ latest *bool
+ version *bool
+}
+
func renderCommandList() string {
var sb strings.Builder
for _, cmd := range commandDescriptions {
@@ -68,20 +79,16 @@ func currentVersion() string {
return "Version: (devel)"
}
-func runCLI(args []string) int {
- var output strings.Builder
-
- mainFlagSet := flag.NewFlagSet("poke-cli", flag.ContinueOnError)
-
- // -l, --latest flag retrieves the latest Docker image and GitHub release versions available
- latestFlag := mainFlagSet.Bool("latest", false, "Prints the program's latest Docker image and release versions.")
- shortLatestFlag := mainFlagSet.Bool("l", false, "Prints the program's latest Docker image and release versions.")
+func setupMainFlagSet() *mainFlags {
+ f := &mainFlags{}
+ f.FlagSet = flag.NewFlagSet("mainFlags", flag.ContinueOnError)
+ f.FlagSet.SetInterspersed(false)
- // -v, --version flag retrieves the currently installed version
- currentVersionFlag := mainFlagSet.Bool("version", false, "Prints the current version")
- shortCurrentVersionFlag := mainFlagSet.Bool("v", false, "Prints the current version")
+ f.config = f.FlagSet.BoolP("config", "c", false, "Launch the config settings screen")
+ f.latest = f.FlagSet.BoolP("latest", "l", false, "Prints the latest version available")
+ f.version = f.FlagSet.BoolP("version", "v", false, "Prints the current version")
- mainFlagSet.Usage = func() {
+ f.FlagSet.Usage = func() {
helpMessage := styling.HelpBorder.Render(
"Welcome! This tool displays data related to Pokémon!",
"\n\n", styling.StyleBold.Render("USAGE:"),
@@ -90,6 +97,7 @@ func runCLI(args []string) int {
fmt.Sprintf("\n\t%-15s %s", "poke-cli [flag]", ""),
"\n\n", styling.StyleBold.Render("FLAGS:"),
fmt.Sprintf("\n\t%-15s %s", "-h, --help", "Shows the help menu"),
+ fmt.Sprintf("\n\t%-15s %s", "-c, --config", "Launch the config settings screen"),
fmt.Sprintf("\n\t%-15s %s", "-l, --latest", "Prints the latest version available"),
fmt.Sprintf("\n\t%-15s %s", "-v, --version", "Prints the current version"),
"\n\n", styling.StyleBold.Render("COMMANDS:"),
@@ -102,37 +110,62 @@ func runCLI(args []string) int {
fmt.Println(helpMessage)
}
+ return f
+}
+
+func runCLI(args []string) int {
+ var output strings.Builder
+
+ cfg, firstRun, err := flags.Load()
+ if err != nil {
+ cfg = flags.Defaults()
+ }
+
+ styling.ApplyTheme(cfg.Display.Theme)
+
+ wantsConfig := slices.Contains(args, "--config") || slices.Contains(args, "-c")
+ if firstRun && !wantsConfig && isInteractive() {
+ if updated, saved, runErr := setup.Run(cfg); runErr == nil && saved {
+ cfg = updated
+ saveConfig(cfg)
+ }
+ }
+
+ connections.ConfigureCache(cfg.Cache.ShowWarning, cfg.Cache.Path)
+
+ f := setupMainFlagSet()
+
switch {
case len(args) == 0:
- mainFlagSet.Usage()
+ f.FlagSet.Usage()
return 0
case len(args) > 0:
if args[0] == "-h" || args[0] == "--help" {
- mainFlagSet.Usage()
+ f.FlagSet.Usage()
return 0
}
}
- err := mainFlagSet.Parse(args)
+ err = f.FlagSet.Parse(args)
if err != nil {
return 2
}
- remainingArgs := mainFlagSet.Args()
+ remainingArgs := f.FlagSet.Args()
type commandFunc func([]string) (string, error)
commands := map[string]commandFunc{
- "ability": ability.AbilityCommand,
- "berry": berry.BerryCommand,
- "card": card.CardCommand,
- "item": item.ItemCommand,
- "move": move.MoveCommand,
- "natures": natures.NaturesCommand,
- "pokemon": pokemon.PokemonCommand,
- "speed": speed.SpeedCommand,
- "tcg": tcg.TcgCommand,
- "types": types.TypesCommand,
- "search": search.SearchCommand,
+ "ability": ability.AbilityCommand,
+ "berry": berry.BerryCommand,
+ "card": card.CardCommand,
+ "comp": comp.CompCommand,
+ "item": item.ItemCommand,
+ "mechanics": mechanics.MechanicsCommand,
+ "move": move.MoveCommand,
+ "pokemon": pokemon.PokemonCommand,
+ "search": search.SearchCommand,
+ "speed": speed.SpeedCommand,
+ "types": types.TypesCommand,
}
cmdArg := ""
@@ -142,18 +175,27 @@ func runCLI(args []string) int {
cmdFunc, exists := commands[cmdArg]
switch {
- case len(remainingArgs) == 0 && !*latestFlag && !*shortLatestFlag && !*currentVersionFlag && !*shortCurrentVersionFlag:
- mainFlagSet.Usage()
+ case len(remainingArgs) == 0 && !*f.latest && !*f.version && !*f.config:
+ f.FlagSet.Usage()
return 1
- case *latestFlag || *shortLatestFlag:
+ case *f.latest:
_, err := flags.LatestFlag()
if err != nil {
return 1
}
return 0
- case *currentVersionFlag || *shortCurrentVersionFlag:
+ case *f.version:
fmt.Println(currentVersion())
return 0
+ case *f.config:
+ updated, saved, runErr := setup.Run(cfg)
+ if runErr != nil {
+ return 1
+ }
+ if saved {
+ saveConfig(updated)
+ }
+ return 0
case exists:
return utils.HandleCommandOutput(cmdFunc, remainingArgs)()
default:
@@ -171,6 +213,17 @@ func runCLI(args []string) int {
var exit = os.Exit
+func isInteractive() bool {
+ return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
+}
+
+func saveConfig(cfg flags.Config) {
+ if err := flags.Save(cfg); err != nil {
+ fmt.Fprintln(os.Stderr, styling.WarningColor.Render(
+ "Couldn't save settings to the config file (it may be open elsewhere); changes won't persist."))
+ }
+}
+
func main() {
exit(runCLI(os.Args[1:]))
}
diff --git a/cmd/ability/ability.go b/cmd/ability/ability.go
index 656b4b53..0474fd3b 100644
--- a/cmd/ability/ability.go
+++ b/cmd/ability/ability.go
@@ -2,7 +2,6 @@ package ability
import (
"errors"
- "flag"
"fmt"
"strings"
@@ -10,6 +9,7 @@ import (
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/flags"
"github.com/digitalghost-dev/poke-cli/styling"
+ flag "github.com/spf13/pflag"
)
func AbilityCommand(args []string) (string, error) {
@@ -81,7 +81,8 @@ func AbilityCommand(args []string) (string, error) {
}
capitalizedAbility := styling.CapitalizeResourceName(abilityName)
- output.WriteString(styling.StyleBold.Render(capitalizedAbility) + "\n")
+ output.WriteString(styling.StyleBold.Render(capitalizedAbility))
+ output.WriteByte('\n')
generationParts := strings.Split(abilitiesStruct.Generation.Name, "-")
if len(generationParts) > 1 {
@@ -99,7 +100,7 @@ func AbilityCommand(args []string) (string, error) {
fmt.Fprintf(&output, "%s Effect: %s", styling.ColoredBullet, englishShortEffect)
}
- if *af.Pokemon || *af.ShortPokemon {
+ if *af.Pokemon {
if err := flags.PokemonAbilitiesFlag(&output, endpoint, abilityName); err != nil {
return utils.HandleFlagError(&output, err)
}
diff --git a/cmd/comp/champions/champions.go b/cmd/comp/champions/champions.go
new file mode 100644
index 00000000..85e34c28
--- /dev/null
+++ b/cmd/comp/champions/champions.go
@@ -0,0 +1,22 @@
+package champions
+
+import (
+ "fmt"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/connections"
+)
+
+func Run() (back bool, err error) {
+ final, err := tea.NewProgram(newDashboard(connections.CallTCGData)).Run()
+ if err != nil {
+ return false, fmt.Errorf("error running champions dashboard: %w", err)
+ }
+
+ result, ok := final.(dashboardModel)
+ if !ok {
+ return false, fmt.Errorf("unexpected model type from champions dashboard: got %T, want dashboardModel", final)
+ }
+
+ return result.goBack, nil
+}
diff --git a/cmd/comp/champions/dashboard.go b/cmd/comp/champions/dashboard.go
new file mode 100644
index 00000000..01666ed5
--- /dev/null
+++ b/cmd/comp/champions/dashboard.go
@@ -0,0 +1,147 @@
+package champions
+
+import (
+ "fmt"
+
+ "charm.land/bubbles/v2/table"
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+)
+
+var tabs = []string{"Pokémon Overview", "Usage", "Top Teams", "Speed Tiers"}
+
+type dashboardModel struct {
+ activeTab int
+ conn shell.ConnFunc
+ data *dashboardData
+ err error
+ goBack bool
+ height int
+ quit bool
+ overview table.Model
+ speed table.Model
+ styles *shell.Styles
+ teams table.Model
+ usage table.Model
+ width int
+}
+
+func newDashboard(conn shell.ConnFunc) dashboardModel {
+ return dashboardModel{
+ conn: conn,
+ styles: shell.NewStyles(),
+ }
+}
+
+func (m dashboardModel) renderTab(contentWidth int) string {
+ if m.err != nil {
+ return fmt.Sprintf("fetch error: %v", m.err)
+ }
+
+ if m.data == nil {
+ return " Loading..."
+ }
+
+ switch m.activeTab {
+ case 0:
+ return renderOverview(m.overview, m.data.CompInfo, contentWidth)
+ case 1:
+ return renderUsage(m.usage, m.data.Usage)
+ case 2:
+ return renderTeamsTable(m.teams, m.data.Teams, contentWidth)
+ case 3:
+ return renderSpeedTiers(m.speed, m.data.SpeedTiers)
+ default:
+ return ""
+ }
+}
+
+func (m dashboardModel) Init() tea.Cmd {
+ return fetchDashboardData(m.conn)
+}
+
+func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ m.quit = true
+ return m, tea.Quit
+ case "b":
+ m.goBack = true
+ return m, tea.Quit
+ case "w":
+ return m, utils.Open("https://web.poke-cli.com/")
+ case "right", "l", "tab":
+ m.activeTab = min(m.activeTab+1, len(tabs)-1)
+ return m, nil
+ case "left", "h", "shift+tab":
+ m.activeTab = max(m.activeTab-1, 0)
+ return m, nil
+ }
+ if m.data != nil {
+ switch m.activeTab {
+ case 0:
+ var cmd tea.Cmd
+ m.overview, cmd = m.overview.Update(msg)
+ return m, cmd
+ case 1:
+ var cmd tea.Cmd
+ m.usage, cmd = m.usage.Update(msg)
+ return m, cmd
+ case 2:
+ var cmd tea.Cmd
+ m.teams, cmd = m.teams.Update(msg)
+ return m, cmd
+ case 3:
+ var cmd tea.Cmd
+ m.speed, cmd = m.speed.Update(msg)
+ return m, cmd
+ }
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ if m.data != nil {
+ m.overview = newOverviewTable(m.data.CompInfo, m.height)
+ m.teams = newTeamsTable(m.data.Teams, contentWidth(m.width), m.height)
+ m.usage = newUsageTable(m.data.Usage, m.height)
+ m.speed = newSpeedTable(m.data.SpeedTiers, m.height)
+ }
+ return m, nil
+ case dataMsg:
+ if msg.err != nil {
+ m.err = msg.err
+ return m, nil
+ }
+ m.data = msg.data
+ m.overview = newOverviewTable(m.data.CompInfo, m.height)
+ m.teams = newTeamsTable(m.data.Teams, contentWidth(m.width), m.height)
+ m.usage = newUsageTable(m.data.Usage, m.height)
+ m.speed = newSpeedTable(m.data.SpeedTiers, m.height)
+ return m, nil
+ }
+
+ return m, nil
+}
+
+func (m dashboardModel) View() tea.View {
+ if m.quit {
+ return tea.NewView("\n Goodbye! \n")
+ }
+
+ if m.styles == nil {
+ return tea.NewView("")
+ }
+
+ body := m.styles.Render(tabs, m.activeTab, m.width, m.renderTab)
+
+ v := tea.NewView(body)
+ v.AltScreen = true
+ return v
+}
+
+func contentWidth(width int) int {
+ return max(width-10, 40)
+}
diff --git a/cmd/comp/champions/dashboard_test.go b/cmd/comp/champions/dashboard_test.go
new file mode 100644
index 00000000..057ba2d0
--- /dev/null
+++ b/cmd/comp/champions/dashboard_test.go
@@ -0,0 +1,358 @@
+package champions
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/exp/teatest/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+func noopConn(_ string) ([]byte, error) { return []byte("[]"), nil }
+
+func testTeams() []teamRow {
+ return []teamRow{
+ {
+ Player: "Alice",
+ Record: "7-1",
+ Tournament: "Worlds 2026",
+ Archetypes: []string{"Hyper Offense"},
+ Pokemon: []string{"Miraidon", "Flutter Mane", "Iron Hands", "Landorus", "Rillaboom", "Ogerpon"},
+ WebURL: "https://example.com/team/1",
+ },
+ {
+ Player: "Bob",
+ Record: "6-2",
+ Tournament: "Regional Sao Paulo",
+ Archetypes: nil,
+ Pokemon: []string{"Calyrex"},
+ WebURL: "",
+ },
+ }
+}
+
+func testCompInfo() []compInfoRow {
+ return []compInfoRow{
+ {
+ Pokemon: "Miraidon",
+ WebURL: "https://example.com/pokemon/1",
+ CommonMoves: []commonStat{{Name: "Protect", UsagePercent: 90.5}},
+ CommonAbilities: []commonStat{{Name: "Hadron Engine", UsagePercent: 100}},
+ CommonItems: []commonStat{{Name: "Choice Specs", UsagePercent: 45.2}},
+ CommonTeammates: []commonStat{{Name: "Flutter Mane", UsagePercent: 70.1}},
+ },
+ {
+ Pokemon: "Calyrex-Shadow",
+ WebURL: "",
+ CommonMoves: []commonStat{{Name: "Astral Barrage", UsagePercent: 99.1}},
+ CommonAbilities: []commonStat{{Name: "As One", UsagePercent: 100}},
+ CommonItems: nil,
+ CommonTeammates: []commonStat{{Name: "Miraidon", UsagePercent: 40.0}},
+ },
+ }
+}
+
+func testUsage() []usageRow {
+ return []usageRow{
+ {Rank: 1, Pokemon: "Basculegion", UsagePercent: 51.5},
+ {Rank: 2, Pokemon: "Kingambit", UsagePercent: 40.69},
+ }
+}
+
+func testSpeedTiers() []speedTierRow {
+ return []speedTierRow{
+ {Rank: 1, Pokemon: "Mega Aerodactyl", BaseSpe: 150, Neutral0: 170, Neutral252: 202, NegMin: 153, Max: 222, MaxScarf: 333, NeutralScarf: 303},
+ {Rank: 9, Pokemon: "Aerodactyl", BaseSpe: 130, Neutral0: 150, Neutral252: 182, NegMin: 135, Max: 200, MaxScarf: 300, NeutralScarf: 273},
+ }
+}
+
+func newTestDashboard() dashboardModel {
+ return dashboardModel{
+ conn: noopConn,
+ styles: shell.NewStyles(),
+ width: 120,
+ height: 40,
+ }
+}
+
+func loadedTestDashboard() dashboardModel {
+ m := newTestDashboard()
+ nm, _ := m.Update(dataMsg{data: &dashboardData{CompInfo: testCompInfo(), Teams: testTeams(), Usage: testUsage(), SpeedTiers: testSpeedTiers()}})
+ return nm.(dashboardModel)
+}
+
+func TestNewDashboard(t *testing.T) {
+ m := newDashboard(noopConn)
+ if m.styles == nil {
+ t.Error("expected styles to be set")
+ }
+ if m.conn == nil {
+ t.Error("expected conn to be set")
+ }
+ if m.data != nil || m.activeTab != 0 || m.goBack {
+ t.Error("expected a clean initial model")
+ }
+}
+
+func TestDashboard_Init_ReturnsCmd(t *testing.T) {
+ if newTestDashboard().Init() == nil {
+ t.Error("expected Init() to return a non-nil cmd")
+ }
+}
+
+func TestDashboard_Update_Quit(t *testing.T) {
+ tests := []struct {
+ name string
+ msg tea.KeyPressMsg
+ }{
+ {"ctrl+c", tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}},
+ {"esc", tea.KeyPressMsg{Code: tea.KeyEscape}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ newM, cmd := newTestDashboard().Update(tt.msg)
+ if newM.(dashboardModel).goBack {
+ t.Error("quit should not set goBack")
+ }
+ if cmd == nil {
+ t.Error("expected a quit command")
+ }
+ })
+ }
+}
+
+func TestDashboard_Update_Back(t *testing.T) {
+ tm := teatest.NewTestModel(t, newTestDashboard(), teatest.WithInitialTermSize(120, 40))
+ tm.Send(tea.KeyPressMsg{Code: 'b', Text: "b"})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ if !tm.FinalModel(t).(dashboardModel).goBack {
+ t.Error("expected goBack=true after pressing b")
+ }
+}
+
+func TestDashboard_Update_OpenWeb(t *testing.T) {
+ newM, cmd := newTestDashboard().Update(tea.KeyPressMsg{Code: 'w', Text: "w"})
+ if cmd == nil {
+ t.Error("expected a command from pressing w")
+ }
+ if newM.(dashboardModel).goBack {
+ t.Error("pressing w should not set goBack")
+ }
+}
+
+func TestDashboard_Update_TabNavigation(t *testing.T) {
+ m := newTestDashboard()
+ for _, key := range []tea.KeyPressMsg{{Code: tea.KeyRight}, {Code: 'l', Text: "l"}, {Code: tea.KeyTab}} {
+ nm, _ := m.Update(key)
+ if nm.(dashboardModel).activeTab != 1 {
+ t.Errorf("expected activeTab=1 after %v, got %d", key, nm.(dashboardModel).activeTab)
+ }
+ }
+
+ m.activeTab = 1
+ for _, key := range []tea.KeyPressMsg{{Code: tea.KeyLeft}, {Code: 'h', Text: "h"}, {Code: tea.KeyTab, Mod: tea.ModShift}} {
+ nm, _ := m.Update(key)
+ if nm.(dashboardModel).activeTab != 0 {
+ t.Errorf("expected activeTab=0 after %v, got %d", key, nm.(dashboardModel).activeTab)
+ }
+ }
+}
+
+func TestDashboard_Update_TabNavigation_Clamps(t *testing.T) {
+ m := newTestDashboard()
+ nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ if nm.(dashboardModel).activeTab != 0 {
+ t.Errorf("expected activeTab to clamp at 0, got %d", nm.(dashboardModel).activeTab)
+ }
+
+ m.activeTab = len(tabs) - 1
+ nm2, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ if nm2.(dashboardModel).activeTab != len(tabs)-1 {
+ t.Errorf("expected activeTab to clamp at %d, got %d", len(tabs)-1, nm2.(dashboardModel).activeTab)
+ }
+}
+
+func TestDashboard_Update_DataMsg_Success(t *testing.T) {
+ m := loadedTestDashboard()
+ if m.data == nil {
+ t.Fatal("expected data to be set")
+ }
+ if len(m.teams.Rows()) != 2 {
+ t.Errorf("expected 2 team rows, got %d", len(m.teams.Rows()))
+ }
+ if len(m.overview.Rows()) != 2 {
+ t.Errorf("expected 2 overview rows, got %d", len(m.overview.Rows()))
+ }
+ if len(m.speed.Rows()) != 2 {
+ t.Errorf("expected 2 speed rows, got %d", len(m.speed.Rows()))
+ }
+ if len(m.usage.Rows()) != 2 {
+ t.Errorf("expected 2 usage rows, got %d", len(m.usage.Rows()))
+ }
+}
+
+func TestDashboard_Update_OverviewTableNavigation(t *testing.T) {
+ m := loadedTestDashboard()
+ m.activeTab = 0
+ nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ if nm.(dashboardModel).overview.Cursor() != 1 {
+ t.Errorf("expected overview cursor to advance to 1, got %d", nm.(dashboardModel).overview.Cursor())
+ }
+}
+
+func TestDashboard_Update_DataMsg_Error(t *testing.T) {
+ m := newTestDashboard()
+ nm, _ := m.Update(dataMsg{err: errors.New("fetch failed")})
+ if nm.(dashboardModel).err == nil {
+ t.Error("expected err to be set")
+ }
+}
+
+func TestDashboard_Update_WindowSize(t *testing.T) {
+ loaded := loadedTestDashboard()
+ nm, _ := loaded.Update(tea.WindowSizeMsg{Width: 160, Height: 50})
+ result := nm.(dashboardModel)
+ if result.width != 160 || result.height != 50 {
+ t.Errorf("expected 160x50, got %dx%d", result.width, result.height)
+ }
+ if len(result.teams.Rows()) != 2 {
+ t.Errorf("expected teams table rebuilt with 2 rows, got %d", len(result.teams.Rows()))
+ }
+}
+
+func TestDashboard_Update_WindowSize_BeforeData(t *testing.T) {
+ nm, _ := newTestDashboard().Update(tea.WindowSizeMsg{Width: 160, Height: 50})
+ result := nm.(dashboardModel)
+ if result.width != 160 || result.height != 50 {
+ t.Errorf("expected 160x50, got %dx%d", result.width, result.height)
+ }
+}
+
+func TestDashboard_Update_TeamsTableNavigation(t *testing.T) {
+ m := loadedTestDashboard()
+ m.activeTab = 2
+ nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ if nm.(dashboardModel).teams.Cursor() != 1 {
+ t.Errorf("expected table cursor to advance to 1, got %d", nm.(dashboardModel).teams.Cursor())
+ }
+}
+
+func TestDashboard_Update_UsageTableNavigation(t *testing.T) {
+ m := loadedTestDashboard()
+ m.activeTab = 1
+ nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ if nm.(dashboardModel).usage.Cursor() != 1 {
+ t.Errorf("expected usage cursor to advance to 1, got %d", nm.(dashboardModel).usage.Cursor())
+ }
+}
+
+func TestDashboard_Update_SpeedTableNavigation(t *testing.T) {
+ m := loadedTestDashboard()
+ m.activeTab = 3
+ nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ if nm.(dashboardModel).speed.Cursor() != 1 {
+ t.Errorf("expected speed cursor to advance to 1, got %d", nm.(dashboardModel).speed.Cursor())
+ }
+}
+
+func TestDashboard_Update_UnhandledKey(t *testing.T) {
+ m := newTestDashboard()
+ nm, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
+ if cmd != nil {
+ t.Error("expected no command for an unhandled key without data")
+ }
+ if nm.(dashboardModel).activeTab != 0 {
+ t.Error("unhandled key should not change the active tab")
+ }
+}
+
+func TestDashboard_View_NilStyles(t *testing.T) {
+ if (dashboardModel{}).View().Content != "" {
+ t.Error("expected empty view when styles is nil")
+ }
+}
+
+func TestDashboard_View_Loading(t *testing.T) {
+ v := newTestDashboard().View()
+ if !v.AltScreen {
+ t.Error("expected AltScreen enabled")
+ }
+ if !strings.Contains(v.Content, "Loading") {
+ t.Error("expected loading message before data arrives")
+ }
+}
+
+func TestDashboard_View_FetchError(t *testing.T) {
+ m := newTestDashboard()
+ m.err = errors.New("network error")
+ if !strings.Contains(m.View().Content, "fetch error") {
+ t.Error("expected fetch error in view")
+ }
+}
+
+func TestDashboard_View_ContainsTabs(t *testing.T) {
+ content := newTestDashboard().View().Content
+ for _, tab := range tabs {
+ if !strings.Contains(content, tab) {
+ t.Errorf("expected view to contain tab %q", tab)
+ }
+ }
+}
+
+func TestDashboard_View_AllTabs(t *testing.T) {
+ m := loadedTestDashboard()
+ for tab := range len(tabs) {
+ m.activeTab = tab
+ if m.View().Content == "" {
+ t.Errorf("expected non-empty view for tab %d", tab)
+ }
+ }
+}
+
+func TestDashboard_RenderTab(t *testing.T) {
+ loaded := loadedTestDashboard()
+
+ tests := []struct {
+ name string
+ model dashboardModel
+ tab int
+ contains string
+ }{
+ {"error", func() dashboardModel { m := newTestDashboard(); m.err = errors.New("boom"); return m }(), 0, "fetch error"},
+ {"loading", newTestDashboard(), 0, "Loading"},
+ {"overview", loaded, 0, "Miraidon"},
+ {"usage", loaded, 1, "Basculegion"},
+ {"top teams", loaded, 2, "Alice"},
+ {"speed tiers", loaded, 3, "Mega Aerodactyl"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.model.activeTab = tt.tab
+ out := tt.model.renderTab(contentWidth(tt.model.width))
+ if !strings.Contains(out, tt.contains) {
+ t.Errorf("renderTab tab %d = %q, want substring %q", tt.tab, out, tt.contains)
+ }
+ })
+ }
+}
+
+func TestContentWidth(t *testing.T) {
+ tests := []struct {
+ width int
+ want int
+ }{
+ {120, 110},
+ {50, 40},
+ {10, 40},
+ {0, 40},
+ }
+ for _, tt := range tests {
+ if got := contentWidth(tt.width); got != tt.want {
+ t.Errorf("contentWidth(%d) = %d, want %d", tt.width, got, tt.want)
+ }
+ }
+}
diff --git a/cmd/comp/champions/data.go b/cmd/comp/champions/data.go
new file mode 100644
index 00000000..542b9c8b
--- /dev/null
+++ b/cmd/comp/champions/data.go
@@ -0,0 +1,153 @@
+package champions
+
+import (
+ "encoding/json"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+const (
+ compInfoURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/pikalytics_pokemon_comp_info?select=pokemon,web_url,common_moves,common_abilities,common_items,common_teammates&order=pokemon"
+ topTeamsURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/pikalytics_top_teams?select=author,record,tournament,archetypes,pokemon,web_url&order=rank"
+ usageURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/pikalytics_usage?select=rank,pokemon,usage_percent&order=rank"
+ speedTiersURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/pikalytics_speed_tiers?select=rank,pokemon,base_spe,neutral_0_sp,neutral_32_sp,neg_spe_0_sp,max_speed,max_scarf,neutral_32_scarf&order=rank"
+)
+
+type dashboardData struct {
+ CompInfo []compInfoRow
+ Teams []teamRow
+ Usage []usageRow
+ SpeedTiers []speedTierRow
+}
+
+type dataMsg struct {
+ data *dashboardData
+ err error
+}
+
+type teamRow struct {
+ Player string `json:"author"`
+ Record string `json:"record"`
+ Tournament string `json:"tournament"`
+ Archetypes []string `json:"archetypes"`
+ Pokemon []string `json:"pokemon"`
+ WebURL string `json:"web_url"`
+}
+
+type compInfoRow struct {
+ Pokemon string `json:"pokemon"`
+ WebURL string `json:"web_url"`
+ CommonMoves []commonStat `json:"common_moves"`
+ CommonAbilities []commonStat `json:"common_abilities"`
+ CommonItems []commonStat `json:"common_items"`
+ CommonTeammates []commonStat `json:"common_teammates"`
+}
+
+type commonStat struct {
+ Name string `json:"name"`
+ UsagePercent float64 `json:"usage_percent"`
+}
+
+type usageRow struct {
+ Rank int `json:"rank"`
+ Pokemon string `json:"pokemon"`
+ UsagePercent float64 `json:"usage_percent"`
+}
+
+type speedTierRow struct {
+ Rank int `json:"rank"`
+ Pokemon string `json:"pokemon"`
+ BaseSpe int `json:"base_spe"`
+ Neutral0 int `json:"neutral_0_sp"`
+ Neutral252 int `json:"neutral_32_sp"`
+ NegMin int `json:"neg_spe_0_sp"`
+ Max int `json:"max_speed"`
+ MaxScarf int `json:"max_scarf"`
+ NeutralScarf int `json:"neutral_32_scarf"`
+}
+
+func fetchDashboardData(conn shell.ConnFunc) tea.Cmd {
+ return func() tea.Msg {
+ compInfo, err := fetchCompInfo(conn)
+ if err != nil {
+ return dataMsg{err: err}
+ }
+
+ teams, err := fetchTopTeams(conn)
+ if err != nil {
+ return dataMsg{err: err}
+ }
+
+ usage, err := fetchUsage(conn)
+ if err != nil {
+ return dataMsg{err: err}
+ }
+
+ speedTiers, err := fetchSpeedTiers(conn)
+ if err != nil {
+ return dataMsg{err: err}
+ }
+
+ return dataMsg{
+ data: &dashboardData{
+ CompInfo: compInfo,
+ Teams: teams,
+ Usage: usage,
+ SpeedTiers: speedTiers,
+ },
+ }
+ }
+}
+
+func fetchCompInfo(conn shell.ConnFunc) ([]compInfoRow, error) {
+ body, err := conn(compInfoURL)
+ if err != nil {
+ return nil, err
+ }
+
+ var rows []compInfoRow
+ if err := json.Unmarshal(body, &rows); err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+func fetchTopTeams(conn shell.ConnFunc) ([]teamRow, error) {
+ body, err := conn(topTeamsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ var teams []teamRow
+ if err := json.Unmarshal(body, &teams); err != nil {
+ return nil, err
+ }
+ return teams, nil
+}
+
+func fetchUsage(conn shell.ConnFunc) ([]usageRow, error) {
+ body, err := conn(usageURL)
+ if err != nil {
+ return nil, err
+ }
+
+ var rows []usageRow
+ if err := json.Unmarshal(body, &rows); err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+func fetchSpeedTiers(conn shell.ConnFunc) ([]speedTierRow, error) {
+ body, err := conn(speedTiersURL)
+ if err != nil {
+ return nil, err
+ }
+
+ var rows []speedTierRow
+ if err := json.Unmarshal(body, &rows); err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
diff --git a/cmd/comp/champions/data_test.go b/cmd/comp/champions/data_test.go
new file mode 100644
index 00000000..23152941
--- /dev/null
+++ b/cmd/comp/champions/data_test.go
@@ -0,0 +1,107 @@
+package champions
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestFetchDashboardData_Success(t *testing.T) {
+ var capturedURLs []string
+ conn := func(url string) ([]byte, error) {
+ capturedURLs = append(capturedURLs, url)
+ switch url {
+ case compInfoURL:
+ return []byte(`[
+ {
+ "pokemon":"Miraidon",
+ "web_url":"https://example.com/pokemon/1",
+ "common_moves":[{"name":"Protect","usage_percent":90.5}],
+ "common_abilities":[{"name":"Hadron Engine","usage_percent":100}],
+ "common_items":[{"name":"Choice Specs","usage_percent":45.2}],
+ "common_teammates":[{"name":"Flutter Mane","usage_percent":70.1}]
+ }
+ ]`), nil
+ case topTeamsURL:
+ return []byte(`[
+ {"author":"Alice","record":"7-1","tournament":"Worlds 2026","archetypes":["Hyper Offense"],"pokemon":["Miraidon","Flutter Mane","Iron Hands"],"web_url":"https://example.com/1"},
+ {"author":"Bob","record":"6-2","tournament":"Regional","archetypes":[],"pokemon":["Calyrex"],"web_url":""}
+ ]`), nil
+ case usageURL:
+ return []byte(`[
+ {"rank":1,"pokemon":"Basculegion","usage_percent":51.5},
+ {"rank":2,"pokemon":"Kingambit","usage_percent":40.69}
+ ]`), nil
+ case speedTiersURL:
+ return []byte(`[
+ {"rank":1,"pokemon":"Mega Aerodactyl","base_spe":150,"neutral_0_sp":170,"neutral_32_sp":202,"neg_spe_0_sp":153,"max_speed":222,"max_scarf":333,"neutral_32_scarf":303}
+ ]`), nil
+ default:
+ t.Fatalf("unexpected URL %q", url)
+ return nil, nil
+ }
+ }
+
+ msg := fetchDashboardData(conn)().(dataMsg)
+ if msg.err != nil {
+ t.Fatalf("unexpected error: %v", msg.err)
+ }
+ if msg.data == nil {
+ t.Fatal("expected data, got nil")
+ }
+ if len(msg.data.CompInfo) != 1 {
+ t.Fatalf("expected 1 comp info row, got %d", len(msg.data.CompInfo))
+ }
+ if len(msg.data.Teams) != 2 {
+ t.Fatalf("expected 2 teams, got %d", len(msg.data.Teams))
+ }
+ first := msg.data.Teams[0]
+ if first.Player != "Alice" || first.Record != "7-1" || first.Tournament != "Worlds 2026" {
+ t.Errorf("unexpected first team: %+v", first)
+ }
+ if len(first.Pokemon) != 3 || first.WebURL != "https://example.com/1" {
+ t.Errorf("unexpected first team detail: %+v", first)
+ }
+ if len(msg.data.Usage) != 2 {
+ t.Fatalf("expected 2 usage rows, got %d", len(msg.data.Usage))
+ }
+ if u := msg.data.Usage[0]; u.Pokemon != "Basculegion" || u.UsagePercent != 51.5 {
+ t.Errorf("unexpected usage row: %+v", u)
+ }
+ if len(msg.data.SpeedTiers) != 1 {
+ t.Fatalf("expected 1 speed tier row, got %d", len(msg.data.SpeedTiers))
+ }
+ if st := msg.data.SpeedTiers[0]; st.Pokemon != "Mega Aerodactyl" || st.BaseSpe != 150 || st.MaxScarf != 333 {
+ t.Errorf("unexpected speed tier row: %+v", st)
+ }
+ want := []string{compInfoURL, topTeamsURL, usageURL, speedTiersURL}
+ if len(capturedURLs) != len(want) {
+ t.Fatalf("expected %d fetches, got %v", len(want), capturedURLs)
+ }
+ for i, u := range want {
+ if capturedURLs[i] != u {
+ t.Errorf("fetch %d = %q, want %q", i, capturedURLs[i], u)
+ }
+ }
+}
+
+func TestFetchDashboardData_ConnectionError(t *testing.T) {
+ conn := func(_ string) ([]byte, error) { return nil, errors.New("refused") }
+ msg := fetchDashboardData(conn)().(dataMsg)
+ if msg.err == nil {
+ t.Error("expected error")
+ }
+ if msg.data != nil {
+ t.Error("expected nil data on connection error")
+ }
+}
+
+func TestFetchDashboardData_InvalidJSON(t *testing.T) {
+ conn := func(_ string) ([]byte, error) { return []byte("not json"), nil }
+ msg := fetchDashboardData(conn)().(dataMsg)
+ if msg.err == nil {
+ t.Error("expected unmarshal error")
+ }
+ if msg.data != nil {
+ t.Error("expected nil data on invalid json")
+ }
+}
diff --git a/cmd/comp/champions/tab_components.go b/cmd/comp/champions/tab_components.go
new file mode 100644
index 00000000..6651a56b
--- /dev/null
+++ b/cmd/comp/champions/tab_components.go
@@ -0,0 +1,426 @@
+// Tab rendering helpers for the Champions dashboard.
+// The Bubble Tea lifecycle stays in dashboard.go; this file builds the per-tab views.
+
+package champions
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "charm.land/bubbles/v2/table"
+ "charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+var captionStyle = lipgloss.NewStyle().Foreground(styling.Gray).Italic(true)
+
+// Overview tab
+func newOverviewTable(rows []compInfoRow, height int) table.Model {
+ const nameWidth = 22
+ columns := []table.Column{{Title: "Pokémon", Width: nameWidth}}
+
+ trows := make([]table.Row, 0, len(rows))
+ for _, row := range rows {
+ trows = append(trows, table.Row{row.Pokemon})
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(trows),
+ table.WithFocused(true),
+ table.WithHeight(max(height-12, 5)),
+ table.WithWidth(nameWidth+4),
+ )
+ t.SetStyles(shell.TableStyles())
+ return t
+}
+
+func renderOverview(pokemonTable table.Model, rows []compInfoRow, width int) string {
+ if len(rows) == 0 {
+ return "No data available"
+ }
+
+ caption := captionStyle.Render("Select a Pokémon to see its most common moves, items, abilities, and teammates from recent Champions events.")
+
+ detailWidth := max(width-pokemonTable.Width()-4, 40)
+ detail := renderPokemonDetail(selectedCompInfo(pokemonTable, rows), detailWidth)
+ body := lipgloss.JoinHorizontal(lipgloss.Top, pokemonTable.View(), " ", detail)
+ return caption + "\n\n" + body
+}
+
+func selectedCompInfo(pokemonTable table.Model, rows []compInfoRow) compInfoRow {
+ if len(rows) == 0 {
+ return compInfoRow{}
+ }
+
+ idx := min(max(pokemonTable.Cursor(), 0), len(rows)-1)
+ return rows[idx]
+}
+
+func renderPokemonDetail(row compInfoRow, width int) string {
+ colWidth := min(max((width-3)/2, 18), 34)
+
+ moves := renderStatColumn("Common Moves", row.CommonMoves, colWidth)
+ items := renderStatColumn("Common Items", row.CommonItems, colWidth)
+ abilities := renderStatColumn("Common Abilities", row.CommonAbilities, colWidth)
+ teammates := renderStatColumn("Common Teammates", row.CommonTeammates, colWidth)
+
+ var b strings.Builder
+ b.WriteString(styling.Yellow.Render(row.Pokemon))
+ b.WriteString("\n\n")
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, moves, " ", items))
+ b.WriteString("\n\n")
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, abilities, " ", teammates))
+ if row.WebURL != "" {
+ b.WriteString("\n\n")
+ b.WriteString(detailLine("Link", row.WebURL, width))
+ }
+ return b.String()
+}
+
+func renderStatColumn(title string, stats []commonStat, width int) string {
+ var b strings.Builder
+ b.WriteString(styling.StyleBold.Render(title))
+ b.WriteString("\n")
+ if len(stats) == 0 {
+ b.WriteString("-")
+ } else {
+ for i, stat := range stats {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(statLine(stat, width))
+ }
+ }
+ return lipgloss.NewStyle().Width(width).Render(b.String())
+}
+
+func statLine(stat commonStat, width int) string {
+ const pctWidth = 6
+ nameWidth := min(max(width-pctWidth-1, 6), 20)
+ name := lipgloss.NewStyle().Width(nameWidth).Render(truncateName(stat.Name, nameWidth))
+ return fmt.Sprintf("%s %*.1f%%", name, pctWidth-1, stat.UsagePercent)
+}
+
+func truncateName(name string, width int) string {
+ if lipgloss.Width(name) <= width {
+ return name
+ }
+ runes := []rune(name)
+ if width <= 1 {
+ return string(runes[:max(width, 0)])
+ }
+ return string(runes[:width-1]) + "…"
+}
+
+// Usage tab
+func newUsageTable(rows []usageRow, height int) table.Model {
+ const barWidth = 22
+ columns := []table.Column{
+ {Title: "#", Width: 4},
+ {Title: "Pokémon", Width: 22},
+ {Title: "Usage", Width: 7},
+ {Title: "Share", Width: barWidth},
+ }
+
+ trows := make([]table.Row, 0, len(rows))
+ for _, row := range rows {
+ trows = append(trows, table.Row{
+ strconv.Itoa(row.Rank),
+ row.Pokemon,
+ fmt.Sprintf("%.1f%%", row.UsagePercent),
+ usageBar(row.UsagePercent, barWidth),
+ })
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(trows),
+ table.WithFocused(true),
+ table.WithHeight(max(height-12, 5)),
+ table.WithWidth(tableWidth(columns)),
+ )
+ t.SetStyles(shell.TableStyles())
+ return t
+}
+
+func renderUsage(usageTable table.Model, rows []usageRow) string {
+ if len(rows) == 0 {
+ return "No data available"
+ }
+
+ caption := captionStyle.Render("Share of teams at recent Champions events that used each Pokémon.")
+ return caption + "\n\n" + usageTable.View()
+}
+
+func usageBar(pct float64, width int) string {
+ filled := min(int(pct*float64(width)/100), width)
+ if filled == 0 && pct > 0 {
+ filled = 1
+ }
+ return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
+}
+
+// Top Teams tab
+func newTeamsTable(teams []teamRow, width, height int) table.Model {
+ columns := teamColumns(width)
+ rows := make([]table.Row, 0, len(teams))
+
+ for _, team := range teams {
+ rows = append(rows, table.Row{
+ team.Player,
+ team.Record,
+ team.Tournament,
+ joinOrDash(team.Archetypes),
+ teamCore(team.Pokemon),
+ })
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(max(height-22, 5)),
+ table.WithWidth(tableWidth(columns)),
+ )
+ t.SetStyles(shell.TableStyles())
+ return t
+}
+
+func renderTeamsTable(teamsTable table.Model, teams []teamRow, width int) string {
+ if len(teamsTable.Rows()) == 0 {
+ return "No data available"
+ }
+
+ detail := renderTeamDetail(selectedTeam(teamsTable, teams), width)
+ return teamsTable.View() + "\n\n" + detail
+}
+
+func teamColumns(width int) []table.Column {
+ const recordWidth = 8
+
+ separatorWidth := 5 * 2
+ availableWidth := max(width-separatorWidth-recordWidth, 40)
+
+ playerWidth := min(max(availableWidth*20/100, 8), 24)
+ tournamentWidth := min(max(availableWidth*28/100, 12), 40)
+ archetypesWidth := min(max(availableWidth*20/100, 8), 24)
+ teamWidth := max(availableWidth-playerWidth-tournamentWidth-archetypesWidth, 8)
+
+ return []table.Column{
+ {Title: "Player", Width: playerWidth},
+ {Title: "Record", Width: recordWidth},
+ {Title: "Tournament", Width: tournamentWidth},
+ {Title: "Archetypes", Width: archetypesWidth},
+ {Title: "Core", Width: teamWidth},
+ }
+}
+
+func selectedTeam(teamsTable table.Model, teams []teamRow) teamRow {
+ if len(teams) == 0 {
+ return teamRow{}
+ }
+
+ idx := min(max(teamsTable.Cursor(), 0), len(teams)-1)
+ return teams[idx]
+}
+
+func renderTeamDetail(team teamRow, width int) string {
+ var b strings.Builder
+
+ title := fmt.Sprintf("%s (%s)", team.Player, team.Record)
+ b.WriteString(styling.Yellow.Render("Selected Team"))
+ b.WriteString("\n")
+ b.WriteString(styling.StyleBold.Render(title))
+ b.WriteString("\n")
+ b.WriteString(detailLine("Tournament", team.Tournament, width))
+ b.WriteString("\n")
+ b.WriteString(detailLine("Archetypes", joinOrDash(team.Archetypes), width))
+ b.WriteString("\n")
+ b.WriteString(detailLine("Team", joinOrDash(team.Pokemon), width))
+ if team.WebURL != "" {
+ b.WriteString("\n")
+ b.WriteString(detailLine("Link", team.WebURL, width))
+ }
+
+ return b.String()
+}
+
+func tableWidth(columns []table.Column) int {
+ width := len(columns) * 2
+ for _, c := range columns {
+ width += c.Width
+ }
+ return width
+}
+
+func joinOrDash(values []string) string {
+ if len(values) == 0 {
+ return "-"
+ }
+ return strings.Join(values, ", ")
+}
+
+func teamCore(pokemon []string) string {
+ if len(pokemon) <= 3 {
+ return joinOrDash(pokemon)
+ }
+ return strings.Join(pokemon[:3], ", ") + fmt.Sprintf(" +%d", len(pokemon)-3)
+}
+
+func detailLine(label, value string, width int) string {
+ prefix := styling.StyleBold.Render(label + ": ")
+ plainPrefixWidth := len(label) + 2
+ lineWidth := max(width-plainPrefixWidth, 20)
+ lines := wrapWords(value, lineWidth)
+ if len(lines) == 0 {
+ return prefix + "-"
+ }
+
+ var b strings.Builder
+ b.WriteString(prefix)
+ b.WriteString(lines[0])
+ for _, line := range lines[1:] {
+ b.WriteString("\n")
+ b.WriteString(strings.Repeat(" ", plainPrefixWidth))
+ b.WriteString(line)
+ }
+ return b.String()
+}
+
+func wrapWords(value string, width int) []string {
+ words := strings.Fields(value)
+ if len(words) == 0 {
+ return nil
+ }
+
+ lines := make([]string, 0, 2)
+ current := words[0]
+ for _, word := range words[1:] {
+ if len(current) > width {
+ lines = append(lines, splitLongWord(current, width)...)
+ current = word
+ continue
+ }
+ if len(current)+1+len(word) > width {
+ lines = append(lines, current)
+ current = word
+ continue
+ }
+ current += " " + word
+ }
+ if len(current) > width {
+ lines = append(lines, splitLongWord(current, width)...)
+ } else {
+ lines = append(lines, current)
+ }
+ return lines
+}
+
+func splitLongWord(word string, width int) []string {
+ if width <= 0 {
+ return []string{word}
+ }
+
+ var lines []string
+ for len(word) > width {
+ lines = append(lines, word[:width])
+ word = word[width:]
+ }
+ if word != "" {
+ lines = append(lines, word)
+ }
+ return lines
+}
+
+// Speed Tiers tab
+func newSpeedTable(rows []speedTierRow, height int) table.Model {
+ columns := []table.Column{
+ {Title: "#", Width: 4},
+ {Title: "Pokémon", Width: 20},
+ {Title: "Base", Width: 5},
+ {Title: "Min", Width: 5},
+ {Title: "Max", Width: 5},
+ {Title: "Scarf", Width: 6},
+ }
+
+ trows := make([]table.Row, 0, len(rows))
+ for _, row := range rows {
+ trows = append(trows, table.Row{
+ strconv.Itoa(row.Rank),
+ row.Pokemon,
+ strconv.Itoa(row.BaseSpe),
+ strconv.Itoa(row.NegMin),
+ strconv.Itoa(row.Max),
+ strconv.Itoa(row.MaxScarf),
+ })
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(trows),
+ table.WithFocused(true),
+ table.WithHeight(max(height-12, 5)),
+ table.WithWidth(tableWidth(columns)),
+ )
+ t.SetStyles(shell.TableStyles())
+ return t
+}
+
+func renderSpeedTiers(speedTable table.Model, rows []speedTierRow) string {
+ if len(rows) == 0 {
+ return "No data available"
+ }
+
+ caption := captionStyle.Render("Speed stats at level 50. Min = 0 EVs, negative nature. Max = 252 EVs, positive nature.")
+ detail := renderSpeedDetail(selectedSpeedTier(speedTable, rows))
+ body := lipgloss.JoinHorizontal(lipgloss.Top, speedTable.View(), " ", detail)
+ return caption + "\n\n" + body
+}
+
+func selectedSpeedTier(speedTable table.Model, rows []speedTierRow) speedTierRow {
+ if len(rows) == 0 {
+ return speedTierRow{}
+ }
+
+ idx := min(max(speedTable.Cursor(), 0), len(rows)-1)
+ return rows[idx]
+}
+
+func renderSpeedDetail(row speedTierRow) string {
+ var b strings.Builder
+ b.WriteString(styling.Yellow.Render("Selected Pokémon"))
+ b.WriteString("\n")
+
+ b.WriteString(styling.StyleBold.Render(row.Pokemon))
+ b.WriteString("\n\n")
+
+ stats := []struct {
+ label string
+ value int
+ }{
+ {"Base Speed", row.BaseSpe},
+ {"Min (0 EV -Spe)", row.NegMin},
+ {"Neutral (0 EV)", row.Neutral0},
+ {"Neutral (252 EV)", row.Neutral252},
+ {"Max (252 EV +Spe)", row.Max},
+ {"Neutral + Scarf", row.NeutralScarf},
+ {"Max + Scarf", row.MaxScarf},
+ }
+
+ for i, s := range stats {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(speedStatLine(s.label, s.value))
+ }
+ return b.String()
+}
+
+func speedStatLine(label string, value int) string {
+ const labelWidth = 19
+ padded := lipgloss.NewStyle().Width(labelWidth).Render(label)
+ return padded + styling.StyleBold.Render(strconv.Itoa(value))
+}
diff --git a/cmd/comp/champions/tab_components_test.go b/cmd/comp/champions/tab_components_test.go
new file mode 100644
index 00000000..bef56649
--- /dev/null
+++ b/cmd/comp/champions/tab_components_test.go
@@ -0,0 +1,401 @@
+package champions
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+)
+
+func TestJoinOrDash(t *testing.T) {
+ tests := []struct {
+ name string
+ in []string
+ want string
+ }{
+ {"empty", nil, "-"},
+ {"single", []string{"Miraidon"}, "Miraidon"},
+ {"multiple", []string{"Miraidon", "Flutter Mane"}, "Miraidon, Flutter Mane"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := joinOrDash(tt.in); got != tt.want {
+ t.Errorf("joinOrDash(%v) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTeamCore(t *testing.T) {
+ tests := []struct {
+ name string
+ in []string
+ want string
+ }{
+ {"empty", nil, "-"},
+ {"three or fewer", []string{"A", "B", "C"}, "A, B, C"},
+ {"more than three", []string{"A", "B", "C", "D", "E"}, "A, B, C +2"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := teamCore(tt.in); got != tt.want {
+ t.Errorf("teamCore(%v) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNewOverviewTable(t *testing.T) {
+ rows := testCompInfo()
+ tbl := newOverviewTable(rows, 40)
+ if len(tbl.Rows()) != len(rows) {
+ t.Fatalf("expected %d rows, got %d", len(rows), len(tbl.Rows()))
+ }
+ if tbl.Rows()[0][0] != "Miraidon" {
+ t.Errorf("expected first row Miraidon, got %q", tbl.Rows()[0][0])
+ }
+}
+
+func TestSelectedCompInfo(t *testing.T) {
+ rows := testCompInfo()
+
+ if got := selectedCompInfo(newOverviewTable(nil, 40), nil); got.Pokemon != "" {
+ t.Errorf("expected zero compInfoRow for empty rows, got %+v", got)
+ }
+
+ tbl := newOverviewTable(rows, 40)
+ if got := selectedCompInfo(tbl, rows); got.Pokemon != "Miraidon" {
+ t.Errorf("expected first Pokémon selected, got %q", got.Pokemon)
+ }
+}
+
+func TestRenderOverview(t *testing.T) {
+ if got := renderOverview(newOverviewTable(nil, 40), nil, 120); got != "No data available" {
+ t.Errorf("expected empty-state message, got %q", got)
+ }
+
+ rows := testCompInfo()
+ out := renderOverview(newOverviewTable(rows, 40), rows, 120)
+ for _, want := range []string{"Miraidon", "Common Moves", "Common Items", "Common Abilities", "Common Teammates", "Protect", "Flutter Mane"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected overview to contain %q", want)
+ }
+ }
+}
+
+func TestRenderPokemonDetail(t *testing.T) {
+ withLink := testCompInfo()[0]
+ out := renderPokemonDetail(withLink, 90)
+ for _, want := range []string{"Miraidon", "Protect", "Hadron Engine", "Choice Specs", "Flutter Mane", "Link", "https://example.com/pokemon/1", "90.5%", "100.0%"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected detail to contain %q, got:\n%s", want, out)
+ }
+ }
+
+ noLink := testCompInfo()[1]
+ out2 := renderPokemonDetail(noLink, 90)
+ if strings.Contains(out2, "Link") {
+ t.Error("expected no Link line when WebURL is empty")
+ }
+ if !strings.Contains(out2, "Common Items") {
+ t.Error("expected Common Items heading even with no items")
+ }
+}
+
+func TestRenderStatColumn(t *testing.T) {
+ empty := renderStatColumn("Common Items", nil, 30)
+ if !strings.Contains(empty, "Common Items") || !strings.Contains(empty, "-") {
+ t.Errorf("expected title and dash placeholder, got %q", empty)
+ }
+
+ stats := []commonStat{{Name: "Protect", UsagePercent: 90.5}, {Name: "Tailwind", UsagePercent: 10.25}}
+ out := renderStatColumn("Common Moves", stats, 30)
+ for _, want := range []string{"Common Moves", "Protect", "90.5%", "Tailwind", "10.2%"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected stat column to contain %q, got:\n%s", want, out)
+ }
+ }
+}
+
+func TestStatLine(t *testing.T) {
+ line := statLine(commonStat{Name: "Shadow Sneak", UsagePercent: 89.758}, 24)
+ if !strings.Contains(line, "Shadow Sneak") || !strings.Contains(line, "89.8%") {
+ t.Errorf("unexpected stat line: %q", line)
+ }
+}
+
+func TestTruncateName(t *testing.T) {
+ tests := []struct {
+ name string
+ width int
+ want string
+ }{
+ {"Protect", 10, "Protect"},
+ {"King's Shield", 6, "King'…"},
+ {"Charizard-Mega-Y", 1, "C"},
+ {"", 5, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := truncateName(tt.name, tt.width); got != tt.want {
+ t.Errorf("truncateName(%q, %d) = %q, want %q", tt.name, tt.width, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTeamColumns(t *testing.T) {
+ for _, width := range []int{200, 120, 40, 10} {
+ cols := teamColumns(width)
+ if len(cols) != 5 {
+ t.Fatalf("width %d: expected 5 columns, got %d", width, len(cols))
+ }
+ titles := []string{"Player", "Record", "Tournament", "Archetypes", "Core"}
+ for i, c := range cols {
+ if c.Title != titles[i] {
+ t.Errorf("width %d: column %d title = %q, want %q", width, i, c.Title, titles[i])
+ }
+ if c.Width <= 0 {
+ t.Errorf("width %d: column %q has non-positive width %d", width, c.Title, c.Width)
+ }
+ }
+ }
+}
+
+func TestTableWidth(t *testing.T) {
+ cols := teamColumns(120)
+ got := tableWidth(cols)
+ want := len(cols) * 2
+ for _, c := range cols {
+ want += c.Width
+ }
+ if got != want {
+ t.Errorf("tableWidth = %d, want %d", got, want)
+ }
+}
+
+func TestSelectedTeam(t *testing.T) {
+ teams := testTeams()
+
+ if got := selectedTeam(newTeamsTable(nil, 120, 40), nil); got.Player != "" {
+ t.Errorf("expected zero teamRow for empty teams, got %+v", got)
+ }
+
+ tbl := newTeamsTable(teams, 120, 40)
+ if got := selectedTeam(tbl, teams); got.Player != "Alice" {
+ t.Errorf("expected first team selected, got %q", got.Player)
+ }
+}
+
+func TestNewTeamsTable(t *testing.T) {
+ teams := testTeams()
+ tbl := newTeamsTable(teams, 120, 40)
+ if len(tbl.Rows()) != 2 {
+ t.Fatalf("expected 2 rows, got %d", len(tbl.Rows()))
+ }
+ first := tbl.Rows()[0]
+ if first[0] != "Alice" || first[1] != "7-1" {
+ t.Errorf("unexpected first row: %v", first)
+ }
+ if !strings.Contains(first[4], "+3") {
+ t.Errorf("expected core column to truncate a 6-mon team, got %q", first[4])
+ }
+}
+
+func TestRenderTeamsTable(t *testing.T) {
+ if got := renderTeamsTable(newTeamsTable(nil, 120, 40), nil, 120); got != "No data available" {
+ t.Errorf("expected empty-state message, got %q", got)
+ }
+
+ teams := testTeams()
+ out := renderTeamsTable(newTeamsTable(teams, 120, 40), teams, 120)
+ for _, want := range []string{"Alice", "Selected Team", "Worlds 2026"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected rendered table to contain %q", want)
+ }
+ }
+}
+
+func TestRenderTeamDetail(t *testing.T) {
+ withLink := testTeams()[0]
+ out := renderTeamDetail(withLink, 120)
+ for _, want := range []string{"Alice", "7-1", "Worlds 2026", "Hyper Offense", "Miraidon", "Link", "https://example.com/team/1"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected detail to contain %q, got:\n%s", want, out)
+ }
+ }
+
+ noLink := testTeams()[1]
+ out2 := renderTeamDetail(noLink, 120)
+ if strings.Contains(out2, "Link") {
+ t.Error("expected no Link line when WebURL is empty")
+ }
+ if !strings.Contains(out2, "-") {
+ t.Error("expected dash placeholder for empty archetypes")
+ }
+}
+
+func TestDetailLine(t *testing.T) {
+ line := detailLine("Tournament", "Worlds 2026", 80)
+ if !strings.Contains(line, "Tournament") || !strings.Contains(line, "Worlds 2026") {
+ t.Errorf("unexpected detail line: %q", line)
+ }
+
+ empty := detailLine("Archetypes", "", 80)
+ if !strings.HasSuffix(empty, "-") {
+ t.Errorf("expected dash for empty value, got %q", empty)
+ }
+
+ wrapped := detailLine("Team", strings.Repeat("Pokemon ", 30), 40)
+ if !strings.Contains(wrapped, "\n") {
+ t.Error("expected long value to wrap onto multiple lines")
+ }
+}
+
+func TestWrapWords(t *testing.T) {
+ if wrapWords("", 10) != nil {
+ t.Error("expected nil for empty input")
+ }
+
+ lines := wrapWords("one two three four five", 9)
+ if len(lines) < 2 {
+ t.Errorf("expected wrapping into multiple lines, got %v", lines)
+ }
+ for _, line := range lines {
+ if strings.HasPrefix(line, " ") || strings.HasSuffix(line, " ") {
+ t.Errorf("expected trimmed lines, got %q", line)
+ }
+ }
+
+ long := wrapWords("supercalifragilisticexpialidocious", 5)
+ if len(long) < 2 {
+ t.Errorf("expected a long single word to be split, got %v", long)
+ }
+}
+
+func TestSplitLongWord(t *testing.T) {
+ if got := splitLongWord("word", 0); len(got) != 1 || got[0] != "word" {
+ t.Errorf("expected non-positive width to return the word unchanged, got %v", got)
+ }
+
+ got := splitLongWord("abcdefg", 3)
+ want := []string{"abc", "def", "g"}
+ if len(got) != len(want) {
+ t.Fatalf("splitLongWord = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("chunk %d = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestNewSpeedTable(t *testing.T) {
+ rows := testSpeedTiers()
+ tbl := newSpeedTable(rows, 40)
+ if len(tbl.Rows()) != len(rows) {
+ t.Fatalf("expected %d rows, got %d", len(rows), len(tbl.Rows()))
+ }
+ first := tbl.Rows()[0]
+ if first[0] != "1" || first[1] != "Mega Aerodactyl" || first[2] != "150" || first[5] != "333" {
+ t.Errorf("unexpected first row: %v", first)
+ }
+}
+
+func TestSelectedSpeedTier(t *testing.T) {
+ rows := testSpeedTiers()
+
+ if got := selectedSpeedTier(newSpeedTable(nil, 40), nil); got.Pokemon != "" {
+ t.Errorf("expected zero speedTierRow for empty rows, got %+v", got)
+ }
+
+ tbl := newSpeedTable(rows, 40)
+ if got := selectedSpeedTier(tbl, rows); got.Pokemon != "Mega Aerodactyl" {
+ t.Errorf("expected first row selected, got %q", got.Pokemon)
+ }
+}
+
+func TestRenderSpeedTiers(t *testing.T) {
+ if got := renderSpeedTiers(newSpeedTable(nil, 40), nil); got != "No data available" {
+ t.Errorf("expected empty-state message, got %q", got)
+ }
+
+ rows := testSpeedTiers()
+ out := renderSpeedTiers(newSpeedTable(rows, 40), rows)
+ for _, want := range []string{"level 50", "Mega Aerodactyl", "Base Speed", "Max + Scarf", "150", "333"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected speed tiers view to contain %q", want)
+ }
+ }
+}
+
+func TestRenderSpeedDetail(t *testing.T) {
+ out := renderSpeedDetail(testSpeedTiers()[0])
+ for _, want := range []string{"Selected Pokémon", "Mega Aerodactyl", "Base Speed", "150", "Min (0 EV -Spe)", "153", "Max (252 EV +Spe)", "222", "Max + Scarf", "333"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected detail to contain %q, got:\n%s", want, out)
+ }
+ }
+}
+
+func TestSpeedStatLine(t *testing.T) {
+ line := speedStatLine("Base Speed", 150)
+ if !strings.Contains(line, "Base Speed") || !strings.Contains(line, "150") {
+ t.Errorf("unexpected stat line: %q", line)
+ }
+}
+
+func TestNewUsageTable(t *testing.T) {
+ rows := testUsage()
+ tbl := newUsageTable(rows, 40)
+ if len(tbl.Rows()) != len(rows) {
+ t.Fatalf("expected %d rows, got %d", len(rows), len(tbl.Rows()))
+ }
+ first := tbl.Rows()[0]
+ if first[0] != "1" || first[1] != "Basculegion" || first[2] != "51.5%" {
+ t.Errorf("unexpected first row: %v", first)
+ }
+ if !strings.Contains(first[3], "█") {
+ t.Errorf("expected a share bar in the row, got %q", first[3])
+ }
+}
+
+func TestRenderUsage(t *testing.T) {
+ if got := renderUsage(newUsageTable(nil, 40), nil); got != "No data available" {
+ t.Errorf("expected empty-state message, got %q", got)
+ }
+
+ rows := testUsage()
+ out := renderUsage(newUsageTable(rows, 40), rows)
+ for _, want := range []string{"Share of teams", "Basculegion", "51.5%", "Kingambit"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected usage view to contain %q", want)
+ }
+ }
+}
+
+func TestUsageBar(t *testing.T) {
+ tests := []struct {
+ name string
+ pct float64
+ width int
+ wantFill int
+ }{
+ {"zero", 0, 20, 0},
+ {"tiny rounds up to 1", 2.0, 20, 1},
+ {"half", 50, 20, 10},
+ {"full at 100", 100, 20, 20},
+ {"clamped above 100", 150, 20, 20},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ bar := usageBar(tt.pct, tt.width)
+ if got := strings.Count(bar, "█"); got != tt.wantFill {
+ t.Errorf("usageBar(%v, %d) filled = %d, want %d", tt.pct, tt.width, got, tt.wantFill)
+ }
+ if lipgloss.Width(bar) != tt.width {
+ t.Errorf("usageBar width = %d, want %d", lipgloss.Width(bar), tt.width)
+ }
+ })
+ }
+}
diff --git a/cmd/comp/comp.go b/cmd/comp/comp.go
new file mode 100644
index 00000000..6a2c77f5
--- /dev/null
+++ b/cmd/comp/comp.go
@@ -0,0 +1,76 @@
+package comp
+
+import (
+ "fmt"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/champions"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/tcg"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/vgc"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+)
+
+func CompCommand(args []string) (string, error) {
+ var output strings.Builder
+
+ usage := func() {
+ output.WriteString(
+ utils.GenerateHelpMessage(
+ utils.HelpConfig{
+ Description: "Get details about competitive Pokémon.",
+ CmdName: "comp",
+ },
+ ),
+ )
+ }
+
+ if utils.CheckHelpFlag(args, usage) {
+ return output.String(), nil
+ }
+
+ // Validate arguments
+ if err := utils.ValidateArgs(
+ args,
+ utils.Validator{MaxArgs: 2, CmdName: "comp", RequireName: false, HasFlags: false},
+ ); err != nil {
+ output.WriteString(err.Error())
+ return output.String(), err
+ }
+
+ // Program 1: Competition type selection
+ for {
+ finalModel, err := tea.NewProgram(CompList()).Run()
+ if err != nil {
+ return "", fmt.Errorf("error running comp selection program: %w", err)
+ }
+
+ result, ok := finalModel.(pickerModel)
+ if !ok {
+ return "", fmt.Errorf("unexpected model type from competition selection: got %T, want compModel", finalModel)
+ }
+
+ if result.compID == "" {
+ break
+ }
+
+ var back bool
+
+ switch result.compID {
+ case "tcg":
+ back, err = tcg.Run()
+ case "vgc":
+ back, err = vgc.Run()
+ case "champions":
+ back, err = champions.Run()
+ }
+ if err != nil {
+ return "", err
+ }
+ if !back {
+ break
+ }
+ }
+
+ return output.String(), nil
+}
diff --git a/cmd/comp/comp_test.go b/cmd/comp/comp_test.go
new file mode 100644
index 00000000..335f8132
--- /dev/null
+++ b/cmd/comp/comp_test.go
@@ -0,0 +1,45 @@
+package comp
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+func TestCompCommand_Help(t *testing.T) {
+ for _, flag := range []string{"-h", "--help"} {
+ t.Run(flag, func(t *testing.T) {
+ output, err := CompCommand([]string{"comp", flag})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ clean := styling.StripANSI(output)
+ for _, want := range []string{"USAGE:", "comp", "competitive"} {
+ if !strings.Contains(clean, want) {
+ t.Errorf("expected help output to contain %q, got:\n%s", want, clean)
+ }
+ }
+ })
+ }
+}
+
+func TestCompCommand_TooManyArgs(t *testing.T) {
+ output, err := CompCommand([]string{"comp", "one", "two"})
+ if err == nil {
+ t.Fatal("expected error for too many arguments")
+ }
+ if !strings.Contains(styling.StripANSI(output), "Too many arguments") {
+ t.Errorf("expected 'Too many arguments' in output, got:\n%s", styling.StripANSI(output))
+ }
+}
+
+func TestCompCommand_InvalidOption(t *testing.T) {
+ output, err := CompCommand([]string{"comp", "bogus"})
+ if err == nil {
+ t.Fatal("expected error for invalid option")
+ }
+ if !strings.Contains(styling.StripANSI(output), "only available options") {
+ t.Errorf("expected invalid-option error in output, got:\n%s", styling.StripANSI(output))
+ }
+}
diff --git a/cmd/comp/selection.go b/cmd/comp/selection.go
new file mode 100644
index 00000000..2ba642ca
--- /dev/null
+++ b/cmd/comp/selection.go
@@ -0,0 +1,85 @@
+package comp
+
+import (
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+var compIDMap = map[string]string{
+ "TCG Competition Data": "tcg",
+ "VGC Competition Data": "vgc",
+ "Pokémon Champions Data": "champions",
+}
+
+type pickerModel struct {
+ list list.Model
+ choice string
+ compID string
+ quitting bool
+}
+
+func (m pickerModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ m.quitting = true
+ return m, tea.Quit
+ case "enter":
+ i, ok := m.list.SelectedItem().(styling.Item)
+ if ok {
+ m.choice = string(i)
+ m.compID = compIDMap[string(i)]
+ }
+ return m, tea.Quit
+ }
+ case tea.WindowSizeMsg:
+ m.list.SetWidth(msg.Width)
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m pickerModel) View() tea.View {
+ var content string
+ if m.quitting {
+ content = "\n Quitting comp command...\n\n"
+ } else if m.choice != "" {
+ content = styling.QuitTextStyle.Render("Viewing data for:", m.choice)
+ } else {
+ content = "\n" + m.list.View()
+ }
+
+ v := tea.NewView(content)
+ v.AltScreen = true
+ return v
+}
+
+func CompList() pickerModel {
+ items := []list.Item{
+ styling.Item("TCG Competition Data"),
+ styling.Item("VGC Competition Data"),
+ styling.Item("Pokémon Champions Data"),
+ }
+
+ const listWidth = 24
+ const listHeight = 12
+
+ l := list.New(items, styling.ItemDelegate{}, listWidth, listHeight)
+ l.Title = "Pick a competition type"
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ l.Styles.Title = styling.TitleStyle
+ l.Styles.PaginationStyle = styling.PaginationStyle
+ l.Styles.HelpStyle = styling.HelpStyle
+
+ return pickerModel{list: l}
+}
diff --git a/cmd/comp/selection_test.go b/cmd/comp/selection_test.go
new file mode 100644
index 00000000..52eb1241
--- /dev/null
+++ b/cmd/comp/selection_test.go
@@ -0,0 +1,104 @@
+package comp
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/exp/teatest/v2"
+)
+
+func TestCompList_BuildsModel(t *testing.T) {
+ m := CompList()
+ if len(m.list.Items()) != 3 {
+ t.Errorf("expected 3 items, got %d", len(m.list.Items()))
+ }
+ if m.list.Title != "Pick a competition type" {
+ t.Errorf("unexpected title: %q", m.list.Title)
+ }
+ if m.compID != "" || m.choice != "" || m.quitting {
+ t.Error("expected a clean initial model")
+ }
+}
+
+func TestCompIDMap(t *testing.T) {
+ tests := map[string]string{
+ "TCG Competition Data": "tcg",
+ "VGC Competition Data": "vgc",
+ "Pokémon Champions Data": "champions",
+ }
+ for label, want := range tests {
+ if got := compIDMap[label]; got != want {
+ t.Errorf("compIDMap[%q] = %q, want %q", label, got, want)
+ }
+ }
+}
+
+func TestPicker_Init(t *testing.T) {
+ if CompList().Init() != nil {
+ t.Error("expected Init() to return nil")
+ }
+}
+
+func TestPicker_Update_Quit(t *testing.T) {
+ for _, key := range []tea.KeyPressMsg{
+ {Code: tea.KeyEscape},
+ {Code: 'c', Mod: tea.ModCtrl},
+ } {
+ newModel, cmd := CompList().Update(key)
+ result := newModel.(pickerModel)
+ if !result.quitting {
+ t.Errorf("expected quitting=true after %v", key)
+ }
+ if cmd == nil {
+ t.Error("expected a quit command")
+ }
+ }
+}
+
+func TestPicker_Update_Enter_SetsChoice(t *testing.T) {
+ tm := teatest.NewTestModel(t, CompList(), teatest.WithInitialTermSize(80, 24))
+ tm.Send(tea.KeyPressMsg{Code: tea.KeyEnter})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ final := tm.FinalModel(t).(pickerModel)
+ if final.choice != "TCG Competition Data" {
+ t.Errorf("expected first item chosen, got %q", final.choice)
+ }
+ if final.compID != "tcg" {
+ t.Errorf("expected compID=tcg, got %q", final.compID)
+ }
+}
+
+func TestPicker_Update_WindowResize(t *testing.T) {
+ newModel, _ := CompList().Update(tea.WindowSizeMsg{Width: 100, Height: 40})
+ if newModel.(pickerModel).list.Width() != 100 {
+ t.Errorf("expected list width 100, got %d", newModel.(pickerModel).list.Width())
+ }
+}
+
+func TestPicker_View_States(t *testing.T) {
+ quit := CompList()
+ quit.quitting = true
+ if !strings.Contains(quit.View().Content, "Quitting") {
+ t.Error("expected quitting message")
+ }
+
+ chosen := CompList()
+ chosen.choice = "TCG Competition Data"
+ if !strings.Contains(chosen.View().Content, "TCG Competition Data") {
+ t.Error("expected chosen competition in view")
+ }
+
+ normal := CompList()
+ v := normal.View()
+ if v.Content == "" {
+ t.Error("expected non-empty normal view")
+ }
+ if !v.AltScreen {
+ t.Error("expected AltScreen enabled")
+ }
+ if !strings.Contains(v.Content, "TCG Competition Data") {
+ t.Error("expected list items in normal view")
+ }
+}
diff --git a/cmd/tcg/barchart.go b/cmd/comp/shell/barchart.go
similarity index 52%
rename from cmd/tcg/barchart.go
rename to cmd/comp/shell/barchart.go
index 6cd5ae9c..2732f42b 100644
--- a/cmd/tcg/barchart.go
+++ b/cmd/comp/shell/barchart.go
@@ -1,4 +1,4 @@
-package tcg
+package shell
import (
"fmt"
@@ -6,41 +6,38 @@ import (
"strings"
)
-type barChartItem struct {
+type Tally struct {
Label string
- Total int
+ Count int
}
-func barChart(s []barChartItem, width, labelWidth int) string {
+func BarChart(s []Tally, width, labelWidth int) string {
if len(s) == 0 {
return ""
}
- sorted := make([]barChartItem, len(s))
+ sorted := make([]Tally, len(s))
copy(sorted, s)
sort.Slice(sorted, func(i, j int) bool {
- return sorted[i].Total > sorted[j].Total
+ return sorted[i].Count > sorted[j].Count
})
display := sorted
if len(sorted) > 9 {
other := 0
for _, stat := range sorted[9:] {
- other += stat.Total
+ other += stat.Count
}
- display = append(sorted[:9], barChartItem{Label: "Other", Total: other})
+ display = append(sorted[:9], Tally{Label: "Other", Count: other})
}
const countWidth = 5
- maxBarWidth := width - labelWidth - countWidth - 4
- if maxBarWidth < 10 {
- maxBarWidth = 10
- }
+ maxBarWidth := max(width-labelWidth-countWidth-4, 10)
maxVal := 0
for _, stat := range display {
- if stat.Total > maxVal {
- maxVal = stat.Total
+ if stat.Count > maxVal {
+ maxVal = stat.Count
}
}
@@ -48,13 +45,13 @@ func barChart(s []barChartItem, width, labelWidth int) string {
for _, stat := range display {
barWidth := 0
if maxVal > 0 {
- barWidth = stat.Total * maxBarWidth / maxVal
- if barWidth == 0 && stat.Total > 0 {
+ barWidth = stat.Count * maxBarWidth / maxVal
+ if barWidth == 0 && stat.Count > 0 {
barWidth = 1
}
}
bar := strings.Repeat("█", barWidth) + strings.Repeat(" ", maxBarWidth-barWidth)
- fmt.Fprintf(&sb, "%-*s %s %*d\n", labelWidth, stat.Label, bar, countWidth, stat.Total)
+ fmt.Fprintf(&sb, "%-*s %s %*d\n", labelWidth, stat.Label, bar, countWidth, stat.Count)
}
return sb.String()
}
diff --git a/cmd/tcg/barchart_test.go b/cmd/comp/shell/barchart_test.go
similarity index 56%
rename from cmd/tcg/barchart_test.go
rename to cmd/comp/shell/barchart_test.go
index e0c0ee6b..3c519333 100644
--- a/cmd/tcg/barchart_test.go
+++ b/cmd/comp/shell/barchart_test.go
@@ -1,4 +1,4 @@
-package tcg
+package shell
import (
"strings"
@@ -6,19 +6,19 @@ import (
)
func TestBarChart_Empty(t *testing.T) {
- result := barChart([]barChartItem{}, 80, 20)
+ result := BarChart([]Tally{}, 80, 20)
if result != "" {
t.Errorf("expected empty string for empty input, got %q", result)
}
}
func TestBarChart_AllZeroTotals(t *testing.T) {
- items := []barChartItem{
- {Label: "USA", Total: 0},
- {Label: "Japan", Total: 0},
+ items := []Tally{
+ {Label: "USA", Count: 0},
+ {Label: "Japan", Count: 0},
}
- // should not panic (division by zero)
- result := barChart(items, 80, 20)
+
+ result := BarChart(items, 80, 20)
if result == "" {
t.Error("expected non-empty output for non-empty input")
}
@@ -28,10 +28,10 @@ func TestBarChart_AllZeroTotals(t *testing.T) {
}
func TestBarChart_SingleEntry(t *testing.T) {
- items := []barChartItem{
- {Label: "USA", Total: 10},
+ items := []Tally{
+ {Label: "USA", Count: 10},
}
- result := barChart(items, 80, 20)
+ result := BarChart(items, 80, 20)
if result == "" {
t.Error("expected non-empty output for single entry")
}
@@ -44,12 +44,12 @@ func TestBarChart_SingleEntry(t *testing.T) {
}
func TestBarChart_SortsDescending(t *testing.T) {
- items := []barChartItem{
- {Label: "France", Total: 5},
- {Label: "USA", Total: 20},
- {Label: "Japan", Total: 10},
+ items := []Tally{
+ {Label: "France", Count: 5},
+ {Label: "USA", Count: 20},
+ {Label: "Japan", Count: 10},
}
- result := barChart(items, 80, 20)
+ result := BarChart(items, 80, 20)
lines := strings.Split(strings.TrimRight(result, "\n"), "\n")
if len(lines) != 3 {
@@ -64,20 +64,20 @@ func TestBarChart_SortsDescending(t *testing.T) {
}
func TestBarChart_TopNineWithOther(t *testing.T) {
- items := []barChartItem{
- {Label: "A", Total: 100},
- {Label: "B", Total: 90},
- {Label: "C", Total: 80},
- {Label: "D", Total: 70},
- {Label: "E", Total: 60},
- {Label: "F", Total: 50},
- {Label: "G", Total: 40},
- {Label: "H", Total: 30},
- {Label: "I", Total: 20},
- {Label: "J", Total: 10},
- {Label: "K", Total: 5},
- }
- result := barChart(items, 80, 20)
+ items := []Tally{
+ {Label: "A", Count: 100},
+ {Label: "B", Count: 90},
+ {Label: "C", Count: 80},
+ {Label: "D", Count: 70},
+ {Label: "E", Count: 60},
+ {Label: "F", Count: 50},
+ {Label: "G", Count: 40},
+ {Label: "H", Count: 30},
+ {Label: "I", Count: 20},
+ {Label: "J", Count: 10},
+ {Label: "K", Count: 5},
+ }
+ result := BarChart(items, 80, 20)
lines := strings.Split(strings.TrimRight(result, "\n"), "\n")
if len(lines) != 10 {
@@ -89,11 +89,11 @@ func TestBarChart_TopNineWithOther(t *testing.T) {
}
func TestBarChart_ExactlyNine(t *testing.T) {
- items := make([]barChartItem, 9)
+ items := make([]Tally, 9)
for i := range items {
- items[i] = barChartItem{Label: "X", Total: i + 1}
+ items[i] = Tally{Label: "X", Count: i + 1}
}
- result := barChart(items, 80, 20)
+ result := BarChart(items, 80, 20)
lines := strings.Split(strings.TrimRight(result, "\n"), "\n")
if len(lines) != 9 {
@@ -105,15 +105,15 @@ func TestBarChart_ExactlyNine(t *testing.T) {
}
func TestBarChart_DoesNotMutateInput(t *testing.T) {
- items := []barChartItem{
- {Label: "France", Total: 5},
- {Label: "USA", Total: 20},
- {Label: "Japan", Total: 10},
+ items := []Tally{
+ {Label: "France", Count: 5},
+ {Label: "USA", Count: 20},
+ {Label: "Japan", Count: 10},
}
- original := make([]barChartItem, len(items))
+ original := make([]Tally, len(items))
copy(original, items)
- barChart(items, 80, 20)
+ BarChart(items, 80, 20)
for i, s := range items {
if s != original[i] {
@@ -123,44 +123,41 @@ func TestBarChart_DoesNotMutateInput(t *testing.T) {
}
func TestBarChart_NarrowWidth(t *testing.T) {
- items := []barChartItem{
- {Label: "USA", Total: 10},
+ items := []Tally{
+ {Label: "USA", Count: 10},
}
- result := barChart(items, 5, 20)
+ result := BarChart(items, 5, 20)
if result == "" {
t.Error("expected non-empty output even for very narrow width")
}
}
func TestBarChart_OtherExceedsTopEntry(t *testing.T) {
- // Regression: "Other" total can exceed any individual entry's total,
- // which previously caused a negative strings.Repeat count panic.
- items := []barChartItem{
- {Label: "A", Total: 10},
- {Label: "B", Total: 9},
- {Label: "C", Total: 8},
- {Label: "D", Total: 7},
- {Label: "E", Total: 6},
- {Label: "F", Total: 5},
- {Label: "G", Total: 4},
- {Label: "H", Total: 3},
- {Label: "I", Total: 2},
- {Label: "J", Total: 50},
- {Label: "K", Total: 50},
- }
- result := barChart(items, 80, 20)
+ items := []Tally{
+ {Label: "A", Count: 10},
+ {Label: "B", Count: 9},
+ {Label: "C", Count: 8},
+ {Label: "D", Count: 7},
+ {Label: "E", Count: 6},
+ {Label: "F", Count: 5},
+ {Label: "G", Count: 4},
+ {Label: "H", Count: 3},
+ {Label: "I", Count: 2},
+ {Label: "J", Count: 50},
+ {Label: "K", Count: 50},
+ }
+ result := BarChart(items, 80, 20)
if result == "" {
t.Error("expected non-empty output")
}
}
func TestBarChart_MinOneBlock(t *testing.T) {
- // Small totals relative to maxVal should still render at least one block.
- items := []barChartItem{
- {Label: "Big", Total: 428},
- {Label: "Tiny", Total: 1},
+ items := []Tally{
+ {Label: "Big", Count: 428},
+ {Label: "Tiny", Count: 1},
}
- result := barChart(items, 80, 20)
+ result := BarChart(items, 80, 20)
lines := strings.Split(strings.TrimRight(result, "\n"), "\n")
tinyLine := lines[1]
if !strings.Contains(tinyLine, "█") {
diff --git a/cmd/comp/shell/dashboard.go b/cmd/comp/shell/dashboard.go
new file mode 100644
index 00000000..df7c926d
--- /dev/null
+++ b/cmd/comp/shell/dashboard.go
@@ -0,0 +1,167 @@
+package shell
+
+import (
+ "fmt"
+
+ "charm.land/bubbles/v2/table"
+ tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+)
+
+type dashboardModel struct {
+ spec Spec
+ conn ConnFunc
+ styles *Styles
+ activeTab int
+ width int
+ height int
+ tournament string
+ decoded *Decoded
+ table table.Model
+ extraTable table.Model
+ goBack bool
+ err error
+}
+
+type dataMsg struct {
+ decoded Decoded
+ err error
+}
+
+func newDashboard(spec Spec, conn ConnFunc, location string) dashboardModel {
+ return dashboardModel{
+ spec: spec,
+ conn: conn,
+ styles: NewStyles(),
+ tournament: location,
+ }
+}
+
+func fetchDashboard(spec Spec, location string, conn ConnFunc) tea.Cmd {
+ return func() tea.Msg {
+ body, err := conn(spec.DashboardURL(location))
+ if err != nil {
+ return dataMsg{err: err}
+ }
+ decoded, err := spec.Decode(body)
+ if err != nil {
+ return dataMsg{err: err}
+ }
+ return dataMsg{decoded: decoded}
+ }
+}
+
+func newTable(spec Spec, rows []table.Row, width, height int) table.Model {
+ columns := spec.Columns(width - 8)
+ tableWidth := len(columns) * 2
+ for _, c := range columns {
+ tableWidth += c.Width
+ }
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(max(height-14, 5)),
+ table.WithWidth(tableWidth),
+ )
+ t.SetStyles(TableStyles())
+ return t
+}
+
+func (m *dashboardModel) buildTables() {
+ if m.decoded == nil {
+ return
+ }
+ m.table = newTable(m.spec, m.decoded.TableRows, m.width, m.height)
+ m.extraTable = newUsageTable(m.decoded.Extra, len(m.decoded.TableRows), m.width, m.height)
+}
+
+func (m dashboardModel) Init() tea.Cmd {
+ return fetchDashboard(m.spec, m.tournament, m.conn)
+}
+
+func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ return m, tea.Quit
+ case "b":
+ m.goBack = true
+ return m, tea.Quit
+ case "w":
+ return m, utils.Open("https://web.poke-cli.com/")
+ case "right", "l", "tab":
+ m.activeTab = min(m.activeTab+1, len(m.spec.Tabs)-1)
+ return m, nil
+ case "left", "h", "shift+tab":
+ m.activeTab = max(m.activeTab-1, 0)
+ return m, nil
+ }
+ if m.decoded != nil {
+ var cmd tea.Cmd
+ switch m.activeTab {
+ case 1:
+ m.table, cmd = m.table.Update(msg)
+ case 2:
+ m.extraTable, cmd = m.extraTable.Update(msg)
+ }
+ return m, cmd
+ }
+
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ if m.decoded != nil {
+ m.buildTables()
+ }
+ return m, nil
+
+ case dataMsg:
+ if msg.err != nil {
+ m.err = msg.err
+ return m, nil
+ }
+ d := msg.decoded
+ m.decoded = &d
+ m.buildTables()
+ }
+
+ return m, nil
+}
+
+func (m dashboardModel) renderTab(contentWidth int) string {
+ if m.err != nil {
+ return fmt.Sprintf("fetch error: %v", m.err)
+ }
+ if m.decoded == nil {
+ return " Loading..."
+ }
+ switch m.activeTab {
+ case 0:
+ return m.decoded.Overview(contentWidth, m.styles.HighlightColor)
+ case 1:
+ return m.table.View()
+ case 2:
+ view := m.extraTable.View()
+ if c := m.decoded.Extra.Caption; c != "" {
+ view += "\n\n" + captionStyle.Render(c)
+ }
+ return view
+ case 3:
+ return BarChart(m.decoded.Countries, contentWidth, 20)
+ }
+ return ""
+}
+
+func (m dashboardModel) View() tea.View {
+ if m.styles == nil {
+ return tea.NewView("")
+ }
+
+ body := m.styles.Render(m.spec.Tabs, m.activeTab, m.width, m.renderTab)
+
+ v := tea.NewView(body)
+ v.AltScreen = true
+ return v
+}
diff --git a/cmd/comp/shell/dashboard_test.go b/cmd/comp/shell/dashboard_test.go
new file mode 100644
index 00000000..6061dc3f
--- /dev/null
+++ b/cmd/comp/shell/dashboard_test.go
@@ -0,0 +1,156 @@
+package shell
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/exp/teatest/v2"
+)
+
+func newTestDashboard() dashboardModel {
+ return dashboardModel{
+ spec: testSpec(),
+ conn: noopConn,
+ styles: NewStyles(),
+ tournament: "London",
+ width: 120,
+ height: 40,
+ }
+}
+
+func loadedTestDashboard() dashboardModel {
+ m := newTestDashboard()
+ nm, _ := m.Update(dataMsg{decoded: testDecoded()})
+ return nm.(dashboardModel)
+}
+
+func TestDashboard_Init_ReturnsCmd(t *testing.T) {
+ if newTestDashboard().Init() == nil {
+ t.Error("expected Init() to return a non-nil cmd")
+ }
+}
+
+func TestDashboard_Update_Quit(t *testing.T) {
+ tests := []struct {
+ name string
+ msg tea.KeyPressMsg
+ }{
+ {"ctrl+c", tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}},
+ {"esc", tea.KeyPressMsg{Code: tea.KeyEscape}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tm := teatest.NewTestModel(t, newTestDashboard(), teatest.WithInitialTermSize(120, 40))
+ tm.Send(tt.msg)
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ })
+ }
+}
+
+func TestDashboard_Update_Back(t *testing.T) {
+ tm := teatest.NewTestModel(t, newTestDashboard(), teatest.WithInitialTermSize(120, 40))
+ tm.Send(tea.KeyPressMsg{Code: 'b', Text: "b"})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ if !tm.FinalModel(t).(dashboardModel).goBack {
+ t.Error("expected goBack=true after pressing b")
+ }
+}
+
+func TestDashboard_Update_TabNavigation(t *testing.T) {
+ m := newTestDashboard()
+ newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ if newM.(dashboardModel).activeTab != 1 {
+ t.Errorf("expected activeTab=1 after right, got %d", newM.(dashboardModel).activeTab)
+ }
+ newM2, _ := newM.(dashboardModel).Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ if newM2.(dashboardModel).activeTab != 0 {
+ t.Errorf("expected activeTab=0 after left, got %d", newM2.(dashboardModel).activeTab)
+ }
+}
+
+func TestDashboard_Update_TabNavigation_Clamps(t *testing.T) {
+ m := newTestDashboard()
+ newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ if newM.(dashboardModel).activeTab != 0 {
+ t.Errorf("expected activeTab to clamp at 0, got %d", newM.(dashboardModel).activeTab)
+ }
+ m.activeTab = 3
+ newM2, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ if newM2.(dashboardModel).activeTab != 3 {
+ t.Errorf("expected activeTab to clamp at 3, got %d", newM2.(dashboardModel).activeTab)
+ }
+}
+
+func TestDashboard_Update_DataMsg_Success(t *testing.T) {
+ m := loadedTestDashboard()
+ if m.decoded == nil {
+ t.Fatal("expected decoded to be set")
+ }
+ if len(m.table.Rows()) != 2 {
+ t.Errorf("expected 2 table rows, got %d", len(m.table.Rows()))
+ }
+}
+
+func TestDashboard_Update_DataMsg_Error(t *testing.T) {
+ m := newTestDashboard()
+ newM, _ := m.Update(dataMsg{err: errors.New("fetch failed")})
+ if newM.(dashboardModel).err == nil {
+ t.Error("expected err to be set")
+ }
+}
+
+func TestDashboard_Update_WindowSize(t *testing.T) {
+ m := newTestDashboard()
+ newM, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 50})
+ result := newM.(dashboardModel)
+ if result.width != 160 || result.height != 50 {
+ t.Errorf("expected 160x50, got %dx%d", result.width, result.height)
+ }
+}
+
+func TestDashboard_View_NilStyles(t *testing.T) {
+ if (dashboardModel{}).View().Content != "" {
+ t.Error("expected empty view when styles is nil")
+ }
+}
+
+func TestDashboard_View_ContainsTabs(t *testing.T) {
+ view := newTestDashboard().View()
+ for _, tab := range []string{"Overview", "Standings", "Extra", "Countries"} {
+ if !strings.Contains(view.Content, tab) {
+ t.Errorf("expected view to contain tab %q", tab)
+ }
+ }
+}
+
+func TestDashboard_View_LoadingState(t *testing.T) {
+ if !strings.Contains(newTestDashboard().View().Content, "Loading") {
+ t.Error("expected loading message before data arrives")
+ }
+}
+
+func TestDashboard_View_FetchError(t *testing.T) {
+ m := newTestDashboard()
+ m.err = errors.New("network error")
+ if !strings.Contains(m.View().Content, "fetch error") {
+ t.Error("expected fetch error in view")
+ }
+}
+
+func TestDashboard_View_AllTabs(t *testing.T) {
+ m := loadedTestDashboard()
+ wants := map[int]string{0: "OVERVIEW-BODY", 2: "EXTRA-CAPTION", 3: "USA"}
+ for tab := 0; tab <= 3; tab++ {
+ m.activeTab = tab
+ content := m.View().Content
+ if content == "" {
+ t.Errorf("expected non-empty view for tab %d", tab)
+ }
+ if want, ok := wants[tab]; ok && !strings.Contains(content, want) {
+ t.Errorf("expected tab %d to render %q", tab, want)
+ }
+ }
+}
diff --git a/cmd/comp/shell/frame.go b/cmd/comp/shell/frame.go
new file mode 100644
index 00000000..9059673b
--- /dev/null
+++ b/cmd/comp/shell/frame.go
@@ -0,0 +1,75 @@
+package shell
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/table"
+ "charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+const keyMenu = "← → (switch tab) • b (back) • w (web) • ctrl+c | esc (quit)"
+
+var captionStyle = lipgloss.NewStyle().Foreground(styling.Gray).Italic(true)
+
+func (s *Styles) Render(tabs []string, activeTab, width int, renderContent func(contentWidth int) string) string {
+ doc := strings.Builder{}
+
+ var renderedTabs []string
+ for i, t := range tabs {
+ var style lipgloss.Style
+ isFirst, isLast, isActive := i == 0, i == len(tabs)-1, i == activeTab
+ if isActive {
+ style = s.ActiveTab
+ } else {
+ style = s.InactiveTab
+ }
+ border, _, _, _, _ := style.GetBorder()
+ if isFirst && isActive {
+ border.BottomLeft = "│"
+ } else if isFirst && !isActive {
+ border.BottomLeft = "├"
+ } else if isLast && isActive {
+ border.BottomRight = "└"
+ } else if isLast && !isActive {
+ border.BottomRight = "┴"
+ }
+ style = style.Border(border)
+ renderedTabs = append(renderedTabs, style.Render(t))
+ }
+
+ row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
+
+ windowWidth := max(width-8, lipgloss.Width(row)-2)
+ contentWidth := windowWidth - 2
+
+ fillWidth := windowWidth - lipgloss.Width(row)
+ if fillWidth > 0 {
+ fill := lipgloss.NewStyle().Foreground(s.HighlightColor).
+ Render(strings.Repeat("─", fillWidth-1) + "┐")
+ row = row + fill
+ }
+
+ content := renderContent(contentWidth)
+
+ doc.WriteString(row)
+ doc.WriteString("\n")
+ doc.WriteString(s.Window.Width(windowWidth).Render(content))
+ doc.WriteString("\n")
+ doc.WriteString(styling.KeyMenu.Render(keyMenu))
+
+ return s.Doc.Render(doc.String())
+}
+
+func TableStyles() table.Styles {
+ s := table.DefaultStyles()
+ s.Header = s.Header.
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(styling.YellowColor).
+ BorderBottom(true).
+ Bold(true)
+ s.Selected = s.Selected.
+ Foreground(lipgloss.Color("#000")).
+ Background(styling.YellowColor)
+ return s
+}
diff --git a/cmd/comp/shell/frame_test.go b/cmd/comp/shell/frame_test.go
new file mode 100644
index 00000000..ffe935e9
--- /dev/null
+++ b/cmd/comp/shell/frame_test.go
@@ -0,0 +1,56 @@
+package shell
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNewStyles(t *testing.T) {
+ s := NewStyles()
+ if s == nil {
+ t.Fatal("expected non-nil styles")
+ }
+ if s.HighlightColor == nil {
+ t.Error("expected highlight color to be set")
+ }
+}
+
+func TestRender_ContainsTabsContentAndMenu(t *testing.T) {
+ s := NewStyles()
+ tabs := []string{"Overview", "Standings", "Usage", "Countries"}
+ var gotWidth int
+ body := s.Render(tabs, 0, 120, func(contentWidth int) string {
+ gotWidth = contentWidth
+ return "TAB-BODY"
+ })
+
+ for _, tab := range tabs {
+ if !strings.Contains(body, tab) {
+ t.Errorf("expected rendered frame to contain tab %q", tab)
+ }
+ }
+ if !strings.Contains(body, "TAB-BODY") {
+ t.Error("expected rendered frame to contain the content from the closure")
+ }
+ if !strings.Contains(body, "back") || !strings.Contains(body, "quit") {
+ t.Error("expected the key menu in the rendered frame")
+ }
+ if gotWidth <= 0 {
+ t.Errorf("expected a positive content width passed to renderContent, got %d", gotWidth)
+ }
+}
+
+func TestRender_NarrowWidthDoesNotPanic(t *testing.T) {
+ s := NewStyles()
+ body := s.Render([]string{"A", "B"}, 1, 10, func(int) string { return "x" })
+ if body == "" {
+ t.Error("expected non-empty frame even at narrow width")
+ }
+}
+
+func TestTableStyles(t *testing.T) {
+ st := TableStyles()
+ if st.Header.GetBold() != true {
+ t.Error("expected header style to be bold")
+ }
+}
diff --git a/cmd/tcg/tournamentslist.go b/cmd/comp/shell/picker.go
similarity index 70%
rename from cmd/tcg/tournamentslist.go
rename to cmd/comp/shell/picker.go
index 53db148b..64b089e0 100644
--- a/cmd/tcg/tournamentslist.go
+++ b/cmd/comp/shell/picker.go
@@ -1,18 +1,26 @@
-package tcg
+package shell
import (
"encoding/json"
+ "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
)
-type tournamentsModel struct {
- conn func(string) ([]byte, error)
- tournaments []tournamentData
- selected *tournamentData
+type TournamentRef struct {
+ Location string `json:"location"`
+ TextDate string `json:"text_date"`
+}
+
+type pickerModel struct {
+ conn ConnFunc
+ listURL string
+ tournaments []TournamentRef
+ selected *TournamentRef
error error
list list.Model
loading bool
@@ -20,59 +28,56 @@ type tournamentsModel struct {
quitting bool
}
-type tournamentData struct {
- Location string `json:"location"`
- TextDate string `json:"text_date"`
-}
-
type tournamentsDataMsg struct {
- tournaments []tournamentData
+ tournaments []TournamentRef
err error
}
-func fetchTournaments(conn func(string) ([]byte, error)) tea.Cmd {
+func fetchTournaments(listURL string, conn ConnFunc) tea.Cmd {
return func() tea.Msg {
- endpoint := "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/standings?select=location,text_date&rank=eq.1&order=start_date.desc"
- body, err := conn(endpoint)
+ body, err := conn(listURL)
if err != nil {
return tournamentsDataMsg{err: err}
}
- var allTournaments []tournamentData
- if err = json.Unmarshal(body, &allTournaments); err != nil {
+ var all []TournamentRef
+ if err = json.Unmarshal(body, &all); err != nil {
return tournamentsDataMsg{err: err}
}
- return tournamentsDataMsg{tournaments: allTournaments}
+ return tournamentsDataMsg{tournaments: all}
}
}
-func tournamentsList(conn func(string) ([]byte, error)) tournamentsModel {
+func newPicker(spec Spec, conn ConnFunc) pickerModel {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = styling.Yellow
- return tournamentsModel{
+ return pickerModel{
conn: conn,
+ listURL: spec.ListURL,
loading: true,
spinner: s,
}
}
-func (m tournamentsModel) Init() tea.Cmd {
+func (m pickerModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
- fetchTournaments(m.conn),
+ fetchTournaments(m.listURL, m.conn),
)
}
-func (m tournamentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "esc":
m.quitting = true
return m, tea.Quit
+ case "w":
+ return m, utils.Open("https://web.poke-cli.com/")
case "enter":
idx := m.list.Index()
if idx >= 0 && idx < len(m.tournaments) {
@@ -107,6 +112,13 @@ func (m tournamentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
l.Styles.PaginationStyle = styling.PaginationStyle
l.Styles.HelpStyle = styling.HelpStyle
+ webBinding := key.NewBinding(
+ key.WithKeys("w"),
+ key.WithHelp("w", "web"),
+ )
+ l.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{webBinding} }
+ l.AdditionalFullHelpKeys = func() []key.Binding { return []key.Binding{webBinding} }
+
m.list = l
m.loading = false
return m, nil
@@ -131,7 +143,7 @@ func (m tournamentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m tournamentsModel) View() tea.View {
+func (m pickerModel) View() tea.View {
var content string
if m.quitting {
content = "\n Quitting...\n\n"
diff --git a/cmd/comp/shell/picker_test.go b/cmd/comp/shell/picker_test.go
new file mode 100644
index 00000000..c989d905
--- /dev/null
+++ b/cmd/comp/shell/picker_test.go
@@ -0,0 +1,155 @@
+package shell
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/exp/teatest/v2"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+func loadedPicker() pickerModel {
+ tournaments := []TournamentRef{
+ {Location: "London", TextDate: "January 10-12, 2025"},
+ {Location: "Dallas", TextDate: "February 1-2, 2025"},
+ }
+ var items []list.Item
+ for _, td := range tournaments {
+ items = append(items, styling.Item(td.Location+" · "+td.TextDate))
+ }
+ l := list.New(items, styling.ItemDelegate{}, 40, 16)
+ l.SetFilteringEnabled(false)
+ return pickerModel{conn: noopConn, tournaments: tournaments, list: l, loading: false}
+}
+
+func TestPicker_InitialState(t *testing.T) {
+ m := newPicker(testSpec(), noopConn)
+ if !m.loading {
+ t.Error("expected loading=true on init")
+ }
+ if m.selected != nil {
+ t.Error("expected selected=nil on init")
+ }
+ if m.Init() == nil {
+ t.Error("expected Init() to return a non-nil cmd")
+ }
+}
+
+func TestFetchTournaments_ConnectionError(t *testing.T) {
+ mock := func(_ string) ([]byte, error) { return nil, errors.New("refused") }
+ msg := fetchTournaments("https://x.test", mock)()
+ result := msg.(tournamentsDataMsg)
+ if result.err == nil {
+ t.Error("expected error")
+ }
+ if result.tournaments != nil {
+ t.Error("expected nil tournaments on error")
+ }
+}
+
+func TestFetchTournaments_InvalidJSON(t *testing.T) {
+ mock := func(_ string) ([]byte, error) { return []byte("not json"), nil }
+ if fetchTournaments("https://x.test", mock)().(tournamentsDataMsg).err == nil {
+ t.Error("expected unmarshal error")
+ }
+}
+
+func TestFetchTournaments_Success(t *testing.T) {
+ var capturedURL string
+ mock := func(url string) ([]byte, error) {
+ capturedURL = url
+ return []byte(`[{"location":"London","text_date":"Jan 10-12"}]`), nil
+ }
+ result := fetchTournaments("https://x.test/list", mock)().(tournamentsDataMsg)
+ if result.err != nil {
+ t.Fatalf("unexpected error: %v", result.err)
+ }
+ if len(result.tournaments) != 1 || result.tournaments[0].Location != "London" {
+ t.Errorf("unexpected tournaments: %+v", result.tournaments)
+ }
+ if capturedURL != "https://x.test/list" {
+ t.Errorf("expected the spec's ListURL to be fetched, got %q", capturedURL)
+ }
+}
+
+func TestPicker_Update_Quit(t *testing.T) {
+ tm := teatest.NewTestModel(t, loadedPicker(), teatest.WithInitialTermSize(80, 24))
+ tm.Send(tea.KeyPressMsg{Code: tea.KeyEscape})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ if !tm.FinalModel(t).(pickerModel).quitting {
+ t.Error("expected quitting=true after esc")
+ }
+}
+
+func TestPicker_Update_Enter_SetsSelected(t *testing.T) {
+ tm := teatest.NewTestModel(t, loadedPicker(), teatest.WithInitialTermSize(80, 24))
+ tm.Send(tea.KeyPressMsg{Code: tea.KeyEnter})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
+ final := tm.FinalModel(t).(pickerModel)
+ if final.selected == nil || final.selected.Location != "London" {
+ t.Errorf("expected London selected, got %+v", final.selected)
+ }
+}
+
+func TestPicker_Update_DataMsg_Success(t *testing.T) {
+ m := newPicker(testSpec(), noopConn)
+ newModel, _ := m.Update(tournamentsDataMsg{tournaments: []TournamentRef{{Location: "London"}}})
+ result := newModel.(pickerModel)
+ if result.loading {
+ t.Error("expected loading=false after data")
+ }
+ if len(result.tournaments) != 1 {
+ t.Errorf("expected 1 tournament, got %d", len(result.tournaments))
+ }
+}
+
+func TestPicker_Update_DataMsg_Error(t *testing.T) {
+ m := newPicker(testSpec(), noopConn)
+ newModel, _ := m.Update(tournamentsDataMsg{err: errors.New("boom")})
+ result := newModel.(pickerModel)
+ if result.loading || result.error == nil {
+ t.Error("expected loading=false and error set")
+ }
+}
+
+func TestPicker_Update_WindowResize_WhenLoaded(t *testing.T) {
+ newModel, _ := loadedPicker().Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+ if newModel.(pickerModel).list.Width() != 120 {
+ t.Errorf("expected list width 120, got %d", newModel.(pickerModel).list.Width())
+ }
+}
+
+func TestPicker_View_States(t *testing.T) {
+ loading := newPicker(testSpec(), noopConn)
+ if !strings.Contains(loading.View().Content, "Loading tournaments") {
+ t.Error("expected loading message")
+ }
+
+ errM := newPicker(testSpec(), noopConn)
+ errM.loading = false
+ errM.error = errors.New("something went wrong")
+ if !strings.Contains(errM.View().Content, "something went wrong") {
+ t.Error("expected error message in view")
+ }
+
+ quit := newPicker(testSpec(), noopConn)
+ quit.quitting = true
+ if !strings.Contains(quit.View().Content, "Quitting") {
+ t.Error("expected quitting message")
+ }
+
+ sel := loadedPicker()
+ td := sel.tournaments[0]
+ sel.selected = &td
+ if !strings.Contains(sel.View().Content, "London") {
+ t.Error("expected selected tournament in view")
+ }
+
+ if loadedPicker().View().Content == "" {
+ t.Error("expected non-empty normal view")
+ }
+}
diff --git a/cmd/comp/shell/run.go b/cmd/comp/shell/run.go
new file mode 100644
index 00000000..84e64bc4
--- /dev/null
+++ b/cmd/comp/shell/run.go
@@ -0,0 +1,104 @@
+package shell
+
+import (
+ "fmt"
+ "image/color"
+ "strconv"
+ "strings"
+
+ "charm.land/bubbles/v2/table"
+ tea "charm.land/bubbletea/v2"
+)
+
+type ConnFunc func(string) ([]byte, error)
+
+type Frequency struct {
+ NameHeader string
+ CountHeader string
+ Caption string
+ Items []Tally
+}
+
+type Decoded struct {
+ TableRows []table.Row
+ Overview func(contentWidth int, highlight color.Color) string
+ Extra Frequency
+ Countries []Tally
+}
+
+type Spec struct {
+ Tabs []string
+ ListURL string
+ DashboardURL func(location string) string
+ Columns func(width int) []table.Column
+ Decode func(body []byte) (Decoded, error)
+}
+
+func Run(spec Spec, conn ConnFunc) (back bool, err error) {
+ runPicker := func(m pickerModel) (pickerModel, error) {
+ final, err := tea.NewProgram(m).Run()
+ if err != nil {
+ return pickerModel{}, err
+ }
+ result, ok := final.(pickerModel)
+ if !ok {
+ return pickerModel{}, fmt.Errorf("unexpected model type from tournament selection: got %T, want pickerModel", final)
+ }
+ return result, nil
+ }
+
+ runDashboard := func(m dashboardModel) (dashboardModel, error) {
+ final, err := tea.NewProgram(m).Run()
+ if err != nil {
+ return dashboardModel{}, err
+ }
+ result, ok := final.(dashboardModel)
+ if !ok {
+ return dashboardModel{}, fmt.Errorf("unexpected model type from dashboard: got %T, want dashboardModel", final)
+ }
+ return result, nil
+ }
+
+ if err := loop(spec, conn, runPicker, runDashboard); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+func loop(
+ spec Spec,
+ conn ConnFunc,
+ runPicker func(pickerModel) (pickerModel, error),
+ runDashboard func(dashboardModel) (dashboardModel, error),
+) error {
+ for {
+ result, err := runPicker(newPicker(spec, conn))
+ if err != nil {
+ return fmt.Errorf("error running tournament selection program: %w", err)
+ }
+ if result.selected == nil {
+ break
+ }
+
+ dash, err := runDashboard(newDashboard(spec, conn, result.selected.Location))
+ if err != nil {
+ return fmt.Errorf("error running dashboard program: %w", err)
+ }
+ if !dash.goBack {
+ break
+ }
+ }
+ return nil
+}
+
+func FormatInt(n int) string {
+ s := strconv.Itoa(n)
+ var result strings.Builder
+ for i, c := range s {
+ if i > 0 && (len(s)-i)%3 == 0 {
+ result.WriteRune(',')
+ }
+ result.WriteRune(c)
+ }
+ return result.String()
+}
diff --git a/cmd/comp/shell/run_test.go b/cmd/comp/shell/run_test.go
new file mode 100644
index 00000000..dbca0957
--- /dev/null
+++ b/cmd/comp/shell/run_test.go
@@ -0,0 +1,112 @@
+package shell
+
+import (
+ "errors"
+ "image/color"
+ "testing"
+
+ "charm.land/bubbles/v2/table"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func noopConn(_ string) ([]byte, error) { return []byte("[]"), nil }
+
+func testSpec() Spec {
+ return Spec{
+ Tabs: []string{"Overview", "Standings", "Extra", "Countries"},
+ ListURL: "https://example.test/list",
+ DashboardURL: func(loc string) string { return "https://example.test/dash?location=" + loc },
+ Columns: func(width int) []table.Column {
+ return []table.Column{{Title: "Rank", Width: 4}, {Title: "Name", Width: 20}}
+ },
+ Decode: func(_ []byte) (Decoded, error) { return testDecoded(), nil },
+ }
+}
+
+func testDecoded() Decoded {
+ return Decoded{
+ TableRows: []table.Row{{"1", "Ash"}, {"2", "Misty"}},
+ Countries: []Tally{{Label: "USA", Count: 5}},
+ Overview: func(_ int, _ color.Color) string { return "OVERVIEW-BODY" },
+ Extra: Frequency{
+ NameHeader: "Deck",
+ CountHeader: "Players",
+ Caption: "EXTRA-CAPTION",
+ Items: []Tally{{Label: "EXTRA-DECK", Count: 2}},
+ },
+ }
+}
+
+func TestFormatInt(t *testing.T) {
+ tests := []struct {
+ n int
+ want string
+ }{
+ {0, "0"},
+ {999, "999"},
+ {1000, "1,000"},
+ {4010, "4,010"},
+ {10000, "10,000"},
+ {1000000, "1,000,000"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ if got := FormatInt(tt.n); got != tt.want {
+ t.Errorf("FormatInt(%d) = %q, want %q", tt.n, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestLoop_NoTournamentSelected(t *testing.T) {
+ runPicker := func(_ pickerModel) (pickerModel, error) { return pickerModel{selected: nil}, nil }
+ dashCalled := false
+ runDashboard := func(_ dashboardModel) (dashboardModel, error) {
+ dashCalled = true
+ return dashboardModel{}, nil
+ }
+ err := loop(testSpec(), noopConn, runPicker, runDashboard)
+ require.NoError(t, err)
+ require.False(t, dashCalled, "dashboard should not launch when no tournament is selected")
+}
+
+func TestLoop_TournamentSelected_DashboardExits(t *testing.T) {
+ td := TournamentRef{Location: "London"}
+ runPicker := func(_ pickerModel) (pickerModel, error) { return pickerModel{selected: &td}, nil }
+ runDashboard := func(m dashboardModel) (dashboardModel, error) {
+ assert.Equal(t, "London", m.tournament)
+ return dashboardModel{goBack: false}, nil
+ }
+ assert.NoError(t, loop(testSpec(), noopConn, runPicker, runDashboard))
+}
+
+func TestLoop_GoBack_LoopsToPicker(t *testing.T) {
+ td := TournamentRef{Location: "London"}
+ calls := 0
+ runPicker := func(_ pickerModel) (pickerModel, error) {
+ calls++
+ if calls == 1 {
+ return pickerModel{selected: &td}, nil
+ }
+ return pickerModel{selected: nil}, nil
+ }
+ runDashboard := func(_ dashboardModel) (dashboardModel, error) { return dashboardModel{goBack: true}, nil }
+ require.NoError(t, loop(testSpec(), noopConn, runPicker, runDashboard))
+ require.Equal(t, 2, calls, "expected the picker to run twice")
+}
+
+func TestLoop_PickerError(t *testing.T) {
+ runPicker := func(_ pickerModel) (pickerModel, error) { return pickerModel{}, errors.New("boom") }
+ err := loop(testSpec(), noopConn, runPicker, nil)
+ assert.ErrorContains(t, err, "tournament selection")
+}
+
+func TestLoop_DashboardError(t *testing.T) {
+ td := TournamentRef{Location: "London"}
+ runPicker := func(_ pickerModel) (pickerModel, error) { return pickerModel{selected: &td}, nil }
+ runDashboard := func(_ dashboardModel) (dashboardModel, error) {
+ return dashboardModel{}, errors.New("boom")
+ }
+ assert.ErrorContains(t, loop(testSpec(), noopConn, runPicker, runDashboard), "dashboard")
+}
diff --git a/cmd/comp/shell/styles.go b/cmd/comp/shell/styles.go
new file mode 100644
index 00000000..62b04d25
--- /dev/null
+++ b/cmd/comp/shell/styles.go
@@ -0,0 +1,49 @@
+package shell
+
+import (
+ "image/color"
+ "os"
+
+ "charm.land/lipgloss/v2"
+)
+
+type Styles struct {
+ Doc lipgloss.Style
+ InactiveTab lipgloss.Style
+ ActiveTab lipgloss.Style
+ Window lipgloss.Style
+ HighlightColor color.Color
+}
+
+func tabBorderWithBottom(left, middle, right string) lipgloss.Border {
+ border := lipgloss.RoundedBorder()
+ border.BottomLeft = left
+ border.Bottom = middle
+ border.BottomRight = right
+ return border
+}
+
+func NewStyles() *Styles {
+ inactiveTabBorder := tabBorderWithBottom("┴", "─", "┴")
+ activeTabBorder := tabBorderWithBottom("┘", " ", "└")
+ isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+ ld := lipgloss.LightDark(isDark)
+ highlightColor := ld(lipgloss.Color("#874BFD"), lipgloss.Color("#7D56F4"))
+
+ s := new(Styles)
+ s.Doc = lipgloss.NewStyle().
+ Padding(1, 2, 1, 2)
+ s.InactiveTab = lipgloss.NewStyle().
+ Border(inactiveTabBorder, true).
+ BorderForeground(highlightColor).
+ Padding(0, 1)
+ s.ActiveTab = s.InactiveTab.
+ Border(activeTabBorder, true)
+ s.Window = lipgloss.NewStyle().
+ BorderForeground(highlightColor).
+ Padding(2, 0).
+ Border(lipgloss.NormalBorder()).
+ UnsetBorderTop()
+ s.HighlightColor = highlightColor
+ return s
+}
diff --git a/cmd/comp/shell/usagetable.go b/cmd/comp/shell/usagetable.go
new file mode 100644
index 00000000..5ce48c4a
--- /dev/null
+++ b/cmd/comp/shell/usagetable.go
@@ -0,0 +1,64 @@
+package shell
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "charm.land/bubbles/v2/table"
+)
+
+func newUsageTable(f Frequency, total, width, height int) table.Model {
+ avail := width - 8
+ const rankW, countW, shareW, barWidth = 4, 8, 18, 11
+ nameW := min(max(avail-rankW-countW-shareW-8, 16), 40)
+
+ columns := []table.Column{
+ {Title: "#", Width: rankW},
+ {Title: f.NameHeader, Width: nameW},
+ {Title: f.CountHeader, Width: countW},
+ {Title: "Share", Width: shareW},
+ }
+
+ items := make([]Tally, len(f.Items))
+ copy(items, f.Items)
+ sort.Slice(items, func(i, j int) bool { return items[i].Count > items[j].Count })
+
+ rows := make([]table.Row, len(items))
+ for i, it := range items {
+ rows[i] = table.Row{
+ strconv.Itoa(i + 1),
+ it.Label,
+ strconv.Itoa(it.Count),
+ shareCell(it.Count, total, barWidth),
+ }
+ }
+
+ tableHeight := height - 14
+ if f.Caption != "" {
+ tableHeight -= 2
+ }
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(max(tableHeight, 5)),
+ table.WithWidth(rankW+nameW+countW+shareW+4*2),
+ )
+ t.SetStyles(TableStyles())
+ return t
+}
+
+func shareCell(count, total, barWidth int) string {
+ filled, pct := 0, 0
+ if total > 0 {
+ filled = min(count*barWidth/total, barWidth)
+ if filled == 0 && count > 0 {
+ filled = 1
+ }
+ pct = count * 100 / total
+ }
+ bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
+ return fmt.Sprintf("%s %3d%%", bar, pct)
+}
diff --git a/cmd/comp/shell/usagetable_test.go b/cmd/comp/shell/usagetable_test.go
new file mode 100644
index 00000000..73ddca23
--- /dev/null
+++ b/cmd/comp/shell/usagetable_test.go
@@ -0,0 +1,53 @@
+package shell
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNewUsageTable_SortedRanked(t *testing.T) {
+ f := Frequency{NameHeader: "Pokémon", CountHeader: "Teams", Items: []Tally{
+ {Label: "Incineroar", Count: 5},
+ {Label: "Flutter Mane", Count: 8},
+ {Label: "Rillaboom", Count: 2},
+ }}
+ tbl := newUsageTable(f, 10, 120, 40)
+ rows := tbl.Rows()
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+ if rows[0][0] != "1" || rows[0][1] != "Flutter Mane" || rows[0][2] != "8" {
+ t.Errorf("expected rank-1 row to be Flutter Mane/8, got %v", rows[0])
+ }
+ if rows[2][1] != "Rillaboom" {
+ t.Errorf("expected lowest count last, got %q", rows[2][1])
+ }
+ if !strings.Contains(rows[0][3], "80%") || !strings.Contains(rows[0][3], "█") {
+ t.Errorf("expected rank-1 share to show a full-ish bar and 80%%, got %q", rows[0][3])
+ }
+}
+
+func TestShareCell(t *testing.T) {
+ full := shareCell(20, 20, 11)
+ if strings.Count(full, "█") != 11 || !strings.Contains(full, "100%") {
+ t.Errorf("expected a full 11-block bar at 100%%, got %q", full)
+ }
+
+ half := shareCell(10, 20, 11)
+ if strings.Count(half, "█") != 5 || !strings.Contains(half, "50%") {
+ t.Errorf("expected ~half bar at 50%% (absolute), got %q", half)
+ }
+
+ none := shareCell(0, 20, 11)
+ if strings.Contains(none, "█") || !strings.Contains(none, "0%") {
+ t.Errorf("expected no filled blocks at 0%%, got %q", none)
+ }
+
+ over := shareCell(30, 20, 11)
+ if strings.Count(over, "█") != 11 {
+ t.Errorf("expected filled to clamp at barWidth when count>total, got %q", over)
+ }
+
+ _ = shareCell(5, 0, 11)
+}
diff --git a/cmd/comp/tcg/data.go b/cmd/comp/tcg/data.go
new file mode 100644
index 00000000..fdb28981
--- /dev/null
+++ b/cmd/comp/tcg/data.go
@@ -0,0 +1,89 @@
+package tcg
+
+import (
+ "encoding/json"
+ "image/color"
+ "strconv"
+
+ "charm.land/bubbles/v2/table"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+type standingRow struct {
+ Rank int `json:"rank"`
+ Name string `json:"name"`
+ Points int `json:"points"`
+ Record string `json:"record"`
+ OppWinPct string `json:"opp_win_percent"`
+ OppOppWinPct string `json:"opp_opp_win_percent"`
+ Deck string `json:"deck"`
+ PlayerCountry string `json:"player_country"`
+ CountryCode string `json:"country_code"`
+ Location string `json:"location"`
+ TextDate string `json:"text_date"`
+ Type string `json:"type"`
+ PlayerQty int `json:"player_quantity"`
+}
+
+func decode(body []byte) (shell.Decoded, error) {
+ var rows []standingRow
+ if err := json.Unmarshal(body, &rows); err != nil {
+ return shell.Decoded{}, err
+ }
+
+ d := shell.Decoded{
+ TableRows: make([]table.Row, len(rows)),
+ Countries: countryItems(rows),
+ }
+ for i, r := range rows {
+ d.TableRows[i] = table.Row{
+ strconv.Itoa(r.Rank), r.Name, strconv.Itoa(r.Points), r.Record,
+ r.OppWinPct, r.OppOppWinPct, r.Deck, r.PlayerCountry,
+ }
+ }
+
+ d.Extra = shell.Frequency{
+ NameHeader: "Deck",
+ CountHeader: "Players",
+ Caption: "Based on the top 256 players per event.",
+ Items: deckItems(rows),
+ }
+
+ var tournament, tType, date, winner, winningDeck string
+ var total int
+ if len(rows) > 0 {
+ first := rows[0]
+ tournament, tType, date = first.Location, first.Type, first.TextDate
+ winner, winningDeck, total = first.Name, first.Deck, first.PlayerQty
+ }
+ d.Overview = func(contentWidth int, hc color.Color) string {
+ return overviewContent(tournament, tType, date, winner, winningDeck, total, contentWidth, hc)
+ }
+ return d, nil
+}
+
+func countryItems(rows []standingRow) []shell.Tally {
+ counts := map[string]int{}
+ for _, r := range rows {
+ if r.PlayerCountry != "" {
+ counts[r.PlayerCountry]++
+ }
+ }
+ items := make([]shell.Tally, 0, len(counts))
+ for country, n := range counts {
+ items = append(items, shell.Tally{Label: country, Count: n})
+ }
+ return items
+}
+
+func deckItems(rows []standingRow) []shell.Tally {
+ counts := map[string]int{}
+ for _, r := range rows {
+ counts[r.Deck]++
+ }
+ items := make([]shell.Tally, 0, len(counts))
+ for deck, n := range counts {
+ items = append(items, shell.Tally{Label: deck, Count: n})
+ }
+ return items
+}
diff --git a/cmd/comp/tcg/data_test.go b/cmd/comp/tcg/data_test.go
new file mode 100644
index 00000000..577d7e92
--- /dev/null
+++ b/cmd/comp/tcg/data_test.go
@@ -0,0 +1,90 @@
+package tcg
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+)
+
+func TestDecode_Success(t *testing.T) {
+ body := []byte(`[
+ {"rank":1,"name":"Ash","points":47,"record":"15 - 1 - 0","deck":"gardevoir","player_country":"USA","type":"Regional","text_date":"Jan 10","player_quantity":500,"location":"London"},
+ {"rank":2,"name":"Misty","points":44,"deck":"dragapult","player_country":"Japan"}
+ ]`)
+ d, err := decode(body)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(d.TableRows) != 2 {
+ t.Errorf("expected 2 table rows, got %d", len(d.TableRows))
+ }
+ if len(d.Countries) != 2 {
+ t.Errorf("expected 2 country tallies, got %d", len(d.Countries))
+ }
+
+ overview := d.Overview(120, lipgloss.Color("#7D56F4"))
+ for _, s := range []string{"London", "Regional", "Ash", "gardevoir", "500"} {
+ if !strings.Contains(overview, s) {
+ t.Errorf("expected overview to contain %q", s)
+ }
+ }
+
+ if d.Extra.NameHeader != "Deck" || d.Extra.CountHeader != "Players" {
+ t.Errorf("unexpected Extra headers: %q / %q", d.Extra.NameHeader, d.Extra.CountHeader)
+ }
+ decks := map[string]int{}
+ for _, it := range d.Extra.Items {
+ decks[it.Label] = it.Count
+ }
+ if decks["gardevoir"] != 1 || decks["dragapult"] != 1 {
+ t.Errorf("expected Decks tallies for gardevoir+dragapult, got %v", decks)
+ }
+}
+
+func TestDecode_InvalidJSON(t *testing.T) {
+ if _, err := decode([]byte("not json")); err == nil {
+ t.Error("expected unmarshal error, got nil")
+ }
+}
+
+func TestDecode_EmptyCountrySkipped(t *testing.T) {
+ body := []byte(`[{"rank":1,"name":"Ash","player_country":""},{"rank":2,"name":"Misty","player_country":"Japan"}]`)
+ d, err := decode(body)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(d.Countries) != 1 {
+ t.Errorf("expected empty country skipped (1 tally), got %d", len(d.Countries))
+ }
+}
+
+func TestStandingsColumns_HasDeck(t *testing.T) {
+ cols := standingsColumns(120)
+ if len(cols) != 8 {
+ t.Fatalf("expected 8 columns, got %d", len(cols))
+ }
+ found := false
+ for _, c := range cols {
+ if c.Title == "Deck" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected a Deck column in the TCG standings table")
+ }
+}
+
+func TestSpec_URLs(t *testing.T) {
+ s := Spec()
+ if !strings.Contains(s.ListURL, "comp_tcg_standings_view") {
+ t.Errorf("expected TCG view in list URL, got %q", s.ListURL)
+ }
+ durl := s.DashboardURL("São Paulo")
+ if !strings.Contains(durl, "comp_tcg_standings_view") {
+ t.Errorf("expected TCG view in dashboard URL, got %q", durl)
+ }
+ if !strings.Contains(durl, "S%C3%A3o") {
+ t.Errorf("expected URL-encoded location, got %q", durl)
+ }
+}
diff --git a/cmd/comp/tcg/tab_overview.go b/cmd/comp/tcg/tab_overview.go
new file mode 100644
index 00000000..1690918b
--- /dev/null
+++ b/cmd/comp/tcg/tab_overview.go
@@ -0,0 +1,29 @@
+package tcg
+
+import (
+ "fmt"
+ "image/color"
+
+ "charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+func overviewContent(tournament, tournamentType, tournamentDate, winner, winningDeck string, totalPlayers, contentWidth int, highlightColor color.Color) string {
+ header := fmt.Sprintf("%s · %s · %s", tournament, tournamentType, tournamentDate)
+
+ statBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(highlightColor).
+ Padding(1, 2).
+ Width(26).
+ Align(lipgloss.Center)
+
+ totalBox := statBox.Render("Total Players\n\n" + shell.FormatInt(totalPlayers))
+ winnerBox := statBox.Render("Winner\n\n" + winner)
+ deckBox := statBox.Render("Winning Deck\n\n" + winningDeck)
+
+ boxes := lipgloss.JoinHorizontal(lipgloss.Top, totalBox, " ", winnerBox, " ", deckBox)
+
+ content := header + "\n\n" + boxes
+ return lipgloss.NewStyle().Width(contentWidth).Align(lipgloss.Center).Render(content)
+}
diff --git a/cmd/tcg/tab_overview_test.go b/cmd/comp/tcg/tab_overview_test.go
similarity index 71%
rename from cmd/tcg/tab_overview_test.go
rename to cmd/comp/tcg/tab_overview_test.go
index 5b421424..23af4d82 100644
--- a/cmd/tcg/tab_overview_test.go
+++ b/cmd/comp/tcg/tab_overview_test.go
@@ -10,7 +10,6 @@ import (
func TestOverviewContent(t *testing.T) {
tests := []struct {
name string
- flag string
tournament string
tType string
tDate string
@@ -22,7 +21,6 @@ func TestOverviewContent(t *testing.T) {
}{
{
name: "all fields present in output",
- flag: "🇺🇸",
tournament: "Dallas",
tType: "Regional",
tDate: "January 10-12, 2025",
@@ -55,7 +53,7 @@ func TestOverviewContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := overviewContent(tt.flag, tt.tournament, tt.tType, tt.tDate, tt.winner, tt.winningDeck, tt.totalPlayers, tt.contentWidth, lipgloss.Color("#7D56F4"))
+ result := overviewContent(tt.tournament, tt.tType, tt.tDate, tt.winner, tt.winningDeck, tt.totalPlayers, tt.contentWidth, lipgloss.Color("#7D56F4"))
if result == "" {
t.Fatal("expected non-empty output")
}
@@ -67,26 +65,3 @@ func TestOverviewContent(t *testing.T) {
})
}
}
-
-func TestFormatInt(t *testing.T) {
- tests := []struct {
- n int
- want string
- }{
- {0, "0"},
- {999, "999"},
- {1000, "1,000"},
- {4010, "4,010"},
- {10000, "10,000"},
- {1000000, "1,000,000"},
- }
-
- for _, tt := range tests {
- t.Run(tt.want, func(t *testing.T) {
- got := formatInt(tt.n)
- if got != tt.want {
- t.Errorf("formatInt(%d) = %q, want %q", tt.n, got, tt.want)
- }
- })
- }
-}
diff --git a/cmd/comp/tcg/tcg.go b/cmd/comp/tcg/tcg.go
new file mode 100644
index 00000000..3056b1b0
--- /dev/null
+++ b/cmd/comp/tcg/tcg.go
@@ -0,0 +1,44 @@
+package tcg
+
+import (
+ "net/url"
+
+ "charm.land/bubbles/v2/table"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+ "github.com/digitalghost-dev/poke-cli/connections"
+)
+
+const baseURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/comp_tcg_standings_view"
+
+func Run() (back bool, err error) {
+ return shell.Run(Spec(), connections.CallTCGData)
+}
+
+func Spec() shell.Spec {
+ return shell.Spec{
+ Tabs: []string{"Overview", "Standings", "Decks", "Countries"},
+ ListURL: baseURL + "?select=location,text_date&rank=eq.1&order=start_date.desc",
+ DashboardURL: func(location string) string {
+ cols := "rank,name,points,record,opp_win_percent,opp_opp_win_percent,deck,player_country,country_code,location,text_date,type,player_quantity"
+ return baseURL + "?select=" + cols + "&location=eq." + url.QueryEscape(location) + "&order=rank"
+ },
+ Columns: standingsColumns,
+ Decode: decode,
+ }
+}
+
+func standingsColumns(width int) []table.Column {
+ fixedWidth := 4 + 20 + 6 + 10 + 7 + 7 + 18
+ separators := 8 * 2
+ deckWidth := min(max(width-fixedWidth-separators, 10), 30)
+ return []table.Column{
+ {Title: "Rank", Width: 4},
+ {Title: "Name", Width: 20},
+ {Title: "Points", Width: 6},
+ {Title: "Record", Width: 10},
+ {Title: "OPW%", Width: 7},
+ {Title: "OOPW%", Width: 7},
+ {Title: "Deck", Width: deckWidth},
+ {Title: "Country", Width: 18},
+ }
+}
diff --git a/cmd/comp/vgc/data.go b/cmd/comp/vgc/data.go
new file mode 100644
index 00000000..3e652be0
--- /dev/null
+++ b/cmd/comp/vgc/data.go
@@ -0,0 +1,106 @@
+package vgc
+
+import (
+ "encoding/json"
+ "image/color"
+ "strconv"
+
+ "charm.land/bubbles/v2/table"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+type vgcMon struct {
+ Name string `json:"name"`
+ Item string `json:"item"`
+ Ability string `json:"ability"`
+ TeraType string `json:"teratype"`
+ Moves []string `json:"badges"`
+}
+
+type standingRow struct {
+ Rank int `json:"rank"`
+ Name string `json:"name"`
+ Points int `json:"points"`
+ Record string `json:"record"`
+ OppWinPct string `json:"opp_win_percent"`
+ OppOppWinPct string `json:"opp_opp_win_percent"`
+ Team []vgcMon `json:"team"`
+ PlayerCountry string `json:"player_country"`
+ CountryCode string `json:"country_code"`
+ Location string `json:"location"`
+ TextDate string `json:"text_date"`
+ Type string `json:"type"`
+ PlayerQty int `json:"player_quantity"`
+}
+
+func decode(body []byte) (shell.Decoded, error) {
+ var rows []standingRow
+ if err := json.Unmarshal(body, &rows); err != nil {
+ return shell.Decoded{}, err
+ }
+
+ d := shell.Decoded{
+ TableRows: make([]table.Row, len(rows)),
+ Countries: countryItems(rows),
+ }
+ for i, r := range rows {
+ d.TableRows[i] = table.Row{
+ strconv.Itoa(r.Rank), r.Name, strconv.Itoa(r.Points), r.Record,
+ r.OppWinPct, r.OppOppWinPct, r.PlayerCountry,
+ }
+ }
+
+ d.Extra = shell.Frequency{
+ NameHeader: "Pokémon",
+ CountHeader: "Teams",
+ Caption: "Based on the top 256 players' teams per event.",
+ Items: usageItems(rows),
+ }
+
+ var tournament, tType, date, winner string
+ var total int
+ var winnerTeam []string
+ if len(rows) > 0 {
+ first := rows[0]
+ tournament, tType, date = first.Location, first.Type, first.TextDate
+ winner, total = first.Name, first.PlayerQty
+ winnerTeam = make([]string, len(first.Team))
+ for i, mon := range first.Team {
+ winnerTeam[i] = baseName(mon.Name)
+ }
+ }
+ d.Overview = func(contentWidth int, hc color.Color) string {
+ return overviewContent(tournament, tType, date, winner, winnerTeam, total, contentWidth, hc)
+ }
+ return d, nil
+}
+
+func countryItems(rows []standingRow) []shell.Tally {
+ counts := map[string]int{}
+ for _, r := range rows {
+ if r.PlayerCountry != "" {
+ counts[r.PlayerCountry]++
+ }
+ }
+ items := make([]shell.Tally, 0, len(counts))
+ for country, n := range counts {
+ items = append(items, shell.Tally{Label: country, Count: n})
+ }
+ return items
+}
+
+func usageItems(rows []standingRow) []shell.Tally {
+ counts := map[string]int{}
+ for _, r := range rows {
+ for _, mon := range r.Team {
+ if mon.Name != "" {
+ counts[mon.Name]++
+ }
+ }
+ }
+ items := make([]shell.Tally, 0, len(counts))
+ for name, n := range counts {
+ items = append(items, shell.Tally{Label: name, Count: n})
+ }
+ return items
+}
diff --git a/cmd/comp/vgc/data_test.go b/cmd/comp/vgc/data_test.go
new file mode 100644
index 00000000..8b651238
--- /dev/null
+++ b/cmd/comp/vgc/data_test.go
@@ -0,0 +1,98 @@
+package vgc
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+)
+
+func TestDecode_Success(t *testing.T) {
+ body := []byte(`[
+ {"rank":1,"name":"Arsal","points":45,"player_country":"United States","type":"Regional","text_date":"May 29-31, 2026","player_quantity":1013,"location":"Indianapolis",
+ "team":[{"name":"Venusaur"},{"name":"Landorus [Therian Forme]"},{"name":"Iron Crown"}]},
+ {"rank":2,"name":"Wolfe","points":42,"player_country":"United States",
+ "team":[{"name":"Sneasler"},{"name":"Landorus [Therian Forme]"},{"name":"Iron Crown"}]}
+ ]`)
+ d, err := decode(body)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ if len(d.TableRows) != 2 {
+ t.Errorf("expected 2 table rows, got %d", len(d.TableRows))
+ }
+ if len(d.Countries) != 1 {
+ t.Errorf("expected 1 country tally, got %d", len(d.Countries))
+ }
+
+ overview := d.Overview(120, lipgloss.Color("#7D56F4"))
+ for _, s := range []string{"Indianapolis", "Regional", "Arsal", "Venusaur", "1,013", "Winning Team"} {
+ if !strings.Contains(overview, s) {
+ t.Errorf("expected overview to contain %q", s)
+ }
+ }
+
+ if !strings.Contains(overview, "Landorus") || strings.Contains(overview, "Therian Forme") {
+ t.Error("expected Landorus forme bracket stripped in the winner team box")
+ }
+
+ if d.Extra.NameHeader != "Pokémon" || d.Extra.CountHeader != "Teams" {
+ t.Errorf("unexpected Extra headers: %q / %q", d.Extra.NameHeader, d.Extra.CountHeader)
+ }
+ usage := map[string]int{}
+ for _, it := range d.Extra.Items {
+ usage[it.Label] = it.Count
+ }
+ if usage["Iron Crown"] != 2 || usage["Landorus [Therian Forme]"] != 2 {
+ t.Errorf("expected Usage tallies to keep full forme names, got %v", usage)
+ }
+}
+
+func TestDecode_TeamJSONB(t *testing.T) {
+ body := []byte(`[{"rank":1,"name":"Arsal","team":[
+ {"id":"10021","name":"Landorus [Therian Forme]","item":"Choice Band","ability":"Intimidate","teratype":"Steel","badges":["Stomping Tantrum","U-turn","Earthquake","Rock Slide"]}
+ ]}]`)
+ d, err := decode(body)
+ if err != nil {
+ t.Fatalf("decode error: %v", err)
+ }
+ usage := map[string]int{}
+ for _, it := range d.Extra.Items {
+ usage[it.Label] = it.Count
+ }
+ if usage["Landorus [Therian Forme]"] != 1 {
+ t.Errorf("expected forme name kept in usage tallies, got %v", usage)
+ }
+}
+
+func TestDecode_InvalidJSON(t *testing.T) {
+ if _, err := decode([]byte("not json")); err == nil {
+ t.Error("expected unmarshal error, got nil")
+ }
+}
+
+func TestStandingsColumns_NoDeck(t *testing.T) {
+ cols := standingsColumns(120)
+ if len(cols) != 7 {
+ t.Fatalf("expected 7 columns, got %d", len(cols))
+ }
+ for _, c := range cols {
+ if c.Title == "Deck" {
+ t.Error("VGC standings should not have a Deck column")
+ }
+ }
+}
+
+func TestSpec_URLs(t *testing.T) {
+ s := Spec()
+ if !strings.Contains(s.ListURL, "comp_vgc_standings_view") {
+ t.Errorf("expected VGC view in list URL, got %q", s.ListURL)
+ }
+ durl := s.DashboardURL("São Paulo")
+ if !strings.Contains(durl, "comp_vgc_standings_view") || !strings.Contains(durl, "team") {
+ t.Errorf("expected VGC view + team column in dashboard URL, got %q", durl)
+ }
+ if !strings.Contains(durl, "S%C3%A3o") {
+ t.Errorf("expected URL-encoded location, got %q", durl)
+ }
+}
diff --git a/cmd/comp/vgc/tab_overview.go b/cmd/comp/vgc/tab_overview.go
new file mode 100644
index 00000000..57215973
--- /dev/null
+++ b/cmd/comp/vgc/tab_overview.go
@@ -0,0 +1,80 @@
+package vgc
+
+import (
+ "fmt"
+ "image/color"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+)
+
+func baseName(name string) string {
+ if i := strings.IndexByte(name, '['); i > 0 {
+ return strings.TrimSpace(name[:i])
+ }
+ return name
+}
+
+func teamGrid(team []string) string {
+ if len(team) == 0 {
+ return "—"
+ }
+
+ bullets := make([]string, len(team))
+ for i, name := range team {
+ bullets[i] = "• " + name
+ }
+
+ rows := (len(bullets) + 1) / 2
+ left := bullets[:rows]
+ right := bullets[rows:]
+
+ cellWidth := func(items []string) int {
+ w := 0
+ for _, s := range items {
+ if lw := lipgloss.Width(s); lw > w {
+ w = lw
+ }
+ }
+ return w
+ }
+ leftStyle := lipgloss.NewStyle().Width(cellWidth(left))
+ rightStyle := lipgloss.NewStyle().Width(cellWidth(right))
+
+ lines := make([]string, rows)
+ for i := range rows {
+ r := ""
+ if i < len(right) {
+ r = right[i]
+ }
+ lines[i] = leftStyle.Render(left[i]) + " " + rightStyle.Render(r)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func overviewContent(tournament, tournamentType, tournamentDate, winner string, winnerTeam []string, totalPlayers, contentWidth int, highlightColor color.Color) string {
+ header := fmt.Sprintf("%s · %s · %s", tournament, tournamentType, tournamentDate)
+
+ statBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(highlightColor).
+ Padding(1, 2).
+ Width(26).
+ Align(lipgloss.Center)
+
+ totalBox := statBox.Render("Total Players\n\n" + shell.FormatInt(totalPlayers))
+ winnerBox := statBox.Render("Winner\n\n" + winner)
+
+ teamBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(highlightColor).
+ Padding(1, 2).
+ Align(lipgloss.Center).
+ Render("Winning Team\n\n" + teamGrid(winnerTeam))
+
+ boxes := lipgloss.JoinHorizontal(lipgloss.Top, totalBox, " ", winnerBox, " ", teamBox)
+
+ content := header + "\n\n" + boxes
+ return lipgloss.NewStyle().Width(contentWidth).Align(lipgloss.Center).Render(content)
+}
diff --git a/cmd/comp/vgc/tab_overview_test.go b/cmd/comp/vgc/tab_overview_test.go
new file mode 100644
index 00000000..6a3f93e7
--- /dev/null
+++ b/cmd/comp/vgc/tab_overview_test.go
@@ -0,0 +1,107 @@
+package vgc
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+)
+
+func TestOverviewContent(t *testing.T) {
+ tests := []struct {
+ name string
+ tournament string
+ tType string
+ tDate string
+ winner string
+ winnerTeam []string
+ totalPlayers int
+ contentWidth int
+ contains []string
+ }{
+ {
+ name: "all fields present in output",
+ tournament: "Indianapolis",
+ tType: "Regional",
+ tDate: "May 29-31, 2026",
+ winner: "Arsal Puri",
+ winnerTeam: []string{"Venusaur", "Landorus", "Iron Crown"},
+ totalPlayers: 1013,
+ contentWidth: 110,
+ contains: []string{"Indianapolis", "Regional", "May 29-31, 2026", "Arsal Puri", "Venusaur", "Iron Crown", "1,013", "Total Players", "Winner", "Winning Team"},
+ },
+ {
+ name: "large player count formatted with commas",
+ totalPlayers: 1000000,
+ contentWidth: 80,
+ contains: []string{"1,000,000"},
+ },
+ {
+ name: "empty team does not panic",
+ contentWidth: 80,
+ contains: []string{"Total Players", "Winner", "Winning Team"},
+ },
+ {
+ name: "narrow content width does not panic",
+ tournament: "Turin",
+ winner: "Trainer Red",
+ winnerTeam: []string{"Miraidon"},
+ totalPlayers: 500,
+ contentWidth: 10,
+ contains: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := overviewContent(tt.tournament, tt.tType, tt.tDate, tt.winner, tt.winnerTeam, tt.totalPlayers, tt.contentWidth, lipgloss.Color("#7D56F4"))
+ if result == "" {
+ t.Fatal("expected non-empty output")
+ }
+ for _, s := range tt.contains {
+ if !strings.Contains(result, s) {
+ t.Errorf("expected output to contain %q", s)
+ }
+ }
+ })
+ }
+}
+
+func TestTeamGrid(t *testing.T) {
+ team := []string{"Venusaur", "Charizard", "Garchomp", "Incineroar", "Floette", "Sinistcha"}
+ grid := teamGrid(team)
+ lines := strings.Split(grid, "\n")
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 rows for a 6-member team, got %d: %q", len(lines), grid)
+ }
+ if !strings.Contains(lines[0], "Venusaur") || !strings.Contains(lines[0], "Incineroar") {
+ t.Errorf("expected first row to pair Venusaur + Incineroar, got %q", lines[0])
+ }
+ if !strings.Contains(grid, "• ") {
+ t.Error("expected bullets in the grid")
+ }
+
+ if teamGrid(nil) != "—" {
+ t.Error("expected em dash placeholder for an empty team")
+ }
+}
+
+func TestBaseName(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"Tornadus [Incarnate Forme]", "Tornadus"},
+ {"Urshifu [Rapid Strike Style]", "Urshifu"},
+ {"Venusaur", "Venusaur"},
+ {"Ogerpon [Hearthflame Mask]", "Ogerpon"},
+ {"", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.in, func(t *testing.T) {
+ if got := baseName(tt.in); got != tt.want {
+ t.Errorf("baseName(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/cmd/comp/vgc/vgc.go b/cmd/comp/vgc/vgc.go
new file mode 100644
index 00000000..d06c687e
--- /dev/null
+++ b/cmd/comp/vgc/vgc.go
@@ -0,0 +1,45 @@
+package vgc
+
+import (
+ "net/url"
+
+ "charm.land/bubbles/v2/table"
+ "github.com/digitalghost-dev/poke-cli/cmd/comp/shell"
+ "github.com/digitalghost-dev/poke-cli/connections"
+)
+
+const baseURL = "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/comp_vgc_standings_view"
+
+func Run() (back bool, err error) {
+ return shell.Run(Spec(), connections.CallTCGData)
+}
+
+func Spec() shell.Spec {
+ return shell.Spec{
+ Tabs: []string{"Overview", "Standings", "Usage", "Countries"},
+ ListURL: baseURL + "?select=location,text_date&rank=eq.1&order=start_date.desc",
+ DashboardURL: func(location string) string {
+ cols := "rank,name,points,record,opp_win_percent,opp_opp_win_percent,team,player_country,country_code,location,text_date,type,player_quantity"
+ return baseURL + "?select=" + cols + "&location=eq." + url.QueryEscape(location) + "&order=rank"
+ },
+ Columns: standingsColumns,
+ Decode: decode,
+ }
+}
+
+func standingsColumns(width int) []table.Column {
+ fixedWidth := 4 + 6 + 10 + 7 + 7
+ separators := 7 * 2
+ flexWidth := max(width-fixedWidth-separators, 40)
+ nameWidth := min(max(flexWidth*3/5, 20), 32)
+ countryWidth := min(max(flexWidth-nameWidth, 16), 24)
+ return []table.Column{
+ {Title: "Rank", Width: 4},
+ {Title: "Name", Width: nameWidth},
+ {Title: "Points", Width: 6},
+ {Title: "Record", Width: 10},
+ {Title: "OPW%", Width: 7},
+ {Title: "OOPW%", Width: 7},
+ {Title: "Country", Width: countryWidth},
+ }
+}
diff --git a/cmd/mechanics/mechanics.go b/cmd/mechanics/mechanics.go
new file mode 100644
index 00000000..a9b7a11d
--- /dev/null
+++ b/cmd/mechanics/mechanics.go
@@ -0,0 +1,59 @@
+package mechanics
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+ "github.com/digitalghost-dev/poke-cli/flags"
+ flag "github.com/spf13/pflag"
+)
+
+func MechanicsCommand(args []string) (string, error) {
+ var output strings.Builder
+
+ usage := func() {
+ output.WriteString(
+ utils.GenerateHelpMessage(
+ utils.HelpConfig{
+ Description: "Get details about game mechanics.",
+ CmdName: "mechanics",
+ Flags: []utils.FlagHelp{
+ {Short: "-n", Long: "--natures", Description: "Prints a table with all natures and their respective buffs and debuffs."},
+ },
+ },
+ ),
+ )
+ }
+
+ mf := flags.SetupMechanicsFlagSet()
+
+ if utils.CheckHelpFlag(args, usage) {
+ return output.String(), nil
+ }
+
+ if err := utils.ValidateArgs(
+ args,
+ utils.Validator{MaxArgs: 2, CmdName: "mechanics", RequireName: false, HasFlags: true},
+ ); err != nil {
+ output.WriteString(err.Error())
+ return output.String(), err
+ }
+
+ if err := mf.FlagSet.Parse(args[1:]); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return output.String(), nil
+ }
+ output.WriteString(utils.FormatFlagError("mechanics", err))
+ return output.String(), err
+ }
+
+ switch {
+ case *mf.Natures:
+ output.WriteString(flags.NaturesFlag())
+ default:
+ usage()
+ }
+
+ return output.String(), nil
+}
diff --git a/cmd/mechanics/mechanics_test.go b/cmd/mechanics/mechanics_test.go
new file mode 100644
index 00000000..07ce15d6
--- /dev/null
+++ b/cmd/mechanics/mechanics_test.go
@@ -0,0 +1,71 @@
+package mechanics
+
+import (
+ "testing"
+
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+ "github.com/digitalghost-dev/poke-cli/styling"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMechanicsCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ expectedOutput string
+ wantError bool
+ }{
+ {
+ name: "Mechanics help flag --help",
+ args: []string{"mechanics", "--help"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_help.golden"),
+ },
+ {
+ name: "Mechanics help flag -h",
+ args: []string{"mechanics", "-h"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_help.golden"),
+ },
+ {
+ name: "No flag shows help",
+ args: []string{"mechanics"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_help.golden"),
+ },
+ {
+ name: "Natures flag --natures",
+ args: []string{"mechanics", "--natures"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_natures.golden"),
+ },
+ {
+ name: "Natures flag -n",
+ args: []string{"mechanics", "-n"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_natures.golden"),
+ },
+ {
+ name: "Too many arguments",
+ args: []string{"mechanics", "--natures", "extra"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_too_many_args.golden"),
+ wantError: true,
+ },
+ {
+ name: "Invalid flag",
+ args: []string{"mechanics", "--bogus"},
+ expectedOutput: utils.LoadGolden(t, "mechanics_invalid_flag.golden"),
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := MechanicsCommand(tt.args)
+ if tt.wantError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ cleanOutput := styling.StripANSI(output)
+
+ assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected")
+ })
+ }
+}
diff --git a/cmd/natures/natures_test.go b/cmd/natures/natures_test.go
deleted file mode 100644
index 40fe83bd..00000000
--- a/cmd/natures/natures_test.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package natures
-
-import (
- "github.com/digitalghost-dev/poke-cli/cmd/utils"
- "github.com/digitalghost-dev/poke-cli/styling"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "testing"
-)
-
-func TestNaturesCommand(t *testing.T) {
- tests := []struct {
- name string
- args []string
- expectedOutput string
- wantError bool
- }{
- {
- name: "Natures help flag",
- args: []string{"natures", "--help"},
- expectedOutput: utils.LoadGolden(t, "natures_help.golden"),
- },
- {
- name: "Natures help flag",
- args: []string{"natures", "-h"},
- expectedOutput: utils.LoadGolden(t, "natures_help.golden"),
- },
- {
- name: "Invalid extra argument",
- args: []string{"natures", "brave"},
- expectedOutput: utils.LoadGolden(t, "natures_invalid_extra_arg.golden"),
- wantError: true,
- },
- {
- name: "Full Natures output with table",
- args: []string{"natures"},
- expectedOutput: utils.LoadGolden(t, "natures.golden"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- output, err := NaturesCommand(tt.args)
- if tt.wantError {
- require.Error(t, err)
- } else {
- require.NoError(t, err)
- }
- cleanOutput := styling.StripANSI(output)
-
- assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected")
- })
- }
-}
diff --git a/cmd/pokemon/pokemon.go b/cmd/pokemon/pokemon.go
index 3abbbd94..e2cedba2 100644
--- a/cmd/pokemon/pokemon.go
+++ b/cmd/pokemon/pokemon.go
@@ -3,7 +3,6 @@ package pokemon
import (
"bytes"
"errors"
- "flag"
"fmt"
"io"
"strings"
@@ -12,6 +11,7 @@ import (
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/flags"
"github.com/digitalghost-dev/poke-cli/styling"
+ flag "github.com/spf13/pflag"
)
// PokemonCommand processes the Pokémon command
@@ -28,11 +28,10 @@ func PokemonCommand(args []string) (string, error) {
ShowHyphenHint: true,
Flags: []utils.FlagHelp{
{Short: "-a", Long: "--abilities", Description: "Prints the Pokémon's abilities."},
- {Short: "-d", Long: "--defense", Description: "Prints the Pokémon's type defenses."},
+ {Short: "-d", Long: "--defenses", Description: "Prints the Pokémon's type defenses."},
{Short: "-i=xx", Long: "--image=xx", Description: "Prints out the Pokémon's default sprite.\n\t " + styling.StyleItalic.Render("options: [sm, md, lg]")},
{Short: "-m", Long: "--moves", Description: "Prints the Pokémon's learnable moves."},
{Short: "-s", Long: "--stats", Description: "Prints the Pokémon's base stats."},
- {Short: "-t", Long: "--types", Description: styling.ErrorColor.Render("Deprecated. Typing is included by default.")},
},
},
),
@@ -97,12 +96,9 @@ func PokemonCommand(args []string) (string, error) {
capitalizedString, entryOutput.String(), typeOutput.String(), metricsOutput.String(), speciesOutput.String(), eggGroupOutput.String(), effortValuesOutput.String(),
)
- if *pf.Image != "" || *pf.ShortImage != "" {
+ if *pf.Image != "" {
// Determine the size based on the provided flags
size := *pf.Image
- if *pf.ShortImage != "" {
- size = *pf.ShortImage
- }
// Call the ImageFlag function with the specified size
if err := flags.ImageFlag(&output, endpoint, pokemonName, size); err != nil {
@@ -115,11 +111,10 @@ func PokemonCommand(args []string) (string, error) {
condition bool
flagFunc func(io.Writer, string, string) error
}{
- {*pf.Abilities || *pf.ShortAbilities, flags.AbilitiesFlag},
- {*pf.Defenses || *pf.ShortDefenses, flags.DefenseFlag},
- {*pf.Moves || *pf.ShortMoves, flags.MovesFlag},
- {*pf.Stats || *pf.ShortStats, flags.StatsFlag},
- {*pf.Types || *pf.ShortTypes, flags.TypesFlag},
+ {*pf.Abilities, flags.AbilitiesFlag},
+ {*pf.Defenses, flags.DefenseFlag},
+ {*pf.Moves, flags.MovesFlag},
+ {*pf.Stats, flags.StatsFlag},
}
for _, check := range flagChecks {
diff --git a/cmd/pokemon/pokemon_test.go b/cmd/pokemon/pokemon_test.go
index d90e7016..57a39148 100644
--- a/cmd/pokemon/pokemon_test.go
+++ b/cmd/pokemon/pokemon_test.go
@@ -46,12 +46,12 @@ func TestPokemonCommand(t *testing.T) {
},
{
name: "Pokemon defense flag",
- args: []string{"pokemon", "dragapult", "--defense"},
+ args: []string{"pokemon", "dragapult", "--defenses"},
expectedOutput: utils.LoadGolden(t, "pokemon_defense.golden"),
},
{
name: "Pokemon defense flag with ability immunity",
- args: []string{"pokemon", "gastrodon", "--defense"},
+ args: []string{"pokemon", "gastrodon", "--defenses"},
expectedOutput: utils.LoadGolden(t, "pokemon_defense_ability_immunities.golden"),
},
{
diff --git a/cmd/search/model_input.go b/cmd/search/model_input.go
index fc8e09fe..372151d7 100644
--- a/cmd/search/model_input.go
+++ b/cmd/search/model_input.go
@@ -16,49 +16,53 @@ func UpdateInput(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
- if m.ShowResults {
+ if m.showResults {
// If results are shown, pressing 'b' resets to search view
if msg.String() == "b" {
- m.ShowResults = false
- m.TextInput.Reset()
- m.TextInput.Focus()
- m.WarningMessage = ""
+ m.showResults = false
+ m.textInput.Reset()
+ m.textInput.Focus()
+ m.warningMessage = ""
return m, textinput.Blink
}
} else {
switch msg.Code {
case tea.KeyEnter:
- searchTerm := m.TextInput.Value()
+ searchTerm := m.textInput.Value()
_, endpoint := RenderInput(m)
// checking for blank queries
if strings.TrimSpace(searchTerm) == "" {
- m.WarningMessage = utils.FormatError("No blank queries")
+ m.warningMessage = utils.FormatError("No blank queries")
return m, nil
}
// Call PokéAPI
result, err := query(endpoint, searchTerm)
if err != nil {
- m.WarningMessage = utils.FormatError(fmt.Sprintf("Error fetching search results: %v", err))
+ m.warningMessage = utils.FormatError(fmt.Sprintf("Error fetching search results: %v", err))
return m, nil
}
// Format results
var sb strings.Builder
for _, r := range result.Results {
- sb.WriteString(styling.ColoredBullet.String() + " " + r.Name + "\n")
+ sb.WriteString(styling.ColoredBullet.String())
+ sb.WriteString(" ")
+ sb.WriteString(r.Name)
+ sb.WriteString("\n")
}
+
resultsDisplay := sb.String()
- m.SearchResults = resultsDisplay
- m.ShowResults = true
+ m.searchResults = resultsDisplay
+ m.showResults = true
return m, nil
}
}
}
- m.TextInput, cmd = m.TextInput.Update(msg)
+ m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
@@ -67,7 +71,7 @@ func RenderInput(m model) (string, string) {
var msg string
var endpoint string
- switch m.Choice {
+ switch m.choice {
case 0:
msg = "Enter an Ability name:"
endpoint = "ability"
@@ -81,9 +85,9 @@ func RenderInput(m model) (string, string) {
msg = "Enter your search query:"
}
- if m.ShowResults {
+ if m.showResults {
// Check if there are any results
- results := m.SearchResults
+ results := m.searchResults
if strings.TrimSpace(results) == "" {
results = lipgloss.NewStyle().
Foreground(lipgloss.Color("9")).
@@ -98,16 +102,16 @@ func RenderInput(m model) (string, string) {
}
warning := ""
- if m.WarningMessage != "" {
+ if m.warningMessage != "" {
warning = "\n\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("9")).
- Render(m.WarningMessage)
+ Render(m.warningMessage)
}
return fmt.Sprintf(
"%s\n\n%s\n\n%s%s",
msg,
- m.TextInput.View(),
+ m.textInput.View(),
styling.KeyMenu.Render("Press Enter to confirm\nctrl+c | esc (quit)"),
warning,
), endpoint
diff --git a/cmd/search/model_input_test.go b/cmd/search/model_input_test.go
index f6df8725..8350b94b 100644
--- a/cmd/search/model_input_test.go
+++ b/cmd/search/model_input_test.go
@@ -12,8 +12,8 @@ func TestUpdateInput(t *testing.T) {
ti.SetValue("mewtwo")
m := model{
- ShowResults: true,
- TextInput: ti,
+ showResults: true,
+ textInput: ti,
}
msg := tea.KeyPressMsg{Code: 'b', Text: "b"}
@@ -21,11 +21,11 @@ func TestUpdateInput(t *testing.T) {
updated := mUpdated.(model)
- if updated.ShowResults {
- t.Errorf("expected ShowResults to be false after pressing 'b'")
+ if updated.showResults {
+ t.Errorf("expected showResults to be false after pressing 'b'")
}
- if updated.TextInput.Value() != "" {
- t.Errorf("expected TextInput to be reset")
+ if updated.textInput.Value() != "" {
+ t.Errorf("expected textInput to be reset")
}
}
diff --git a/cmd/search/model_selection.go b/cmd/search/model_selection.go
index ec53b636..a5793f38 100644
--- a/cmd/search/model_selection.go
+++ b/cmd/search/model_selection.go
@@ -14,18 +14,18 @@ func UpdateSelection(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
switch msg.String() {
case "down":
- m.Choice++
- if m.Choice > 2 {
- m.Choice = 2
+ m.choice++
+ if m.choice > 2 {
+ m.choice = 2
}
case "up":
- m.Choice--
- if m.Choice < 0 {
- m.Choice = 0
+ m.choice--
+ if m.choice < 0 {
+ m.choice = 0
}
case "enter":
- m.Chosen = true
- m.TextInput.Focus()
+ m.chosen = true
+ m.textInput.Focus()
return m, textinput.Blink
}
}
@@ -34,7 +34,7 @@ func UpdateSelection(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
// RenderSelection renders the selection menu.
func RenderSelection(m model) string {
- c := m.Choice
+ c := m.choice
greeting := styling.StyleItalic.Render("Search for a resource and return a matching selection table")
choices := fmt.Sprintf(
"%s\n%s\n%s",
diff --git a/cmd/search/model_selection_test.go b/cmd/search/model_selection_test.go
index 255b0dcf..af7e9dc3 100644
--- a/cmd/search/model_selection_test.go
+++ b/cmd/search/model_selection_test.go
@@ -21,16 +21,16 @@ func TestSelection(t *testing.T) {
final := testModel.FinalModel(t).(model)
- if !final.Chosen {
- t.Errorf("Expected model to be in Chosen state after pressing enter")
+ if !final.chosen {
+ t.Errorf("Expected model to be in chosen state after pressing enter")
}
- if final.Choice != 0 {
- t.Errorf("Expected Choice to be 0, got %d", final.Choice)
+ if final.choice != 0 {
+ t.Errorf("Expected choice to be 0, got %d", final.choice)
}
- if !final.TextInput.Focused() {
- t.Errorf("Expected TextInput to be focused after selection")
+ if !final.textInput.Focused() {
+ t.Errorf("Expected textInput to be focused after selection")
}
- if !final.Quitting {
+ if !final.quitting {
t.Errorf("Expected model to be quitting after ctrl+c")
}
}
@@ -39,7 +39,7 @@ func TestChoiceClamping(t *testing.T) {
m := initialModel()
testModel := teatest.NewTestModel(t, m)
- // Move down twice, this should attempt to exceed max Choice
+ // Move down twice, this should attempt to exceed max choice
testModel.Send(tea.KeyPressMsg{Code: tea.KeyDown}) // 0 → 1
testModel.Send(tea.KeyPressMsg{Code: tea.KeyDown}) // 1 → 2, but should clamp to 1
@@ -55,7 +55,7 @@ func TestChoiceClamping(t *testing.T) {
final := testModel.FinalModel(t).(model)
- if final.Choice != 0 && final.Choice != 1 {
- t.Errorf("Choice should be clamped between 0 and 1, got %d", final.Choice)
+ if final.choice != 0 && final.choice != 1 {
+ t.Errorf("choice should be clamped between 0 and 1, got %d", final.choice)
}
}
diff --git a/cmd/search/search.go b/cmd/search/search.go
index 02d4c50c..91500b3d 100644
--- a/cmd/search/search.go
+++ b/cmd/search/search.go
@@ -44,13 +44,13 @@ func SearchCommand(args []string) (string, error) {
// model structure
type model struct {
- Choice int
- Chosen bool
- Quitting bool
- TextInput textinput.Model
- ShowResults bool
- SearchResults string
- WarningMessage string
+ choice int
+ chosen bool
+ quitting bool
+ textInput textinput.Model
+ showResults bool
+ searchResults string
+ warningMessage string
}
func initialModel() model {
@@ -60,7 +60,7 @@ func initialModel() model {
ti.SetWidth(20)
return model{
- TextInput: ti,
+ textInput: ti,
}
}
@@ -75,12 +75,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "esc":
- m.Quitting = true
+ m.quitting = true
return m, tea.Quit
}
}
- if !m.Chosen {
+ if !m.chosen {
return UpdateSelection(msg, m)
}
return UpdateInput(msg, m)
@@ -88,14 +88,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View renders the correct UI screen.
func (m model) View() tea.View {
- if m.Quitting {
+ if m.quitting {
return tea.NewView("\n Quitting search...\n\n")
}
- if m.ShowResults {
+ if m.showResults {
resultsView, _ := RenderInput(m) // Fetch results view
return tea.NewView(resultsView)
}
- if !m.Chosen {
+ if !m.chosen {
return tea.NewView(RenderSelection(m))
}
inputView, _ := RenderInput(m)
diff --git a/cmd/search/search_test.go b/cmd/search/search_test.go
index cd00c0e1..7186bf41 100644
--- a/cmd/search/search_test.go
+++ b/cmd/search/search_test.go
@@ -63,7 +63,7 @@ func TestModelQuit(t *testing.T) {
msg := tea.KeyPressMsg{Code: tea.KeyEscape}
newModel, cmd := m.Update(msg)
- assert.True(t, newModel.(model).Quitting, "Model should be set to quitting")
+ assert.True(t, newModel.(model).quitting, "Model should be set to quitting")
if cmd != nil {
assert.Equal(t, cmd(), tea.Quit(), "Update() should return tea.Quit command")
@@ -78,23 +78,23 @@ func TestSearchCommandValidationError(t *testing.T) {
}
func TestModelViewQuitting(t *testing.T) {
- m := model{Quitting: true}
+ m := model{quitting: true}
view := m.View()
assert.Contains(t, view.Content, "Quitting search", "View should show quitting message")
}
func TestModelViewShowResults(t *testing.T) {
m := model{
- ShowResults: true,
- SearchResults: "Test Results",
+ showResults: true,
+ searchResults: "Test Results",
}
view := m.View()
- // View calls RenderInput when ShowResults is true
+ // View calls RenderInput when showResults is true
assert.NotEmpty(t, view.Content, "View should render results")
}
func TestModelViewNotChosen(t *testing.T) {
- m := model{Chosen: false}
+ m := model{chosen: false}
view := m.View()
// View calls RenderSelection when not chosen
assert.Contains(t, view.Content, "Search for a resource", "View should show selection prompt")
diff --git a/cmd/tcg/dashboard.go b/cmd/tcg/dashboard.go
deleted file mode 100644
index 390473d9..00000000
--- a/cmd/tcg/dashboard.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package tcg
-
-import (
- "fmt"
- "image/color"
- "os"
- "strings"
-
- "charm.land/bubbles/v2/table"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/digitalghost-dev/poke-cli/styling"
-)
-
-type styles struct {
- doc lipgloss.Style
- inactiveTab lipgloss.Style
- activeTab lipgloss.Style
- window lipgloss.Style
- highlightColor color.Color
-}
-
-func tabBorderWithBottom(left, middle, right string) lipgloss.Border {
- border := lipgloss.RoundedBorder()
- border.BottomLeft = left
- border.Bottom = middle
- border.BottomRight = right
- return border
-}
-
-func newStyles() *styles {
- inactiveTabBorder := tabBorderWithBottom("┴", "─", "┴")
- activeTabBorder := tabBorderWithBottom("┘", " ", "└")
- isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
- ld := lipgloss.LightDark(isDark)
- highlightColor := ld(lipgloss.Color("#874BFD"), lipgloss.Color("#7D56F4"))
-
- s := new(styles)
- s.doc = lipgloss.NewStyle().
- Padding(1, 2, 1, 2)
- s.inactiveTab = lipgloss.NewStyle().
- Border(inactiveTabBorder, true).
- BorderForeground(highlightColor).
- Padding(0, 1)
- s.activeTab = s.inactiveTab.
- Border(activeTabBorder, true)
- s.window = lipgloss.NewStyle().
- BorderForeground(highlightColor).
- Padding(2, 0).
- Border(lipgloss.NormalBorder()).
- UnsetBorderTop()
- s.highlightColor = highlightColor
- return s
-}
-
-type model struct {
- conn func(string) ([]byte, error)
- tabs []string
- styles *styles
- activeTab int
- width int
- height int
- standingsTable table.Model
- tournament string
- tournamentDate string
- tournamentType string
- standings []standingRows
- countryStats []countryStats
- deckStats []deckStats
- totalPlayers int
- winner string
- winningDeck string
- flag string
- goBack bool
- err error
-}
-
-func overviewView(m model, contentWidth int) string {
- if len(m.standings) == 0 {
- return " Loading..."
- }
- return overviewContent(m.flag, m.tournament, m.tournamentType, m.tournamentDate, m.winner, m.winningDeck, m.totalPlayers, contentWidth, m.styles.highlightColor)
-}
-
-func countriesView(s []countryStats, width int) string {
- return countriesContent(s, width)
-}
-
-func (m model) Init() tea.Cmd {
- return fetchData(m.tournament, m.conn)
-}
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch keypress := msg.String(); keypress {
- case "ctrl+c", "esc":
- return m, tea.Quit
- case "b":
- m.goBack = true
- return m, tea.Quit
- case "right", "l", "n", "tab":
- m.activeTab = min(m.activeTab+1, len(m.tabs)-1)
- return m, nil
- case "left", "h", "p", "shift+tab":
- m.activeTab = max(m.activeTab-1, 0)
- return m, nil
- }
- if m.activeTab == 1 {
- var cmd tea.Cmd
- m.standingsTable, cmd = m.standingsTable.Update(msg)
- return m, cmd
- }
-
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- if len(m.standings) > 0 {
- m.standingsTable = standingsTable(m.standings, m.width-8, m.height)
- }
- return m, nil
-
- case standingsDataMsg:
- if msg.err != nil {
- m.err = msg.err
- return m, nil
- }
- m.standings = msg.items
- if len(msg.items) > 0 {
- first := msg.items[0]
- m.totalPlayers = first.PlayerQty
- m.winner = first.Name
- m.winningDeck = first.Deck
- m.flag = countryFlag(first.ISOCode)
- m.tournamentDate = first.TextDate
- m.tournamentType = first.Type
- }
- countryCounts := map[string]int{}
- for _, row := range msg.items {
- if row.PlayerCountry != "" {
- countryCounts[row.PlayerCountry]++
- }
- }
- for country, count := range countryCounts {
- m.countryStats = append(m.countryStats, countryStats{Country: country, Total: count})
- }
- deckCounts := map[string]int{}
- for _, row := range msg.items {
- deckCounts[row.Deck]++
- }
- for deck, count := range deckCounts {
- m.deckStats = append(m.deckStats, deckStats{Deck: deck, Total: count})
- }
- m.standingsTable = standingsTable(msg.items, m.width-8, m.height)
- }
-
- return m, nil
-}
-
-func (m model) View() tea.View {
- if m.styles == nil {
- return tea.NewView("")
- }
-
- doc := strings.Builder{}
- s := m.styles
-
- var renderedTabs []string
-
- for i, t := range m.tabs {
- var style lipgloss.Style
- isFirst, isLast, isActive := i == 0, i == len(m.tabs)-1, i == m.activeTab
- if isActive {
- style = s.activeTab
- } else {
- style = s.inactiveTab
- }
- border, _, _, _, _ := style.GetBorder()
- if isFirst && isActive {
- border.BottomLeft = "│"
- } else if isFirst && !isActive {
- border.BottomLeft = "├"
- } else if isLast && isActive {
- border.BottomRight = "└"
- } else if isLast && !isActive {
- border.BottomRight = "┴"
- }
- style = style.Border(border)
- renderedTabs = append(renderedTabs, style.Render(t))
- }
-
- row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
-
- // Use terminal width if available, otherwise fall back to tab row width.
- // doc has Padding(1,2,1,2) = 4 horizontal chars; window border = 2 chars.
- windowWidth := max(m.width-8, lipgloss.Width(row)-2)
- contentWidth := windowWidth - 2
-
- // Fill the gap between the tab row and the window's right edge so the top
- // border line stretches the full width of the window.
- highlightColor := m.styles.highlightColor
- fillWidth := windowWidth - lipgloss.Width(row)
- if fillWidth > 0 {
- fill := lipgloss.NewStyle().Foreground(highlightColor).
- Render(strings.Repeat("─", fillWidth-1) + "┐")
- row = row + fill
- }
-
- var content string
- switch m.activeTab {
- // Overview Tab
- case 0:
- if m.err != nil {
- content = fmt.Sprintf("fetch error: %v", m.err)
- } else {
- content = overviewView(m, contentWidth)
- }
-
- // Standings Tab
- case 1:
- if m.err != nil {
- content = fmt.Sprintf("fetch error: %v", m.err)
- } else if len(m.standings) == 0 {
- content = " Loading..."
- } else {
- content = m.standingsTable.View()
- }
-
- // Decks Tab
- case 2:
- if m.err != nil {
- content = fmt.Sprintf("fetch error: %v", m.err)
- } else {
- content = decksContent(m.deckStats, contentWidth)
- }
-
- // Countries Tab
- case 3:
- if m.err != nil {
- content = fmt.Sprintf("fetch error: %v", m.err)
- } else {
- content = countriesView(m.countryStats, contentWidth)
- }
- }
-
- doc.WriteString(row)
- doc.WriteString("\n")
- doc.WriteString(s.window.Width(windowWidth).Render(content))
- doc.WriteString("\n")
- doc.WriteString(styling.KeyMenu.Render("← → (switch tab) • b (back) • ctrl+c | esc (quit)"))
-
- v := tea.NewView(s.doc.Render(doc.String()))
- v.AltScreen = true
- return v
-}
diff --git a/cmd/tcg/dashboard_test.go b/cmd/tcg/dashboard_test.go
deleted file mode 100644
index c16995c0..00000000
--- a/cmd/tcg/dashboard_test.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package tcg
-
-import (
- "errors"
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/x/exp/teatest/v2"
-)
-
-func newTestModel() model {
- return model{
- conn: noopConn,
- tabs: []string{"Overview", "Standings", "Decks", "Countries"},
- styles: newStyles(),
- tournament: "London",
- width: 120,
- height: 40,
- }
-}
-
-func loadedTestModel() model {
- m := newTestModel()
- items := []standingRows{
- {Rank: 1, Name: "Ash", Points: 47, Deck: "gardevoir", PlayerCountry: "USA", ISOCode: "US", PlayerQty: 500, TextDate: "Jan 10", Type: "Regional"},
- {Rank: 2, Name: "Misty", Points: 44, Deck: "dragapult", PlayerCountry: "Japan", ISOCode: "JP", PlayerQty: 500, TextDate: "Jan 10", Type: "Regional"},
- }
- newModel, _ := m.Update(standingsDataMsg{items: items})
- return newModel.(model)
-}
-
-// Init
-
-func TestDashboardModel_Init_ReturnsCmd(t *testing.T) {
- m := newTestModel()
- if m.Init() == nil {
- t.Error("expected Init() to return a non-nil cmd")
- }
-}
-
-// Update — key messages
-
-func TestDashboardModel_Update_Quit(t *testing.T) {
- tests := []struct {
- name string
- msg tea.KeyPressMsg
- }{
- {"ctrl+c", tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}},
- {"esc", tea.KeyPressMsg{Code: tea.KeyEscape}},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- m := newTestModel()
- tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40))
- tm.Send(tt.msg)
- tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
- })
- }
-}
-
-func TestDashboardModel_Update_Back(t *testing.T) {
- m := newTestModel()
- tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40))
- tm.Send(tea.KeyPressMsg{Code: 'b', Text: "b"})
- tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
- final := tm.FinalModel(t).(model)
- if !final.goBack {
- t.Error("expected goBack=true after pressing b")
- }
-}
-
-func TestDashboardModel_Update_TabNavigation(t *testing.T) {
- m := newTestModel()
- // right moves forward
- newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
- if newM.(model).activeTab != 1 {
- t.Errorf("expected activeTab=1 after right, got %d", newM.(model).activeTab)
- }
- // left moves back
- newM2, _ := newM.(model).Update(tea.KeyPressMsg{Code: tea.KeyLeft})
- if newM2.(model).activeTab != 0 {
- t.Errorf("expected activeTab=0 after left, got %d", newM2.(model).activeTab)
- }
-}
-
-func TestDashboardModel_Update_TabNavigation_Clamps(t *testing.T) {
- m := newTestModel()
- // can't go below 0
- newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
- if newM.(model).activeTab != 0 {
- t.Errorf("expected activeTab to clamp at 0, got %d", newM.(model).activeTab)
- }
- // can't exceed last tab
- m.activeTab = 3
- newM2, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
- if newM2.(model).activeTab != 3 {
- t.Errorf("expected activeTab to clamp at 3, got %d", newM2.(model).activeTab)
- }
-}
-
-// Update — standingsDataMsg
-
-func TestDashboardModel_Update_StandingsDataMsg_Success(t *testing.T) {
- m := loadedTestModel()
- if len(m.standings) != 2 {
- t.Errorf("expected 2 standings rows, got %d", len(m.standings))
- }
- if m.winner != "Ash" {
- t.Errorf("expected winner=Ash, got %q", m.winner)
- }
- if m.winningDeck != "gardevoir" {
- t.Errorf("expected winningDeck=gardevoir, got %q", m.winningDeck)
- }
- if m.totalPlayers != 500 {
- t.Errorf("expected totalPlayers=500, got %d", m.totalPlayers)
- }
- if len(m.countryStats) == 0 {
- t.Error("expected countryStats to be populated")
- }
- if len(m.deckStats) == 0 {
- t.Error("expected deckStats to be populated")
- }
-}
-
-func TestDashboardModel_Update_StandingsDataMsg_Error(t *testing.T) {
- m := newTestModel()
- newM, _ := m.Update(standingsDataMsg{err: errors.New("fetch failed")})
- result := newM.(model)
- if result.err == nil {
- t.Error("expected err to be set")
- }
-}
-
-func TestDashboardModel_Update_EmptyPlayerCountry_Skipped(t *testing.T) {
- m := newTestModel()
- items := []standingRows{
- {Rank: 1, Name: "Ash", PlayerCountry: ""},
- {Rank: 2, Name: "Misty", PlayerCountry: "Japan"},
- }
- newM, _ := m.Update(standingsDataMsg{items: items})
- result := newM.(model)
- if len(result.countryStats) != 1 {
- t.Errorf("expected 1 countryStats entry (empty country skipped), got %d", len(result.countryStats))
- }
-}
-
-// Update — WindowSizeMsg
-
-func TestDashboardModel_Update_WindowSize(t *testing.T) {
- m := newTestModel()
- newM, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 50})
- result := newM.(model)
- if result.width != 160 {
- t.Errorf("expected width=160, got %d", result.width)
- }
- if result.height != 50 {
- t.Errorf("expected height=50, got %d", result.height)
- }
-}
-
-// View
-
-func TestDashboardModel_View_NilStyles(t *testing.T) {
- m := model{}
- if m.View().Content != "" {
- t.Error("expected empty string when styles is nil")
- }
-}
-
-func TestDashboardModel_View_ContainsTabs(t *testing.T) {
- m := newTestModel()
- view := m.View()
- for _, tab := range []string{"Overview", "Standings", "Decks", "Countries"} {
- if !strings.Contains(view.Content, tab) {
- t.Errorf("expected view to contain tab %q", tab)
- }
- }
-}
-
-func TestDashboardModel_View_LoadingState(t *testing.T) {
- m := newTestModel()
- view := m.View()
- if !strings.Contains(view.Content, "Loading") {
- t.Error("expected loading message before data arrives")
- }
-}
-
-func TestDashboardModel_View_FetchError(t *testing.T) {
- m := newTestModel()
- m.err = errors.New("network error")
- view := m.View()
- if !strings.Contains(view.Content, "fetch error") {
- t.Errorf("expected fetch error in view, got: %s", view.Content)
- }
-}
-
-func TestDashboardModel_View_AllTabs(t *testing.T) {
- m := loadedTestModel()
- for tab := 0; tab <= 3; tab++ {
- m.activeTab = tab
- view := m.View()
- if view.Content == "" {
- t.Errorf("expected non-empty view for tab %d", tab)
- }
- }
-}
diff --git a/cmd/tcg/data.go b/cmd/tcg/data.go
deleted file mode 100644
index ebeeb3f4..00000000
--- a/cmd/tcg/data.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package tcg
-
-import (
- "encoding/json"
- "net/url"
- "strings"
-
- tea "charm.land/bubbletea/v2"
-)
-
-type standingRows struct {
- Rank int `json:"rank"`
- Name string `json:"name"`
- Points int `json:"points"`
- Record string `json:"record"`
- OppWinPct string `json:"opp_win_percent"`
- OppOppWinPct string `json:"opp_opp_win_percent"`
- Deck string `json:"deck"`
- PlayerCountry string `json:"player_country"`
- CountryCode string `json:"country_code"`
- Location string `json:"location"`
- TextDate string `json:"text_date"`
- Type string `json:"type"`
- ISOCode string `json:"iso_code"`
- PlayerQty int `json:"player_quantity"`
-}
-
-type standingsDataMsg struct {
- items []standingRows
- err error
-}
-
-func fetchData(tournament string, conn func(string) ([]byte, error)) tea.Cmd {
- return func() tea.Msg {
- cols := "rank,name,points,record,opp_win_percent,opp_opp_win_percent,deck,player_country,country_code,location,text_date,type,iso_code,player_quantity"
- endpoint := "https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/standings?select=" + cols + "&location=eq." + url.QueryEscape(tournament) + "&order=rank"
- body, err := conn(endpoint)
- if err != nil {
- return standingsDataMsg{err: err}
- }
-
- var rows []standingRows
- if err = json.Unmarshal(body, &rows); err != nil {
- return standingsDataMsg{err: err}
- }
-
- return standingsDataMsg{items: rows}
- }
-}
-
-func countryFlag(isoCode string) string {
- code := strings.ToUpper(isoCode)
- if len(code) != 2 {
- return ""
- }
- if code[0] < 'A' || code[0] > 'Z' || code[1] < 'A' || code[1] > 'Z' {
- return ""
- }
- return string(rune(0x1F1E6+(rune(code[0])-'A'))) + string(rune(0x1F1E6+(rune(code[1])-'A')))
-}
diff --git a/cmd/tcg/data_test.go b/cmd/tcg/data_test.go
deleted file mode 100644
index dfc9f567..00000000
--- a/cmd/tcg/data_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package tcg
-
-import (
- "errors"
- "strings"
- "testing"
-)
-
-func TestFetchStandings_ConnectionError(t *testing.T) {
- mock := func(_ string) ([]byte, error) {
- return nil, errors.New("connection refused")
- }
- msg := fetchData("London", mock)()
- result, ok := msg.(standingsDataMsg)
- if !ok {
- t.Fatalf("expected standingsDataMsg, got %T", msg)
- }
- if result.err == nil {
- t.Error("expected error, got nil")
- }
- if result.items != nil {
- t.Error("expected nil items on error")
- }
-}
-
-func TestFetchStandings_InvalidJSON(t *testing.T) {
- mock := func(_ string) ([]byte, error) {
- return []byte("not json"), nil
- }
- msg := fetchData("London", mock)()
- result, ok := msg.(standingsDataMsg)
- if !ok {
- t.Fatalf("expected standingsDataMsg, got %T", msg)
- }
- if result.err == nil {
- t.Error("expected unmarshal error, got nil")
- }
-}
-
-func TestFetchStandings_Success(t *testing.T) {
- mock := func(_ string) ([]byte, error) {
- return []byte(`[{"rank":1,"name":"Alice","player_country":"USA"},{"rank":2,"name":"Bob","player_country":"Japan"}]`), nil
- }
- msg := fetchData("London", mock)()
- result, ok := msg.(standingsDataMsg)
- if !ok {
- t.Fatalf("expected standingsDataMsg, got %T", msg)
- }
- if result.err != nil {
- t.Errorf("expected no error, got %v", result.err)
- }
- if len(result.items) != 2 {
- t.Errorf("expected 2 items, got %d", len(result.items))
- }
- if result.items[0].Name != "Alice" {
- t.Errorf("expected first item name to be Alice, got %q", result.items[0].Name)
- }
-}
-
-func TestFetchStandings_URLEncoding(t *testing.T) {
- var capturedURL string
- mock := func(url string) ([]byte, error) {
- capturedURL = url
- return []byte(`[]`), nil
- }
- fetchData("São Paulo", mock)()
- if !strings.Contains(capturedURL, "S%C3%A3o") {
- t.Errorf("expected URL-encoded tournament name in URL, got %q", capturedURL)
- }
-}
-
-func TestCountryFlag(t *testing.T) {
- tests := []struct {
- isoCode string
- want string
- }{
- {"gb", "🇬🇧"},
- {"GB", "🇬🇧"},
- {"us", "🇺🇸"},
- {"jp", "🇯🇵"},
- {"", ""},
- {"x", ""},
- {"abc", ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.isoCode, func(t *testing.T) {
- got := countryFlag(tt.isoCode)
- if got != tt.want {
- t.Errorf("countryFlag(%q) = %q, want %q", tt.isoCode, got, tt.want)
- }
- })
- }
-}
diff --git a/cmd/tcg/tab_countries.go b/cmd/tcg/tab_countries.go
deleted file mode 100644
index 60f87c9d..00000000
--- a/cmd/tcg/tab_countries.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package tcg
-
-type countryStats struct {
- Country string
- Total int
-}
-
-func countriesContent(s []countryStats, width int) string {
- items := make([]barChartItem, len(s))
- for i, c := range s {
- items[i] = barChartItem{Label: c.Country, Total: c.Total}
- }
-
- return barChart(items, width, 20)
-}
diff --git a/cmd/tcg/tab_countries_test.go b/cmd/tcg/tab_countries_test.go
deleted file mode 100644
index 71bbc102..00000000
--- a/cmd/tcg/tab_countries_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package tcg
-
-import (
- "strings"
- "testing"
-)
-
-func TestCountriesContent(t *testing.T) {
- result := countriesContent([]countryStats{{Country: "USA", Total: 10}}, 80)
- if !strings.Contains(result, "USA") {
- t.Error("expected output to contain country name")
- }
-}
diff --git a/cmd/tcg/tab_decks.go b/cmd/tcg/tab_decks.go
deleted file mode 100644
index e68598ec..00000000
--- a/cmd/tcg/tab_decks.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package tcg
-
-type deckStats struct {
- Deck string
- Total int
-}
-
-func decksContent(s []deckStats, width int) string {
- items := make([]barChartItem, len(s))
- for i, c := range s {
- items[i] = barChartItem{Label: c.Deck, Total: c.Total}
- }
-
- return barChart(items, width, 30)
-}
diff --git a/cmd/tcg/tab_decks_test.go b/cmd/tcg/tab_decks_test.go
deleted file mode 100644
index 0da2c737..00000000
--- a/cmd/tcg/tab_decks_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package tcg
-
-import (
- "strings"
- "testing"
-)
-
-func TestDecksContent(t *testing.T) {
- result := decksContent([]deckStats{{Deck: "gardevoir", Total: 10}}, 80)
- if !strings.Contains(result, "gardevoir") {
- t.Error("expected output to contain deck name")
- }
-}
diff --git a/cmd/tcg/tab_overview.go b/cmd/tcg/tab_overview.go
deleted file mode 100644
index 236d65d7..00000000
--- a/cmd/tcg/tab_overview.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package tcg
-
-import (
- "fmt"
- "image/color"
- "strconv"
- "strings"
-
- "charm.land/lipgloss/v2"
-)
-
-func formatInt(n int) string {
- s := strconv.Itoa(n)
- var result strings.Builder
- for i, c := range s {
- if i > 0 && (len(s)-i)%3 == 0 {
- result.WriteRune(',')
- }
- result.WriteRune(c)
- }
- return result.String()
-}
-
-func overviewContent(flag, tournament, tournamentType, tournamentDate, winner, winningDeck string, totalPlayers, contentWidth int, highlightColor color.Color) string {
- header := fmt.Sprintf("%s %s · %s · %s", flag, tournament, tournamentType, tournamentDate)
-
- statBox := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(highlightColor).
- Padding(1, 2).
- Width(26).
- Align(lipgloss.Center)
-
- totalBox := statBox.Render("Total Players\n\n" + formatInt(totalPlayers))
- winnerBox := statBox.Render("Winner\n\n" + winner)
- deckBox := statBox.Render("Winning Deck\n\n" + winningDeck)
-
- boxes := lipgloss.JoinHorizontal(lipgloss.Top, totalBox, " ", winnerBox, " ", deckBox)
-
- content := header + "\n\n" + boxes
- return lipgloss.NewStyle().Width(contentWidth).Align(lipgloss.Center).Render(content)
-}
diff --git a/cmd/tcg/tab_standings.go b/cmd/tcg/tab_standings.go
deleted file mode 100644
index 31f8e51c..00000000
--- a/cmd/tcg/tab_standings.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package tcg
-
-import (
- "strconv"
-
- "charm.land/bubbles/v2/table"
- "charm.land/lipgloss/v2"
- "github.com/digitalghost-dev/poke-cli/styling"
-)
-
-func standingsTable(rows []standingRows, width, height int) table.Model {
- fixedWidth := 4 + 20 + 6 + 10 + 7 + 7 + 18
- separators := 8 * 2
- deckWidth := min(max(width-fixedWidth-separators, 10), 30)
-
- columns := []table.Column{
- {Title: "Rank", Width: 4},
- {Title: "Name", Width: 20},
- {Title: "Points", Width: 6},
- {Title: "Record", Width: 10},
- {Title: "OPW%", Width: 7},
- {Title: "OOPW%", Width: 7},
- {Title: "Deck", Width: deckWidth},
- {Title: "Country", Width: 18},
- }
-
- tableRows := make([]table.Row, len(rows))
- for i, r := range rows {
- tableRows[i] = table.Row{
- strconv.Itoa(r.Rank),
- r.Name,
- strconv.Itoa(r.Points),
- r.Record,
- r.OppWinPct,
- r.OppOppWinPct,
- r.Deck,
- r.PlayerCountry,
- }
- }
-
- tableHeight := max(height-14, 5)
- tableWidth := fixedWidth + deckWidth + separators
-
- s := table.DefaultStyles()
- s.Header = s.Header.
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(styling.YellowColor).
- BorderBottom(true).
- Bold(true)
- s.Selected = s.Selected.
- Foreground(lipgloss.Color("#000")).
- Background(styling.YellowColor)
-
- t := table.New(
- table.WithColumns(columns),
- table.WithRows(tableRows),
- table.WithFocused(true),
- table.WithHeight(tableHeight),
- table.WithWidth(tableWidth),
- )
- t.SetStyles(s)
-
- return t
-}
diff --git a/cmd/tcg/tab_standings_test.go b/cmd/tcg/tab_standings_test.go
deleted file mode 100644
index 771ac193..00000000
--- a/cmd/tcg/tab_standings_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package tcg
-
-import (
- "strings"
- "testing"
-)
-
-func sampleRows() []standingRows {
- return []standingRows{
- {Rank: 1, Name: "Ash Ketchum", Points: 47, Record: "15 - 1 - 2", OppWinPct: "58.10%", OppOppWinPct: "60.56%", Deck: "gardevoir", PlayerCountry: "United States", ISOCode: "US"},
- {Rank: 2, Name: "Misty Williams", Points: 44, Record: "14 - 2 - 2", OppWinPct: "68.56%", OppOppWinPct: "61.67%", Deck: "dragapult/dusknoir", PlayerCountry: "Japan", ISOCode: "JP"},
- {Rank: 3, Name: "Brock Harrison", Points: 41, Record: "13 - 2 - 2", OppWinPct: "69.01%", OppOppWinPct: "63.78%", Deck: "dragapult", PlayerCountry: "United Kingdom", ISOCode: "GB"},
- }
-}
-
-func TestStandingsTable_ContainsHeaders(t *testing.T) {
- m := standingsTable(sampleRows(), 120, 40)
- view := m.View()
- for _, header := range []string{"Rank", "Name", "Points", "Record", "OPW%", "OOPW%", "Deck", "Country"} {
- if !strings.Contains(view, header) {
- t.Errorf("expected table to contain header %q", header)
- }
- }
-}
-
-func TestStandingsTable_ContainsRowData(t *testing.T) {
- m := standingsTable(sampleRows(), 120, 40)
- view := m.View()
- for _, s := range []string{"Ash Ketchum", "gardevoir", "United States", "47"} {
- if !strings.Contains(view, s) {
- t.Errorf("expected table to contain %q", s)
- }
- }
-}
-
-func TestStandingsTable_EmptyRows(t *testing.T) {
- m := standingsTable([]standingRows{}, 120, 40)
- view := m.View()
- if view == "" {
- t.Fatal("expected non-empty view even with no rows")
- }
-}
-
-func TestStandingsTable_NarrowWidth(t *testing.T) {
- m := standingsTable(sampleRows(), 10, 40)
- if m.View() == "" {
- t.Fatal("expected non-empty view for narrow width")
- }
-}
-
-func TestStandingsTable_ShortHeight(t *testing.T) {
- m := standingsTable(sampleRows(), 120, 5)
- if m.View() == "" {
- t.Fatal("expected non-empty view for short height")
- }
-}
diff --git a/cmd/tcg/tcg.go b/cmd/tcg/tcg.go
deleted file mode 100644
index b8e3aa19..00000000
--- a/cmd/tcg/tcg.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package tcg
-
-import (
- "errors"
- "flag"
- "fmt"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "github.com/digitalghost-dev/poke-cli/cmd/utils"
- "github.com/digitalghost-dev/poke-cli/connections"
- "github.com/digitalghost-dev/poke-cli/flags"
- "github.com/digitalghost-dev/poke-cli/styling"
-)
-
-func TcgCommand(args []string) (string, error) {
- var output strings.Builder
-
- usage := func() {
- output.WriteString(
- utils.GenerateHelpMessage(
- utils.HelpConfig{
- Description: "Get details about TCG tournaments.",
- CmdName: "tcg",
- Flags: []utils.FlagHelp{
- {Short: "-w", Long: "--web", Description: "Opens the Streamlit dashboard in your default browser."},
- },
- },
- ),
- )
- }
-
- if utils.CheckHelpFlag(args, usage) {
- return output.String(), nil
- }
-
- if err := utils.ValidateArgs(
- args,
- utils.Validator{MaxArgs: 2, CmdName: "tcg", RequireName: false, HasFlags: true},
- ); err != nil {
- output.WriteString(err.Error())
- return output.String(), err
- }
-
- tf := flags.SetupTcgFlagSet()
- if err := tf.FlagSet.Parse(args[1:]); err != nil {
- if errors.Is(err, flag.ErrHelp) {
- return output.String(), nil
- }
- fmt.Fprintf(&output, "error parsing flags: %v\n", err)
- return output.String(), err
- }
-
- if *tf.Web || *tf.ShortWeb {
- msg, err := flags.WebFlag("https://web.poke-cli.com/")
- if err != nil {
- return "", err
- }
- output.WriteString(msg)
- return output.String(), nil
- }
-
- conn := connections.CallTCGData
-
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- final, err := tea.NewProgram(m).Run()
- if err != nil {
- return tournamentsModel{}, err
- }
- result, ok := final.(tournamentsModel)
- if !ok {
- return tournamentsModel{}, fmt.Errorf("unexpected model type from tournament selection: got %T, want tournamentsModel", final)
- }
- return result, nil
- }
-
- runDashboard := func(m model) (model, error) {
- final, err := tea.NewProgram(m).Run()
- if err != nil {
- return model{}, err
- }
- result, ok := final.(model)
- if !ok {
- return model{}, fmt.Errorf("unexpected model type from dashboard: got %T, want model", final)
- }
- return result, nil
- }
-
- if err := runTcgLoop(conn, runTournaments, runDashboard); err != nil {
- return "", err
- }
-
- deprecationWarning := styling.WarningBorder.Render(
- styling.WarningColor.Render("⚠ Warning!"),
- "\nThe tcg command is deprecated\nand will be removed in v2.\n\nIt will be renamed to a new\n'comp' command. ",
- )
- output.WriteString(deprecationWarning)
-
- return output.String(), nil
-}
-
-func runTcgLoop(
- conn func(string) ([]byte, error),
- runTournaments func(tournamentsModel) (tournamentsModel, error),
- runDashboard func(model) (model, error),
-) error {
- for {
- result, err := runTournaments(tournamentsList(conn))
- if err != nil {
- return fmt.Errorf("error running tournament selection program: %w", err)
- }
- if result.selected == nil {
- break
- }
-
- tabs := []string{"Overview", "Standings", "Decks", "Countries"}
- dashboard, err := runDashboard(model{
- conn: conn,
- tabs: tabs,
- styles: newStyles(),
- tournament: result.selected.Location,
- })
- if err != nil {
- return fmt.Errorf("error running dashboard program: %w", err)
- }
- if !dashboard.goBack {
- break
- }
- }
- return nil
-}
diff --git a/cmd/tcg/tcg_test.go b/cmd/tcg/tcg_test.go
deleted file mode 100644
index c6069dcd..00000000
--- a/cmd/tcg/tcg_test.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package tcg
-
-import (
- "errors"
- "testing"
-
- "github.com/digitalghost-dev/poke-cli/cmd/utils"
- "github.com/digitalghost-dev/poke-cli/styling"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// runTcgLoop
-
-func TestRunTcgLoop_NoTournamentSelected(t *testing.T) {
- // User quits tournament selection without picking → loop exits immediately.
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- return tournamentsModel{selected: nil}, nil
- }
- dashboardCalled := false
- runDashboard := func(m model) (model, error) {
- dashboardCalled = true
- return model{}, nil
- }
- err := runTcgLoop(noopConn, runTournaments, runDashboard)
- require.NoError(t, err)
- require.False(t, dashboardCalled, "dashboard should not be launched when no tournament is selected")
-}
-
-func TestRunTcgLoop_TournamentSelected_DashboardExits(t *testing.T) {
- // User picks a tournament, views the dashboard, then quits (goBack=false).
- td := tournamentData{Location: "London"}
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- return tournamentsModel{selected: &td}, nil
- }
- runDashboard := func(m model) (model, error) {
- assert.Equal(t, "London", m.tournament)
- return model{goBack: false}, nil
- }
- err := runTcgLoop(noopConn, runTournaments, runDashboard)
- assert.NoError(t, err)
-}
-
-func TestRunTcgLoop_GoBack_LoopsToTournamentSelection(t *testing.T) {
- // User presses b in the dashboard → goes back to the tournament list.
- // On the second visit, they quit without selecting.
- td := tournamentData{Location: "London"}
- calls := 0
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- calls++
- if calls == 1 {
- return tournamentsModel{selected: &td}, nil
- }
- return tournamentsModel{selected: nil}, nil
- }
- runDashboard := func(m model) (model, error) {
- return model{goBack: true}, nil
- }
- err := runTcgLoop(noopConn, runTournaments, runDashboard)
- require.NoError(t, err)
- require.Equal(t, 2, calls, "expected tournament selection to run twice")
-}
-
-func TestRunTcgLoop_TournamentRunnerError(t *testing.T) {
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- return tournamentsModel{}, errors.New("program crashed")
- }
- err := runTcgLoop(noopConn, runTournaments, nil)
- assert.ErrorContains(t, err, "tournament selection")
-}
-
-func TestRunTcgLoop_DashboardRunnerError(t *testing.T) {
- td := tournamentData{Location: "London"}
- runTournaments := func(m tournamentsModel) (tournamentsModel, error) {
- return tournamentsModel{selected: &td}, nil
- }
- runDashboard := func(m model) (model, error) {
- return model{}, errors.New("dashboard crashed")
- }
- err := runTcgLoop(noopConn, runTournaments, runDashboard)
- assert.ErrorContains(t, err, "dashboard")
-}
-
-func TestTcgCommand(t *testing.T) {
- tests := []struct {
- name string
- args []string
- golden string
- wantErr bool
- }{
- {
- name: "help flag short",
- args: []string{"tcg", "-h"},
- golden: "tcg_help.golden",
- wantErr: false,
- },
- {
- name: "help flag long",
- args: []string{"tcg", "--help"},
- golden: "tcg_help.golden",
- wantErr: false,
- },
- {
- name: "too many args",
- args: []string{"tcg", "foo", "bar"},
- golden: "tcg_too_many_args.golden",
- wantErr: true,
- },
- {
- name: "invalid flag",
- args: []string{"tcg", "--bogus"},
- golden: "tcg_invalid_flag.golden",
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- output, err := TcgCommand(tt.args)
- clean := styling.StripANSI(output)
-
- if tt.wantErr {
- require.Error(t, err)
- } else {
- require.NoError(t, err)
- }
- assert.Equal(t, utils.LoadGolden(t, tt.golden), clean)
- })
- }
-}
diff --git a/cmd/tcg/tournamentslist_test.go b/cmd/tcg/tournamentslist_test.go
deleted file mode 100644
index 43269049..00000000
--- a/cmd/tcg/tournamentslist_test.go
+++ /dev/null
@@ -1,250 +0,0 @@
-package tcg
-
-import (
- "errors"
- "strings"
- "testing"
- "time"
-
- "charm.land/bubbles/v2/list"
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/x/exp/teatest/v2"
- "github.com/digitalghost-dev/poke-cli/styling"
-)
-
-func noopConn(_ string) ([]byte, error) { return []byte("[]"), nil }
-
-// helpers
-
-func loadedModel() tournamentsModel {
- tournaments := []tournamentData{
- {Location: "London", TextDate: "January 10-12, 2025"},
- {Location: "Dallas", TextDate: "February 1-2, 2025"},
- }
- var items []list.Item
- for _, td := range tournaments {
- items = append(items, styling.Item(td.Location+" · "+td.TextDate))
- }
- l := list.New(items, styling.ItemDelegate{}, 40, 16)
- l.SetFilteringEnabled(false)
- return tournamentsModel{
- conn: noopConn,
- tournaments: tournaments,
- list: l,
- loading: false,
- }
-}
-
-// tournamentsList factory
-
-func TestTournamentsList_InitialState(t *testing.T) {
- m := tournamentsList(noopConn)
- if !m.loading {
- t.Error("expected loading=true on init")
- }
- if m.selected != nil {
- t.Error("expected selected=nil on init")
- }
-}
-
-// Init
-
-func TestTournamentsModel_Init_ReturnsCmd(t *testing.T) {
- m := tournamentsList(noopConn)
- cmd := m.Init()
- if cmd == nil {
- t.Error("expected Init() to return a non-nil cmd (spinner tick + fetch batch)")
- }
-}
-
-// fetchTournaments
-
-func TestFetchTournaments_ConnectionError(t *testing.T) {
- mock := func(_ string) ([]byte, error) { return nil, errors.New("connection refused") }
- msg := fetchTournaments(mock)()
- result, ok := msg.(tournamentsDataMsg)
- if !ok {
- t.Fatalf("expected tournamentsDataMsg, got %T", msg)
- }
- if result.err == nil {
- t.Error("expected error, got nil")
- }
- if result.tournaments != nil {
- t.Error("expected nil tournaments on error")
- }
-}
-
-func TestFetchTournaments_InvalidJSON(t *testing.T) {
- mock := func(_ string) ([]byte, error) { return []byte("not json"), nil }
- msg := fetchTournaments(mock)()
- result, ok := msg.(tournamentsDataMsg)
- if !ok {
- t.Fatalf("expected tournamentsDataMsg, got %T", msg)
- }
- if result.err == nil {
- t.Error("expected unmarshal error, got nil")
- }
-}
-
-func TestFetchTournaments_Success(t *testing.T) {
- mock := func(_ string) ([]byte, error) {
- return []byte(`[{"location":"London","text_date":"January 10-12, 2025"},{"location":"Dallas","text_date":"February 1-2, 2025"}]`), nil
- }
- msg := fetchTournaments(mock)()
- result, ok := msg.(tournamentsDataMsg)
- if !ok {
- t.Fatalf("expected tournamentsDataMsg, got %T", msg)
- }
- if result.err != nil {
- t.Errorf("expected no error, got %v", result.err)
- }
- if len(result.tournaments) != 2 {
- t.Errorf("expected 2 tournaments, got %d", len(result.tournaments))
- }
- if result.tournaments[0].Location != "London" {
- t.Errorf("expected first location to be London, got %q", result.tournaments[0].Location)
- }
-}
-
-// Update — key messages
-
-func TestTournamentsModel_Update_CtrlC(t *testing.T) {
- tests := []struct {
- name string
- msg tea.KeyPressMsg
- }{
- {name: "ctrl+c", msg: tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}},
- {name: "esc", msg: tea.KeyPressMsg{Code: tea.KeyEscape}},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- m := loadedModel()
- tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
- tm.Send(tt.msg)
- tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
- final := tm.FinalModel(t).(tournamentsModel)
- if !final.quitting {
- t.Errorf("expected quitting=true after %s", tt.name)
- }
- })
- }
-}
-
-func TestTournamentsModel_Update_Enter_SetsSelected(t *testing.T) {
- m := loadedModel()
- tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
- tm.Send(tea.KeyPressMsg{Code: tea.KeyEnter})
- tm.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond))
- final := tm.FinalModel(t).(tournamentsModel)
- if final.selected == nil {
- t.Fatal("expected selected to be set after enter")
- }
- if final.selected.Location != "London" {
- t.Errorf("expected Location=London, got %q", final.selected.Location)
- }
-}
-
-// Update — tournamentsDataMsg
-
-func TestTournamentsModel_Update_DataMsg_Success(t *testing.T) {
- m := tournamentsList(noopConn)
- msg := tournamentsDataMsg{
- tournaments: []tournamentData{
- {Location: "London", TextDate: "January 10-12, 2025"},
- {Location: "Dallas", TextDate: "February 1-2, 2025"},
- },
- }
- newModel, _ := m.Update(msg)
- result := newModel.(tournamentsModel)
- if result.loading {
- t.Error("expected loading=false after data received")
- }
- if len(result.tournaments) != 2 {
- t.Errorf("expected 2 tournaments, got %d", len(result.tournaments))
- }
- if result.list.Items() == nil {
- t.Error("expected list to be populated")
- }
-}
-
-func TestTournamentsModel_Update_DataMsg_Error(t *testing.T) {
- m := tournamentsList(noopConn)
- msg := tournamentsDataMsg{err: errors.New("fetch failed")}
- newModel, _ := m.Update(msg)
- result := newModel.(tournamentsModel)
- if result.loading {
- t.Error("expected loading=false after error")
- }
- if result.error == nil {
- t.Error("expected error to be set")
- }
-}
-
-// Update — window resize
-
-func TestTournamentsModel_Update_WindowResize_WhenLoaded(t *testing.T) {
- m := loadedModel()
- newModel, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
- result := newModel.(tournamentsModel)
- if result.list.Width() != 120 {
- t.Errorf("expected list width=120, got %d", result.list.Width())
- }
-}
-
-func TestTournamentsModel_Update_WindowResize_WhenLoading(t *testing.T) {
- m := tournamentsList(noopConn) // loading=true
- newModel, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
- result := newModel.(tournamentsModel)
- // list should not be updated while loading (it hasn't been created yet)
- if result.list.Width() == 120 {
- t.Error("expected list width to remain unchanged while loading")
- }
-}
-
-// View
-
-func TestTournamentsModel_View_Loading(t *testing.T) {
- m := tournamentsList(noopConn)
- view := m.View()
- if !strings.Contains(view.Content, "Loading tournaments") {
- t.Errorf("expected loading message, got %q", view.Content)
- }
-}
-
-func TestTournamentsModel_View_Error(t *testing.T) {
- m := tournamentsList(noopConn)
- m.loading = false
- m.error = errors.New("something went wrong")
- view := m.View()
- if !strings.Contains(view.Content, "something went wrong") {
- t.Errorf("expected error message in view, got %q", view.Content)
- }
-}
-
-func TestTournamentsModel_View_Quitting(t *testing.T) {
- m := tournamentsList(noopConn)
- m.quitting = true
- view := m.View()
- if !strings.Contains(view.Content, "Quitting") {
- t.Errorf("expected quitting message, got %q", view.Content)
- }
-}
-
-func TestTournamentsModel_View_Selected(t *testing.T) {
- m := loadedModel()
- td := m.tournaments[0]
- m.selected = &td
- view := m.View()
- if !strings.Contains(view.Content, "London") {
- t.Errorf("expected selected tournament in view, got %q", view.Content)
- }
-}
-
-func TestTournamentsModel_View_Normal(t *testing.T) {
- m := loadedModel()
- view := m.View()
- if view.Content == "" {
- t.Error("expected non-empty view for loaded model")
- }
-}
diff --git a/cmd/utils/errors.go b/cmd/utils/errors.go
index a13b1041..9dbc5a7e 100644
--- a/cmd/utils/errors.go
+++ b/cmd/utils/errors.go
@@ -9,6 +9,10 @@ func FormatError(message string) string {
)
}
+func FormatFlagError(cmdName string, err error) string {
+ return FormatError("Invalid flag for " + cmdName + "\n" + err.Error() + "\n\n" + "Run 'poke-cli " + cmdName + " -h' for more info")
+}
+
func FormatNotFoundError(resourceType string) string {
return FormatError(resourceType + " not found.\n• Perhaps a typo?\n• Missing a hyphen instead of a space?")
}
diff --git a/cmd/utils/errors_test.go b/cmd/utils/errors_test.go
index 9ee5bad8..43b33886 100644
--- a/cmd/utils/errors_test.go
+++ b/cmd/utils/errors_test.go
@@ -61,6 +61,15 @@ func TestFormatResourceErrors(t *testing.T) {
"Could not fetch Pokémon data.",
},
},
+ {
+ name: "flag error",
+ format: func() string { return FormatFlagError("mechanics", errors.New("unknown flag: --bogus")) },
+ contains: []string{
+ "Invalid flag for mechanics",
+ "unknown flag: --bogus",
+ "Run 'poke-cli mechanics -h' for more info",
+ },
+ },
}
for _, tt := range tests {
diff --git a/cmd/utils/validateargs_test.go b/cmd/utils/validateargs_test.go
index 2a59fb55..f3567d4d 100644
--- a/cmd/utils/validateargs_test.go
+++ b/cmd/utils/validateargs_test.go
@@ -140,26 +140,19 @@ func TestValidateArgs(t *testing.T) {
contains: "Too many arguments",
},
{
- name: "natures accepts no args",
- args: []string{"natures"},
- validator: Validator{MaxArgs: 2, CmdName: "natures"},
+ name: "mechanics accepts no args",
+ args: []string{"mechanics"},
+ validator: Validator{MaxArgs: 2, CmdName: "mechanics", HasFlags: true},
},
{
- name: "natures accepts help",
- args: []string{"natures", "--help"},
- validator: Validator{MaxArgs: 2, CmdName: "natures"},
+ name: "mechanics accepts natures flag",
+ args: []string{"mechanics", "--natures"},
+ validator: Validator{MaxArgs: 2, CmdName: "mechanics", HasFlags: true},
},
{
- name: "natures rejects extra arg",
- args: []string{"natures", "brave"},
- validator: Validator{MaxArgs: 2, CmdName: "natures"},
- wantErr: true,
- contains: "only available options",
- },
- {
- name: "natures rejects too many args",
- args: []string{"natures", "brave", "--help"},
- validator: Validator{MaxArgs: 2, CmdName: "natures"},
+ name: "mechanics rejects too many args",
+ args: []string{"mechanics", "--natures", "extra"},
+ validator: Validator{MaxArgs: 2, CmdName: "mechanics", HasFlags: true},
wantErr: true,
contains: "Too many arguments",
},
diff --git a/cmd/utils/web.go b/cmd/utils/web.go
new file mode 100644
index 00000000..fdb03a8c
--- /dev/null
+++ b/cmd/utils/web.go
@@ -0,0 +1,40 @@
+// Opens the user's default browser.
+
+package utils
+
+import (
+ "os/exec"
+ "runtime"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// Open returns a tea.Cmd that opens url in the user's default browser.
+// It is best-effort: if no browser launcher is available on the system, it
+// does nothing rather than surfacing an error into the TUI.
+func Open(url string) tea.Cmd {
+ return func() tea.Msg {
+ var (
+ browserCmd string
+ openCmd *exec.Cmd
+ )
+
+ switch runtime.GOOS {
+ case "windows":
+ browserCmd = "cmd"
+ openCmd = exec.Command("cmd", "/c", "start", url) //nolint:gosec
+ case "darwin":
+ browserCmd = "open"
+ openCmd = exec.Command("open", url)
+ default:
+ browserCmd = "xdg-open"
+ openCmd = exec.Command("xdg-open", url)
+ }
+
+ if _, err := exec.LookPath(browserCmd); err != nil {
+ return nil
+ }
+ _ = openCmd.Start()
+ return nil
+ }
+}
diff --git a/cmd/utils/web_test.go b/cmd/utils/web_test.go
new file mode 100644
index 00000000..17b4fcea
--- /dev/null
+++ b/cmd/utils/web_test.go
@@ -0,0 +1,72 @@
+package utils
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestOpenDoesNothingWhenLauncherIsUnavailable(t *testing.T) {
+ t.Setenv("PATH", t.TempDir())
+
+ cmd := Open("https://example.com")
+ if cmd == nil {
+ t.Fatal("Open returned nil Cmd")
+ }
+
+ if msg := cmd(); msg != nil {
+ t.Fatalf("expected nil message, got %#v", msg)
+ }
+}
+
+func TestOpenStartsLauncherWithURL(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test uses a POSIX shell script as a fake launcher")
+ }
+
+ dir := t.TempDir()
+ marker := filepath.Join(dir, "launched")
+ launcher := filepath.Join(dir, browserLauncherName())
+ script := "#!/bin/sh\nprintf '%s' \"$1\" > \"$OPEN_TEST_MARKER\"\n"
+
+ if err := os.WriteFile(launcher, []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake launcher: %v", err)
+ }
+ t.Setenv("PATH", dir)
+ t.Setenv("OPEN_TEST_MARKER", marker)
+
+ url := "https://example.com/cards?q=pikachu"
+ if msg := Open(url)(); msg != nil {
+ t.Fatalf("expected nil message, got %#v", msg)
+ }
+
+ got := waitForLauncherOutput(t, marker)
+ if got != url {
+ t.Fatalf("launcher received %q, want %q", got, url)
+ }
+}
+
+func browserLauncherName() string {
+ if runtime.GOOS == "darwin" {
+ return "open"
+ }
+ return "xdg-open"
+}
+
+func waitForLauncherOutput(t *testing.T, marker string) string {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ got, err := os.ReadFile(marker)
+ if err == nil {
+ return string(got)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ t.Fatalf("fake launcher did not write marker file %q", marker)
+ return ""
+}
diff --git a/connections/cache.go b/connections/cache.go
new file mode 100644
index 00000000..0d4961a0
--- /dev/null
+++ b/connections/cache.go
@@ -0,0 +1,75 @@
+package connections
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "sync"
+
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+var (
+ cacheWarnOnce sync.Once
+ cacheShowWarning = true
+ cacheBinaryPath string
+)
+
+func ConfigureCache(showWarning bool, binaryPath string) {
+ cacheShowWarning = showWarning
+ cacheBinaryPath = binaryPath
+}
+
+func cacheNotice() (string, error) {
+ if suppressCacheWarning() {
+ return "", nil
+ }
+ return styling.WarningColor.Render("poke-cache not installed; running without local caching.\n" +
+ " Install it for faster repeat lookups: https://docs.poke-cli.com/caching"), nil
+}
+
+func warnNoCache() {
+ cacheWarnOnce.Do(func() {
+ if msg, _ := cacheNotice(); msg != "" {
+ fmt.Fprintln(os.Stderr, msg)
+ }
+ })
+}
+
+func suppressCacheWarning() bool {
+ if v, err := strconv.ParseBool(os.Getenv("POKE_CLI_NO_CACHE_WARNING")); err == nil && v {
+ return true
+ }
+ return !cacheShowWarning
+}
+
+func cacheBinary() (string, bool) {
+ if cacheBinaryPath != "" {
+ if info, err := os.Stat(cacheBinaryPath); err == nil && !info.IsDir() {
+ return cacheBinaryPath, true
+ }
+ }
+ path, err := exec.LookPath("poke-cache")
+ if err != nil {
+ return "", false
+ }
+ return path, true
+}
+
+func cachedFetch(url string) ([]byte, error) {
+ if flag.Lookup("test.v") != nil {
+ return directFetch(url)
+ }
+ path, ok := cacheBinary()
+ if !ok {
+ warnNoCache()
+ return directFetch(url)
+ }
+ out, err := exec.Command(path, "get", url).Output()
+ if err != nil {
+ return directFetch(url)
+ }
+ return out, nil
+}
diff --git a/connections/cache_test.go b/connections/cache_test.go
new file mode 100644
index 00000000..c47b9bce
--- /dev/null
+++ b/connections/cache_test.go
@@ -0,0 +1,134 @@
+package connections
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func captureStderr(t *testing.T, fn func()) string {
+ t.Helper()
+ orig := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+ defer func() { os.Stderr = orig }()
+
+ fn()
+ require.NoError(t, w.Close())
+
+ out, err := io.ReadAll(r)
+ require.NoError(t, err)
+ return string(out)
+}
+
+func TestSuppressCacheWarning(t *testing.T) {
+ tests := []struct {
+ value string
+ suppress bool
+ }{
+ {"", false},
+ {"0", false},
+ {"false", false},
+ {"banana", false},
+ {"1", true},
+ {"true", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.value, func(t *testing.T) {
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", tt.value)
+ assert.Equal(t, tt.suppress, suppressCacheWarning())
+ })
+ }
+}
+
+func TestCacheNotice(t *testing.T) {
+ t.Run("not suppressed returns the notice", func(t *testing.T) {
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", "")
+ msg, err := cacheNotice()
+ require.NoError(t, err)
+ assert.Contains(t, msg, "poke-cache not installed")
+ })
+
+ t.Run("suppressed returns empty", func(t *testing.T) {
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", "1")
+ msg, err := cacheNotice()
+ require.NoError(t, err)
+ assert.Empty(t, msg)
+ })
+}
+
+func TestCachedFetch_UnderTestDelegatesToDirectFetch(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ defer ts.Close()
+
+ body, err := cachedFetch(ts.URL)
+ require.NoError(t, err)
+ assert.Equal(t, `{"ok":true}`, string(body))
+}
+
+func TestCachedFetch_PropagatesFetchError(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer ts.Close()
+
+ _, err := cachedFetch(ts.URL)
+ require.Error(t, err)
+
+ var statusErr HTTPStatusError
+ require.ErrorAs(t, err, &statusErr)
+ assert.Equal(t, http.StatusNotFound, statusErr.StatusCode)
+}
+
+func TestWarnNoCache_OncePerProcess(t *testing.T) {
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", "")
+ cacheWarnOnce = sync.Once{}
+
+ first := captureStderr(t, warnNoCache)
+ assert.Contains(t, first, "poke-cache not installed", "expected the notice on the first call")
+
+ second := captureStderr(t, warnNoCache)
+ assert.Empty(t, second, "expected nothing on the second call (sync.Once)")
+}
+
+func TestConfigureCacheConfigSuppresses(t *testing.T) {
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", "")
+ t.Cleanup(func() { ConfigureCache(true, "") })
+
+ ConfigureCache(false, "")
+ assert.True(t, suppressCacheWarning(), "show_warning=false should suppress")
+
+ ConfigureCache(true, "")
+ assert.False(t, suppressCacheWarning(), "show_warning=true should not suppress")
+}
+
+func TestConfigureCacheEnvOverridesConfig(t *testing.T) {
+ t.Cleanup(func() { ConfigureCache(true, "") })
+
+ ConfigureCache(true, "")
+ t.Setenv("POKE_CLI_NO_CACHE_WARNING", "1")
+ assert.True(t, suppressCacheWarning(), "env var should override config")
+}
+
+func TestCacheBinaryPrefersConfiguredPath(t *testing.T) {
+ t.Cleanup(func() { ConfigureCache(true, "") })
+
+ bin := filepath.Join(t.TempDir(), "poke-cache")
+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
+
+ ConfigureCache(true, bin)
+ got, ok := cacheBinary()
+ require.True(t, ok)
+ assert.Equal(t, bin, got)
+}
diff --git a/connections/connection.go b/connections/connection.go
index 5da6d0f3..b8f2db38 100644
--- a/connections/connection.go
+++ b/connections/connection.go
@@ -85,7 +85,6 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error
return fmt.Errorf("invalid URL provided: %w", err)
}
- // Check if running in a test environment
if flag.Lookup("test.v") != nil {
skipHTTPSCheck = true
}
@@ -94,30 +93,35 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error
return errors.New("only HTTPS URLs are allowed for security reasons")
}
- resp, err := httpClient.Get(parsedURL.String())
+ body, err := cachedFetch(parsedURL.String())
if err != nil {
- return fmt.Errorf("error making GET request: %w", err)
+ return err
}
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return HTTPStatusError{StatusCode: resp.StatusCode, URL: rawURL}
+ if err := json.Unmarshal(body, target); err != nil {
+ return fmt.Errorf("error unmarshalling JSON: %w", err)
}
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
+ return nil
+}
+
+func directFetch(url string) ([]byte, error) {
+ resp, err := httpClient.Get(url)
if err != nil {
- return fmt.Errorf("error reading response body: %w", err)
+ return nil, fmt.Errorf("error making GET request: %w", err)
}
- if len(body) > maxAPIResponseBytes {
- return fmt.Errorf("response body exceeds %d bytes", maxAPIResponseBytes)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, HTTPStatusError{StatusCode: resp.StatusCode, URL: url}
}
-
- err = json.Unmarshal(body, target)
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
if err != nil {
- return fmt.Errorf("error unmarshalling JSON: %w", err)
+ return nil, fmt.Errorf("error reading response body: %w", err)
}
-
- return nil
+ if len(body) > maxAPIResponseBytes {
+ return nil, fmt.Errorf("response body exceeds %d bytes", maxAPIResponseBytes)
+ }
+ return body, nil
}
func AbilityApiCall(endpoint, abilityName, baseURL string) (structs.AbilityJSONStruct, string, error) {
diff --git a/card_data/.python-version b/data_platform/.python-version
similarity index 100%
rename from card_data/.python-version
rename to data_platform/.python-version
diff --git a/card_data/README.md b/data_platform/README.md
similarity index 100%
rename from card_data/README.md
rename to data_platform/README.md
diff --git a/card_data/dagster.yaml b/data_platform/dagster.yaml
similarity index 100%
rename from card_data/dagster.yaml
rename to data_platform/dagster.yaml
diff --git a/card_data/data_infrastructure_diagram.png b/data_platform/data_infrastructure_diagram.png
similarity index 100%
rename from card_data/data_infrastructure_diagram.png
rename to data_platform/data_infrastructure_diagram.png
diff --git a/card_data/infrastructure/aws/ec2/.terraform.lock.hcl b/data_platform/infrastructure/aws/ec2/.terraform.lock.hcl
similarity index 100%
rename from card_data/infrastructure/aws/ec2/.terraform.lock.hcl
rename to data_platform/infrastructure/aws/ec2/.terraform.lock.hcl
diff --git a/card_data/infrastructure/aws/ec2/instance.tf b/data_platform/infrastructure/aws/ec2/instance.tf
similarity index 100%
rename from card_data/infrastructure/aws/ec2/instance.tf
rename to data_platform/infrastructure/aws/ec2/instance.tf
diff --git a/card_data/infrastructure/aws/ec2/outputs.tf b/data_platform/infrastructure/aws/ec2/outputs.tf
similarity index 100%
rename from card_data/infrastructure/aws/ec2/outputs.tf
rename to data_platform/infrastructure/aws/ec2/outputs.tf
diff --git a/card_data/infrastructure/aws/ec2/provider.tf b/data_platform/infrastructure/aws/ec2/provider.tf
similarity index 100%
rename from card_data/infrastructure/aws/ec2/provider.tf
rename to data_platform/infrastructure/aws/ec2/provider.tf
diff --git a/card_data/infrastructure/aws/rds/.terraform.lock.hcl b/data_platform/infrastructure/aws/rds/.terraform.lock.hcl
similarity index 100%
rename from card_data/infrastructure/aws/rds/.terraform.lock.hcl
rename to data_platform/infrastructure/aws/rds/.terraform.lock.hcl
diff --git a/card_data/infrastructure/aws/rds/db_instance.tf b/data_platform/infrastructure/aws/rds/db_instance.tf
similarity index 100%
rename from card_data/infrastructure/aws/rds/db_instance.tf
rename to data_platform/infrastructure/aws/rds/db_instance.tf
diff --git a/card_data/infrastructure/aws/rds/db_subnet_group.tf b/data_platform/infrastructure/aws/rds/db_subnet_group.tf
similarity index 100%
rename from card_data/infrastructure/aws/rds/db_subnet_group.tf
rename to data_platform/infrastructure/aws/rds/db_subnet_group.tf
diff --git a/card_data/infrastructure/aws/rds/outputs.tf b/data_platform/infrastructure/aws/rds/outputs.tf
similarity index 100%
rename from card_data/infrastructure/aws/rds/outputs.tf
rename to data_platform/infrastructure/aws/rds/outputs.tf
diff --git a/card_data/infrastructure/aws/rds/provider.tf b/data_platform/infrastructure/aws/rds/provider.tf
similarity index 100%
rename from card_data/infrastructure/aws/rds/provider.tf
rename to data_platform/infrastructure/aws/rds/provider.tf
diff --git a/card_data/infrastructure/aws/rds/variables.tf b/data_platform/infrastructure/aws/rds/variables.tf
similarity index 100%
rename from card_data/infrastructure/aws/rds/variables.tf
rename to data_platform/infrastructure/aws/rds/variables.tf
diff --git a/card_data/infrastructure/aws/vpc/.terraform.lock.hcl b/data_platform/infrastructure/aws/vpc/.terraform.lock.hcl
similarity index 100%
rename from card_data/infrastructure/aws/vpc/.terraform.lock.hcl
rename to data_platform/infrastructure/aws/vpc/.terraform.lock.hcl
diff --git a/card_data/infrastructure/aws/vpc/outputs.tf b/data_platform/infrastructure/aws/vpc/outputs.tf
similarity index 100%
rename from card_data/infrastructure/aws/vpc/outputs.tf
rename to data_platform/infrastructure/aws/vpc/outputs.tf
diff --git a/card_data/infrastructure/aws/vpc/provider.tf b/data_platform/infrastructure/aws/vpc/provider.tf
similarity index 100%
rename from card_data/infrastructure/aws/vpc/provider.tf
rename to data_platform/infrastructure/aws/vpc/provider.tf
diff --git a/card_data/infrastructure/aws/vpc/vpc.tf b/data_platform/infrastructure/aws/vpc/vpc.tf
similarity index 100%
rename from card_data/infrastructure/aws/vpc/vpc.tf
rename to data_platform/infrastructure/aws/vpc/vpc.tf
diff --git a/card_data/infrastructure/dagster.service b/data_platform/infrastructure/dagster.service
similarity index 77%
rename from card_data/infrastructure/dagster.service
rename to data_platform/infrastructure/dagster.service
index 2c2a655a..f9172ce9 100644
--- a/card_data/infrastructure/dagster.service
+++ b/data_platform/infrastructure/dagster.service
@@ -7,9 +7,9 @@ Wants=network-online.target
[Service]
Type=simple
User=ubuntu
-WorkingDirectory=/home/ubuntu/card_data/card_data
+WorkingDirectory=/home/ubuntu/poke-cli/data_platform
Environment="AWS_DEFAULT_REGION=us-west-2"
-Environment="PATH=/home/ubuntu/card_data/card_data/.venv/bin:/usr/local/bin:/usr/bin:/bin"
+Environment="PATH=/home/ubuntu/poke-cli/data_platform/.venv/bin:/usr/local/bin:/usr/bin:/bin"
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
diff --git a/card_data/infrastructure/start-dagster.sh b/data_platform/infrastructure/start-dagster.sh
similarity index 93%
rename from card_data/infrastructure/start-dagster.sh
rename to data_platform/infrastructure/start-dagster.sh
index 4157baee..06b94cac 100644
--- a/card_data/infrastructure/start-dagster.sh
+++ b/data_platform/infrastructure/start-dagster.sh
@@ -48,11 +48,11 @@ if [ -z "$AWS_RDS_HOSTNAME" ] || [ "$AWS_RDS_HOSTNAME" = "null" ]; then
fi
export AWS_RDS_HOSTNAME
-DAGSTER_HOME=/home/ubuntu/card_data/card_data/
+DAGSTER_HOME=/home/ubuntu/poke-cli/data_platform/
export DAGSTER_HOME
# Activate the virtual environment
-source /home/ubuntu/card_data/card_data/.venv/bin/activate
+source /home/ubuntu/poke-cli/data_platform/.venv/bin/activate
# Start Dagster
exec dg dev --host 0.0.0.0 --port 3000
\ No newline at end of file
diff --git a/card_data/infrastructure/wait-for-rds.sh b/data_platform/infrastructure/wait-for-rds.sh
similarity index 100%
rename from card_data/infrastructure/wait-for-rds.sh
rename to data_platform/infrastructure/wait-for-rds.sh
diff --git a/data_platform/pipelines/definitions.py b/data_platform/pipelines/definitions.py
new file mode 100644
index 00000000..33ec34a6
--- /dev/null
+++ b/data_platform/pipelines/definitions.py
@@ -0,0 +1,215 @@
+from pathlib import Path
+
+from dagster import definitions, load_from_defs_folder
+
+import dagster as dg
+
+from .defs.extract.limitless.extract_standings import create_standings_dataframe
+from .defs.competitive import (
+ create_events_dataframe,
+ load_events_data,
+ data_quality_checks_on_comp_events,
+ load_players_data,
+ data_quality_checks_on_comp_players,
+ data_quality_checks_on_comp_rounds,
+ data_quality_checks_on_comp_vg_decklists,
+ data_quality_checks_on_comp_tcg_decklists,
+)
+from .defs.extract.tcgcsv.extract_pricing import build_dataframe
+from .defs.extract.tcgdex.extract_sets import extract_sets_data
+from .defs.extract.tcgdex.extract_series import extract_series_data
+from .defs.load.limitless.load_standings import load_standings_data
+from .defs.load.tcgcsv.load_pricing import (
+ load_pricing_data,
+ data_quality_checks_on_pricing,
+)
+from .defs.load.tcgdex.load_sets import load_sets_data, data_quality_check_on_sets
+from .defs.load.tcgdex.load_series import load_series_data, data_quality_check_on_series
+from .defs.pokeapi.pokemon import load_pokemon
+from .defs.pokeapi.pokemon_sprites import load_vg_pokemon_sprites
+from .defs.pokeapi.base_types import load_vg_types
+from .defs.pokeapi.base_stats import load_vg_stats
+from .defs.pokeapi.pokemon_types import load_vg_pokemon_types
+from .defs.pokeapi.pokemon_stats import load_vg_pokemon_stats
+from .defs.pikalytics.speed_tiers import trigger_pikalytics_speed_tiers
+from .defs.pikalytics.usage import trigger_pikalytics_usage
+from .defs.pikalytics.top_teams import trigger_pikalytics_top_teams
+from .defs.pikalytics.pokemon_comp_info import trigger_pikalytics_pokemon_comp_info
+from .sensors import discord_success_sensor, discord_failure_sensor
+
+
+@definitions
+def defs() -> dg.Definitions:
+ # load_from_defs_folder discovers dbt assets from transform_data.py
+ folder_defs: dg.Definitions = load_from_defs_folder(
+ project_root=Path(__file__).parent.parent
+ )
+ return dg.Definitions.merge(
+ folder_defs,
+ defs_discord_sensors,
+ defs_pricing,
+ defs_sets,
+ defs_series,
+ defs_standings,
+ defs_events,
+ defs_pokeapi,
+ defs_pikalytics,
+ )
+
+
+defs_discord_sensors: dg.Definitions = dg.Definitions(
+ sensors=[discord_success_sensor, discord_failure_sensor],
+)
+
+# Pricing pipeline job
+pricing_pipeline = dg.define_asset_job(
+ name="pricing_pipeline_job",
+ selection=dg.AssetSelection.assets(build_dataframe).downstream(include_self=True),
+)
+
+price_schedule: dg.ScheduleDefinition = dg.ScheduleDefinition(
+ name="price_schedule",
+ cron_schedule="0 14 * * *",
+ target=pricing_pipeline,
+ execution_timezone="America/Los_Angeles",
+)
+
+defs_pricing: dg.Definitions = dg.Definitions(
+ assets=[build_dataframe, load_pricing_data, data_quality_checks_on_pricing],
+ jobs=[pricing_pipeline],
+ schedules=[price_schedule],
+)
+
+# Series pipeline job
+series_pipeline = dg.define_asset_job(
+ name="series_pipeline_job",
+ selection=dg.AssetSelection.assets(extract_series_data).downstream(
+ include_self=True
+ ),
+)
+
+defs_series: dg.Definitions = dg.Definitions(
+ assets=[extract_series_data, load_series_data, data_quality_check_on_series],
+ jobs=[series_pipeline],
+)
+
+# Sets pipeline job
+sets_pipeline = dg.define_asset_job(
+ name="sets_pipeline_job",
+ selection=dg.AssetSelection.assets(extract_sets_data).downstream(include_self=True),
+)
+
+defs_sets: dg.Definitions = dg.Definitions(
+ assets=[extract_sets_data, load_sets_data, data_quality_check_on_sets],
+ jobs=[sets_pipeline],
+)
+
+# Standings pipeline job
+standings_pipeline = dg.define_asset_job(
+ name="standings_pipeline_job",
+ selection=dg.AssetSelection.assets(create_standings_dataframe).downstream(
+ include_self=True
+ ),
+)
+
+defs_standings: dg.Definitions = dg.Definitions(
+ assets=[create_standings_dataframe, load_standings_data],
+ jobs=[standings_pipeline],
+)
+
+# Competitive events + tournaments pipeline job (single job, branches into TCG and VG)
+events_pipeline = dg.define_asset_job(
+ name="comp_pipeline_job",
+ selection=dg.AssetSelection.assets(create_events_dataframe).downstream(include_self=True),
+)
+
+defs_events: dg.Definitions = dg.Definitions(
+ assets=[
+ create_events_dataframe,
+ load_events_data,
+ data_quality_checks_on_comp_events,
+ load_players_data,
+ data_quality_checks_on_comp_players,
+ data_quality_checks_on_comp_rounds,
+ data_quality_checks_on_comp_vg_decklists,
+ data_quality_checks_on_comp_tcg_decklists,
+ ],
+ jobs=[events_pipeline],
+)
+
+
+# The pikalytics dbt models depend on the pokemon model (resolve_pokemon_id refs it),
+# so they sit downstream of load_pokemon. They belong to pikalytics_pipeline_job, so
+# exclude them here to keep the two data sources in separate jobs.
+pikalytics_dbt_models = dg.AssetSelection.assets(
+ "pikalytics_speed_tiers",
+ "pikalytics_usage",
+ "pikalytics_top_teams",
+ "pikalytics_pokemon_comp_info",
+)
+
+# PokéAPI video-game data pipeline job (6 staging loads + their downstream dbt models)
+pokeapi_pipeline = dg.define_asset_job(
+ name="pokeapi_pipeline_job",
+ selection=dg.AssetSelection.assets(
+ load_pokemon,
+ load_vg_pokemon_sprites,
+ load_vg_types,
+ load_vg_stats,
+ load_vg_pokemon_types,
+ load_vg_pokemon_stats,
+ ).downstream(include_self=True)
+ - pikalytics_dbt_models,
+)
+
+pokeapi_schedule: dg.ScheduleDefinition = dg.ScheduleDefinition(
+ name="pokeapi_schedule",
+ cron_schedule="0 14 1,15 * *",
+ target=pokeapi_pipeline,
+ execution_timezone="America/Los_Angeles",
+)
+
+defs_pokeapi: dg.Definitions = dg.Definitions(
+ assets=[
+ load_pokemon,
+ load_vg_pokemon_sprites,
+ load_vg_types,
+ load_vg_stats,
+ load_vg_pokemon_types,
+ load_vg_pokemon_stats,
+ ],
+ jobs=[pokeapi_pipeline],
+ schedules=[pokeapi_schedule],
+)
+
+
+# Pikalytics pipeline job (Dagster-first: triggers the n8n scrapes, then dbt builds public).
+# pokemon_comp_info depends on usage (it reads the fresh top-50 from staging.pikalytics_usage),
+# so it runs after the usage trigger; the rest fan out in parallel.
+pikalytics_pipeline = dg.define_asset_job(
+ name="pikalytics_pipeline_job",
+ selection=dg.AssetSelection.assets(
+ trigger_pikalytics_speed_tiers,
+ trigger_pikalytics_usage,
+ trigger_pikalytics_top_teams,
+ trigger_pikalytics_pokemon_comp_info,
+ ).downstream(include_self=True),
+)
+
+pikalytics_schedule: dg.ScheduleDefinition = dg.ScheduleDefinition(
+ name="pikalytics_schedule",
+ cron_schedule="0 8 * * 1",
+ target=pikalytics_pipeline,
+ execution_timezone="America/Los_Angeles",
+)
+
+defs_pikalytics: dg.Definitions = dg.Definitions(
+ assets=[
+ trigger_pikalytics_speed_tiers,
+ trigger_pikalytics_usage,
+ trigger_pikalytics_top_teams,
+ trigger_pikalytics_pokemon_comp_info,
+ ],
+ jobs=[pikalytics_pipeline],
+ schedules=[pikalytics_schedule],
+)
diff --git a/data_platform/pipelines/defs/competitive.py b/data_platform/pipelines/defs/competitive.py
new file mode 100644
index 00000000..491960cc
--- /dev/null
+++ b/data_platform/pipelines/defs/competitive.py
@@ -0,0 +1,466 @@
+import re
+import subprocess # nosec
+import time
+from pathlib import Path
+
+import dagster as dg
+import polars as pl
+import requests
+from dagster import Backoff, RetryPolicy
+from psycopg2.extras import Json, execute_values
+from sqlalchemy import create_engine, text
+from sqlalchemy.exc import OperationalError
+
+from ..utils.secret_retriever import fetch_secret
+
+WORLDS_TCG_ID = "0000129"
+WORLDS_VG_ID = "0000115"
+EVENT_NAME_TOKEN = "Championships"
+EVENT_SOURCES = (
+ ("tcg", "TCG", WORLDS_TCG_ID),
+ ("vg", "VGC", WORLDS_VG_ID),
+)
+
+REQUEST_DELAY_SECONDS = 0.2
+TOP_N_PLACEMENTS = 256
+RESISTANCE_FLOOR = 0.25 # Pokémon tiebreakers floor each win % at 25%
+COUNTRY_PATTERN = re.compile(r"^(.*?)\s*\[([A-Za-z]{2,3})\]\s*$")
+
+
+def call_api(url: str, session: requests.Session | None = None) -> dict:
+ client = session or requests
+ r = client.get(url, timeout=60)
+ r.raise_for_status()
+ return r.json()
+
+
+def infer_season(start_date: str) -> int:
+ year, month = int(start_date[:4]), int(start_date[5:7])
+ if month >= 9:
+ return year + 1
+ return year
+
+
+def parse_player_name(raw: str) -> tuple[str, str | None]:
+ m = COUNTRY_PATTERN.match(raw)
+ if m:
+ return m.group(1).strip(), m.group(2).upper()
+ return raw.strip(), None
+
+
+def _run_soda_scan(checks_filename: str) -> None:
+ current_file_dir = Path(__file__).parent
+ result = subprocess.run( # nosec
+ [
+ "soda", "scan", "-d", "supabase",
+ "-c", "../soda/configuration.yml",
+ f"../soda/{checks_filename}",
+ ],
+ capture_output=True,
+ text=True,
+ cwd=current_file_dir,
+ )
+ if result.stdout:
+ print(result.stdout)
+ if result.stderr:
+ print(result.stderr)
+ if result.returncode != 0:
+ raise Exception(f"Soda check {checks_filename} failed with return code {result.returncode}")
+
+
+_CREATE_EVENTS = """
+ CREATE TABLE IF NOT EXISTS staging.comp_events (
+ id BIGSERIAL PRIMARY KEY,
+ pokedata_id TEXT,
+ game_type TEXT,
+ name TEXT,
+ start_date TEXT,
+ end_date TEXT,
+ season BIGINT,
+ count BIGINT,
+ rounds BIGINT,
+ last_updated TEXT,
+ UNIQUE (pokedata_id, game_type)
+ )
+"""
+
+_UPSERT_EVENTS = """
+ INSERT INTO staging.comp_events (pokedata_id, game_type, name, start_date, end_date, season, count, rounds, last_updated)
+ VALUES (:pokedata_id, :game_type, :name, :start_date, :end_date, :season, :count, :rounds, :last_updated)
+ ON CONFLICT (pokedata_id, game_type) DO UPDATE SET
+ name = EXCLUDED.name,
+ start_date = EXCLUDED.start_date,
+ end_date = EXCLUDED.end_date,
+ season = EXCLUDED.season,
+ count = EXCLUDED.count,
+ rounds = EXCLUDED.rounds,
+ last_updated = EXCLUDED.last_updated
+"""
+
+
+def build_events_dataframe(data: dict) -> pl.DataFrame:
+ rows = []
+ for source_key, game_type, min_event_id in EVENT_SOURCES:
+ for event in data[source_key]["data"]:
+ if event["id"] <= min_event_id or EVENT_NAME_TOKEN not in event["name"]:
+ continue
+
+ start_date = event["date"]["start"]
+ rows.append({
+ "pokedata_id": event["id"],
+ "game_type": game_type,
+ "name": event["name"],
+ "start_date": start_date,
+ "end_date": event["date"]["end"],
+ "season": infer_season(start_date),
+ "count": int(event["players"]["masters"]),
+ "rounds": event["roundNumbers"]["masters"],
+ "last_updated": event["lastUpdated"],
+ })
+
+ return pl.DataFrame(rows)
+
+
+@dg.asset(kinds={"API", "Polars"}, name="create_events_dataframe")
+def create_events_dataframe() -> pl.DataFrame:
+ data = call_api("https://www.pokedata.ovh/apiv2/tournaments")
+ return build_events_dataframe(data)
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_events_data(create_events_dataframe: pl.DataFrame) -> None:
+ database_url: str = fetch_secret()
+ try:
+ engine = create_engine(database_url)
+ with engine.begin() as conn:
+ conn.execute(text(_CREATE_EVENTS))
+ conn.execute(text(_UPSERT_EVENTS), create_events_dataframe.to_dicts())
+ print(" ✓ Data upserted into staging.comp_events")
+ except OperationalError as e:
+ print(f" ✖ Connection error in load_events_data(): {e}")
+ raise
+
+
+@dg.asset(deps=[load_events_data], kinds={"Soda"}, name="data_quality_checks_on_comp_events")
+def data_quality_checks_on_comp_events() -> None:
+ _run_soda_scan("checks_comp_events.yml")
+
+
+_CREATE_PLAYERS = """
+ CREATE TABLE IF NOT EXISTS staging.comp_players (
+ id BIGSERIAL PRIMARY KEY,
+ pokedata_id TEXT NOT NULL,
+ game_type TEXT NOT NULL,
+ player_name TEXT NOT NULL,
+ country TEXT,
+ placement BIGINT,
+ wins BIGINT,
+ losses BIGINT,
+ ties BIGINT,
+ resistance_self NUMERIC(6, 4),
+ resistance_opp NUMERIC(6, 4),
+ resistance_oppopp NUMERIC(6, 4),
+ dropped_round BIGINT,
+ trainer_name TEXT,
+ UNIQUE (pokedata_id, game_type, player_name)
+ )
+"""
+
+_CREATE_ROUNDS = """
+ CREATE TABLE IF NOT EXISTS staging.comp_rounds (
+ id BIGSERIAL PRIMARY KEY,
+ pokedata_id TEXT NOT NULL,
+ game_type TEXT NOT NULL,
+ player_name TEXT NOT NULL,
+ round_number BIGINT NOT NULL,
+ opponent_name TEXT,
+ result TEXT,
+ table_number TEXT,
+ UNIQUE (pokedata_id, game_type, player_name, round_number)
+ )
+"""
+
+_CREATE_DECKLISTS_TEMPLATE = """
+ CREATE TABLE IF NOT EXISTS staging.{table_name} (
+ id BIGSERIAL PRIMARY KEY,
+ pokedata_id TEXT NOT NULL,
+ game_type TEXT NOT NULL,
+ player_name TEXT NOT NULL,
+ decklist JSONB,
+ UNIQUE (pokedata_id, game_type, player_name)
+ )
+"""
+
+_CREATE_VG_DECKLISTS = _CREATE_DECKLISTS_TEMPLATE.format(table_name="comp_vg_decklists")
+_CREATE_TCG_DECKLISTS = _CREATE_DECKLISTS_TEMPLATE.format(table_name="comp_tcg_decklists")
+
+_INSERT_PLAYERS_SQL = """
+ INSERT INTO staging.comp_players (
+ pokedata_id, game_type, player_name, country, placement,
+ wins, losses, ties,
+ resistance_self, resistance_opp, resistance_oppopp,
+ dropped_round, trainer_name
+ ) VALUES %s
+ ON CONFLICT (pokedata_id, game_type, player_name) DO NOTHING
+"""
+
+_INSERT_ROUNDS_SQL = """
+ INSERT INTO staging.comp_rounds (
+ pokedata_id, game_type, player_name, round_number,
+ opponent_name, result, table_number
+ ) VALUES %s
+ ON CONFLICT (pokedata_id, game_type, player_name, round_number) DO NOTHING
+"""
+
+_INSERT_DECKLISTS_SQL_TEMPLATE = """
+ INSERT INTO staging.{table_name} (
+ pokedata_id, game_type, player_name, decklist
+ ) VALUES %s
+ ON CONFLICT (pokedata_id, game_type, player_name) DO NOTHING
+"""
+
+_INSERT_VG_DECKLISTS_SQL = _INSERT_DECKLISTS_SQL_TEMPLATE.format(
+ table_name="comp_vg_decklists"
+)
+_INSERT_TCG_DECKLISTS_SQL = _INSERT_DECKLISTS_SQL_TEMPLATE.format(
+ table_name="comp_tcg_decklists"
+)
+
+_PLAYER_TABLE_DDLS = (
+ _CREATE_PLAYERS,
+ _CREATE_ROUNDS,
+ _CREATE_VG_DECKLISTS,
+ _CREATE_TCG_DECKLISTS,
+)
+
+
+def fetch_events_to_process(conn) -> list[dict]:
+ """Finished events not yet loaded into comp_players. 2-day buffer past end_date."""
+ query = """
+ SELECT e.pokedata_id, e.game_type
+ FROM staging.comp_events e
+ WHERE e.end_date::date < CURRENT_DATE - INTERVAL '1 day'
+ AND NOT EXISTS (
+ SELECT 1 FROM staging.comp_players p
+ WHERE p.pokedata_id = e.pokedata_id
+ AND p.game_type = e.game_type
+ )
+ ORDER BY e.end_date
+ """
+
+ return [dict(row._mapping) for row in conn.execute(text(query)).all()]
+
+
+def _win_percentage(wins: int, losses: int, ties: int) -> float:
+ """Match win % used by Pokémon tiebreakers: a tie counts as half a win."""
+ total = wins + losses + ties
+ if total <= 0:
+ return RESISTANCE_FLOOR
+ return (wins + 0.5 * ties) / total
+
+
+def compute_resistances(div_data: list[dict]) -> dict[str, dict[str, float]]:
+ win_pct: dict[str, float] = {}
+ opponents: dict[str, list[str]] = {}
+
+ for p in div_data:
+ raw = p["name"]
+ rec = p["record"]
+ byes = sum(
+ 1 for r in (p.get("rounds") or {}).values() if r.get("name") == "BYE"
+ )
+ win_pct[raw] = _win_percentage(rec["wins"] - byes, rec["losses"], rec["ties"])
+ opponents[raw] = [
+ r["name"]
+ for r in (p.get("rounds") or {}).values()
+ if r.get("name") and r["name"] != "BYE"
+ ]
+
+ def _mean(values: list[float]) -> float:
+ return sum(values) / len(values) if values else RESISTANCE_FLOOR
+
+ opp_pct = {
+ raw: _mean([max(RESISTANCE_FLOOR, win_pct[o]) for o in opps if o in win_pct])
+ for raw, opps in opponents.items()
+ }
+ return {
+ raw: {
+ "self": win_pct[raw],
+ "opp": opp_pct[raw],
+ "oppopp": _mean([opp_pct[o] for o in opps if o in opp_pct]),
+ }
+ for raw, opps in opponents.items()
+ }
+
+
+def build_player_rows(
+ data: dict,
+ pid: str,
+ gt: str,
+) -> tuple[list[tuple], list[tuple], list[tuple], list[tuple]]:
+ players: list[tuple] = []
+ rounds: list[tuple] = []
+ decklists_vg: list[tuple] = []
+ decklists_tcg: list[tuple] = []
+
+ for div in data.get("tournament_data", []):
+ div_data = div.get("data", [])
+ resistances = compute_resistances(div_data)
+
+ for p in div_data:
+ placement = p.get("placing")
+ if placement is None or placement > TOP_N_PLACEMENTS:
+ continue
+
+ name, country = parse_player_name(p["name"])
+ res = resistances[p["name"]]
+
+ players.append((
+ pid, gt, name, country, placement,
+ p["record"]["wins"], p["record"]["losses"], p["record"]["ties"],
+ res["self"], res["opp"], res["oppopp"],
+ p.get("drop", -1), p.get("Trainer name"),
+ ))
+
+ for round_num, r in (p.get("rounds") or {}).items():
+ rounds.append((
+ pid, gt, name, int(round_num),
+ r.get("name"), r.get("result"), str(r.get("table", "")),
+ ))
+
+ deck = p.get("decklist")
+ if deck:
+ deck_row = (pid, gt, name, Json(deck))
+ if gt == "VGC":
+ decklists_vg.append(deck_row)
+ else:
+ decklists_tcg.append(deck_row)
+
+ return players, rounds, decklists_vg, decklists_tcg
+
+
+def process_event(
+ cur,
+ pid: str,
+ gt: str,
+ session: requests.Session | None = None,
+) -> tuple[int, int, int]:
+ """Fetch one event from the API and bulk-insert its data via execute_values."""
+ url_segment = "tcg" if gt == "TCG" else "vg"
+ url = f"https://www.pokedata.ovh/apiv2/{url_segment}/id/{pid}/division/masters"
+ data = call_api(url, session=session)
+ players, rounds, decklists_vg, decklists_tcg = build_player_rows(data, pid, gt)
+
+ insert_batches = (
+ (_INSERT_PLAYERS_SQL, players, 500),
+ (_INSERT_ROUNDS_SQL, rounds, 1000),
+ (_INSERT_VG_DECKLISTS_SQL, decklists_vg, 500),
+ (_INSERT_TCG_DECKLISTS_SQL, decklists_tcg, 500),
+ )
+ for insert_sql, rows, page_size in insert_batches:
+ if rows:
+ execute_values(cur, insert_sql, rows, page_size=page_size)
+
+ return len(players), len(rounds), len(decklists_vg) + len(decklists_tcg)
+
+
+@dg.asset(
+ deps=[dg.AssetKey("data_quality_checks_on_comp_events")],
+ kinds={"API", "Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_players_data() -> None:
+ database_url: str = fetch_secret()
+
+ try:
+ engine = create_engine(database_url)
+
+ with engine.begin() as conn:
+ for ddl in _PLAYER_TABLE_DDLS:
+ conn.execute(text(ddl))
+
+ with engine.connect() as conn:
+ events = fetch_events_to_process(conn)
+
+ total = len(events)
+ print(f" → {total} events to process")
+
+ if total == 0:
+ print(" ✓ Nothing to load")
+ return
+
+ overall_start = time.time()
+ successes = 0
+ failures = 0
+ totals = {"players": 0, "rounds": 0, "decklists": 0}
+
+ with engine.connect() as conn, requests.Session() as session:
+ for i, ev in enumerate(events, 1):
+ pid = ev["pokedata_id"]
+ gt = ev["game_type"]
+ start = time.time()
+
+ try:
+ with conn.begin():
+ cur = conn.connection.cursor()
+ n_players, n_rounds, n_decklists = process_event(
+ cur, pid, gt, session
+ )
+ successes += 1
+ totals["players"] += n_players
+ totals["rounds"] += n_rounds
+ totals["decklists"] += n_decklists
+ except requests.HTTPError as e:
+ print(f" ✖ [{i:3d}/{total}] {gt} {pid}: API error {e}; skipping")
+ failures += 1
+ time.sleep(REQUEST_DELAY_SECONDS)
+ continue
+ except Exception as e: # noqa: BLE001
+ print(f" ✖ [{i:3d}/{total}] {gt} {pid}: {type(e).__name__}: {e}; skipping")
+ failures += 1
+ time.sleep(REQUEST_DELAY_SECONDS)
+ continue
+
+ elapsed = time.time() - start
+ total_elapsed = time.time() - overall_start
+ eta_min = (total_elapsed / i) * (total - i) / 60
+ print(
+ f" ✓ [{i:3d}/{total}] {gt} {pid}: "
+ f"{n_players} players, {n_rounds} rounds, {n_decklists} decklists "
+ f"in {elapsed:.1f}s (ETA {eta_min:.1f}min)"
+ )
+
+ time.sleep(REQUEST_DELAY_SECONDS)
+
+ total_min = (time.time() - overall_start) / 60
+ print(
+ f" ✓ Done: {successes} succeeded, {failures} failed in {total_min:.1f}min "
+ f"({totals['players']} players, {totals['rounds']} rounds, {totals['decklists']} decklists)"
+ )
+
+ except OperationalError as e:
+ print(f" ✖ Connection error in load_players_data(): {e}")
+ raise
+
+
+@dg.asset(deps=[load_players_data], kinds={"Soda"}, name="data_quality_checks_on_comp_players")
+def data_quality_checks_on_comp_players() -> None:
+ _run_soda_scan("checks_comp_players.yml")
+
+
+@dg.asset(deps=[load_players_data], kinds={"Soda"}, name="data_quality_checks_on_comp_rounds")
+def data_quality_checks_on_comp_rounds() -> None:
+ _run_soda_scan("checks_comp_rounds.yml")
+
+
+@dg.asset(deps=[load_players_data], kinds={"Soda"}, name="data_quality_checks_on_comp_vg_decklists")
+def data_quality_checks_on_comp_vg_decklists() -> None:
+ _run_soda_scan("checks_comp_vg_decklists.yml")
+
+
+@dg.asset(deps=[load_players_data], kinds={"Soda"}, name="data_quality_checks_on_comp_tcg_decklists")
+def data_quality_checks_on_comp_tcg_decklists() -> None:
+ _run_soda_scan("checks_comp_tcg_decklists.yml")
diff --git a/card_data/pipelines/defs/extract/limitless/extract_standings.py b/data_platform/pipelines/defs/extract/limitless/extract_standings.py
similarity index 100%
rename from card_data/pipelines/defs/extract/limitless/extract_standings.py
rename to data_platform/pipelines/defs/extract/limitless/extract_standings.py
diff --git a/card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py b/data_platform/pipelines/defs/extract/tcgcsv/extract_pricing.py
similarity index 100%
rename from card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py
rename to data_platform/pipelines/defs/extract/tcgcsv/extract_pricing.py
diff --git a/card_data/pipelines/defs/extract/tcgdex/extract_cards.py b/data_platform/pipelines/defs/extract/tcgdex/extract_cards.py
similarity index 100%
rename from card_data/pipelines/defs/extract/tcgdex/extract_cards.py
rename to data_platform/pipelines/defs/extract/tcgdex/extract_cards.py
diff --git a/card_data/pipelines/defs/extract/tcgdex/extract_series.py b/data_platform/pipelines/defs/extract/tcgdex/extract_series.py
similarity index 100%
rename from card_data/pipelines/defs/extract/tcgdex/extract_series.py
rename to data_platform/pipelines/defs/extract/tcgdex/extract_series.py
diff --git a/card_data/pipelines/defs/extract/tcgdex/extract_sets.py b/data_platform/pipelines/defs/extract/tcgdex/extract_sets.py
similarity index 100%
rename from card_data/pipelines/defs/extract/tcgdex/extract_sets.py
rename to data_platform/pipelines/defs/extract/tcgdex/extract_sets.py
diff --git a/card_data/pipelines/defs/load/limitless/load_standings.py b/data_platform/pipelines/defs/load/limitless/load_standings.py
similarity index 69%
rename from card_data/pipelines/defs/load/limitless/load_standings.py
rename to data_platform/pipelines/defs/load/limitless/load_standings.py
index a2d8900f..7c749264 100644
--- a/card_data/pipelines/defs/load/limitless/load_standings.py
+++ b/data_platform/pipelines/defs/load/limitless/load_standings.py
@@ -1,6 +1,7 @@
import dagster as dg
import polars as pl
from dagster import Backoff, RetryPolicy
+from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
from termcolor import colored
@@ -18,10 +19,13 @@ def load_standings_data(create_standings_dataframe: pl.DataFrame) -> None:
df = create_standings_dataframe
try:
- df.write_database(
- table_name=table_name, connection=database_url, if_table_exists="replace"
- )
+ engine = create_engine(database_url)
+ with engine.begin() as conn:
+ conn.execute(text(f"TRUNCATE TABLE {table_name}"))
+ df.write_database(
+ table_name=table_name, connection=conn, if_table_exists="append"
+ )
print(colored(" ✓", "green"), f"Data loaded into {table_name}")
except OperationalError as e:
- print(colored(" ✖", "red"), "Connection error in load_card_data():", e)
+ print(colored(" ✖", "red"), "Connection error in load_standings_data():", e)
raise
diff --git a/card_data/pipelines/defs/load/tcgcsv/load_pricing.py b/data_platform/pipelines/defs/load/tcgcsv/load_pricing.py
similarity index 100%
rename from card_data/pipelines/defs/load/tcgcsv/load_pricing.py
rename to data_platform/pipelines/defs/load/tcgcsv/load_pricing.py
diff --git a/card_data/pipelines/defs/load/tcgdex/load_cards.py b/data_platform/pipelines/defs/load/tcgdex/load_cards.py
similarity index 100%
rename from card_data/pipelines/defs/load/tcgdex/load_cards.py
rename to data_platform/pipelines/defs/load/tcgdex/load_cards.py
diff --git a/card_data/pipelines/defs/load/tcgdex/load_series.py b/data_platform/pipelines/defs/load/tcgdex/load_series.py
similarity index 100%
rename from card_data/pipelines/defs/load/tcgdex/load_series.py
rename to data_platform/pipelines/defs/load/tcgdex/load_series.py
diff --git a/card_data/pipelines/defs/load/tcgdex/load_sets.py b/data_platform/pipelines/defs/load/tcgdex/load_sets.py
similarity index 100%
rename from card_data/pipelines/defs/load/tcgdex/load_sets.py
rename to data_platform/pipelines/defs/load/tcgdex/load_sets.py
diff --git a/data_platform/pipelines/defs/pikalytics/pokemon_comp_info.py b/data_platform/pipelines/defs/pikalytics/pokemon_comp_info.py
new file mode 100644
index 00000000..24bd9fea
--- /dev/null
+++ b/data_platform/pipelines/defs/pikalytics/pokemon_comp_info.py
@@ -0,0 +1,25 @@
+import dagster as dg
+import requests
+from dagster import RetryPolicy, Backoff
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_n8n_webhook_secret
+from .usage import trigger_pikalytics_usage
+
+
+@dg.asset(
+ kinds={"n8n"},
+ deps=[trigger_pikalytics_usage],
+ retry_policy=RetryPolicy(max_retries=1, delay=5, backoff=Backoff.EXPONENTIAL),
+)
+def trigger_pikalytics_pokemon_comp_info() -> None:
+ """Trigger the n8n pokemon-comp-info workflow and block until it finishes. It reads the
+ fresh top-50 from staging.pikalytics_usage (hence the dependency on the usage trigger),
+ scrapes each Pokémon's AI pokedex page, and loads staging.pikalytics_pokemon_comp_info.
+ The webhook responds only when the last node completes; this is a long run (~50 page
+ scrapes), so the timeout is generous and retries are limited (a retry re-runs all 50)."""
+ webhook_url = fetch_n8n_webhook_secret("pikalytics-pokemon-comp-info")
+
+ resp = requests.post(webhook_url, timeout=1200)
+ resp.raise_for_status()
+ print(colored(" ✓", "green"), "n8n pokemon-comp-info workflow completed")
diff --git a/data_platform/pipelines/defs/pikalytics/speed_tiers.py b/data_platform/pipelines/defs/pikalytics/speed_tiers.py
new file mode 100644
index 00000000..d22e9459
--- /dev/null
+++ b/data_platform/pipelines/defs/pikalytics/speed_tiers.py
@@ -0,0 +1,21 @@
+import dagster as dg
+import requests
+from dagster import RetryPolicy, Backoff
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_n8n_webhook_secret
+
+
+@dg.asset(
+ kinds={"n8n"},
+ retry_policy=RetryPolicy(max_retries=2, delay=5, backoff=Backoff.EXPONENTIAL),
+)
+def trigger_pikalytics_speed_tiers() -> None:
+ """Trigger the n8n speed-tiers workflow (scrape → staging.pikalytics_speed_tiers)
+ and block until it finishes. The webhook is configured to respond only when the
+ last node completes, so a successful POST means staging is loaded."""
+ webhook_url = fetch_n8n_webhook_secret("pikalytics-speed-tier")
+
+ resp = requests.post(webhook_url, timeout=600)
+ resp.raise_for_status()
+ print(colored(" ✓", "green"), "n8n speed-tiers workflow completed")
diff --git a/data_platform/pipelines/defs/pikalytics/top_teams.py b/data_platform/pipelines/defs/pikalytics/top_teams.py
new file mode 100644
index 00000000..84e29f71
--- /dev/null
+++ b/data_platform/pipelines/defs/pikalytics/top_teams.py
@@ -0,0 +1,21 @@
+import dagster as dg
+import requests
+from dagster import RetryPolicy, Backoff
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_n8n_webhook_secret
+
+
+@dg.asset(
+ kinds={"n8n"},
+ retry_policy=RetryPolicy(max_retries=2, delay=5, backoff=Backoff.EXPONENTIAL),
+)
+def trigger_pikalytics_top_teams() -> None:
+ """Trigger the n8n top-teams workflow (scrape → staging.pikalytics_top_teams) and
+ block until it finishes. The webhook is configured to respond only when the last
+ node completes, so a successful POST means staging is loaded."""
+ webhook_url = fetch_n8n_webhook_secret("pikalytics-top-teams")
+
+ resp = requests.post(webhook_url, timeout=600)
+ resp.raise_for_status()
+ print(colored(" ✓", "green"), "n8n top-teams workflow completed")
diff --git a/data_platform/pipelines/defs/pikalytics/usage.py b/data_platform/pipelines/defs/pikalytics/usage.py
new file mode 100644
index 00000000..d36ee788
--- /dev/null
+++ b/data_platform/pipelines/defs/pikalytics/usage.py
@@ -0,0 +1,21 @@
+import dagster as dg
+import requests
+from dagster import RetryPolicy, Backoff
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_n8n_webhook_secret
+
+
+@dg.asset(
+ kinds={"n8n"},
+ retry_policy=RetryPolicy(max_retries=2, delay=5, backoff=Backoff.EXPONENTIAL),
+)
+def trigger_pikalytics_usage() -> None:
+ """Trigger the n8n usage workflow (scrape → staging.pikalytics_usage) and block
+ until it finishes. The webhook responds only when the last node completes, so a
+ successful POST means staging is loaded."""
+ webhook_url = fetch_n8n_webhook_secret("pikalytics-usage")
+
+ resp = requests.post(webhook_url, timeout=600)
+ resp.raise_for_status()
+ print(colored(" ✓", "green"), "n8n usage workflow completed")
diff --git a/data_platform/pipelines/defs/pokeapi/base_stats.py b/data_platform/pipelines/defs/pokeapi/base_stats.py
new file mode 100644
index 00000000..35a9d88f
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/base_stats.py
@@ -0,0 +1,36 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_secret
+
+RESOURCE_NAME: str = "stats"
+
+
+def create_dataframe(url: str) -> pl.DataFrame:
+ df = pl.read_csv(url)
+
+ return df
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_vg_stats() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = f"staging.vg_{RESOURCE_NAME}"
+ df = create_dataframe(
+ f"https://raw.githubusercontent.com/PokeAPI/pokeapi/master/data/v2/csv/{RESOURCE_NAME}.csv"
+ )
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(colored(" ✓", "green"), f"Data loaded into {table_name}")
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_vg_stats():", e)
+ raise
diff --git a/data_platform/pipelines/defs/pokeapi/base_types.py b/data_platform/pipelines/defs/pokeapi/base_types.py
new file mode 100644
index 00000000..88516a40
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/base_types.py
@@ -0,0 +1,36 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_secret
+
+RESOURCE_NAME: str = "types"
+
+
+def create_dataframe(url: str) -> pl.DataFrame:
+ df = pl.read_csv(url)
+
+ return df
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_vg_types() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = f"staging.vg_{RESOURCE_NAME}"
+ df = create_dataframe(
+ f"https://raw.githubusercontent.com/PokeAPI/pokeapi/master/data/v2/csv/{RESOURCE_NAME}.csv"
+ )
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(colored(" ✓", "green"), f"Data loaded into {table_name}")
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_vg_types():", e)
+ raise
diff --git a/data_platform/pipelines/defs/pokeapi/pokemon.py b/data_platform/pipelines/defs/pokeapi/pokemon.py
new file mode 100644
index 00000000..9f37a3aa
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/pokemon.py
@@ -0,0 +1,36 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_secret
+
+RESOURCE_NAME: str = "pokemon"
+
+
+def create_dataframe(url: str) -> pl.DataFrame:
+ df = pl.read_csv(url)
+
+ return df
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_pokemon() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = f"staging.{RESOURCE_NAME}"
+ df = create_dataframe(
+ f"https://raw.githubusercontent.com/PokeAPI/pokeapi/master/data/v2/csv/{RESOURCE_NAME}.csv"
+ )
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(colored(" ✓", "green"), f"Data loaded into {table_name}")
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_pokemon():", e)
+ raise
diff --git a/data_platform/pipelines/defs/pokeapi/pokemon_sprites.py b/data_platform/pipelines/defs/pokeapi/pokemon_sprites.py
new file mode 100644
index 00000000..8a4e33d5
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/pokemon_sprites.py
@@ -0,0 +1,70 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.json_retriever import fetch_json
+from ...utils.secret_retriever import fetch_secret
+
+API_BASE: str = "https://api.github.com/repos/PokeAPI/sprites"
+RAW_BASE: str = (
+ "https://raw.githubusercontent.com/PokeAPI/sprites/refs/heads/master/sprites/pokemon"
+)
+
+
+def list_sprite_urls(parent_path: str, dir_name: str, ext: str, url_dir: str) -> dict[int, str]:
+ """Map each Pokémon id to its raw sprite URL, for files named '.' in a
+ sprites subdirectory. The id is only used to line up each gif with its matching
+ png; it is not written to the table (dbt derives that from the URL columns).
+ Uses the Git Trees API because the Contents API caps listings at 1000 entries."""
+
+ parent = fetch_json(f"{API_BASE}/contents/{parent_path}?ref=master")
+ sha = next(entry["sha"] for entry in parent if entry["name"] == dir_name)
+ tree = fetch_json(f"{API_BASE}/git/trees/{sha}")["tree"]
+
+ suffix = f".{ext}"
+ return {
+ int(stem): f"{url_dir}/{node['path']}"
+ for node in tree
+ if node["type"] == "blob"
+ and node["path"].endswith(suffix)
+ and (stem := node["path"].removesuffix(suffix)).isdigit()
+ }
+
+
+def create_dataframe() -> pl.DataFrame:
+ gifs = list_sprite_urls(
+ "sprites/pokemon/other", "showdown", "gif", f"{RAW_BASE}/other/showdown"
+ )
+ pngs = list_sprite_urls("sprites", "pokemon", "png", RAW_BASE)
+ ids = sorted(i for i in set(gifs) | set(pngs) if i >= 1)
+
+ return pl.DataFrame(
+ {
+ "gif_sprite_url": [gifs.get(i) for i in ids],
+ "png_sprite_url": [pngs.get(i) for i in ids],
+ }
+ )
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_vg_pokemon_sprites() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = "staging.vg_pokemon_sprites"
+ df = create_dataframe()
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(
+ colored(" ✓", "green"),
+ f"Data loaded into {table_name} ({df.height} rows)",
+ )
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_vg_pokemon_sprites():", e)
+ raise
diff --git a/data_platform/pipelines/defs/pokeapi/pokemon_stats.py b/data_platform/pipelines/defs/pokeapi/pokemon_stats.py
new file mode 100644
index 00000000..1b4c9e14
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/pokemon_stats.py
@@ -0,0 +1,36 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_secret
+
+RESOURCE_NAME: str = "pokemon_stats"
+
+
+def create_dataframe(url: str) -> pl.DataFrame:
+ df = pl.read_csv(url)
+
+ return df
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_vg_pokemon_stats() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = f"staging.vg_{RESOURCE_NAME}"
+ df = create_dataframe(
+ f"https://raw.githubusercontent.com/PokeAPI/pokeapi/master/data/v2/csv/{RESOURCE_NAME}.csv"
+ )
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(colored(" ✓", "green"), f"Data loaded into {table_name}")
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_vg_pokemon_stats():", e)
+ raise
diff --git a/data_platform/pipelines/defs/pokeapi/pokemon_types.py b/data_platform/pipelines/defs/pokeapi/pokemon_types.py
new file mode 100644
index 00000000..219a401c
--- /dev/null
+++ b/data_platform/pipelines/defs/pokeapi/pokemon_types.py
@@ -0,0 +1,36 @@
+import dagster as dg
+import polars as pl
+from dagster import RetryPolicy, Backoff
+from sqlalchemy.exc import OperationalError
+from termcolor import colored
+
+from ...utils.secret_retriever import fetch_secret
+
+RESOURCE_NAME: str = "pokemon_types"
+
+
+def create_dataframe(url: str) -> pl.DataFrame:
+ df = pl.read_csv(url)
+
+ return df
+
+
+@dg.asset(
+ kinds={"Supabase", "Postgres"},
+ retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL),
+)
+def load_vg_pokemon_types() -> None:
+ database_url: str = fetch_secret()
+ table_name: str = f"staging.vg_{RESOURCE_NAME}"
+ df = create_dataframe(
+ f"https://raw.githubusercontent.com/PokeAPI/pokeapi/master/data/v2/csv/{RESOURCE_NAME}.csv"
+ )
+
+ try:
+ df.write_database(
+ table_name=table_name, connection=database_url, if_table_exists="replace"
+ )
+ print(colored(" ✓", "green"), f"Data loaded into {table_name}")
+ except OperationalError as e:
+ print(colored(" ✖", "red"), "Connection error in load_vg_pokemon_types():", e)
+ raise
diff --git a/card_data/pipelines/defs/transform/transform_data.py b/data_platform/pipelines/defs/transform/transform_data.py
similarity index 60%
rename from card_data/pipelines/defs/transform/transform_data.py
rename to data_platform/pipelines/defs/transform/transform_data.py
index 61f50d3d..f428fffe 100644
--- a/card_data/pipelines/defs/transform/transform_data.py
+++ b/data_platform/pipelines/defs/transform/transform_data.py
@@ -19,6 +19,21 @@ def get_asset_key(self, dbt_resource_props):
"cards": "load_card_data",
"pricing_data": "data_quality_checks_on_pricing",
"standings": "load_standings_data",
+ "comp_events": "data_quality_checks_on_comp_events",
+ "comp_players": "data_quality_checks_on_comp_players",
+ "comp_rounds": "data_quality_checks_on_comp_rounds",
+ "comp_vg_decklists": "data_quality_checks_on_comp_vg_decklists",
+ "comp_tcg_decklists": "data_quality_checks_on_comp_tcg_decklists",
+ "pokemon": "load_pokemon",
+ "vg_types": "load_vg_types",
+ "vg_stats": "load_vg_stats",
+ "vg_pokemon_types": "load_vg_pokemon_types",
+ "vg_pokemon_stats": "load_vg_pokemon_stats",
+ "vg_pokemon_sprites": "load_vg_pokemon_sprites",
+ "pikalytics_speed_tiers": "trigger_pikalytics_speed_tiers",
+ "pikalytics_usage": "trigger_pikalytics_usage",
+ "pikalytics_top_teams": "trigger_pikalytics_top_teams",
+ "pikalytics_pokemon_comp_info": "trigger_pikalytics_pokemon_comp_info",
}
if name in source_mapping:
return dg.AssetKey([source_mapping[name]])
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/data_platform/pipelines/poke_cli_dbt/dbt_project.yml
similarity index 95%
rename from card_data/pipelines/poke_cli_dbt/dbt_project.yml
rename to data_platform/pipelines/poke_cli_dbt/dbt_project.yml
index c8396ccc..99d8a3b5 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/data_platform/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.10.3'
+version: '2.0.0'
profile: 'poke_cli_dbt'
diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_relationships.sql b/data_platform/pipelines/poke_cli_dbt/macros/create_relationships.sql
similarity index 82%
rename from card_data/pipelines/poke_cli_dbt/macros/create_relationships.sql
rename to data_platform/pipelines/poke_cli_dbt/macros/create_relationships.sql
index 9efef9a1..da839d36 100644
--- a/card_data/pipelines/poke_cli_dbt/macros/create_relationships.sql
+++ b/data_platform/pipelines/poke_cli_dbt/macros/create_relationships.sql
@@ -1,4 +1,13 @@
{% macro create_relationships() %}
+ {% if not execute %}{% do return('') %}{% endif %}
+
+ {% set targets = ['cards', 'sets', 'series'] %}
+ {% set ran = [] %}
+ {% for res in results %}
+ {% if res.node.name in targets %}{% do ran.append(res.node.name) %}{% endif %}
+ {% endfor %}
+ {% if ran | length == 0 %}{% do return('') %}{% endif %}
+
{{ print("Dropping existing constraints...") }}
-- Drop existing constraints if they exist (in reverse dependency order)
diff --git a/data_platform/pipelines/poke_cli_dbt/macros/create_rls.sql b/data_platform/pipelines/poke_cli_dbt/macros/create_rls.sql
new file mode 100644
index 00000000..26fbdc9f
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/macros/create_rls.sql
@@ -0,0 +1,10 @@
+{% macro enable_rls(role='PUBLIC', policy_name='Enable Read Access for All Users') %}
+ ALTER TABLE {{ this }} ENABLE ROW LEVEL SECURITY;
+ DROP POLICY IF EXISTS "{{ policy_name }}" ON {{ this }};
+ CREATE POLICY "{{ policy_name }}"
+ ON {{ this }}
+ AS PERMISSIVE
+ FOR SELECT
+ TO {{ role }}
+ USING (true);
+{% endmacro %}
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql b/data_platform/pipelines/poke_cli_dbt/macros/create_view.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/macros/create_view.sql
rename to data_platform/pipelines/poke_cli_dbt/macros/create_view.sql
diff --git a/data_platform/pipelines/poke_cli_dbt/macros/resolve_pokemon_id.sql b/data_platform/pipelines/poke_cli_dbt/macros/resolve_pokemon_id.sql
new file mode 100644
index 00000000..e73b30e3
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/macros/resolve_pokemon_id.sql
@@ -0,0 +1,7 @@
+{% macro resolve_pokemon_id(slug_column) %}
+ COALESCE(
+ (SELECT p.id FROM {{ ref('pokemon') }} p WHERE p.identifier = {{ slug_column }}),
+ (SELECT p.id FROM {{ ref('pokemon') }} p
+ WHERE p.is_default = 1 AND p.identifier LIKE {{ slug_column }} || '-%' LIMIT 1)
+ )
+{% endmacro %}
diff --git a/card_data/pipelines/poke_cli_dbt/models/cards.sql b/data_platform/pipelines/poke_cli_dbt/models/cards.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/models/cards.sql
rename to data_platform/pipelines/poke_cli_dbt/models/cards.sql
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_events.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_events.sql
new file mode 100644
index 00000000..5b54836a
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_events.sql
@@ -0,0 +1,19 @@
+{{ config(
+ materialized='incremental',
+ unique_key=['pokedata_id', 'game_type'],
+ incremental_strategy='merge',
+ on_schema_change='append_new_columns'
+) }}
+
+SELECT
+ pokedata_id,
+ game_type,
+ name,
+ regexp_replace(name, '^(\d{4}\s+)?(.+?)\s+(Pok[ée]mon|Special Championships).*$', '\2') AS location,
+ start_date::date,
+ end_date::date,
+ season,
+ count,
+ rounds,
+ last_updated::timestamp
+FROM {{ source('staging', 'comp_events') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_players.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_players.sql
new file mode 100644
index 00000000..3d0992c8
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_players.sql
@@ -0,0 +1,23 @@
+-- depends_on: {{ ref('comp_events') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokedata_id', 'game_type', 'player_name'],
+ incremental_strategy='merge',
+ on_schema_change='append_new_columns'
+) }}
+
+SELECT
+ pokedata_id,
+ game_type,
+ player_name,
+ country,
+ placement,
+ wins,
+ losses,
+ ties,
+ resistance_self,
+ resistance_opp,
+ resistance_oppopp,
+ dropped_round,
+ trainer_name
+FROM {{ source('staging', 'comp_players') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_rounds.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_rounds.sql
new file mode 100644
index 00000000..76b705e2
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_rounds.sql
@@ -0,0 +1,17 @@
+-- depends_on: {{ ref('comp_players') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokedata_id', 'game_type', 'player_name', 'round_number'],
+ incremental_strategy='merge',
+ on_schema_change='append_new_columns'
+) }}
+
+SELECT
+ pokedata_id,
+ game_type,
+ player_name,
+ round_number,
+ opponent_name,
+ result,
+ NULLIF(TRIM(table_number), '')::int AS table_number
+FROM {{ source('staging', 'comp_rounds') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_decklists.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_decklists.sql
new file mode 100644
index 00000000..545d7db4
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_decklists.sql
@@ -0,0 +1,14 @@
+-- depends_on: {{ ref('comp_players') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokedata_id', 'game_type', 'player_name'],
+ incremental_strategy='merge',
+ on_schema_change='append_new_columns'
+) }}
+
+SELECT
+ pokedata_id,
+ game_type,
+ player_name,
+ decklist
+FROM {{ source('staging', 'comp_tcg_decklists') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_standings_view.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_standings_view.sql
new file mode 100644
index 00000000..6a22a6d0
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_tcg_standings_view.sql
@@ -0,0 +1,83 @@
+{{ config(
+ materialized='view',
+ post_hook=[
+ "ALTER VIEW {{ this }} SET (security_invoker = on)",
+ "GRANT SELECT ON {{ this }} TO anon, authenticated"
+ ]
+) }}
+
+WITH lookup AS ( -- pokedata_id -> Limitless tournament_id (TCG only)
+ SELECT ce.pokedata_id, COALESCE(loc.tid, sole.tid) AS tournament_id
+ FROM (
+ SELECT pokedata_id, name, start_date
+ FROM {{ ref('comp_events') }}
+ WHERE game_type = 'TCG'
+ ) ce
+ LEFT JOIN LATERAL ( -- tier 1: same date + city name appears in the event name
+ SELECT t.tournament_id AS tid
+ FROM {{ source('staging', 'tournaments') }} t
+ WHERE t.start_date = ce.start_date
+ AND ce.name ILIKE '%' || t.location || '%'
+ LIMIT 1
+ ) loc ON true
+ LEFT JOIN LATERAL ( -- tier 2: fallback to the sole tournament on that date
+ SELECT t.tournament_id AS tid
+ FROM {{ source('staging', 'tournaments') }} t
+ JOIN (
+ SELECT start_date
+ FROM {{ source('staging', 'tournaments') }}
+ GROUP BY start_date
+ HAVING count(*) = 1
+ ) u ON u.start_date = t.start_date
+ WHERE t.start_date = ce.start_date
+ LIMIT 1
+ ) sole ON true
+)
+
+SELECT
+ p.placement AS rank,
+ p.player_name AS name,
+ (p.wins * 3 + p.ties) AS points,
+ (p.wins || ' - ' || p.losses || ' - ' || p.ties) AS record,
+ to_char(p.resistance_opp * 100, 'FM990.00') || '%' AS opp_win_percent,
+ to_char(p.resistance_oppopp * 100, 'FM990.00') || '%' AS opp_opp_win_percent,
+ s.deck,
+ s.decklist,
+ cc.country_name AS player_country,
+ lower(p.country) AS country_code,
+ e.location,
+ e.start_date,
+ e.end_date,
+ CASE
+ WHEN e.start_date = e.end_date
+ THEN to_char(e.start_date, 'FMMonth FMDD') || ', ' || to_char(e.start_date, 'YYYY')
+ WHEN extract(month FROM e.start_date) = extract(month FROM e.end_date)
+ THEN to_char(e.start_date, 'FMMonth FMDD') || '–' || to_char(e.end_date, 'FMDD') || ', ' || to_char(e.end_date, 'YYYY')
+ ELSE to_char(e.start_date, 'FMMonth FMDD') || '–' || to_char(e.end_date, 'FMMonth FMDD') || ', ' || to_char(e.end_date, 'YYYY')
+ END AS text_date,
+ CASE
+ WHEN e.name ILIKE '%International%' THEN 'International'
+ WHEN e.name ILIKE '%Regional%' THEN 'Regional'
+ WHEN e.name ILIKE '%World%' THEN 'Worlds'
+ END AS type,
+ e.count AS player_quantity
+FROM {{ ref('comp_players') }} p
+JOIN {{ ref('comp_events') }} e
+ ON e.pokedata_id = p.pokedata_id AND e.game_type = p.game_type
+LEFT JOIN lookup lk ON lk.pokedata_id = p.pokedata_id
+LEFT JOIN LATERAL ( -- one deck/decklist per player; rank=placement disambiguates same-name players
+ SELECT s.deck, s.decklist
+ FROM {{ source('staging', 'standings') }} s
+ WHERE s.tournament_id = lk.tournament_id
+ AND s.name = p.player_name
+ ORDER BY (s.rank = p.placement) DESC, s.rank
+ LIMIT 1
+) s ON true
+LEFT JOIN {{ source('staging', 'country_codes') }} cc ON cc.code = p.country
+WHERE p.game_type = 'TCG'
+ AND e.season >= (
+ CASE WHEN extract(month FROM CURRENT_DATE) >= 9
+ THEN extract(year FROM CURRENT_DATE) + 1
+ ELSE extract(year FROM CURRENT_DATE)
+ END
+ )
\ No newline at end of file
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_vg_decklists.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_vg_decklists.sql
new file mode 100644
index 00000000..387a02c0
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_vg_decklists.sql
@@ -0,0 +1,14 @@
+-- depends_on: {{ ref('comp_players') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokedata_id', 'game_type', 'player_name'],
+ incremental_strategy='merge',
+ on_schema_change='sync_all_columns'
+) }}
+
+SELECT
+ pokedata_id,
+ game_type,
+ player_name,
+ decklist
+FROM {{ source('staging', 'comp_vg_decklists') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/comp_vgc_standings_view.sql b/data_platform/pipelines/poke_cli_dbt/models/comp_vgc_standings_view.sql
new file mode 100644
index 00000000..fdb63843
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/comp_vgc_standings_view.sql
@@ -0,0 +1,50 @@
+{{ config(
+ materialized='view',
+ post_hook=[
+ "ALTER VIEW {{ this }} SET (security_invoker = on)",
+ "GRANT SELECT ON {{ this }} TO anon, authenticated"
+ ]
+) }}
+
+SELECT
+ p.placement AS rank,
+ p.player_name AS name,
+ (p.wins * 3 + p.ties) AS points,
+ (p.wins || ' - ' || p.losses || ' - ' || p.ties) AS record,
+ to_char(p.resistance_opp * 100, 'FM990.00') || '%' AS opp_win_percent,
+ to_char(p.resistance_oppopp * 100, 'FM990.00') || '%' AS opp_opp_win_percent,
+ d.decklist AS team,
+ cc.country_name AS player_country,
+ lower(p.country) AS country_code,
+ e.location,
+ e.start_date,
+ e.end_date,
+ CASE
+ WHEN e.start_date = e.end_date
+ THEN to_char(e.start_date, 'FMMonth FMDD') || ', ' || to_char(e.start_date, 'YYYY')
+ WHEN extract(month FROM e.start_date) = extract(month FROM e.end_date)
+ THEN to_char(e.start_date, 'FMMonth FMDD') || '–' || to_char(e.end_date, 'FMDD') || ', ' || to_char(e.end_date, 'YYYY')
+ ELSE to_char(e.start_date, 'FMMonth FMDD') || '–' || to_char(e.end_date, 'FMMonth FMDD') || ', ' || to_char(e.end_date, 'YYYY')
+ END AS text_date,
+ CASE
+ WHEN e.name ILIKE '%International%' THEN 'International'
+ WHEN e.name ILIKE '%Regional%' THEN 'Regional'
+ WHEN e.name ILIKE '%Special%' THEN 'Special'
+ WHEN e.name ILIKE '%World%' THEN 'Worlds'
+ END AS type,
+ e.count AS player_quantity
+FROM {{ ref('comp_players') }} p
+JOIN {{ ref('comp_events') }} e
+ ON e.pokedata_id = p.pokedata_id AND e.game_type = p.game_type
+LEFT JOIN {{ ref('comp_vg_decklists') }} d
+ ON d.pokedata_id = p.pokedata_id
+ AND d.game_type = p.game_type
+ AND d.player_name = p.player_name
+LEFT JOIN {{ source('staging', 'country_codes') }} cc ON cc.code = p.country
+WHERE p.game_type = 'VGC'
+ AND e.season >= (
+ CASE WHEN extract(month FROM CURRENT_DATE) >= 9
+ THEN extract(year FROM CURRENT_DATE) + 1
+ ELSE extract(year FROM CURRENT_DATE)
+ END
+ )
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pikalytics_pokemon_comp_info.sql b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_pokemon_comp_info.sql
new file mode 100644
index 00000000..ca03fc18
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_pokemon_comp_info.sql
@@ -0,0 +1,30 @@
+{{ config(
+ materialized='incremental',
+ pre_hook="truncate table {{ this }}"
+) }}
+
+WITH staged AS (
+ SELECT
+ pokemon,
+ web_url,
+ common_moves,
+ common_abilities,
+ common_items,
+ common_teammates,
+ TRIM(BOTH '-' FROM LOWER(REGEXP_REPLACE(pokemon, '[^a-zA-Z0-9]+', '-', 'g'))) AS pokemon_slug
+ FROM {{ source('staging', 'pikalytics_pokemon_comp_info') }}
+)
+
+SELECT
+ 'gen9championsvgc2026regma' AS format,
+ pokemon,
+ pokemon_slug,
+ {{ resolve_pokemon_id('pokemon_slug') }} AS pokemon_id,
+ web_url,
+ common_moves,
+ common_abilities,
+ common_items,
+ common_teammates,
+ 'pikalytics' AS source
+FROM staged
+ORDER BY pokemon
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pikalytics_speed_tiers.sql b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_speed_tiers.sql
new file mode 100644
index 00000000..18298c3e
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_speed_tiers.sql
@@ -0,0 +1,57 @@
+{{ config(
+ materialized='incremental',
+ pre_hook="truncate table {{ this }}"
+) }}
+
+WITH staged AS (
+ SELECT
+ format,
+ rank,
+ pokemon,
+ base_spe,
+ TRIM(BOTH '-' FROM LOWER(REGEXP_REPLACE(pokemon, '[^a-zA-Z0-9]+', '-', 'g'))) AS pokemon_slug
+ FROM {{ source('staging', 'pikalytics_speed_tiers') }}
+),
+
+tiers AS (
+ SELECT
+ format,
+ rank,
+ pokemon,
+ pokemon_slug,
+ -- internal only (not selected): normalize the naive slug to the hub form to resolve the id
+ CASE
+ WHEN pokemon_slug ~ '^mega-.*-[xy]$' THEN REGEXP_REPLACE(pokemon_slug, '^mega-(.*)-([xy])$', '\1-mega-\2')
+ WHEN pokemon_slug LIKE 'mega-%' THEN REGEXP_REPLACE(pokemon_slug, '^mega-(.*)$', '\1-mega')
+ WHEN pokemon_slug = 'basculegion-f' THEN 'basculegion-female'
+ ELSE pokemon_slug
+ END AS hub_slug,
+ base_spe,
+ base_spe + 20 AS neutral_0_sp,
+ base_spe + 52 AS neutral_32_sp,
+ FLOOR((base_spe + 52) * 1.1)::int AS max_speed
+ FROM staged
+)
+
+SELECT
+ format,
+ rank,
+ pokemon,
+ pokemon_slug,
+ {{ resolve_pokemon_id('hub_slug') }} AS pokemon_id,
+ base_spe,
+ neutral_0_sp,
+ neutral_32_sp,
+ FLOOR(neutral_0_sp * 0.9)::int AS neg_spe_0_sp,
+ max_speed,
+ FLOOR(max_speed * 1.5)::int AS max_scarf,
+ FLOOR(neutral_32_sp * 1.5)::int AS neutral_32_scarf,
+ pokemon ILIKE 'mega %' AS is_mega,
+ CASE
+ WHEN base_spe >= 100 THEN 'fast'
+ WHEN base_spe >= 60 THEN 'mid'
+ ELSE 'slow'
+ END AS speed_bucket,
+ 'pikalytics' AS source
+FROM tiers
+ORDER BY rank
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pikalytics_top_teams.sql b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_top_teams.sql
new file mode 100644
index 00000000..19dc6c25
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_top_teams.sql
@@ -0,0 +1,50 @@
+{{ config(
+ materialized='incremental',
+ pre_hook="truncate table {{ this }}"
+) }}
+
+WITH staged AS (
+ SELECT
+ rank,
+ author,
+ record,
+ tournament,
+ archetypes,
+ pokemon,
+ web_url
+ FROM {{ source('staging', 'pikalytics_top_teams') }}
+),
+
+record_parsed AS (
+ SELECT
+ s.*,
+ (
+ SELECT ARRAY_AGG(m[1]::int)
+ FROM REGEXP_MATCHES(s.record, '[0-9]+', 'g') AS m
+ ) AS record_nums
+ FROM staged s
+)
+
+SELECT
+ 'gen9championsvgc2026regma' AS format,
+ rank,
+ author,
+ record,
+ record_nums[1] AS wins,
+ record_nums[2] AS losses,
+ record_nums[3] AS ties,
+ tournament,
+ archetypes,
+ pokemon,
+ (
+ SELECT JSONB_AGG(
+ {{ resolve_pokemon_id("TRIM(BOTH '-' FROM LOWER(REGEXP_REPLACE(elem, '[^a-zA-Z0-9]+', '-', 'g')))") }}
+ ORDER BY ord
+ )
+ FROM JSONB_ARRAY_ELEMENTS_TEXT(pokemon) WITH ORDINALITY AS t(elem, ord)
+ WHERE TRIM(elem) <> ''
+ ) AS pokemon_ids,
+ web_url,
+ 'pikalytics' AS source
+FROM record_parsed
+ORDER BY rank
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pikalytics_usage.sql b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_usage.sql
new file mode 100644
index 00000000..e9987541
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pikalytics_usage.sql
@@ -0,0 +1,26 @@
+{{ config(
+ materialized='incremental',
+ pre_hook="truncate table {{ this }}"
+) }}
+
+WITH staged AS (
+ SELECT
+ rank,
+ pokemon,
+ usage_percent,
+ web_url,
+ TRIM(BOTH '-' FROM LOWER(REGEXP_REPLACE(pokemon, '[^a-zA-Z0-9]+', '-', 'g'))) AS pokemon_slug
+ FROM {{ source('staging', 'pikalytics_usage') }}
+)
+
+SELECT
+ 'gen9championsvgc2026regma' AS format,
+ rank,
+ pokemon,
+ pokemon_slug,
+ {{ resolve_pokemon_id('pokemon_slug') }} AS pokemon_id,
+ usage_percent,
+ web_url,
+ 'pikalytics' AS source
+FROM staged
+ORDER BY rank
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pokedex_view.sql b/data_platform/pipelines/poke_cli_dbt/models/pokedex_view.sql
new file mode 100644
index 00000000..1c0ecbb4
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pokedex_view.sql
@@ -0,0 +1,37 @@
+{{ config(
+ materialized='view',
+ post_hook=[
+ "ALTER VIEW {{ this }} SET (security_invoker = on)",
+ "GRANT SELECT ON {{ this }} TO anon, authenticated"
+ ]
+) }}
+
+SELECT
+ p.id AS pokemon_id,
+ p.id AS dex_number,
+ p.identifier AS slug,
+ INITCAP(REPLACE(p.identifier, '-', ' ')) AS name,
+ s.gif_sprite_url,
+ s.png_sprite_url,
+ COALESCE(
+ JSONB_AGG(
+ JSONB_BUILD_OBJECT(
+ 'slot', pt.slot,
+ 'name', t.identifier
+ )
+ ORDER BY pt.slot
+ ) FILTER (WHERE t.identifier IS NOT NULL),
+ '[]'::JSONB
+ ) AS types
+FROM {{ ref('pokemon') }} p
+LEFT JOIN {{ ref('vg_pokemon_sprites') }} s
+ ON s.pokemon_id = p.id
+LEFT JOIN {{ ref('vg_pokemon_types') }} pt
+ ON pt.pokemon_id = p.id
+LEFT JOIN {{ ref('vg_types') }} t
+ ON t.id = pt.type_id
+GROUP BY
+ p.id,
+ p.identifier,
+ s.gif_sprite_url,
+ s.png_sprite_url
diff --git a/data_platform/pipelines/poke_cli_dbt/models/pokemon.sql b/data_platform/pipelines/poke_cli_dbt/models/pokemon.sql
new file mode 100644
index 00000000..e65587e2
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/pokemon.sql
@@ -0,0 +1,17 @@
+{{ config(
+ materialized='incremental',
+ unique_key='id',
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ id,
+ identifier,
+ species_id,
+ height,
+ weight,
+ base_experience,
+ "order",
+ is_default
+FROM {{ source('staging', 'pokemon') }}
diff --git a/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql b/data_platform/pipelines/poke_cli_dbt/models/pricing_data.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/models/pricing_data.sql
rename to data_platform/pipelines/poke_cli_dbt/models/pricing_data.sql
diff --git a/card_data/pipelines/poke_cli_dbt/models/series.sql b/data_platform/pipelines/poke_cli_dbt/models/series.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/models/series.sql
rename to data_platform/pipelines/poke_cli_dbt/models/series.sql
diff --git a/card_data/pipelines/poke_cli_dbt/models/sets.sql b/data_platform/pipelines/poke_cli_dbt/models/sets.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/models/sets.sql
rename to data_platform/pipelines/poke_cli_dbt/models/sets.sql
diff --git a/data_platform/pipelines/poke_cli_dbt/models/sources.yml b/data_platform/pipelines/poke_cli_dbt/models/sources.yml
new file mode 100644
index 00000000..c9ac2f7f
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/sources.yml
@@ -0,0 +1,404 @@
+version: 2
+
+sources:
+ - name: staging
+ description: "Staging schema containing raw data loaded from extract pipeline"
+ tables:
+ - name: series
+ description: "Pokemon card series data"
+ columns:
+ - name: id
+ description: "Unique series identifier"
+ - name: name
+ description: "Series name"
+ - name: logo
+ description: "Series logo URL"
+
+ - name: sets
+ description: "Pokemon card sets data"
+ columns:
+ - name: series_id
+ description: "Foreign key to series"
+ - name: set_id
+ description: "Unique set identifier"
+ - name: set_name
+ description: "Set name"
+ - name: official_card_count
+ description: "Official number of cards in set"
+ - name: total_card_count
+ description: "Total number of cards including variants"
+ - name: logo
+ description: "Set logo URL"
+ - name: symbol
+ description: "Set symbol URL"
+
+ - name: cards
+ description: "Pokemon cards data"
+ columns:
+ - name: id
+ description: "Unique card identifier"
+ - name: name
+ description: "Card name"
+ - name: category
+ description: "Card category (Pokemon, Trainer, Energy)"
+ - name: hp
+ description: "Card HP value"
+ - name: image
+ description: "Card image URL"
+ - name: rarity
+ description: "Card rarity"
+ - name: types
+ description: "Card types (comma-separated)"
+ - name: localId
+ description: "Card local ID within the set"
+ - name: illustrator
+ description: "Card illustrator"
+ - name: regulationMark
+ description: "Regulation mark"
+ - name: retreat
+ description: "Retreat cost (numeric)"
+ - name: stage
+ description: "Evolution stage"
+ - name: legal_standard
+ description: "Legal status in Standard"
+ - name: legal_expanded
+ description: "Legal status in Expanded"
+ - name: set_cardCount_official
+ description: "Official card count for the set"
+ - name: set_cardCount_total
+ description: "Total card count including variants"
+ - name: set_id
+ description: "Foreign key to set"
+ - name: set_logo
+ description: "Set logo URL"
+ - name: set_name
+ description: "Set name"
+ - name: set_symbol
+ description: "Set symbol URL"
+ - name: attacks_json
+ description: "Raw attacks JSON"
+ - name: attack_1_name
+ description: "First attack name"
+ - name: attack_1_damage
+ description: "First attack damage"
+ - name: attack_1_effect
+ description: "First attack effect"
+ - name: attack_1_cost
+ description: "First attack energy cost"
+ - name: attack_2_name
+ description: "Second attack name"
+ - name: attack_2_damage
+ description: "Second attack damage"
+ - name: attack_2_effect
+ description: "Second attack effect"
+ - name: attack_2_cost
+ description: "Second attack energy cost"
+ - name: attack_3_name
+ description: "Third attack name"
+ - name: attack_3_damage
+ description: "Third attack damage"
+ - name: attack_3_effect
+ description: "Third attack effect"
+ - name: attack_3_cost
+ description: "Third attack energy cost"
+
+ - name: standings
+ description: "Player standings data for tournaments"
+ columns:
+ - name: rank
+ description: "Player rank in the tournament"
+ - name: name
+ description: "Player name"
+ - name: points
+ description: "Player points"
+ - name: record
+ description: "Player win/loss record"
+ - name: opp_win_percent
+ description: "Opponent win percentage"
+ - name: opp_opp_win_percent
+ description: "Opponent's opponent win percentage"
+ - name: deck
+ description: "Deck name used by player"
+ - name: decklist
+ description: "Decklist URL"
+ - name: country
+ description: "Player country code"
+ - name: tournament_id
+ description: "Foreign key to tournaments"
+
+ - name: tournaments
+ description: "Tournament metadata"
+ columns:
+ - name: tournament_id
+ description: "Unique tournament identifier"
+ - name: location
+ description: "Tournament location"
+ - name: start_date
+ description: "Tournament start date"
+ - name: end_date
+ description: "Tournament end date"
+ - name: type
+ description: "Tournament type"
+ - name: player_quantity
+ description: "Number of players in the tournament"
+ - name: text_date
+ description: "Tournament date in text format"
+ - name: country_code
+ description: "Country ISO code"
+ - name: logo
+ description: "Tournament type logo URL"
+ - name: latitude
+ description: "Tournament city latitude"
+ - name: longitude
+ description: "Tournament city longitude"
+
+ - name: country_codes
+ description: "Country code to country name mapping with geographic centroids"
+ columns:
+ - name: code
+ description: "ISO country code"
+ - name: country_name
+ description: "Full country name"
+
+ - name: pricing_data
+ description: "Card pricing data"
+ meta:
+ dagster:
+ asset_key: ["load_pricing_data"]
+ columns:
+ - name: product_id
+ description: "Product ID"
+ - name: name
+ description: "Card name"
+ - name: card_number
+ description: "Card number"
+ - name: market_price
+ description: "Market price"
+
+ - name: comp_events
+ description: "Masters-division TCG and VGC tournament events from pokedata.ovh"
+ columns:
+ - name: pokedata_id
+ description: "Pokedata event ID"
+ - name: game_type
+ description: "Game type (TCG or VGC)"
+ - name: name
+ description: "Tournament name"
+ - name: start_date
+ description: "Event start date"
+ - name: end_date
+ description: "Event end date"
+ - name: season
+ description: "Competitive season (e.g. 2026)"
+ - name: count
+ description: "Masters division player count"
+ - name: rounds
+ description: "Masters division round count"
+ - name: last_updated
+ description: "Timestamp of last data update from pokedata.ovh"
+
+ - name: comp_players
+ description: "Masters-division players for each tournament event"
+ columns:
+ - name: pokedata_id
+ description: "Pokedata event ID"
+ - name: game_type
+ description: "Game type (TCG or VGC)"
+ - name: player_name
+ description: "Player name (country suffix stripped)"
+ - name: country
+ description: "Player country code (e.g. US, BR, JP)"
+ - name: placement
+ description: "Final placement in the tournament"
+ - name: wins
+ description: "Match wins"
+ - name: losses
+ description: "Match losses"
+ - name: ties
+ description: "Match ties"
+ - name: resistance_self
+ description: "Win percentage of the player"
+ - name: resistance_opp
+ description: "Average opponent win percentage"
+ - name: resistance_oppopp
+ description: "Average opponent's-opponent win percentage"
+ - name: dropped_round
+ description: "Round the player dropped (-1 if never dropped)"
+ - name: trainer_name
+ description: "In-game trainer name (VG only)"
+
+ - name: comp_rounds
+ description: "Per-round results for each player in each tournament"
+ columns:
+ - name: pokedata_id
+ description: "Pokedata event ID"
+ - name: game_type
+ description: "Game type (TCG or VGC)"
+ - name: player_name
+ description: "Player name (country suffix stripped)"
+ - name: round_number
+ description: "Round number"
+ - name: opponent_name
+ description: "Opponent display name (may include country, or 'BYE')"
+ - name: result
+ description: "Round result (W/L/T)"
+ - name: table_number
+ description: "Table number (raw text, trimmed and cast to int in dbt model)"
+
+ - name: comp_vg_decklists
+ description: "VG decklists: one row per player, full decklist stored as JSONB array of 6 Pokemon"
+ columns:
+ - name: pokedata_id
+ description: "Pokedata event ID"
+ - name: game_type
+ description: "Game type (VGC)"
+ - name: player_name
+ description: "Player name (country suffix stripped)"
+ - name: decklist
+ description: "JSONB array of 6 Pokemon: [{id, name, teratype, ability, item, badges[4 moves]}]"
+
+ - name: comp_tcg_decklists
+ description: "TCG decklists: one row per player, full decklist stored as JSONB object with pokemon/trainer/energy arrays"
+ columns:
+ - name: pokedata_id
+ description: "Pokedata event ID"
+ - name: game_type
+ description: "Game type (TCG)"
+ - name: player_name
+ description: "Player name (country suffix stripped)"
+ - name: decklist
+ description: "JSONB object with shape {pokemon: [{count, name, number, set}], trainer: [...], energy: [...]}"
+
+ - name: pokemon
+ description: "Raw PokéAPI pokemon data (shared TCG + VG hub; includes alternate forms with is_default = 0)"
+ columns:
+ - name: id
+ description: "Unique pokemon identifier (alternate forms use 10000+ ids)"
+ - name: identifier
+ description: "Pokemon name slug (e.g. bulbasaur)"
+ - name: species_id
+ description: "Foreign key to pokemon_species (not currently loaded)"
+ - name: height
+ description: "Height in decimetres"
+ - name: weight
+ description: "Weight in hectograms"
+ - name: base_experience
+ description: "Base experience yield"
+ - name: order
+ description: "National Pokédex sort order (reserved word; quote as \"order\" in SQL)"
+ - name: is_default
+ description: "1 for the default form, 0 for alternate forms (megas, regional forms)"
+
+ - name: vg_types
+ description: "Raw PokéAPI types lookup (Fire, Water, etc.)"
+ columns:
+ - name: id
+ description: "Unique type identifier"
+ - name: identifier
+ description: "Type name slug (e.g. fire)"
+ - name: generation_id
+ description: "Generation the type was introduced"
+ - name: damage_class_id
+ description: "Legacy physical/special damage class (pre-Gen IV)"
+
+ - name: vg_stats
+ description: "Raw PokéAPI stats lookup (hp, attack, etc.)"
+ columns:
+ - name: id
+ description: "Unique stat identifier"
+ - name: damage_class_id
+ description: "Damage class the stat relates to (nullable, e.g. hp)"
+ - name: identifier
+ description: "Stat name slug (e.g. attack)"
+ - name: is_battle_only
+ description: "1 if the stat exists only in battle (e.g. accuracy, evasion)"
+ - name: game_index
+ description: "Internal stat ordering used by the games"
+
+ - name: vg_pokemon_types
+ description: "Raw PokéAPI pokemon-to-type bridge (many-to-many)"
+ columns:
+ - name: pokemon_id
+ description: "Foreign key to pokemon"
+ - name: type_id
+ description: "Foreign key to types"
+ - name: slot
+ description: "Type slot (1 = primary, 2 = secondary)"
+
+ - name: vg_pokemon_stats
+ description: "Raw PokéAPI pokemon-to-stat bridge with base stat values"
+ columns:
+ - name: pokemon_id
+ description: "Foreign key to pokemon"
+ - name: stat_id
+ description: "Foreign key to stats"
+ - name: base_stat
+ description: "Base value of the stat for this pokemon"
+ - name: effort
+ description: "Effort value (EV) yield for defeating this pokemon"
+
+ - name: vg_pokemon_sprites
+ description: "Raw PokéAPI sprite URLs (one row per pokemon id, aligned from the GitHub sprite repo)"
+ columns:
+ - name: gif_sprite_url
+ description: "Animated Showdown gif URL (null if no gif exists for the id)"
+ - name: png_sprite_url
+ description: "Static png URL fallback (null if no png exists for the id)"
+
+ - name: pikalytics_speed_tiers
+ description: "Raw Pikalytics speed-tier rows scraped by n8n (pikalytics.com/ai/speed-tiers), full-replaced each run; tier math is derived downstream in the pikalytics_speed_tiers dbt model"
+ columns:
+ - name: format
+ description: "Format code (e.g. gen9championsvgc2026)"
+ - name: rank
+ description: "Speed tier rank within the format"
+ - name: pokemon
+ description: "Pokémon display name (proper case, includes Mega prefix where applicable)"
+ - name: base_spe
+ description: "Base Speed stat (the only scraped numeric; all other tiers derived in dbt)"
+
+ - name: pikalytics_usage
+ description: "Raw Pikalytics usage rows scraped by n8n (pikalytics.com/ai/pokedex/), full-replaced each run; pokemon_slug + pokemon_id derived downstream in the pikalytics_usage dbt model"
+ columns:
+ - name: rank
+ description: "Usage rank within the format"
+ - name: pokemon
+ description: "Pokémon display name (proper case, includes form suffix e.g. Charizard-Mega-Y)"
+ - name: usage_percent
+ description: "Usage percentage"
+ - name: web_url
+ description: "Pikalytics pokedex page URL for the Pokémon"
+
+ - name: pikalytics_top_teams
+ description: "Raw Pikalytics top-teams rows scraped by n8n (pikalytics.com/ai/top-teams/), full-replaced each run; wins/losses/ties, pokemon_ids, and format/source derived downstream in the pikalytics_top_teams dbt model"
+ columns:
+ - name: rank
+ description: "Team rank within the format"
+ - name: author
+ description: "Team author / player name"
+ - name: record
+ description: "Win/loss/tie record as scraped text (e.g. 7-2-1)"
+ - name: tournament
+ description: "Tournament name (may contain punctuation or vertical bars)"
+ - name: archetypes
+ description: "JSONB array of archetype label strings (empty array if none)"
+ - name: pokemon
+ description: "JSONB array of Pokémon display-name strings on the team"
+ - name: web_url
+ description: "Pikalytics top-teams page URL"
+
+ - name: pikalytics_pokemon_comp_info
+ description: "Raw Pikalytics per-Pokémon competitive info scraped by n8n from each top-50 usage Pokémon's AI pokedex page (full-replaced each run); pokemon_slug + pokemon_id and format/source derived downstream in the pikalytics_pokemon_comp_info dbt model"
+ columns:
+ - name: pokemon
+ description: "Pokémon display name (carried from the staging.pikalytics_usage list)"
+ - name: web_url
+ description: "Pikalytics pokedex page URL for the Pokémon"
+ - name: common_moves
+ description: "JSONB array of {name, usage_percent} for the Pokémon's common moves"
+ - name: common_abilities
+ description: "JSONB array of {name, usage_percent} for common abilities"
+ - name: common_items
+ description: "JSONB array of {name, usage_percent} for common held items"
+ - name: common_teammates
+ description: "JSONB array of {name, usage_percent} for common teammates"
\ No newline at end of file
diff --git a/card_data/pipelines/poke_cli_dbt/models/standings.sql b/data_platform/pipelines/poke_cli_dbt/models/standings.sql
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/models/standings.sql
rename to data_platform/pipelines/poke_cli_dbt/models/standings.sql
diff --git a/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_sprites.sql b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_sprites.sql
new file mode 100644
index 00000000..fbd828f4
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_sprites.sql
@@ -0,0 +1,16 @@
+-- depends_on: {{ ref('pokemon') }}
+{{ config(
+ materialized='incremental',
+ unique_key='pokemon_id',
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ COALESCE(
+ substring(gif_sprite_url FROM '/(\d+)\.gif$'),
+ substring(png_sprite_url FROM '/(\d+)\.png$')
+ )::int AS pokemon_id,
+ gif_sprite_url,
+ png_sprite_url
+FROM {{ source('staging', 'vg_pokemon_sprites') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_stats.sql b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_stats.sql
new file mode 100644
index 00000000..46e7c5ab
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_stats.sql
@@ -0,0 +1,14 @@
+-- depends_on: {{ ref('pokemon') }}
+-- depends_on: {{ ref('vg_stats') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokemon_id', 'stat_id'],
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ pokemon_id,
+ stat_id,
+ base_stat
+FROM {{ source('staging', 'vg_pokemon_stats') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_types.sql b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_types.sql
new file mode 100644
index 00000000..c5be4263
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/vg_pokemon_types.sql
@@ -0,0 +1,14 @@
+-- depends_on: {{ ref('pokemon') }}
+-- depends_on: {{ ref('vg_types') }}
+{{ config(
+ materialized='incremental',
+ unique_key=['pokemon_id', 'type_id'],
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ pokemon_id,
+ type_id,
+ slot
+FROM {{ source('staging', 'vg_pokemon_types') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/vg_stats.sql b/data_platform/pipelines/poke_cli_dbt/models/vg_stats.sql
new file mode 100644
index 00000000..cec1d56f
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/vg_stats.sql
@@ -0,0 +1,11 @@
+{{ config(
+ materialized='incremental',
+ unique_key='id',
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ id,
+ identifier
+FROM {{ source('staging', 'vg_stats') }}
diff --git a/data_platform/pipelines/poke_cli_dbt/models/vg_types.sql b/data_platform/pipelines/poke_cli_dbt/models/vg_types.sql
new file mode 100644
index 00000000..ed022786
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/models/vg_types.sql
@@ -0,0 +1,11 @@
+{{ config(
+ materialized='incremental',
+ unique_key='id',
+ incremental_strategy='merge',
+ post_hook="{{ enable_rls(role='authenticated', policy_name='Enable Read Access for Authenticated Users') }}"
+) }}
+
+SELECT
+ id,
+ identifier
+FROM {{ source('staging', 'vg_types') }}
diff --git a/card_data/pipelines/poke_cli_dbt/profiles.yml b/data_platform/pipelines/poke_cli_dbt/profiles.yml
similarity index 100%
rename from card_data/pipelines/poke_cli_dbt/profiles.yml
rename to data_platform/pipelines/poke_cli_dbt/profiles.yml
diff --git a/data_platform/pipelines/poke_cli_dbt/tests/comp_events_location_unparsed.sql b/data_platform/pipelines/poke_cli_dbt/tests/comp_events_location_unparsed.sql
new file mode 100644
index 00000000..c54b6546
--- /dev/null
+++ b/data_platform/pipelines/poke_cli_dbt/tests/comp_events_location_unparsed.sql
@@ -0,0 +1,7 @@
+SELECT
+ pokedata_id,
+ game_type,
+ name,
+ location
+FROM {{ ref('comp_events') }}
+WHERE location LIKE '%Championships%'
diff --git a/card_data/pipelines/sensors.py b/data_platform/pipelines/sensors.py
similarity index 93%
rename from card_data/pipelines/sensors.py
rename to data_platform/pipelines/sensors.py
index d100f5cd..37e1d712 100644
--- a/card_data/pipelines/sensors.py
+++ b/data_platform/pipelines/sensors.py
@@ -9,7 +9,7 @@ def discord_success_sensor(context: RunStatusSensorContext):
context.log.info(f"Detected successful run: {context.dagster_run.run_id}")
try:
response = requests.post(
- fetch_n8n_webhook_secret(),
+ fetch_n8n_webhook_secret("dagster-job-alert"),
json={
"job_name": context.dagster_run.job_name,
"status": "SUCCESS",
@@ -29,7 +29,7 @@ def discord_failure_sensor(context: RunStatusSensorContext):
context.log.info(f"Detected failed run: {context.dagster_run.run_id}")
try:
response = requests.post(
- fetch_n8n_webhook_secret(),
+ fetch_n8n_webhook_secret("dagster-job-alert"),
json={
"job_name": context.dagster_run.job_name,
"status": "FAILURE",
diff --git a/data_platform/pipelines/soda/checks_comp_events.yml b/data_platform/pipelines/soda/checks_comp_events.yml
new file mode 100644
index 00000000..bdf34cce
--- /dev/null
+++ b/data_platform/pipelines/soda/checks_comp_events.yml
@@ -0,0 +1,44 @@
+checks for comp_events:
+ - row_count > 60:
+ name: Minimum row count check
+
+ - schema:
+ fail:
+ when required column missing: [pokedata_id, game_type, name, start_date, end_date, season, count, rounds, last_updated]
+ when wrong column type:
+ start_date: text
+ end_date: text
+ season: bigint
+ count: bigint
+ rounds: bigint
+
+ - missing_count(pokedata_id) = 0:
+ name: Pokedata ID completeness
+
+ - missing_count(game_type) = 0:
+ name: Game type completeness
+
+ - missing_count(name) = 0:
+ name: Name completeness
+
+ - missing_count(start_date) = 0:
+ name: Start date completeness
+
+ - missing_count(end_date) = 0:
+ name: End date completeness
+
+ - missing_count(season) = 0:
+ name: Season completeness
+
+ - duplicate_count(pokedata_id, game_type) = 0:
+ name: Pokedata ID + game type uniqueness
+
+ - invalid_count(season) = 0:
+ valid min: 2025
+ valid max: 2030
+ name: Season range validation
+
+ - invalid_count(count) = 0:
+ valid min: 1
+ name: Masters count positive
+
diff --git a/data_platform/pipelines/soda/checks_comp_players.yml b/data_platform/pipelines/soda/checks_comp_players.yml
new file mode 100644
index 00000000..078750bf
--- /dev/null
+++ b/data_platform/pipelines/soda/checks_comp_players.yml
@@ -0,0 +1,49 @@
+checks for comp_players:
+ - schema:
+ fail:
+ when required column missing: [pokedata_id, game_type, player_name, placement, wins, losses, ties, resistance_self, resistance_opp, resistance_oppopp, dropped_round]
+ when wrong column type:
+ pokedata_id: text
+ game_type: text
+ player_name: text
+ country: text
+ placement: bigint
+ wins: bigint
+ losses: bigint
+ ties: bigint
+ resistance_self: numeric
+ resistance_opp: numeric
+ resistance_oppopp: numeric
+ dropped_round: bigint
+ trainer_name: text
+
+ - missing_count(pokedata_id) = 0:
+ name: Pokedata ID completeness
+
+ - missing_count(game_type) = 0:
+ name: Game type completeness
+
+ - missing_count(player_name) = 0:
+ name: Player name completeness
+
+ - missing_count(placement) = 0:
+ name: Placement completeness
+
+ - duplicate_count(pokedata_id, game_type, player_name) = 0:
+ name: Player uniqueness within tournament
+
+ - invalid_count(placement) = 0:
+ valid min: 1
+ name: Placement positive
+
+ - invalid_count(wins) = 0:
+ valid min: 0
+ name: Wins non-negative
+
+ - invalid_count(losses) = 0:
+ valid min: 0
+ name: Losses non-negative
+
+ - invalid_count(ties) = 0:
+ valid min: 0
+ name: Ties non-negative
diff --git a/data_platform/pipelines/soda/checks_comp_rounds.yml b/data_platform/pipelines/soda/checks_comp_rounds.yml
new file mode 100644
index 00000000..e6b6bc57
--- /dev/null
+++ b/data_platform/pipelines/soda/checks_comp_rounds.yml
@@ -0,0 +1,35 @@
+checks for comp_rounds:
+ - schema:
+ fail:
+ when required column missing: [pokedata_id, game_type, player_name, round_number, opponent_name, result, table_number]
+ when wrong column type:
+ pokedata_id: text
+ game_type: text
+ player_name: text
+ round_number: bigint
+ opponent_name: text
+ result: text
+ table_number: text
+
+ - missing_count(pokedata_id) = 0:
+ name: Pokedata ID completeness
+
+ - missing_count(game_type) = 0:
+ name: Game type completeness
+
+ - missing_count(player_name) = 0:
+ name: Player name completeness
+
+ - missing_count(round_number) = 0:
+ name: Round number completeness
+
+ - duplicate_count(pokedata_id, game_type, player_name, round_number) = 0:
+ name: Round uniqueness per player
+
+ - invalid_count(round_number) = 0:
+ valid min: 1
+ name: Round number positive
+
+ - invalid_count(result) = 0:
+ valid values: [W, L, T]
+ name: Round result is W/L/T
diff --git a/data_platform/pipelines/soda/checks_comp_tcg_decklists.yml b/data_platform/pipelines/soda/checks_comp_tcg_decklists.yml
new file mode 100644
index 00000000..91281ba9
--- /dev/null
+++ b/data_platform/pipelines/soda/checks_comp_tcg_decklists.yml
@@ -0,0 +1,25 @@
+checks for comp_tcg_decklists:
+ - schema:
+ fail:
+ when required column missing: [pokedata_id, game_type, player_name, decklist]
+ when wrong column type:
+ pokedata_id: text
+ game_type: text
+ player_name: text
+ decklist: jsonb
+
+ - missing_count(pokedata_id) = 0:
+ name: Pokedata ID completeness
+
+ - missing_count(player_name) = 0:
+ name: Player name completeness
+
+ - missing_count(decklist) = 0:
+ name: Decklist completeness
+
+ - duplicate_count(pokedata_id, game_type, player_name) = 0:
+ name: One decklist per player per tournament
+
+ - invalid_count(game_type) = 0:
+ valid values: [TCG]
+ name: Game type is TCG only
diff --git a/data_platform/pipelines/soda/checks_comp_vg_decklists.yml b/data_platform/pipelines/soda/checks_comp_vg_decklists.yml
new file mode 100644
index 00000000..9445b971
--- /dev/null
+++ b/data_platform/pipelines/soda/checks_comp_vg_decklists.yml
@@ -0,0 +1,25 @@
+checks for comp_vg_decklists:
+ - schema:
+ fail:
+ when required column missing: [pokedata_id, game_type, player_name, decklist]
+ when wrong column type:
+ pokedata_id: text
+ game_type: text
+ player_name: text
+ decklist: jsonb
+
+ - missing_count(pokedata_id) = 0:
+ name: Pokedata ID completeness
+
+ - missing_count(player_name) = 0:
+ name: Player name completeness
+
+ - missing_count(decklist) = 0:
+ name: Decklist completeness
+
+ - duplicate_count(pokedata_id, game_type, player_name) = 0:
+ name: One decklist per player per tournament
+
+ - invalid_count(game_type) = 0:
+ valid values: [VGC]
+ name: Game type is VGC only
diff --git a/card_data/pipelines/soda/checks_pricing.yml b/data_platform/pipelines/soda/checks_pricing.yml
similarity index 100%
rename from card_data/pipelines/soda/checks_pricing.yml
rename to data_platform/pipelines/soda/checks_pricing.yml
diff --git a/card_data/pipelines/soda/checks_series.yml b/data_platform/pipelines/soda/checks_series.yml
similarity index 100%
rename from card_data/pipelines/soda/checks_series.yml
rename to data_platform/pipelines/soda/checks_series.yml
diff --git a/card_data/pipelines/soda/checks_sets.yml b/data_platform/pipelines/soda/checks_sets.yml
similarity index 100%
rename from card_data/pipelines/soda/checks_sets.yml
rename to data_platform/pipelines/soda/checks_sets.yml
diff --git a/card_data/pipelines/soda/configuration.yml b/data_platform/pipelines/soda/configuration.yml
similarity index 100%
rename from card_data/pipelines/soda/configuration.yml
rename to data_platform/pipelines/soda/configuration.yml
diff --git a/card_data/pipelines/tests/extract_pricing_test.py b/data_platform/pipelines/tests/extract_pricing_test.py
similarity index 100%
rename from card_data/pipelines/tests/extract_pricing_test.py
rename to data_platform/pipelines/tests/extract_pricing_test.py
diff --git a/card_data/pipelines/tests/extract_series_test.py b/data_platform/pipelines/tests/extract_series_test.py
similarity index 100%
rename from card_data/pipelines/tests/extract_series_test.py
rename to data_platform/pipelines/tests/extract_series_test.py
diff --git a/card_data/pipelines/tests/extract_sets_test.py b/data_platform/pipelines/tests/extract_sets_test.py
similarity index 100%
rename from card_data/pipelines/tests/extract_sets_test.py
rename to data_platform/pipelines/tests/extract_sets_test.py
diff --git a/card_data/pipelines/tests/json_retriever_test.py b/data_platform/pipelines/tests/json_retriever_test.py
similarity index 100%
rename from card_data/pipelines/tests/json_retriever_test.py
rename to data_platform/pipelines/tests/json_retriever_test.py
diff --git a/card_data/pipelines/tests/secret_retriever_test.py b/data_platform/pipelines/tests/secret_retriever_test.py
similarity index 94%
rename from card_data/pipelines/tests/secret_retriever_test.py
rename to data_platform/pipelines/tests/secret_retriever_test.py
index 301385c0..b91b7853 100644
--- a/card_data/pipelines/tests/secret_retriever_test.py
+++ b/data_platform/pipelines/tests/secret_retriever_test.py
@@ -75,11 +75,6 @@ def test_fetch_secret_cache_raises(mock_get_session, mock_secret_cache_cls):
fetch_secret()
-# ---------------------------------------------------------------------------
-# fetch_n8n_webhook_secret()
-# ---------------------------------------------------------------------------
-
-
@patch("pipelines.utils.secret_retriever.SecretCache")
@patch("pipelines.utils.secret_retriever.botocore.session.get_session")
def test_fetch_n8n_webhook_secret_success(mock_get_session, mock_secret_cache_cls):
@@ -90,7 +85,7 @@ def test_fetch_n8n_webhook_secret_success(mock_get_session, mock_secret_cache_cl
mock_cache_instance.get_secret_string.return_value = secret_payload
mock_secret_cache_cls.return_value = mock_cache_instance
- result = fetch_n8n_webhook_secret()
+ result = fetch_n8n_webhook_secret("n8n_webhook")
assert result == "https://n8n.example.com/hook/abc" # nosec
mock_cache_instance.get_secret_string.assert_called_once_with("n8n_webhook")
@@ -107,7 +102,7 @@ def test_fetch_n8n_webhook_secret_missing_key(mock_get_session, mock_secret_cach
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(KeyError, match="n8n_webhook"):
- fetch_n8n_webhook_secret()
+ fetch_n8n_webhook_secret("n8n_webhook")
@patch("pipelines.utils.secret_retriever.SecretCache")
@@ -119,7 +114,7 @@ def test_fetch_n8n_webhook_secret_invalid_json(mock_get_session, mock_secret_cac
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(json.JSONDecodeError):
- fetch_n8n_webhook_secret()
+ fetch_n8n_webhook_secret("n8n_webhook")
@patch("pipelines.utils.secret_retriever.SecretCache")
@@ -133,7 +128,7 @@ def test_fetch_n8n_webhook_secret_empty_json_object(
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(KeyError, match="n8n_webhook"):
- fetch_n8n_webhook_secret()
+ fetch_n8n_webhook_secret("n8n_webhook")
@patch("pipelines.utils.secret_retriever.SecretCache")
@@ -145,4 +140,4 @@ def test_fetch_n8n_webhook_secret_cache_raises(mock_get_session, mock_secret_cac
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(Exception, match="Access denied"):
- fetch_n8n_webhook_secret()
+ fetch_n8n_webhook_secret("n8n_webhook")
diff --git a/card_data/pipelines/tests/sensors_test.py b/data_platform/pipelines/tests/sensors_test.py
similarity index 100%
rename from card_data/pipelines/tests/sensors_test.py
rename to data_platform/pipelines/tests/sensors_test.py
diff --git a/card_data/pipelines/utils/json_retriever.py b/data_platform/pipelines/utils/json_retriever.py
similarity index 100%
rename from card_data/pipelines/utils/json_retriever.py
rename to data_platform/pipelines/utils/json_retriever.py
diff --git a/card_data/pipelines/utils/secret_retriever.py b/data_platform/pipelines/utils/secret_retriever.py
similarity index 92%
rename from card_data/pipelines/utils/secret_retriever.py
rename to data_platform/pipelines/utils/secret_retriever.py
index a23eac9a..360c01cd 100644
--- a/card_data/pipelines/utils/secret_retriever.py
+++ b/data_platform/pipelines/utils/secret_retriever.py
@@ -23,7 +23,7 @@ def fetch_secret() -> str:
return secret_dict["database_uri"]
-def fetch_n8n_webhook_secret() -> str:
+def fetch_n8n_webhook_secret(key: str) -> str:
client = botocore.session.get_session().create_client("secretsmanager")
cache_config = SecretCacheConfig()
cache = SecretCache(config=cache_config, client=client)
@@ -31,4 +31,4 @@ def fetch_n8n_webhook_secret() -> str:
secret = cast(str, cache.get_secret_string("n8n_webhook"))
secret_dict: dict[str, str] = json.loads(secret)
- return secret_dict["n8n_webhook"]
+ return secret_dict[key]
diff --git a/card_data/pyproject.toml b/data_platform/pyproject.toml
similarity index 96%
rename from card_data/pyproject.toml
rename to data_platform/pyproject.toml
index 125f93c6..2c84f50f 100644
--- a/card_data/pyproject.toml
+++ b/data_platform/pyproject.toml
@@ -1,6 +1,6 @@
[project]
-name = "card-data"
-version = "v1.10.3"
+name = "data-platform"
+version = "v2.0.0"
description = "File directory to store all data related processes for the Pokémon TCG."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/card_data/uv.lock b/data_platform/uv.lock
similarity index 99%
rename from card_data/uv.lock
rename to data_platform/uv.lock
index eec09d4f..03c11fbf 100644
--- a/card_data/uv.lock
+++ b/data_platform/uv.lock
@@ -137,77 +137,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/98/c7c26ff399994e2b1119cc36027aaae46b9d646a49b70a82c2622e44c94b/botocore-1.42.16-py3-none-any.whl", hash = "sha256:b1f584a0f8645c12e07bf6ec9c18e05221a789f2a9b2d3c6291deb42f8c1c542", size = 14585775, upload-time = "2025-12-23T20:44:08.092Z" },
]
-[[package]]
-name = "card-data"
-version = "1.10.0"
-source = { virtual = "." }
-dependencies = [
- { name = "aws-secretsmanager-caching" },
- { name = "beautifulsoup4" },
- { name = "dagster" },
- { name = "dagster-dbt" },
- { name = "dagster-dg-cli" },
- { name = "dagster-postgres" },
- { name = "dagster-webserver" },
- { name = "dbt-core" },
- { name = "dbt-postgres" },
- { name = "pandas" },
- { name = "polars" },
- { name = "psycopg2-binary" },
- { name = "pyarrow" },
- { name = "pydantic" },
- { name = "pytest-cov" },
- { name = "requests" },
- { name = "soda-core-postgres" },
- { name = "sqlalchemy" },
- { name = "termcolor" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "dagster-dbt" },
- { name = "dagster-dg-cli" },
- { name = "dagster-postgres" },
- { name = "dagster-webserver" },
- { name = "pytest" },
- { name = "pytest-codspeed" },
- { name = "responses" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "aws-secretsmanager-caching", specifier = "==1.1.3" },
- { name = "beautifulsoup4", specifier = "==4.13.5" },
- { name = "dagster", specifier = "==1.12.7" },
- { name = "dagster-dbt", specifier = "==0.28.7" },
- { name = "dagster-dg-cli", specifier = "==1.12.7" },
- { name = "dagster-postgres", specifier = "==0.28.7" },
- { name = "dagster-webserver", specifier = "==1.12.7" },
- { name = "dbt-core", specifier = "==1.10.8" },
- { name = "dbt-postgres", specifier = "==1.9.0" },
- { name = "pandas", specifier = "==2.3.1" },
- { name = "polars", specifier = "==1.31.0" },
- { name = "psycopg2-binary", specifier = "==2.9.10" },
- { name = "pyarrow", specifier = "==20.0.0" },
- { name = "pydantic", specifier = "==2.11.7" },
- { name = "pytest-cov", specifier = "==7.1.0" },
- { name = "requests", specifier = "==2.32.4" },
- { name = "soda-core-postgres", specifier = "==3.5.5" },
- { name = "sqlalchemy", specifier = "==2.0.41" },
- { name = "termcolor", specifier = "==3.1.0" },
-]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "dagster-dbt", specifier = "==0.28.7" },
- { name = "dagster-dg-cli" },
- { name = "dagster-postgres", specifier = "==0.28.7" },
- { name = "dagster-webserver", specifier = "==1.12.7" },
- { name = "pytest", specifier = "==9.0.2" },
- { name = "pytest-codspeed", specifier = "==4.2.0" },
- { name = "responses", specifier = "==0.25.8" },
-]
-
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -724,6 +653,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/55/6c13cfde6c77b4efd331a316766fde9a14a97f895458b59b1d771d5783ae/dagster_webserver-1.12.7-py3-none-any.whl", hash = "sha256:d14dcb9ac05caa05fbccf26cb871dc0ab9a1d12e692903030dcfb394d91412b0", size = 12582819, upload-time = "2025-12-19T19:06:38.423Z" },
]
+[[package]]
+name = "data-platform"
+version = "2.0.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "aws-secretsmanager-caching" },
+ { name = "beautifulsoup4" },
+ { name = "dagster" },
+ { name = "dagster-dbt" },
+ { name = "dagster-dg-cli" },
+ { name = "dagster-postgres" },
+ { name = "dagster-webserver" },
+ { name = "dbt-core" },
+ { name = "dbt-postgres" },
+ { name = "pandas" },
+ { name = "polars" },
+ { name = "psycopg2-binary" },
+ { name = "pyarrow" },
+ { name = "pydantic" },
+ { name = "pytest-cov" },
+ { name = "requests" },
+ { name = "soda-core-postgres" },
+ { name = "sqlalchemy" },
+ { name = "termcolor" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "dagster-dbt" },
+ { name = "dagster-dg-cli" },
+ { name = "dagster-postgres" },
+ { name = "dagster-webserver" },
+ { name = "pytest" },
+ { name = "pytest-codspeed" },
+ { name = "responses" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aws-secretsmanager-caching", specifier = "==1.1.3" },
+ { name = "beautifulsoup4", specifier = "==4.13.5" },
+ { name = "dagster", specifier = "==1.12.7" },
+ { name = "dagster-dbt", specifier = "==0.28.7" },
+ { name = "dagster-dg-cli", specifier = "==1.12.7" },
+ { name = "dagster-postgres", specifier = "==0.28.7" },
+ { name = "dagster-webserver", specifier = "==1.12.7" },
+ { name = "dbt-core", specifier = "==1.10.8" },
+ { name = "dbt-postgres", specifier = "==1.9.0" },
+ { name = "pandas", specifier = "==2.3.1" },
+ { name = "polars", specifier = "==1.31.0" },
+ { name = "psycopg2-binary", specifier = "==2.9.10" },
+ { name = "pyarrow", specifier = "==20.0.0" },
+ { name = "pydantic", specifier = "==2.11.7" },
+ { name = "pytest-cov", specifier = "==7.1.0" },
+ { name = "requests", specifier = "==2.32.4" },
+ { name = "soda-core-postgres", specifier = "==3.5.5" },
+ { name = "sqlalchemy", specifier = "==2.0.41" },
+ { name = "termcolor", specifier = "==3.1.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "dagster-dbt", specifier = "==0.28.7" },
+ { name = "dagster-dg-cli" },
+ { name = "dagster-postgres", specifier = "==0.28.7" },
+ { name = "dagster-webserver", specifier = "==1.12.7" },
+ { name = "pytest", specifier = "==9.0.2" },
+ { name = "pytest-codspeed", specifier = "==4.2.0" },
+ { name = "responses", specifier = "==0.25.8" },
+]
+
[[package]]
name = "dbt-adapters"
version = "1.16.3"
diff --git a/docs/Architecture/Commands/comp.md b/docs/Architecture/Commands/comp.md
new file mode 100644
index 00000000..391d7ed7
--- /dev/null
+++ b/docs/Architecture/Commands/comp.md
@@ -0,0 +1,73 @@
+---
+weight: 2
+---
+
+# `comp`
+
+How the `poke-cli comp` TUI loads TCG and VGC tournament standings, and which Supabase views back each branch.
+
+## Shared Flow
+
+
+
+The command starts with a competition picker. After the user chooses `TCG` or `VGC`, the selected branch follows the same runtime shape:
+
+1. Fetch one row per current-season tournament for the selected competition type.
+2. Let the user pick a tournament.
+3. Fetch that tournament's full standings.
+4. Derive dashboard tabs client-side from the standings rows.
+
+Both branches make two REST round-trips per session: one for the tournament list and one for the selected tournament dashboard. Both views use the same `security_invoker` + grant pattern, so `anon`/`authenticated` need `SELECT` on the view and on every base table the view reads.
+
+## TCG
+
+The TCG branch reads `comp_tcg_standings_view`. That view joins pokedata.ovh competitive data with Limitless enrichment so the dashboard can show standings, archetypes, decklist links, and country rollups.
+
+### TCG table inputs
+
+| Table | Source | Supplies |
+|-------|--------|----------|
+| `public.comp_players` | pokedata.ovh | rank, points, record, opp / opp-opp win %, country code |
+| `public.comp_events` | pokedata.ovh | location, start/end dates, text_date, type, player_quantity |
+| `staging.standings` | Limitless | deck (archetype), decklist URL |
+| `staging.tournaments` | Limitless | pokedata_id → Limitless tournament_id lookup (the join key) |
+| `staging.country_codes` | Manual Upload | player_country (full name from the ISO code) |
+
+The three `staging.*` tables are **internal feeds only**: they live in a non-exposed schema, so they're reachable solely through the view, never as their own REST endpoints.
+
+### TCG dashboard
+
+The TCG dashboard derives these tabs from the selected tournament's standings rows:
+
+- **Overview:** tournament summary and winner details.
+- **Standings:** rank, player, points, record, resistance, country, and deck.
+- **Decks:** archetype frequency bar chart.
+- **Countries:** player-country frequency bar chart.
+
+## VGC
+
+The VGC branch reads `comp_vgc_standings_view`. It is simpler than the TCG branch because VGC does not use Limitless enrichment; the team data comes from pokedata.ovh decklists.
+
+### VGC table inputs
+
+| Table | Source | Supplies |
+|-------|--------|----------|
+| `public.comp_players` | pokedata.ovh | rank, points, record, opp / opp-opp win %, country code |
+| `public.comp_events` | pokedata.ovh | location, start/end dates, text_date, type, player_quantity |
+| `public.comp_vg_decklists` | pokedata.ovh | team JSONB for each player |
+| `staging.country_codes` | Manual Upload | player_country (full name from the ISO code) |
+
+### VGC dashboard
+
+The VGC dashboard derives these tabs from the selected tournament's standings rows:
+
+- **Overview:** tournament summary, winner details, and winner's team.
+- **Standings:** rank, player, points, record, resistance, country, and team.
+- **Usage:** Pokémon usage frequency bar chart derived from each row's `team` array.
+- **Countries:** player-country frequency bar chart.
+
+## Shared Notes
+
+- Both standings views filter to the current competitive season, which auto-rolls each fall.
+- TCG has a `deck` column sourced from Limitless archetype data.
+- VGC has a `team` column sourced from six-Pokémon decklist JSONB data.
diff --git a/docs/Architecture/Services/rust-aggregation-service.md b/docs/Architecture/Services/rust-aggregation-service.md
new file mode 100644
index 00000000..a42c9a9d
--- /dev/null
+++ b/docs/Architecture/Services/rust-aggregation-service.md
@@ -0,0 +1,10 @@
+---
+weight: 1
+---
+
+# Rust Aggregation Service
+
+How the Go CLI delegates Pokémon data assembly to the `poke-aggregate` Rust binary, from flag parsing through PokéAPI fetches to the JSON it hands back for rendering.
+
+## Diagram
+
\ No newline at end of file
diff --git a/docs/Architecture/Services/rust-caching-service.md b/docs/Architecture/Services/rust-caching-service.md
new file mode 100644
index 00000000..12c3cf9f
--- /dev/null
+++ b/docs/Architecture/Services/rust-caching-service.md
@@ -0,0 +1,10 @@
+---
+weight: 2
+---
+
+# Rust Caching Service
+
+This page decribes how the Go CLI checks for the `poke-cache` Rust binary, and how it handles the case when it's not found. The caching service is an optional component that provides local caching of PokéAPI responses to speed up repeated queries. If the binary is absent, the CLI will still function but without caching benefits, and it will print a one-time notice to inform the user.
+
+## Diagram
+
\ No newline at end of file
diff --git a/docs/Architecture/index.md b/docs/Architecture/index.md
new file mode 100644
index 00000000..76347041
--- /dev/null
+++ b/docs/Architecture/index.md
@@ -0,0 +1,32 @@
+---
+weight: 2
+---
+
+# Overview
+
+This section is a behind-the-scenes reference for how individual parts of the CLI work at runtime.
+
+Where the [Infrastructure Guide](../Infrastructure_Guide/index.md) covers how the backend is **built and provisioned**, these pages cover how the CLI **reads and assembles data** when a command runs:
+
+* The request paths
+* The services involved
+* Which tables or APIs answer each call
+
+Each page focuses on one command or service and leads with a sequence diagram.
+
+## Commands
+
+How individual CLI commands fetch and assemble their data at runtime.
+
+| Command | Covers |
+|------|--------|
+| [`comp`](Commands/comp.md) | How `poke-cli comp` loads TCG and VGC tournament standings and other data from Supabase |
+
+## Services
+
+Standalone services the CLI relies on.
+
+| Service | Covers |
+|------|--------|
+| [Rust Aggregation Service](Services/rust-aggregation-service.md) | How the Go CLI delegates Pokémon data assembly to the `poke-aggregate` Rust binary |
+| [Rust Caching Service](Services/rust-caching-service.md) | How the program calls the Rust binary to fetch cached data |
diff --git a/docs/Dockerfile b/docs/Dockerfile
index 70ae6d5d..90f936ad 100644
--- a/docs/Dockerfile
+++ b/docs/Dockerfile
@@ -6,7 +6,7 @@ RUN groupadd -r -g 10001 docsuser && \
WORKDIR /build
-RUN pip install --no-cache-dir mkdocs mkdocs-material mkdocs-nav-weight
+RUN pip install --no-cache-dir mkdocs mkdocs-material mkdocs-nav-weight mkdocs-glightbox
COPY --chown=docsuser:docsuser mkdocs.yml /build/mkdocs.yml
COPY --chown=docsuser:docsuser docs/ /build/docs/
diff --git a/docs/Infrastructure_Guide/cloud-deployment.md b/docs/Infrastructure_Guide/cloud-deployment.md
index 3e5a1719..7e27ccdc 100644
--- a/docs/Infrastructure_Guide/cloud-deployment.md
+++ b/docs/Infrastructure_Guide/cloud-deployment.md
@@ -50,19 +50,19 @@ Connect to the virtual machine and run the following commands to get everything
```shell
git remote add -f origin https://github.com/digitalghost-dev/poke-cli/
```
- * Edit the `git` config file to turn on sparse checkout:
+ * Initialize sparse checkout:
```shell
- git config core.sparseCheckout true
+ git sparse-checkout init --cone
```
* Tell `git` which directory to check out. Then, pull that directory:
```shell
- echo "card_data/" >> .git/info/sparse-checkout
+ git sparse-checkout set data_platform
```
* Pull the repository into the local directory:
```shell
git pull origin main
```
- * Verify that `card_data/` directory was created:
+ * Verify that `data_platform/` directory was created:
```shell
ls
```
@@ -118,7 +118,7 @@ stop once each day with AWS EventBridge. To automate the starting of the Dagster
### Service Files
-The `card_data/infrastructure/` directory has the following files:
+The `data_platform/infrastructure/` directory has the following files:
1. `dagster.service` - the main `systemd` file for defining the Dagster service and environment.
2. `wait-for-rds.sh` - stored as `ExecStartPre` in `dagster.service` to check if the RDS instance is available.
@@ -158,11 +158,11 @@ Copy or move the files from the checked out repository to the proper directory o
be edited to match project specific configuration. Such as the proper RDS instance name in `wait-for-rds.sh`_):
```shell
-cp card_data/card_data/infrastructure/wait-for-rds.sh /home/ubuntu/
+cp poke-cli/data_platform/infrastructure/wait-for-rds.sh /home/ubuntu/
-cp card_data/card_data/infrastructure/start-dagster.sh /home/ubuntu/
+cp poke-cli/data_platform/infrastructure/start-dagster.sh /home/ubuntu/
-cp card_data/card_data/infrastructure/dagster.service /etc/systemd/system/
+cp poke-cli/data_platform/infrastructure/dagster.service /etc/systemd/system/
```
#### Create Files
@@ -185,9 +185,9 @@ First, create `dagster.service`
[Service]
Type=simple
User=ubuntu
- WorkingDirectory=/home/ubuntu/card_data/card_data
+ WorkingDirectory=/home/ubuntu/poke-cli/data_platform
Environment="AWS_DEFAULT_REGION=us-west-2"
- Environment="PATH=/home/ubuntu/card_data/card_data/.venv/bin:/usr/local/bin:/usr/bin:/bin"
+ Environment="PATH=/home/ubuntu/poke-cli/data_platform/.venv/bin:/usr/local/bin:/usr/bin:/bin"
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
@@ -309,11 +309,11 @@ Last, create `start-dagster.sh`
fi
export AWS_RDS_HOSTNAME
- DAGSTER_HOME=/home/ubuntu/card_data/card_data/
+ DAGSTER_HOME=/home/ubuntu/poke-cli/data_platform/
export DAGSTER_HOME
# Activate the virtual environment
- source /home/ubuntu/card_data/card_data/.venv/bin/activate
+ source /home/ubuntu/poke-cli/data_platform/.venv/bin/activate
# Start Dagster
exec dg dev --host 0.0.0.0 --port 3000
@@ -345,4 +345,4 @@ View live logs:
```shell
sudo journalctl -u dagster.service -f
-```
\ No newline at end of file
+```
diff --git a/docs/Infrastructure_Guide/cloudflare-tunnel.md b/docs/Infrastructure_Guide/cloudflare-tunnel.md
index a5c8a690..5551256b 100644
--- a/docs/Infrastructure_Guide/cloudflare-tunnel.md
+++ b/docs/Infrastructure_Guide/cloudflare-tunnel.md
@@ -13,15 +13,18 @@ weight: 9
## Overview
-Cloudflare Tunnel is used here to make the **Dagster webserver** (running on an EC2 instance) reachable
-to **n8n Cloud** so that Pipeline 3 in [8 // n8n](n8n.md#pipeline-3-speed-tiers-scrape) can launch a Dagster
-job via the GraphQL API. n8n Cloud's dynamic egress IPs never need to be whitelisted because the tunnel is an
-outbound connection initiated by the VM, not an inbound one accepted by it.
+Cloudflare Tunnel is optional in the v2.0.0 architecture. The current Pikalytics flow is **Dagster → n8n**:
+Dagster triggers n8n scraper webhooks, then dbt builds the public tables. n8n Cloud no longer needs to reach
+Dagster to launch a job.
+
+This guide is still useful when the **Dagster webserver** runs on an EC2 instance and needs to be reachable
+from a browser or trusted external automation without opening inbound ports on the VM. Cloudflare Tunnel works
+through an outbound connection initiated by the VM, so dynamic client IPs never need to be whitelisted.
The end result: `https://dagster.example.com` resolves through Cloudflare to the Dagster UI/GraphQL
endpoint, with no public ports opened on the VM. The hostname is protected by Cloudflare Access, so
requests are rejected unless they include the `CF-Access-Client-Id` and `CF-Access-Client-Secret`
-headers from the n8n service token created in Cloudflare Zero Trust.
+headers from the service token created in Cloudflare Zero Trust.
## Prerequisites
@@ -40,7 +43,7 @@ This guide uses the following placeholder values:
| `` | Tunnel credentials UUID generated by Cloudflare |
| `` | Dagster repository location name |
| `` | Dagster repository name |
-| `` | Dagster job to launch from n8n |
+| `` | Dagster job to launch through the GraphQL API |
## Setup
@@ -225,8 +228,8 @@ and on completion fire the existing run-status webhook into n8n's
## Locking Down the Tunnel
The tunnel hostname should not be left publicly reachable. Cloudflare Access can require a service
-token before traffic reaches Dagster, which lets n8n call the GraphQL endpoint without exposing the UI
-or API to the open internet.
+token before traffic reaches Dagster, which lets trusted automation call the GraphQL endpoint without
+exposing the UI or API to the open internet.
### 1. Open Zero Trust
@@ -241,7 +244,7 @@ account name is sufficient, then the **Free** plan can be selected.
In the Zero Trust dashboard:
1. Go to **Access controls → Service credentials → Service Tokens**.
-2. Create a service token named `n8n-dagster-trigger`.
+2. Create a service token named `dagster-graphql-client`.
3. Copy both generated values:
* `CF-Access-Client-Id`
* `CF-Access-Client-Secret`
@@ -257,11 +260,11 @@ In **Access → Applications**, start a new self-hosted application and create a
| Field | Value |
|---|---|
-| **Policy name** | `n8n service token` |
+| **Policy name** | `dagster service token` |
| **Action** | `Service Auth` |
| **Rule type** | `Include` |
| **Selector** | `Service Token` |
-| **Value** | `n8n-dagster-trigger` |
+| **Value** | `dagster-graphql-client` |
If the **Value** dropdown has no service token options, create the service token first, then refresh the
application page.
@@ -277,7 +280,7 @@ Configure the self-hosted application:
| **Domain** | `example.com` |
| **Path** | leave blank |
| **Browser-based RDP, SSH, or VNC sessions** | off |
-| **Policy** | `n8n service token` |
+| **Policy** | `dagster service token` |
Leaving **Path** blank protects the whole `dagster.example.com` hostname. That is intentional: both the
Dagster UI and `/graphql` endpoint should be behind Access.
@@ -307,10 +310,11 @@ Access test because it proves the request reached Dagster and only failed becaus
Cloudflare validates these headers at the edge before forwarding to Dagster. No VM-side changes are
required.
-## Wiring n8n to the Locked-Down Tunnel
+## Legacy: Wiring an External Job Launcher
-The n8n HTTP Request node that closes Pipeline 3's loop uses the same `launchRun` mutation body from
-[Launching a Job](#launching-a-job). Settings:
+The current v2.0.0 Pikalytics scraper flow does not use this pattern; Dagster calls n8n instead. If a future
+workflow needs an external scheduler or n8n workflow to launch a Dagster job, use the same `launchRun`
+mutation body from [Launching a Job](#launching-a-job). HTTP request settings:
| Field | Value |
|---|---|
@@ -347,8 +351,8 @@ The Cloudflare Access headers use the service token values created in [Locking D
}
```
-A successful execution shows `data.launchRun.__typename = "LaunchRunSuccess"` plus a `runId` in the n8n
-output panel, and a fresh run appears in the Dagster UI within seconds.
+A successful execution shows `data.launchRun.__typename = "LaunchRunSuccess"` plus a `runId`, and a fresh run
+appears in the Dagster UI within seconds.
---
diff --git a/docs/Infrastructure_Guide/local-deployment.md b/docs/Infrastructure_Guide/local-deployment.md
index 1fa9f542..b860674d 100644
--- a/docs/Infrastructure_Guide/local-deployment.md
+++ b/docs/Infrastructure_Guide/local-deployment.md
@@ -12,7 +12,7 @@ The [4. AWS](aws.md) section will show how to deploy this solution on the cloud.
## Python
-First, create a directory for all the Python code. This project's Python directory is called `card_data`.
+First, create a directory for all the Python code. This project's Python directory is called `data_platform`.
Once the directory is created, `cd` into it from the terminal.
All following commands will take place in this directory.
@@ -38,29 +38,20 @@ _uv is the main package and project manager used in this project. Learn more abo
uv init
```
-4. Sync `uv` with the libraries from `card_data/pyproject.toml`. Syncing ensures that all project dependencies are installed and up-to-date with the lockfile.
+4. Sync `uv` with the libraries from `data_platform/pyproject.toml`. Syncing ensures that all project dependencies are installed and up-to-date with the lockfile.
```bash
uv sync
```
### ELT Files
The files used for extracting, loading, then transforming the data all live in the `pipelines/defs/` directory.
-At the time of writing, the `extract/` and `load/` directories have 2 different files aimed at calling different APIs.
-One for card data from [tcgdex](https://tcgdex.dev/) and the other for pricing data from [tcgcsv](https://tcgcsv.com/).
+The code is grouped by source:
-```
-.
-└── pipelines/
- └── defs/
- ├── extract/
- │ ├── extract_data.py
- │ └── extract_pricing_data.py
- ├── load/
- │ ├── load_data.py
- │ └── load_pricing_data.py
- └── transform/
- └── transform_data.py
-```
+- `extract/` and `load/` hold the original TCGDex, TCGCSV, and Limitless extract/load assets.
+- `competitive.py` loads pokedata.ovh TCG/VGC events, players, rounds, and decklists.
+- `pokeapi/` loads video-game reference data from PokéAPI CSV exports.
+- `pikalytics/` triggers n8n scraper workflows that land raw Pikalytics data in Supabase staging.
+- `transform/` maps dbt models into Dagster's asset graph.
---
@@ -96,21 +87,25 @@ With Dagster running, the pipelines can be triggered to populate data in Supabas
This step is required before dbt or Soda will work since both tools expect data to already
exist in the staging tables.
-This project currently has 4 pipelines:
-* Card data
-* Set data
-* Series data
-* Pricing data
+This project currently has these Dagster jobs:
+
+* `pricing_pipeline_job`
+* `series_pipeline_job`
+* `sets_pipeline_job`
+* `standings_pipeline_job`
+* `comp_pipeline_job`
+* `pokeapi_pipeline_job`
+* `pikalytics_pipeline_job`

1. Open the Dagster UI at `http://127.0.0.1:3000`.
2. On the top bar, click on **Assets**.
-3. Select the assets to materialize. For initial setup, select:
+3. Select the assets to materialize. For an initial TCG card-data setup, select:
- `extract_series_data`
- `extract_sets_data`
- `load_series_data`
- - `load_set_data`
+ - `load_sets_data`
4. Click **Materialize selected** in the top right.
5. Monitor the run in the **Runs** tab. Once complete, the data will be available in the Supabase `staging` schema.
@@ -141,7 +136,7 @@ uv add dbt
This will add the libraries to `pyproject.toml` file.
-Initialize a `dbt` project in the `card_data` directory:
+Initialize a `dbt` project in the `data_platform` directory:
```bash
dbt init
```
@@ -206,7 +201,7 @@ Soda and its components needed for the project can be installed with `uv`:
uv add soda-core-postgres
```
-2. Create a `configuration.yml` and `checks.yml` files under the `card_data/pipelines/soda/` directory.
+2. Create a `configuration.yml` and `checks.yml` files under the `data_platform/pipelines/soda/` directory.
* The `configuration.yml` holds the information for connecting to the data source. The below is an example from this project that reads the `username` and `password`
from the local environment so that this file can be safely committed to `git` and pushed to GitHub.
@@ -278,4 +273,4 @@ soda scan -d supabase -c /path/to/configuration.yml /path/to/checks.yml
[15:25:40] missing_count(logo) = 0 [PASSED]
[15:25:40] invalid_count(logo) = 0 [PASSED]
[15:25:40] All is good. No failures. No warnings. No errors.
-```
\ No newline at end of file
+```
diff --git a/docs/Infrastructure_Guide/n8n.md b/docs/Infrastructure_Guide/n8n.md
index c3b8a75b..3defcc9a 100644
--- a/docs/Infrastructure_Guide/n8n.md
+++ b/docs/Infrastructure_Guide/n8n.md
@@ -6,20 +6,42 @@ weight: 8
!!! question "What is n8n?"
- n8n is a workflow automation platform that lets you wire together HTTP calls, databases, AI services, and notifications using a visual node editor. Each node is one step in a pipeline; data flows between them as JSON. n8n Cloud is the managed, hosted version where workflows run on n8n's infrastructure on a scheduled configuration.
+ n8n is a workflow automation platform that lets you wire together HTTP calls, databases, AI services, and notifications using a visual node editor. Each node is one step in a pipeline; data flows between them as JSON. n8n Cloud is the managed, hosted version where workflows run on n8n's infrastructure.
## Overview
-n8n is used in this project for a few different reasons such as performing API status checks, sending success/failure notifications, or ingesting data from sources that are not in a friendly format like a REST API.
+n8n is used in this project for a few different reasons such as performing API status checks, sending success/failure notifications, and ingesting data from sources that aren't exposed as a friendly REST API.
-One of n8n pipelines in this project, for example, scrapes `.md` files from [Pikalytics](https://www.pikalytics.com/ai/speed-tiers) with Firecrawl's LLM-powered extraction service to pull structured speed tier data into Supabase.
+The Pikalytics data, for example, has `ai/` available endpoints, so n8n scrapes it with [Firecrawl](https://www.firecrawl.dev/)'s LLM-powered extraction and lands the raw rows in Supabase. These scraper workflows are **triggered by Dagster** — they no longer carry their own schedule. See [Pipeline 3](#pipeline-3-pikalytics-scrapers).
-This project uses n8n cloud.
+This project uses n8n Cloud.
+
+!!! note "Two directions of Dagster ↔ n8n integration"
+
+ * **Dagster → n8n → Discord** (Pipeline 1): Dagster posts run-status webhooks to n8n, which formats and forwards them as Discord alerts.
+ * **Dagster → n8n → Supabase** (Pipeline 3): Dagster *triggers* n8n scraper workflows and blocks until they finish loading staging.
+
+ Both use the same keyed `fetch_n8n_webhook_secret(key)` helper to resolve webhook URLs from the `n8n_webhook` AWS secret.
+
+## Create an Account
+
+### n8n
+
+Visit the n8n [sign-up page](https://n8n.io/) to create an account. The Cloud plan is recommended for this
+project — it removes the operational overhead of self-hosting.
+
+### Firecrawl
+
+The Pikalytics scrapers ([Pipeline 3](#pipeline-3-pikalytics-scrapers)) use Firecrawl for extraction.
+
+1. Create a [Firecrawl](https://www.firecrawl.dev/signin?view=signup) account to get an API key. The free tier is sufficient for this use case.
+2. After account creation, find your API key in the [dashboard](https://www.firecrawl.dev/app/api-keys) and copy it.
+3. Add it in n8n as the `Firecrawl account` credential — the scraper workflows reference it by that name.
## Current Pipelines
-* Pipeline 1 - Dagster Job Status Check
-* Pipeline 2 - Supabase API Status Check
-* Pipeline 3 - Champions Speed Tiers Scrape
+* Pipeline 1 — Dagster Job Status Check (Dagster → n8n → Discord)
+* Pipeline 2 — Supabase API Status Check (n8n schedule → Discord)
+* Pipeline 3 — Pikalytics Scrapers (Dagster → n8n → Supabase staging)
### Pipeline 1: Job Status Check
@@ -163,243 +185,162 @@ takes over, `$json` reflects the If node's output rather than the original HTTP
---
-### Pipeline 3: Speed Tiers Scrape
+### Pipeline 3: Pikalytics Scrapers
-#### Pipeline Shape
+_Four Dagster-triggered workflows that scrape [Pikalytics](https://www.pikalytics.com/) into Supabase `staging.*`. n8n is a **dumb extractor** — all scheduling lives in Dagster and all derivation lives in dbt._
-```mermaid
-sequenceDiagram
- autonumber
- participant n8n
- participant Firecrawl
- participant Supabase
- participant Dagster
- participant dbt
+The four workflows fan into a single Dagster job (`pikalytics_pipeline_job`, weekly Mondays 08:00 LA). Each
+workflow is a webhook that Dagster POSTs to; the webhook is set to **respond only when its last node
+finishes**, so the Dagster trigger asset blocks until staging is fully loaded before dbt runs.
- Note over n8n: Schedule Trigger fires (monthly)
- n8n->>Firecrawl: POST /v2/scrape (URL + JSON schema)
- Firecrawl-->>n8n: 263 rows (rank, pokemon, base_spe)
- Note over n8n: Code node derives 6 speed columns
- n8n->>Supabase: UPSERT staging.champions_speed_tiers
- Supabase-->>n8n: 263 rows affected
- n8n->>Dagster: POST /graphql launchRun(...)
- Dagster-->>n8n: { runId }
- Dagster->>dbt: dbt build --select tag:champions_speed_tiers
- dbt->>Supabase: CREATE OR REPLACE public.champions_speed_tiers
- Supabase-->>dbt: ok
- dbt-->>Dagster: success
-```
+| Trigger asset | Workflow | Webhook path | Scrapes | Lands in |
+|---|---|---|---|---|
+| `trigger_pikalytics_speed_tiers` | `speed-tiers` | `pikalytics-speed-tier` | speed-tiers page | `staging.pikalytics_speed_tiers` |
+| `trigger_pikalytics_usage` | `usage` | `pikalytics-usage` | top-50 usage (pokedex) | `staging.pikalytics_usage` |
+| `trigger_pikalytics_top_teams` | `top-teams` | `pikalytics-top-teams` | top teams | `staging.pikalytics_top_teams` |
+| `trigger_pikalytics_pokemon_comp_info` | `pokemon-comp-info` | `pikalytics-pokemon-comp-info` | each top-50 Pokémon's AI page | `staging.pikalytics_pokemon_comp_info` |
-#### Create an Account
-
-Visit the n8n [sign-up page](https://n8n.io/) to create an account. The Cloud plan is recommended for this project —
-it removes the operational overhead of self-hosting.
-
-#### Supabase: Create the Staging Table
-
-_Run this SQL in the Supabase SQL Editor once before configuring the n8n workflow._
-
-```sql
--- This should already exist from the initial Supabase setup
-create schema if not exists staging;
-
-create table if not exists staging.champions_speed_tiers (
- snapshot_month date not null,
- format text not null,
- rank int not null,
- pokemon text not null,
- base_spe int not null,
- neutral_0_sp int not null,
- neutral_32_sp int not null,
- max_speed int not null,
- neg_spe_0_sp int not null,
- max_scarf int not null,
- neutral_32_scarf int not null,
- ingested_at timestamptz not null default now(),
- primary key (snapshot_month, format, pokemon)
-);
-
-create index if not exists idx_speed_tiers_rank
- on staging.champions_speed_tiers (snapshot_month, format, rank);
-```
+#### Responsibility split
-The composite primary key on `(snapshot_month, format, pokemon)` makes the upsert idempotent. re-runs within the same
-month overwrite rather than duplicate.
+| Layer | Responsibility |
+|-------|----------------|
+| Dagster | Owns scheduling, dependency order, retries, and dbt asset lineage. |
+| n8n | Extracts raw Pikalytics rows and writes them to staging. |
+| Firecrawl | Converts rendered Pikalytics pages into structured rows. |
+| dbt | Derives slugs, `pokemon_id`, record math, speed tiers, constants, RLS, and public tables. |
+| Supabase | Stores staging and public tables used by the app. |
-#### Workflow
+n8n should not calculate analytics fields such as speed buckets, parsed wins/losses/ties, or Pokémon IDs. Those transformations live in dbt so they are version-controlled and testable.
-##### 1. Schedule Trigger
+!!! warning "What changed from the old design"
-_Fires the workflow on a monthly cadence._
+ Each workflow used to own a **Schedule Trigger** and a Code node that did the math/derivations, then
+ upserted into a `public` table with `snapshot_month` / `ingested_at` columns. Now:
-1. Add a **Schedule Trigger** node.
+ * **Dagster owns scheduling** — one job, one weekly schedule. The workflows lost their schedule triggers
+ and only run when Dagster calls them.
+ * **n8n only extracts raw rows** into `staging` (full truncate + insert; no snapshot column).
+ * **dbt derives everything** when it builds `public`: `pokemon_slug`, `pokemon_id` (via the
+ `resolve_pokemon_id` macro), speed-tier math, `wins`/`losses`/`ties`, etc. — all version-controlled.
-2. Set the cron expression to `0 8 5 * *` (8 AM UTC on the 5th of each month).
+#### Pipeline Shape (common)
-3. Pikalytics regenerates its AI endpoints around the 1st of each month — running on the 5th gives a buffer in case
- regeneration is delayed.
+Used by `speed-tiers`, `usage`, and `top-teams`:
-##### 2. Firecrawl Scrape
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Dagster
+ participant n8n
+ participant Firecrawl
+ participant Supabase
+ participant dbt
-_Hits Pikalytics' AI markdown endpoint and extracts structured rows via Firecrawl's JSON mode._
+ Note over Dagster: pikalytics_pipeline_job (weekly, Mon 08:00 LA)
+ Dagster->>n8n: POST /webhook/{path} (trigger_* asset, blocks)
+ n8n->>Supabase: TRUNCATE staging.{table}
+ n8n->>Firecrawl: scrape Pikalytics page
+ Firecrawl-->>n8n: raw rows
+ Note over n8n: Shape Rows (raw cols only + count guard)
+ n8n->>Supabase: INSERT staging.{table}
+ n8n-->>Dagster: 200 (responds when last node finishes)
+ Dagster->>dbt: dbt build (model is downstream of the trigger asset)
+ dbt->>Supabase: build public.{table} (+ pokemon_id via macro, RLS)
+```
-1. Create a [Firecrawl](https://www.firecrawl.dev/signin?view=signup) account to get an API key. The free tier is sufficient for this use case.
-2. After account creation, find your API key in the [dashboard](https://www.firecrawl.dev/app/api-keys) and copy it.
-3. In n8n, add a **Firecrawl** node.
-4. Set up the credential with your Firecrawl API key.
-5. Configure the node:
- * **Resource:** `Scraping`
- * **Operation:** `/scrape`
- * **URL:** `https://www.pikalytics.com/ai/speed-tiers`
-6. Under **Scrape Options**, add **Formats** and configure:
- * **Type:** JSON
- * **Prompt:** `Extract every row from the Champions Speed Tiers table. Each row maps to one Pokemon. Do not skip any rows.`
- * **Schema:**
- ```json
- {
- "type": "object",
- "properties": {
- "rows": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "rank": { "type": "integer" },
- "pokemon": { "type": "string" },
- "base_spe": { "type": "integer" }
- },
- "required": ["rank", "pokemon", "base_spe"]
- }
- }
- }
- }
- ```
-7. Set **Timeout (Ms)** to `120000`. The LLM extraction takes ~55 seconds for 263 rows.
-8. Leave **Only Main Content** enabled.
-
-!!! note "Why only 3 fields?"
-
- Pikalytics' speed tier table has 9 columns, but the other 6 are deterministic functions of `base_spe`
- (Champions uses fixed level 50, max 32 Skill Points, 31 IVs). Asking the LLM to emit all 9 fields × 263 rows
- pushes past Firecrawl's completion limits and times out. Asking for just `rank`, `pokemon`, `base_spe`
- succeeds reliably and lets us derive the rest in the next node.
-
-##### 3. Code (Math + Metadata)
-
-_Derives the remaining six speed columns from `base_spe` and stamps each row with snapshot metadata._
-
-1. Add a **Code** node, connected to Firecrawl's output.
-2. Set **Mode** to `Run Once for All Items` and **Language** to `JavaScript`.
-3. Paste:
-
-```javascript
-const fc = $input.first().json.data.json.rows;
-
-const today = new Date();
-const snapshotMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`;
-
-const rows = fc.map(r => {
- const neutral_0_sp = r.base_spe + 20;
- const neutral_32_sp = r.base_spe + 52;
- const max_speed = Math.floor(neutral_32_sp * 1.1);
- return {
- snapshot_month: snapshotMonth,
- format: "gen9championsvgc2026",
- rank: r.rank,
- pokemon: r.pokemon,
- base_spe: r.base_spe,
- neutral_0_sp,
- neutral_32_sp,
- max_speed,
- neg_spe_0_sp: Math.floor(neutral_0_sp * 0.9),
- max_scarf: Math.floor(max_speed * 1.5),
- neutral_32_scarf: Math.floor(neutral_32_sp * 1.5),
- };
-});
-
-if (rows.length < 200) {
- throw new Error(`Speed tiers row count looks wrong: got ${rows.length}, expected ~263`);
-}
+#### Workflow (common shape)
-return rows.map(json => ({ json }));
-```
+`Webhook → Truncate Staging → Firecrawl Scrape → Shape Rows → Insert Staging`
-The `rows.length < 200` guard turns silent breakage (e.g. Pikalytics changes the table layout) into a workflow
-failure that surfaces immediately rather than letting bad data into Postgres.
+##### 1. Webhook
-The derivation formulas are:
+_Entry point Dagster posts to._
-| Column | Formula |
-|--------|---------|
-| `neutral_0_sp` | `base_spe + 20` |
-| `neutral_32_sp` | `base_spe + 52` |
-| `max_speed` | `floor((base_spe + 52) * 1.1)` |
-| `neg_spe_0_sp` | `floor((base_spe + 20) * 0.9)` |
-| `max_scarf` | `floor(max_speed * 1.5)` |
-| `neutral_32_scarf` | `floor((base_spe + 52) * 1.5)` |
+* **HTTP Method:** `POST`
+* **Path:** `pikalytics-` (see table above)
+* **Respond:** **When Last Node Finishes** — this is what makes the Dagster POST block until the load
+ completes, so dbt never runs on stale/empty staging.
-!!! warning "n8n Cloud's Python sandbox"
+There is **no Schedule Trigger** — Dagster owns the cadence.
- The Code node also supports Python, but n8n Cloud runs Python via Pyodide with no standard library access
- (`datetime`, `math`, etc. are all blocked). For this reason JavaScript is the more practical choice for
- transformations inside n8n. Python work for this project lives in the Dagster pipeline instead.
+##### 2. Truncate Staging
-##### 4. Postgres Upsert (Supabase)
+A **Postgres** node that runs `TRUNCATE` on `staging.pikalytics_` (full-replace each run; the tables have
+no snapshot column). Uses the `Postgres account` credential.
-_Lands the transformed rows in `staging.champions_speed_tiers` idempotently._
+##### 3. Firecrawl Scrape
-1. Add a **Postgres** node, connected to the Code node's output.
-2. Set up the credential with the Supabase **Session Pooler** connection string (port `6543`). The pooler is
- recommended for serverless callers like n8n Cloud.
-3. Configure the node:
- * **Operation:** `Insert or Update` (Upsert)
- * **Schema:** `staging`
- * **Table:** `champions_speed_tiers`
- * **Mapping Column Mode:** `Map Automatically` (the JSON keys from the Code node match column names exactly)
- * **Columns to Match On:** `snapshot_month`, `format`, `pokemon`
+A **Firecrawl** node (`/scrape`) against the relevant Pikalytics URL. The flat tables use Firecrawl's JSON
+mode with a schema describing just the raw columns; the LLM extraction returns one object per row. Uses the
+`Firecrawl account` credential.
-!!! note
+##### 4. Shape Rows
- Supabase requires SSL. If the connection fails with a `pg_hba.conf` error, ensure SSL is enabled in the
- credential settings.
+A **Code** node that keeps **only the raw scraped columns** and applies a row-count guard
+(`throw if rows.length < N`) so a layout change on Pikalytics fails loudly instead of writing bad data. No
+derivations happen here — that's dbt's job.
-##### 5. Trigger Dagster (Planned)
+##### 5. Insert Staging
-_Once the staging row lands, n8n hits the Dagster GraphQL endpoint to launch the dbt-materialization job._
+A **Postgres** node that inserts the raw rows into `staging.pikalytics_`. JSONB columns (e.g. `top-teams`'
+`archetypes` / `pokemon`) are `JSON.stringify`-d in the column mapping.
-This step calls Dagster's `launchRun` mutation against the `dagster-webserver` instance running on EC2.
-Because n8n Cloud has dynamic egress IPs, Dagster is fronted by a Cloudflare Tunnel rather than exposed
-through the EC2 security group directly.
+#### Variant: `pokemon-comp-info` (per-Pokémon loop)
-The HTTP Request node will POST to `https:///graphql` with the mutation:
+This one enriches each of the top-50 usage Pokémon, so it reads the usage list first and loops:
-```graphql
-mutation LaunchRun($p: ExecutionParams!) {
- launchRun(executionParams: $p) {
- __typename
- ... on LaunchRunSuccess { run { runId } }
- ... on PythonError { message }
- }
-}
+```mermaid
+graph LR
+ A[Webhook] --> B[Truncate
comp_info staging]
+ B --> C[Fetch Usage List
SELECT pokemon, web_url
FROM staging.pikalytics_usage]
+ C --> D[Prepare AI URLs]
+ D --> E{{Loop Pokemon
batch 3}}
+ E -->|each batch| F[Scrape AI page]
+ F --> G[Parse Common Sections
markdown → JSONB]
+ G --> H[Insert comp_info staging]
+ H --> E
+ E -->|done| I[Done]
```
-Selecting the `champions_speed_tiers_dbt_job` job in the `card_data` repository location.
+* It reads **`staging.pikalytics_usage`** (the freshly-loaded list), so its Dagster trigger
+ (`trigger_pikalytics_pokemon_comp_info`) **depends on `trigger_pikalytics_usage`** — usage loads first,
+ then comp-info reads it. This is the only cross-workflow dependency in the pipeline.
+* The **Parse Common Sections** Code node parses the scraped markdown into four JSONB arrays
+ (`common_moves`, `common_abilities`, `common_items`, `common_teammates`) of `{name, usage_percent}`. This
+ is genuine *extraction* (structuring a scraped page), so it stays in n8n; `pokemon_id` is still derived in
+ dbt.
+* It's a long run (~50 page scrapes), so the trigger asset uses a generous timeout and limited retries.
+
+#### Dagster + Secrets Wiring
+
+* Each workflow is fired by a `trigger_pikalytics_*` asset in `pipelines/defs/pikalytics/`, which POSTs the
+ webhook URL resolved via `fetch_n8n_webhook_secret("pikalytics-")` (keys in the `n8n_webhook` AWS
+ secret).
+* n8n nodes authenticate with the `Postgres account` (truncate / fetch / insert) and `Firecrawl account`
+ (scrape) credentials.
+* The workflow must be **published/active** for its production webhook URL to respond — if it's toggled off,
+ the trigger asset fails with a 404.
+
+#### Downstream dbt models
-##### 6. Notify (Planned)
+After the n8n workflows finish, dbt builds the public Pikalytics snapshot models:
-_Posts run status to Discord on success or failure._
+| Model | Notes |
+|-------|-------|
+| `pikalytics_speed_tiers` | Resolves each Pokémon to the shared `pokemon` hub and derives common speed benchmarks. |
+| `pikalytics_usage` | Stores top usage rows for the current format. |
+| `pikalytics_top_teams` | Parses team records and stores `pokemon_ids` as a JSONB array aligned with the scraped team list. |
+| `pikalytics_pokemon_comp_info` | Stores common moves, abilities, items, and teammates scraped from each Pokémon detail page. |
-A final HTTP Request (or Discord) node sends a webhook with run summary — row count, duration, dbt status —
-mirroring the existing Dagster-run notifications described in the [Overview](index.md#data-infrastructure-diagram).
+The public models are current snapshots, not history tables. A successful run replaces staging and rebuilds the public tables for the current format.
-##### 7. Verifying the Pipeline
+#### Verifying
-After the upsert step succeeds:
+After a run of `pikalytics_pipeline_job`:
-1. Open Supabase → Table Editor → `staging.champions_speed_tiers`.
-2. Confirm 263 rows exist with `snapshot_month` set to the first of the current month.
-3. Spot-check a handful of rows against [pikalytics.com/speed-tiers](https://www.pikalytics.com/speed-tiers) —
- for example, Mega Aerodactyl should appear at rank 1 with `base_spe = 150`, `max_speed = 222`.
+1. Confirm `staging.pikalytics_` and `public.pikalytics_` row counts match.
+2. Confirm `pokemon_id` is non-null (it's resolved against the `public.pokemon` hub in dbt).
+3. For `pokemon-comp-info`, confirm the four `common_*` JSONB arrays are populated.
---
diff --git a/docs/Infrastructure_Guide/supabase.md b/docs/Infrastructure_Guide/supabase.md
index a9cf547d..f38e52cd 100644
--- a/docs/Infrastructure_Guide/supabase.md
+++ b/docs/Infrastructure_Guide/supabase.md
@@ -30,4 +30,26 @@ postgresql://postgres.[USERNAME]:[YOUR-PASSWORD]@aws-0-us-east-2.pooler.supabase
```
6. Note the connection string for later instructions such as creating a secret of the string in AWS Secrets Manager[^1].
+## How `poke-cli` Uses Supabase
+
+Supabase stores project-owned datasets from several sources to provide information that cannot be fetched directly from PokéAPI at CLI runtime.
+
+| Source | Provides | Used by |
+|--------|----------|---------|
+| TCGDex | Card, set, series, and image metadata | `poke-cli card` |
+| TCGCSV / TCGPlayer | Market pricing for TCG cards | `poke-cli card` |
+| pokedata.ovh | Competitive TCG and VGC events, standings, rounds, and decklists | `poke-cli comp`, Streamlit web app |
+| Limitless | TCG archetype and decklist-link enrichment for tournament standings | `poke-cli comp`, Streamlit web app |
+| PokéAPI CSV exports | Video-game reference data used for relational joins and internal analytics | dbt models, internal analytics |
+| Pikalytics via n8n + Firecrawl | Current VGC metagame snapshots scraped from rendered web pages | Streamlit web app, competitive analysis |
+
+## Schemas
+
+| Schema | Purpose |
+|--------|---------|
+| `staging` | Raw loads from Python/Dagster and n8n scraper workflows. These tables are optimized for bulk loading and do not carry the full public constraint set. |
+| `public` | dbt-built consumer tables and views. These are the objects exposed through Supabase REST and used by the CLI/web app. |
+
+The CLI uses a publishable `sb_publishable_*` key for read-only REST access.
+
[^1]: Used in section: [3. AWS // Secrets Manager](aws.md#secrets-manager).
\ No newline at end of file
diff --git a/docs/assets/command_flows/command-comp-flow.png b/docs/assets/command_flows/command-comp-flow.png
new file mode 100644
index 00000000..ce22b844
Binary files /dev/null and b/docs/assets/command_flows/command-comp-flow.png differ
diff --git a/docs/assets/ability.gif b/docs/assets/command_gifs/ability.gif
similarity index 100%
rename from docs/assets/ability.gif
rename to docs/assets/command_gifs/ability.gif
diff --git a/docs/assets/command_gifs/berry.gif b/docs/assets/command_gifs/berry.gif
new file mode 100644
index 00000000..2cda671b
Binary files /dev/null and b/docs/assets/command_gifs/berry.gif differ
diff --git a/docs/assets/card.gif b/docs/assets/command_gifs/card.gif
similarity index 100%
rename from docs/assets/card.gif
rename to docs/assets/command_gifs/card.gif
diff --git a/docs/assets/command_gifs/comp.gif b/docs/assets/command_gifs/comp.gif
new file mode 100644
index 00000000..1fdd10ef
Binary files /dev/null and b/docs/assets/command_gifs/comp.gif differ
diff --git a/docs/assets/item.gif b/docs/assets/command_gifs/item.gif
similarity index 100%
rename from docs/assets/item.gif
rename to docs/assets/command_gifs/item.gif
diff --git a/docs/assets/move.gif b/docs/assets/command_gifs/move.gif
similarity index 100%
rename from docs/assets/move.gif
rename to docs/assets/command_gifs/move.gif
diff --git a/docs/assets/natures.gif b/docs/assets/command_gifs/natures.gif
similarity index 100%
rename from docs/assets/natures.gif
rename to docs/assets/command_gifs/natures.gif
diff --git a/docs/assets/pokemon_abilities_moves.gif b/docs/assets/command_gifs/pokemon-abilities-moves.gif
similarity index 100%
rename from docs/assets/pokemon_abilities_moves.gif
rename to docs/assets/command_gifs/pokemon-abilities-moves.gif
diff --git a/docs/assets/pokemon_defense.gif b/docs/assets/command_gifs/pokemon-defense.gif
similarity index 100%
rename from docs/assets/pokemon_defense.gif
rename to docs/assets/command_gifs/pokemon-defense.gif
diff --git a/docs/assets/pokemon_image.gif b/docs/assets/command_gifs/pokemon-image.gif
similarity index 100%
rename from docs/assets/pokemon_image.gif
rename to docs/assets/command_gifs/pokemon-image.gif
diff --git a/docs/assets/pokemon_stats.gif b/docs/assets/command_gifs/pokemon-stats.gif
similarity index 100%
rename from docs/assets/pokemon_stats.gif
rename to docs/assets/command_gifs/pokemon-stats.gif
diff --git a/docs/assets/search.gif b/docs/assets/command_gifs/search.gif
similarity index 100%
rename from docs/assets/search.gif
rename to docs/assets/command_gifs/search.gif
diff --git a/docs/assets/speed.gif b/docs/assets/command_gifs/speed.gif
similarity index 100%
rename from docs/assets/speed.gif
rename to docs/assets/command_gifs/speed.gif
diff --git a/docs/assets/types.gif b/docs/assets/command_gifs/types.gif
similarity index 100%
rename from docs/assets/types.gif
rename to docs/assets/command_gifs/types.gif
diff --git a/docs/assets/services/cli-tcg-flow.png b/docs/assets/services/cli-tcg-flow.png
new file mode 100644
index 00000000..b6a6c687
Binary files /dev/null and b/docs/assets/services/cli-tcg-flow.png differ
diff --git a/docs/assets/services/rust-aggregation-service.png b/docs/assets/services/rust-aggregation-service.png
new file mode 100644
index 00000000..68f19f61
Binary files /dev/null and b/docs/assets/services/rust-aggregation-service.png differ
diff --git a/docs/assets/services/rust-caching-service.png b/docs/assets/services/rust-caching-service.png
new file mode 100644
index 00000000..8faac864
Binary files /dev/null and b/docs/assets/services/rust-caching-service.png differ
diff --git a/docs/assets/tcg.gif b/docs/assets/tcg.gif
deleted file mode 100644
index 5def3412..00000000
Binary files a/docs/assets/tcg.gif and /dev/null differ
diff --git a/docs/commands.md b/docs/commands.md
index 1813a731..0c33cdb1 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -1,12 +1,32 @@
# Commands
## main
+* Print the help menu or view information about the program
**Available Flags**
+* `--config | -c`
* `--latest | -l`
* `--version | -v`
+Exampe:
+
+```bash
+# print help menu
+poke-cli
+# or
+poke-cli --help
+
+# edit program settings
+poke-cli --config
+
+# check latest release vesion
+poke-cli --latest
+
+# check current installed version
+poke-cli --version
+```
+
---
## `ability`
@@ -18,14 +38,32 @@ the generation in which it first appeared, and a list of Pokémon that possess i
* `--pokemon | -p`
Example:
-```console
+```bash
poke-cli ability solar-power
poke-cli ability solar-power --pokemon # list Pokémon that posses the ability
```
Output:
-
+
+
+---
+
+## `berry`
+* Retrieve information about a specific berry.
+
+Example:
+```bash
+# specific berry
+poke-cli berry oran
+
+# TUI screen
+poke-cli berry
+```
+
+Output:
+
+
---
@@ -54,13 +92,35 @@ The following terminals are confirmed to have protocol support and render card i
Basic terminal emulators may show card details without images or may not render images correctly.
Example:
-```console
+```bash
poke-cli card
```
Output:
-
+
+
+---
+
+## `comp`
+* Browse current competitive Pokémon standings through an interactive TUI.
+
+The command opens a competition picker:
+
+1. Select `TCG` or `VGC`.
+2. Select a tournament.
+3. Browse the tournament dashboard.
+
+The dashboard supports Overview / Standings / Decks / Countries tabs for TCG and Overview / Standings / Usage / Countries tabs for VGC. Press `w` inside the TUI to open the web dashboard.
+
+Example:
+```bash
+poke-cli comp
+```
+
+Output:
+
+
---
@@ -68,42 +128,46 @@ Output:
* Retrieve information about a specific item, including its cost, category and description.
Example:
-```console
+```bash
poke-cli item poke-ball
```
Output:
-
+
---
-## `move`
-* Retrieve information about a specific move, including its type, power, PP, accuracy, category, etc.,
-and the move's effect.
+## `mechanics`
+* Retrieve data about video game mechanics.
+
+**Available Flags**
+
+* `--natures | -n`
Example:
-```console
-poke-cli move dazzling-gleam
+```bash
+poke-cli mechanics --natures
```
Output:
-
+
---
-## `natures`
-* Retrieve a table of all natures and the stats they affect.
+## `move`
+* Retrieve information about a specific move, including its type, power, PP, accuracy, category, etc.,
+and the move's effect.
Example:
-```console
-poke-cli natures
+```bash
+poke-cli move dazzling-gleam
```
Output:
-
+
---
@@ -113,53 +177,49 @@ Output:
**Available Flags**
* `-a | --abilities`
-* `-d | --defense`
+* `-d | --defenses`
* `-i=xx | --image=xx`
* `-m | --moves`
* `-s | --stats`
-* `-t | --types`
-!!! warning
-
- The `-t | --types` flag is deprecated will be removed in v2.
- The Pokémon's typing is now included in the base `pokemon` command.
+The Pokémon's typing is included in the base `pokemon` command output.
Example:
-```console
+```bash
poke-cli pokemon rockruff --abilities --moves
```
Output:
-
+
Example:
-```console
-poke-cli pokemon gastrodon --defense
+```bash
+poke-cli pokemon gastrodon --defenses
```
Output:
-
+
Example:
-```console
+```bash
# choose between three sizes: 'sm', 'md', 'lg'
poke-cli pokemon tyranitar --image=sm
```
Output:
-
+
Example:
-```console
+```bash
poke-cli pokemon cacturne --stats
```
Output:
-
+
---
@@ -167,13 +227,13 @@ Output:
* Search for resources from different endpoints. Searchable endpoints include `ability`, `pokemon`, and `move`.
Example:
-```console
+```bash
poke-cli search
```
Output:
-
+
---
@@ -194,30 +254,12 @@ The command opens an interactive form and asks for the following values:
The final speed is calculated with the standard stat formula and rounded down.
Example:
-```console
+```bash
poke-cli speed
```
Output:
-
-
----
-
-## `tcg`
-* Retrieve details about all competitive TCG tournaments for the current season.
-
-**Available Flags**
-
-* `--web | -w` - Open the tournament's website in the default browser.
-
-Example:
-```console
-poke-cli tcg
-```
-
-Output:
-
-
+
---
@@ -225,9 +267,9 @@ Output:
* Retrieve details about a specific type and a damage relation table.
Example:
-```console
+```bash
poke-cli types
```
Output:
-
+
diff --git a/docs/installation.md b/docs/installation.md
index d2211051..6f5c2e3e 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -46,7 +46,6 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
| Package Type | Distributions | Repository Setup | Installation Command |
|:------------:|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------|
-| `apk` | Alpine | `sudo apk add --no-cache bash && curl -1sLf 'https://dl.cloudsmith.io/basic/digitalghost-dev/poke-cli/setup.alpine.sh' \| sudo -E bash` | `sudo apk add poke-cli --update-cache` |
| `deb` | Ubuntu, Debian | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.deb.sh' \| sudo -E bash` | `sudo apt-get install poke-cli` |
| `rpm` | Fedora, CentOS, Red Hat, openSUSE | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.rpm.sh' \| sudo -E bash` | `sudo yum install poke-cli` |
@@ -63,11 +62,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```console
- docker run --rm -it digitalghostdev/poke-cli:v1.10.3 [subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v2.0.0 [subcommand] [flag]
```
* Enter the container and use its shell:
```console
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.3 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v2.0.0 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -77,13 +76,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly:
```console
# Kitty
- docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.3 card
+ docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v2.0.0 card
# WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby
- docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.3 card
+ docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v2.0.0 card
# Windows Terminal (Sixel)
- docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.3 card
+ docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v2.0.0 card
```
If your terminal is not listed above, image rendering is not supported inside Docker.
@@ -108,10 +107,10 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
#### Example usage
```console
# Windows
- .\poke-cli.exe pokemon charizard --types --abilities
+ .\poke-cli.exe pokemon charizard --abilities
# Unix
- .\poke-cli ability airlock --pokemon
+ ./poke-cli ability airlock --pokemon
```
### Source
@@ -121,3 +120,7 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
go install github.com/digitalghost-dev/poke-cli@latest
```
2. The tool should be ready to use if `$PATH` is set up.
+
+!!! tip
+
+ `go install` builds only the `poke-cli` binary, **not** the `poke-cache` caching helper (a separate binary that every packaged install bundles). `poke-cli` works the same without it; it just calls PokéAPI directly instead of caching responses on disk. To enable caching, download the `poke-cache` archive for your platform from the [releases](https://github.com/digitalghost-dev/poke-cli/releases/latest) page, extract it, and move the `poke-cache` binary onto your `$PATH`.
diff --git a/flags/abilityflagset.go b/flags/ability_flagset.go
similarity index 87%
rename from flags/abilityflagset.go
rename to flags/ability_flagset.go
index d82cfbad..52540931 100644
--- a/flags/abilityflagset.go
+++ b/flags/ability_flagset.go
@@ -1,29 +1,27 @@
package flags
import (
- "flag"
"fmt"
"io"
"strings"
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/styling"
+ flag "github.com/spf13/pflag"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type AbilityFlags struct {
- FlagSet *flag.FlagSet
- Pokemon *bool
- ShortPokemon *bool
+ FlagSet *flag.FlagSet
+ Pokemon *bool
}
func SetupAbilityFlagSet() *AbilityFlags {
af := &AbilityFlags{}
af.FlagSet = flag.NewFlagSet("abilityFlags", flag.ContinueOnError)
- af.Pokemon = af.FlagSet.Bool("pokemon", false, "List all Pokémon with chosen ability")
- af.ShortPokemon = af.FlagSet.Bool("p", false, "List all Pokémon with chosen ability")
+ af.Pokemon = af.FlagSet.BoolP("pokemon", "p", false, "List all Pokémon with chosen ability")
af.FlagSet.Usage = func() {
helpMessage := styling.HelpBorder.Render("poke-cli ability [flags]\n\n",
diff --git a/flags/abilityflagset_test.go b/flags/ability_flagset_test.go
similarity index 97%
rename from flags/abilityflagset_test.go
rename to flags/ability_flagset_test.go
index f1a9cd67..124fec70 100644
--- a/flags/abilityflagset_test.go
+++ b/flags/ability_flagset_test.go
@@ -25,7 +25,6 @@ func TestSetupAbilityFlagSet(t *testing.T) {
name string
}{
{af.Pokemon, false, "Pokemon flag should be 'pokemon'"},
- {af.ShortPokemon, false, "Short pokemon flag should be 'p'"},
}
for _, tt := range flagTests {
diff --git a/flags/config.go b/flags/config.go
new file mode 100644
index 00000000..6795cbe3
--- /dev/null
+++ b/flags/config.go
@@ -0,0 +1,155 @@
+// Package config loads and saves the user's poke-cli preferences as a TOML file.
+
+// config storage across operating systems:
+// macOS: ~/Library/Application Support/poke-cli/config.toml
+// Windows: %AppData%\poke-cli\config.toml
+// Linux: ~/.config/poke-cli/config.toml
+
+package flags
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ toml "github.com/pelletier/go-toml/v2"
+)
+
+const SchemaVersion = 1
+
+const (
+ ThemeYellow = "yellow"
+ ThemeRed = "red"
+ ThemeBlue = "blue"
+)
+
+const (
+ ImageAuto = "auto"
+ ImageKitty = "kitty"
+ ImageSixel = "sixel"
+ ImageNone = "none"
+)
+
+type Config struct {
+ Version int `toml:"version"`
+ Display Display `toml:"display"`
+ Cache Cache `toml:"cache"`
+}
+
+type Display struct {
+ Theme string `toml:"theme"`
+ ImageProtocol string `toml:"image_protocol"`
+}
+
+// Cache controls the poke-cache integration: whether to show the "not
+// installed" warning, and an optional explicit binary path for when poke-cache
+// is not on PATH.
+type Cache struct {
+ ShowWarning bool `toml:"show_warning"`
+ Path string `toml:"path"`
+}
+
+func Defaults() Config {
+ return Config{
+ Version: SchemaVersion,
+ Display: Display{
+ Theme: ThemeYellow,
+ ImageProtocol: ImageAuto,
+ },
+ Cache: Cache{
+ ShowWarning: true,
+ },
+ }
+}
+
+// Path resolves where the config lives. os.UserConfigDir gives the OS-native
+// base and a poke-cli/ dir is added so future state files have a home alongside it.
+func Path() (string, error) {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "poke-cli", "config.toml"), nil
+}
+
+// Load resolves the real path and delegates to LoadFrom.
+func Load() (Config, bool, error) {
+ path, err := Path()
+ if err != nil {
+ return Defaults(), false, err
+ }
+ return LoadFrom(path)
+}
+
+func LoadFrom(path string) (Config, bool, error) {
+ cfg := Defaults()
+ data, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return cfg, true, nil
+ }
+ if err != nil {
+ return cfg, false, err
+ }
+ if err := toml.Unmarshal(data, &cfg); err != nil {
+ return Defaults(), false, err
+ }
+ cfg.normalize()
+ return cfg, false, nil
+}
+
+// Save resolves the real path and delegates to SaveTo.
+func Save(cfg Config) error {
+ path, err := Path()
+ if err != nil {
+ return err
+ }
+ return SaveTo(path, cfg)
+}
+
+func SaveTo(path string, cfg Config) error {
+ cfg.normalize()
+ data, err := toml.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(dir, "config-*.toml")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ _ = os.Remove(tmpName)
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpName)
+ return err
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ _ = os.Remove(tmpName)
+ return err
+ }
+ return nil
+}
+
+func (c *Config) normalize() {
+ switch c.Display.Theme {
+ case ThemeYellow, ThemeRed, ThemeBlue:
+ default:
+ c.Display.Theme = ThemeYellow
+ }
+ switch c.Display.ImageProtocol {
+ case ImageAuto, ImageKitty, ImageSixel, ImageNone:
+ default:
+ c.Display.ImageProtocol = ImageAuto
+ }
+ if c.Version == 0 {
+ c.Version = SchemaVersion
+ }
+}
diff --git a/flags/config_test.go b/flags/config_test.go
new file mode 100644
index 00000000..90b54d50
--- /dev/null
+++ b/flags/config_test.go
@@ -0,0 +1,100 @@
+package flags
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func writeTempConfig(t *testing.T, content string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.toml")
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+ return path
+}
+
+func TestDefaults(t *testing.T) {
+ cfg := Defaults()
+
+ assert.Equal(t, SchemaVersion, cfg.Version)
+ assert.Equal(t, ThemeYellow, cfg.Display.Theme)
+ assert.Equal(t, ImageAuto, cfg.Display.ImageProtocol)
+ assert.True(t, cfg.Cache.ShowWarning)
+}
+
+func TestLoadFromMissingIsFirstRun(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "does-not-exist.toml")
+
+ cfg, firstRun, err := LoadFrom(path)
+
+ require.NoError(t, err)
+ assert.True(t, firstRun)
+ assert.Equal(t, Defaults(), cfg)
+}
+
+func TestLoadFromPartialKeepsDefaults(t *testing.T) {
+ path := writeTempConfig(t, "[display]\ntheme = \"red\"\n")
+
+ cfg, firstRun, err := LoadFrom(path)
+
+ require.NoError(t, err)
+ assert.False(t, firstRun)
+ assert.Equal(t, ThemeRed, cfg.Display.Theme)
+ assert.Equal(t, ImageAuto, cfg.Display.ImageProtocol)
+ assert.True(t, cfg.Cache.ShowWarning)
+}
+
+func TestLoadFromCorruptFallsBack(t *testing.T) {
+ path := writeTempConfig(t, "[display]\ntheme = \"unterminated\n")
+
+ cfg, firstRun, err := LoadFrom(path)
+
+ require.Error(t, err)
+ assert.False(t, firstRun)
+ assert.Equal(t, Defaults(), cfg)
+}
+
+func TestLoadFromClampsUnknownValues(t *testing.T) {
+ path := writeTempConfig(t, "[display]\ntheme = \"chartreuse\"\nimage_protocol = \"ascii\"\n")
+
+ cfg, _, err := LoadFrom(path)
+
+ require.NoError(t, err)
+ assert.Equal(t, ThemeYellow, cfg.Display.Theme)
+ assert.Equal(t, ImageAuto, cfg.Display.ImageProtocol)
+}
+
+func TestSaveToLoadFromRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "nested", "config.toml")
+ want := Config{
+ Version: SchemaVersion,
+ Display: Display{Theme: ThemeBlue, ImageProtocol: ImageKitty},
+ Cache: Cache{ShowWarning: false, Path: "/opt/poke-cache"},
+ }
+
+ require.NoError(t, SaveTo(path, want))
+
+ got, firstRun, err := LoadFrom(path)
+ require.NoError(t, err)
+ assert.False(t, firstRun)
+ assert.Equal(t, want, got)
+}
+
+func TestSaveToCleansUpTempOnFailure(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "config.toml")
+ require.NoError(t, os.Mkdir(target, 0o755))
+
+ err := SaveTo(target, Defaults())
+ require.Error(t, err)
+
+ entries, readErr := os.ReadDir(dir)
+ require.NoError(t, readErr)
+ for _, e := range entries {
+ assert.False(t, strings.HasPrefix(e.Name(), "config-"), "temp file %q should have been cleaned up", e.Name())
+ }
+}
diff --git a/cmd/natures/natures.go b/flags/mechanics_flagset.go
similarity index 61%
rename from cmd/natures/natures.go
rename to flags/mechanics_flagset.go
index 377d80cd..fec26753 100644
--- a/cmd/natures/natures.go
+++ b/flags/mechanics_flagset.go
@@ -1,39 +1,39 @@
-package natures
+package flags
import (
+ "fmt"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
- "github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
+ flag "github.com/spf13/pflag"
)
-func NaturesCommand(args []string) (string, error) {
- var output strings.Builder
+type MechanicsFlags struct {
+ FlagSet *flag.FlagSet
+ Natures *bool
+}
+
+func SetupMechanicsFlagSet() *MechanicsFlags {
+ mf := &MechanicsFlags{}
+ mf.FlagSet = flag.NewFlagSet("mechanicsFlags", flag.ContinueOnError)
- usage := func() {
- output.WriteString(
- utils.GenerateHelpMessage(
- utils.HelpConfig{
- Description: "Get details about all natures.",
- CmdName: "natures",
- },
- ),
+ mf.Natures = mf.FlagSet.BoolP("natures", "n", false, "Show a table with natures.")
+
+ mf.FlagSet.Usage = func() {
+ helpMessage := styling.HelpBorder.Render("poke-cli mechanics [flags]\n\n",
+ styling.StyleBold.Render("FLAGS:"),
+ fmt.Sprintf("\n\t%-30s %s", "-n, --natures", "Show a table with natures."),
)
+ fmt.Println(helpMessage)
}
- if utils.CheckHelpFlag(args, usage) {
- return output.String(), nil
- }
+ return mf
+}
- if err := utils.ValidateArgs(
- args,
- utils.Validator{MaxArgs: 2, CmdName: "natures", RequireName: false, HasFlags: false},
- ); err != nil {
- output.WriteString(err.Error())
- return output.String(), err
- }
+func NaturesFlag() string {
+ var output strings.Builder
output.WriteString("Natures affect the growth of a Pokémon.\n" +
"Each nature increases one of its stats by 10% and decreases one by 10%.\n" +
@@ -64,12 +64,5 @@ func NaturesCommand(args []string) (string, error) {
output.WriteString(t.Render())
output.WriteString("\n")
- deprecationWarning := styling.WarningBorder.Render(
- styling.WarningColor.Render("⚠ Warning!"),
- "\nThe natures command is deprecated\nand will be removed in v2.\n\nIt will move to a flag under the\nnew mechanics command. ",
- )
- output.WriteString("\n")
- output.WriteString(deprecationWarning)
-
- return output.String(), nil
+ return output.String()
}
diff --git a/flags/mechanics_flagset_test.go b/flags/mechanics_flagset_test.go
new file mode 100644
index 00000000..e74758c3
--- /dev/null
+++ b/flags/mechanics_flagset_test.go
@@ -0,0 +1,63 @@
+package flags
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/digitalghost-dev/poke-cli/styling"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSetupMechanicsFlagSet(t *testing.T) {
+ mf := SetupMechanicsFlagSet()
+
+ assert.NotNil(t, mf, "Flag set should not be nil")
+ assert.Equal(t, "mechanicsFlags", mf.FlagSet.Name(), "Flag set name should be 'mechanicsFlags'")
+
+ flagTests := []struct {
+ flag interface{}
+ expected interface{}
+ name string
+ }{
+ {mf.Natures, false, "Natures flag should default to false"},
+ }
+
+ for _, tt := range flagTests {
+ assert.NotNil(t, tt.flag, tt.name)
+ assert.Equal(t, tt.expected, reflect.ValueOf(tt.flag).Elem().Interface(), tt.name)
+ }
+}
+
+func TestMechanicsFlagSetParse(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantNatures bool
+ }{
+ {"long natures flag", []string{"--natures"}, true},
+ {"short natures flag", []string{"-n"}, true},
+ {"no flags", []string{}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mf := SetupMechanicsFlagSet()
+ err := mf.FlagSet.Parse(tt.args)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantNatures, *mf.Natures, "natures flag value")
+ })
+ }
+}
+
+func TestNaturesFlag(t *testing.T) {
+ output := styling.StripANSI(NaturesFlag())
+
+ assert.Contains(t, output, "Natures affect the growth of a Pokémon.")
+ assert.Contains(t, output, "Nature Chart:")
+
+ // Spot-check natures from each row/column of the chart.
+ for _, nature := range []string{"Hardy", "Adamant", "Brave", "Timid", "Serious"} {
+ assert.Contains(t, output, nature, "chart should contain nature %q", nature)
+ }
+}
diff --git a/flags/pokemonflagset.go b/flags/pokemon_flagset.go
similarity index 85%
rename from flags/pokemonflagset.go
rename to flags/pokemon_flagset.go
index 3686965e..c4d98061 100644
--- a/flags/pokemonflagset.go
+++ b/flags/pokemon_flagset.go
@@ -3,7 +3,6 @@
package flags
import (
- "flag"
"fmt"
"image"
"io"
@@ -23,6 +22,7 @@ import (
"github.com/digitalghost-dev/poke-cli/structs"
"github.com/digitalghost-dev/poke-cli/styling"
"github.com/disintegration/imaging"
+ flag "github.com/spf13/pflag"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -32,19 +32,12 @@ const maxPokemonSpriteBytes = 5 * 1024 * 1024 // 5 MiB
var pokemonSpriteHTTPClient = connections.NewDefaultHTTPClient()
type PokemonFlags struct {
- FlagSet *flag.FlagSet
- Abilities *bool
- ShortAbilities *bool
- Defenses *bool
- ShortDefenses *bool
- Image *string
- ShortImage *string
- Moves *bool
- ShortMoves *bool
- Stats *bool
- ShortStats *bool
- Types *bool
- ShortTypes *bool
+ FlagSet *flag.FlagSet
+ Abilities *bool
+ Defenses *bool
+ Image *string
+ Moves *bool
+ Stats *bool
}
func header(header string) string {
@@ -66,23 +59,15 @@ func SetupPokemonFlagSet() *PokemonFlags {
pf := &PokemonFlags{}
pf.FlagSet = flag.NewFlagSet("pokeFlags", flag.ContinueOnError)
- pf.Abilities = pf.FlagSet.Bool("abilities", false, "Print the Pokémon's abilities")
- pf.ShortAbilities = pf.FlagSet.Bool("a", false, "Print the Pokémon's abilities")
+ pf.Abilities = pf.FlagSet.BoolP("abilities", "a", false, "Print the Pokémon's abilities")
- pf.Defenses = pf.FlagSet.Bool("defense", false, "Print the Pokémon's type defenses")
- pf.ShortDefenses = pf.FlagSet.Bool("d", false, "Print the Pokémon's type defenses")
+ pf.Defenses = pf.FlagSet.BoolP("defenses", "d", false, "Print the Pokémon's type defenses")
- pf.Image = pf.FlagSet.String("image", "", "Print the Pokémon's default sprite")
- pf.ShortImage = pf.FlagSet.String("i", "", "Print the Pokémon's default sprite")
+ pf.Image = pf.FlagSet.StringP("image", "i", "", "Print the Pokémon's default sprite")
- pf.Moves = pf.FlagSet.Bool("moves", false, "Print the Pokémon's learnable moves")
- pf.ShortMoves = pf.FlagSet.Bool("m", false, "Print the Pokémon's learnable moves")
+ pf.Moves = pf.FlagSet.BoolP("moves", "m", false, "Print the Pokémon's learnable moves")
- pf.Stats = pf.FlagSet.Bool("stats", false, "Print the Pokémon's base stats")
- pf.ShortStats = pf.FlagSet.Bool("s", false, "Print the Pokémon's base stats")
-
- pf.Types = pf.FlagSet.Bool("types", false, "Print the Pokémon's typing")
- pf.ShortTypes = pf.FlagSet.Bool("t", false, "Print the Pokémon's typing")
+ pf.Stats = pf.FlagSet.BoolP("stats", "s", false, "Print the Pokémon's base stats")
hintMessage := styling.StyleItalic.Render("options: [sm, md, lg]")
@@ -90,12 +75,11 @@ func SetupPokemonFlagSet() *PokemonFlags {
helpMessage := styling.HelpBorder.Render("poke-cli pokemon [flags]\n\n",
styling.StyleBold.Render("FLAGS:"),
fmt.Sprintf("\n\t%-30s %s", "-a, --abilities", "Prints the Pokémon's abilities."),
- fmt.Sprintf("\n\t%-30s %s", "-d, --defense", "Prints the Pokémon's type defenses."),
+ fmt.Sprintf("\n\t%-30s %s", "-d, --defenses", "Prints the Pokémon's type defenses."),
fmt.Sprintf("\n\t%-30s %s", "-i=xx, --image=xx", "Prints out the Pokémon's default sprite."),
fmt.Sprintf("\n\t%5s%-15s", "", hintMessage),
fmt.Sprintf("\n\t%-30s %s", "-m, --moves", "Prints the Pokémon's learnable moves."),
fmt.Sprintf("\n\t%-30s %s", "-s, --stats", "Prints the Pokémon's base stats."),
- fmt.Sprintf("\n\t%-30s %s", "-t, --types", styling.ErrorColor.Render("Deprecated. Typing is included by default.")),
fmt.Sprintf("\n\t%-30s %s", "-h, --help", "Prints the help menu."),
)
fmt.Println(helpMessage)
@@ -664,38 +648,3 @@ func StatsFlag(w io.Writer, endpoint string, pokemonName string) error {
return nil
}
-
-func TypesFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
- if err != nil {
- return err
- }
-
- // Print the header from header func
- _, err = fmt.Fprintln(w, header("Typing"))
- if err != nil {
- return err
- }
-
- for _, pokeType := range pokemonStruct.Types {
- colorHex, exists := styling.ColorMap[pokeType.Type.Name]
- if exists {
- color := lipgloss.Color(colorHex)
- style := lipgloss.NewStyle().Bold(true).Foreground(color)
- styledName := style.Render(cases.Title(language.English).String(pokeType.Type.Name))
- _, err := fmt.Fprintf(w, "Type %d: %s\n", pokeType.Slot, styledName)
- if err != nil {
- return err
- }
- } else {
- _, err := fmt.Fprintf(w, "Type %d: %s\n", pokeType.Slot, cases.Title(language.English).String(pokeType.Type.Name))
- if err != nil {
- return err
- }
- }
- }
-
- fmt.Fprintln(w, styling.WarningBorder.Render(styling.WarningColor.Render("⚠ Warning!"), "\nThe '-t | --types' flag is deprecated\nand will be removed in v2.\n\nTyping is now included by default.\nYou no longer need this flag. "))
-
- return nil
-}
diff --git a/flags/pokemonflagset_test.go b/flags/pokemon_flagset_test.go
similarity index 81%
rename from flags/pokemonflagset_test.go
rename to flags/pokemon_flagset_test.go
index 10b4f886..4c9341e2 100644
--- a/flags/pokemonflagset_test.go
+++ b/flags/pokemon_flagset_test.go
@@ -24,17 +24,10 @@ func TestSetupPokemonFlagSet(t *testing.T) {
name string
}{
{pf.Abilities, false, "Abilities flag should be 'abilities'"},
- {pf.ShortAbilities, false, "Short abilities flag should be 'a'"},
{pf.Defenses, false, "Defenses flag should be 'defense'"},
- {pf.ShortDefenses, false, "Short Defenses flag should be 'd'"},
{pf.Image, "", "Image flag default value should be 'md'"},
- {pf.ShortImage, "", "Short image flag default value should be 'md'"},
{pf.Moves, false, "Moves flag default value should be 'moves'"},
- {pf.ShortMoves, false, "Short moves flag default value should be 'm'"},
- {pf.Types, false, "Types flag should be 'types'"},
{pf.Stats, false, "Stats flag should be 'stats'"},
- {pf.ShortStats, false, "Short stats flag should be 's'"},
- {pf.ShortTypes, false, "Short types flag should be 't'"},
}
for _, tt := range flagTests {
@@ -243,41 +236,3 @@ Total 318
assert.Equal(t, expectedOutput, actualOutput, "Output should contain data for the stats flag")
}
-
-func TestTypesFlag(t *testing.T) {
- var output bytes.Buffer
- stdout := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
-
- err := TypesFlag(&output, "pokemon", "bulbasaur")
-
- if closeErr := w.Close(); closeErr != nil {
- t.Fatalf("Failed to close pipe writer: %v", closeErr)
- }
- os.Stdout = stdout
-
- _, readErr := output.ReadFrom(r)
- if readErr != nil {
- t.Fatalf("Failed to read from pipe: %v", readErr)
- }
-
- require.NoError(t, err, "TypesFlag should not return an error for a valid Pokémon")
-
- expectedOutput := `──────
-Typing
-Type 1: Grass
-Type 2: Poison
-╭─────────────────────────────────────╮
-│⚠ Warning! │
-│The '-t | --types' flag is deprecated│
-│and will be removed in v2. │
-│ │
-│Typing is now included by default. │
-│You no longer need this flag. │
-╰─────────────────────────────────────╯
-`
- actualOutput := styling.StripANSI(output.String())
-
- assert.Equal(t, expectedOutput, actualOutput, "Output should contain data for the types flag")
-}
diff --git a/flags/tcg_flagset.go b/flags/tcg_flagset.go
deleted file mode 100644
index 93cba581..00000000
--- a/flags/tcg_flagset.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package flags
-
-import (
- "flag"
- "fmt"
- "os/exec"
- "runtime"
- "strings"
-
- "github.com/digitalghost-dev/poke-cli/styling"
-)
-
-type TcgFlags struct {
- FlagSet *flag.FlagSet
- Web *bool
- ShortWeb *bool
-}
-
-func SetupTcgFlagSet() *TcgFlags {
- tf := &TcgFlags{}
- tf.FlagSet = flag.NewFlagSet("tcgFlags", flag.ContinueOnError)
-
- tf.Web = tf.FlagSet.Bool("web", false, "Opens a Streamlit dashboard of stats in the browser")
- tf.ShortWeb = tf.FlagSet.Bool("w", false, "Opens a Streamlit dashboard of stats in the browser")
-
- tf.FlagSet.Usage = func() {
- helpMessage := styling.HelpBorder.Render(
- "poke-cli tcg [flags]\n\n",
- styling.StyleBold.Render("FLAGS:"),
- fmt.Sprintf("\n\t%-30s %s", "-w, --web", "Opens a Streamlit dashboard of stats in the browser."),
- )
- fmt.Println(helpMessage)
- }
-
- return tf
-}
-
-func WebFlag(url string) (string, error) {
- var output strings.Builder
-
- var openCmd *exec.Cmd
- var browserCmd string
-
- switch runtime.GOOS {
- case "windows":
- browserCmd = "cmd"
- openCmd = exec.Command("cmd", "/c", "start", url) //nolint:gosec
- case "darwin":
- browserCmd = "open"
- openCmd = exec.Command("open", url)
- default:
- browserCmd = "xdg-open"
- openCmd = exec.Command("xdg-open", url)
- }
-
- if _, err := exec.LookPath(browserCmd); err != nil {
- fmt.Fprintf(&output, "Can't open a browser in this environment. Visit manually:\n%s\n", url)
- return output.String(), nil
- }
-
- if err := openCmd.Start(); err != nil {
- fmt.Fprintf(&output, "Failed to open browser: %v\nVisit manually:\n%s\n", err, url)
- return output.String(), nil
- }
-
- fmt.Fprintf(&output, "Opening: %s\n", url)
- return output.String(), nil
-}
diff --git a/flags/tcg_flagset_test.go b/flags/tcg_flagset_test.go
deleted file mode 100644
index a9498d26..00000000
--- a/flags/tcg_flagset_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package flags
-
-import (
- "reflect"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestSetupTcgFlagSet(t *testing.T) {
- tf := SetupTcgFlagSet()
-
- assert.NotNil(t, tf, "Flag set should not be nil")
- assert.Equal(t, "tcgFlags", tf.FlagSet.Name(), "Flag set name should be 'tcgFlags'")
-
- flagTests := []struct {
- flag interface{}
- expected interface{}
- name string
- }{
- {tf.Web, false, "Web flag default should be false"},
- {tf.ShortWeb, false, "ShortWeb flag default should be false"},
- }
-
- for _, tt := range flagTests {
- assert.NotNil(t, tt.flag, tt.name)
- assert.Equal(t, tt.expected, reflect.ValueOf(tt.flag).Elem().Interface(), tt.name)
- }
-}
-
-func TestSetupTcgFlagSet_ParseWebFlag(t *testing.T) {
- tests := []struct {
- name string
- args []string
- web bool
- }{
- {"long flag", []string{"--web"}, true},
- {"short flag", []string{"-w"}, true},
- {"no flag", []string{}, false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- tf := SetupTcgFlagSet()
- err := tf.FlagSet.Parse(tt.args)
- require.NoError(t, err)
- assert.Equal(t, tt.web, *tf.Web || *tf.ShortWeb)
- })
- }
-}
diff --git a/go.mod b/go.mod
index 98756fb4..9a9de14a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/digitalghost-dev/poke-cli
-go 1.25.10
+go 1.25.11
require (
charm.land/bubbles/v2 v2.1.0
@@ -14,11 +14,13 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/disintegration/imaging v1.6.2
github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4
+ github.com/pelletier/go-toml/v2 v2.4.0
github.com/schollz/closestmatch v2.1.0+incompatible
+ github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
- golang.org/x/image v0.38.0
+ golang.org/x/image v0.41.0
golang.org/x/term v0.42.0
- golang.org/x/text v0.35.0
+ golang.org/x/text v0.37.0
modernc.org/sqlite v1.39.1
)
@@ -58,7 +60,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 93b4e6de..9a576513 100644
--- a/go.sum
+++ b/go.sum
@@ -90,6 +90,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
+github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -100,6 +102,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -107,23 +111,23 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
-golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
-golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
-golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
+golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/mkdocs.yml b/mkdocs.yml
index 642e5be4..f259d7bc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -78,6 +78,7 @@ plugins:
- search
- mkdocs-nav-weight:
section_renamed: false
+ - glightbox
extra_css:
- stylesheets/extra.css
\ No newline at end of file
diff --git a/nfpm.yaml b/nfpm.yaml
index f9ac9265..e3407227 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.10.3"
+version: "v2.0.0"
section: "default"
version_schema: semver
maintainer: "Christian S"
@@ -12,4 +12,6 @@ license: "MIT"
contents:
- src: ./poke-cli
- dst: /usr/bin/poke-cli
\ No newline at end of file
+ dst: /usr/bin/poke-cli
+ - src: ./poke-cache
+ dst: /usr/bin/poke-cache
\ No newline at end of file
diff --git a/services/Cargo.lock b/services/Cargo.lock
new file mode 100644
index 00000000..610dd5de
--- /dev/null
+++ b/services/Cargo.lock
@@ -0,0 +1,1642 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "services"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "clap",
+ "dirs",
+ "futures",
+ "hex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/services/Cargo.toml b/services/Cargo.toml
new file mode 100644
index 00000000..872b5973
--- /dev/null
+++ b/services/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "services"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0"
+async-trait = "0.1"
+clap = { version = "4", features = ["derive"] }
+dirs = "6.0"
+futures = "0.3"
+hex = "0.4"
+reqwest = { version = "0.12", features = ["blocking", "json", "rustls-tls"], default-features = false }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
+thiserror = "1"
+tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
diff --git a/services/README.md b/services/README.md
new file mode 100644
index 00000000..ffc0bd94
--- /dev/null
+++ b/services/README.md
@@ -0,0 +1,13 @@
+# Services
+
+Various system-level services written in Rust that are used by the application.
+
+These currently include:
+
+* Caching
+ * Caches data from APIs to improve performance and reduce network usage.
+
+* Aggregation
+ * Fetches each PokéAPI resource once and returns one structured JSON profile for the Go CLI to render.
+ 
+
\ No newline at end of file
diff --git a/services/src/aggregate.rs b/services/src/aggregate.rs
new file mode 100644
index 00000000..dd404339
--- /dev/null
+++ b/services/src/aggregate.rs
@@ -0,0 +1,471 @@
+use crate::api::{RawDamageRelations, RawMove, RawPokemon, RawPokemonSpecies, RawType};
+use crate::domain::{
+ LearnableMove, PokemonAbility, PokemonSpeciesInfo, PokemonStats, PokemonTyping,
+ TypeDefenseProfile, TypeEffectiveness,
+};
+use crate::domain::{PartialResourceError, Pokemon, ResourceSourceMetadata};
+use futures::StreamExt;
+use serde::de::DeserializeOwned;
+use std::collections::HashMap;
+use std::time::{SystemTime, UNIX_EPOCH};
+use tokio::runtime::Runtime;
+
+const ALL_TYPES: &[&str] = &[
+ "normal", "fire", "water", "electric", "grass", "ice", "fighting", "poison", "ground",
+ "flying", "psychic", "bug", "rock", "ghost", "dragon", "dark", "steel", "fairy",
+];
+const MAX_CONNCURANT_FETCHES: usize = 8;
+
+pub struct ProfileOptions {
+ pub abilities: bool,
+ pub defense: bool,
+ pub image: Option,
+ pub moves: bool,
+ pub stats: bool,
+}
+
+pub fn run(name: &str, opts: &ProfileOptions) -> anyhow::Result {
+ get_pokemon_profile(name, opts)
+}
+
+// functions focused on retrieving data
+fn fetch_json(url: &str) -> anyhow::Result {
+ let data: T = reqwest::blocking::get(url)?
+ .error_for_status()?
+ .json::()?;
+
+ Ok(data)
+}
+
+fn get_pokemon(name: &str) -> anyhow::Result {
+ let url: String = format!("https://pokeapi.co/api/v2/pokemon/{name}");
+
+ fetch_json(&url)
+}
+
+fn get_pokemon_species(name: &str) -> anyhow::Result {
+ let url: String = format!("https://pokeapi.co/api/v2/pokemon-species/{name}");
+
+ fetch_json(&url)
+}
+
+fn get_type(name: &str) -> anyhow::Result {
+ let url: String = format!("https://pokeapi.co/api/v2/type/{name}");
+
+ fetch_json(&url)
+}
+
+// functions focused on building data
+fn build_stats(pokemon: &RawPokemon) -> Vec {
+ pokemon
+ .stats
+ .iter()
+ .map(|s| PokemonStats {
+ name: s.stat.name.clone(),
+ base_stat: s.base_stat,
+ })
+ .collect()
+}
+
+fn build_abilities(pokemon: &RawPokemon) -> Vec {
+ pokemon
+ .abilities
+ .iter()
+ .map(|a| PokemonAbility {
+ name: a.ability.name.clone(),
+ is_hidden: a.is_hidden,
+ })
+ .collect()
+}
+
+fn build_defenses(pokemon: &RawPokemon) -> anyhow::Result {
+ let mut multipliers: HashMap =
+ ALL_TYPES.iter().map(|d| (d.to_string(), 1.0)).collect();
+
+ for entry in &pokemon.types {
+ // fetch type/fire, type/flying
+ let type_data = get_type(&entry.typing.name)?;
+ apply_damage_relations(&mut multipliers, &type_data.damage_relations);
+ }
+
+ Ok(bucket(multipliers))
+}
+
+fn apply_damage_relations(out: &mut HashMap, rels: &RawDamageRelations) {
+ for t in &rels.double_damage_from {
+ if let Some(m) = out.get_mut(&t.name) {
+ *m *= 2.0;
+ }
+ }
+ for t in &rels.half_damage_from {
+ if let Some(m) = out.get_mut(&t.name) {
+ *m *= 0.5;
+ }
+ }
+ for t in &rels.no_damage_from {
+ if let Some(m) = out.get_mut(&t.name) {
+ *m *= 0.0;
+ }
+ }
+}
+
+fn bucket(multipliers: HashMap) -> TypeDefenseProfile {
+ let mut immune = vec![];
+ let mut weak = vec![];
+ let mut resistant = vec![];
+ let mut normal = vec![];
+
+ for (type_name, m) in multipliers {
+ if m == 0.0 {
+ immune.push(TypeEffectiveness {
+ type_name,
+ multiplier: m,
+ });
+ } else if m > 1.0 {
+ weak.push(TypeEffectiveness {
+ type_name,
+ multiplier: m,
+ });
+ } else if m < 1.0 {
+ resistant.push(TypeEffectiveness {
+ type_name,
+ multiplier: m,
+ });
+ } else {
+ normal.push(type_name);
+ }
+ }
+
+ // stable output: weak high→low, resist low→high, then alphabetical
+ weak.sort_by(|a, b| {
+ b.multiplier
+ .partial_cmp(&a.multiplier)
+ .unwrap()
+ .then(a.type_name.cmp(&b.type_name))
+ });
+ resistant.sort_by(|a, b| {
+ a.multiplier
+ .partial_cmp(&b.multiplier)
+ .unwrap()
+ .then(a.type_name.cmp(&b.type_name))
+ });
+ immune.sort_by(|a, b| a.type_name.cmp(&b.type_name));
+ normal.sort();
+ TypeDefenseProfile {
+ weak_to: weak,
+ resistant_to: resistant,
+ immune_to: immune,
+ normal_damage: normal,
+ }
+}
+
+fn build_species(species: &RawPokemonSpecies) -> PokemonSpeciesInfo {
+ PokemonSpeciesInfo {
+ name: species.name.clone(),
+ egg_groups: species.egg_groups.iter().map(|g| g.name.clone()).collect(),
+ gender_rate: species.gender_rate,
+ hatch_counter: species.hatch_counter,
+ evolves_from: species
+ .evolves_from_species
+ .as_ref()
+ .map(|n| n.name.clone()),
+ }
+}
+
+fn build_types(pokemon: &RawPokemon) -> Vec {
+ pokemon
+ .types
+ .iter()
+ .map(|t| PokemonTyping {
+ name: t.typing.name.clone(),
+ slot: t.slot,
+ })
+ .collect()
+}
+
+pub fn get_pokemon_profile(name: &str, opts: &ProfileOptions) -> anyhow::Result {
+ let pokemon = get_pokemon(name)?;
+ let pokemon_species = get_pokemon_species(&pokemon.species.name)?;
+
+ let (moves, move_partials) = if opts.moves {
+ let (m, p) = build_moves(&pokemon, "scarlet-violet", "level-up");
+ (Some(m), p)
+ } else {
+ (None, vec![])
+ };
+
+ let profile = Pokemon {
+ id: pokemon.id,
+ name: pokemon.name.clone(),
+ height: pokemon.height,
+ weight: pokemon.weight,
+ species: build_species(&pokemon_species),
+ abilities: if opts.abilities {
+ Some(build_abilities(&pokemon))
+ } else {
+ None
+ },
+ defenses: if opts.defense {
+ Some(build_defenses(&pokemon)?)
+ } else {
+ None
+ },
+ moves,
+ types: build_types(&pokemon),
+ stats: if opts.stats {
+ Some(build_stats(&pokemon))
+ } else {
+ None
+ },
+ source: ResourceSourceMetadata {
+ fetched_at: now_epoch_secs(),
+ partial_errors: move_partials, // ← was vec![]
+ },
+ };
+
+ Ok(profile)
+}
+
+fn now_epoch_secs() -> String {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ .to_string()
+}
+
+struct MoveCandidate {
+ name: String,
+ level: u8,
+}
+
+fn filter_and_dedupe(
+ pokemon: &RawPokemon,
+ version_group: &str,
+ learn_method: &str,
+) -> Vec {
+ let mut best: HashMap = HashMap::new();
+
+ for entry in &pokemon.moves {
+ for detail in &entry.version_group_details {
+ // keep only the rows matching BOTH filters
+ if detail.version_group.name == version_group
+ && detail.move_learn_method.name == learn_method
+ {
+ let level = detail.level_learned_at;
+ best.entry(entry.r#move.name.clone())
+ .and_modify(|existing| {
+ if level < *existing {
+ *existing = level;
+ }
+ })
+ .or_insert(level);
+ }
+ }
+ }
+
+ best.into_iter()
+ .map(|(name, level)| MoveCandidate { name, level })
+ .collect()
+}
+
+fn build_learnable_move(
+ cand: &MoveCandidate,
+ raw: &RawMove,
+ version_group: &str,
+ learn_method: &str,
+) -> LearnableMove {
+ LearnableMove {
+ name: cand.name.clone(),
+ level: cand.level, // from the candidate (9c)
+ type_name: raw.typing.name.clone(), // from the move detail
+ category: raw.damage_class.name.clone(),
+ power: raw.power,
+ accuracy: raw.accuracy,
+ pp: raw.pp,
+ learn_method: learn_method.to_string(),
+ version_group: version_group.to_string(),
+ }
+}
+
+async fn fetch_move(client: &reqwest::Client, name: &str) -> anyhow::Result {
+ let url: String = format!("https://pokeapi.co/api/v2/move/{name}");
+ let raw = client
+ .get(&url)
+ .send()
+ .await?
+ .error_for_status()?
+ .json::()
+ .await?;
+
+ Ok(raw)
+}
+
+fn build_moves(
+ pokemon: &RawPokemon,
+ version_group: &str,
+ learn_method: &str,
+) -> (Vec, Vec) {
+ let candidates = filter_and_dedupe(pokemon, version_group, learn_method);
+
+ let runtime: Runtime = Runtime::new().expect("tokio runtime");
+ let results: Vec> = runtime.block_on(async {
+ let client = reqwest::Client::new();
+
+ futures::stream::iter(candidates)
+ .map(|cand| {
+ let client = &client;
+ async move {
+ match fetch_move(client, &cand.name).await {
+ Ok(raw) => Ok(build_learnable_move(
+ &cand,
+ &raw,
+ version_group,
+ learn_method,
+ )),
+ Err(e) => Err(PartialResourceError {
+ resource: "move".to_string(),
+ name: cand.name.clone(),
+ error: e.to_string(),
+ }),
+ }
+ }
+ })
+ .buffer_unordered(MAX_CONNCURANT_FETCHES)
+ .collect()
+ .await
+ });
+
+ let mut moves = vec![];
+ let mut partials = vec![];
+
+ for r in results {
+ match r {
+ Ok(m) => moves.push(m),
+ Err(p) => partials.push(p),
+ }
+ }
+ moves.sort_by_key(|m| (m.level, m.name.clone()));
+
+ (moves, partials)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CHARIZARD: &str = include_str!("../tests/fixtures/charizard.json");
+ const CHARIZARD_SPECIES: &str = include_str!("../tests/fixtures/charizard-species.json");
+ const TYPE_FIRE: &str = include_str!("../tests/fixtures/type-fire.json");
+ const TYPE_FLYING: &str = include_str!("../tests/fixtures/type-flying.json");
+
+ fn charizard() -> RawPokemon {
+ serde_json::from_str(CHARIZARD).unwrap()
+ }
+
+ fn charizard_species() -> RawPokemonSpecies {
+ serde_json::from_str(CHARIZARD_SPECIES).unwrap()
+ }
+
+ #[test]
+ fn build_abilities_maps_names_and_hidden() {
+ let abilities = build_abilities(&charizard());
+
+ assert_eq!(abilities.len(), 2);
+ assert_eq!(abilities[0].name, "blaze");
+ // solar-power is charizard's hidden ability
+ assert!(
+ abilities
+ .iter()
+ .any(|a| a.name == "solar-power" && a.is_hidden)
+ );
+ }
+
+ #[test]
+ fn build_types_maps_names_and_slots() {
+ let types = build_types(&charizard());
+
+ assert_eq!(types.len(), 2);
+ assert_eq!(types[0].name, "fire");
+ assert_eq!(types[0].slot, 1);
+ assert_eq!(types[1].name, "flying");
+ assert_eq!(types[1].slot, 2);
+ }
+
+ #[test]
+ fn build_stats_maps_all_six() {
+ let stats = build_stats(&charizard());
+
+ assert_eq!(stats.len(), 6);
+ assert_eq!(stats[0].name, "hp");
+ }
+
+ #[test]
+ fn build_species_maps_summary() {
+ let species = build_species(&charizard_species());
+
+ assert_eq!(species.evolves_from, Some("charmeleon".to_string()));
+ assert_eq!(species.gender_rate, 1);
+ assert!(species.egg_groups.contains(&"monster".to_string()));
+ }
+
+ #[test]
+ fn build_defense_buckets_charizard() {
+ let fire: RawType = serde_json::from_str(TYPE_FIRE).unwrap();
+ let flying: RawType = serde_json::from_str(TYPE_FLYING).unwrap();
+
+ // same pipeline as build_defense, but from fixtures (no network)
+ let mut multipliers: std::collections::HashMap =
+ ALL_TYPES.iter().map(|t| (t.to_string(), 1.0)).collect();
+ apply_damage_relations(&mut multipliers, &fire.damage_relations);
+ apply_damage_relations(&mut multipliers, &flying.damage_relations);
+
+ let defense = bucket(multipliers);
+
+ // double weakness — exactly what the *= 20.5 typo broke
+ assert!(
+ defense
+ .weak_to
+ .iter()
+ .any(|e| e.type_name == "rock" && e.multiplier == 4.0)
+ );
+ // double resistance
+ assert!(
+ defense
+ .resistant_to
+ .iter()
+ .any(|e| e.type_name == "bug" && e.multiplier == 0.25)
+ );
+ // immunity dominates
+ assert!(defense.immune_to.iter().any(|e| e.type_name == "ground"));
+ }
+
+ #[test]
+ fn filter_and_dedupe_keeps_unique_level_up_moves() {
+ let candidates = filter_and_dedupe(&charizard(), "scarlet-violet", "level-up");
+
+ assert!(!candidates.is_empty());
+
+ // dedupe worked: no move name appears twice
+ let total = candidates.len();
+ let mut names: Vec<&str> = candidates.iter().map(|c| c.name.as_str()).collect();
+ names.sort();
+ names.dedup();
+ assert_eq!(names.len(), total, "candidate names should be unique");
+
+ // a known charizard level-up move is present
+ assert!(candidates.iter().any(|c| c.name == "ember"));
+ }
+
+ #[test]
+ fn filter_and_dedupe_filters_by_version_group() {
+ let real = filter_and_dedupe(&charizard(), "scarlet-violet", "level-up");
+ let bogus = filter_and_dedupe(&charizard(), "no-such-game", "level-up");
+
+ assert!(!real.is_empty());
+ assert!(
+ bogus.is_empty(),
+ "a non-existent version group should match nothing"
+ );
+ }
+}
diff --git a/services/src/api.rs b/services/src/api.rs
new file mode 100644
index 00000000..da07ada3
--- /dev/null
+++ b/services/src/api.rs
@@ -0,0 +1,122 @@
+use serde::Deserialize;
+
+#[derive(Deserialize, Debug)]
+pub struct NamedRef {
+ pub name: String,
+ pub url: String,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawPokemon {
+ pub id: u32,
+ pub name: String,
+ pub height: u32,
+ pub weight: u32,
+ pub species: NamedRef,
+ pub types: Vec,
+ pub abilities: Vec,
+ pub stats: Vec,
+ pub moves: Vec,
+ pub sprites: RawSprites,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawTypingEntry {
+ pub slot: u8,
+ // `type` is a Rust keyword, so rename the JSON key onto a legal field name.
+ #[serde(rename = "type")]
+ pub typing: NamedRef,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawAbilitiesEntry {
+ pub ability: NamedRef,
+ #[serde(default)]
+ pub is_hidden: bool,
+ pub slot: u8,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawStatsEntry {
+ pub base_stat: u16,
+ pub effort: u8,
+ pub stat: NamedRef,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawMovesEntry {
+ // `move` is a Rust keyword; `r#move` escapes it.
+ pub r#move: NamedRef,
+ pub version_group_details: Vec,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawVersionGroupDetail {
+ pub level_learned_at: u8,
+ pub move_learn_method: NamedRef,
+ pub version_group: NamedRef,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawSprites {
+ pub front_default: Option,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawPokemonSpecies {
+ pub name: String,
+ pub egg_groups: Vec,
+ pub gender_rate: i8,
+ pub hatch_counter: u8,
+ pub evolves_from_species: Option,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawType {
+ pub damage_relations: RawDamageRelations,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawDamageRelations {
+ pub double_damage_from: Vec,
+ pub half_damage_from: Vec,
+ pub no_damage_from: Vec,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct RawMove {
+ pub name: String,
+ // `type` is a Rust keyword, so rename the JSON key onto a legal field name.
+ #[serde(rename = "type")]
+ pub typing: NamedRef,
+ pub damage_class: NamedRef, // category lives here
+ pub power: Option,
+ pub accuracy: Option,
+ pub pp: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CHARIZARD: &str = include_str!("../tests/fixtures/charizard.json");
+ const CHARIZARD_SPECIES: &str = include_str!("../tests/fixtures/charizard-species.json");
+
+ #[test]
+ fn raw_pokemon_deserialize() {
+ let pokemon: RawPokemon = serde_json::from_str(CHARIZARD).unwrap();
+
+ assert_eq!(pokemon.id, 6);
+ assert_eq!(pokemon.name, "charizard");
+ assert_eq!(pokemon.types.len(), 2);
+ }
+
+ #[test]
+ fn raw_pokemon_species_deserialize() {
+ let pokemon_species: RawPokemonSpecies = serde_json::from_str(CHARIZARD_SPECIES).unwrap();
+
+ assert_eq!(pokemon_species.name, "charizard");
+ assert!(pokemon_species.evolves_from_species.is_some());
+ assert_eq!(pokemon_species.gender_rate, 1);
+ }
+}
diff --git a/services/src/bin/poke-aggregate.rs b/services/src/bin/poke-aggregate.rs
new file mode 100644
index 00000000..0f46fccf
--- /dev/null
+++ b/services/src/bin/poke-aggregate.rs
@@ -0,0 +1,154 @@
+use clap::{Parser, Subcommand, ValueEnum};
+use services::aggregate::{ProfileOptions, run};
+
+#[derive(Parser)]
+#[command(name = "poke-aggregate", version)]
+struct Cli {
+ #[command(subcommand)]
+ command: SubCommands,
+}
+
+#[derive(Subcommand)]
+enum SubCommands {
+ Pokemon(PokemonArgs),
+}
+
+#[derive(Parser)]
+struct PokemonArgs {
+ name: String,
+
+ #[arg(short = 'a', long)]
+ abilities: bool,
+
+ #[arg(short = 'd', long)]
+ defense: bool,
+
+ #[arg(short = 'i', long, value_enum)]
+ image: Option,
+
+ #[arg(short = 'm', long)]
+ moves: bool,
+
+ #[arg(short = 's', long)]
+ stats: bool,
+}
+
+#[derive(Clone, Copy, ValueEnum)]
+enum ImageSize {
+ Sm,
+ Md,
+ Lg,
+}
+
+impl From for ProfileOptions {
+ fn from(args: PokemonArgs) -> Self {
+ Self {
+ abilities: args.abilities,
+ defense: args.defense,
+ image: args.image.map(|size| {
+ match size {
+ ImageSize::Sm => "sm",
+ ImageSize::Md => "md",
+ ImageSize::Lg => "lg",
+ }
+ .to_string()
+ }),
+ moves: args.moves,
+ stats: args.stats,
+ }
+ }
+}
+
+fn main() -> anyhow::Result<()> {
+ let cli: Cli = Cli::parse();
+
+ match cli.command {
+ SubCommands::Pokemon(args) => {
+ let name: String = args.name.clone();
+ let options: ProfileOptions = args.into();
+
+ let profile: services::domain::Pokemon = run(&name, &options)?;
+
+ serde_json::to_writer_pretty(std::io::stdout(), &profile)?;
+ println!();
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_chained_flags() {
+ let cli = Cli::try_parse_from([
+ "poke-aggregate",
+ "pokemon",
+ "charizard",
+ "-a",
+ "-s",
+ "--image=md",
+ ])
+ .unwrap();
+
+ match cli.command {
+ SubCommands::Pokemon(args) => {
+ assert_eq!(args.name, "charizard");
+ assert!(args.abilities);
+ assert!(args.stats);
+ assert!(!args.defense);
+ assert!(!args.moves);
+ assert!(matches!(args.image, Some(ImageSize::Md)));
+ }
+ }
+ }
+
+ #[test]
+ fn image_equals_and_space_forms_are_equivalent() {
+ let equals =
+ Cli::try_parse_from(["poke-aggregate", "pokemon", "charizard", "--image=lg"]).unwrap();
+ let spaced =
+ Cli::try_parse_from(["poke-aggregate", "pokemon", "charizard", "--image", "lg"])
+ .unwrap();
+
+ for cli in [equals, spaced] {
+ match cli.command {
+ SubCommands::Pokemon(args) => {
+ assert!(matches!(args.image, Some(ImageSize::Lg)));
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn rejects_bad_image_size() {
+ let result =
+ Cli::try_parse_from(["poke-aggregate", "pokemon", "charizard", "--image", "xl"]);
+
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn missing_name_is_rejected() {
+ let result = Cli::try_parse_from(["poke-aggregate", "pokemon"]);
+
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn from_pokemon_args_maps_image_enum_to_string() {
+ let cli =
+ Cli::try_parse_from(["poke-aggregate", "pokemon", "charizard", "--image=md", "-a"])
+ .unwrap();
+
+ match cli.command {
+ SubCommands::Pokemon(args) => {
+ let opts: ProfileOptions = args.into();
+ assert!(opts.abilities);
+ assert_eq!(opts.image, Some("md".to_string()));
+ }
+ }
+ }
+}
diff --git a/services/src/bin/poke-cache.rs b/services/src/bin/poke-cache.rs
new file mode 100644
index 00000000..77b44e2c
--- /dev/null
+++ b/services/src/bin/poke-cache.rs
@@ -0,0 +1,43 @@
+use std::env;
+use std::fs;
+
+use anyhow::bail;
+use services::cache::{cache_is_fresh, get_cache_dir, hash_url, write_atomic};
+
+fn main() -> anyhow::Result<()> {
+ let args: Vec = env::args().collect();
+
+ if args.len() != 3 || args[1] != "get" {
+ bail!("Usage: poke-cache get ");
+ }
+
+ let url = &args[2];
+ let filename = hash_url(url) + ".json";
+ let cache_dir = get_cache_dir();
+
+ fs::create_dir_all(&cache_dir)?;
+
+ let cache_path = cache_dir.join(filename);
+
+ if cache_is_fresh(&cache_path)? {
+ eprintln!("Cache hit. Reading: {cache_path:?}");
+ let body = fs::read_to_string(&cache_path)?;
+ println!("{}", body);
+
+ return Ok(());
+ }
+
+ if cache_path.exists() {
+ eprintln!("Stale cache. Fetching: {url}");
+ } else {
+ eprintln!("Cache miss. Fetching: {url}");
+ }
+
+ let response_body = reqwest::blocking::get(url)?.error_for_status()?.text()?;
+
+ write_atomic(&cache_path, &response_body)?;
+
+ println!("{}", response_body);
+
+ Ok(())
+}
diff --git a/services/src/cache.rs b/services/src/cache.rs
new file mode 100644
index 00000000..8cd62b1b
--- /dev/null
+++ b/services/src/cache.rs
@@ -0,0 +1,110 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use anyhow::Result;
+use sha2::{Digest, Sha256};
+
+pub const TTL: Duration = Duration::from_secs(60 * 60 * 24); // 24 hours
+
+pub fn hash_url(url: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(url.as_bytes());
+
+ hex::encode(hasher.finalize())
+}
+
+pub fn get_cache_dir() -> PathBuf {
+ dirs::cache_dir()
+ .unwrap_or_else(|| PathBuf::from("poke-cache"))
+ .join("poke-cache")
+}
+
+pub fn cache_is_fresh(path: &PathBuf) -> anyhow::Result {
+ if !path.exists() {
+ return Ok(false);
+ }
+
+ let metadata = fs::metadata(path)?;
+ let age = metadata.modified()?.elapsed()?;
+
+ Ok(age < TTL)
+}
+
+pub fn write_atomic(path: &Path, data: &str) -> Result<()> {
+ let temp_path = path.with_extension("tmp");
+ fs::write(&temp_path, data)?;
+ fs::rename(&temp_path, path)?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::env;
+ use std::fs::{File, FileTimes};
+ use std::time::SystemTime;
+
+ // Unique-per-test path inside the OS temp dir so parallel tests don't collide.
+ fn temp_path(label: &str) -> PathBuf {
+ env::temp_dir().join(format!(
+ "poke-cache-test-{}-{}.json",
+ std::process::id(),
+ label
+ ))
+ }
+
+ #[test]
+ fn hash_url_is_deterministic_and_distinct() {
+ let a: String = hash_url("https://pokeapi.co/api/v2/pokemon/charizard");
+ let b: String = hash_url("https://pokeapi.co/api/v2/pokemon/charizard");
+ let c: String = hash_url("https://pokeapi.co/api/v2/pokemon/pikachu");
+
+ assert_eq!(a, b); // same input -> same hash
+ assert_ne!(a, c); // different input -> different hash
+ assert_eq!(a.len(), 64); // sha256 hex is 64 chars
+ }
+
+ #[test]
+ fn get_cache_dir_targets_poke_cache() {
+ assert!(get_cache_dir().ends_with("poke-cache"));
+ }
+
+ #[test]
+ fn cache_is_fresh_is_false_for_missing_file() {
+ let path: PathBuf = temp_path("missing");
+ let _ = fs::remove_file(&path); // ensure it doesn't exist
+
+ assert!(!cache_is_fresh(&path).unwrap());
+ }
+
+ #[test]
+ fn cache_is_fresh_is_false_for_stale_file() {
+ let path: PathBuf = temp_path("stale");
+ let _ = fs::remove_file(&path);
+
+ fs::write(&path, "hello").unwrap();
+ let stale_time: SystemTime = SystemTime::now() - (TTL + Duration::from_secs(1));
+ let file: File = File::options().write(true).open(&path).unwrap();
+ file.set_times(FileTimes::new().set_modified(stale_time))
+ .unwrap();
+
+ assert!(!cache_is_fresh(&path).unwrap());
+
+ fs::remove_file(&path).unwrap();
+ }
+
+ #[test]
+ fn write_atomic_writes_content_and_is_fresh() {
+ let path: PathBuf = temp_path("roundtrip");
+ let _ = fs::remove_file(&path);
+
+ write_atomic(&path, "hello").unwrap();
+
+ assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
+ assert!(cache_is_fresh(&path).unwrap());
+
+ fs::remove_file(&path).unwrap();
+ }
+}
diff --git a/services/src/domain.rs b/services/src/domain.rs
new file mode 100644
index 00000000..b9148df4
--- /dev/null
+++ b/services/src/domain.rs
@@ -0,0 +1,163 @@
+use serde::Serialize;
+
+#[derive(Serialize, Debug)]
+pub struct Pokemon {
+ pub id: u32,
+ pub name: String,
+ pub height: u32,
+ pub weight: u32,
+ pub species: PokemonSpeciesInfo,
+ pub types: Vec,
+
+ // Optional sections are omitted from the JSON entirely when not requested,
+ // rather than serialized as `null`.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub abilities: Option>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub stats: Option>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub moves: Option>,
+
+ pub source: ResourceSourceMetadata,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub defenses: Option,
+}
+
+#[derive(Serialize, Debug)]
+pub struct PokemonTyping {
+ pub name: String,
+ pub slot: u8,
+}
+
+#[derive(Serialize, Debug)]
+pub struct PokemonAbility {
+ pub name: String,
+ pub is_hidden: bool,
+}
+
+#[derive(Serialize, Debug)]
+pub struct PokemonStats {
+ pub name: String,
+ pub base_stat: u16,
+}
+
+#[derive(Serialize, Debug)]
+pub struct PokemonSpeciesInfo {
+ pub name: String,
+ pub egg_groups: Vec,
+ pub gender_rate: i8,
+ pub hatch_counter: u8,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub evolves_from: Option,
+}
+
+#[derive(Serialize, Debug)]
+pub struct ResourceSourceMetadata {
+ pub fetched_at: String, // RFC3339
+ pub partial_errors: Vec,
+}
+
+#[derive(Serialize, Debug)]
+pub struct PartialResourceError {
+ pub resource: String,
+ pub name: String,
+ pub error: String,
+}
+
+#[derive(Serialize, Debug)]
+pub struct TypeDefenseProfile {
+ pub weak_to: Vec,
+ pub resistant_to: Vec,
+ pub immune_to: Vec,
+ pub normal_damage: Vec,
+}
+
+#[derive(Serialize, Debug)]
+pub struct TypeEffectiveness {
+ #[serde(rename = "type")]
+ pub type_name: String,
+ pub multiplier: f32,
+}
+
+#[derive(Serialize, Debug)]
+pub struct LearnableMove {
+ pub name: String,
+ pub level: u8,
+ #[serde(rename = "type")]
+ pub type_name: String,
+ pub category: String,
+ pub power: Option,
+ pub accuracy: Option,
+ pub pp: Option,
+ pub learn_method: String,
+ pub version_group: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_pokemon() -> Pokemon {
+ Pokemon {
+ id: 6,
+ name: "charizard".to_string(),
+ height: 17,
+ weight: 905,
+ species: PokemonSpeciesInfo {
+ name: "charizard".to_string(),
+ egg_groups: vec!["monster".to_string(), "dragon".to_string()],
+ gender_rate: 1,
+ hatch_counter: 20,
+ evolves_from: Some("charmeleon".to_string()),
+ },
+ types: vec![
+ PokemonTyping {
+ name: "fire".to_string(),
+ slot: 1,
+ },
+ PokemonTyping {
+ name: "flying".to_string(),
+ slot: 2,
+ },
+ ],
+ abilities: None,
+ stats: None,
+ moves: None,
+ defenses: None,
+ source: ResourceSourceMetadata {
+ fetched_at: "2026-05-30T00:00:00Z".to_string(),
+ partial_errors: vec![],
+ },
+ }
+ }
+
+ #[test]
+ fn omits_unrequested_sections() {
+ let value = serde_json::to_value(sample_pokemon()).unwrap();
+
+ assert_eq!(value["name"], "charizard");
+ assert_eq!(value["types"].as_array().unwrap().len(), 2);
+
+ // None sections are skipped entirely, not serialized as null.
+ assert!(value.get("abilities").is_none());
+ assert!(value.get("stats").is_none());
+ }
+
+ #[test]
+ fn includes_requested_sections() {
+ let mut pokemon = sample_pokemon();
+ pokemon.abilities = Some(vec![PokemonAbility {
+ name: "blaze".to_string(),
+ is_hidden: false,
+ }]);
+
+ let value = serde_json::to_value(pokemon).unwrap();
+
+ assert_eq!(value["abilities"][0]["name"], "blaze");
+ assert_eq!(value["abilities"][0]["is_hidden"], false);
+ }
+}
diff --git a/services/src/lib.rs b/services/src/lib.rs
new file mode 100644
index 00000000..6362389a
--- /dev/null
+++ b/services/src/lib.rs
@@ -0,0 +1,4 @@
+pub mod aggregate;
+pub mod api;
+pub mod cache;
+pub mod domain;
diff --git a/services/tests/fixtures/charizard-species.json b/services/tests/fixtures/charizard-species.json
new file mode 100644
index 00000000..84e10632
--- /dev/null
+++ b/services/tests/fixtures/charizard-species.json
@@ -0,0 +1 @@
+{"base_happiness":70,"capture_rate":45,"color":{"name":"red","url":"https://pokeapi.co/api/v2/pokemon-color/8/"},"egg_groups":[{"name":"monster","url":"https://pokeapi.co/api/v2/egg-group/1/"},{"name":"dragon","url":"https://pokeapi.co/api/v2/egg-group/14/"}],"evolution_chain":{"url":"https://pokeapi.co/api/v2/evolution-chain/2/"},"evolves_from_species":{"name":"charmeleon","url":"https://pokeapi.co/api/v2/pokemon-species/5/"},"flavor_text_entries":[{"flavor_text":"Spits fire that\nis hot enough to\nmelt boulders.\fKnown to cause\nforest fires\nunintentionally.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"red","url":"https://pokeapi.co/api/v2/version/1/"}},{"flavor_text":"Spits fire that\nis hot enough to\nmelt boulders.\fKnown to cause\nforest fires\nunintentionally.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"blue","url":"https://pokeapi.co/api/v2/version/2/"}},{"flavor_text":"When expelling a\nblast of super\nhot fire, the red\fflame at the tip\nof its tail burns\nmore intensely.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"yellow","url":"https://pokeapi.co/api/v2/version/3/"}},{"flavor_text":"If CHARIZARD be\ncomes furious, the\nflame at the tip\fof its tail flares\nup in a whitish-\nblue color.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"gold","url":"https://pokeapi.co/api/v2/version/4/"}},{"flavor_text":"Breathing intense,\nhot flames, it can\nmelt almost any\fthing. Its breath\ninflicts terrible\npain on enemies.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"silver","url":"https://pokeapi.co/api/v2/version/5/"}},{"flavor_text":"It uses its wings\nto fly high. The\ntemperature of its\ffire increases as\nit gains exper\nience in battle.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"crystal","url":"https://pokeapi.co/api/v2/version/6/"}},{"flavor_text":"CHARIZARD flies around the sky in\nsearch of powerful opponents.\nIt breathes fire of such great heat\fthat it melts anything. However, it\nnever turns its fiery breath on any\nopponent weaker than itself.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"ruby","url":"https://pokeapi.co/api/v2/version/7/"}},{"flavor_text":"CHARIZARD flies around the sky in\nsearch of powerful opponents.\nIt breathes fire of such great heat\fthat it melts anything. However, it\nnever turns its fiery breath on any\nopponent weaker than itself.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"sapphire","url":"https://pokeapi.co/api/v2/version/8/"}},{"flavor_text":"A CHARIZARD flies about in search of\nstrong opponents. It breathes intense\nflames that can melt any material. However,\nit will never torch a weaker foe.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"emerald","url":"https://pokeapi.co/api/v2/version/9/"}},{"flavor_text":"Its wings can carry this POKéMON close to\nan altitude of 4,600 feet. It blows out\nfire at very high temperatures.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"firered","url":"https://pokeapi.co/api/v2/version/10/"}},{"flavor_text":"It spits fire that is hot enough to melt\nboulders. It may cause forest fires by\nblowing flames.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"leafgreen","url":"https://pokeapi.co/api/v2/version/11/"}},{"flavor_text":"It is said that CHARIZARD’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"diamond","url":"https://pokeapi.co/api/v2/version/12/"}},{"flavor_text":"It is said that CHARIZARD’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"pearl","url":"https://pokeapi.co/api/v2/version/13/"}},{"flavor_text":"It is said that CHARIZARD’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"platinum","url":"https://pokeapi.co/api/v2/version/14/"}},{"flavor_text":"If CHARIZARD becomes furious,\nthe flame at the tip of its tail flares\nup in a light blue shade.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"heartgold","url":"https://pokeapi.co/api/v2/version/15/"}},{"flavor_text":"Breathing intense, hot flames, it can\nmelt almost anything. Its breath\ninflicts terrible pain on enemies.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"soulsilver","url":"https://pokeapi.co/api/v2/version/16/"}},{"flavor_text":"On raconte que la flamme du\nDracaufeu s’intensifie après\nun combat difficile.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"black","url":"https://pokeapi.co/api/v2/version/17/"}},{"flavor_text":"It is said that Charizard’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"black","url":"https://pokeapi.co/api/v2/version/17/"}},{"flavor_text":"On raconte que la flamme du\nDracaufeu s’intensifie après\nun combat difficile.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"white","url":"https://pokeapi.co/api/v2/version/18/"}},{"flavor_text":"It is said that Charizard’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"white","url":"https://pokeapi.co/api/v2/version/18/"}},{"flavor_text":"It is said that Charizard’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"black-2","url":"https://pokeapi.co/api/v2/version/21/"}},{"flavor_text":"It is said that Charizard’s fire\nburns hotter if it has\nexperienced harsh battles.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"white-2","url":"https://pokeapi.co/api/v2/version/22/"}},{"flavor_text":"くちから しゃくねつの ほのおを\nはきだすとき しっぽの さきは\nより あかく はげしく もえあがる。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"입에서 작렬하는 불꽃을\n토해낼 때 꼬리의 끝이\n더욱 붉고 격렬하게 타오른다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"Quand il crache son souffle brûlant, la flamme au bout\nde sa queue s’embrase.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"Wenn dieses Pokémon einen Strahl glühenden\nFeuers speit, leuchtet seine Schwanzspitze auf.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"Cuando lanza una descarga de fuego supercaliente, la\nroja llama de su cola brilla más intensamente.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"Quando emette le sue lingue di fuoco, la fiamma\nrossa sulla punta della coda brucia più intensamente.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"When expelling a blast of superhot fire,\nthe red flame at the tip of its tail burns\nmore intensely.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"口から 灼熱の 炎を 吐き出すとき\n尻尾の 先は\nより 赤く 激しく 燃え上がる。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"x","url":"https://pokeapi.co/api/v2/version/23/"}},{"flavor_text":"ちじょう 1400メートル まで\nハネを つかって とぶことができる。\nこうねつの ほのおを はく。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"지상 1400m까지\n날개를 사용해 날 수 있다.\n고열의 불꽃을 내뿜는다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"Ses ailes peuvent le faire voler à plus de 1 400 m\nd’altitude. Ce Pokémon crache du feu à des\ntempératures très élevées.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"Dieses Pokémon kann mit seinen Flügeln\neine Höhe von bis zu 1 400 m erreichen.\nEs spuckt sehr heißes Feuer.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"Con las alas que tiene puede alcanzar una altura de\ncasi 1400 m. Suele escupir fuego por la boca.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"Grazie alle possenti ali può volare fino a 1400 m\nd’altezza. Sputa fuoco a una temperatura\nimpressionante.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"Its wings can carry this Pokémon close to an\naltitude of 4,600 feet. It blows out fire at very\nhigh temperatures.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"地上 1400メートルまで\n羽を 使って 飛ぶことができる。\n高熱の 炎を 吐く。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"y","url":"https://pokeapi.co/api/v2/version/24/"}},{"flavor_text":"つよい あいてを もとめて そらを とびまわる。\nなんでも とかして しまう こうねつの ほのおを\nじぶんより よわいものに むけることは しない。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"강한 상대를 찾아 하늘을 날아다닌다.\n무엇이든 다 녹여버리는 고열의 불꽃을\n자신보다 약한 자에게 들이대지 않는다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"Dracaufeu parcourt les cieux pour trouver des adversaires\nà sa mesure. Il crache de puissantes flammes capables de\nfaire fondre n’importe quoi. Mais il ne dirige jamais son souffle\ndestructeur vers un ennemi plus faible.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"Glurak fliegt durch die Lüfte, um starke Gegner aufzuspüren.\nSein heißer Feueratem bringt alles zum Schmelzen. Aber es\nrichtet seinen Feueratem nie auf schwächere Gegner.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"Charizard se dedica a volar por los cielos en busca de\noponentes fuertes. Echa fuego por la boca y es capaz de\nderretir cualquier cosa. No obstante, si su rival es más débil\nque él, no usará este ataque.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"Charizard solca i cieli in cerca di nemici molto forti.\nRiesce a emettere fiammate di un calore tale da fondere\nogni cosa. Tuttavia, non rivolge mai le sue micidiali lingue\ndi fuoco contro avversari più deboli di lui.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"Charizard flies around the sky in search of powerful opponents.\nIt breathes fire of such great heat that it melts anything.\nHowever, it never turns its fiery breath on any opponent\nweaker than itself.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"強い 相手を 求めて 空を 飛び回る。\nなんでも 溶かして しまう 高熱の 炎を\n自分より 弱いものに 向けることは しない。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"omega-ruby","url":"https://pokeapi.co/api/v2/version/25/"}},{"flavor_text":"つよい あいてを もとめて そらを とびまわる。\nなんでも とかして しまう こうねつの ほのおを\nじぶんより よわいものに むけることは しない。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"강한 상대를 찾아 하늘을 날아다닌다.\n무엇이든 다 녹여버리는 고열의 불꽃을\n자신보다 약한 자에게 들이대지 않는다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"Dracaufeu parcourt les cieux pour trouver des adversaires\nà sa mesure. Il crache de puissantes flammes capables de\nfaire fondre n’importe quoi. Mais il ne dirige jamais son souffle\ndestructeur vers un ennemi plus faible.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"Glurak fliegt durch die Lüfte, um starke Gegner aufzuspüren.\nSein heißer Feueratem bringt alles zum Schmelzen. Aber es\nrichtet seinen Feueratem nie gegen schwächere Gegner.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"Charizard se dedica a volar por los cielos en busca de\noponentes fuertes. Echa fuego por la boca y es capaz de\nderretir cualquier cosa. No obstante, si su rival es más débil\nque él, no usará este ataque.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"Charizard solca i cieli in cerca di nemici molto forti.\nRiesce a emettere fiammate di un calore tale da fondere\nogni cosa. Tuttavia, non rivolge mai le sue micidiali lingue\ndi fuoco contro avversari più deboli di lui.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"Charizard flies around the sky in search of powerful opponents.\nIt breathes fire of such great heat that it melts anything.\nHowever, it never turns its fiery breath on any opponent\nweaker than itself.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"強い 相手を 求めて 空を 飛び回る。\nなんでも 溶かして しまう 高熱の 炎を\n自分より 弱いものに 向けることは しない。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"alpha-sapphire","url":"https://pokeapi.co/api/v2/version/26/"}},{"flavor_text":"くちから しゃくねつの ほのおを\nはきだすとき シッポのさきは\nより あかく はげしく もえあがる。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"입에서 작렬하는 불꽃을\n토해낼 때 꼬리의 끝이\n더욱 붉고 격렬하게 타오른다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"從口中噴出熾熱的火焰時,\n尾巴尖端的紅色火焰\n會燃燒得更加激烈。","language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"Quand il crache son souffle brûlant, la flamme\nau bout de sa queue s’embrase.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"Wenn es einen Strahl glühenden Feuers speit,\nlodert die rote Flamme an seiner Schwanzspitze\nnoch intensiver.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"Cuando exhala fuego abrasador, la llama de la\npunta de la cola se aviva y adquiere un intenso\ncolor rojo.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"Quando emette lingue di fuoco roventi\ndalla bocca, la fiamma rossa sulla punta\ndella coda brucia più intensamente.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"When this Pokémon expels a blast of superhot\nfire, the red flame at the tip of its tail burns\nmore intensely.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"口から 灼熱の 炎を\n吐き出すとき シッポの先は\nより 赤く 激しく 燃えあがる。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"从口中喷出灼热的火焰时,\n尾巴尖端的红色火焰\n会燃烧得更加猛烈。","language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"version":{"name":"lets-go-pikachu","url":"https://pokeapi.co/api/v2/version/31/"}},{"flavor_text":"くちから しゃくねつの ほのおを\nはきだすとき シッポのさきは\nより あかく はげしく もえあがる。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"입에서 작렬하는 불꽃을\n토해낼 때 꼬리의 끝이\n더욱 붉고 격렬하게 타오른다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"從口中噴出熾熱的火焰時,\n尾巴尖端的紅色火焰\n會燃燒得更加激烈。","language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"Quand il crache son souffle brûlant, la flamme\nau bout de sa queue s’embrase.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"Wenn es einen Strahl glühenden Feuers speit,\nlodert die rote Flamme an seiner Schwanzspitze\nnoch intensiver.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"Cuando exhala fuego abrasador, la llama de la\npunta de la cola se aviva y adquiere un intenso\ncolor rojo.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"Quando emette lingue di fuoco roventi\ndalla bocca, la fiamma rossa sulla punta\ndella coda brucia più intensamente.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"When this Pokémon expels a blast of superhot\nfire, the red flame at the tip of its tail burns\nmore intensely.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"口から 灼熱の 炎を\n吐き出すとき シッポの先は\nより 赤く 激しく 燃えあがる。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"从口中喷出灼热的火焰时,\n尾巴尖端的红色火焰\n会燃烧得更加猛烈。","language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"version":{"name":"lets-go-eevee","url":"https://pokeapi.co/api/v2/version/32/"}},{"flavor_text":"がんせきも やけるような\nしゃくねつの ほのおを はいて\nやまかじを おこすことが ある。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"암석도 태워버릴 정도로\n작열하는 화염을 뿜어\n산불을 일으킬 때가 있다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"會噴出彷彿連岩石\n都能燒焦的灼熱火焰。\n有時會引發森林火災。","language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"Son souffle brûlant peut faire fondre la roche.\nIl est parfois la cause d’incendies de forêt.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"Dieses Pokémon kann mit seinem Feueratem\nFelsen schmelzen. Es verursacht ab und zu\nWaldbrände.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"Escupe un fuego tan caliente que funde las rocas.\nCausa incendios forestales sin querer.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"Sputa fiamme incandescenti in grado di fondere\nle rocce. A volte causa incendi boschivi.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"It spits fire that is hot enough to melt boulders.\nIt may cause forest fires by blowing flames.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"岩石も 焼けるような\n灼熱の 炎を 吐いて\n山火事を 起こすことが ある。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"能够喷出猛烈的火焰,\n仿佛连岩石都能烤焦。\n有时会引发森林火灾。","language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"version":{"name":"sword","url":"https://pokeapi.co/api/v2/version/33/"}},{"flavor_text":"ちじょう 1400メートル まで\nハネを つかって とぶことができる。\nこうねつの ほのおを はく。","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"지상 1400m까지\n날개를 사용해 날 수 있다.\n고열의 불꽃을 내뿜는다.","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"能用翅膀飛到距離地面\n1400公尺的高度。\n會吐出高溫的火焰。","language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"Ses ailes lui permettent de voler à plus de\n1 400 m d’altitude. Ce Pokémon crache du feu\nà des températures très élevées.","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"Dieses Pokémon kann mit seinen Flügeln eine\nHöhe von bis zu 1 400 m erreichen. Es spuckt\nsehr heißes Feuer.","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"Sus potentes alas le permiten volar a una altura\nde 1400 m. Escupe llamaradas que llegan a\nalcanzar temperaturas elevadísimas.","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"Grazie alle possenti ali può volare fino a\n1.400 m d’altezza. Le fiamme che sputa\npossono raggiungere temperature altissime.","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"Its wings can carry this Pokémon close to an\naltitude of 4,600 feet. It blows out fire at very\nhigh temperatures.","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"地上 1400メートル まで\n羽を 使って 飛ぶことができる。\n高熱の 炎を 吐く。","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}},{"flavor_text":"能用翅膀飞到距地面\n1400米的高度。\n会吐出高温火焰。","language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"version":{"name":"shield","url":"https://pokeapi.co/api/v2/version/34/"}}],"form_descriptions":[],"forms_switchable":true,"gender_rate":1,"genera":[{"genus":"かえんポケモン","language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"}},{"genus":"화염포켓몬","language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"}},{"genus":"火焰寶可夢","language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"}},{"genus":"Pokémon Flamme","language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"}},{"genus":"Flammen-Pokémon","language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"}},{"genus":"Pokémon Llama","language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"}},{"genus":"Pokémon Fiamma","language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"}},{"genus":"Flame Pokémon","language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"}},{"genus":"かえんポケモン","language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"}},{"genus":"火焰宝可梦","language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"}}],"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"},"growth_rate":{"name":"medium-slow","url":"https://pokeapi.co/api/v2/growth-rate/4/"},"habitat":{"name":"mountain","url":"https://pokeapi.co/api/v2/pokemon-habitat/4/"},"has_gender_differences":false,"hatch_counter":20,"id":6,"is_baby":false,"is_legendary":false,"is_mythical":false,"name":"charizard","names":[{"language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"name":"リザードン"},{"language":{"name":"ja-roma","url":"https://pokeapi.co/api/v2/language/2/"},"name":"Lizardon"},{"language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"name":"리자몽"},{"language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"name":"噴火龍"},{"language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"name":"Dracaufeu"},{"language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"name":"Glurak"},{"language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"name":"Charizard"},{"language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"name":"Charizard"},{"language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"name":"Charizard"},{"language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"name":"リザードン"},{"language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"name":"喷火龙"}],"order":6,"pal_park_encounters":[{"area":{"name":"field","url":"https://pokeapi.co/api/v2/pal-park-area/2/"},"base_score":90,"rate":3}],"pokedex_numbers":[{"entry_number":6,"pokedex":{"name":"national","url":"https://pokeapi.co/api/v2/pokedex/1/"}},{"entry_number":6,"pokedex":{"name":"kanto","url":"https://pokeapi.co/api/v2/pokedex/2/"}},{"entry_number":231,"pokedex":{"name":"original-johto","url":"https://pokeapi.co/api/v2/pokedex/3/"}},{"entry_number":236,"pokedex":{"name":"updated-johto","url":"https://pokeapi.co/api/v2/pokedex/7/"}},{"entry_number":111,"pokedex":{"name":"conquest-gallery","url":"https://pokeapi.co/api/v2/pokedex/11/"}},{"entry_number":85,"pokedex":{"name":"kalos-central","url":"https://pokeapi.co/api/v2/pokedex/12/"}},{"entry_number":6,"pokedex":{"name":"letsgo-kanto","url":"https://pokeapi.co/api/v2/pokedex/26/"}},{"entry_number":380,"pokedex":{"name":"galar","url":"https://pokeapi.co/api/v2/pokedex/27/"}},{"entry_number":169,"pokedex":{"name":"blueberry","url":"https://pokeapi.co/api/v2/pokedex/33/"}},{"entry_number":153,"pokedex":{"name":"lumiose-city","url":"https://pokeapi.co/api/v2/pokedex/34/"}},{"entry_number":6,"pokedex":{"name":"champions","url":"https://pokeapi.co/api/v2/pokedex/36/"}}],"shape":{"name":"upright","url":"https://pokeapi.co/api/v2/pokemon-shape/6/"},"varieties":[{"is_default":true,"pokemon":{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon/6/"}},{"is_default":false,"pokemon":{"name":"charizard-mega-x","url":"https://pokeapi.co/api/v2/pokemon/10034/"}},{"is_default":false,"pokemon":{"name":"charizard-mega-y","url":"https://pokeapi.co/api/v2/pokemon/10035/"}},{"is_default":false,"pokemon":{"name":"charizard-gmax","url":"https://pokeapi.co/api/v2/pokemon/10196/"}}]}
\ No newline at end of file
diff --git a/services/tests/fixtures/charizard.json b/services/tests/fixtures/charizard.json
new file mode 100644
index 00000000..3dbd4a5a
--- /dev/null
+++ b/services/tests/fixtures/charizard.json
@@ -0,0 +1 @@
+{"abilities":[{"ability":{"name":"blaze","url":"https://pokeapi.co/api/v2/ability/66/"},"is_hidden":false,"slot":1},{"ability":{"name":"solar-power","url":"https://pokeapi.co/api/v2/ability/94/"},"is_hidden":true,"slot":3}],"base_experience":240,"cries":{"latest":"https://raw.githubusercontent.com/PokeAPI/cries/main/cries/pokemon/latest/6.ogg","legacy":"https://raw.githubusercontent.com/PokeAPI/cries/main/cries/pokemon/legacy/6.ogg"},"forms":[{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon-form/6/"}],"game_indices":[{"game_index":180,"version":{"name":"red","url":"https://pokeapi.co/api/v2/version/1/"}},{"game_index":180,"version":{"name":"blue","url":"https://pokeapi.co/api/v2/version/2/"}},{"game_index":180,"version":{"name":"yellow","url":"https://pokeapi.co/api/v2/version/3/"}},{"game_index":6,"version":{"name":"gold","url":"https://pokeapi.co/api/v2/version/4/"}},{"game_index":6,"version":{"name":"silver","url":"https://pokeapi.co/api/v2/version/5/"}},{"game_index":6,"version":{"name":"crystal","url":"https://pokeapi.co/api/v2/version/6/"}},{"game_index":6,"version":{"name":"ruby","url":"https://pokeapi.co/api/v2/version/7/"}},{"game_index":6,"version":{"name":"sapphire","url":"https://pokeapi.co/api/v2/version/8/"}},{"game_index":6,"version":{"name":"emerald","url":"https://pokeapi.co/api/v2/version/9/"}},{"game_index":6,"version":{"name":"firered","url":"https://pokeapi.co/api/v2/version/10/"}},{"game_index":6,"version":{"name":"leafgreen","url":"https://pokeapi.co/api/v2/version/11/"}},{"game_index":6,"version":{"name":"diamond","url":"https://pokeapi.co/api/v2/version/12/"}},{"game_index":6,"version":{"name":"pearl","url":"https://pokeapi.co/api/v2/version/13/"}},{"game_index":6,"version":{"name":"platinum","url":"https://pokeapi.co/api/v2/version/14/"}},{"game_index":6,"version":{"name":"heartgold","url":"https://pokeapi.co/api/v2/version/15/"}},{"game_index":6,"version":{"name":"soulsilver","url":"https://pokeapi.co/api/v2/version/16/"}},{"game_index":6,"version":{"name":"black","url":"https://pokeapi.co/api/v2/version/17/"}},{"game_index":6,"version":{"name":"white","url":"https://pokeapi.co/api/v2/version/18/"}},{"game_index":6,"version":{"name":"black-2","url":"https://pokeapi.co/api/v2/version/21/"}},{"game_index":6,"version":{"name":"white-2","url":"https://pokeapi.co/api/v2/version/22/"}}],"height":17,"held_items":[],"id":6,"is_default":true,"location_area_encounters":"https://pokeapi.co/api/v2/pokemon/6/encounters","moves":[{"move":{"name":"mega-punch","url":"https://pokeapi.co/api/v2/move/5/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"fire-punch","url":"https://pokeapi.co/api/v2/move/7/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"thunder-punch","url":"https://pokeapi.co/api/v2/move/9/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"scratch","url":"https://pokeapi.co/api/v2/move/10/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"swords-dance","url":"https://pokeapi.co/api/v2/move/14/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"cut","url":"https://pokeapi.co/api/v2/move/15/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"wing-attack","url":"https://pokeapi.co/api/v2/move/17/"},"version_group_details":[{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}}]},{"move":{"name":"fly","url":"https://pokeapi.co/api/v2/move/19/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"mega-kick","url":"https://pokeapi.co/api/v2/move/25/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"headbutt","url":"https://pokeapi.co/api/v2/move/29/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}}]},{"move":{"name":"body-slam","url":"https://pokeapi.co/api/v2/move/34/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"take-down","url":"https://pokeapi.co/api/v2/move/36/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"double-edge","url":"https://pokeapi.co/api/v2/move/38/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"leer","url":"https://pokeapi.co/api/v2/move/43/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":15,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":15,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":15,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}},{"level_learned_at":15,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"bite","url":"https://pokeapi.co/api/v2/move/44/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"growl","url":"https://pokeapi.co/api/v2/move/45/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":8,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":8,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":4,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"roar","url":"https://pokeapi.co/api/v2/move/46/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"ember","url":"https://pokeapi.co/api/v2/move/52/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":9,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":9,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":8,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":8,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":9,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":9,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":7,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":8,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":9,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}},{"level_learned_at":9,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"flamethrower","url":"https://pokeapi.co/api/v2/move/53/"},"version_group_details":[{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":42,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":42,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":42,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":34,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":47,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":30,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":30,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":30,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"hyper-beam","url":"https://pokeapi.co/api/v2/move/63/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"submission","url":"https://pokeapi.co/api/v2/move/66/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"counter","url":"https://pokeapi.co/api/v2/move/68/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"seismic-toss","url":"https://pokeapi.co/api/v2/move/69/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"strength","url":"https://pokeapi.co/api/v2/move/70/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"solar-beam","url":"https://pokeapi.co/api/v2/move/76/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-rage","url":"https://pokeapi.co/api/v2/move/82/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":17,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":26,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"fire-spin","url":"https://pokeapi.co/api/v2/move/83/"},"version_group_details":[{"level_learned_at":55,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":55,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":49,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":49,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":49,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":64,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":56,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":33,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":46,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":55,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":55,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"earthquake","url":"https://pokeapi.co/api/v2/move/89/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"fissure","url":"https://pokeapi.co/api/v2/move/90/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"dig","url":"https://pokeapi.co/api/v2/move/91/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"toxic","url":"https://pokeapi.co/api/v2/move/92/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"rage","url":"https://pokeapi.co/api/v2/move/99/"},"version_group_details":[{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"mimic","url":"https://pokeapi.co/api/v2/move/102/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"double-team","url":"https://pokeapi.co/api/v2/move/104/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"smokescreen","url":"https://pokeapi.co/api/v2/move/108/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":9,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":9,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":10,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":9,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":7,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"defense-curl","url":"https://pokeapi.co/api/v2/move/111/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}}]},{"move":{"name":"reflect","url":"https://pokeapi.co/api/v2/move/115/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"bide","url":"https://pokeapi.co/api/v2/move/117/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"fire-blast","url":"https://pokeapi.co/api/v2/move/126/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"swift","url":"https://pokeapi.co/api/v2/move/129/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"skull-bash","url":"https://pokeapi.co/api/v2/move/130/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"fury-swipes","url":"https://pokeapi.co/api/v2/move/154/"},"version_group_details":[{"level_learned_at":20,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}}]},{"move":{"name":"rest","url":"https://pokeapi.co/api/v2/move/156/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"rock-slide","url":"https://pokeapi.co/api/v2/move/157/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"slash","url":"https://pokeapi.co/api/v2/move/163/"},"version_group_details":[{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":44,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":41,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":43,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":24,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":36,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"substitute","url":"https://pokeapi.co/api/v2/move/164/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-blue","url":"https://pokeapi.co/api/v2/version-group/1/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"yellow","url":"https://pokeapi.co/api/v2/version-group/2/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"red-green-japan","url":"https://pokeapi.co/api/v2/version-group/28/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"blue-japan","url":"https://pokeapi.co/api/v2/version-group/29/"}}]},{"move":{"name":"snore","url":"https://pokeapi.co/api/v2/move/173/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"curse","url":"https://pokeapi.co/api/v2/move/174/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}}]},{"move":{"name":"protect","url":"https://pokeapi.co/api/v2/move/182/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"scary-face","url":"https://pokeapi.co/api/v2/move/184/"},"version_group_details":[{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":27,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":21,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":39,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":39,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":39,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"belly-drum","url":"https://pokeapi.co/api/v2/move/187/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"mud-slap","url":"https://pokeapi.co/api/v2/move/189/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"outrage","url":"https://pokeapi.co/api/v2/move/200/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"sandstorm","url":"https://pokeapi.co/api/v2/move/201/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"endure","url":"https://pokeapi.co/api/v2/move/203/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"false-swipe","url":"https://pokeapi.co/api/v2/move/206/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"swagger","url":"https://pokeapi.co/api/v2/move/207/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"fury-cutter","url":"https://pokeapi.co/api/v2/move/210/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"steel-wing","url":"https://pokeapi.co/api/v2/move/211/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"attract","url":"https://pokeapi.co/api/v2/move/213/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"sleep-talk","url":"https://pokeapi.co/api/v2/move/214/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"return","url":"https://pokeapi.co/api/v2/move/216/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"frustration","url":"https://pokeapi.co/api/v2/move/218/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"dynamic-punch","url":"https://pokeapi.co/api/v2/move/223/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}}]},{"move":{"name":"dragon-breath","url":"https://pokeapi.co/api/v2/move/225/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":12,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":12,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":12,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"iron-tail","url":"https://pokeapi.co/api/v2/move/231/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"metal-claw","url":"https://pokeapi.co/api/v2/move/232/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":13,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"hidden-power","url":"https://pokeapi.co/api/v2/move/237/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"twister","url":"https://pokeapi.co/api/v2/move/239/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"sunny-day","url":"https://pokeapi.co/api/v2/move/241/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"crunch","url":"https://pokeapi.co/api/v2/move/242/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"ancient-power","url":"https://pokeapi.co/api/v2/move/246/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"rock-smash","url":"https://pokeapi.co/api/v2/move/249/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"gold-silver","url":"https://pokeapi.co/api/v2/version-group/3/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"crystal","url":"https://pokeapi.co/api/v2/version-group/4/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"beat-up","url":"https://pokeapi.co/api/v2/move/251/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"heat-wave","url":"https://pokeapi.co/api/v2/move/257/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":59,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":59,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":59,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":71,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"will-o-wisp","url":"https://pokeapi.co/api/v2/move/261/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"facade","url":"https://pokeapi.co/api/v2/move/263/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"focus-punch","url":"https://pokeapi.co/api/v2/move/264/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"helping-hand","url":"https://pokeapi.co/api/v2/move/270/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"brick-break","url":"https://pokeapi.co/api/v2/move/280/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"secret-power","url":"https://pokeapi.co/api/v2/move/290/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}}]},{"move":{"name":"blaze-kick","url":"https://pokeapi.co/api/v2/move/299/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"blast-burn","url":"https://pokeapi.co/api/v2/move/307/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"weather-ball","url":"https://pokeapi.co/api/v2/move/311/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"air-cutter","url":"https://pokeapi.co/api/v2/move/314/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"overheat","url":"https://pokeapi.co/api/v2/move/315/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"rock-tomb","url":"https://pokeapi.co/api/v2/move/317/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"aerial-ace","url":"https://pokeapi.co/api/v2/move/332/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-claw","url":"https://pokeapi.co/api/v2/move/337/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ruby-sapphire","url":"https://pokeapi.co/api/v2/version-group/5/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"emerald","url":"https://pokeapi.co/api/v2/version-group/6/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"firered-leafgreen","url":"https://pokeapi.co/api/v2/version-group/7/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"colosseum","url":"https://pokeapi.co/api/v2/version-group/12/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"xd","url":"https://pokeapi.co/api/v2/version-group/13/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-dance","url":"https://pokeapi.co/api/v2/move/349/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"roost","url":"https://pokeapi.co/api/v2/move/355/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"natural-gift","url":"https://pokeapi.co/api/v2/move/363/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"tailwind","url":"https://pokeapi.co/api/v2/move/366/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"fling","url":"https://pokeapi.co/api/v2/move/374/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"flare-blitz","url":"https://pokeapi.co/api/v2/move/394/"},"version_group_details":[{"level_learned_at":66,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":66,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":66,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":77,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":75,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"air-slash","url":"https://pokeapi.co/api/v2/move/403/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":3,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":6,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":1,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-pulse","url":"https://pokeapi.co/api/v2/move/406/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-rush","url":"https://pokeapi.co/api/v2/move/407/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"focus-blast","url":"https://pokeapi.co/api/v2/move/411/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"giga-impact","url":"https://pokeapi.co/api/v2/move/416/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"shadow-claw","url":"https://pokeapi.co/api/v2/move/421/"},"version_group_details":[{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":2,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":4,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":1,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":5,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"fire-fang","url":"https://pokeapi.co/api/v2/move/424/"},"version_group_details":[{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":28,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":19,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":19,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":19,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"defog","url":"https://pokeapi.co/api/v2/move/432/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"captivate","url":"https://pokeapi.co/api/v2/move/445/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"diamond-pearl","url":"https://pokeapi.co/api/v2/version-group/8/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"ominous-wind","url":"https://pokeapi.co/api/v2/move/466/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"platinum","url":"https://pokeapi.co/api/v2/version-group/9/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"heartgold-soulsilver","url":"https://pokeapi.co/api/v2/version-group/10/"}}]},{"move":{"name":"hone-claws","url":"https://pokeapi.co/api/v2/move/468/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}}]},{"move":{"name":"flame-burst","url":"https://pokeapi.co/api/v2/move/481/"},"version_group_details":[{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":32,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"flame-charge","url":"https://pokeapi.co/api/v2/move/488/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"round","url":"https://pokeapi.co/api/v2/move/496/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"echoed-voice","url":"https://pokeapi.co/api/v2/move/497/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"sky-drop","url":"https://pokeapi.co/api/v2/move/507/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"incinerate","url":"https://pokeapi.co/api/v2/move/510/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}}]},{"move":{"name":"acrobatics","url":"https://pokeapi.co/api/v2/move/512/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"inferno","url":"https://pokeapi.co/api/v2/move/517/"},"version_group_details":[{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":62,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":54,"move_learn_method":{"name":"level-up","url":"https://pokeapi.co/api/v2/move-learn-method/1/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"fire-pledge","url":"https://pokeapi.co/api/v2/move/519/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"bulldoze","url":"https://pokeapi.co/api/v2/move/523/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-tail","url":"https://pokeapi.co/api/v2/move/525/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-white","url":"https://pokeapi.co/api/v2/version-group/11/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"black-2-white-2","url":"https://pokeapi.co/api/v2/version-group/14/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"lets-go-pikachu-lets-go-eevee","url":"https://pokeapi.co/api/v2/version-group/19/"}},{"level_learned_at":0,"move_learn_method":{"name":"egg","url":"https://pokeapi.co/api/v2/move-learn-method/2/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"work-up","url":"https://pokeapi.co/api/v2/move/526/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"brilliant-diamond-shining-pearl","url":"https://pokeapi.co/api/v2/version-group/23/"}}]},{"move":{"name":"heat-crash","url":"https://pokeapi.co/api/v2/move/535/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"hurricane","url":"https://pokeapi.co/api/v2/move/542/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"confide","url":"https://pokeapi.co/api/v2/move/590/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}}]},{"move":{"name":"mystical-fire","url":"https://pokeapi.co/api/v2/move/595/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"power-up-punch","url":"https://pokeapi.co/api/v2/move/612/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"x-y","url":"https://pokeapi.co/api/v2/version-group/15/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"omega-ruby-alpha-sapphire","url":"https://pokeapi.co/api/v2/version-group/16/"}}]},{"move":{"name":"brutal-swing","url":"https://pokeapi.co/api/v2/move/693/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sun-moon","url":"https://pokeapi.co/api/v2/version-group/17/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"ultra-sun-ultra-moon","url":"https://pokeapi.co/api/v2/version-group/18/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"breaking-swipe","url":"https://pokeapi.co/api/v2/move/784/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"scale-shot","url":"https://pokeapi.co/api/v2/move/799/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"dual-wingbeat","url":"https://pokeapi.co/api/v2/move/814/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}}]},{"move":{"name":"scorching-sands","url":"https://pokeapi.co/api/v2/move/815/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"tutor","url":"https://pokeapi.co/api/v2/move-learn-method/3/"},"order":null,"version_group":{"name":"sword-shield","url":"https://pokeapi.co/api/v2/version-group/20/"}},{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"tera-blast","url":"https://pokeapi.co/api/v2/move/851/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"dragon-cheer","url":"https://pokeapi.co/api/v2/move/913/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]},{"move":{"name":"temper-flare","url":"https://pokeapi.co/api/v2/move/915/"},"version_group_details":[{"level_learned_at":0,"move_learn_method":{"name":"machine","url":"https://pokeapi.co/api/v2/move-learn-method/4/"},"order":null,"version_group":{"name":"scarlet-violet","url":"https://pokeapi.co/api/v2/version-group/25/"}}]}],"name":"charizard","order":7,"past_abilities":[{"abilities":[{"ability":null,"is_hidden":true,"slot":3}],"generation":{"name":"generation-iv","url":"https://pokeapi.co/api/v2/generation/4/"}}],"past_stats":[{"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"},"stats":[{"base_stat":85,"effort":0,"stat":{"name":"special","url":"https://pokeapi.co/api/v2/stat/9/"}}]}],"past_types":[],"species":{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon-species/6/"},"sprites":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/6.png","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/6.png","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/6.png","front_shiny_female":null,"other":{"dream_world":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/6.svg","front_female":null},"home":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/shiny/6.png","front_shiny_female":null},"official-artwork":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/shiny/6.png"},"showdown":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/showdown/back/6.gif","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/showdown/back/shiny/6.gif","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/showdown/6.gif","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/showdown/shiny/6.gif","front_shiny_female":null}},"versions":{"generation-i":{"red-blue":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/back/6.png","back_gray":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/back/gray/6.png","back_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/transparent/back/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/6.png","front_gray":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/gray/6.png","front_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/red-blue/transparent/6.png"},"yellow":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/back/6.png","back_gray":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/back/gray/6.png","back_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/transparent/back/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/6.png","front_gray":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/gray/6.png","front_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/transparent/6.png"}},"generation-ii":{"crystal":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/back/6.png","back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/back/shiny/6.png","back_shiny_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/transparent/back/shiny/6.png","back_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/transparent/back/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/shiny/6.png","front_shiny_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/transparent/shiny/6.png","front_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/crystal/transparent/6.png"},"gold":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/gold/back/6.png","back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/gold/back/shiny/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/gold/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/gold/shiny/6.png","front_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/gold/transparent/6.png"},"silver":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/silver/back/6.png","back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/silver/back/shiny/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/silver/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/silver/shiny/6.png","front_transparent":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ii/silver/transparent/6.png"}},"generation-iii":{"emerald":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/emerald/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/emerald/shiny/6.png"},"firered-leafgreen":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/firered-leafgreen/back/6.png","back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/firered-leafgreen/back/shiny/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/firered-leafgreen/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/firered-leafgreen/shiny/6.png"},"ruby-sapphire":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/ruby-sapphire/back/6.png","back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/ruby-sapphire/back/shiny/6.png","front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/ruby-sapphire/6.png","front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iii/ruby-sapphire/shiny/6.png"}},"generation-iv":{"diamond-pearl":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/diamond-pearl/back/6.png","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/diamond-pearl/back/shiny/6.png","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/diamond-pearl/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/diamond-pearl/shiny/6.png","front_shiny_female":null},"heartgold-soulsilver":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/back/6.png","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/back/shiny/6.png","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/shiny/6.png","front_shiny_female":null},"platinum":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/platinum/back/6.png","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/platinum/back/shiny/6.png","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/platinum/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/platinum/shiny/6.png","front_shiny_female":null}},"generation-ix":{"scarlet-violet":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-ix/scarlet-violet/6.png","front_female":null}},"generation-v":{"black-white":{"animated":{"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/back/6.gif","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/back/shiny/6.gif","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/6.gif","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/6.gif","front_shiny_female":null},"back_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/back/6.png","back_female":null,"back_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/back/shiny/6.png","back_shiny_female":null,"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/shiny/6.png","front_shiny_female":null}},"generation-vi":{"omegaruby-alphasapphire":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vi/omegaruby-alphasapphire/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vi/omegaruby-alphasapphire/shiny/6.png","front_shiny_female":null},"x-y":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vi/x-y/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vi/x-y/shiny/6.png","front_shiny_female":null}},"generation-vii":{"icons":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/icons/6.png","front_female":null},"ultra-sun-ultra-moon":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/6.png","front_female":null,"front_shiny":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/shiny/6.png","front_shiny_female":null}},"generation-viii":{"brilliant-diamond-shining-pearl":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-viii/brilliant-diamond-shining-pearl/6.png","front_female":null},"icons":{"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-viii/icons/6.png","front_female":null}}}},"stats":[{"base_stat":78,"effort":0,"stat":{"name":"hp","url":"https://pokeapi.co/api/v2/stat/1/"}},{"base_stat":84,"effort":0,"stat":{"name":"attack","url":"https://pokeapi.co/api/v2/stat/2/"}},{"base_stat":78,"effort":0,"stat":{"name":"defense","url":"https://pokeapi.co/api/v2/stat/3/"}},{"base_stat":109,"effort":3,"stat":{"name":"special-attack","url":"https://pokeapi.co/api/v2/stat/4/"}},{"base_stat":85,"effort":0,"stat":{"name":"special-defense","url":"https://pokeapi.co/api/v2/stat/5/"}},{"base_stat":100,"effort":0,"stat":{"name":"speed","url":"https://pokeapi.co/api/v2/stat/6/"}}],"types":[{"slot":1,"type":{"name":"fire","url":"https://pokeapi.co/api/v2/type/10/"}},{"slot":2,"type":{"name":"flying","url":"https://pokeapi.co/api/v2/type/3/"}}],"weight":905}
\ No newline at end of file
diff --git a/services/tests/fixtures/type-fire.json b/services/tests/fixtures/type-fire.json
new file mode 100644
index 00000000..64c6e5d5
--- /dev/null
+++ b/services/tests/fixtures/type-fire.json
@@ -0,0 +1 @@
+{"damage_relations":{"double_damage_from":[{"name":"ground","url":"https://pokeapi.co/api/v2/type/5/"},{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"water","url":"https://pokeapi.co/api/v2/type/11/"}],"double_damage_to":[{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"steel","url":"https://pokeapi.co/api/v2/type/9/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"},{"name":"ice","url":"https://pokeapi.co/api/v2/type/15/"}],"half_damage_from":[{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"steel","url":"https://pokeapi.co/api/v2/type/9/"},{"name":"fire","url":"https://pokeapi.co/api/v2/type/10/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"},{"name":"ice","url":"https://pokeapi.co/api/v2/type/15/"},{"name":"fairy","url":"https://pokeapi.co/api/v2/type/18/"}],"half_damage_to":[{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"fire","url":"https://pokeapi.co/api/v2/type/10/"},{"name":"water","url":"https://pokeapi.co/api/v2/type/11/"},{"name":"dragon","url":"https://pokeapi.co/api/v2/type/16/"}],"no_damage_from":[],"no_damage_to":[]},"game_indices":[{"game_index":20,"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"}},{"game_index":20,"generation":{"name":"generation-ii","url":"https://pokeapi.co/api/v2/generation/2/"}},{"game_index":10,"generation":{"name":"generation-iii","url":"https://pokeapi.co/api/v2/generation/3/"}},{"game_index":10,"generation":{"name":"generation-iv","url":"https://pokeapi.co/api/v2/generation/4/"}},{"game_index":9,"generation":{"name":"generation-v","url":"https://pokeapi.co/api/v2/generation/5/"}},{"game_index":9,"generation":{"name":"generation-vi","url":"https://pokeapi.co/api/v2/generation/6/"}}],"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"},"id":10,"move_damage_class":{"name":"special","url":"https://pokeapi.co/api/v2/move-damage-class/3/"},"moves":[{"name":"fire-punch","url":"https://pokeapi.co/api/v2/move/7/"},{"name":"ember","url":"https://pokeapi.co/api/v2/move/52/"},{"name":"flamethrower","url":"https://pokeapi.co/api/v2/move/53/"},{"name":"fire-spin","url":"https://pokeapi.co/api/v2/move/83/"},{"name":"fire-blast","url":"https://pokeapi.co/api/v2/move/126/"},{"name":"flame-wheel","url":"https://pokeapi.co/api/v2/move/172/"},{"name":"sacred-fire","url":"https://pokeapi.co/api/v2/move/221/"},{"name":"sunny-day","url":"https://pokeapi.co/api/v2/move/241/"},{"name":"heat-wave","url":"https://pokeapi.co/api/v2/move/257/"},{"name":"will-o-wisp","url":"https://pokeapi.co/api/v2/move/261/"},{"name":"eruption","url":"https://pokeapi.co/api/v2/move/284/"},{"name":"blaze-kick","url":"https://pokeapi.co/api/v2/move/299/"},{"name":"blast-burn","url":"https://pokeapi.co/api/v2/move/307/"},{"name":"overheat","url":"https://pokeapi.co/api/v2/move/315/"},{"name":"flare-blitz","url":"https://pokeapi.co/api/v2/move/394/"},{"name":"fire-fang","url":"https://pokeapi.co/api/v2/move/424/"},{"name":"lava-plume","url":"https://pokeapi.co/api/v2/move/436/"},{"name":"magma-storm","url":"https://pokeapi.co/api/v2/move/463/"},{"name":"flame-burst","url":"https://pokeapi.co/api/v2/move/481/"},{"name":"flame-charge","url":"https://pokeapi.co/api/v2/move/488/"},{"name":"incinerate","url":"https://pokeapi.co/api/v2/move/510/"},{"name":"inferno","url":"https://pokeapi.co/api/v2/move/517/"},{"name":"fire-pledge","url":"https://pokeapi.co/api/v2/move/519/"},{"name":"heat-crash","url":"https://pokeapi.co/api/v2/move/535/"},{"name":"searing-shot","url":"https://pokeapi.co/api/v2/move/545/"},{"name":"blue-flare","url":"https://pokeapi.co/api/v2/move/551/"},{"name":"fiery-dance","url":"https://pokeapi.co/api/v2/move/552/"},{"name":"v-create","url":"https://pokeapi.co/api/v2/move/557/"},{"name":"fusion-flare","url":"https://pokeapi.co/api/v2/move/558/"},{"name":"mystical-fire","url":"https://pokeapi.co/api/v2/move/595/"},{"name":"inferno-overdrive--physical","url":"https://pokeapi.co/api/v2/move/640/"},{"name":"inferno-overdrive--special","url":"https://pokeapi.co/api/v2/move/641/"},{"name":"fire-lash","url":"https://pokeapi.co/api/v2/move/680/"},{"name":"burn-up","url":"https://pokeapi.co/api/v2/move/682/"},{"name":"shell-trap","url":"https://pokeapi.co/api/v2/move/704/"},{"name":"mind-blown","url":"https://pokeapi.co/api/v2/move/720/"},{"name":"sizzly-slide","url":"https://pokeapi.co/api/v2/move/735/"},{"name":"max-flare","url":"https://pokeapi.co/api/v2/move/757/"},{"name":"pyro-ball","url":"https://pokeapi.co/api/v2/move/780/"},{"name":"burning-jealousy","url":"https://pokeapi.co/api/v2/move/807/"},{"name":"raging-fury","url":"https://pokeapi.co/api/v2/move/833/"},{"name":"torch-song","url":"https://pokeapi.co/api/v2/move/871/"},{"name":"armor-cannon","url":"https://pokeapi.co/api/v2/move/890/"},{"name":"bitter-blade","url":"https://pokeapi.co/api/v2/move/891/"},{"name":"blazing-torque","url":"https://pokeapi.co/api/v2/move/896/"},{"name":"burning-bulwark","url":"https://pokeapi.co/api/v2/move/908/"},{"name":"temper-flare","url":"https://pokeapi.co/api/v2/move/915/"}],"name":"fire","names":[{"language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"name":"ほのお"},{"language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"name":"불꽃"},{"language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"name":"火"},{"language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"name":"Feu"},{"language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"name":"Feuer"},{"language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"name":"Fuego"},{"language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"name":"Fuoco"},{"language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"name":"Fire"},{"language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"name":"ほのお"},{"language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"name":"火"}],"past_damage_relations":[{"damage_relations":{"double_damage_from":[{"name":"ground","url":"https://pokeapi.co/api/v2/type/5/"},{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"water","url":"https://pokeapi.co/api/v2/type/11/"}],"double_damage_to":[{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"},{"name":"ice","url":"https://pokeapi.co/api/v2/type/15/"}],"half_damage_from":[{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"fire","url":"https://pokeapi.co/api/v2/type/10/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"}],"half_damage_to":[{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"fire","url":"https://pokeapi.co/api/v2/type/10/"},{"name":"water","url":"https://pokeapi.co/api/v2/type/11/"},{"name":"dragon","url":"https://pokeapi.co/api/v2/type/16/"}],"no_damage_from":[],"no_damage_to":[]},"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"}}],"pokemon":[{"pokemon":{"name":"charmander","url":"https://pokeapi.co/api/v2/pokemon/4/"},"slot":1},{"pokemon":{"name":"charmeleon","url":"https://pokeapi.co/api/v2/pokemon/5/"},"slot":1},{"pokemon":{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon/6/"},"slot":1},{"pokemon":{"name":"vulpix","url":"https://pokeapi.co/api/v2/pokemon/37/"},"slot":1},{"pokemon":{"name":"ninetales","url":"https://pokeapi.co/api/v2/pokemon/38/"},"slot":1},{"pokemon":{"name":"growlithe","url":"https://pokeapi.co/api/v2/pokemon/58/"},"slot":1},{"pokemon":{"name":"arcanine","url":"https://pokeapi.co/api/v2/pokemon/59/"},"slot":1},{"pokemon":{"name":"ponyta","url":"https://pokeapi.co/api/v2/pokemon/77/"},"slot":1},{"pokemon":{"name":"rapidash","url":"https://pokeapi.co/api/v2/pokemon/78/"},"slot":1},{"pokemon":{"name":"magmar","url":"https://pokeapi.co/api/v2/pokemon/126/"},"slot":1},{"pokemon":{"name":"flareon","url":"https://pokeapi.co/api/v2/pokemon/136/"},"slot":1},{"pokemon":{"name":"moltres","url":"https://pokeapi.co/api/v2/pokemon/146/"},"slot":1},{"pokemon":{"name":"cyndaquil","url":"https://pokeapi.co/api/v2/pokemon/155/"},"slot":1},{"pokemon":{"name":"quilava","url":"https://pokeapi.co/api/v2/pokemon/156/"},"slot":1},{"pokemon":{"name":"typhlosion","url":"https://pokeapi.co/api/v2/pokemon/157/"},"slot":1},{"pokemon":{"name":"slugma","url":"https://pokeapi.co/api/v2/pokemon/218/"},"slot":1},{"pokemon":{"name":"magcargo","url":"https://pokeapi.co/api/v2/pokemon/219/"},"slot":1},{"pokemon":{"name":"houndour","url":"https://pokeapi.co/api/v2/pokemon/228/"},"slot":2},{"pokemon":{"name":"houndoom","url":"https://pokeapi.co/api/v2/pokemon/229/"},"slot":2},{"pokemon":{"name":"magby","url":"https://pokeapi.co/api/v2/pokemon/240/"},"slot":1},{"pokemon":{"name":"entei","url":"https://pokeapi.co/api/v2/pokemon/244/"},"slot":1},{"pokemon":{"name":"ho-oh","url":"https://pokeapi.co/api/v2/pokemon/250/"},"slot":1},{"pokemon":{"name":"torchic","url":"https://pokeapi.co/api/v2/pokemon/255/"},"slot":1},{"pokemon":{"name":"combusken","url":"https://pokeapi.co/api/v2/pokemon/256/"},"slot":1},{"pokemon":{"name":"blaziken","url":"https://pokeapi.co/api/v2/pokemon/257/"},"slot":1},{"pokemon":{"name":"numel","url":"https://pokeapi.co/api/v2/pokemon/322/"},"slot":1},{"pokemon":{"name":"camerupt","url":"https://pokeapi.co/api/v2/pokemon/323/"},"slot":1},{"pokemon":{"name":"torkoal","url":"https://pokeapi.co/api/v2/pokemon/324/"},"slot":1},{"pokemon":{"name":"chimchar","url":"https://pokeapi.co/api/v2/pokemon/390/"},"slot":1},{"pokemon":{"name":"monferno","url":"https://pokeapi.co/api/v2/pokemon/391/"},"slot":1},{"pokemon":{"name":"infernape","url":"https://pokeapi.co/api/v2/pokemon/392/"},"slot":1},{"pokemon":{"name":"magmortar","url":"https://pokeapi.co/api/v2/pokemon/467/"},"slot":1},{"pokemon":{"name":"heatran","url":"https://pokeapi.co/api/v2/pokemon/485/"},"slot":1},{"pokemon":{"name":"victini","url":"https://pokeapi.co/api/v2/pokemon/494/"},"slot":2},{"pokemon":{"name":"tepig","url":"https://pokeapi.co/api/v2/pokemon/498/"},"slot":1},{"pokemon":{"name":"pignite","url":"https://pokeapi.co/api/v2/pokemon/499/"},"slot":1},{"pokemon":{"name":"emboar","url":"https://pokeapi.co/api/v2/pokemon/500/"},"slot":1},{"pokemon":{"name":"pansear","url":"https://pokeapi.co/api/v2/pokemon/513/"},"slot":1},{"pokemon":{"name":"simisear","url":"https://pokeapi.co/api/v2/pokemon/514/"},"slot":1},{"pokemon":{"name":"darumaka","url":"https://pokeapi.co/api/v2/pokemon/554/"},"slot":1},{"pokemon":{"name":"darmanitan-standard","url":"https://pokeapi.co/api/v2/pokemon/555/"},"slot":1},{"pokemon":{"name":"litwick","url":"https://pokeapi.co/api/v2/pokemon/607/"},"slot":2},{"pokemon":{"name":"lampent","url":"https://pokeapi.co/api/v2/pokemon/608/"},"slot":2},{"pokemon":{"name":"chandelure","url":"https://pokeapi.co/api/v2/pokemon/609/"},"slot":2},{"pokemon":{"name":"heatmor","url":"https://pokeapi.co/api/v2/pokemon/631/"},"slot":1},{"pokemon":{"name":"larvesta","url":"https://pokeapi.co/api/v2/pokemon/636/"},"slot":2},{"pokemon":{"name":"volcarona","url":"https://pokeapi.co/api/v2/pokemon/637/"},"slot":2},{"pokemon":{"name":"reshiram","url":"https://pokeapi.co/api/v2/pokemon/643/"},"slot":2},{"pokemon":{"name":"fennekin","url":"https://pokeapi.co/api/v2/pokemon/653/"},"slot":1},{"pokemon":{"name":"braixen","url":"https://pokeapi.co/api/v2/pokemon/654/"},"slot":1},{"pokemon":{"name":"delphox","url":"https://pokeapi.co/api/v2/pokemon/655/"},"slot":1},{"pokemon":{"name":"fletchinder","url":"https://pokeapi.co/api/v2/pokemon/662/"},"slot":1},{"pokemon":{"name":"talonflame","url":"https://pokeapi.co/api/v2/pokemon/663/"},"slot":1},{"pokemon":{"name":"litleo","url":"https://pokeapi.co/api/v2/pokemon/667/"},"slot":1},{"pokemon":{"name":"pyroar-male","url":"https://pokeapi.co/api/v2/pokemon/668/"},"slot":1},{"pokemon":{"name":"volcanion","url":"https://pokeapi.co/api/v2/pokemon/721/"},"slot":1},{"pokemon":{"name":"litten","url":"https://pokeapi.co/api/v2/pokemon/725/"},"slot":1},{"pokemon":{"name":"torracat","url":"https://pokeapi.co/api/v2/pokemon/726/"},"slot":1},{"pokemon":{"name":"incineroar","url":"https://pokeapi.co/api/v2/pokemon/727/"},"slot":1},{"pokemon":{"name":"oricorio-baile","url":"https://pokeapi.co/api/v2/pokemon/741/"},"slot":1},{"pokemon":{"name":"salandit","url":"https://pokeapi.co/api/v2/pokemon/757/"},"slot":2},{"pokemon":{"name":"salazzle","url":"https://pokeapi.co/api/v2/pokemon/758/"},"slot":2},{"pokemon":{"name":"turtonator","url":"https://pokeapi.co/api/v2/pokemon/776/"},"slot":1},{"pokemon":{"name":"blacephalon","url":"https://pokeapi.co/api/v2/pokemon/806/"},"slot":1},{"pokemon":{"name":"scorbunny","url":"https://pokeapi.co/api/v2/pokemon/813/"},"slot":1},{"pokemon":{"name":"raboot","url":"https://pokeapi.co/api/v2/pokemon/814/"},"slot":1},{"pokemon":{"name":"cinderace","url":"https://pokeapi.co/api/v2/pokemon/815/"},"slot":1},{"pokemon":{"name":"carkol","url":"https://pokeapi.co/api/v2/pokemon/838/"},"slot":2},{"pokemon":{"name":"coalossal","url":"https://pokeapi.co/api/v2/pokemon/839/"},"slot":2},{"pokemon":{"name":"sizzlipede","url":"https://pokeapi.co/api/v2/pokemon/850/"},"slot":1},{"pokemon":{"name":"centiskorch","url":"https://pokeapi.co/api/v2/pokemon/851/"},"slot":1},{"pokemon":{"name":"fuecoco","url":"https://pokeapi.co/api/v2/pokemon/909/"},"slot":1},{"pokemon":{"name":"crocalor","url":"https://pokeapi.co/api/v2/pokemon/910/"},"slot":1},{"pokemon":{"name":"skeledirge","url":"https://pokeapi.co/api/v2/pokemon/911/"},"slot":1},{"pokemon":{"name":"charcadet","url":"https://pokeapi.co/api/v2/pokemon/935/"},"slot":1},{"pokemon":{"name":"armarouge","url":"https://pokeapi.co/api/v2/pokemon/936/"},"slot":1},{"pokemon":{"name":"ceruledge","url":"https://pokeapi.co/api/v2/pokemon/937/"},"slot":1},{"pokemon":{"name":"scovillain","url":"https://pokeapi.co/api/v2/pokemon/952/"},"slot":2},{"pokemon":{"name":"iron-moth","url":"https://pokeapi.co/api/v2/pokemon/994/"},"slot":1},{"pokemon":{"name":"chi-yu","url":"https://pokeapi.co/api/v2/pokemon/1004/"},"slot":2},{"pokemon":{"name":"gouging-fire","url":"https://pokeapi.co/api/v2/pokemon/1020/"},"slot":1},{"pokemon":{"name":"rotom-heat","url":"https://pokeapi.co/api/v2/pokemon/10008/"},"slot":2},{"pokemon":{"name":"castform-sunny","url":"https://pokeapi.co/api/v2/pokemon/10013/"},"slot":1},{"pokemon":{"name":"darmanitan-zen","url":"https://pokeapi.co/api/v2/pokemon/10017/"},"slot":1},{"pokemon":{"name":"charizard-mega-x","url":"https://pokeapi.co/api/v2/pokemon/10034/"},"slot":1},{"pokemon":{"name":"charizard-mega-y","url":"https://pokeapi.co/api/v2/pokemon/10035/"},"slot":1},{"pokemon":{"name":"houndoom-mega","url":"https://pokeapi.co/api/v2/pokemon/10048/"},"slot":2},{"pokemon":{"name":"blaziken-mega","url":"https://pokeapi.co/api/v2/pokemon/10050/"},"slot":1},{"pokemon":{"name":"groudon-primal","url":"https://pokeapi.co/api/v2/pokemon/10078/"},"slot":2},{"pokemon":{"name":"camerupt-mega","url":"https://pokeapi.co/api/v2/pokemon/10087/"},"slot":1},{"pokemon":{"name":"marowak-alola","url":"https://pokeapi.co/api/v2/pokemon/10115/"},"slot":1},{"pokemon":{"name":"salazzle-totem","url":"https://pokeapi.co/api/v2/pokemon/10129/"},"slot":2},{"pokemon":{"name":"marowak-totem","url":"https://pokeapi.co/api/v2/pokemon/10149/"},"slot":1},{"pokemon":{"name":"darmanitan-galar-zen","url":"https://pokeapi.co/api/v2/pokemon/10178/"},"slot":2},{"pokemon":{"name":"charizard-gmax","url":"https://pokeapi.co/api/v2/pokemon/10196/"},"slot":1},{"pokemon":{"name":"cinderace-gmax","url":"https://pokeapi.co/api/v2/pokemon/10210/"},"slot":1},{"pokemon":{"name":"coalossal-gmax","url":"https://pokeapi.co/api/v2/pokemon/10215/"},"slot":2},{"pokemon":{"name":"centiskorch-gmax","url":"https://pokeapi.co/api/v2/pokemon/10220/"},"slot":1},{"pokemon":{"name":"growlithe-hisui","url":"https://pokeapi.co/api/v2/pokemon/10229/"},"slot":1},{"pokemon":{"name":"arcanine-hisui","url":"https://pokeapi.co/api/v2/pokemon/10230/"},"slot":1},{"pokemon":{"name":"typhlosion-hisui","url":"https://pokeapi.co/api/v2/pokemon/10233/"},"slot":1},{"pokemon":{"name":"tauros-paldea-blaze-breed","url":"https://pokeapi.co/api/v2/pokemon/10251/"},"slot":2},{"pokemon":{"name":"ogerpon-hearthflame-mask","url":"https://pokeapi.co/api/v2/pokemon/10274/"},"slot":2},{"pokemon":{"name":"emboar-mega","url":"https://pokeapi.co/api/v2/pokemon/10286/"},"slot":1},{"pokemon":{"name":"chandelure-mega","url":"https://pokeapi.co/api/v2/pokemon/10291/"},"slot":2},{"pokemon":{"name":"delphox-mega","url":"https://pokeapi.co/api/v2/pokemon/10293/"},"slot":1},{"pokemon":{"name":"pyroar-mega","url":"https://pokeapi.co/api/v2/pokemon/10295/"},"slot":1},{"pokemon":{"name":"heatran-mega","url":"https://pokeapi.co/api/v2/pokemon/10311/"},"slot":1},{"pokemon":{"name":"scovillain-mega","url":"https://pokeapi.co/api/v2/pokemon/10320/"},"slot":2}],"sprites":{"generation-iii":{"colosseum":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/colosseum/10.png","symbol_icon":null},"emerald":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/emerald/10.png","symbol_icon":null},"firered-leafgreen":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/firered-leafgreen/10.png","symbol_icon":null},"ruby-sapphire":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/ruby-sapphire/10.png","symbol_icon":null},"xd":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/xd/10.png","symbol_icon":null}},"generation-iv":{"diamond-pearl":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/diamond-pearl/10.png","symbol_icon":null},"heartgold-soulsilver":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/heartgold-soulsilver/10.png","symbol_icon":null},"platinum":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/platinum/10.png","symbol_icon":null}},"generation-ix":{"scarlet-violet":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/10.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/small/10.png"}},"generation-v":{"black-2-white-2":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-v/black-2-white-2/10.png","symbol_icon":null},"black-white":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-v/black-white/10.png","symbol_icon":null}},"generation-vi":{"omega-ruby-alpha-sapphire":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vi/omega-ruby-alpha-sapphire/10.png","symbol_icon":null},"x-y":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vi/x-y/10.png","symbol_icon":null}},"generation-vii":{"lets-go-pikachu-lets-go-eevee":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/lets-go-pikachu-lets-go-eevee/10.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/lets-go-pikachu-lets-go-eevee/small/10.png"},"sun-moon":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/sun-moon/10.png","symbol_icon":null},"ultra-sun-ultra-moon":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/ultra-sun-ultra-moon/10.png","symbol_icon":null}},"generation-viii":{"brilliant-diamond-shining-pearl":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/brilliant-diamond-shining-pearl/10.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/brilliant-diamond-shining-pearl/small/10.png"},"legends-arceus":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/legends-arceus/10.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/legends-arceus/small/10.png"},"sword-shield":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/sword-shield/10.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/sword-shield/small/10.png"}}}}
\ No newline at end of file
diff --git a/services/tests/fixtures/type-flying.json b/services/tests/fixtures/type-flying.json
new file mode 100644
index 00000000..a5fad636
--- /dev/null
+++ b/services/tests/fixtures/type-flying.json
@@ -0,0 +1 @@
+{"damage_relations":{"double_damage_from":[{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"electric","url":"https://pokeapi.co/api/v2/type/13/"},{"name":"ice","url":"https://pokeapi.co/api/v2/type/15/"}],"double_damage_to":[{"name":"fighting","url":"https://pokeapi.co/api/v2/type/2/"},{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"}],"half_damage_from":[{"name":"fighting","url":"https://pokeapi.co/api/v2/type/2/"},{"name":"bug","url":"https://pokeapi.co/api/v2/type/7/"},{"name":"grass","url":"https://pokeapi.co/api/v2/type/12/"}],"half_damage_to":[{"name":"rock","url":"https://pokeapi.co/api/v2/type/6/"},{"name":"steel","url":"https://pokeapi.co/api/v2/type/9/"},{"name":"electric","url":"https://pokeapi.co/api/v2/type/13/"}],"no_damage_from":[{"name":"ground","url":"https://pokeapi.co/api/v2/type/5/"}],"no_damage_to":[]},"game_indices":[{"game_index":2,"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"}},{"game_index":2,"generation":{"name":"generation-ii","url":"https://pokeapi.co/api/v2/generation/2/"}},{"game_index":2,"generation":{"name":"generation-iii","url":"https://pokeapi.co/api/v2/generation/3/"}},{"game_index":2,"generation":{"name":"generation-iv","url":"https://pokeapi.co/api/v2/generation/4/"}},{"game_index":2,"generation":{"name":"generation-v","url":"https://pokeapi.co/api/v2/generation/5/"}},{"game_index":2,"generation":{"name":"generation-vi","url":"https://pokeapi.co/api/v2/generation/6/"}}],"generation":{"name":"generation-i","url":"https://pokeapi.co/api/v2/generation/1/"},"id":3,"move_damage_class":{"name":"physical","url":"https://pokeapi.co/api/v2/move-damage-class/2/"},"moves":[{"name":"gust","url":"https://pokeapi.co/api/v2/move/16/"},{"name":"wing-attack","url":"https://pokeapi.co/api/v2/move/17/"},{"name":"fly","url":"https://pokeapi.co/api/v2/move/19/"},{"name":"peck","url":"https://pokeapi.co/api/v2/move/64/"},{"name":"drill-peck","url":"https://pokeapi.co/api/v2/move/65/"},{"name":"mirror-move","url":"https://pokeapi.co/api/v2/move/119/"},{"name":"sky-attack","url":"https://pokeapi.co/api/v2/move/143/"},{"name":"aeroblast","url":"https://pokeapi.co/api/v2/move/177/"},{"name":"feather-dance","url":"https://pokeapi.co/api/v2/move/297/"},{"name":"air-cutter","url":"https://pokeapi.co/api/v2/move/314/"},{"name":"aerial-ace","url":"https://pokeapi.co/api/v2/move/332/"},{"name":"bounce","url":"https://pokeapi.co/api/v2/move/340/"},{"name":"roost","url":"https://pokeapi.co/api/v2/move/355/"},{"name":"pluck","url":"https://pokeapi.co/api/v2/move/365/"},{"name":"tailwind","url":"https://pokeapi.co/api/v2/move/366/"},{"name":"air-slash","url":"https://pokeapi.co/api/v2/move/403/"},{"name":"brave-bird","url":"https://pokeapi.co/api/v2/move/413/"},{"name":"defog","url":"https://pokeapi.co/api/v2/move/432/"},{"name":"chatter","url":"https://pokeapi.co/api/v2/move/448/"},{"name":"sky-drop","url":"https://pokeapi.co/api/v2/move/507/"},{"name":"acrobatics","url":"https://pokeapi.co/api/v2/move/512/"},{"name":"hurricane","url":"https://pokeapi.co/api/v2/move/542/"},{"name":"oblivion-wing","url":"https://pokeapi.co/api/v2/move/613/"},{"name":"dragon-ascent","url":"https://pokeapi.co/api/v2/move/620/"},{"name":"supersonic-skystrike--physical","url":"https://pokeapi.co/api/v2/move/626/"},{"name":"supersonic-skystrike--special","url":"https://pokeapi.co/api/v2/move/627/"},{"name":"beak-blast","url":"https://pokeapi.co/api/v2/move/690/"},{"name":"floaty-fall","url":"https://pokeapi.co/api/v2/move/731/"},{"name":"max-airstream","url":"https://pokeapi.co/api/v2/move/766/"},{"name":"dual-wingbeat","url":"https://pokeapi.co/api/v2/move/814/"},{"name":"bleakwind-storm","url":"https://pokeapi.co/api/v2/move/846/"}],"name":"flying","names":[{"language":{"name":"ja-hrkt","url":"https://pokeapi.co/api/v2/language/1/"},"name":"ひこう"},{"language":{"name":"ko","url":"https://pokeapi.co/api/v2/language/3/"},"name":"비행"},{"language":{"name":"zh-hant","url":"https://pokeapi.co/api/v2/language/4/"},"name":"飛行"},{"language":{"name":"fr","url":"https://pokeapi.co/api/v2/language/5/"},"name":"Vol"},{"language":{"name":"de","url":"https://pokeapi.co/api/v2/language/6/"},"name":"Flug"},{"language":{"name":"es","url":"https://pokeapi.co/api/v2/language/7/"},"name":"Volador"},{"language":{"name":"it","url":"https://pokeapi.co/api/v2/language/8/"},"name":"Volante"},{"language":{"name":"en","url":"https://pokeapi.co/api/v2/language/9/"},"name":"Flying"},{"language":{"name":"ja","url":"https://pokeapi.co/api/v2/language/11/"},"name":"ひこう"},{"language":{"name":"zh-hans","url":"https://pokeapi.co/api/v2/language/12/"},"name":"飞行"}],"past_damage_relations":[],"pokemon":[{"pokemon":{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon/6/"},"slot":2},{"pokemon":{"name":"butterfree","url":"https://pokeapi.co/api/v2/pokemon/12/"},"slot":2},{"pokemon":{"name":"pidgey","url":"https://pokeapi.co/api/v2/pokemon/16/"},"slot":2},{"pokemon":{"name":"pidgeotto","url":"https://pokeapi.co/api/v2/pokemon/17/"},"slot":2},{"pokemon":{"name":"pidgeot","url":"https://pokeapi.co/api/v2/pokemon/18/"},"slot":2},{"pokemon":{"name":"spearow","url":"https://pokeapi.co/api/v2/pokemon/21/"},"slot":2},{"pokemon":{"name":"fearow","url":"https://pokeapi.co/api/v2/pokemon/22/"},"slot":2},{"pokemon":{"name":"zubat","url":"https://pokeapi.co/api/v2/pokemon/41/"},"slot":2},{"pokemon":{"name":"golbat","url":"https://pokeapi.co/api/v2/pokemon/42/"},"slot":2},{"pokemon":{"name":"farfetchd","url":"https://pokeapi.co/api/v2/pokemon/83/"},"slot":2},{"pokemon":{"name":"doduo","url":"https://pokeapi.co/api/v2/pokemon/84/"},"slot":2},{"pokemon":{"name":"dodrio","url":"https://pokeapi.co/api/v2/pokemon/85/"},"slot":2},{"pokemon":{"name":"scyther","url":"https://pokeapi.co/api/v2/pokemon/123/"},"slot":2},{"pokemon":{"name":"gyarados","url":"https://pokeapi.co/api/v2/pokemon/130/"},"slot":2},{"pokemon":{"name":"aerodactyl","url":"https://pokeapi.co/api/v2/pokemon/142/"},"slot":2},{"pokemon":{"name":"articuno","url":"https://pokeapi.co/api/v2/pokemon/144/"},"slot":2},{"pokemon":{"name":"zapdos","url":"https://pokeapi.co/api/v2/pokemon/145/"},"slot":2},{"pokemon":{"name":"moltres","url":"https://pokeapi.co/api/v2/pokemon/146/"},"slot":2},{"pokemon":{"name":"dragonite","url":"https://pokeapi.co/api/v2/pokemon/149/"},"slot":2},{"pokemon":{"name":"hoothoot","url":"https://pokeapi.co/api/v2/pokemon/163/"},"slot":2},{"pokemon":{"name":"noctowl","url":"https://pokeapi.co/api/v2/pokemon/164/"},"slot":2},{"pokemon":{"name":"ledyba","url":"https://pokeapi.co/api/v2/pokemon/165/"},"slot":2},{"pokemon":{"name":"ledian","url":"https://pokeapi.co/api/v2/pokemon/166/"},"slot":2},{"pokemon":{"name":"crobat","url":"https://pokeapi.co/api/v2/pokemon/169/"},"slot":2},{"pokemon":{"name":"togetic","url":"https://pokeapi.co/api/v2/pokemon/176/"},"slot":2},{"pokemon":{"name":"natu","url":"https://pokeapi.co/api/v2/pokemon/177/"},"slot":2},{"pokemon":{"name":"xatu","url":"https://pokeapi.co/api/v2/pokemon/178/"},"slot":2},{"pokemon":{"name":"hoppip","url":"https://pokeapi.co/api/v2/pokemon/187/"},"slot":2},{"pokemon":{"name":"skiploom","url":"https://pokeapi.co/api/v2/pokemon/188/"},"slot":2},{"pokemon":{"name":"jumpluff","url":"https://pokeapi.co/api/v2/pokemon/189/"},"slot":2},{"pokemon":{"name":"yanma","url":"https://pokeapi.co/api/v2/pokemon/193/"},"slot":2},{"pokemon":{"name":"murkrow","url":"https://pokeapi.co/api/v2/pokemon/198/"},"slot":2},{"pokemon":{"name":"gligar","url":"https://pokeapi.co/api/v2/pokemon/207/"},"slot":2},{"pokemon":{"name":"delibird","url":"https://pokeapi.co/api/v2/pokemon/225/"},"slot":2},{"pokemon":{"name":"mantine","url":"https://pokeapi.co/api/v2/pokemon/226/"},"slot":2},{"pokemon":{"name":"skarmory","url":"https://pokeapi.co/api/v2/pokemon/227/"},"slot":2},{"pokemon":{"name":"lugia","url":"https://pokeapi.co/api/v2/pokemon/249/"},"slot":2},{"pokemon":{"name":"ho-oh","url":"https://pokeapi.co/api/v2/pokemon/250/"},"slot":2},{"pokemon":{"name":"beautifly","url":"https://pokeapi.co/api/v2/pokemon/267/"},"slot":2},{"pokemon":{"name":"taillow","url":"https://pokeapi.co/api/v2/pokemon/276/"},"slot":2},{"pokemon":{"name":"swellow","url":"https://pokeapi.co/api/v2/pokemon/277/"},"slot":2},{"pokemon":{"name":"wingull","url":"https://pokeapi.co/api/v2/pokemon/278/"},"slot":2},{"pokemon":{"name":"pelipper","url":"https://pokeapi.co/api/v2/pokemon/279/"},"slot":2},{"pokemon":{"name":"masquerain","url":"https://pokeapi.co/api/v2/pokemon/284/"},"slot":2},{"pokemon":{"name":"ninjask","url":"https://pokeapi.co/api/v2/pokemon/291/"},"slot":2},{"pokemon":{"name":"swablu","url":"https://pokeapi.co/api/v2/pokemon/333/"},"slot":2},{"pokemon":{"name":"altaria","url":"https://pokeapi.co/api/v2/pokemon/334/"},"slot":2},{"pokemon":{"name":"tropius","url":"https://pokeapi.co/api/v2/pokemon/357/"},"slot":2},{"pokemon":{"name":"salamence","url":"https://pokeapi.co/api/v2/pokemon/373/"},"slot":2},{"pokemon":{"name":"rayquaza","url":"https://pokeapi.co/api/v2/pokemon/384/"},"slot":2},{"pokemon":{"name":"starly","url":"https://pokeapi.co/api/v2/pokemon/396/"},"slot":2},{"pokemon":{"name":"staravia","url":"https://pokeapi.co/api/v2/pokemon/397/"},"slot":2},{"pokemon":{"name":"staraptor","url":"https://pokeapi.co/api/v2/pokemon/398/"},"slot":2},{"pokemon":{"name":"mothim","url":"https://pokeapi.co/api/v2/pokemon/414/"},"slot":2},{"pokemon":{"name":"combee","url":"https://pokeapi.co/api/v2/pokemon/415/"},"slot":2},{"pokemon":{"name":"vespiquen","url":"https://pokeapi.co/api/v2/pokemon/416/"},"slot":2},{"pokemon":{"name":"drifloon","url":"https://pokeapi.co/api/v2/pokemon/425/"},"slot":2},{"pokemon":{"name":"drifblim","url":"https://pokeapi.co/api/v2/pokemon/426/"},"slot":2},{"pokemon":{"name":"honchkrow","url":"https://pokeapi.co/api/v2/pokemon/430/"},"slot":2},{"pokemon":{"name":"chatot","url":"https://pokeapi.co/api/v2/pokemon/441/"},"slot":2},{"pokemon":{"name":"mantyke","url":"https://pokeapi.co/api/v2/pokemon/458/"},"slot":2},{"pokemon":{"name":"togekiss","url":"https://pokeapi.co/api/v2/pokemon/468/"},"slot":2},{"pokemon":{"name":"yanmega","url":"https://pokeapi.co/api/v2/pokemon/469/"},"slot":2},{"pokemon":{"name":"gliscor","url":"https://pokeapi.co/api/v2/pokemon/472/"},"slot":2},{"pokemon":{"name":"pidove","url":"https://pokeapi.co/api/v2/pokemon/519/"},"slot":2},{"pokemon":{"name":"tranquill","url":"https://pokeapi.co/api/v2/pokemon/520/"},"slot":2},{"pokemon":{"name":"unfezant","url":"https://pokeapi.co/api/v2/pokemon/521/"},"slot":2},{"pokemon":{"name":"woobat","url":"https://pokeapi.co/api/v2/pokemon/527/"},"slot":2},{"pokemon":{"name":"swoobat","url":"https://pokeapi.co/api/v2/pokemon/528/"},"slot":2},{"pokemon":{"name":"sigilyph","url":"https://pokeapi.co/api/v2/pokemon/561/"},"slot":2},{"pokemon":{"name":"archen","url":"https://pokeapi.co/api/v2/pokemon/566/"},"slot":2},{"pokemon":{"name":"archeops","url":"https://pokeapi.co/api/v2/pokemon/567/"},"slot":2},{"pokemon":{"name":"ducklett","url":"https://pokeapi.co/api/v2/pokemon/580/"},"slot":2},{"pokemon":{"name":"swanna","url":"https://pokeapi.co/api/v2/pokemon/581/"},"slot":2},{"pokemon":{"name":"emolga","url":"https://pokeapi.co/api/v2/pokemon/587/"},"slot":2},{"pokemon":{"name":"rufflet","url":"https://pokeapi.co/api/v2/pokemon/627/"},"slot":2},{"pokemon":{"name":"braviary","url":"https://pokeapi.co/api/v2/pokemon/628/"},"slot":2},{"pokemon":{"name":"vullaby","url":"https://pokeapi.co/api/v2/pokemon/629/"},"slot":2},{"pokemon":{"name":"mandibuzz","url":"https://pokeapi.co/api/v2/pokemon/630/"},"slot":2},{"pokemon":{"name":"tornadus-incarnate","url":"https://pokeapi.co/api/v2/pokemon/641/"},"slot":1},{"pokemon":{"name":"thundurus-incarnate","url":"https://pokeapi.co/api/v2/pokemon/642/"},"slot":2},{"pokemon":{"name":"landorus-incarnate","url":"https://pokeapi.co/api/v2/pokemon/645/"},"slot":2},{"pokemon":{"name":"fletchling","url":"https://pokeapi.co/api/v2/pokemon/661/"},"slot":2},{"pokemon":{"name":"fletchinder","url":"https://pokeapi.co/api/v2/pokemon/662/"},"slot":2},{"pokemon":{"name":"talonflame","url":"https://pokeapi.co/api/v2/pokemon/663/"},"slot":2},{"pokemon":{"name":"vivillon","url":"https://pokeapi.co/api/v2/pokemon/666/"},"slot":2},{"pokemon":{"name":"hawlucha","url":"https://pokeapi.co/api/v2/pokemon/701/"},"slot":2},{"pokemon":{"name":"noibat","url":"https://pokeapi.co/api/v2/pokemon/714/"},"slot":1},{"pokemon":{"name":"noivern","url":"https://pokeapi.co/api/v2/pokemon/715/"},"slot":1},{"pokemon":{"name":"yveltal","url":"https://pokeapi.co/api/v2/pokemon/717/"},"slot":2},{"pokemon":{"name":"rowlet","url":"https://pokeapi.co/api/v2/pokemon/722/"},"slot":2},{"pokemon":{"name":"dartrix","url":"https://pokeapi.co/api/v2/pokemon/723/"},"slot":2},{"pokemon":{"name":"pikipek","url":"https://pokeapi.co/api/v2/pokemon/731/"},"slot":2},{"pokemon":{"name":"trumbeak","url":"https://pokeapi.co/api/v2/pokemon/732/"},"slot":2},{"pokemon":{"name":"toucannon","url":"https://pokeapi.co/api/v2/pokemon/733/"},"slot":2},{"pokemon":{"name":"oricorio-baile","url":"https://pokeapi.co/api/v2/pokemon/741/"},"slot":2},{"pokemon":{"name":"minior-red-meteor","url":"https://pokeapi.co/api/v2/pokemon/774/"},"slot":2},{"pokemon":{"name":"celesteela","url":"https://pokeapi.co/api/v2/pokemon/797/"},"slot":2},{"pokemon":{"name":"rookidee","url":"https://pokeapi.co/api/v2/pokemon/821/"},"slot":1},{"pokemon":{"name":"corvisquire","url":"https://pokeapi.co/api/v2/pokemon/822/"},"slot":1},{"pokemon":{"name":"corviknight","url":"https://pokeapi.co/api/v2/pokemon/823/"},"slot":1},{"pokemon":{"name":"cramorant","url":"https://pokeapi.co/api/v2/pokemon/845/"},"slot":1},{"pokemon":{"name":"enamorus-incarnate","url":"https://pokeapi.co/api/v2/pokemon/905/"},"slot":2},{"pokemon":{"name":"squawkabilly-green-plumage","url":"https://pokeapi.co/api/v2/pokemon/931/"},"slot":2},{"pokemon":{"name":"wattrel","url":"https://pokeapi.co/api/v2/pokemon/940/"},"slot":2},{"pokemon":{"name":"kilowattrel","url":"https://pokeapi.co/api/v2/pokemon/941/"},"slot":2},{"pokemon":{"name":"bombirdier","url":"https://pokeapi.co/api/v2/pokemon/962/"},"slot":1},{"pokemon":{"name":"flamigo","url":"https://pokeapi.co/api/v2/pokemon/973/"},"slot":1},{"pokemon":{"name":"iron-jugulis","url":"https://pokeapi.co/api/v2/pokemon/993/"},"slot":2},{"pokemon":{"name":"shaymin-sky","url":"https://pokeapi.co/api/v2/pokemon/10006/"},"slot":2},{"pokemon":{"name":"rotom-fan","url":"https://pokeapi.co/api/v2/pokemon/10011/"},"slot":2},{"pokemon":{"name":"tornadus-therian","url":"https://pokeapi.co/api/v2/pokemon/10019/"},"slot":1},{"pokemon":{"name":"thundurus-therian","url":"https://pokeapi.co/api/v2/pokemon/10020/"},"slot":2},{"pokemon":{"name":"landorus-therian","url":"https://pokeapi.co/api/v2/pokemon/10021/"},"slot":2},{"pokemon":{"name":"charizard-mega-y","url":"https://pokeapi.co/api/v2/pokemon/10035/"},"slot":2},{"pokemon":{"name":"pinsir-mega","url":"https://pokeapi.co/api/v2/pokemon/10040/"},"slot":2},{"pokemon":{"name":"aerodactyl-mega","url":"https://pokeapi.co/api/v2/pokemon/10042/"},"slot":2},{"pokemon":{"name":"pidgeot-mega","url":"https://pokeapi.co/api/v2/pokemon/10073/"},"slot":2},{"pokemon":{"name":"rayquaza-mega","url":"https://pokeapi.co/api/v2/pokemon/10079/"},"slot":2},{"pokemon":{"name":"salamence-mega","url":"https://pokeapi.co/api/v2/pokemon/10089/"},"slot":2},{"pokemon":{"name":"oricorio-pom-pom","url":"https://pokeapi.co/api/v2/pokemon/10123/"},"slot":2},{"pokemon":{"name":"oricorio-pau","url":"https://pokeapi.co/api/v2/pokemon/10124/"},"slot":2},{"pokemon":{"name":"oricorio-sensu","url":"https://pokeapi.co/api/v2/pokemon/10125/"},"slot":2},{"pokemon":{"name":"minior-orange-meteor","url":"https://pokeapi.co/api/v2/pokemon/10130/"},"slot":2},{"pokemon":{"name":"minior-yellow-meteor","url":"https://pokeapi.co/api/v2/pokemon/10131/"},"slot":2},{"pokemon":{"name":"minior-green-meteor","url":"https://pokeapi.co/api/v2/pokemon/10132/"},"slot":2},{"pokemon":{"name":"minior-blue-meteor","url":"https://pokeapi.co/api/v2/pokemon/10133/"},"slot":2},{"pokemon":{"name":"minior-indigo-meteor","url":"https://pokeapi.co/api/v2/pokemon/10134/"},"slot":2},{"pokemon":{"name":"minior-violet-meteor","url":"https://pokeapi.co/api/v2/pokemon/10135/"},"slot":2},{"pokemon":{"name":"minior-red","url":"https://pokeapi.co/api/v2/pokemon/10136/"},"slot":2},{"pokemon":{"name":"minior-orange","url":"https://pokeapi.co/api/v2/pokemon/10137/"},"slot":2},{"pokemon":{"name":"minior-yellow","url":"https://pokeapi.co/api/v2/pokemon/10138/"},"slot":2},{"pokemon":{"name":"minior-green","url":"https://pokeapi.co/api/v2/pokemon/10139/"},"slot":2},{"pokemon":{"name":"minior-blue","url":"https://pokeapi.co/api/v2/pokemon/10140/"},"slot":2},{"pokemon":{"name":"minior-indigo","url":"https://pokeapi.co/api/v2/pokemon/10141/"},"slot":2},{"pokemon":{"name":"minior-violet","url":"https://pokeapi.co/api/v2/pokemon/10142/"},"slot":2},{"pokemon":{"name":"articuno-galar","url":"https://pokeapi.co/api/v2/pokemon/10169/"},"slot":2},{"pokemon":{"name":"zapdos-galar","url":"https://pokeapi.co/api/v2/pokemon/10170/"},"slot":2},{"pokemon":{"name":"moltres-galar","url":"https://pokeapi.co/api/v2/pokemon/10171/"},"slot":2},{"pokemon":{"name":"cramorant-gulping","url":"https://pokeapi.co/api/v2/pokemon/10182/"},"slot":1},{"pokemon":{"name":"cramorant-gorging","url":"https://pokeapi.co/api/v2/pokemon/10183/"},"slot":1},{"pokemon":{"name":"charizard-gmax","url":"https://pokeapi.co/api/v2/pokemon/10196/"},"slot":2},{"pokemon":{"name":"butterfree-gmax","url":"https://pokeapi.co/api/v2/pokemon/10198/"},"slot":2},{"pokemon":{"name":"corviknight-gmax","url":"https://pokeapi.co/api/v2/pokemon/10212/"},"slot":1},{"pokemon":{"name":"braviary-hisui","url":"https://pokeapi.co/api/v2/pokemon/10240/"},"slot":2},{"pokemon":{"name":"enamorus-therian","url":"https://pokeapi.co/api/v2/pokemon/10249/"},"slot":2},{"pokemon":{"name":"squawkabilly-blue-plumage","url":"https://pokeapi.co/api/v2/pokemon/10260/"},"slot":2},{"pokemon":{"name":"squawkabilly-yellow-plumage","url":"https://pokeapi.co/api/v2/pokemon/10261/"},"slot":2},{"pokemon":{"name":"squawkabilly-white-plumage","url":"https://pokeapi.co/api/v2/pokemon/10262/"},"slot":2},{"pokemon":{"name":"clefable-mega","url":"https://pokeapi.co/api/v2/pokemon/10278/"},"slot":2},{"pokemon":{"name":"dragonite-mega","url":"https://pokeapi.co/api/v2/pokemon/10281/"},"slot":2},{"pokemon":{"name":"skarmory-mega","url":"https://pokeapi.co/api/v2/pokemon/10284/"},"slot":2},{"pokemon":{"name":"hawlucha-mega","url":"https://pokeapi.co/api/v2/pokemon/10300/"},"slot":2},{"pokemon":{"name":"staraptor-mega","url":"https://pokeapi.co/api/v2/pokemon/10308/"},"slot":2}],"sprites":{"generation-iii":{"colosseum":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/colosseum/3.png","symbol_icon":null},"emerald":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/emerald/3.png","symbol_icon":null},"firered-leafgreen":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/firered-leafgreen/3.png","symbol_icon":null},"ruby-sapphire":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/ruby-sapphire/3.png","symbol_icon":null},"xd":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iii/xd/3.png","symbol_icon":null}},"generation-iv":{"diamond-pearl":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/diamond-pearl/3.png","symbol_icon":null},"heartgold-soulsilver":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/heartgold-soulsilver/3.png","symbol_icon":null},"platinum":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-iv/platinum/3.png","symbol_icon":null}},"generation-ix":{"scarlet-violet":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/3.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/small/3.png"}},"generation-v":{"black-2-white-2":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-v/black-2-white-2/3.png","symbol_icon":null},"black-white":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-v/black-white/3.png","symbol_icon":null}},"generation-vi":{"omega-ruby-alpha-sapphire":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vi/omega-ruby-alpha-sapphire/3.png","symbol_icon":null},"x-y":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vi/x-y/3.png","symbol_icon":null}},"generation-vii":{"lets-go-pikachu-lets-go-eevee":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/lets-go-pikachu-lets-go-eevee/3.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/lets-go-pikachu-lets-go-eevee/small/3.png"},"sun-moon":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/sun-moon/3.png","symbol_icon":null},"ultra-sun-ultra-moon":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-vii/ultra-sun-ultra-moon/3.png","symbol_icon":null}},"generation-viii":{"brilliant-diamond-shining-pearl":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/brilliant-diamond-shining-pearl/3.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/brilliant-diamond-shining-pearl/small/3.png"},"legends-arceus":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/legends-arceus/3.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/legends-arceus/small/3.png"},"sword-shield":{"name_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/sword-shield/3.png","symbol_icon":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-viii/sword-shield/small/3.png"}}}}
\ No newline at end of file
diff --git a/setup/setup.go b/setup/setup.go
new file mode 100644
index 00000000..fa7f46f8
--- /dev/null
+++ b/setup/setup.go
@@ -0,0 +1,178 @@
+package setup
+
+import (
+ _ "embed"
+ "os/exec"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
+ "github.com/digitalghost-dev/poke-cli/flags"
+ "github.com/digitalghost-dev/poke-cli/styling"
+)
+
+//go:embed snorlax.txt
+var snorlax string
+
+const (
+ rowTheme = iota
+ rowCacheWarn
+ rowReleases
+ rowSave
+ rowCount
+)
+
+const releasesURL = "https://github.com/digitalghost-dev/poke-cli/releases"
+
+var themeChoices = []string{flags.ThemeYellow, flags.ThemeRed, flags.ThemeBlue}
+
+type model struct {
+ cfg flags.Config
+ cursor int
+ cacheOK bool
+ width int
+ height int
+ saved bool
+}
+
+func newModel(cfg flags.Config) model {
+ _, err := exec.LookPath("poke-cache")
+ return model{cfg: cfg, cacheOK: err == nil}
+}
+
+func Run(cfg flags.Config) (flags.Config, bool, error) {
+ out, err := tea.NewProgram(newModel(cfg)).Run()
+ if err != nil {
+ return cfg, false, err
+ }
+ final, ok := out.(model)
+ if !ok || !final.saved {
+ styling.ApplyTheme(cfg.Display.Theme)
+ return cfg, false, nil
+ }
+ styling.ApplyTheme(final.cfg.Display.Theme)
+ return final.cfg, true, nil
+}
+
+func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c", "esc", "q":
+ return m, tea.Quit
+ case "up", "k":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ return m, nil
+ case "down", "j":
+ if m.cursor < rowCount-1 {
+ m.cursor++
+ }
+ return m, nil
+ case "left", "right":
+ m.adjust(msg.String() == "right")
+ return m, nil
+ case "enter", "space":
+ switch m.cursor {
+ case rowReleases:
+ return m, utils.Open(releasesURL)
+ case rowSave:
+ m.saved = true
+ return m, tea.Quit
+ default:
+ m.adjust(true)
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+func (m *model) adjust(forward bool) {
+ switch m.cursor {
+ case rowTheme:
+ m.cfg.Display.Theme = cycle(themeChoices, m.cfg.Display.Theme, forward)
+ styling.ApplyTheme(m.cfg.Display.Theme)
+ case rowCacheWarn:
+ m.cfg.Cache.ShowWarning = !m.cfg.Cache.ShowWarning
+ }
+}
+
+func cycle(opts []string, cur string, forward bool) string {
+ i := 0
+ for j, o := range opts {
+ if o == cur {
+ i = j
+ break
+ }
+ }
+ if forward {
+ i = (i + 1) % len(opts)
+ } else {
+ i = (i - 1 + len(opts)) % len(opts)
+ }
+ return opts[i]
+}
+
+func (m model) Init() tea.Cmd {
+ return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ return m, nil
+ case tea.KeyPressMsg:
+ return m.handleKey(msg)
+ }
+ return m, nil
+}
+
+func (m model) View() tea.View {
+ welcomeMessage := "Welcome! Please choose some quick prefrences.\n\n"
+
+ row := func(label, value string, focused int) string {
+ cursor := " "
+ line := lipgloss.NewStyle().Render(label + " " + value)
+ if m.cursor == focused {
+ cursor = "> "
+ line = styling.Yellow.Render(label + " " + value)
+ }
+ return cursor + line
+ }
+
+ settings := lipgloss.JoinVertical(lipgloss.Left,
+ row("Theme", m.cfg.Display.Theme, rowTheme),
+ row("Cache warning", onOff(m.cfg.Cache.ShowWarning), rowCacheWarn),
+ "",
+ row("Open releases page", "", rowReleases),
+ row("Save & quit", "", rowSave),
+ )
+
+ cache := "not found"
+ if m.cacheOK {
+ cache = styling.Green.Render("✓ installed")
+ }
+ status := "poke-cache: " + cache
+
+ panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
+ BorderForeground(styling.YellowColor).Padding(1, 2)
+
+ body := lipgloss.JoinVertical(lipgloss.Left, panel.Render(settings), panel.Render(status))
+
+ content := body
+ if m.width == 0 || m.width >= lipgloss.Width(snorlax)+lipgloss.Width(body)+4 {
+ content = lipgloss.JoinHorizontal(lipgloss.Top, body, " ", snorlax)
+ }
+ content += "\n" + styling.KeyMenu.Render("↑/↓ move • ←/→ change • enter select • esc quit")
+
+ v := tea.NewView(welcomeMessage + content)
+ v.AltScreen = true
+ return v
+}
+
+func onOff(b bool) string {
+ if b {
+ return "on"
+ }
+ return "off"
+}
diff --git a/setup/snorlax.txt b/setup/snorlax.txt
new file mode 100644
index 00000000..2d643895
--- /dev/null
+++ b/setup/snorlax.txt
@@ -0,0 +1,27 @@
+⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣤⣤⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣬⣿⡿⠿⢿⣶⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡿⠁⠀⠉⠙⠻⠿⣶⣦⣀⣠⣤⣤⣤⣤⣤⣤⣶⠿⠟⠫⠉⠀⠀⢀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⣀⣠⣤⠤⣤⣤⣀⡒⠀⣀⣤⡤⠶⠶⠤⣄⡀⠤⣀⣸⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⠀⢀⣀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⣠⡼⠛⠉⠀⠀⠀⠀⠀⠈⠙⠟⠉⠀⠀⠀⠀⠀⠈⠙⠳⣏⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⣴⣤⣿⣻⣿⣿⣷⣿⣧⠀⠀⠀⠀⠀⢸⣿⣸⠋⠀⠠⠴⠶⠒⠒⠲⠆⠀⠀⠀⠀⠘⠓⠒⠒⠲⠶⠀⠀⠈⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⢴⡿⣿⡿⠋⠉⠩⠙⣿⣿⣄⠀⠀⠀⠀⣼⣿⠇⠀⠀⠀⠀⠀⠀⣠⣿⣄⣀⣀⣀⣀⣀⣴⣄⠀⠀⠀⠀⠀⠀⡇⢻⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠈⣿⡿⠁⠀⠀⠀⠀⠹⣿⣿⣷⣄⠀⢰⣿⣿⠀⠀⠀⠀⠀⢀⣀⣨⣤⣤⠤⠤⠤⠤⠤⣬⣭⣄⣀⣀⠀⢀⡼⠁⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⢠⣿⠁⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⣷⣼⣿⣿⣠⠴⠖⠚⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠓⠶⢤⣸⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠈⣿⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⡿⠟⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣦⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠸⣿⢰⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡿⣿⣷⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⣿⡼⡀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠈⢿⣻⣿⣷⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⢻⣧⢅⠀⠀⢀⡾⣻⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠇⠀⠘⡇⠹⣇⠙⢿⣦⣀⠀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠘⣿⡌⡀⣰⡟⢡⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⡗⠀⠹⣆⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⢻⣧⣰⡟⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠞⠁⠀⠀⣰⠿⠀⠀⢻⡄⠀⠀⠙⢿⣧⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⠈⣿⡟⡄⠀⠈⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠔⠃⠀⠀⢀⣴⠏⠂⠀⠀⠘⣧⡀⠀⠀⠀⠻⣷⡀⠀⠀
+⠀⠀⠀⠀⠀⢰⣿⢹⠀⠀⠀⠈⠳⣄⡀⠀⠀⠀⠀⣀⣠⣤⡴⠶⠒⠒⠛⠛⠛⠛⠛⠛⢛⠛⠒⠒⠒⠒⠶⠶⠶⠶⠿⠥⠤⠤⠴⠶⡟⠁⠀⠀⠀⢀⠀⣿⠀⠀⠀⠀⠀⠹⣷⡀⠀
+⠀⠀⠀⠀⠀⣼⡏⠀⢠⣄⠀⠀⠀⠀⠉⠙⠛⠉⠉⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⢀⣠⠾⢻⡇⢻⢀⠀⠀⠀⠀⠀⢻⣇⠀
+⠀⠀⠀⠀⠀⣿⡇⠀⣾⠉⠷⣄⣂⣂⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⢴⡟⠁⠀⣼⣧⣼⡧⠄⢀⣠⣤⠀⣿⣿⣦
+⠀⢀⣄⣀⣀⣿⣧⡶⢿⣀⣠⡼⠋⠉⠉⠙⠳⢦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠞⠉⠀⠈⠷⣤⠾⠛⠀⢨⡽⠟⠋⣩⣯⣾⣿⣙⣿
+⠀⠸⣿⡉⠉⠛⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣧⣠⣶⣿⢿⣿⣯⣿⡏
+⠀⠀⢹⣷⣀⡴⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠁⠹⣿⣿⣿⠛⠁
+⠀⢠⣿⠁⠀⠀⠀⠀⠀⢀⡴⠚⠉⢉⣉⣉⠉⠉⠒⠲⣿⣿⣿⣎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⢀⡠⣖⣦⣭⣤⣥⣐⠒⠦⢄⡀⠀⠀⠀⡤⠿⣿⣦⣤⠀
+⣤⣾⣇⣀⡀⠀⠀⠀⣠⠎⣠⡴⠛⠉⠉⠉⠹⣆⠀⢀⣿⣿⣿⣿⡄⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀⠀⠀⠀⣿⢀⠎⣾⠋⠀⠀⠀⠀⠉⠳⣄⠀⠙⡄⠀⠀⣧⣀⣤⡾⠟⠀
+⢿⣥⣀⣺⠇⠀⠀⢠⠇⣰⠏⠀⠀⠀⠀⠀⠀⣿⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣿⡜⠀⣇⠀⠀⠀⠀⠀⠀⠀⢹⠀⠀⠁⠀⠀⢸⣿⠁⠀⠀⠀
+⠀⠹⣿⡁⠀⠀⠀⢸⠀⣯⠀⠀⠀⠀⠀⢀⣴⠃⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠙⢦⡀⠀⠀⠀⣀⣴⠏⠀⢀⡇⠀⣰⡿⠁⠀⠀⠀⠀
+⠀⠀⠈⢿⣦⡀⠀⠀⢣⡘⢦⣄⣀⣤⠶⠋⣡⣾⠟⠿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⠟⢿⣦⣀⠉⠉⠉⠉⠉⠀⢀⡤⢋⣠⡾⠋⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠉⡛⣷⣶⣤⡭⠷⠤⣤⣴⣶⣟⡛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣻⣿⣷⣶⣶⣶⣴⣷⣾⣿⠛⠀⠀⠀⠀⠀⠀⠀⠀
\ No newline at end of file
diff --git a/styling/huh_theme.go b/styling/huh_theme.go
index 98fa6a4c..2905c898 100644
--- a/styling/huh_theme.go
+++ b/styling/huh_theme.go
@@ -7,7 +7,7 @@ import (
func FormTheme() *huh.Theme {
var (
- yellow = lipgloss.Color(LightYellow)
+ yellow = lipgloss.Color(accent)
blue = lipgloss.Color("#3B4CCA")
red = lipgloss.Color("#D00000")
black = lipgloss.Color("#000000")
diff --git a/styling/list.go b/styling/list.go
index a77b0e44..d7b841e6 100644
--- a/styling/list.go
+++ b/styling/list.go
@@ -22,11 +22,9 @@ var (
func init() {
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
- ld := lipgloss.LightDark(isDark)
defaults := list.DefaultStyles(isDark)
PaginationStyle = defaults.PaginationStyle.PaddingLeft(4)
HelpStyle = defaults.HelpStyle.PaddingLeft(4).PaddingBottom(1)
- SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(ld(lipgloss.Color(DarkYellow), lipgloss.Color(LightYellow)))
}
type Item string
diff --git a/styling/styling.go b/styling/styling.go
index 882ee73f..faabd9ff 100644
--- a/styling/styling.go
+++ b/styling/styling.go
@@ -3,26 +3,26 @@ package styling
import (
"fmt"
"image/color"
- "os"
"regexp"
"strings"
"charm.land/lipgloss/v2"
- "golang.org/x/term"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
-const (
- HyphenHint = "Use a hyphen when typing a name with a space."
+const HyphenHint = "Use a hyphen when typing a name with a space."
- PrimaryYellow = "#FFCC00"
- LightYellow = "#FFDE00"
- DarkYellow = "#E1AD01"
-)
+var palettes = map[string]string{
+ "yellow": "#E1AD01",
+ "red": "#f00000",
+ "blue": "#3B4CCA",
+}
+
+var accent string
var (
- YellowColor = lipgloss.Color(PrimaryYellow)
+ YellowColor color.Color
YellowAdaptive color.Color
YellowAdaptive2 color.Color
)
@@ -33,7 +33,7 @@ var (
Gray = lipgloss.Color("#777777")
Yellow lipgloss.Style
ColoredBullet lipgloss.Style
- CheckboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(PrimaryYellow))
+ CheckboxStyle lipgloss.Style
KeyMenu = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
DocsLink string
@@ -41,11 +41,9 @@ var (
StyleBold = lipgloss.NewStyle().Bold(true)
StyleItalic = lipgloss.NewStyle().Italic(true)
StyleUnderline = lipgloss.NewStyle().Underline(true)
- HelpBorder = lipgloss.NewStyle().
- BorderStyle(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color(PrimaryYellow))
- ErrorColor = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2055C"))
- ErrorBorder = lipgloss.NewStyle().
+ HelpBorder lipgloss.Style
+ ErrorColor = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2055C"))
+ ErrorBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#F2055C"))
ApiErrorStyle = lipgloss.NewStyle().
@@ -56,10 +54,8 @@ var (
WarningBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF8C00"))
- TypesTableBorder = lipgloss.NewStyle().
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color(PrimaryYellow))
- ColorMap = map[string]string{
+ TypesTableBorder lipgloss.Style
+ ColorMap = map[string]string{
"normal": "#B7B7A9",
"fire": "#FF4422",
"water": "#3499FF",
@@ -81,23 +77,36 @@ var (
}
)
-func init() {
- isDark := true
- if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) {
- isDark = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+func ApplyTheme(name string) {
+ hex, ok := palettes[name]
+ if !ok {
+ hex = palettes["yellow"]
}
- ld := lipgloss.LightDark(isDark)
- YellowAdaptive = ld(lipgloss.Color(DarkYellow), lipgloss.Color(LightYellow))
- YellowAdaptive2 = ld(lipgloss.Color(DarkYellow), lipgloss.Color(PrimaryYellow))
- Yellow = lipgloss.NewStyle().Foreground(YellowAdaptive)
- ColoredBullet = lipgloss.NewStyle().
- SetString("•").
- Foreground(lipgloss.Color(PrimaryYellow))
+ accent = hex
+ c := lipgloss.Color(hex)
+
+ YellowColor = c
+ YellowAdaptive = c
+ YellowAdaptive2 = c
+ Yellow = lipgloss.NewStyle().Foreground(c)
+ ColoredBullet = lipgloss.NewStyle().SetString("•").Foreground(c)
+ CheckboxStyle = lipgloss.NewStyle().Foreground(c)
+ HelpBorder = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(c)
+ TypesTableBorder = lipgloss.NewStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(c)
+ SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(c)
DocsLink = lipgloss.NewStyle().
- Foreground(YellowAdaptive2).
+ Foreground(c).
Render("\x1b]8;;https://docs.poke-cli.com\x1b\\docs.poke-cli.com\x1b]8;;\x1b\\")
}
+func init() {
+ ApplyTheme("yellow")
+}
+
// GetTypeColor Helper function to get color for a given type name from colorMap
func GetTypeColor(typeName string) string {
typeColor := ColorMap[typeName]
diff --git a/styling/styling_test.go b/styling/styling_test.go
index efd7f501..52bc3ad3 100644
--- a/styling/styling_test.go
+++ b/styling/styling_test.go
@@ -114,3 +114,18 @@ func TestFormTheme(t *testing.T) {
assert.Equal(t, theme.Focused.Title, theme.Group.Title, "Group title should match focused title")
assert.Equal(t, theme.Focused.Description, theme.Group.Description, "Group description should match focused description")
}
+
+func TestApplyThemeSwapsAndFallsBack(t *testing.T) {
+ t.Cleanup(func() { ApplyTheme("yellow") })
+
+ ApplyTheme("red")
+ redColor := YellowColor
+ assert.Equal(t, "#f00000", accent)
+
+ ApplyTheme("blue")
+ assert.Equal(t, "#3B4CCA", accent)
+ assert.NotEqual(t, redColor, YellowColor)
+
+ ApplyTheme("bogus")
+ assert.Equal(t, "#E1AD01", accent)
+}
diff --git a/testdata/ability_invalid_flag.golden b/testdata/ability_invalid_flag.golden
index a7c394ae..3984fd25 100644
--- a/testdata/ability_invalid_flag.golden
+++ b/testdata/ability_invalid_flag.golden
@@ -1 +1 @@
-error parsing flags: flag provided but not defined: -bogus
+error parsing flags: unknown flag: --bogus
diff --git a/testdata/cli_help.golden b/testdata/cli_help.golden
index d38e7833..ac1b1c90 100644
--- a/testdata/cli_help.golden
+++ b/testdata/cli_help.golden
@@ -8,6 +8,7 @@
│ │
│ FLAGS: │
│ -h, --help Shows the help menu │
+│ -c, --config Launch the config settings screen │
│ -l, --latest Prints the latest version available │
│ -v, --version Prints the current version │
│ │
@@ -15,13 +16,13 @@
│ ability Get details about an ability │
│ berry Get details about a berry │
│ card Get details about a TCG card │
+│ comp Get details about competitive Pokémon │
│ item Get details about an item │
+│ mechanics Get details about video game mechanics │
│ move Get details about a move │
-│ natures Get details about all natures │
│ pokemon Get details about a Pokémon │
│ search Search for a resource │
│ speed Calculate the speed of a Pokémon in battle│
-│ tcg Get details about TCG tournaments │
│ types Get details about a typing │
│ │
│ Use a hyphen when typing a name with a space. │
diff --git a/testdata/cli_incorrect_command.golden b/testdata/cli_incorrect_command.golden
index e0c258b7..15a35c88 100644
--- a/testdata/cli_incorrect_command.golden
+++ b/testdata/cli_incorrect_command.golden
@@ -6,13 +6,13 @@
│ ability Get details about an ability │
│ berry Get details about a berry │
│ card Get details about a TCG card │
+│ comp Get details about competitive Pokémon │
│ item Get details about an item │
+│ mechanics Get details about video game mechanics │
│ move Get details about a move │
-│ natures Get details about all natures │
│ pokemon Get details about a Pokémon │
│ search Search for a resource │
│ speed Calculate the speed of a Pokémon in battle│
-│ tcg Get details about TCG tournaments │
│ types Get details about a typing │
│ │
│Also run poke-cli -h for more info! │
diff --git a/testdata/tcg_help.golden b/testdata/mechanics_help.golden
similarity index 53%
rename from testdata/tcg_help.golden
rename to testdata/mechanics_help.golden
index 07486a23..6301f770 100644
--- a/testdata/tcg_help.golden
+++ b/testdata/mechanics_help.golden
@@ -1,10 +1,10 @@
-╭─────────────────────────────────────────────────────────────────────────────────────────╮
-│Get details about TCG tournaments. │
-│ │
-│ USAGE: │
-│ poke-cli tcg │
-│ │
-│ FLAGS: │
-│ -h, --help Prints the help menu. │
-│ -w, --web Opens the Streamlit dashboard in your default browser.│
-╰─────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+│Get details about game mechanics. │
+│ │
+│ USAGE: │
+│ poke-cli mechanics │
+│ │
+│ FLAGS: │
+│ -h, --help Prints the help menu. │
+│ -n, --natures Prints a table with all natures and their respective buffs and debuffs.│
+╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/mechanics_invalid_flag.golden b/testdata/mechanics_invalid_flag.golden
new file mode 100644
index 00000000..db135edd
--- /dev/null
+++ b/testdata/mechanics_invalid_flag.golden
@@ -0,0 +1,7 @@
+╭─────────────────────────────────────────╮
+│✖ Error! │
+│Invalid flag for mechanics │
+│unknown flag: --bogus │
+│ │
+│Run 'poke-cli mechanics -h' for more info│
+╰─────────────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/natures.golden b/testdata/mechanics_natures.golden
similarity index 81%
rename from testdata/natures.golden
rename to testdata/mechanics_natures.golden
index 8ddc0a8c..ef735f45 100644
--- a/testdata/natures.golden
+++ b/testdata/mechanics_natures.golden
@@ -16,12 +16,3 @@ Nature Chart:
├──────────┼─────────┼──────────┼──────────┼──────────┼─────────┤
│ Speed │ Timid │ Hasty │ Jolly │ Naive │ Serious │
└──────────┴─────────┴──────────┴──────────┴──────────┴─────────┘
-
-╭─────────────────────────────────╮
-│⚠ Warning! │
-│The natures command is deprecated│
-│and will be removed in v2. │
-│ │
-│It will move to a flag under the │
-│new mechanics command. │
-╰─────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/tcg_too_many_args.golden b/testdata/mechanics_too_many_args.golden
similarity index 100%
rename from testdata/tcg_too_many_args.golden
rename to testdata/mechanics_too_many_args.golden
diff --git a/testdata/natures_help.golden b/testdata/natures_help.golden
deleted file mode 100644
index d3933e0d..00000000
--- a/testdata/natures_help.golden
+++ /dev/null
@@ -1,9 +0,0 @@
-╭─────────────────────────────────────────────────────────╮
-│Get details about all natures. │
-│ │
-│ USAGE: │
-│ poke-cli natures │
-│ │
-│ FLAGS: │
-│ -h, --help Prints the help menu. │
-╰─────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/natures_invalid_extra_arg.golden b/testdata/natures_invalid_extra_arg.golden
deleted file mode 100644
index 8970f84e..00000000
--- a/testdata/natures_invalid_extra_arg.golden
+++ /dev/null
@@ -1,5 +0,0 @@
-╭──────────────────────────────────────╮
-│✖ Error! │
-│The only available options after the │
-│ command are '-h' or '--help'│
-╰──────────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/pokemon_help.golden b/testdata/pokemon_help.golden
index 48a044bf..a4e1fa3a 100644
--- a/testdata/pokemon_help.golden
+++ b/testdata/pokemon_help.golden
@@ -1,17 +1,16 @@
-╭─────────────────────────────────────────────────────────────────────────────╮
-│Get details about a specific Pokémon. │
-│ │
-│ USAGE: │
-│ poke-cli pokemon [flag] │
-│ Use a hyphen when typing a name with a space. │
-│ │
-│ FLAGS: │
-│ -h, --help Prints the help menu. │
-│ -a, --abilities Prints the Pokémon's abilities. │
-│ -d, --defense Prints the Pokémon's type defenses. │
-│ -i=xx, --image=xx Prints out the Pokémon's default sprite. │
-│ options: [sm, md, lg] │
-│ -m, --moves Prints the Pokémon's learnable moves. │
-│ -s, --stats Prints the Pokémon's base stats. │
-│ -t, --types Deprecated. Typing is included by default.│
-╰─────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
+╭───────────────────────────────────────────────────────────────────────────╮
+│Get details about a specific Pokémon. │
+│ │
+│ USAGE: │
+│ poke-cli pokemon [flag] │
+│ Use a hyphen when typing a name with a space. │
+│ │
+│ FLAGS: │
+│ -h, --help Prints the help menu. │
+│ -a, --abilities Prints the Pokémon's abilities. │
+│ -d, --defenses Prints the Pokémon's type defenses. │
+│ -i=xx, --image=xx Prints out the Pokémon's default sprite.│
+│ options: [sm, md, lg] │
+│ -m, --moves Prints the Pokémon's learnable moves. │
+│ -s, --stats Prints the Pokémon's base stats. │
+╰───────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/testdata/pokemon_invalid_flag.golden b/testdata/pokemon_invalid_flag.golden
index a7c394ae..3984fd25 100644
--- a/testdata/pokemon_invalid_flag.golden
+++ b/testdata/pokemon_invalid_flag.golden
@@ -1 +1 @@
-error parsing flags: flag provided but not defined: -bogus
+error parsing flags: unknown flag: --bogus
diff --git a/testdata/tcg_invalid_flag.golden b/testdata/tcg_invalid_flag.golden
deleted file mode 100644
index a7c394ae..00000000
--- a/testdata/tcg_invalid_flag.golden
+++ /dev/null
@@ -1 +0,0 @@
-error parsing flags: flag provided but not defined: -bogus
diff --git a/web/pyproject.toml b/web/pyproject.toml
index 6d832061..0ba08a2d 100644
--- a/web/pyproject.toml
+++ b/web/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "web"
-version = "v1.10.3"
+version = "v2.0.0"
description = "Streamlit dashboard for browsing and visualizing Pokémon TCG tournament standings and results."
readme = "README.md"
requires-python = ">=3.12"