Compare commits

19 Commits

Author SHA1 Message Date
0ee9ed0abb feat: roomier BO2 companion with +/- rank stepping
All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s
- Scale the COD page to the screen: larger gun art, text and progress
  ribbon, sized from one viewport unit so it fills a MacBook Pro 16"
  and big monitors and still fits 1280x720 without scrolling.
- Five columns on wide screens and four below, picked by CSS, so the
  server-rendered page already has the final layout.
- Replace the tabs/filters/overview rows with one toolbar of matching
  controls.
- Player and weapon ranks step with + and -: level 55 + 1 is the next
  prestige at level 1, up to max prestige. Presses show at once, batch
  while a save is running, and stepRank does the arithmetic in SQL.
- Clicking a rank still opens exact entry for big jumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:50:19 +00:00
8eec207f2d fix: create deployment backups with allowed Docker permissions
All checks were successful
Test and deploy staging / staging (push) Successful in 2m26s
2026-10-08 21:10:21 +00:00
c2bc9050c2 feat: build compact BO2 grind companion with cumulative ranks
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
2026-10-08 21:06:19 +00:00
c2ba8febb4 Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
2026-09-25 22:43:04 +00:00
f6b34150d4 add ai slop warnings
All checks were successful
Test and deploy staging / staging (push) Successful in 12m6s
2026-09-25 16:41:45 +00:00
abac077541 ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
2026-09-25 00:44:06 +00:00
c757d0882a add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
2026-09-24 19:24:09 +00:00
a10585a4d2 add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:18 +00:00
438623d857 add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Dutch and Korean (explained in English) and English (explained in Dutch)
from A1 to C1, with a Hangul level for Korean. Lessons with notes, words
and sentences generate multiple-choice, typing, sentence building, cloze,
listening and matching exercises. Endless challenges: a seeded daily
challenge, sprint, survival, mixed practice, level tests, spaced-repetition
reviews and generated drills (numbers, clock times, de/het, plurals, Dutch
and Korean conjugation, particles, Hangul reading, irregular verbs).
Progress is stored in localStorage with export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:34:56 +00:00
afb1f191df add world opinions project
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
World map with switchable layers (what "liberal" means, what left vs right
is about, traditional/secular and survival/self-expression values), country
cards, a shared-scale party compass, a values scatter and national
narratives of major events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:24:40 +00:00
499dd94cbb update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
2026-09-23 18:06:20 +00:00
2466db74cd fix csv import for money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
2026-09-23 17:23:14 +00:00
0638ead87f add money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
2026-09-23 17:02:32 +00:00
753b2e5d6a add animal modal and page
All checks were successful
Test and deploy staging / staging (push) Successful in 1m54s
2026-09-23 16:56:19 +00:00
3691a85fdf add projects dropdown
All checks were successful
Test and deploy staging / staging (push) Successful in 1m48s
2026-09-23 16:44:21 +00:00
4b63e9ca11 remove emojis with real icons
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
2026-09-23 16:12:41 +00:00
f1cd0ef849 add catchemall progress bars and hide uncatchable pokemon by default
All checks were successful
Test and deploy staging / staging (push) Successful in 28s
2026-09-23 04:59:22 +00:00
eca43c82d0 fix zod imports in bun test build
Some checks failed
Test and deploy staging / staging (push) Failing after 1m39s
2026-09-23 04:47:27 +00:00
1d0b96e7bc optimize building and deploying
Some checks failed
Test and deploy staging / staging (push) Failing after 13s
2026-09-23 04:36:58 +00:00
4408 changed files with 131696 additions and 2945 deletions

View File

@@ -9,14 +9,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- name: Install and test
run: |
bun install --frozen-lockfile
bun run test:unit -- --run
# Build once. The same SHA-tagged image stays on the deployment host for
# one-click promotion to production; the production workflow never rebuilds it.
- uses: docker/build-push-action@v5

View File

@@ -12,14 +12,6 @@ jobs:
with:
ref: staging
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- name: Check staging source
run: |
bun install --frozen-lockfile
bun run test:unit -- --run
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

View File

@@ -5,3 +5,16 @@ yarn.lock
# Generated pinned catalog
data/pokemon-catalog.json
# Generated Pokémon locations and encounters
data/pokemon-locations.json
# Generated Pokémon research and map coordinates
data/pokemon-research.json
data/pokemon-atlas.json
# Attributed generated challenge snapshot
data/cod-challenges.json
# Generated local image download manifest
data/game-images.json
data/cod-achievement-art.json

View File

@@ -3,6 +3,7 @@ WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run test:unit -- --run
RUN bun run build
FROM oven/bun:1.4.0
@@ -14,6 +15,7 @@ COPY --from=builder /app/build ./build
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/data ./data
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
COPY package.json ./
EXPOSE 3000

464
README.md Executable file → Normal file
View File

@@ -6,3 +6,467 @@ This is my personal website, including:
- About me
- Blog posts + comments
- Projects like Animaldex, CatchEmAll
## osu! Map Finder
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
SvelteKit, Tailwind 4, and adapter-node setup. Uses server-only direct `fetch`
calls; no Spotify/osu! SDK or additional services.
Set these private variables in your local `.env` or deployment environment:
```text
SPOTIFY_CLIENT_ID
SPOTIFY_CLIENT_SECRET
OSU_CLIENT_ID
OSU_CLIENT_SECRET
```
Never prefix these with `PUBLIC_` or commit `.env`. Providers read
`$env/dynamic/private`; the browser receives only display metadata and results.
The other projects on this personal site retain their own environment requirements.
Supported inputs: song text (`Deftones Change`), Spotify track, playlist, and album
URLs, and `spotify:track:ID`, `spotify:playlist:ID`, `spotify:album:ID` URIs.
URLs allow HTTP/HTTPS, `www`, locale prefixes, query parameters and trailing slashes.
Artist, episode, show and other resources receive a validation error.
Playlists and albums preserve order and process up to **50 usable unique tracks**
(`MAX_PLAYLIST_TRACKS`), scanning at most **10 pages** (`MAX_PLAYLIST_PAGES`).
Null, unavailable, local, malformed and episode entries are skipped. Truncation is
shown explicitly. Album tracks inherit the album artwork/name. Each match has its
own error state; an osu! failure cannot erase other completed tracks. The enhanced
form shows a pending state and renders the complete batch; native form submission
also works without JavaScript. Filters require JavaScript.
Spotify playlist availability depends on application quota/access. Both legacy
`tracks/track` and newer `items/item` payloads are supported. Spotify's
[2026 development-mode changes](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)
restrict playlist access; client credentials cannot unlock private/user-authorized
playlists. Such responses produce an access error. This project intentionally does
not add user authentication.
Matching is pure and testable in `src/lib/map-finder/matching.ts`: Unicode and
punctuation normalization, diacritic removal, common annotation cleanup, title and
artist token overlap, Unicode metadata alternatives, and version penalties. Title
contributes 65 points and artist 35. High confidence starts at 88, medium at 72;
weak artist evidence is capped below medium, and a different version costs 24.
These are heuristics, not probabilities. Candidates below 40 are omitted; up to six
are sorted and deduplicated by beatmapset ID. Low-confidence candidates are collapsed.
Search first uses artist + title, then one cleaned-title fallback if no medium/high
match exists. Duration is omitted because osu! map length may reflect a song cut.
Tokens are reused until 30 seconds before expiry with a shared refresh promise.
Matching results cache for five minutes (maximum 500 entries), including no-match
results, and identical pending searches are coalesced. Three concurrent osu!
matching jobs are allowed per process. The site allows one active search per IP,
up to six active searches globally, and 12 budget units per IP per 10 minutes:
a single track costs one, a playlist/album six. Expired records are cleaned on
requests; the limiter has a 10,000-record ceiling. It uses `getClientAddress()`,
never arbitrary forwarded headers; unsupported adapters share a conservative key.
Configure adapter-node's trusted proxy/client-address settings correctly at deployment.
Requests have a 12-second timeout and at most two retries for network errors,
429s or server errors. Backoff includes jitter. `Retry-After` is honored; delays
above four seconds return a helpful error and retain a provider cooldown rather
than holding the request open. All caches, cooldowns, concurrency and rate limits
are per process and reset on restart; multiple instances do not share them.
Local commands (npm scripts and existing tooling; no new dependencies):
```sh
npm install
npm run dev
npm run test:unit -- --run
npm run test:integration
npm run check
npm run lint
npm run build
```
Finder unit tests mock upstream APIs. Browser tests cover initial layout and
validation with and without enhancement, and do not call Spotify or osu!.
## Language learner
`/projects/language-learner` teaches Dutch and Korean (explained in English) and
English (explained in Dutch), from A1 to C1. Korean starts with a Hangul level.
It runs entirely in the browser: no database tables, migrations or API keys.
- Course content lives in `src/lib/language-learner/courses/<lang>/<level>.ts` as
compact `target | translation | note` lines, plus markdown notes and
multiple-choice grammar questions (`prompt | answer | wrong; wrong | explanation`).
Exercises (choice, typing, sentence building, fill-in-the-blank, listening,
matching) are generated from that data. `content.test.ts` validates every lesson.
- Endless drills are generated by code in `src/lib/language-learner/drills/`:
number words, Dutch/English clock times, de/het, plurals, Dutch and Korean
conjugation (a rule-based Korean conjugator handling the ㅂ/ㄷ/르/ㅅ/ㅎ
irregulars), Korean particles and counters, Hangul reading, English irregular
verbs, comparatives and a/an.
- Challenges: a seeded daily challenge (first score of the day counts), 60-second
sprint, survival with three lives, mixed practice, level tests that mark a level
as done at 80%, and spaced-repetition reviews (simplified SM-2).
- Audio uses the browser's speech synthesis, so listening exercises appear only
when the device has a voice for the language. Romanization of Korean is
approximate Revised Romanization.
- Progress, streaks and settings are stored in `localStorage`
(`language-learner-v1`), per browser. Export/import on the overview page moves
them between devices.
## Shared accounts and permissions
`/login`, `/register`, and `/account` are the central account pages. Better Auth
handles email/password credentials and database-backed sessions for the entire
site. Google and osu! buttons appear when the corresponding client ID and secret
are configured. The header exposes account access everywhere.
Copy the auth settings from `.env.example`. `BETTER_AUTH_SECRET` must be at least
32 random characters; there is no runtime fallback secret. Set `ORIGIN` and
`BETTER_AUTH_URL` to the same public origin (for example,
`https://dev.stanrunge.dev` behind the local proxy). Register these exact callbacks:
- Google: `<BETTER_AUTH_URL>/api/auth/callback/google`
- osu!: `<BETTER_AUTH_URL>/api/auth/callback/osu`
osu! uses the `identify` scope and a stable numeric account ID. Its API does not
supply an email address, so these accounts use a reserved, unverified
`<id>@osu.invalid` internal identifier, hidden from the account UI. Credential
registration cannot claim that namespace. Provider accounts are linked explicitly
from `/account` while logged in; there is no automatic merging by email. Link a
provider **before** using it to create a separate account if you want shared
progress. Existing separate accounts are not automatically merged.
Every new user has role `user`. The shared server guards enforce administrator
access for blog drafts/writes, list creation/editing, Animaldex moderation, and
Progress edits. Legacy `ADMIN_SECRET`, `AUTH_TOKEN`, `admin_token`, and `token`
logins no longer authorize anything. To grant yourself admin access, register,
copy the account ID shown on `/account`, then run on the server:
```sh
npm run auth:role -- <user-id> admin
# To revoke access:
npm run auth:role -- <user-id> user
```
Role changes take effect on the next request; roles cannot be set through signup
or profile updates. Public registrations are never made admin automatically.
Future projects should reference the shared `user.id`, use `requireUser(event)`,
and scope **both reads and writes** to the session user's ID. Use
`requireAdmin(event)` for site-wide administration; never accept an owner ID from
a form or infer permission from the presence of a cookie.
Email/password registration works without a mail service. Email verification and
password recovery emails are **not configured** yet. Connect a transactional mail
sender and Better Auth's verification/reset hooks before offering those flows.
Better Auth's rate limiting is enabled and currently uses per-process memory;
a multi-instance deployment needs shared rate-limit storage. Account pages and
saved progress require a working database. Authentication forms require JavaScript.
## Game tracker
`/projects/game-tracker` is the hub for completionist progress. Catchemall lives at
`/projects/game-tracker/pokemon` (the old `/projects/catch-em-all` URL redirects),
with location pages under `/projects/game-tracker/pokemon/locations`. Call of Duty
games live at `/projects/game-tracker/call-of-duty/<slug>`. All catalog data is
owned by the site: it is checked in under `data/` and seeded into Postgres, and
nothing calls PokéAPI or Steam at runtime.
### Pokémon catalog
`npm run catalog:build` rebuilds two files from the pinned PokéAPI CSV snapshot
(revision recorded inside them):
- `data/pokemon-catalog.json`: **1,025 species, 53 game versions/expansions and
27,267 species/game entries**, plus each species' evolution chain and the
evolution method that applies **in each game** (Magnezone needs Mt. Coronet in
Platinum but a Thunder Stone in Sword).
- `data/pokemon-locations.json`: 1,104 locations and about 67,000 encounters
(area, method, levels, rarity, form and conditions such as time of day, season
or swarm). Max Raid dens are folded per location.
Each entry has one availability:
- `catchable`: a documented encounter (wild, gift, static, in-game trade).
- `evolve`: a pre-evolution is obtainable in that game and the method exists
there, e.g. Blastoise in Red is “Evolve Wartortle: Level 36+”.
- `breed`: an obtainable relative can breed it (not in Gen 1, Let’s Go,
Colosseum/XD or the Legends games); incense babies name their incense.
- `event_only`: a mythical in the game with no other route.
- `unknown`: in the game's Pokédex data, but no confirmed route.
`catchable`, `evolve` and `breed` can be ticked and count toward progress.
PokéAPI has **no encounter data** for Brilliant Diamond/Shining Pearl, Legends:
Arceus, Scarlet/Violet and their DLC, or Legends: Z-A, so those games stay
`unknown` until another source is added. Missing pairs are not proof of absence.
Administrators can correct any entry from the Pokémon detail dialog and add notes
and a picture (JPEG/PNG/WebP, 5 MB, stored in R2 under `locations/`) on any
location page. Rows carry a `source`: seeding refreshes `pokeapi` and `derived`
rows and never touches `manual` ones. Location notes and pictures always survive
reseeding, and user progress is keyed by entry, so it survives too.
### Call of Duty catalog
`data/call-of-duty.json` covers Black Ops, Black Ops II, Ghosts, Advanced
Warfare, Black Ops III, Infinite Warfare and WWII. Weapon classes, post-launch
(DLC/update) weapons and camo rules are **hand-maintained** from the Call of Duty
Wiki (CC BY-SA). `npm run catalog:cod` refreshes only the achievement lists from
the public Steam Community pages. Users tick per-weapon milestones (bought or
unlocked, max level, Gold, Gold Cheetah); class and overall camos (Diamond, Dark
Matter, Solar, Black Sky, Chrome, Chrome Tiger) are derived in
`src/lib/game-tracker/mastery.ts`, and post-launch weapons never gate them.
Weapon progress is keyed by a slug of the weapon name, so renaming a weapon in
the JSON starts its progress over; the seed keeps removed items that already have
progress and warns about them.
### Database
For an existing database using the current domain schemas:
```sh
npm run db:migrate:auth
npm run db:migrate:foundation
npm run db:migrate:money-tracker
npm run db:migrate:game-tracker
npm run db:seed
npm run db:seed:cod
```
For a fresh database, run `npm run db:push` followed by both seeds.
The migrations are additive and repeatable; they do not reset tables. The
game-tracker migration adds the `evolve`/`breed` availability values, the entry
`source` column (marking pre-existing hand-edited notes as `manual`), and the
location, encounter, evolution and `tracker_*` tables. Unknown legacy OAuth
providers cause the auth migration to fail for explicit review instead of
guessing an issuer. Back up a production database before any schema migration.
The scripts use `DB_URL` from `.env` via Bun.
## Money tracker
`/projects/money-tracker` is restricted to administrator accounts. Its tables
are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns.
Transactions can be entered manually or imported from a CSV or tab-separated
export with `date,account,description,amount` headers. Dates accept
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
such as `-€1,234.50`. Quoted fields and comma, semicolon, or tab delimiters
are supported, and empty rows are skipped. Imported entries are stored in date
order. `Start balance` rows are imported as ordinary transactions, so leave
the account opening balance at zero for those accounts. Import creates
missing accounts and adds every row in one database transaction. It does not
deduplicate repeat uploads, so import each file once. Accounts have an editable
opening balance for money held before the first recorded transaction.
The current balance and history stop at today; future dated transactions feed the
wealth projection. The projection invests spendable money above the configured
reserve in a modeled balance, even without an existing investment account.
An existing investment account can optionally be selected as the starting
balance for growth. The projection treats recurring entries as monthly
equivalents from next month and can disable them to avoid double counting entries
already recorded as future transactions. Its reserve, hypothetical return,
contribution rule, and end date are editable. Wish list priority runs from tier 5
(highest) to tier 1 (lowest), with cumulative estimated costs on the overview.
The utility comparison uses meter readings within the configured billing period,
the current per-unit rate, and prorated monthly advances. It excludes fixed
charges, taxes, and past rate changes. Set the billing start to a recorded opening
meter reading for a meaningful annual comparison.
Seeding runs in one transaction and is repeatable. It preserves row IDs, existing
catches, selected games, and hand-maintained availability notes. Pokémon are keyed
by national number, games by PokéAPI version ID, and entries by Pokémon/game pair.
Existing games with exactly matching names are adopted to preserve their IDs.
The old broken reset and auth-schema overwrite commands have been removed.
Availability is deliberately conservative:
- `catchable`: PokéAPI supplies an encounter record. Conditions or a particular
form may be required. These entries contribute to the catch checklist.
- `unknown` (`?`): a Pokédex or game-index record exists, but the acquisition method
has not been confirmed. Membership alone never becomes “catchable”.
- A dash: no source record; this does **not** prove the Pokémon is unavailable.
- Manually maintained trade/transfer/event entries remain supported.
Encounter coverage varies by game and is especially incomplete for newer titles.
The import does not invent evolution, breeding, gift, trade, transfer, or event
rules from missing data. The page explains these limits. Catalog refreshes are
explicit: update `REVISION` in `scripts/build-pokemon-catalog.py`, run
`npm run catalog:build`, review the snapshot, and rerun the seed. Source:
[PokéAPI data](https://github.com/PokeAPI/pokeapi/tree/master/data/v2/csv).
Catches and selected games are stored server-side under the authenticated user's
ID. Repeated catch requests are idempotent, writes validate catalog IDs, and an
explicit empty game selection survives reloads. The UI rolls back failed saves
and prevents overlapping changes to the same catch.
`npm run test:unit -- --run` covers authorization, safe return paths, osu! identity
mapping, and catalog integrity alongside existing tests. `npm run test:auth` runs
browser/database checks against a migrated, seeded **development** database; it
creates temporary test accounts/content and removes them afterward. It checks
registration, login/logout, cookie replay, role escalation attempts, cross-user
progress isolation, admin routes, game selection, and OAuth authorization startup.
Completing Google/osu! consent still requires real provider credentials and the
registered callback URL. The integration follows the installed Better Auth APIs;
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
## Staging and production rollout
Use `staging` as the working branch and set it as the repository's default branch
in Gitea after the first push. A direct push to `staging` runs unit tests, builds
the site and a SHA-tagged Docker image, and deploys that image to
`https://staging.stanrunge.dev`. After checking staging, use Gitea Actions →
**Deploy production** → **Run workflow**. That action retests the current staging
commit, verifies that staging runs its exact image, and starts the **same image**
at `https://stanrunge.dev`. It does not rebuild the image or need a PR. Pushes to
`main` no longer deploy production; once `staging` is the default branch, its
current manual workflow is also the one shown in Gitea's Actions UI.
The staging Docker build runs `bun install --frozen-lockfile`, unit tests, and
the production build, so a failed test never produces a deployable image. The
production button verifies the exact tested staging image before switching
production. Browser/database tests need a
dedicated test database and are not part of this gate yet. `npm run check` and
full lint are also not gates while the repository has existing unrelated
diagnostics; add them after those are fixed. A failed test, build, or preflight
prevents the switch; a failed post-deploy health check attempts to restore the
previous image tag. Deployment never migrates or seeds either database. The
Gitea runner needs Docker CLI/socket access and the existing `DEPLOY_KEY` secret.
Before either push, configure the existing private environment file on that
host. Staging uses `/etc/personal-website-staging/staging.env`; production uses
`/etc/personal-website/prod.env`. Each file needs `DB_URL`, a unique random
`BETTER_AUTH_SECRET` of at least 32 characters, and both `ORIGIN` and
`BETTER_AUTH_URL` set exactly to its public HTTPS origin. Add the Google and
osu! client ID/secret pairs for whichever sign-in buttons should be enabled.
List and Animaldex picture uploads reuse the existing R2 bucket; set the five
`R2_*` values from `.env.example`. The app server uploads images to R2, so the
bucket does not need browser CORS rules for these forms. Each image is limited
to 5 MB; the container sets `BODY_SIZE_LIMIT=6M` for multipart requests.
Their provider console callback URLs must use that environment's origin:
```text
https://staging.stanrunge.dev/api/auth/callback/google
https://staging.stanrunge.dev/api/auth/callback/osu
https://stanrunge.dev/api/auth/callback/google
https://stanrunge.dev/api/auth/callback/osu
```
For this first rollout, review the SQL and back up each database. The first push
to `staging` may pass **Transfer tested image** but fail **Deploy staging** at
the database preflight. That is expected if the database has not been migrated:
the image is then available on the deployment host for you to run the migrations
yourself. If `animals`, `sightings`, and `photos` still have UUID IDs, first apply
`scripts/animaldex-uuid-to-serial.sql` with `psql` once. If the database has the
legacy singular tables `game`, `pokemon_game_entry`, `user_entry`, and `user_game`,
then apply `scripts/legacy-schema-migration.sql` with `psql` once. That migration
renames those tables without discarding their rows and creates the empty base
`lists` and `list_items` tables. Run both reviewed SQL files before the container
commands below. On the deployment host, use the SHA from the staging Actions run:
```sh
release_image=git.stanrunge.dev/stan/personal-website:<staging-sha>
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-auth-catalog.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-foundation.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-money-tracker.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-game-tracker.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-tracker-journal.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-catalog.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-catalog.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-pokemon-research.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-challenges.ts
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-art.ts
```
These catalog seeds use the reviewed files in `data/` and upsert catalog rows;
they preserve user accounts, catches, ranks, and saved challenge progress. Do not
copy or restore the entire development database over staging or production.
Repeat the same migration and seed commands for staging first, replacing the
environment file with `/etc/personal-website/prod.env` only after staging has
passed its preflight and been reviewed. Back up production before applying its
migrations. Rerun the same staging Actions run after the staging migration and
seed complete. Click the production button only after production migration and
seed complete. Future releases without database changes need only a staging push
and the production button.
The foundation migration gives lists slugs, progress, pictures, and blog tags;
gives site-owned join/settings rows numeric IDs; and adds `created_at`/
`updated_at` with a database update trigger. Blog posts already had numeric IDs
and timestamps. Better Auth's account/session IDs stay as text because they are
provider-managed identities. The deploy script verifies the migrations and
catalog before switching the image, then checks `/login` and Catchemall. A
failed app health check attempts to restore the previous image tag. Database
changes are not rolled back with the app image.
The release image contains `scripts/`, `data/`, and the schema files needed by
the migration tools. After registration in each environment, promote your own
account using the host's matching environment file, for example:
```sh
sudo docker run --rm --network backend \
--env-file /etc/personal-website-staging/staging.env \
git.stanrunge.dev/stan/personal-website:<deployed-sha> \
bun scripts/set-admin.ts <account-id> admin
```
Use `/etc/personal-website/prod.env` and its deployed image SHA for production.
The account ID is shown on `/account`. No public signup grants admin privileges.
# CS prices
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
individual listing. The separate `cs_item_dimensions` table makes it possible to
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
additional imported dimensions such as collection or rarity. Group chart points
average the available per-listing prices equally within each time bucket. Raw
observations retain their source, currency, timestamp, kind, price in cents, and
volume. Chart responses use up to roughly 240 time buckets per series and allow
up to 1,000 selected series; actual response time still depends on database size
and hardware.
With `DB_URL` pointed at a running Postgres database:
```sh
npm run db:migrate:cs-prices
npm run cs-prices:ingest -- catalog
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
```
The catalog pass pages through Counter-Strike market listings and saves a current
lowest-listing snapshot. The history pass visits each catalog item and stores
Steam's available historical sale-price and volume points. It needs a Steam web
session, may take many hours for the full catalog, and can be resumed with
`--start=<item offset>` and bounded with `--limit=<items>`. Requests pause for two
seconds by default (`--delay-ms=...`, minimum one second), retry temporary
failures, and upsert observations safely. Run catalog regularly for new listings
and snapshots; run history regularly for new sale points. Steam's market web
endpoints are undocumented and may change or limit access. The historical
granularity and completeness are bounded by what Steam returns; running this
collector cannot reconstruct older high-frequency prices that Steam no longer
exposes.
For another provider or a prepared archive, import JSON Lines instead:
```sh
npm run cs-prices:ingest -- import ./prices.jsonl
```
Each line needs `marketHashName`, `source`, `currency` (three-letter ISO code),
`observedAt` (ISO timestamp), `priceCents`, and `kind`. Optional `name`, `volume`,
and `dimensions` add detail. For example:
```json
{
"marketHashName": "AK-47 | Redline (Field-Tested)",
"source": "archive",
"currency": "USD",
"observedAt": "2026-09-24T12:00:00Z",
"priceCents": 1850,
"volume": 42,
"kind": "sale-median",
"dimensions": { "collection": "Phoenix", "rarity": "classified" }
}
```
Imported sources and price kinds appear in the page's source and kind controls.

View File

@@ -11,30 +11,40 @@
"@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85",
"d3-geo": "^3.1.1",
"drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0",
"postgres": "^3.4.9",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/simple-icons": "^1.2.94",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.62.1",
"@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1",
"@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.19",
"cn": "^0.4.0",
"drizzle-kit": "^1.0.0-rc.4",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
@@ -43,11 +53,13 @@
"lucide-svelte": "^1.0.1",
"prettier": "3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"shadcn-svelte": "^1.7.0",
"svelte": "^5.57.0",
"svelte-check": "4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~6",
"typescript-eslint": "8.68.0",
"unplugin-icons": "^23.0.1",
@@ -201,6 +213,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource-variable/montserrat": ["@fontsource-variable/montserrat@5.3.0", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -281,6 +295,8 @@
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@lucide/svelte": ["@lucide/svelte@1.48.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw=="],
"@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="],
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
@@ -479,6 +495,8 @@
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
@@ -497,6 +515,10 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="],
"@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.68.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="],
@@ -619,6 +641,10 @@
"cmdk-sv": ["cmdk-sv@0.0.19", "", { "dependencies": { "bits-ui": "^0.21.12", "nanoid": "^5.0.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA=="],
"cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
@@ -631,6 +657,10 @@
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -651,6 +681,8 @@
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
"echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -747,6 +779,8 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -857,6 +891,8 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
@@ -955,6 +991,8 @@
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"shadcn-svelte": ["shadcn-svelte@1.7.0", "", { "dependencies": { "cn": "^0.3.0", "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-AK3ZsYOL8PN/RxcEBCHGvQj2TrLfaaBsbw0QohyEb3nn/yPNDRC/+9iSjnTp/uKEe+tAi0tBxB/Yhzno0N7EEg=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1005,12 +1043,16 @@
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -1051,6 +1093,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
@@ -1061,6 +1105,8 @@
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
@@ -1087,6 +1133,8 @@
"cmdk-sv/bits-ui": ["bits-ui@0.21.16", "", { "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", "nanoid": "^5.0.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.118" } }, "sha512-XFZ7/bK7j/K+5iktxX/ZpmoFHjYjpPzP5EOO/4bWiaFg5TG1iMcfjDhlBTQnJxD6BoVoHuqeZPHZvaTgF4Iv3Q=="],
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"eslint-plugin-compat/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
@@ -1105,6 +1153,8 @@
"rollup/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"shadcn-svelte/cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="],
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
@@ -1113,6 +1163,10 @@
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

View File

@@ -2,7 +2,7 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
@@ -12,5 +12,9 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default"
"registry": "https://shadcn-svelte.com/registry",
"style": "vega",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}

5166
data/call-of-duty.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,620 @@
{
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f84ba26b1844819ea9e52aef0d0570559b57a75a.jpg": "/game-art/call-of-duty/achievements/238b785e1fca8f84bbee.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3b18cd1c090368b0386de8cc46f5a169c4038e9.jpg": "/game-art/call-of-duty/achievements/668be15f0347459b94f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0a2812d34a7fa98e20285368bc1a763bd792f889.jpg": "/game-art/call-of-duty/achievements/5cd7a661f6d3e7e6e1bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/584c74c05982e10c4441dd65324f277b7539eae2.jpg": "/game-art/call-of-duty/achievements/3f54bba6d8f7de2e2284.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b1b1349e22f48972109b743d520183ba70caad3c.jpg": "/game-art/call-of-duty/achievements/a9236535546a8dbc79b5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aa159223f265ab937d7d5b6690ddcf49020a2cbc.jpg": "/game-art/call-of-duty/achievements/7535dcc4298af27198c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f2edbcfaed969439c62f7a4e6f1eb068b413e158.jpg": "/game-art/call-of-duty/achievements/8852bd4a99d6ccc75845.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e9d07584f66650764670664404079276b4a3406b.jpg": "/game-art/call-of-duty/achievements/d49877c3e2ce9ca95bd9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5b0accacfbcfa0396cb41f268b5ad8d9a65c613.jpg": "/game-art/call-of-duty/achievements/1826f646d000287dc1f6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e8d0caf4122d896eb05024593bb3b66dadf4e88d.jpg": "/game-art/call-of-duty/achievements/652149e0966e1eae6125.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/066c88a94e5a4b27555d5c643a08859ddf915d0f.jpg": "/game-art/call-of-duty/achievements/090152a9c71b3a45e225.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8557b021165d25955cb8c9fef04bde10d9c73a99.jpg": "/game-art/call-of-duty/achievements/19009908c84ec2cafc6d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e63f09bb9b40effe11802afce07b6e9a90571724.jpg": "/game-art/call-of-duty/achievements/e20d161a6df6dc45f756.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/77911ac60f573a18fe2b136fe366c6eed30fe108.jpg": "/game-art/call-of-duty/achievements/3805fbfc6e08a1eeab92.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8fb4508be92820c78ff69f0e69546de0d9b76be7.jpg": "/game-art/call-of-duty/achievements/a647123583f71660a8df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/052772410645d83c3fce7b03c9467b66ccdf0213.jpg": "/game-art/call-of-duty/achievements/2bf1bc39e772950369fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f6e25d8f4ebbe3166dfc3b58c34867f268f2d033.jpg": "/game-art/call-of-duty/achievements/566ea9ce791bf9abe5a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/54d9340f91dbdb3e22ff5fc256ce30d0fa6a963c.jpg": "/game-art/call-of-duty/achievements/fdfdce2ff535cf32cd0a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/82c5e869cf17efa57d7c2a6d19903cb2aee7ff0d.jpg": "/game-art/call-of-duty/achievements/c3e15cc571084bd8e032.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/19dcec07495607501ec6b251e2b822be1b60f0fd.jpg": "/game-art/call-of-duty/achievements/3a814d01d4b72ed78b86.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2af436c800d1b2343c79bb8cfec3517a896b2074.jpg": "/game-art/call-of-duty/achievements/ca1b094aada4c4d3e3bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6e962666da615b8cb3821e60f3f104b270338e04.jpg": "/game-art/call-of-duty/achievements/e09047b22965850447f9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d013df6d049ec65340b44f297ae5e181d10e4b49.jpg": "/game-art/call-of-duty/achievements/04af8a32026bacfca67c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/5e4644a4b38f3fc47114804ec397ea4700629285.jpg": "/game-art/call-of-duty/achievements/b9c0bf5f5afcc39213c9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/75798a0d173926d2ee97d71bca3ec380927d104d.jpg": "/game-art/call-of-duty/achievements/02bc128c11c94faf1c9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5feb12f310b6feb51db21f92cf62e53ba3f6521.jpg": "/game-art/call-of-duty/achievements/edb0787878d0b6e8e696.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4a90e8dda5c440eb25d33a6f495edbed0140d7af.jpg": "/game-art/call-of-duty/achievements/a57766605831b6778bf0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/35e478ea8071694ca9be384f361aea9c2928c420.jpg": "/game-art/call-of-duty/achievements/350f4b45235fdc221371.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7047eecc9292d4c18462340cb7cbeb417f35b0f7.jpg": "/game-art/call-of-duty/achievements/68dc121e074880e68595.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c51d6a0a1a54e2b152d000fea518a0e6359dac4b.jpg": "/game-art/call-of-duty/achievements/5297acb4f4da5e454251.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b6bedf6b9fb9a8dc846b8a0d178bde44ab140f35.jpg": "/game-art/call-of-duty/achievements/40ebfc363b9df8683469.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d2b7d0909fed0934b8958181b8e6e3834c31698.jpg": "/game-art/call-of-duty/achievements/5772b839a7ac1b2099a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c531aac734c62f81aea8b4a3cd0d393b3da7e4a4.jpg": "/game-art/call-of-duty/achievements/3db466d8d7ea4e0ce404.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a00d833009c4c2f5390f3f6750a341c3ded2e3f4.jpg": "/game-art/call-of-duty/achievements/8a1dc6e2c75c02ca2d9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/904509f71a94d27bd02c8c59f735ce9625cc1c49.jpg": "/game-art/call-of-duty/achievements/078f7c246f6c0bdd0193.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c6bbe0d3d22db00af800f931976e8003be50ee06.jpg": "/game-art/call-of-duty/achievements/80472754bf73ca7d58e6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/21177bc1209e70be184fd480004bb2082c27e428.jpg": "/game-art/call-of-duty/achievements/bec752b67dc9ab1ca236.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2f1b3a3deb73182554d30c5e6240bf2abb7e9137.jpg": "/game-art/call-of-duty/achievements/4ad516df8a73d5904575.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/414eea359802851cd50bf577d6be3d814988a88a.jpg": "/game-art/call-of-duty/achievements/9989fb508e8136957f1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/1e1797ea80faffaebab8aac35df9b5f737a3a740.jpg": "/game-art/call-of-duty/achievements/013fa5da7e485ac3242c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f8d225e74c13b54a4be33d1db176424518cae516.jpg": "/game-art/call-of-duty/achievements/1e08a1136a06591bcd6f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/294382b96591ade0f7a3dd35f69aa64cf8e0d14d.jpg": "/game-art/call-of-duty/achievements/f56176dafd96a78a3c03.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6cfdcdff1c60ba4087436650c376379668611765.jpg": "/game-art/call-of-duty/achievements/907d50d6eb676aad489f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/db3ebdb04f6d8dc9208cb41d65bf0dde71e48ee6.jpg": "/game-art/call-of-duty/achievements/1af5f1612db6eb49ec8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3b4ddc46340c4ee914c43232708e4f0b99d1f433.jpg": "/game-art/call-of-duty/achievements/6ffbbc679ea915877be5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/93c7772577b5cbf3385f4b505017684f0d27a77e.jpg": "/game-art/call-of-duty/achievements/cb00bccd7155c5c79939.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d430d7cb0e27f1357148e8c57c1ff53969e9789.jpg": "/game-art/call-of-duty/achievements/5d0dc7bf0c89d2cb7a4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0fd21bfea7ae6cea7d19beacda813546b8aa2aac.jpg": "/game-art/call-of-duty/achievements/dbaad790ad126bced253.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/9dad5b5851b3b41c654b5b64bf17289c2bd25e5c.jpg": "/game-art/call-of-duty/achievements/74b743415e4823b9afbd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/fab6950999928104e43aafbed1b875bd8fa535ae.jpg": "/game-art/call-of-duty/achievements/afdea16426b4c756e4bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5d3e15aeb110c232b25d35badd27d3723cfc79f.jpg": "/game-art/call-of-duty/achievements/34bd87a05e2d5c87308d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3008c9f8bc996aab7ccd1c03252eb489eb2a961.jpg": "/game-art/call-of-duty/achievements/8e7cbdfd2248aa26d16e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3e370768bce2246e4a34e2b95a63894a8f3ad300.jpg": "/game-art/call-of-duty/achievements/af00a2e00f4d494e1bc7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/221f9b2e1a6562e0f2dc99518bc133b6d9e28845.jpg": "/game-art/call-of-duty/achievements/c45cafdc867922a8fe07.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0703c58b9cdbc32a607b198dd6df75aebbda4e0f.jpg": "/game-art/call-of-duty/achievements/8878c7ee8bd4313bb88c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/dfffdca7f1eec0f51c4d1ba6c7794a98d234ef36.jpg": "/game-art/call-of-duty/achievements/ef3dc83102c12eda7a11.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3924b4c5269c1db5e3fe069ef1b14bbdcdd03b7b.jpg": "/game-art/call-of-duty/achievements/e8811318dbb354abc472.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d959ef75d60450e2eeb53099cd3ac21ed8543d89.jpg": "/game-art/call-of-duty/achievements/a8f55324896c83dac16c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aaf807e4357992e7926fa8e37d994dea52716f5d.jpg": "/game-art/call-of-duty/achievements/ef83905f63cb07adf3b4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d8f4344e51350ddbcf8594090fb5fe716a1cf87e.jpg": "/game-art/call-of-duty/achievements/18074a3c513c6266b0e2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a2f171ee14933001a4126270eedbfbbe51f41c0e.jpg": "/game-art/call-of-duty/achievements/7767a2b17c42543b9a15.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/945ede3b335f123ae7f556d8125a0f957c04ad30.jpg": "/game-art/call-of-duty/achievements/10f6dc026886bf6d8fed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3aa709991192b7df667d76aeade231ac5d5a491e.jpg": "/game-art/call-of-duty/achievements/586ae3efa7b170f43654.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/ae1ea1ba275ee59ba9b86bef5b2d22488f9b5c8a.jpg": "/game-art/call-of-duty/achievements/8623c2c97d51409a3fcf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/33bf5941093863c085a3cba80d03004258b2bdc8.jpg": "/game-art/call-of-duty/achievements/4bb2e298d674f00435e7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4b1a75db52ecb1484674cb4c8fd10ae4028d9b0e.jpg": "/game-art/call-of-duty/achievements/4558c32b182fb8e840ef.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0ad5d6255b43a6d24b4ecc7ac5d4b87289e24ab7.jpg": "/game-art/call-of-duty/achievements/8bc453728da64b88935a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/12112302162a4ca970c616a9b4d6c777d0e7e623.jpg": "/game-art/call-of-duty/achievements/12bc6d970ab690b96ee4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/02c69139d83d887e8bbad07330f7c076e4181a28.jpg": "/game-art/call-of-duty/achievements/bc3aa66672504284d00e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/f84f81208278703664dbd530e5e3c6c5c71daf13.jpg": "/game-art/call-of-duty/achievements/613c8f97eb5108156e44.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/0dd80a8bb848350d4b8a0a18dafb270652068384.jpg": "/game-art/call-of-duty/achievements/e2769289d1b1467be1b9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0133fda3a5fa19b1165259600644d25d981bf479.jpg": "/game-art/call-of-duty/achievements/334bacf4dad73d50f310.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/ab8d4ccecae718e5314300a6716d0c3623ce0dc5.jpg": "/game-art/call-of-duty/achievements/2d642533372b4304001b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0980082fe85ff0d25840700b6ed2e52504956d1c.jpg": "/game-art/call-of-duty/achievements/4470c1371d884636304f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bef8dba7289f3eb05a69fcb97e252b13a44a492.jpg": "/game-art/call-of-duty/achievements/dd9e2b94ec07c8fabd4d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/910637c7094876116abbb2e786652120fb4f65f3.jpg": "/game-art/call-of-duty/achievements/a142154c721fce62166a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/df51dbfc2880d1d6053175b6160efb42a71d0a44.jpg": "/game-art/call-of-duty/achievements/5c7e46bb2e598bd62c8f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f43d08d5e78681254c6d1d83edea8a0a3ed6d50b.jpg": "/game-art/call-of-duty/achievements/00e8802dfb3a6e23b510.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/eb2f8a890a5a1c09082c7f31d6907ad9852b8d6b.jpg": "/game-art/call-of-duty/achievements/a6a776d25ae0dd14f3f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1814c8da565a05aeb72c3a260dcbb53722b7349c.jpg": "/game-art/call-of-duty/achievements/0292942faf8b9048ae7b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/69f41d5d43ccfe5b5491392eae1b1ec9e2eec557.jpg": "/game-art/call-of-duty/achievements/18bdee8e74450d4081c5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/7f6c52357f7ea9a087edf50427d002b2544c2321.jpg": "/game-art/call-of-duty/achievements/7344321d980fae59ea9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/bdb4750ed6445a7fb6e9a588ad7c7f25595bbfe3.jpg": "/game-art/call-of-duty/achievements/64144e08856de27591ec.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6a8c99571f5eda2d2e43696189f23ce5fea933b3.jpg": "/game-art/call-of-duty/achievements/babce0e82a2404d14780.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/774b2da404ddc3a9056c8dff17cf67aa8f8abf6c.jpg": "/game-art/call-of-duty/achievements/6635d754150bed1f4a52.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a9e6270ce3479af37f5ace927b7eac23f014ad19.jpg": "/game-art/call-of-duty/achievements/7f8eaceb05beacb38982.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2958048adcec6bf1ff5ada6285f9525e76edaf03.jpg": "/game-art/call-of-duty/achievements/f2c28703a829c38a7ef7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/d17d6491ab01329bc3585da8b36dc354308983d3.jpg": "/game-art/call-of-duty/achievements/26bdf2f4f35b581412fb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bdf965dafb49cb647a31cc21baf86572df84cfa.jpg": "/game-art/call-of-duty/achievements/18e91bf129656ecfa143.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/e27810ec54b75392b3539ddddfc6ada5de42cc36.jpg": "/game-art/call-of-duty/achievements/65b1a4886f0cefe57fcd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/03ee61e8a78cbd8ec3b9dfa592d20d3433ef8759.jpg": "/game-art/call-of-duty/achievements/f1e208e8d69e884977d3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5d8f8186eb5516caaad9138a3c7cb9b85e0059e0.jpg": "/game-art/call-of-duty/achievements/536203b9c317cef8f921.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a064792dbe470c7ebf93c07df2c0125b8199f68f.jpg": "/game-art/call-of-duty/achievements/f43afe71b1b2f4c99be6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1dbf014f724756b8ea85f94879089ce63344b453.jpg": "/game-art/call-of-duty/achievements/0a02f70e810fd5c73ed1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c5ca4e900ec711fbbaa15322bcacca74057e92ab.jpg": "/game-art/call-of-duty/achievements/c46c3d53d7617ce2df29.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2e406d05afd666007c927c29c7cb8df66dcf125a.jpg": "/game-art/call-of-duty/achievements/4c1ee54bbdc0de51a397.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/9dfd28b4500bdc6a148469ad3576fca079768bc8.jpg": "/game-art/call-of-duty/achievements/30101749dcc9e8f00f0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/53173f0fc6c55779ed242f82ea899194c58e1b6b.jpg": "/game-art/call-of-duty/achievements/cf15357082a813202247.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f6699b8c585d8e0f74d946382dc9a6b944668e1d.jpg": "/game-art/call-of-duty/achievements/4d80ad746889b6e51b11.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6952628963694dbdb8c8b2149bd5d0b13d25a512.jpg": "/game-art/call-of-duty/achievements/f5e0217080c8e78a385b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a452e8f7a3986dfe5ee2dcfd5997db72e36b1908.jpg": "/game-art/call-of-duty/achievements/3ced3d4600e56bd1b8f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5ced4efe9b5745469076c965e0fa878c1579b388.jpg": "/game-art/call-of-duty/achievements/68b751f778d9075b3438.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/dc92dcfcab652a8d51315a3e9abc8a2b87db664f.jpg": "/game-art/call-of-duty/achievements/687438517f482f07c3cd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c2a4e5c077eca0eb3913fccdf1cd502444859717.jpg": "/game-art/call-of-duty/achievements/9ac358819352bb811725.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c1683a5aaa4459a4c30a64c7114581b5e5d535db.jpg": "/game-art/call-of-duty/achievements/f579808080eac5263a19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/4c18e7babfce7b1e9a87a91ee8f3d906336f0d66.jpg": "/game-art/call-of-duty/achievements/34fdac009785f70f3a10.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/81c1462e80a491628924060da29d6b28fd04eab1.jpg": "/game-art/call-of-duty/achievements/d1df330a62288f93daee.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/d17a0f39120c0fe05b991f50c8158ccc3a0519db.jpg": "/game-art/call-of-duty/achievements/35b09deb66bebeaffe24.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/cfffa291c82382e008c2b1102864d8efb2bb54b5.jpg": "/game-art/call-of-duty/achievements/9cf4c992ca6581f3188e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/f05286d7cbdebfe28a34155a8c3a87158555f940.jpg": "/game-art/call-of-duty/achievements/a6c2e17cef39da2ae9f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/0124448f02f1564f79b5b0db570b1f736a6e10c0.jpg": "/game-art/call-of-duty/achievements/2ec7b277532d83f8bc00.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9cb9fed7bd89695d81b2df2a78e54e7496ff7811.jpg": "/game-art/call-of-duty/achievements/414383f0814d172be947.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/5ef50a6c4cb152dd5a599d9edbfcb8b01800b208.jpg": "/game-art/call-of-duty/achievements/9001d16125fc11453433.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/3e1149b43b4a4f9173e3a5bc8b8eb6020745430c.jpg": "/game-art/call-of-duty/achievements/ec4fa15d8342ffa088be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4149289f16f86509f23d4a64d3305c8949b639a.jpg": "/game-art/call-of-duty/achievements/07109eeca8b226f66582.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4198c3c5a45727f7fbd1b56a0550b39bd2c7be41.jpg": "/game-art/call-of-duty/achievements/5e6d7390b0040d17f382.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/aa3095124501a0269a16d1ca1d43ce7a576dd097.jpg": "/game-art/call-of-duty/achievements/fe71b44a31aa3bffaba1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b5e4c73252d3bcf7adc023659eef75227d469a4f.jpg": "/game-art/call-of-duty/achievements/ee23c7268ede05849376.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8688428ee57a31db06a01f617b27fa594b2e1768.jpg": "/game-art/call-of-duty/achievements/e55f4f23ab46a034b0e5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ffd7006549012bd3446a63cfb6a5ab02ce9c4b23.jpg": "/game-art/call-of-duty/achievements/d0628ddac4b96e0acbff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/44b401b61ef76da507f4f68ba70529b79813c434.jpg": "/game-art/call-of-duty/achievements/4bdfcb1087e540bf298b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/58b1b329fc69b4575f51890b299a613a66168b9f.jpg": "/game-art/call-of-duty/achievements/ed3aa2c7c084d6206576.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ba3c0b4a33c13a614aabcf3689a5de688fc2ce8c.jpg": "/game-art/call-of-duty/achievements/b7a6b37b1a42b24dbcc8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/014b27d6529a2962da3b0885a0f381386d315fb9.jpg": "/game-art/call-of-duty/achievements/92fc55d6e72e1a9562af.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/57137bee776f973ac60b5b78f12368a40eb668a1.jpg": "/game-art/call-of-duty/achievements/39917528f97b83a77af7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/402d492afcb268e1c782a2d9b0efaf6a888ac043.jpg": "/game-art/call-of-duty/achievements/d9369ed45d2404a7042e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/7654f81ef6a8904b079d0efc8979bfb70fe84031.jpg": "/game-art/call-of-duty/achievements/21a06303c02c87795486.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c115776df2f978c2fefeaac726905b4562b1f2bc.jpg": "/game-art/call-of-duty/achievements/aafd004c36a22f406f02.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/20e2eaed04069ab2d120e3ad59ff5962811f873b.jpg": "/game-art/call-of-duty/achievements/5718a95c950469faebf5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9024a82b5900604aae70224d142aba561b03f32d.jpg": "/game-art/call-of-duty/achievements/94a082f60893d4cac5a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b1255b28f31764890bd8e091fbbc8e6cc733b8ff.jpg": "/game-art/call-of-duty/achievements/ecf176d404db072b1191.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4fa1dc3da619e91ee1a9451bee10f76fb1f2fff.jpg": "/game-art/call-of-duty/achievements/d1b71242d74e7049b1d9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/88516669f90dbf453e51ef71a80283c816ce8e39.jpg": "/game-art/call-of-duty/achievements/95151875ccbdf29fc0b3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8870a661c34b3f30442b5b739fe0efe4cf6e7c88.jpg": "/game-art/call-of-duty/achievements/acc3d4a27a712aa33d5e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ff6956ca387b85abc2f8fd9d556d3171f365bec2.jpg": "/game-art/call-of-duty/achievements/a296ebf4142d08eb8171.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/affa528c8efa5ace55e526fe1056f1c0c880b3a3.jpg": "/game-art/call-of-duty/achievements/34faa2286f859e4ef883.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/e8624092dbc511ce22738709002767687b1318ef.jpg": "/game-art/call-of-duty/achievements/5ab29e7e5d2638bfd973.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/2d084cd19f869118a54e873560b7cc72f5958ecf.jpg": "/game-art/call-of-duty/achievements/68e944579f95a6efbce8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/008cfb3cb67e60096605cca5a345940459dfaef0.jpg": "/game-art/call-of-duty/achievements/d4ffdfc1a9c180cbbec9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/23c6604893406d0c1de71cf902b83036d5195cba.jpg": "/game-art/call-of-duty/achievements/49c5e3803cd325081584.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/78c51356d1ec5d11a5859b571406cc13a0437f81.jpg": "/game-art/call-of-duty/achievements/da67236546bab6d710ad.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bdb32d53b37dfb7ee8697f33d6d8b562f6d9bb19.jpg": "/game-art/call-of-duty/achievements/a04d785f7384b8ec08de.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/6e23e3219dac8f448d9771f0c85252c4e51b53ea.jpg": "/game-art/call-of-duty/achievements/845f6e30ece4d5830bc7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c6bd87838a42ad9105417c8206ef03befc063e1c.jpg": "/game-art/call-of-duty/achievements/c07284fe0ce0ae0cf020.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/add7001a22ffe44d0eb1a231ba8b3678bf607cb0.jpg": "/game-art/call-of-duty/achievements/fe8363fccefe3e208c56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/60a410c18c0961922c0b6184b6cf560f9a104e9c.jpg": "/game-art/call-of-duty/achievements/04536f0a02897beda365.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8d9968d9592f55f89feae4de7eec43727232a39e.jpg": "/game-art/call-of-duty/achievements/a8de98585c8315e0d03c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/080951c14ab06932c4f5c1bd8d3b3e6c1bf39714.jpg": "/game-art/call-of-duty/achievements/be8130070b07f1d7de8d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/87d54d65d1e289e2067c9c3242826efc9386c284.jpg": "/game-art/call-of-duty/achievements/597da19410c37ba79471.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bd13209f20a3a91977efed0be33feb57f475b0e6.jpg": "/game-art/call-of-duty/achievements/f52e922e57b3e985f75e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c512a6fdd4046539d974a46fa800c78bffb386be.jpg": "/game-art/call-of-duty/achievements/08063cb881c78706d4da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/13dfac69945f5592f310d1c8691ce77efe65cc43.jpg": "/game-art/call-of-duty/achievements/09d0c54a2098a7c62b55.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/d52adc9ffe0a7e4afdf34377bfe630f5974ae46e.jpg": "/game-art/call-of-duty/achievements/1f03769c740daab1c248.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/74d98845ae2afd34468df4636dcfc8e7ae3b01ff.jpg": "/game-art/call-of-duty/achievements/0a97ffde899d5d8a3892.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b83aab19a451e06dcf837b7c714250d8bdc0f2e1.jpg": "/game-art/call-of-duty/achievements/93f9eff015cb69ddb6d8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4fde690288c1729e309bb6aa8d699d3ee183efee.jpg": "/game-art/call-of-duty/achievements/6c16d165aeeee4f7f8d5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/608f862c91acbd5070827e16a74acd7e8301dc1b.jpg": "/game-art/call-of-duty/achievements/46401b6250fb1b2d8743.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/0c6091bc69bc52636473f15b8846c3121e4f9692.jpg": "/game-art/call-of-duty/achievements/1c25fab35bd9506e3ec2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ebfb4b1a4669815e2abde15a1f5bb133b05a9ab3.jpg": "/game-art/call-of-duty/achievements/b9e3bae0f6ed760e6ec2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/25c2af163b6af528fae74383bfd03d9a2a8517ae.jpg": "/game-art/call-of-duty/achievements/fc49cf28f27efa9eb0d7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8df32a41f76f24ac6e8e7b324ef4e9fa9f827fee.jpg": "/game-art/call-of-duty/achievements/31c5acd1d552c3ada03e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/938412147809b3b8d782a0c6b1354980b7a76466.jpg": "/game-art/call-of-duty/achievements/33c4b3d1c6340964894f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/02389e50db94d8103d71e3e26532659526caf88c.jpg": "/game-art/call-of-duty/achievements/f38810da50f22d46bcb8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a398ddce474468a1e86dd574eb2ea0d4376c3054.jpg": "/game-art/call-of-duty/achievements/de27cce95c4d870ba405.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bc18770b2f678ce079eb645b346bc382b1413701.jpg": "/game-art/call-of-duty/achievements/b518642efce084c12125.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/eee8e470d8814bcd7656b725fd69a2befa9b85aa.jpg": "/game-art/call-of-duty/achievements/76bb5e0379a74272a2b8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bd9bc54978cc4e496bd56b01b9310558b445ee66.jpg": "/game-art/call-of-duty/achievements/aa77b61181dd612fdc28.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/92780cf10aadd423c0220c712bda43d771c28895.jpg": "/game-art/call-of-duty/achievements/211aa8aae0f177b5e529.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a50d913586131c5361a061484ab1af89a55bf500.jpg": "/game-art/call-of-duty/achievements/55259b5c8a012ee36742.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/43116761fcecd5ee2e779fcf4a44671d059dea36.jpg": "/game-art/call-of-duty/achievements/66cab33804509ca93ec5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/53aa6769167cb791c8937d914eea6187b457206c.jpg": "/game-art/call-of-duty/achievements/774f9f97189ec253aae2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bfa0592d8e8afd7223ce09077e90eb96f18ef54b.jpg": "/game-art/call-of-duty/achievements/fa8d7a05d505c8a169cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a24743f65dd411c593ce4aeea2c0ddca531b2f97.jpg": "/game-art/call-of-duty/achievements/c27becc53b17266aa07a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/c33f19060684bf97b90c7a2b5e4acd45282a4f41.jpg": "/game-art/call-of-duty/achievements/f8f9105abc5a19505251.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9fae3eaaeb9592ab9abb923853648db26f060727.jpg": "/game-art/call-of-duty/achievements/d8713fdad3cce9149fbc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d2309eb8d02d6cba6d5c9066bc290f81507724d6.jpg": "/game-art/call-of-duty/achievements/f78b311c634d024f7212.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/58a5787ec19de789f22118238c219166b5879c2a.jpg": "/game-art/call-of-duty/achievements/fc0511b8a8f451e31b96.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a4db0a6179ec7e43ab2e9d528dd800193f3a3f2e.jpg": "/game-art/call-of-duty/achievements/a8d8e785ea66bfd50395.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7df87b2e05cecca9b64b72379999f3ec684c90e4.jpg": "/game-art/call-of-duty/achievements/5a05b2edc9a6cf26c2b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/847f129434ca57fea22fad195dd050f7ac84b26d.jpg": "/game-art/call-of-duty/achievements/05dc7fab4c1945a38c89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/3f2ce46fe0875b0e20bcda66dda170cfaa2bd326.jpg": "/game-art/call-of-duty/achievements/a184150a8351d169bea0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b1f82e4fdd456c755e74831dbd9656f8513cb02b.jpg": "/game-art/call-of-duty/achievements/ea8506d6d5ecb4d83d41.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1df05bcc60677d4079a0477f358ed2969bfbc9c5.jpg": "/game-art/call-of-duty/achievements/62a4e28a78e2fbc6620b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/ba24d697d0b40477fbe3c8f910b998709e571c51.jpg": "/game-art/call-of-duty/achievements/518c2e90a89ad4e18f89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/0273d8ae279400fdfc70a04193f38a5761aa0c6c.jpg": "/game-art/call-of-duty/achievements/6867da51392b2a7cbbcb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b22e38d57625347eab217d729128eeb4db0b4b42.jpg": "/game-art/call-of-duty/achievements/042c2e99d89b11b11ce2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/4268c8d61c9b7ba0f3e9d30f5c1b8710eeffbe8a.jpg": "/game-art/call-of-duty/achievements/f1cb256ef89049cee39b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d631ddab876e49701f239d48c3c726b920e4afb1.jpg": "/game-art/call-of-duty/achievements/05ddeeac3170749eabb7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/35978e3bee44c5f47960bf6402d8017f80e621eb.jpg": "/game-art/call-of-duty/achievements/a6a8081342baa4827e01.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7649a95fc52417c32bb68ba9b6e2b5afbb8ed356.jpg": "/game-art/call-of-duty/achievements/5b8bdad757dcbb5910df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/350975aca49822ad5333b344c09fe5731ca09a50.jpg": "/game-art/call-of-duty/achievements/88eaa22dcd5346871a17.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9a1954acd31b60a475590b67bdd5076b1a9031ed.jpg": "/game-art/call-of-duty/achievements/189eba245560bf593d4c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1366d36a636b584ef7ada54a563ce7d0bfe9ce46.jpg": "/game-art/call-of-duty/achievements/cd69584aaea92839e157.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7d4e5fbb281349fdfdac177573815bf29b11c0da.jpg": "/game-art/call-of-duty/achievements/e75c0a817729b4502215.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a3f98881e75528718bb396a93f5eda22e4bf89ce.jpg": "/game-art/call-of-duty/achievements/d465f49420763f09c12b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/527e6f6f7a6985ccd146095c86a95a07972cc5f3.jpg": "/game-art/call-of-duty/achievements/be3c201d42ccf528c417.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/e2f9814f32a4f19b0662cacf9e60c7f050aaf788.jpg": "/game-art/call-of-duty/achievements/982416111ad59fb0ee5c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/166d66a24f482cc50fe730defb60b5b594cd942e.jpg": "/game-art/call-of-duty/achievements/28b83c917f642ebf63bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/2dbc27cc5cc21467c3721ca8b8ec7367bdb73626.jpg": "/game-art/call-of-duty/achievements/edef7fdb97fe10818f0a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/f538ea6d78ea1645dc54c04af031ccdee6568984.jpg": "/game-art/call-of-duty/achievements/270e2a421a005b5dddfe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1f92129f1dd043e20858106244a5fc272f686b86.jpg": "/game-art/call-of-duty/achievements/92e43364427d5e9059b6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6d7a6e51581af0ffe24116942136f2d81912fd5f.jpg": "/game-art/call-of-duty/achievements/881a6dcdb647a5456dbe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/bf9871d14aa2080c53c6ad7bd8b44fb698ce9858.jpg": "/game-art/call-of-duty/achievements/cbf2768728500d2aea68.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be9985fb5b0b3ec7f6090fd6e9bcc6999ff47c39.jpg": "/game-art/call-of-duty/achievements/5e2480d30de157fa3fc9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2a65ab1924a9aa0973853c89cbf6de1b6a3f1b56.jpg": "/game-art/call-of-duty/achievements/cb01eaaed54970697e75.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b8d2965a03da8768a690adf601a6125275c65954.jpg": "/game-art/call-of-duty/achievements/5442fbdf2f6974acd90d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/92c128fb406db30bdb68b246b95f73e980bd0007.jpg": "/game-art/call-of-duty/achievements/7c4f4b2fc6b80acf9e3a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/dfffd590f99c950a8f42737602bf5bbcde05ef51.jpg": "/game-art/call-of-duty/achievements/c114604b3ea2245bd5ff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c26807e278a807bfa9a9e194132debb994e700d3.jpg": "/game-art/call-of-duty/achievements/c720a215a01a41843917.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/f86efa50cdb567bf7fe1b3d732bfc90f28c2d85c.jpg": "/game-art/call-of-duty/achievements/2e69343cde4ac31be831.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/57083ea13b16f8cddc531bbceb75d6497690eb82.jpg": "/game-art/call-of-duty/achievements/1ee9ce141d49f68403b1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/68cff2f93656ee35858621a27f45df5d637d6897.jpg": "/game-art/call-of-duty/achievements/92dd45baf84e04330fea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c5c38a113aa37738d0238d463fa76db953406524.jpg": "/game-art/call-of-duty/achievements/cb6f70177e029d0bb502.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3d5e62983b187417073ceef554847f9d00ce394a.jpg": "/game-art/call-of-duty/achievements/ba6f3cd833455f3b90bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/029d30e48640e9e588e7c254d42d735760f2e01f.jpg": "/game-art/call-of-duty/achievements/3a2f2380e76d6d2d4d3c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/40389e331060b203357e57dd61a3f363b9979475.jpg": "/game-art/call-of-duty/achievements/b47151eba05bf44da97f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/7ff96353c673619e2d2842385084d09868959e91.jpg": "/game-art/call-of-duty/achievements/561a880efddcee091658.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ccb127bdaa673e31efb9d70454119e02ae2ca766.jpg": "/game-art/call-of-duty/achievements/cae6882a0941fa16921a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/087aed50312fb462e05211ad90905406b2409b5f.jpg": "/game-art/call-of-duty/achievements/4804a593f80db9e96f61.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d72dc9e5f3b89f6a69e288de7f51fadf101ad876.jpg": "/game-art/call-of-duty/achievements/914c34dc12a4487979fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6f52f66ffde12ccab6fd9dbe2a0a6e352b691b46.jpg": "/game-art/call-of-duty/achievements/06e0022dd9d28a55dd89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0c41ca34b961eae7baed5cd9fa22206f7196da5a.jpg": "/game-art/call-of-duty/achievements/c723c6e6e732d58c5c93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/fbf0db3c197a2865ff8a546cd12950491b59eab7.jpg": "/game-art/call-of-duty/achievements/f6881f12ab401ed7ed80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/91ca2c866c87108ee73388103a4e14c95e9e5d45.jpg": "/game-art/call-of-duty/achievements/2f9a0970dc33a4e8f3e7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0246b25415a4eb83d6dae41bbefe5800e9c758b9.jpg": "/game-art/call-of-duty/achievements/90438c063f19119ea25d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a2d770c6dbc5f58106ba15255c1815e2fa6bfb65.jpg": "/game-art/call-of-duty/achievements/c85019f07f9c65e315dd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/51f85202f9407ac9c89c1a23c62717454aeed483.jpg": "/game-art/call-of-duty/achievements/b99c72128425236b1035.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d946bb02b2001926c36d1c8b4bcf2e9da2a7df9f.jpg": "/game-art/call-of-duty/achievements/2a5be22b7dc548a2822c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ca842a5338ed929ed9e82bfa95c3dd371d2197a4.jpg": "/game-art/call-of-duty/achievements/0bcdb9a4c85d98296f13.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b9cc357bf7dff52609164ffc6ef9357fc29e49b7.jpg": "/game-art/call-of-duty/achievements/14804cc8bd7397a0ff89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be864d18461b9998bbbb0a8d3d16a6327c151222.jpg": "/game-art/call-of-duty/achievements/4adda62a9920bf5ed7db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/eb0573e8cc9f1a3878d7ef7ec4ac80dfa1761b61.jpg": "/game-art/call-of-duty/achievements/9aea21d05fe9092fd238.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/84caa17a06d06d31adc9931bcb6db33e62c47670.jpg": "/game-art/call-of-duty/achievements/a6d5ae65a7085ade450a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/99a4e0e015ae9be3b8ca65867bbab6650e2be4a7.jpg": "/game-art/call-of-duty/achievements/5aebe7390c44348243a4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/86064965b478de57183d1b43d122f8dcb55bf933.jpg": "/game-art/call-of-duty/achievements/baf8d5dac758f4d4e27d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/647e830027c5674a640ac6e4cd15c2ed182f40ee.jpg": "/game-art/call-of-duty/achievements/917e8bb7b9071f135d07.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a5c165e92b5b46b90a7bd9e6ef1f0e8142c9a9fe.jpg": "/game-art/call-of-duty/achievements/34e5afd793ffa563ff78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c83af569c6421ec90065f01e5f6ca3bbbfed12e3.jpg": "/game-art/call-of-duty/achievements/6504e1d1cf9bd7c5e023.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/298570669846c5b34f22843c132d7ab3282f28ff.jpg": "/game-art/call-of-duty/achievements/aa7ec52c45ac218e3b5e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/522d635afe5643642ff92e4e847cc2134924ea49.jpg": "/game-art/call-of-duty/achievements/891c8afc4c4d72e9052d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/35e201ad8332ddfbcacceec0661498f86ea7920d.jpg": "/game-art/call-of-duty/achievements/58117f46b67e5f8319bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/81ba45eba34f972948c411f8fe449149cebdd6a7.jpg": "/game-art/call-of-duty/achievements/ea2c5010e5df6266508c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/698088f494c39ba52fa3901a40ed972197065b12.jpg": "/game-art/call-of-duty/achievements/7e8aff9916e8753b6017.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a62c206a46f6961b66eaa42b93465b4a9acb90d3.jpg": "/game-art/call-of-duty/achievements/f8e7e9067abe8bd97043.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/63b91d1c9f1057775175185dd7ed32a85d7c4024.jpg": "/game-art/call-of-duty/achievements/eb5e344cbc69c8be20c2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a445134fc4d8ebcd88c4509a2e7f4c0e4b4cf935.jpg": "/game-art/call-of-duty/achievements/73aaa55d00fc669decb6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a013f9c37c35c49595a007006016d74058191cc9.jpg": "/game-art/call-of-duty/achievements/68a76bf623fbaaffa1de.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/9b30e1a1fbb4b87626c730dffbabf4b29a12111d.jpg": "/game-art/call-of-duty/achievements/93be31b31e10c70d69da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2fb09ee55aa0815adf363172bdd1ecab23f724b2.jpg": "/game-art/call-of-duty/achievements/3e3fa6bfd93d450030f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3a2fa1c8efe6e383a891ae866aa8fdaa1841679a.jpg": "/game-art/call-of-duty/achievements/d76d9073568fdb7f1e22.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/5f0d23a405f525db66d5de98217fe178f3085a31.jpg": "/game-art/call-of-duty/achievements/05a8efb5e1e15477d51c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/20755ba1aed7213aafb9678daa6718fbd654fefe.jpg": "/game-art/call-of-duty/achievements/3e8f3d7cb413572aee1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/050bae232fd990eb93163372db049de1e1667220.jpg": "/game-art/call-of-duty/achievements/a3fa1324f9bfa48e7ffd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad68409f7ee2029e76ffc99a24431817e479e15c.jpg": "/game-art/call-of-duty/achievements/0a790da83079d22ff365.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/77c17d48b3bb8f201f1c70106830ac2c93cc2bec.jpg": "/game-art/call-of-duty/achievements/1f569f12ef6bf02e2f60.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/9876a14b1eb1e169200e97ad975ba8a3f30f9dc1.jpg": "/game-art/call-of-duty/achievements/27832d705a5816832e31.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/de9c3a1e8c1e223cc764433967c2f4d6f957e56c.jpg": "/game-art/call-of-duty/achievements/db531ff792ba71a88bd2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/818648ba1376faae9caed0a37b39ee62c4cfdac1.jpg": "/game-art/call-of-duty/achievements/21351a3cca37d9104b8b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d61fe4bfae30b393c098e533598dcae13d54e70.jpg": "/game-art/call-of-duty/achievements/e467ca6eec81624f607b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/13e2d09ec3fd46b409918f9e78e08e016028d662.jpg": "/game-art/call-of-duty/achievements/4407c5769f18a5653760.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a863a128014547ec4ffee3151c84a0f0217a3673.jpg": "/game-art/call-of-duty/achievements/6c1736bbf16d4782813f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a6103cf19e593a4f72dba733a9587a8b11afdd56.jpg": "/game-art/call-of-duty/achievements/d63505f2c28cd355246c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3c2cf01172a6ebe981d788ae2a93169befaff5e8.jpg": "/game-art/call-of-duty/achievements/81358b11c5b0cddbed0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2e11039befd3a7ce82b8f2a7f082bcbe7fc2bd48.jpg": "/game-art/call-of-duty/achievements/88da698e6c00ecffc4ad.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/fc28df331e97465a79a21e474913c33fd9ad7b36.jpg": "/game-art/call-of-duty/achievements/fd8dea35848fe13e55d4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/686c5d0f87a272597a0b20d9532375fc9e5b7d12.jpg": "/game-art/call-of-duty/achievements/61884f34fcc524b656f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e52fc0cbbc3344a95f9221fdb4cfe83dc3309f8b.jpg": "/game-art/call-of-duty/achievements/6323591518b8852fecf8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc113dc8c9b656e3c2d0126ad847a518d8e71f43.jpg": "/game-art/call-of-duty/achievements/8893e5ac9a39df8861ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f58a4c733358e524682fe4bff82e2b1a1bab647b.jpg": "/game-art/call-of-duty/achievements/579c1482e528f86abea0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4920ae39511c42ad9a0e07493ef88aa63c60b25f.jpg": "/game-art/call-of-duty/achievements/e50138224f098ff2b0e9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2edc4b06d9a471fee962c1e88ed15af793182854.jpg": "/game-art/call-of-duty/achievements/c2c0bf56904af06f44af.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d36e7c98bc7caa26d3c02d4ded63a2a0b3e5f3a.jpg": "/game-art/call-of-duty/achievements/6a5efa3bd9334d5d867b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e0f85322f92ba1ff6567bbc23dfbf89dff58f650.jpg": "/game-art/call-of-duty/achievements/290296290c6b8566569a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad615c659c19ba7050c7d8b226d6dfdc19cfa062.jpg": "/game-art/call-of-duty/achievements/57922440c12a64977ca8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/28711f22e4d02183fa3b9ddee726798870d3294c.jpg": "/game-art/call-of-duty/achievements/7614725d8df2f9e2deaa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/1adeb3c1d0616ed493b878064414ba475a3fc970.jpg": "/game-art/call-of-duty/achievements/68a83b461dbf8944ba1b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/5e1ff8cee3a47d7259264b3e0d672e0c7358f6e2.jpg": "/game-art/call-of-duty/achievements/e1a62db91c1bbee7d6bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bfc8834dad242fa7b4e54f965e52e3af764c648f.jpg": "/game-art/call-of-duty/achievements/fb9e970b872359137afb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/adc0de3da240048a1c3ef711e8935298e7048d71.jpg": "/game-art/call-of-duty/achievements/18938e8b05c43850a1d1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/37f79052f673cd66e823b8a2ac0edf46361e9086.jpg": "/game-art/call-of-duty/achievements/de047bf800f352c3ccda.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/90bd08a0ca63a5f797a632c37360fcd7417d670d.jpg": "/game-art/call-of-duty/achievements/cc9a0743649ab4c59526.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f21d7109756d613aae0c1f5939edd05ff11f497a.jpg": "/game-art/call-of-duty/achievements/f544fe840003238312be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/c710f5068bd6fcc2e003c9687bf35a4e9eb6ec75.jpg": "/game-art/call-of-duty/achievements/470a0f7cb6ebca39d5a2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/142953876ef39884da73128e38f1e1031da26075.jpg": "/game-art/call-of-duty/achievements/4a5a93323ab645faf0bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/44a46d2994af6d8908b1b1a8db56f63e93ebb9d9.jpg": "/game-art/call-of-duty/achievements/bc64976f8e558446785b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3605bded742f799361ad78d90b6e69a1b2ed4396.jpg": "/game-art/call-of-duty/achievements/9f300fde5a055be0a2f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/16b7df11cfbc7e897f6b166c2f6af9a572f148bf.jpg": "/game-art/call-of-duty/achievements/c6f06ebc885130e0722e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4ab8c81fbd79007c23a981e4b44212e3d91ff1d9.jpg": "/game-art/call-of-duty/achievements/9e2b0d60184c6df69f4a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/17fad7e3884b0a6d22d9186521159c8a48f99710.jpg": "/game-art/call-of-duty/achievements/d9668edb4a10ec5afb29.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/439b0eb7928b4b7b7e4e6fb1b9f4e78454f6129c.jpg": "/game-art/call-of-duty/achievements/39a710368dfcc33592fd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/95855c51876cd96887b8044dac125939defb8756.jpg": "/game-art/call-of-duty/achievements/9c8dab60983dd5f09299.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/339a36f54f554c4bd428b34c6ab57ca302a33148.jpg": "/game-art/call-of-duty/achievements/33c148406570b01ac65e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e847bc992c94bb85aa7d877cca66d7a304202c38.jpg": "/game-art/call-of-duty/achievements/2b4d4ce264f28bf49169.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc7b95aca3dd892e3af7bb8855609e5deffc640c.jpg": "/game-art/call-of-duty/achievements/ddf6fde35a75833626cf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bdc263e06fd24394a7b3f556e6383b5cf2a5bd8e.jpg": "/game-art/call-of-duty/achievements/822d7187b609f4026d01.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/57f801ff4e86a62c29137529be436dd0c67e801a.jpg": "/game-art/call-of-duty/achievements/a7e8830d01f2edb54842.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/d1d328cd4b41675b205aa41b179f39cdef652162.jpg": "/game-art/call-of-duty/achievements/8e54b7d6307955986b96.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52da6d72be62dd5a6c4e06ac0a4b6caa8e77109c.jpg": "/game-art/call-of-duty/achievements/4d410e57cd4155a21ff5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/bb758e805f9d2da1053db7db6518605b8b74acfd.jpg": "/game-art/call-of-duty/achievements/9f5847949abb34cbf970.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5b120f358bd219fa1830106aff9713e3e6888210.jpg": "/game-art/call-of-duty/achievements/ddb5a6e3ba7e9cf06f1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b3c22eab9f1e84f9d73392731db39fc30d0396d1.jpg": "/game-art/call-of-duty/achievements/6566800a8a3788ec7255.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6467e3d1e0d8a819cc4e064b3451e2519c2a8c21.jpg": "/game-art/call-of-duty/achievements/d519143240b213146571.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/96bb5a6ddaed3705e0fb1b04cf889dc484e48348.jpg": "/game-art/call-of-duty/achievements/fe5d49946f714a41def4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/47cc1f734302c3acd1a15c365de915d8c6bbad73.jpg": "/game-art/call-of-duty/achievements/588e3e026859e6b20c4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/236c9cf40603e1c35cace7ad3b923005d8313c07.jpg": "/game-art/call-of-duty/achievements/d4b4f64c747f8cce5747.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/861a3a694b818be9072467dd283c028371b2e063.jpg": "/game-art/call-of-duty/achievements/ef0937348329fb2b5b7f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e90c82ba064b78420e1ed4bad078158bfb708985.jpg": "/game-art/call-of-duty/achievements/e744091006695c8e43f0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/756b0d4a29fbc2ebbf236daf1e8a030e346041bc.jpg": "/game-art/call-of-duty/achievements/75446a54b94fe2cca1ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7fa120b283d4bdd452167da70156378588dd63d7.jpg": "/game-art/call-of-duty/achievements/7d76c465d77c0c4ed74d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/421be651f3b3faa73f873b1aff31f918b6d33faf.jpg": "/game-art/call-of-duty/achievements/356e1b587d1aa06e229f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5c09f121abcde97031674a6224f3cc20f50fea2f.jpg": "/game-art/call-of-duty/achievements/6b7a793e48a2806701b1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/2b42df8394c11a52ffd953d12fdae05b9fdc5b83.jpg": "/game-art/call-of-duty/achievements/50eed7e3271e385ba055.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7e6b3e6c827e209091223bcc22237a2904cf765d.jpg": "/game-art/call-of-duty/achievements/77f0a50f6e76f0895d78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4ca7533816c1d6901b77bfee63e5d8b8be38dc45.jpg": "/game-art/call-of-duty/achievements/10b81c1bf5661167f7e9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ce04968c413bfeca4b66ec2368a65da39a4b6f46.jpg": "/game-art/call-of-duty/achievements/3e1b28577bacd3294557.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9b16bf7dab254eed651d2a78a41f3e8cd98fd52c.jpg": "/game-art/call-of-duty/achievements/48ec62405747e62c1221.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9808074d5ee0e3fed290a3871963fc27f702a494.jpg": "/game-art/call-of-duty/achievements/140c3fc9803cc12de9e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52f205ce172e2455709b9c56d9a91ac9f83c2cc4.jpg": "/game-art/call-of-duty/achievements/f8471d3a20bca44f2c6d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6bc4563784f085681652b5cf1938417d092ea225.jpg": "/game-art/call-of-duty/achievements/fbb12b75d252fcf4c473.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/50039d61608edf89dea7a3661a44d20fe2ef5316.jpg": "/game-art/call-of-duty/achievements/a3e82b8ab886ea64042b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/642be79721bbdd465e8cd02abc08e3dfedbf0f16.jpg": "/game-art/call-of-duty/achievements/4844d808b7dcf60ab4ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/f47dbd16bfda8265ffed525c4020176a536f03a2.jpg": "/game-art/call-of-duty/achievements/7fd6c2e9a3fc8d1013b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7bad50fee59ad5bc0c3ca3bf18b1982199386110.jpg": "/game-art/call-of-duty/achievements/4249be6f37e30ef940ec.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/1c883dc3d1adc6a5ec710f76191277b769ea76e9.jpg": "/game-art/call-of-duty/achievements/f741557d449ea7cde80a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/dfb171eac5a5c121258d4e05475c5d2e24f7cd1c.jpg": "/game-art/call-of-duty/achievements/17e4f46fee28877750f3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/022fb52a500d56c8437262d50fa280c2714bb42f.jpg": "/game-art/call-of-duty/achievements/8132fe0e13988bd24fd1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5f8e24e7491983f80a7fa0ff69e0493e07d0be0d.jpg": "/game-art/call-of-duty/achievements/e42ff6122323ed9a3395.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/af2e18e12cb9755a12a35732a6f22d4544f5c3cc.jpg": "/game-art/call-of-duty/achievements/127c266e7e0fc974c8b8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/58ec162ca04995a17db5193753a6ab4d72a549fe.jpg": "/game-art/call-of-duty/achievements/90e6a8e1e520abcf4a56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7c38971df65317d11d36d0bc83446007b7e82d3e.jpg": "/game-art/call-of-duty/achievements/d57ea860dac87b16eed1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e3d2196805f3364ccf63b11af82bf78f26fe367f.jpg": "/game-art/call-of-duty/achievements/055fa62b0ec398270f1f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c055805d0b537d1f5592821c4957a05c56c035e0.jpg": "/game-art/call-of-duty/achievements/c141e7cd14b49ac24e14.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ff3828ee10a3dd90c39d4ba03e4c80318f41df3a.jpg": "/game-art/call-of-duty/achievements/26ce14a18ff0d3ca4630.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/19725429483357cf9923024eb3b01571cb590964.jpg": "/game-art/call-of-duty/achievements/c222d739e10080d08e71.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/374aef06c38570f275550d5ec112dadbdafe2474.jpg": "/game-art/call-of-duty/achievements/65d6c7d09869e3d44d9a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4d1a14f525f9eee3732db407b0b00e8776b7da80.jpg": "/game-art/call-of-duty/achievements/ce5174efcfa4cd484408.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e64fa2ec9f5b716db5aadab888dfbed2897cfb57.jpg": "/game-art/call-of-duty/achievements/3649dd52dcfcbb203aed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c74fb5b0b7a56ebc7f963c1d30caa069820d2540.jpg": "/game-art/call-of-duty/achievements/35d06b424797d3dec59e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/35a53e8e4094957bfa43273c524cf45133da03df.jpg": "/game-art/call-of-duty/achievements/8d292c25901c52b93b82.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ffdbc4312f0f2420a218acd67ad92df364910a6b.jpg": "/game-art/call-of-duty/achievements/d8fad671953c03f693d8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b544bd18fb12b0b37891c889a51755e59318404a.jpg": "/game-art/call-of-duty/achievements/3140fc6f7f446c716a1b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/67a2a2b17f8736eec7743f0df0c1c96a8f545272.jpg": "/game-art/call-of-duty/achievements/7ef86a67871720d7252c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/be09a9092346a464996c7df633d6f9f953b008d6.jpg": "/game-art/call-of-duty/achievements/197b69315304f7e48443.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1d4a50459abf5d2364a5d98606f6262a6fab9213.jpg": "/game-art/call-of-duty/achievements/223cbe388d22b5576646.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d6b5ee282b0916528cb140ccbef2612cf1e21d4b.jpg": "/game-art/call-of-duty/achievements/e735731d7451544af1a6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db0d1b5f92f92cbd3d80842d57943d8d5be8d5cb.jpg": "/game-art/call-of-duty/achievements/e76bb42ca3475d9b0892.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/bfd15017d6af1bc8d41067ac2c572955c217493d.jpg": "/game-art/call-of-duty/achievements/ae01cdcbada7670a3afa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b244c1fea2b4fdea2623a6b02b8679306b02b56.jpg": "/game-art/call-of-duty/achievements/03b4a6ad35e8d85f2257.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/254dec333adbadd7753e20a030fba050bdd03a78.jpg": "/game-art/call-of-duty/achievements/73502027f0cfafd4b5c0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8f9d970c9cfbf6a836e459d3ac8e15f1e38930ba.jpg": "/game-art/call-of-duty/achievements/8856e5ac6156c0c7ab33.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25ed74dddf450df12f377e5342c81713b3129d2a.jpg": "/game-art/call-of-duty/achievements/4f967cbfd21a42fbaa16.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f825b53bb58668624201e12ed4eabd033efcd585.jpg": "/game-art/call-of-duty/achievements/b10628c3d94d45f19de9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a737ecf9f6d5e3953b1bf4c50aae4cec5e9e06bb.jpg": "/game-art/call-of-duty/achievements/2e67b31daa3d79a4df8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/9e7be918ab894a01f7728744ba615eac1820817c.jpg": "/game-art/call-of-duty/achievements/3a0fba9657b38aa35e43.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f21271c5b4fe81a8c2dacb3124dd38d5bf8f2a66.jpg": "/game-art/call-of-duty/achievements/be14e6541017097851a9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fcc3e34ba09068858def201bcdf9ceb789eb61bc.jpg": "/game-art/call-of-duty/achievements/6c614d52d10224d2fa8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/30de18e9078ef6b63b3299eae374845469d095d5.jpg": "/game-art/call-of-duty/achievements/46af7819bce39547f361.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c62e9ba3af62855025c2b3b17a91ef02e2528cd2.jpg": "/game-art/call-of-duty/achievements/bc5360cb1b7898b761ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d36b6dfff0667595586a4ddbf1d7c3788acbf38b.jpg": "/game-art/call-of-duty/achievements/acf446664a46e34baee6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/936a1c5bd72edb720616e40eab136d672d607a4a.jpg": "/game-art/call-of-duty/achievements/4c88c48b2a35e2ea664f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/86f552ab60ab431d435dc70606adfc1212a6744b.jpg": "/game-art/call-of-duty/achievements/b175cc13888dd81b6a9e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/381722b8ae8a5f47100470a2d2cb419c872da647.jpg": "/game-art/call-of-duty/achievements/6ad5ee337862d9c49188.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41169a20ac367bd8a588ace5b5d39ad139c92b78.jpg": "/game-art/call-of-duty/achievements/4895825fc7ac017a9c81.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6348a58f593d9f44d2cedbdca5f221d0042b679d.jpg": "/game-art/call-of-duty/achievements/cfcac47d7797bdb2a9b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/26745f61f56cbf73a45b7ef9b1948e492148a5b4.jpg": "/game-art/call-of-duty/achievements/2ce53b47a9e930cd0f78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6090b4e792a1023679013b471e2aa71ecd5d4f99.jpg": "/game-art/call-of-duty/achievements/74b955b39ea1ed52fc64.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/def3fc3485640dd6edae975f87139aeef4d4fbe1.jpg": "/game-art/call-of-duty/achievements/f54072dc01e0c107a2bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68f16e67a78e2b087c70ba959b216b49c16c2abf.jpg": "/game-art/call-of-duty/achievements/ca18fcccac729d9d7db5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/92a8876b6145ad17c8f0404f86d01bf26a010334.jpg": "/game-art/call-of-duty/achievements/3cc75903f021d64740bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e71deeb01ca53bb6bf6a7e6973efe9b82a0f8665.jpg": "/game-art/call-of-duty/achievements/388fce23aa91b4901180.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/474b8a9fb5b38c894cd1f1957a63b679bb771216.jpg": "/game-art/call-of-duty/achievements/e773ca386395ded5b66f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c41b5d959900613a8cc39386742f7b22291f7999.jpg": "/game-art/call-of-duty/achievements/60b0825c4351c069e7cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d4a3b90d95dc9533703d69aade0ebed45b0b8693.jpg": "/game-art/call-of-duty/achievements/f4973928757861b05492.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4a856c1791fd65d131bdb4fd173acc78aee409a1.jpg": "/game-art/call-of-duty/achievements/06254bba874e7fd01696.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e53cf9bba3fac118dda21382e0e22df8eb24194b.jpg": "/game-art/call-of-duty/achievements/ddf914f209f0775a6681.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e24f7b67ee2e209383ad61e5db207235e5c1b36c.jpg": "/game-art/call-of-duty/achievements/c891f4cb387f443cf069.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1937a0f8698adcadc42a0ace8b4462977a760d3d.jpg": "/game-art/call-of-duty/achievements/09de527af050eb1ea6db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/caaa4c120cc31a3b3844078901690924622eefec.jpg": "/game-art/call-of-duty/achievements/100ea250a4b8821ed763.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8264162bd63715cce724a4c0e43200863e9db401.jpg": "/game-art/call-of-duty/achievements/245b5e76379398c97e60.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/3b7c4ba4b7813850520cb9490ad83b5053674cf3.jpg": "/game-art/call-of-duty/achievements/caf98f2b75941adb9458.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/889a2ef104e08f5c7202344b4002c92360a198d7.jpg": "/game-art/call-of-duty/achievements/47b989f34d821c69c13c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5be087b25337e1be80fc34fab49516a9bc77b6b6.jpg": "/game-art/call-of-duty/achievements/c4dfbcc832173be93b88.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b85c34b1028b1a36a15b1a2a5163c887f127b43.jpg": "/game-art/call-of-duty/achievements/134ff04d96b5e2117659.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e3c00a88baa550f02cb701bd54a48a75584884ec.jpg": "/game-art/call-of-duty/achievements/691f0231b7479088e835.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cb1ff523e32e0c9c4d71db0f03ba939783e2c65e.jpg": "/game-art/call-of-duty/achievements/076ad1effb6e1150ee58.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/13a1311666c200fe0d063946a86ff12eccafdacc.jpg": "/game-art/call-of-duty/achievements/b2aad82684e6f9525b74.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/24f298d0cad725a3a2a6d6c157ba8184954bd51d.jpg": "/game-art/call-of-duty/achievements/1f401a4643636b7e09ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db45274296fa6e35c811ad848ae8ad626135d5e4.jpg": "/game-art/call-of-duty/achievements/6513f9d21ce06e1c34a4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a0c4ad945aba7cb59e1cd01286a298bc0319048b.jpg": "/game-art/call-of-duty/achievements/e7cf115da299db339ab8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2391734766760a40fb7938f2f70273c4d3d63525.jpg": "/game-art/call-of-duty/achievements/901df0e5c0d48e171f53.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ec08f0773c6d0e51642dde798ec750e9aff7d6e9.jpg": "/game-art/call-of-duty/achievements/54ddc40c8fce36f87df2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/de077255c5d4e52e0f6f8f00fb2e9dbca80a64d1.jpg": "/game-art/call-of-duty/achievements/c0ae7942b7a2eb803695.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e014cca3b1ca0ac9b9252f8a4541a80a07972956.jpg": "/game-art/call-of-duty/achievements/07de5ba81b60ceeb4adb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/668440cb517ae4f0bb5de178bb65482af2f9352c.jpg": "/game-art/call-of-duty/achievements/36735e3cc67ff6aefd7d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8a237750c9186100f4bf102ce1958334d8c94a5f.jpg": "/game-art/call-of-duty/achievements/394689ecddb3bb3c6ae7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1087feeae28b52a8691214007e0e4b55193c57b6.jpg": "/game-art/call-of-duty/achievements/64727141ed121cf93c42.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2ba51a64811bce2e9d987100c8de9057edefddd0.jpg": "/game-art/call-of-duty/achievements/632a1fcb4fedf10658d6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4290a97f840765a0a8eaad905cdc3944c524b6dd.jpg": "/game-art/call-of-duty/achievements/ca5280a764455c85011e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c9c5c6ad44fd794cca4f1dc884f38f5d04bdc49c.jpg": "/game-art/call-of-duty/achievements/e0b4aa8da3809ac08f42.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b518c5c6818a1dd05389dc4d00d68ec6d1c295aa.jpg": "/game-art/call-of-duty/achievements/bd4b2888b439fa2fdb0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e70609d0d14761efad36689c6164748b9ab906dc.jpg": "/game-art/call-of-duty/achievements/2a6bea06204ff2131803.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/98a9190243d1e999f28e52cff2f41eacf2bea14a.jpg": "/game-art/call-of-duty/achievements/29d44f556a418702ab3f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/858fa9620a0b3f18af5202262cd01014111fa142.jpg": "/game-art/call-of-duty/achievements/f0c8abc666782ed1b999.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41e444d3d97c38c8c9745eb9812270071366d034.jpg": "/game-art/call-of-duty/achievements/5dfbb5d9c9e6761f5421.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33c0c6c42510a5740d92052401cfa3e022041aee.jpg": "/game-art/call-of-duty/achievements/cbbf8d2b5b17d9049b8f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ad607c6979932af3f628a2f49ab7861768317271.jpg": "/game-art/call-of-duty/achievements/b4664863a39199d14aed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b21d4dba771c1b995b823ae472ad2449ede75e96.jpg": "/game-art/call-of-duty/achievements/2595dd69394a004aa9b3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a8691d946c8df1cbe15a5b5a844ce67afe44edec.jpg": "/game-art/call-of-duty/achievements/a33c85c2eac09eee12d3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/94ec5a16bec33a09a7e6d46c59228dc2bfe39036.jpg": "/game-art/call-of-duty/achievements/6ec3b50ebd02d0735b93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f4eecc8ddd7ee7293fa25832335188b015ffb83d.jpg": "/game-art/call-of-duty/achievements/e630036163d8caf0debb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7f11480479347fcea37cc1cfae939aeba0310c02.jpg": "/game-art/call-of-duty/achievements/4836d8a31e08f9d59a2b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f92300994fb718a533ca129c8ff923a926ab45a3.jpg": "/game-art/call-of-duty/achievements/acec1c540085bf43a216.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2064b041848207e9152648f9b04b10c0eb4a5ea6.jpg": "/game-art/call-of-duty/achievements/add0b1b64943198d70f3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0cd1b2512a34e0cdaccffdedc50a3922b97928f9.jpg": "/game-art/call-of-duty/achievements/57f87a62c441cc00fee3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/016736a5e36aa630ef854098bcf8b46a0ed090ce.jpg": "/game-art/call-of-duty/achievements/5ad988718dfbdf86382d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/32576cd779cc6194a221fa52d0fcd1ab42a3d3c2.jpg": "/game-art/call-of-duty/achievements/6081ccee396297fcf8f0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2eb29923c09df13e6d1459d86a81fb0578304ab4.jpg": "/game-art/call-of-duty/achievements/ccecd58a309d11d1175b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ba1e4900d687debb7029236ccc1faa64db1180eb.jpg": "/game-art/call-of-duty/achievements/061b960e95b7d94f8f43.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5cadec3374b283cb8f9dc854701bc822bdcce372.jpg": "/game-art/call-of-duty/achievements/ecdf86fbec69e35d181d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25a6f8d781a75b9c5a2836a6143654af206e7ed2.jpg": "/game-art/call-of-duty/achievements/99ec70d1d7557298d43a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0c3c4505562edca7e9b5c2f33461bed0d024cd9e.jpg": "/game-art/call-of-duty/achievements/f876467edbcda81649b4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8dedc819af9d9899a880116f1c5749157a41331b.jpg": "/game-art/call-of-duty/achievements/31c585a4c4713380fb47.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/880481de0edbb3b6c8106b91619047765c0438a4.jpg": "/game-art/call-of-duty/achievements/fe2f56321b012cbac9a5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b25f0928717fdc0078a4b8235d4f8090f9347b2a.jpg": "/game-art/call-of-duty/achievements/a7d36705f1683cc0f51f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33cbc57d8d5d260b2a029f72aa840e6f777a5fcc.jpg": "/game-art/call-of-duty/achievements/5a3e56b651582cd37b0b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/00f4b4482ffe5af8e36fc29ad10184fabb796b2e.jpg": "/game-art/call-of-duty/achievements/f9484a145c44b5d0cbd1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5b6d4af29251e8c797ea6c8df0b5856a338147f7.jpg": "/game-art/call-of-duty/achievements/a14a5d22282b82e02b4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/67cc050f25ae01778daa55c135f8935f0745ea5d.jpg": "/game-art/call-of-duty/achievements/2e87d420089b4f2be57f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/559bc3b290bc195d1285c286f1cde1c24335391b.jpg": "/game-art/call-of-duty/achievements/3c6074ea42bb0c376cb8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/782ab1a29161fcea2ee6637a6c0965e074e0bc35.jpg": "/game-art/call-of-duty/achievements/aac4dce9aa2be9c78d9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ecf93da66531bf5795410c7401216bc46c96377f.jpg": "/game-art/call-of-duty/achievements/27bc37653fd38aff2e63.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c25d1ef82dc9ae885afc75fb7db37722039a5cfa.jpg": "/game-art/call-of-duty/achievements/ed46540b107123ccac71.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/61e6b6f00e56022f96fff4ea74393b211d87333c.jpg": "/game-art/call-of-duty/achievements/1189b4ced39f5b73873d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68cf892bf027d8f5b337c878809441595772c462.jpg": "/game-art/call-of-duty/achievements/42184870c69db3836487.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1c86b033653580d08f213eda50989d7a7284ae57.jpg": "/game-art/call-of-duty/achievements/28d3a3ca08a7bccc03fc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cf50aefce11bd69bc0e7aaa776cc8cfbe32c1843.jpg": "/game-art/call-of-duty/achievements/cabc845fe34956e075a8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e1cda475ce180a6b18d5707c3e43f791890a8a0a.jpg": "/game-art/call-of-duty/achievements/594e4fdf8acf6f23526d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7cec40e8b161cad9671f4a0e6a3c7b753312bbae.jpg": "/game-art/call-of-duty/achievements/b73876cee3831c464041.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fed3d249602647c0793d1054b15a88b61e532fcd.jpg": "/game-art/call-of-duty/achievements/88afe330f75fb3f138a5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ca4a4b6a35ed886f3a38de19ac74cc1d79cf9b24.jpg": "/game-art/call-of-duty/achievements/73c21ba0cdda98cc9bf6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a4ea4b980d2ecaf4e2cd586d771d4a8ea67f0fb1.jpg": "/game-art/call-of-duty/achievements/0eea8fd2286728848ad9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9d54b20f3f810ba5350c026dd5ea10fb5219eb9d.jpg": "/game-art/call-of-duty/achievements/307d65e590b40d9b2ed7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/476ebb35cfa9b4c0b1cfdc764396347cf8a956a1.jpg": "/game-art/call-of-duty/achievements/821090905722714275bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4d7838ed2db245e8dc1efd9c33bfb484d0987f5b.jpg": "/game-art/call-of-duty/achievements/9949c131335becbb7bde.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6f53e953c713a7cb5ccb5f927324289297424f53.jpg": "/game-art/call-of-duty/achievements/bf2530311d97100ba635.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0da3fabafb959cabc6450cdf61b380ec186a0742.jpg": "/game-art/call-of-duty/achievements/b6595861a5baa64dd7ca.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1d1d49d31480100db0d8d3ff4ebe60600c548c33.jpg": "/game-art/call-of-duty/achievements/a993f3d757f6e294b0ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/364a213998f2261a3bc4c79ebe3d0b2786fcef6d.jpg": "/game-art/call-of-duty/achievements/b1b2c8cca82ab7fef281.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e41644864b89f501bb6b4e81f58d34e6f7a692cc.jpg": "/game-art/call-of-duty/achievements/24f087406bd644483bc0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7cb49cf9cc0bbbb0d345993b1251fe0ad5115b97.jpg": "/game-art/call-of-duty/achievements/7ea2fbd55068b35f2b52.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/503545f86c912ce5f504001f50f0187dbda22e6f.jpg": "/game-art/call-of-duty/achievements/5e04d92c3fba71824fae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/68a5bbfd6300ee0f9ab5dcd3f1abbc1158f3a4fa.jpg": "/game-art/call-of-duty/achievements/6ecc81eb586e45d95d80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eaa5797d0d8aa6892b2cd89342afb9e20854cf90.jpg": "/game-art/call-of-duty/achievements/d4d590aa6e41f28ba0c9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/914889189cdea483f10e7911c0ec21c743bb7ee7.jpg": "/game-art/call-of-duty/achievements/22dc6c8473596aeb227f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/57b930b1ea0cf6e610139859dc2e6d56226a2633.jpg": "/game-art/call-of-duty/achievements/bcd92f34c188b2c94e19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/efbe0632197664b2c0228426c8df13097631a37d.jpg": "/game-art/call-of-duty/achievements/4ae83a4491c70c5a7a73.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f4b3adcedec5e27d71b7af7e9ee0428cd1dcf400.jpg": "/game-art/call-of-duty/achievements/5dd6624c4ec7afaadf27.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c17c0c50abc6e6d9a9aa85e458f54608b099df9b.jpg": "/game-art/call-of-duty/achievements/1a88c2ae4f55f28fe1cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/be10a6dca336bcffc3db6e100cc84e2afc39a04e.jpg": "/game-art/call-of-duty/achievements/dda424d5bf63271f6a83.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d36e209bce7aeee14bcd7421ec5989c3d6766335.jpg": "/game-art/call-of-duty/achievements/ea4c12853a21dcc53f2d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/45373ed485cfa11602295fc21cb619facf1b587a.jpg": "/game-art/call-of-duty/achievements/050347b46f0d8368c7c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5fd86313adb4952d991ea88fd25c264b4cf3e4d0.jpg": "/game-art/call-of-duty/achievements/a50db8da0e01557fbd8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abfde4e6cf3c3461caf90d5830c2984fbaf4bc68.jpg": "/game-art/call-of-duty/achievements/48753b740d2a2cdc0ba2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fe8f95d54e14d3186cbcb91c422943ed386f4668.jpg": "/game-art/call-of-duty/achievements/85ab28ed3bd4013da705.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ed8e02846abd5ed0be162106597261e0323ca74b.jpg": "/game-art/call-of-duty/achievements/4ee5e40d6a92ccf126ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c53a03bdce2b1ca3aa4a7c1c51b2fae47aec2417.jpg": "/game-art/call-of-duty/achievements/15f92324581bf6590bf6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/69206aa25a08794251623f894f522dd974094996.jpg": "/game-art/call-of-duty/achievements/a07c68c98dc2d336adda.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/8fd625f315d38418c48ebc2e525ee9a132562029.jpg": "/game-art/call-of-duty/achievements/38e607282e37825c2269.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/01d0cc9c538cf12c4a2604f31f9d44b0a6b81bc6.jpg": "/game-art/call-of-duty/achievements/ff3a9b02aa377eed93fe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7783476c4c94f89e01165466eb82d896e0247c45.jpg": "/game-art/call-of-duty/achievements/8a2840e5b9584aa1546b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/48f63f1800eccb761142db00dab66bb306409774.jpg": "/game-art/call-of-duty/achievements/4b5e9de3363f43ea5dac.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/191459e1e92933065eb06461831ec18c8748aac5.jpg": "/game-art/call-of-duty/achievements/09cbb08ecadeced19848.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/bc5c4bfac95f698f5339a3458aa43b8765802e7f.jpg": "/game-art/call-of-duty/achievements/ffb4db59b87d8527049c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d3f4ef642be6bc91a1f93bc0f079ef3b18ce8b05.jpg": "/game-art/call-of-duty/achievements/9b4b9d4026c4db6643b2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e174be71bda7e57d4c4fe7993ca4a958f3352648.jpg": "/game-art/call-of-duty/achievements/f90d041f942f984f6c15.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c52901981e755d5553186c5b77f6d59348a50f95.jpg": "/game-art/call-of-duty/achievements/d29610fabecd57672df4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d20c0b3c00365f3ec8bc9d24682c20aa487cc8ae.jpg": "/game-art/call-of-duty/achievements/31432873709e76e4e1a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/583e60eb79cd565e85aa1bd4c59bec3aa32e4434.jpg": "/game-art/call-of-duty/achievements/63dfa9a75717ece9f7fe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/809188e0d91acbef64b5f99a05bdce0e76a48300.jpg": "/game-art/call-of-duty/achievements/2be108c182fe9595ccb9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/322e0ab6895d3f97a06244de57038145321ba78f.jpg": "/game-art/call-of-duty/achievements/55f264e040cb5f11dd6c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e0d98de5d187d767c9c29ed3fefa5e94fb5d2a71.jpg": "/game-art/call-of-duty/achievements/da40d7b9be06fd8c6981.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fad7498a85145e17041e7a42a2ac239c9a836940.jpg": "/game-art/call-of-duty/achievements/3cdbcc76097e36b877ae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/cca04d8cb905c1a79dd337fb637db65753b1eb74.jpg": "/game-art/call-of-duty/achievements/0316533b014576c6f9d4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4be11b62681fe629ce889e408cd33d7c9ce2d82.jpg": "/game-art/call-of-duty/achievements/99974045c45ca4746967.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a23d7843879e2533cd5ca05cdbb12768421f5200.jpg": "/game-art/call-of-duty/achievements/d9687e41518b77bf05ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2140d0ad44a74cf73dfa8e5eecff94545dbec800.jpg": "/game-art/call-of-duty/achievements/fb7536a485a7fcd2102e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/41eb949d9e5ebded26f444accfbbf776e423d0c1.jpg": "/game-art/call-of-duty/achievements/5d97318fb384c8043818.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5e903ad5b14acac2a3ce30b241eca02318efe08c.jpg": "/game-art/call-of-duty/achievements/c7ac6468f1a9dd020ad9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/190cc9b3a7a4738b2c0c0aa5b3581c3d13d0fc67.jpg": "/game-art/call-of-duty/achievements/ee8e525ffbd4aad55d05.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f26e0102562e48114cfdc47063638c0ff13ae369.jpg": "/game-art/call-of-duty/achievements/2598397bd2d8472b6ffa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4779b58576c848e74e133b602c4febb942a486c0.jpg": "/game-art/call-of-duty/achievements/6c22b7ab6d07086e064f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/851be884cab04c069af4b95853b679e5783c0a2d.jpg": "/game-art/call-of-duty/achievements/8e7820b5324e90ea2e19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e51e3ab96ef6d0c2a029724865f2db73074f879e.jpg": "/game-art/call-of-duty/achievements/fe0bca15248c5db77368.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/73263340a8746250ab3a8f1186f10c238cb98481.jpg": "/game-art/call-of-duty/achievements/51155702084575df7717.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0ce7057339b8725b96f9ecca6f9a244456e050c3.jpg": "/game-art/call-of-duty/achievements/eb67cbdf0f2bf1e2e392.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6091d5200a8aed5d2bf8079cd8e5f331b25a6d6a.jpg": "/game-art/call-of-duty/achievements/c14b826b7ceee473d291.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/565514b78a058199b82113be663edafbfcfaeaa6.jpg": "/game-art/call-of-duty/achievements/0e4f0bf6cff2c371105d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/232be3c5fc55ef830f30fe8e4505373482dba0ed.jpg": "/game-art/call-of-duty/achievements/875fa76fce401a711538.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abe4a2fcc597ac2f024aeaeac6e4aa6f8a989951.jpg": "/game-art/call-of-duty/achievements/c9eece5fc57482ab737d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d2fca5a28ce5c89ec913731cf05742e5a8ec5afc.jpg": "/game-art/call-of-duty/achievements/0151aa252dbe2c64fa5b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ce1781c257f210cf1e816d4704030365abc75058.jpg": "/game-art/call-of-duty/achievements/ab8c01332d03579e6fc0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f737b9b719c85a924574939fd4d188c1817b9619.jpg": "/game-art/call-of-duty/achievements/5f0fd6350175e1f60148.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fa839e5d6e9db883809176ac97e5e1e1fb364e50.jpg": "/game-art/call-of-duty/achievements/98a68729deb6696212f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a47053b4181e3d73780e2aa08454f29883e5b4ea.jpg": "/game-art/call-of-duty/achievements/bd970842650295550c88.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e787578182bf475de654ff869d7a76204d7358c0.jpg": "/game-art/call-of-duty/achievements/5d72276ec7235d317174.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0174240168f53900f133adf792fe1435ee1b0ea1.jpg": "/game-art/call-of-duty/achievements/7cde51a094d2ba37d596.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c7abcb4db7eae619b4b94affd18b152fb039fe1f.jpg": "/game-art/call-of-duty/achievements/2776bc87b100fcf92325.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fdf433700a6079bd2af957abe935150557992f31.jpg": "/game-art/call-of-duty/achievements/59803f6ef69ae9c0b923.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6a11b9adabb47de14ec6e99d57cd08fd05c7638b.jpg": "/game-art/call-of-duty/achievements/43bf5dd8b3e0c059e922.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f7c7b9405060e89852c1fdd1b98cd99fd4556ee6.jpg": "/game-art/call-of-duty/achievements/51ad1c409df815178212.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d1cd031ca9d9664353b45bfea4fd6be7da9c0498.jpg": "/game-art/call-of-duty/achievements/14e430fa2934e973aa36.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e7e98e8582812646a25139106f785cbd75520f63.jpg": "/game-art/call-of-duty/achievements/2b8350b0eae8221c10f8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9f05aef05e025f2b3562f02a3879a150188beaa9.jpg": "/game-art/call-of-duty/achievements/9fc62e9028597c00592e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/65010f7eb16e517591dc2b62da2a907475033cdd.jpg": "/game-art/call-of-duty/achievements/326861e9806de8f193a8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/13c39f9153e906d20d6fd4526884deb3b08a13a1.jpg": "/game-art/call-of-duty/achievements/ed1fc961b8cae53a42df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4b6c8ab4e761956616be5b79ec00f3df015162d.jpg": "/game-art/call-of-duty/achievements/1884707001ee9093877b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/df54112c58d577e0c021905122dcd40b2f6e7a98.jpg": "/game-art/call-of-duty/achievements/1afc6d1d8574b1961ca8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e794b4b7a3adcaf08c50323b1a019fa34eed3632.jpg": "/game-art/call-of-duty/achievements/fae378553e0ee2f50a9d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eb1ca331fcaaa3e1ddf481375bebff75a3ee8c76.jpg": "/game-art/call-of-duty/achievements/b1771805712e9d7dca3f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f6db79f0db66c4ff5998b54f392bc5f6671e1e62.jpg": "/game-art/call-of-duty/achievements/033de94bcc4b798ef4c6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/804df5f215f8b28b76e370f636664b803e9cca52.jpg": "/game-art/call-of-duty/achievements/754dbd600cc2e348d91e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2264003a9af6d55e694d9ebfb8c4be90678c9a18.jpg": "/game-art/call-of-duty/achievements/7a11fd527ab181f42e46.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/afdb278ecf2d8812f2eb4e2ba1916746fac6b715.jpg": "/game-art/call-of-duty/achievements/8a30f1adc1bc6d98f62a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5aa426a557d386edacc8c53a16f31538643fc52a.jpg": "/game-art/call-of-duty/achievements/ed424ec96f90c53b8412.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1f55129c4a789aeac4909e1d61618b5ea67a6c95.jpg": "/game-art/call-of-duty/achievements/0d96a1f789220f3ee488.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e33837825d9424f56fcef9fe51e4f7cd8c7ad661.jpg": "/game-art/call-of-duty/achievements/692b9a85693cd1405c98.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/b829035e663ee531e0603a0506f2247cc13e5990.jpg": "/game-art/call-of-duty/achievements/a4dac4e7d2f8011e0a34.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6b396a5ecb13f571bac9d485356927f286048727.jpg": "/game-art/call-of-duty/achievements/b601313d7027944c14ff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f5204549ec2b2350fe2abb39f7b31106f0eee9d.jpg": "/game-art/call-of-duty/achievements/5423e6e0220bf8389f93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/6d40e8236f865b88e62f5b8f81683733b190de10.jpg": "/game-art/call-of-duty/achievements/464c02401563b468757a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/444ca3ad0ffcd78d48869b996ac0c3f28e8a3892.jpg": "/game-art/call-of-duty/achievements/f21af58534176e7cc2ae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9de932c337c236172603c5380673c6c02be47e56.jpg": "/game-art/call-of-duty/achievements/87efada8bf8cd21d007e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/81828d6e7db5cd1f3e05061f8cb2c2ff212bf021.jpg": "/game-art/call-of-duty/achievements/00fe9ca1edc2bf6ef2c7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/a3a798f1259d54c816478e6636dfb812faa38cd3.jpg": "/game-art/call-of-duty/achievements/017d310d77f9ce71f34d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d3fd3f4a6d1160e7fca344c7153a3c1c3790e20d.jpg": "/game-art/call-of-duty/achievements/89bc701b69649a38c36c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/97caaed86660711ef4d92f431057ed5f0ed37ab2.jpg": "/game-art/call-of-duty/achievements/e9040935e077d69a190a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9a09587009ade2752f099e19c947c7ba6b3edbf4.jpg": "/game-art/call-of-duty/achievements/516471bdf6e23a7c3525.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d24857c1f2837e608c8dc19bdd780cf11dceb234.jpg": "/game-art/call-of-duty/achievements/f8d28a760c778fb16f3b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d4cd1139dbfda102e4b2865ca8fdd2d2f46b1e3a.jpg": "/game-art/call-of-duty/achievements/0a0f8b7255d63297ad28.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f51760b958da5c541c968359c4904ae65b577eb.jpg": "/game-art/call-of-duty/achievements/304f33ab3c88f3c4bb61.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1f41f3bdd8749e0f4893ba61eed72f52d5cacb6f.jpg": "/game-art/call-of-duty/achievements/a22e8f846d1302897f3b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/14db187aa9805219b5e7665953e2ff5370eb4bf1.jpg": "/game-art/call-of-duty/achievements/34a87af7e5ed992e5f56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe4b494bd12470168e58611cc5848fad5af4c556.jpg": "/game-art/call-of-duty/achievements/47f3c124d30542618467.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/cd0fd8249e28ab27c9ad41bda381164ebb24bdbe.jpg": "/game-art/call-of-duty/achievements/6e28b230d5c7f0ccfd98.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b77eee595f7c4d33efbbd156cd05b91f2137d504.jpg": "/game-art/call-of-duty/achievements/8078c43246474ae807e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/8c0b886c75e4c8aa9c7140f7138b1139625a14d4.jpg": "/game-art/call-of-duty/achievements/1e444cfdba5372cf8d80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f0d225a5898fbdbf0320519a2e9269232ee0b2a1.jpg": "/game-art/call-of-duty/achievements/52cb1a51cb4f0b4a579c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bd1fa843cba1aeaea05401a2012de9f626cdea56.jpg": "/game-art/call-of-duty/achievements/173b27753257df49245a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe251e95bb30bf3e566dba7a9a2764588aca5303.jpg": "/game-art/call-of-duty/achievements/325c34e0be02eb788bce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/4d0bb5694f2137cfa4bd6b45c50dfde39974e797.jpg": "/game-art/call-of-duty/achievements/91a62cd332f49a9dbcbb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b11c309f8f71d56743791e9066bf507bce47089d.jpg": "/game-art/call-of-duty/achievements/7bc5d0d30105499b4787.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/c83c48ee231f23865e1564bd9dd609d159e2dc78.jpg": "/game-art/call-of-duty/achievements/786a6fbe1c805696cbeb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f46558af9e3f8d4c34e6d910ddcd06899d7668dd.jpg": "/game-art/call-of-duty/achievements/46d491e0a9b3ef87b4c3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f1bd0dc5211c6469c2eb8914a76376b7c9587da2.jpg": "/game-art/call-of-duty/achievements/35bbd7b64edea5f0dbcc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/e415053365c5cd2a794422e6e6e82b7447695978.jpg": "/game-art/call-of-duty/achievements/c5b97fe5c00980e17812.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bf5061bb9b4d88a78819244cc9cb4fee608d8e4c.jpg": "/game-art/call-of-duty/achievements/e38ea5af67911aaa1672.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ce15e88ab93d982b480395308ff8d3a51f4bfd09.jpg": "/game-art/call-of-duty/achievements/0d6e0f9af9b044891c85.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/53e20712d27d0fa30f6389683703fec90785b9f2.jpg": "/game-art/call-of-duty/achievements/fde64e9607353ca56656.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/3aab6e911ce76a3ae861515b15eab3880fbed914.jpg": "/game-art/call-of-duty/achievements/9fa641633b88a769124a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/0ceaafe4e56dc6a5be562d6695fd556c896f1a6e.jpg": "/game-art/call-of-duty/achievements/dd4cde7eaa47be497800.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1eeeda536f7fcca06176e03a8c8d0d673f93db81.jpg": "/game-art/call-of-duty/achievements/5e6c05ca59d43672b9e6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/062937d06c763afe5c917ce8c6ff2a3d8c1a10c7.jpg": "/game-art/call-of-duty/achievements/dcd3cfe2c0164cda451f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ac5a08806508f32eaa1e8ee3b630e28c34fe8a8e.jpg": "/game-art/call-of-duty/achievements/26a745ed804f7c028b9e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fafcdac24818ab3ea5fb9b062822e315636d7ec1.jpg": "/game-art/call-of-duty/achievements/96ad629fae03dc395189.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3da38c567f9e948f20dc472fdb59f407b96dc8b2.jpg": "/game-art/call-of-duty/achievements/5324eff1e27445cda337.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6a78ffd1b01c0db1b7efc5d4be32d98eea5c4e8e.jpg": "/game-art/call-of-duty/achievements/845b6b03c0a95993479f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/c56dac0afdaf369cc301c0067d1988bb74b67618.jpg": "/game-art/call-of-duty/achievements/47d8dd228ad855fcea24.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b2a1593fe4240b9c6a6f4dd69965e33d5f0abe1.jpg": "/game-art/call-of-duty/achievements/73a2389b03f7587fc0e5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d1f38005260fe5598668227f322d4fbfc955a590.jpg": "/game-art/call-of-duty/achievements/aadf5c23e728238bb9c5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8007c03be77aba99d7b1cc88e02a020118047bde.jpg": "/game-art/call-of-duty/achievements/1c4e1eb7189ca710164b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3e8727718b0f19b5faec8c42fab0b862b4b63481.jpg": "/game-art/call-of-duty/achievements/413f83285d53ffcae2fd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e7405eabbe8111af18d2edf6ff029482b2b700fb.jpg": "/game-art/call-of-duty/achievements/26b06ce878eff10b73bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6930db97368989cd6fd99cc8875511008f80f9ab.jpg": "/game-art/call-of-duty/achievements/6e906dc87a721d88e3bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/066c58519c94120a7586b048285be0e03f2eac8f.jpg": "/game-art/call-of-duty/achievements/5b39f17436deb4386859.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9f473cb021b4e2a466eaabdd43e70b0d4bcc33c6.jpg": "/game-art/call-of-duty/achievements/06adf873abc0e85ec5c0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ea7184e640b125cf66a0b41f50782e20141d149.jpg": "/game-art/call-of-duty/achievements/0f588f009483c1119c89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0d7fb5796c7fed3697cf1e005f9cf771d8c54d83.jpg": "/game-art/call-of-duty/achievements/fcb7c46cd797564a698f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dd125e0c672897afd49f58d916a6b30508d5cb8f.jpg": "/game-art/call-of-duty/achievements/8bf89d4fa5608647b7fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6215cea00d06af99fc481233e5ae205d2fd4cecd.jpg": "/game-art/call-of-duty/achievements/0923efbeb3f152d328be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ed6a97188c019b9c5ad79ec923756f29ba6e4c4.jpg": "/game-art/call-of-duty/achievements/5155ebec3a8d0596e4db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/b10776bf82f624ffa3266e371074ac5f990a2f21.jpg": "/game-art/call-of-duty/achievements/ab9164941f8672bc8abb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d92a57a9bdb0215bbf025f1ebfeef31b86808b74.jpg": "/game-art/call-of-duty/achievements/741578149080d68ea0cf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/35abebfdd66507398c87c1fb106bd847f29cd103.jpg": "/game-art/call-of-duty/achievements/60315f55775a3fce1077.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/714ebbc054d4a315f36b603047be752e3f03d6e0.jpg": "/game-art/call-of-duty/achievements/df765b3d7d4bf9e8daf4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e001bbd75e459f1a3698594ae4d906e637e85505.jpg": "/game-art/call-of-duty/achievements/6b6d3959856e3f9ca287.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/27b2f66216c7326455b475b4968a371b579ee57c.jpg": "/game-art/call-of-duty/achievements/e61c3692670ed08908c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9bd50348aa430df867060e24d3770e48b09672dd.jpg": "/game-art/call-of-duty/achievements/c7b703f4789fdf88f2e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fcf0b94a7b7a9c7bc86fc5b797308a6139b7ef54.jpg": "/game-art/call-of-duty/achievements/d4451dddcf7b12a1e957.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/642bb5bf764fe94d8b8ed64221f81ea418dd67f8.jpg": "/game-art/call-of-duty/achievements/b98dcb94df80f1203e69.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/2830777cd6e2ca860803a580a507927315639bdb.jpg": "/game-art/call-of-duty/achievements/e7684dc386168bb855ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/f317858e66eae25497d0038dc802403ca25a6d69.jpg": "/game-art/call-of-duty/achievements/e3d5603aaef9f9181396.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/77d79b47cc9d7a1f75250e9b568e35d6aa9c5b0d.jpg": "/game-art/call-of-duty/achievements/3606dac327d5e7c2f002.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9a2332cefa0811232da7eb65cce6babdd53e17aa.jpg": "/game-art/call-of-duty/achievements/373fb8fa42b25f2ff2f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dfbef312f1d2fcfeb4003204c0318041cf40bbac.jpg": "/game-art/call-of-duty/achievements/d2fa391ff5e1ba047d69.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8c653f390740da89eea0a708fea7a073f6042dc8.jpg": "/game-art/call-of-duty/achievements/6f0bcb55520c5f4ee6d6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/143591c3bd783549dec12e594ca66eae0a7ab3b9.jpg": "/game-art/call-of-duty/achievements/5bb20f6fc96f66d387da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a46c13da4c9b6ed4e1477231c8b36349fc808f0b.jpg": "/game-art/call-of-duty/achievements/ffaffffefe23de4af17e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a0f7fc7d8cf212d04fa86a806791f43ee8bb7c10.jpg": "/game-art/call-of-duty/achievements/7b4336d95ad38bdceb14.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8e0e68c0f8fc9c69b0e5fd7dc50fdd9c6b2335b5.jpg": "/game-art/call-of-duty/achievements/8ebc6e71dbc8afa226c6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d2bcd4fcee0e57475afca3dcba7809540521ec20.jpg": "/game-art/call-of-duty/achievements/178528d63e0791dfa11d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/146e6d1394fea265d9850d6a482d643fa55cf8bd.jpg": "/game-art/call-of-duty/achievements/c0fdcae2d2e23d569eb4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/725b6f81036fe2e27e105e7f2d5fa569af5a0300.jpg": "/game-art/call-of-duty/achievements/9179e207bd077972ff97.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8a0ee0fc42268e7308d0cbe121944d95647baa9a.jpg": "/game-art/call-of-duty/achievements/f26378d314c0b4bf71f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/20e57a0d1b40c952c8af700c8b5cee0cc12b4cd8.jpg": "/game-art/call-of-duty/achievements/807b6d5288ea4e99c540.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/531e5e8aab594ff1e740f96f1063186c3e0f9567.jpg": "/game-art/call-of-duty/achievements/8a6ee3f02bbce9526be7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/adef655eb9161e85b9b75c5fe4341238df00ed1b.jpg": "/game-art/call-of-duty/achievements/c9e7867e6868bbdbd283.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5d099ed40cadcfa7996e9dc2bac903bbc5427830.jpg": "/game-art/call-of-duty/achievements/21dd1009bcc8472b26d1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0874326e2469ef5053d713487d2774adf4a83794.jpg": "/game-art/call-of-duty/achievements/50b2a42d676828bf2407.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/14c155b68e14d342bb1807ac44d7f96cf40d5066.jpg": "/game-art/call-of-duty/achievements/954434d2dbdb5fcc4343.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/31381253ea5db5f151e6b2ed375edbd17031b9de.jpg": "/game-art/call-of-duty/achievements/4738f4e73fdec7acb4c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/18e378dd7b85bb39e37a3b2c94b5673b2bc192ab.jpg": "/game-art/call-of-duty/achievements/923bcc83b36645e6ef85.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/15d7872c42fcbf3a829021296824cca0a505c6d2.jpg": "/game-art/call-of-duty/achievements/8f7ce98dd2e8abf78bd9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/1b97bf2a964c883d01b91d830d953078f731413a.jpg": "/game-art/call-of-duty/achievements/f9fe1161efe00ae8c571.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/70dab8649791478c2d9877465550534656917c0f.jpg": "/game-art/call-of-duty/achievements/048b05607127d4ee5b7b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/89b9d0d811451df6f7c57c25183ada2ee1ddc402.jpg": "/game-art/call-of-duty/achievements/73a862019bb136eb7a86.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/244a19d76e4c44187b88a0d30f2883631e7c82f5.jpg": "/game-art/call-of-duty/achievements/0f898edbf93d8f89dace.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b505784913a7dd3d8fd62174cdd76a0fb49c151.jpg": "/game-art/call-of-duty/achievements/8ca100618c6de814d6d5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/bc21875d3703b16313741bed719261fbb2d1ffdf.jpg": "/game-art/call-of-duty/achievements/f3d9e7b5a4fd35172686.webp"
}

1874
data/cod-art.json Normal file

File diff suppressed because it is too large Load Diff

36766
data/cod-challenges.json Normal file

File diff suppressed because it is too large Load Diff

90
data/cod-rank-art.json Normal file
View File

@@ -0,0 +1,90 @@
[
{
"game": "black-ops-2",
"prestige": 1,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661214/9FD6B03A8AB4ABE1D52ADEB9A14D8DBB72054910/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 2,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661581/1D2C4EAD9F7EBCE0955B6BBE0FEA78A643B817C7/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 3,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661865/38F7010DD1543AA9A08C584D1B90DD7C9C138BA1/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 4,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804662398/E36D618159E6474536E21AFD5873BB652658C010/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 5,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804662706/45B72C58EB62C84E20A50AEDF2539069BFBF4531/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 6,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804663180/AB4D41F43A0A68C3417F5DBB1ADA54365F217A49/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 7,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804668104/BC9E371A435F8CCD160A22ED70AABB4E747E78E4/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 8,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804668443/6985C3A4733C6CC5E19EEDC53E95FBE705752C70/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 9,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804669343/BCBD2BC8411DA714684CE5E01C24F4E3DA948F5F/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 10,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804670653/CD1052286A32FDA566E1849D3B6F02ADB22C45CC/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": "master",
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804671070/305118EBAF387C239C6CBF2D8C808E1E6305D3E7/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
}
]

39352
data/game-images.json Normal file

File diff suppressed because it is too large Load Diff

5568
data/pokemon-atlas.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3894
data/pokemon-map-art.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{
"note": "First release of each title; Red and Blue refer to the international versions. Japan versions are separate catalog entries.",
"source": "https://bulbapedia.bulbagarden.net/wiki/Pok%C3%A9mon_games",
"dates": {
"red-japan": "1996-02-27",
"green-japan": "1996-02-27",
"blue-japan": "1996-10-15",
"red": "1998-09-28",
"blue": "1998-09-28",
"yellow": "1998-09-12",
"gold": "1999-11-21",
"silver": "1999-11-21",
"crystal": "2000-12-14",
"ruby": "2002-11-21",
"sapphire": "2002-11-21",
"colosseum": "2003-11-21",
"firered": "2004-01-29",
"leafgreen": "2004-01-29",
"emerald": "2004-09-16",
"xd": "2005-08-04",
"diamond": "2006-09-28",
"pearl": "2006-09-28",
"platinum": "2008-09-13",
"heartgold": "2009-09-12",
"soulsilver": "2009-09-12",
"black": "2010-09-18",
"white": "2010-09-18",
"black-2": "2012-06-23",
"white-2": "2012-06-23",
"x": "2013-10-12",
"y": "2013-10-12",
"omega-ruby": "2014-11-21",
"alpha-sapphire": "2014-11-21",
"sun": "2016-11-18",
"moon": "2016-11-18",
"ultra-sun": "2017-11-17",
"ultra-moon": "2017-11-17",
"lets-go-pikachu": "2018-11-16",
"lets-go-eevee": "2018-11-16",
"sword": "2019-11-15",
"shield": "2019-11-15",
"the-isle-of-armor-sword": "2020-06-17",
"the-isle-of-armor-shield": "2020-06-17",
"the-crown-tundra-sword": "2020-10-22",
"the-crown-tundra-shield": "2020-10-22",
"brilliant-diamond": "2021-11-19",
"shining-pearl": "2021-11-19",
"legends-arceus": "2022-01-28",
"scarlet": "2022-11-18",
"violet": "2022-11-18",
"the-teal-mask-scarlet": "2023-09-13",
"the-teal-mask-violet": "2023-09-13",
"the-indigo-disk-scarlet": "2023-12-14",
"the-indigo-disk-violet": "2023-12-14",
"legends-za": "2025-10-16",
"mega-dimension": "2025-12-10",
"champions": "2026-04-08"
}
}

File diff suppressed because one or more lines are too long

132
docs/game-tracker.md Normal file
View File

@@ -0,0 +1,132 @@
# Game tracker
The development database has the journal migration and catalog imports applied. Production and staging need the same additive migration and seeds before deploying this code:
```sh
bun run db:migrate:tracker-journal
bun run db:seed:research
bun run db:seed:challenges
bun run db:seed:art
```
These commands require `DB_URL`. They build on the existing Pokémon and Call of Duty catalogs. They preserve user catches, teams, ranks and challenge progress. Removed challenge definitions are retired only if nobody has saved progress for them. Deployment preflight checks the new tables and populated research catalogs.
## Local images and library layout
The tracker includes locally stored WebP pictures for **all 53 Pokémon entries, all seven COD entries and osu!**, plus the series logos. Most games use box art; expansions use their promotional artwork. Covers keep their original aspect ratio. No supplied files or remote image hotlinks are required. Replace a WebP at the same path to use your own art.
`data/game-images.json` records download URLs, original sources and credits. `data/pokemon-releases.json` records chronology. Game rights remain with Nintendo/The Pokémon Company, Activision, and ppy; these assets are not claimed as site-owned or openly licensed. The public `/projects/game-tracker/credits` page links the sources.
```sh
# Pillow required; map rebuilding also needs beautifulsoup4.
npm run catalog:images
npm run catalog:map-art
npm run check:images
```
The downloader preserves transparency/aspect ratio, writes WebP atomically and reuses valid existing files. PokéAPI sprite URLs and Libretro box-art URLs are pinned to source commits. The three Scarlet/Violet maps are assembled from the original regional map tiles by `build-pokemon-map-art.py`. Map downloads are cached in `/tmp/game-map-art`.
The library has one column per series on desktop. Pokémon has a main journal link and only the games explicitly chosen in rules, arranged chronologically in exactly two columns; COD is chronological and osu! is a single entry. Large collections can scroll vertically so covers and names remain readable. At mobile sizes or high browser zoom, the series flow vertically. In the Pokémon journal, cover-only buttons stay visible across the header. They use one row for small selections and two rows for larger selections; the active cover is highlighted. Narrow screens can scroll the two-row cover strip horizontally.
## Pokémon
Open `/projects/game-tracker/pokemon?game=platinum`. A playthrough remembers its stage and selected location when **Save my place** is pressed. The adventure view hides encounters requiring radar, swarms, another cartridge or similar special conditions by default; these can be revealed with a checkbox. This is a convenience filter, not a full simulation of story gates or encounter eligibility.
The Pokédex defaults to a regional dex where the source has one. National order remains available. Map encounters show methods, areas, levels, rates and conditions. Species details include evolution information, stats, abilities, biology and searchable game-specific learnsets.
The companion layout keeps the current team and an animated completion ribbon above tabs for the map, Pokédex, PC and milestones. Dex and PC pages show smaller batches appropriate to the device. Desktop shows the map beside a fitted grid containing every species matching the selected area and filters, without an encounter-list scrollbar. On smaller screens, switch between the region map and area Pokémon. **Expand encounters** opens a larger grid; tapping any portrait opens encounter details and catch registration. Broad locations with hundreds of species use smaller portraits; area/floor filters and the expanded view make these easier to explore.
Register each individual catch with a nickname, current level, four moves, party slot, shiny flag, acquisition method, location, date and notes. The PC is sorted in National Pokédex order and shows the **first registered record** per species, even if a later record has an earlier catch date. Open species research to browse all duplicates. Assigning an occupied team slot moves the previous occupant to the PC. The six slots and caught checkmark update in one transaction; catch and checkmark mutations use the same per-user/game lock.
Existing caught checkmarks remain intact. A checkmark without an individual record offers **Add catch details**. Deleting a catch removes its individual details and party slot but retains the historical caught checkmark. Clear that checkmark separately after removing all records for the species. Species are separate records: after evolving a companion, record the evolved species and move it into the relevant team slot.
Badges, Elite Four, champions, selected bosses and collection milestones vary by game. Custom milestones cover personal goals and events outside the default list. Progress is split into story, postgame, and trades/transfers/events.
### Current catalog coverage
| Data | Records |
| ------------------------------------- | ------: |
| Species and species research | 1,025 |
| Game catalog | 53 |
| Regional dex/game/species assignments | 16,044 |
| Moves | 937 |
| Species/version-group learnsets | 11,331 |
| Existing encounter rows | 67,282 |
| Existing locations | 1,104 |
`data/pokemon-research.json` uses the same pinned [PokéAPI dataset](https://github.com/PokeAPI/pokeapi/tree/575291cdb197a7e3a320297be276c9de4ef8401a) as the existing catalog. Regenerate with `bun run catalog:research`, then seed. Learn methods and levels are version-specific; battle stats, move power/accuracy/PP and biology use the snapshot’s default forms and current values. Alternate forms and historical stat changes are not yet a complete catalog. Missing encounters are not evidence of unavailability, and recent titles have thinner source coverage.
### Atlas coverage
All **13 supported regions** now use local map images: Kanto (including the Sevii Islands), Johto, Hoenn, Sinnoh, Unova, Kalos, Alola, Galar (including its expansions), Hisui, Paldea, Orre, Kitakami and Blueberry Academy. The SVG schematic rendering has been removed. Maps preserve their aspect ratios and support zoom, dragging, keyboard scrolling, clickable markers and optional town labels.
`data/pokemon-map-art.json` stores image dimensions and marker positions. Kanto, Johto, Hoenn, Sinnoh, Unova and Kalos use the location coordinates associated with the [Serebii map images](https://www.serebii.net/pokearth/). Orre uses a Pokémon XD game screenshot. Paldea, Kitakami and the Terarium use assembled in-game map tiles. The remaining landmark positions are manually placed and labeled approximate. Images are regional references shared by titles in that region, not an exhaustive collection of every game's map revision or every interior map. The searchable location list continues to provide imported encounters outside the shown markers.
The original `pokemon-atlas.json` retains location and coordinate reference data from [pret](https://github.com/pret). Its build script does not control the new image backgrounds.
Pokémon sprites, shiny sprites and official artwork are stored as **3,075 local WebP images**, covering every one of the catalog's 1,025 species.
## Completion and discovery
The library and individual game pages show shadcn progress bars with two decimal places, so a single new entry remains visible even in a large catalog. Bars animate between values and respect reduced-motion preferences. **All progress** in Pokémon opens the full collection, regional dex, story, badge, league, boss, postgame and transfer/event breakdown.
Pokémon sightings are independent, per-user/per-game records. Use **seen** in the Pokédex or **Mark as seen** in an encounter dialog. Catches automatically record sightings; simply browsing the catalog never does. Historical caught checkmarks are backfilled as seen by the additive journal migration. A caught species cannot be marked unseen while its caught checkmark remains.
National caught/seen bars count each species once across the games selected in rules, against the full species catalog. Per-game collections include known acquisition methods (including trades/events/transfers) and exclude unconfirmed availability unless a real catch establishes it. In-game collection bars cover catching, evolution and breeding. Regional bars count distinct species across the imported regional dexes, without double-counting overlap.
Game completion balances collection, story, postgame and transfer/event progress equally where data exists. The story category includes a story-cleared checkpoint. **Mark story complete** records that checkpoint without inventing individual badge records; completing every default story milestone also infers it. Games completed counts cleared stories, not 100% collections. Personal milestones have a separate bar, so adding a custom goal does not change the main denominator. Changing selected games changes the scope; removing data can lower percentages.
COD completion equally weights cumulative player/weapon levels, weapon mastery, achievements and challenges where a denominator is known. Each recorded level counts immediately: `prestige × max level + current level`, out of `(max prestige + 1) × max level`. For example, M27 P0 L17 is 17/51, P1 L1 is 18/51, and P2 L17 is 51/51. Missing ranks contribute zero; launchers/melee without weapon levels and games without verified caps are excluded from rank denominators. Library and game totals share the same calculation; historical out-of-range records are bounded for display calculations, never rewritten. Each challenge contributes its fraction of a known target, or a completed/not-completed value when no target exists. Derived Diamond/Dark Matter tiers are shown separately and are not counted a second time in overall completion. Weapon cards, achievement groups, individual challenges and the full class breakdown show their own percentages. Black Ops II's catalog records a player cap of level 55 and prestige 10 (Prestige Master), a weapon prestige cap of 2, and attachment-rank caps by weapon class. Assault rifles cap at 17, SMGs and LMGs at 16, shotguns, pistols and most sniper rifles at 10, Ballista at 11 and Crossbow at 6. Launchers and the other melee specials have no attachment-rank progression. These caps are enforced by both the controls and server actions. osu! shows all-goal, pass, FC and accuracy completion from saved goals.
The BO2 cap list follows the [weapon-level unlock table](https://w.atwiki.jp/blackops2_cod/pages/47.html); the player Prestige Master threshold is documented by [Activision Support](https://support.activision.com/call-of-duty--black-ops-ii/articles/achieving-prestige-master-in-call-of-duty-black-ops-ii). The numeric player cap represents the level-55 prestige cycle; prestige 10 at level 55 displays the Master emblem.
Re-run `bun run db:migrate:tracker-journal` before deploying this update: it adds `pokemon_seen` and `pokemon_journeys.completed` without deleting existing data. Deployment preflight checks both.
## Call of Duty
Player and weapon levels/prestiges are separate from camo mastery. The challenges tab provides search, category filters, numeric progress where a source gives a target, completion checkboxes and linked requirements/rewards.
Weapon cards combine artwork, name, level, prestige and camo progress. Level and prestige **− / +** buttons save immediately, display save failures, and use atomic database increments to preserve simultaneous updates from multiple devices. Prestige + starts the next prestige at level 1. Tap the level number for direct editing. Positive prestige infers that maximum level has been reached at least once, and existing max-level checkmarks remain recognized. Black Ops II player and weapon level/prestige caps are enforced in both controls and server actions. The shared COD companion board groups weapons by class. At desktop sizes of 1280×720 and above, all 40 BO2 weapons fit on one screen; other games use 32-weapon pages. Narrow screens use a readable vertical layout. Every gun has a still image. Select a weapon name to edit camos and use full rank controls; the compact + button saves a level immediately. All ten BO2 prestige emblems and the Master emblem are served locally beside a level badge. Hide finished requires every prestige run and applicable camo milestone, not just a first-run max-level checkmark. Overall mastery statistics are available in the **Progress overview** dialog.
The imported snapshot has **3,674 challenges/operations**: Black Ops 1,489; Black Ops II 526; Ghosts 31; Advanced Warfare 75; Black Ops III 860; Infinite Warfare 329; WWII 364. This is a partial, attributed catalog, not an exhaustive inventory. Ghosts source rows with missing tier thresholds are excluded. Rewards without source text are left blank, and many emblem/calling-card pictures remain unavailable.
`scripts/build-cod-challenges.py` imports rendered, line-numbered wiki snapshots with `--snapshots <directory>`. The checked-in JSON is the deployment input; regenerating from newer research requires supplying source snapshots and reviewing ambiguous rows. Stable keys use game/category/name, so review renamed source headings before importing to avoid splitting progress.
208 locally served WebP weapon pictures are registered in `data/cod-art.json`, covering all seven games, with a larger shadcn dialog preview and original image credits. The base weapon galleries for Black Ops III and WWII are complete. Other games remain partial. Images include renders, game screenshots, named variants and real weapon reference photos, identified in the preview credits. All 618 cataloged achievement icons have local WebP copies. Add verified images and manifest entries, then run `db:seed:art`; the regular COD catalog seed also retains manifest images.
| Base weapon picture coverage | Pictures / weapons |
| ---------------------------- | -----------------: |
| Black Ops | 32 / 42 |
| Black Ops II | 38 / 39 |
| Ghosts | 20 / 40 |
| Advanced Warfare | 34 / 35 |
| Black Ops III | 31 / 31 |
| Infinite Warfare | 20 / 35 |
| WWII | 31 / 31 |
Additional pictures include BO2’s DLC Peacekeeper and Ghosts’ DLC Maverick-A2. BO2’s Combat Knife is the remaining entry without artwork; all guns have pictures. These counts do not imply complete DLC, camo, emblem or calling-card artwork.
Challenge text is adapted from **Call of Duty Wiki contributors**, with per-record source links. The adapted challenge dataset retains the source’s [CC BY-SA terms](https://www.fandom.com/licensing), per [the wiki’s copyright policy](https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights). Game artwork retains its original owners’ rights; it is not covered by the challenge-text license. `data/cod-art.json` records each image’s source, download URL and credit. Pokémon artwork/sprites are downloaded from [PokéAPI’s sprite repository](https://github.com/PokeAPI/sprites) and served locally.
## osu!
`/projects/game-tracker/osu` contains beatmap goals, memorable scores and training sessions for osu!, taiko, catch and mania. Track pass/FC/accuracy/custom goals, score links, pp, combo, misses, mods and notes. Practice records have a skill, map BPM, playback rate and duration. The chart compares qualifying sessions, with a minimum duration filter. Effective BPM is map BPM multiplied by playback rate. These are manual journal entries; there is no automatic account/score import.
## Shared components and validation
Pokémon rules/research/catches, COD ranks/artwork, osu entries, Animaldex details, money forms, article creation and list/item creation/editing use shadcn Dialog. Destructive list/article/language reset controls use Alert Dialog. Existing custom selects use Native Select. Money/list modal fields use shared inputs, textareas and checkboxes. Native disclosure sections that explain content are retained.
```sh
npm run check
npm run build
npm run test:unit -- --run
npm run test:tracker
```
Tracker browser tests use the migrated **local development** database, start their own server on port 4174, create temporary users and delete them afterward. They check Platinum routes and dex ordering, duplicate catch policy, team slot reassignment, ownership, rank/challenge persistence, osu training calculations, dialog keyboard behavior, mobile page widths, local image loading, picture-button selection, and a readable two-column 53-game Pokémon library. Companion checks cover immediate weapon adjustments, concurrent updates, failed-save recovery, prestige inference, all-species encounter grid fitting, expanded encounters and catch registration from the map. Progress checks cover persisted sightings, caught/seen deduplication, completed stories, partial COD challenges, osu! goal completion, rules-scoped totals and direct cover switching. Do not point `test:tracker` at a shared or production database.
## Deployment verification
`deploy-remote.sh` takes a PostgreSQL-version-matched custom-format backup and validates its archive before running the repeatable migrations and catalog seeds on each environment. The COD seed retains stable IDs and preserves stale items with either checklist progress **or weapon ranks**. No saved player/weapon levels are reset or clamped in storage. `audit-tracker-data.ts` prints only row counts and fingerprints for saved tracker records; compare before/after each deployment (newly added journal tables and backfilled sightings are expected). BO2 catalog caps, weapon counts and image coverage are checked by deployment preflight.
BO2 data sources: [attachment level unlocks](https://w.atwiki.jp/blackops2_cod/pages/47.html), [Peacekeeper attachments](https://callofduty.fandom.com/wiki/Peacekeeper), and [weapon prestige](https://callofduty.fandom.com/wiki/Prestige_Mode). Artwork URLs and credits are in `data/cod-art.json` and `data/cod-rank-art.json` and included in the public credits page.

View File

@@ -20,7 +20,7 @@ export default [
}
},
{
files: ['**/*.svelte'],
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: {
parser: ts.parser

975
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,28 +18,50 @@
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-catalog.ts",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts",
"db:migrate:foundation": "bun scripts/migrate-foundation.ts",
"db:migrate:money-tracker": "bun scripts/migrate-money-tracker.ts",
"db:migrate:game-tracker": "bun scripts/migrate-game-tracker.ts",
"db:migrate:cs-prices": "bun scripts/migrate-cs-prices.ts",
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"auth:role": "bun scripts/set-admin.ts",
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts"
"catalog:cod": "python3 scripts/build-cod-catalog.py && prettier --write data/call-of-duty.json",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts",
"db:migrate:tracker-journal": "bun scripts/migrate-tracker-journal.ts",
"catalog:research": "python3 scripts/build-pokemon-research.py",
"db:seed:research": "bun scripts/seed-pokemon-research.ts",
"db:seed:challenges": "bun scripts/seed-cod-challenges.ts",
"test:tracker": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.tracker.config.ts",
"db:seed:art": "bun scripts/seed-cod-art.ts",
"catalog:atlas": "python3 scripts/build-pokemon-atlas.py",
"catalog:images": "python3 scripts/download-game-images.py",
"catalog:map-art": "python3 scripts/build-pokemon-map-art.py",
"check:images": "python3 scripts/check-game-images.py"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/simple-icons": "^1.2.94",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.62.1",
"@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1",
"@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.19",
"cn": "^0.4.0",
"drizzle-kit": "^1.0.0-rc.4",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
@@ -48,11 +70,13 @@
"lucide-svelte": "^1.0.1",
"prettier": "3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"shadcn-svelte": "^1.7.0",
"svelte": "^5.57.0",
"svelte-check": "4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~6",
"typescript-eslint": "8.68.0",
"unplugin-icons": "^23.0.1",
@@ -67,12 +91,16 @@
"@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85",
"d3-geo": "^3.1.1",
"drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0",
"postgres": "^3.4.9",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4"
}
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
process.env.TRACKER_E2E = '1';
export default defineConfig({
testDir: 'tests',
testMatch: 'game-tracker.test.ts',
workers: 1,
use: { baseURL: 'http://127.0.0.1:4174' },
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4174 --strictPort',
url: 'http://127.0.0.1:4174/login',
reuseExistingServer: false,
env: { ORIGIN: 'http://127.0.0.1:4174', BETTER_AUTH_URL: 'http://127.0.0.1:4174' },
timeout: 60000
}
});

View File

@@ -0,0 +1,33 @@
import postgres from 'postgres';
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
const tables = [
'user_entries',
'user_games',
'catch_em_all_settings',
'pokemon_journeys',
'pokemon_catches',
'pokemon_milestones',
'pokemon_seen',
'tracker_progress',
'tracker_ranks',
'tracker_weapon_ranks',
'tracker_challenge_progress',
'osu_entries'
];
try {
const result: Record<string, unknown> = {};
for (const table of tables) {
const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`;
if (!exists.present) continue;
const [row] = await client.unsafe(`select count(*)::int as rows,
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
from (select row_to_json(t)::text as record from "${table}" t) s`);
result[table] = row;
}
console.log(JSON.stringify(result, null, 2));
} finally {
await client.end();
}

View File

@@ -0,0 +1,49 @@
"""Refresh the achievement lists in data/call-of-duty.json from Steam Community.
Weapons, classes and mastery rules in that file are hand-maintained; this only
replaces each game's `achievements` array with the public global achievement
pages of its Steam apps. Keys are `<app id>:<slug>` so progress survives renames
of descriptions or icons.
"""
import html
import json
from pathlib import Path
import re
import urllib.request
TARGET = Path(__file__).resolve().parent.parent / 'data/call-of-duty.json'
ROW = re.compile(r'<div class="achieveRow.*?<img src="([^"]+)".*?<h3>(.*?)</h3>\s*<h5>(.*?)</h5>', re.S)
def slug(text):
return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
def achievements(app):
request = urllib.request.Request(f"https://steamcommunity.com/stats/{app['id']}/achievements/?l=english",
headers={'User-Agent': 'stanrunge.dev game-tracker catalog builder'})
with urllib.request.urlopen(request, timeout=60) as response:
page = response.read().decode('utf-8')
rows, seen = [], {}
for icon, name, description in ROW.findall(page):
name, description = html.unescape(name).strip(), html.unescape(description).strip()
key = f"{app['id']}:{slug(name)}"
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
key += f'-{seen[key]}'
rows.append(dict(key=key, category=app['label'], name=name, description=description or None, icon=icon))
if not rows:
raise RuntimeError(f"No achievements parsed for Steam app {app['id']}")
return rows
def main():
catalog = json.loads(TARGET.read_text())
for game in catalog['games']:
game['achievements'] = [row for app in game['steamApps'] for row in achievements(app)]
print(f"{game['shortName']}: {len(game['achievements'])} achievements")
TARGET.write_text(json.dumps(catalog, ensure_ascii=False, indent='\t') + '\n')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,91 @@
"""Import attributable Call of Duty Wiki table snapshots into a factual challenge catalog.
Input is one or more rendered, line-numbered source snapshots per game, exported
by the research tool. Ambiguous or incomplete rows are skipped. No thresholds
are invented. Keys depend on category + name, never page line numbers.
Usage: python3 scripts/build-cod-challenges.py --snapshots /tmp/cod-wiki
The checked-in JSON is the deployable snapshot; this command does not need DB access.
"""
import argparse
import json
from pathlib import Path
import re
TITLES={'black-ops':'Call_of_Duty:_Black_Ops','black-ops-2':'Call_of_Duty:_Black_Ops_II','ghosts':'Operations_(challenges)/Call_of_Duty:_Ghosts','advanced-warfare':'Call_of_Duty:_Advanced_Warfare','black-ops-3':'Call_of_Duty:_Black_Ops_III','infinite-warfare':'Call_of_Duty:_Infinite_Warfare','wwii':'Call_of_Duty:_WWII'}
VERBS=r'(?:Get|Earn|Kill|Win|Complete|Destroy|Perform|Play|Achieve|Reach|Use|Capture|Defend|Survive|Assist|Shoot|Recover|Hack|Call in|Obtain|Unlock|Steal|Equip|Finish|Plant|Defuse|Deny|Pick up|Revive|Take|Absorb|Damage|Land|Hold|Score|Purchase|Make|Eliminate|Return|Collect|Prevent|Detonate|End|Resupply)'
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def clean(s):return re.sub(r'\s+',' ',s).strip().replace('[]','')
def lines_for(directory,game):
lines={}
for p in directory.glob(game+'*.txt'):
if p.name.split('--')[0].split('.txt')[0]!=game:continue
raw=p.read_text();raw=re.sub(r'cite[^†]+†([^†]+)(?:†[^]+)?',r'\1',raw)
for m in re.finditer(r'L(\d+):\s*(.*?)(?=L\d+:|$)',raw,re.S):
number=int(m[1]);text=m[2].split('-------------------------')[0].strip()
if number not in lines or len(text)>len(lines[number]):lines[number]=text
return sorted(lines.items())
def parse(directory,game):
rows=lines_for(directory,game);heads={};items={};previous='';previous_line=0
source='https://callofduty.fandom.com/wiki/'+('' if game=='ghosts' else 'Challenges/')+TITLES[game]
for number,line in rows:
line=line.replace('\xa0',' ')
heading=re.match(r'^(#{2,6})\s+(.+)',line)
if heading:
level=len(heading[1]);heads={k:v for k,v in heads.items() if k<level};heads[level]=clean(heading[2]);previous='';continue
if game=='ghosts':
# The source omits many tier thresholds. Only standalone, explicit operations
# are imported; unknown '#' counts are deliberately left out.
bullet=re.match(rf'^\s*\*\s+(.+?):\s*((?:{VERBS}|Fire|Force|Crush|Have|Be|Successfully|Infect)\b.+)$',line)
if bullet and heads and '#' not in bullet[2] and any('Operations' in h for h in heads.values()) and not any('References' in h or 'Trivia' in h for h in heads.values()):
name=clean(bullet[1]);requirement=clean(bullet[2]);category=' / '.join(heads[k] for k in sorted(heads))
key=slug(category)+':'+slug(name)
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=None,target=None,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
continue
if not heads or not line or line.startswith(('*','Note:','Challenge','Name ','Description','XP Reward','Image','This ','The ','After ','All ','I:','II:','III:','IV:','V:','VI:')):continue
if any(re.search(r'Contents|Trivia|References|Gallery|Navigation|Explore|Follow|Community|Advertise',h) for h in heads.values()):continue
# BO2 campaign objectives lack stable named table rows; exclude those.
if game=='black-ops-2' and number<705:continue
if game=='black-ops' and number<490:continue
match=re.match(rf'^(.{{2,100}}?)\s+({VERBS}\b.+)$',line)
if match:
name,rest=match.groups()
if re.match(rf'^{VERBS}\b',name) or 'Challenge How' in name:continue
elif game=='advanced-warfare' and previous and number-previous_line<5 and re.match(rf'^{VERBS}\b',line):
name,rest=previous,line
else:
if game=='advanced-warfare' and len(line)<70 and not re.search(r'\d|XP|Playercard|Emblem|Reticle',line):previous=clean(line);previous_line=number
continue
fields=re.split(r'\s{2,}',rest)
requirement=clean(fields[0]);rewards=fields[1:]
if len(fields)==1:
match_xp=re.search(r'\s+(\d[\d,]*)(?:\s*XP)?$',requirement)
if match_xp:rewards=[match_xp[1]+' XP'];requirement=requirement[:match_xp.start()]
reward=clean(' · '.join(r for r in rewards if r not in ['None','Image'] and not re.fullmatch(r'\d+',r)))
reward=re.sub(r'Image:\s*','',reward).replace('Image ','').strip(' ·')
if not reward and any('Image' in r for r in rewards):reward='Calling card (see source)'
if not requirement or len(requirement)>1000 or re.search(r'\bL\d+:',requirement):continue
# Numeric progress only when the requirement starts with an explicit count.
count=re.match(r'^(?:Get|Earn|Kill|Win|Destroy|Play|Capture|Complete|Absorb)\s+(\d[\d,]*)\s+',requirement)
target=int(count[1].replace(',','')) if count else None
category=' / '.join(heads[k] for k in sorted(heads))
if game=='black-ops':category='Multiplayer / '+category
name=clean(name).strip('* ')
if re.search(r'Image:|\bXP\b',name):continue # merged table cells, not a valid challenge name
key=slug(category)+':'+slug(name)
if key in items:continue
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=reward or None,target=target,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
return list(items.values())
def main():
parser=argparse.ArgumentParser();parser.add_argument('--snapshots',type=Path,required=True);args=parser.parse_args()
games={game:parse(args.snapshots,game) for game in TITLES}
result=dict(source=dict(name='Call of Duty Wiki contributors',license='CC BY-SA',licenseUrl='https://www.fandom.com/licensing',attribution='https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights',retrieved='2026-09-25',scope='Named challenge rows with explicit requirements. Incomplete and ambiguous source rows are excluded. Source coverage varies; these are not complete game inventories. Numeric targets are recorded only when explicit at the start of a requirement.'),games=games)
Path('data/cod-challenges.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
print({game:len(rows) for game,rows in games.items()})
if __name__=='__main__':main()

View File

@@ -0,0 +1,85 @@
"""Build the factual region atlas and authored overview maps.
Run from the repository root. Coordinate sources are cached in /tmp/pokemon-atlas.
Later regions are explicitly schematic and do not imply exact route geometry.
"""
import argparse, hashlib, json, re, urllib.request
from pathlib import Path
parser=argparse.ArgumentParser()
parser.add_argument('--cache',type=Path,default=Path('/tmp/pokemon-atlas'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
sources={
'pokeemerald-region.json':'https://raw.githubusercontent.com/pret/pokeemerald/master/src/data/region_map/region_map_sections.json',
'pokefirered-region.json':'https://raw.githubusercontent.com/pret/pokefirered/master/src/data/region_map/region_map_sections.json',
'platinum-map.json':'https://raw.githubusercontent.com/pret/pokeplatinum/main/res/town_map/town_map_data.json',
'johto-landmarks.asm':'https://raw.githubusercontent.com/pret/pokecrystal/master/data/maps/landmarks.asm'
}
checksums={'pokeemerald-region.json': '3a5f8c07c85c17f05cec06d049305962e9a9a1d2bd6eae935f3887866a4ad491', 'pokefirered-region.json': '82e456cbe86a57ebc4bd0d88d6e000e83e28340895ac038079c38d415bc6de5f', 'platinum-map.json': 'b5f6792e7993afa57df3d5b86c0af57488d23642576c4a224ba401bd762ba6b8', 'johto-landmarks.asm': 'bf61258379901daf794555b1a8f73bcf634d50ef3ca6e067f88f599b9df9816f'}
for filename,url in sources.items():
path=args.cache/filename
if not path.exists():
with urllib.request.urlopen(url,timeout=60) as response: path.write_bytes(response.read())
if hashlib.sha256(path.read_bytes()).hexdigest()!=checksums[filename]:
raise ValueError(f'{filename}: upstream coordinates changed; review the map and update its checksum before rebuilding')
from pathlib import Path
locations=json.load(open('data/pokemon-locations.json'))['locations']
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def location(key,region):
key=key.replace('eternia','eterna').replace('mount-coronet','mt-coronet').replace('tin-tower','bell-tower')
keys=[key,f'{region}-{key}',key.replace('route-','sea-route-'),f'{region}-{key.replace("route-","sea-route-")}']
return next((p for p in locations if p['slug'] in keys and (p['region'] or '').lower()==region),None)
def node(key,x,y,region,cells=None):
p=location(key,region)
return {'slug':p['slug'] if p else key,'name':p['name'] if p else key.replace('-',' ').title(),'x':x,'y':y,'kind':'town' if re.search('town|city|village|area$|league',key) else 'route' if 'route' in key else 'landmark','cells':cells or []}
regions={}
for region,file in [('hoenn',args.cache/'pokeemerald-region.json'),('kanto',args.cache/'pokefirered-region.json')]:
d=json.load(open(file))['map_sections'];nodes=[]
for p in d:
if 'x' not in p:continue
key=slug(p['name']);x=p['x'];y=p['y'];w=p['width'];h=p['height']
if region=='hoenn' and key=='none':break
if region=='kanto' and key=='one-island':break
nodes.append(node(key,x+w/2,y+h/2,region,[[x,y,w,h]]))
regions[region]={'name':region.title(),'width':32,'height':20,'nodes':nodes,'source':f'https://github.com/pret/poke{ "emerald" if region=="hoenn" else "firered" }/blob/master/src/data/region_map/region_map_sections.json','precision':'Game map coordinates'}
# Platinum town map blocks preserve branching paths and geographic placement.
d=json.load(open(args.cache/'platinum-map.json'));groups={}
for b in d['blocks']:
key=re.sub(r'_(north|south)$','',b['area'] or '').replace('_','-')
if not key:continue
groups.setdefault(key,[]).append([b['x'],b['z'],1,1])
if b.get('landmark') and b['landmark'].replace('_','-')!=key:
land=b['landmark'].replace('_','-')
if location(land,'sinnoh'):groups.setdefault(land,[]).append([b['x'],b['z'],1,1])
nodes=[node(k,sum(c[0]+.5 for c in cells)/len(cells),sum(c[1]+.5 for c in cells)/len(cells),'sinnoh',cells) for k,cells in groups.items()]
regions['sinnoh']={'name':'Sinnoh','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokeplatinum/blob/main/res/town_map/town_map_data.json','precision':'Platinum town map coordinates'}
s=(args.cache/'johto-landmarks.asm').read_text();nodes=[]
for x,y,key in re.findall(r'landmark\s+(\d+),\s*(\d+),\s*(\w+)Name',s):
if key=='PalletTown':break
name=re.sub(r'([a-z])([A-Z])',r'\1-\2',key);name=re.sub(r'([A-Za-z])(\d)',r'\1-\2',name)
nodes.append(node(name.lower(),int(x)/5,int(y)/5,'johto'))
regions['johto']={'name':'Johto','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokecrystal/blob/master/data/maps/landmarks.asm','precision':'Crystal landmark coordinates'}
# Later-region overview points: deliberately identified as schematic rather than exact route maps.
landmarks={
'unova':'Nuvema Town,23,26;Accumula Town,23,23;Striaton City,26,20;Nacrene City,21,20;Castelia City,16,23;Nimbasa City,16,16;Driftveil City,9,17;Mistralton City,7,10;Icirrus City,11,5;Opelucid City,20,5;Lacunosa Town,26,6;Undella Town,28,12;Black City,26,17;White Forest,28,18;Aspertia City,4,26;Floccesy Town,7,25;Virbank City,10,25;Humilau City,29,4;Pokemon League,28,1',
'kalos':'Vaniville Town,23,25;Aquacorde Town,23,22;Santalune City,24,19;Lumiose City,16,12;Camphrier Town,12,19;Ambrette Town,5,24;Cyllage City,4,18;Geosenge Town,3,12;Shalour City,6,8;Coumarine City,11,6;Laverre City,17,7;Dendemille Town,24,8;Anistar City,28,12;Couriway Town,28,20;Snowbelle City,27,25;Pokemon Village,24,28;Pokemon League,30,17',
'alola':'Iki Town,6,6;Hauoli City,3,9;Melemele Sea,6,12;Heahea City,22,10;Paniola Town,24,7;Royal Avenue,26,11;Konikoni City,21,13;Malie City,26,20;Tapu Village,22,23;Po Town,21,17;Mount Lanakila,21,21;Seafolk Village,5,23;Poni Wilds,4,20;Vast Poni Canyon,7,17;Aether Paradise,14,15',
'galar':'Postwick,15,27;Wedgehurst,15,24;Wild Area,15,20;Motostoke,15,17;Turffield,8,14;Hulbury,23,14;Hammerlocke,15,10;Stow on Side,6,10;Ballonlea,7,5;Circhester,25,8;Spikemuth,26,17;Wyndon,15,3;Isle of Armor,29,23;Crown Tundra,7,28',
'hisui':'Jubilife Village,6,15;Obsidian Fieldlands,9,22;Crimson Mirelands,19,22;Cobalt Coastlands,26,15;Coronet Highlands,16,13;Alabaster Icelands,16,5;Temple of Sinnoh,16,10;Prelude Beach,4,19',
'paldea':'Cabo Poco,17,28;Los Platos,17,24;Mesagoza,17,21;Cortondo,11,22;Artazon,24,22;Levincia,28,15;Cascarrafa,9,16;Porto Marinada,4,11;Medali,12,10;Montenevera,20,6;Alfornada,5,27;Glaseado Mountain,21,9;Casseroya Lake,9,6;Area Zero,17,15;Pokemon League,14,19',
'orre':'Phenac City,21,21;Pyrite Town,12,20;Agate Village,11,6;Mt Battle,22,4;Realgam Tower,19,15;The Under,10,23;Outskirt Stand,26,25;Snagem Hideout,27,15;Cipher Lab,27,8;Gateon Port,5,15;Pokemon HQ Lab,5,22;Rock Poke Spot,16,18;Oasis Poke Spot,23,11;Cave Poke Spot,16,10;Citadark Isle,4,28',
'kitakami':'Mossui Town,16,23;Loyalty Plaza,11,24;Apple Hills,9,21;Kitakami Hall,22,17;Oni Mountain,16,10;Crystal Pool,16,9;Timeless Woods,26,6;Fellhorn Gorge,26,14;Paradise Barrens,6,8;Revelers Road,20,21',
'blueberry':'Central Plaza,16,15;Savanna Biome,8,22;Coastal Biome,24,22;Canyon Biome,8,8;Polar Biome,24,8'
}
for region,raw in landmarks.items():
ns=[]
for point in raw.split(';'):
name,x,y=point.split(',');n=node(slug(name),int(x),int(y),region);n['name']=name.replace('Pokemon','Pokémon');ns.append(n)
regions[region]={'name':{'blueberry':'Blueberry Academy'}.get(region,region.title()),'width':32,'height':30,'nodes':ns,'source':f'https://bulbapedia.bulbagarden.net/wiki/{region.title() if region!="blueberry" else "Terarium"}','precision':'Schematic landmark overview; use the location list for routes'}
# Multiple blocks can describe one route or cave; retain one selectable marker.
for region in regions.values():
merged={}
for point in region['nodes']:
if point['slug'] in merged: merged[point['slug']]['cells'].extend(point['cells'])
else: merged[point['slug']]=point
region['nodes']=list(merged.values())
Path('data/pokemon-atlas.json').write_text(json.dumps({'source':'Coordinates adapted from pret decompilations; later-region landmark overviews are authored schematics. No game or wiki prose is reproduced.','regions':regions},ensure_ascii=False,indent=2)+'\n')
print({k:len(v['nodes']) for k,v in regions.items()})

View File

@@ -1,4 +1,15 @@
"""Build the species-level catalog from a pinned PokéAPI CSV snapshot.
"""Build the owned Pokémon catalog from a pinned PokéAPI CSV snapshot.
Writes two files:
data/pokemon-catalog.json species, games, per-game availability, evolutions
data/pokemon-locations.json locations and aggregated wild/gift/static encounters
Availability per species/game:
catchable documented encounter (wild, gift, static, in-game trade, ...)
evolve a pre-evolution is obtainable in that game and the method exists there
breed an obtainable relative can breed it in that game
event_only mythical Pokémon present in the game without another route
unknown present in the game's Pokédex/index data, route not confirmed
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
@@ -8,13 +19,52 @@ import concurrent.futures
import csv
import json
from pathlib import Path
import re
import tempfile
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
'pokemon_dex_numbers', 'pokedex_version_groups', 'pokemon_game_indices', 'encounters']
'version_group_regions', 'regions', 'region_names', 'pokemon_dex_numbers',
'pokedex_version_groups', 'pokemon_game_indices', 'encounters', 'encounter_slots',
'encounter_methods', 'encounter_method_prose', 'encounter_condition_values',
'encounter_condition_value_prose', 'encounter_condition_value_map', 'locations',
'location_names', 'location_areas', 'location_area_prose', 'pokemon_evolution',
'evolution_trigger_prose', 'item_names', 'move_names', 'pokemon_egg_groups',
'pokemon_forms', 'pokemon_form_names']
# Version groups without a Day Care, Nursery or picnic eggs.
NO_BREEDING = {'red-blue', 'yellow', 'red-green-japan', 'blue-japan', 'colosseum', 'xd',
'lets-go-pikachu-lets-go-eevee', 'legends-arceus', 'legends-za',
'mega-dimension', 'champions'}
# Baby Pokémon that only hatch when a parent holds an incense; species number -> item.
INCENSE = {298: 'Sea Incense', 360: 'Lax Incense', 406: 'Rose Incense', 433: 'Pure Incense',
438: 'Rock Incense', 439: 'Odd Incense', 440: 'Luck Incense', 446: 'Full Incense',
458: 'Wave Incense'}
NO_EGGS = 15
METHOD_LABELS = {
'walk': 'Grass or cave', 'old-rod': 'Old Rod', 'good-rod': 'Good Rod', 'super-rod': 'Super Rod',
'surf': 'Surfing', 'rock-smash': 'Rock Smash', 'headbutt': 'Headbutt', 'dark-grass': 'Dark grass',
'grass-spots': 'Rustling grass', 'cave-spots': 'Dust cloud', 'bridge-spots': 'Bridge shadow',
'super-rod-spots': 'Super Rod (rippling water)', 'surf-spots': 'Surfing (rippling water)',
'yellow-flowers': 'Yellow flowers', 'purple-flowers': 'Purple flowers', 'red-flowers': 'Red flowers',
'rough-terrain': 'Rough terrain', 'gift': 'Gift', 'gift-egg': 'Gift egg', 'static': 'Static encounter',
'pokeflute': 'Poké Flute', 'headbutt-low': 'Headbutt (rare trees)', 'headbutt-normal': 'Headbutt (common trees)',
'headbutt-high': 'Headbutt (frequent trees)', 'squirt-bottle': 'Squirt Bottle', 'wailmer-pail': 'Wailmer Pail',
'seaweed': 'Diving in seaweed', 'roaming-grass': 'Roaming (grass)', 'roaming-water': 'Roaming (water)',
'devon-scope': 'Devon Scope', 'feebas-tile-fishing': 'Fishing on Feebas tiles', 'island-scan': 'Island Scan',
'sos': 'SOS battle', 'bubbling-spots': 'Fishing in bubbling spots', 'berry-trees': 'Berry tree',
'npc-trade': 'In-game trade', 'sos-from-bubbling-spot': 'SOS battle (bubbling spot)',
'overworld': 'Overworld', 'overworld-water': 'Overworld (water)', 'overworld-flying': 'Overworld (flying)',
'overworld-special': 'Overworld (special)', 'overworld-flying-special': 'Overworld (flying, special)',
'overworld-water-special': 'Overworld (water, special)', 'overworld-dirt': 'Overworld (dirt)',
'horde': 'Horde encounter', 'snag': 'Snag from a trainer', 'snag-rematch': 'Snag from a trainer (rematch)',
'pokespot': 'Poké Spot', 'hidden-grotto': 'Hidden Grotto', 'honey-tree': 'Honey tree',
'wanderer': 'Wanderer', 'wanderer-water': 'Wanderer (water)', 'chase-water': 'Chasing (water)',
'dynamax-adventure': 'Dynamax Adventure', 'max-raid': 'Max Raid Battle',
}
TIMES = {'day': 'during the day', 'night': 'at night', 'dusk': 'at dusk', 'full-moon': 'during a full moon'}
def build(directory):
@@ -22,12 +72,18 @@ def build(directory):
with (directory / f'{name}.csv').open() as f:
return list(csv.DictReader(f))
def english(name, key, field='name'):
return {int(r[key]): r[field] for r in rows(name) if r['local_language_id'] == '9'}
def number(value):
return int(value) if value not in ('', None) else None
species = rows('pokemon_species')
pokemon = rows('pokemon')
species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
names = {int(p['pokemon_species_id']): p['name'] for p in rows('pokemon_species_names') if p['local_language_id'] == '9'}
type_names = {int(t['type_id']): t['name'] for t in rows('type_names') if t['local_language_id'] == '9'}
names = english('pokemon_species_names', 'pokemon_species_id')
type_names = english('type_names', 'type_id')
types = {}
for p in rows('pokemon_types'):
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
@@ -35,42 +91,307 @@ def build(directory):
for p in rows('pokemon_stats'):
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
egg_groups = {}
for p in rows('pokemon_egg_groups'):
egg_groups.setdefault(int(p['species_id']), set()).add(int(p['egg_group_id']))
evolves_from = {int(s['id']): number(s['evolves_from_species_id']) for s in species}
chain_of = {int(s['id']): int(s['evolution_chain_id']) for s in species}
mythical = {int(s['id']) for s in species if s['is_mythical'] == '1'}
mons = []
for s in species:
number = int(s['id'])
default = defaults[number]
mons.append(dict(number=number, name=names[number], generation=int(s['generation_id']),
n = int(s['id'])
default = defaults[n]
mons.append(dict(number=n, name=names[n], generation=int(s['generation_id']),
type1=types[default][1], type2=types[default].get(2),
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)}))
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)},
evolvesFrom=evolves_from[n], evolutionChain=chain_of[n],
isBaby=s['is_baby'] == '1', isLegendary=s['is_legendary'] == '1',
isMythical=n in mythical))
# ── Games ────────────────────────────────────────────────────────────
groups = {int(g['id']): g for g in rows('version_groups')}
group_order = {g: int(groups[g]['order']) for g in groups}
regions_for_group = {}
for r in rows('version_group_regions'):
regions_for_group.setdefault(int(r['version_group_id']), set()).add(int(r['region_id']))
region_names = english('region_names', 'region_id')
versions = rows('versions')
game_names = {int(v['version_id']): v['name'] for v in rows('version_names') if v['local_language_id'] == '9'}
games = [dict(sourceId=int(v['id']), name=game_names.get(int(v['id']), v['identifier'].replace('-', ' ').title()),
generation=int(groups[int(v['version_group_id'])]['generation_id'])) for v in versions]
for game, version in zip(games, versions):
if version['identifier'].endswith('-japan'):
game['name'] += ' (Japan)'
game_names = english('version_names', 'version_id')
games, version_group = [], {}
for v in versions:
vid, group = int(v['id']), int(v['version_group_id'])
version_group[vid] = group
name = game_names.get(vid, v['identifier'].replace('-', ' ').title())
if v['identifier'].endswith('-japan'):
name += ' (Japan)'
games.append(dict(sourceId=vid, name=name, slug=v['identifier'],
generation=int(groups[group]['generation_id']), sortOrder=group_order[group],
breeding=groups[group]['identifier'] not in NO_BREEDING))
versions_for_group = {}
for v in versions:
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
# ── Presence (Pokédex membership and game indices) ───────────────────
groups_for_dex = {}
for d in rows('pokedex_version_groups'):
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
entries = {}
# Membership and internal game indices establish presence, not how it is obtained.
present = set()
for p in rows('pokemon_dex_numbers'):
for group in groups_for_dex.get(int(p['pokedex_id']), []):
for version in versions_for_group.get(group, []):
entries[(int(p['species_id']), version)] = 'unknown'
present.add((int(p['species_id']), version))
for p in rows('pokemon_game_indices'):
if int(p['game_index']) > 0:
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'unknown'
for p in rows('encounters'):
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'catchable'
return dict(source={'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION,
'scope': 'All species and game versions in this snapshot; default-form types/stats. Unknown means presence without confirmed encounter data. Missing pairs have no source record, not proven absence.'},
pokemon=mons, games=games, entries=[[p, g, availability] for (p, g), availability in sorted(entries.items())])
present.add((species_for[int(p['pokemon_id'])], int(p['version_id'])))
# ── Locations and encounters ─────────────────────────────────────────
location_names = english('location_names', 'location_id')
location_rows = rows('locations')
location_region = {int(l['id']): number(l['region_id']) for l in location_rows}
locations = [dict(sourceId=int(l['id']), slug=l['identifier'],
name=location_names.get(int(l['id']), l['identifier'].replace('-', ' ').title()),
region=region_names.get(number(l['region_id']) or 0))
for l in location_rows]
location_label = {l['sourceId']: l['name'] for l in locations}
area_prose = english('location_area_prose', 'location_area_id')
areas = {}
for a in rows('location_areas'):
location = int(a['location_id'])
label = None
if a['identifier']:
prose = area_prose.get(int(a['id']), '')
inner = re.fullmatch(re.escape(location_names.get(location, '')) + r' \((.+)\)', prose)
label = inner.group(1) if inner else ' '.join(
w.upper() if re.fullmatch(r'b?\d+f', w) else w for w in a['identifier'].split('-')).capitalize()
areas[int(a['id'])] = (location, label)
method_prose = english('encounter_method_prose', 'encounter_method_id')
method_label = {int(m['id']): METHOD_LABELS.get(m['identifier'], method_prose.get(int(m['id']), m['identifier']))
for m in rows('encounter_methods')}
slots = {int(s['id']): (int(s['encounter_method_id']), number(s['rarity'])) for s in rows('encounter_slots')}
condition_prose = english('encounter_condition_value_prose', 'encounter_condition_value_id')
species_ids = {s['identifier']: int(s['id']) for s in species}
condition_text = {}
for c in rows('encounter_condition_values'):
value, kind, identifier = int(c['id']), int(c['encounter_condition_id']), c['identifier']
if kind == 15: # in-game trade: the Pokémon the NPC wants
wanted = identifier.removeprefix('trade-')
condition_text[value] = f"In exchange for {names[species_ids[wanted]] if wanted in species_ids else wanted.replace('-', ' ')}"
elif kind == 16: # Game Corner prize
condition_text[value] = f"{identifier.removeprefix('coins-')} Game Corner coins"
elif c['is_default'] != '1':
condition_text[value] = condition_prose.get(value) or re.sub(
r'^(other|story-progress|item)-', '', identifier).replace('-', ' ').capitalize()
conditions = {}
for c in rows('encounter_condition_value_map'):
value = int(c['encounter_condition_value_id'])
if value in condition_text:
conditions.setdefault(int(c['encounter_id']), set()).add(condition_text[value])
form_names = {int(f['pokemon_form_id']): f['form_name'] for f in rows('pokemon_form_names')
if f['local_language_id'] == '9' and f['form_name']}
form_for = {int(f['pokemon_id']): form_names.get(int(f['id'])) for f in rows('pokemon_forms')
if f['is_default'] == '1' and int(f['pokemon_id']) not in defaults.values()}
grouped = {}
for e in rows('encounters'):
pid, version = int(e['pokemon_id']), int(e['version_id'])
location, area = areas[int(e['location_area_id'])]
method, rarity = slots[int(e['encounter_slot_id'])]
key = (species_for[pid], version, location, area, method_label[method], form_for.get(pid),
tuple(sorted(conditions.get(int(e['id']), ()))))
low, high, total = grouped.get(key, (999, 0, 0))
grouped[key] = (min(low, int(e['min_level'])), max(high, int(e['max_level'])), total + (rarity or 0))
# Max Raid dens repeat every species per den and star level; fold them per location.
raids = {}
for key in [k for k in grouped if k[4] == METHOD_LABELS['max-raid']]:
s, v, loc, area, method, form, conds = key
low, high, _ = grouped.pop(key)
raid = raids.setdefault((s, v, loc, form), dict(dens=set(), stars=set(), beams=set(), low=999, high=0))
if area:
raid['dens'].add(area.removeprefix('Max Den '))
raid['low'], raid['high'] = min(raid['low'], low), max(raid['high'], high)
for condition in conds:
star = re.search(r'difficulty of (\d) star', condition)
if star:
raid['stars'].add(int(star.group(1)))
elif 'pillar of light' in condition:
raid['beams'].add('Wild Area News' if 'News' in condition else 'purple' if 'purple' in condition else 'red')
for (s, v, loc, form), raid in raids.items():
stars = sorted(raid['stars'])
conds = [f'{stars[0]}★' + (f'–{stars[-1]}★' if len(stars) > 1 else '') + ' raids'] if stars else []
if raid['beams'] == {'purple'}:
conds.append('Rare (purple) beam dens only')
if 'Wild Area News' in raid['beams']:
conds.append('Wild Area News event dens')
dens = sorted(raid['dens'])
area = (('Max Den ' if len(dens) == 1 else 'Max Dens ') + ', '.join(dens)) if dens else None
grouped[(s, v, loc, area, METHOD_LABELS['max-raid'], form, tuple(conds))] = (raid['low'], raid['high'], 0)
encounters = [[s, v, loc, area, method, low, high, min(total, 100) or None, form, list(conds)]
for (s, v, loc, area, method, form, conds), (low, high, total) in sorted(
grouped.items(), key=lambda item: tuple('' if x is None else str(x) for x in item[0]))]
catchable, where = set(), {}
for s, v, loc, *_ in encounters:
catchable.add((s, v))
seen = where.setdefault((s, v), [])
if location_label[loc] not in seen:
seen.append(location_label[loc])
# ── Evolution methods per game ───────────────────────────────────────
items = english('item_names', 'item_id')
moves = english('move_names', 'move_id')
triggers = english('evolution_trigger_prose', 'evolution_trigger_id')
def describe(r):
trigger = int(r['evolution_trigger_id'])
level = number(r['minimum_level'])
if trigger == 1:
text = f'Level {level}+' if level else 'Level up'
elif trigger == 2:
text = 'Trade'
elif trigger == 3:
text = f"Use a {items[int(r['trigger_item_id'])]}"
else:
text = triggers[trigger]
parts = [text]
if r['held_item_id']:
parts.append(f"holding a {items[int(r['held_item_id'])]}")
if r['known_move_id']:
parts.append(f"knowing {moves[int(r['known_move_id'])]}")
if r['known_move_type_id']:
parts.append(f"knowing a {type_names[int(r['known_move_type_id'])]}-type move")
if r['minimum_happiness']:
parts.append('with high friendship')
if r['minimum_affection']:
parts.append('with high affection')
if r['minimum_beauty']:
parts.append('with high Beauty')
if r['time_of_day']:
parts.append(TIMES.get(r['time_of_day'], r['time_of_day']))
if r['near_special_rock'] == '1':
parts.append('near the special rock')
if r['location_id']:
parts.append(f"at {location_label[int(r['location_id'])]}")
if r['party_species_id']:
parts.append(f"with {names[int(r['party_species_id'])]} in the party")
if r['party_type_id']:
parts.append(f"with a {type_names[int(r['party_type_id'])]}-type Pokémon in the party")
if r['trade_species_id']:
parts.append(f"for a {names[int(r['trade_species_id'])]}")
if r['relative_physical_stats']:
parts.append({'1': 'if Attack > Defense', '0': 'if Attack = Defense', '-1': 'if Attack < Defense'}[r['relative_physical_stats']])
if r['gender_id']:
parts.append('(female)' if r['gender_id'] == '1' else '(male)')
if r['needs_overworld_rain'] == '1':
parts.append('while it is raining')
if r['turn_upside_down'] == '1':
parts.append('holding the console upside down')
if r['used_move_id']:
parts.append(f"after using {moves[int(r['used_move_id'])]} {r['minimum_move_count'] or ''} times".replace(' ', ' '))
if r['minimum_steps']:
parts.append(f"after walking {r['minimum_steps']} steps with it")
if r['minimum_damage_taken']:
parts.append(f"after losing {r['minimum_damage_taken']}+ HP without fainting")
if r['needs_multiplayer'] == '1':
parts.append('in Union Circle')
if r['nature_bitmask']:
parts.append('(form depends on nature)')
if r['percentage_chance']:
parts.append(f"({r['percentage_chance']}% chance)")
if r['region_id']:
parts.append(f"in {region_names[int(r['region_id'])]}")
return ' '.join(parts)
evolution_rows = {}
for r in rows('pokemon_evolution'):
evolution_rows.setdefault(int(r['evolved_species_id']), []).append(r)
# Methods are dated from Red/Blue; the earlier Japanese Red/Green/Blue share them.
first_order = min(group_order[int(r['version_group_id'])] for rs in evolution_rows.values() for r in rs)
def methods(target, version):
group = version_group[version]
regions = regions_for_group.get(group, set())
valid = [r for r in evolution_rows.get(target, [])
if group_order[int(r['version_group_id'])] <= max(group_order[group], first_order)
and (not r['region_id'] or int(r['region_id']) in regions)
and (not r['location_id'] or location_region[int(r['location_id'])] in regions)]
# A regional variant (e.g. Alolan Raichu in Alola) replaces the generic method.
regional = {r['required_pokemon_form_id'] for r in valid if r['region_id']}
valid = [r for r in valid if r['region_id'] or r['required_pokemon_form_id'] not in regional]
# Each form variant uses the method from the latest version group that defines it.
latest = {}
for r in valid:
key = (r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])
latest[key] = max(latest.get(key, 0), group_order[int(r['version_group_id'])])
chosen = [describe(r) for r in valid
if group_order[int(r['version_group_id'])] == latest[(r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])]]
return list(dict.fromkeys(chosen))
# ── Availability ─────────────────────────────────────────────────────
availability, notes = {}, {}
for key in present | catchable:
availability[key] = 'catchable' if key in catchable else 'unknown'
if key in catchable:
places = where[key]
notes[key] = ', '.join(places[:3]) + (f' (+{len(places) - 3} more)' if len(places) > 3 else '')
chain_members = {}
for n, chain in chain_of.items():
chain_members.setdefault(chain, []).append(n)
obtainable = {'catchable', 'evolve', 'breed'}
evolutions = {}
for game in games:
v = game['sourceId']
changed = True
while changed:
changed = False
for n in evolves_from:
key = (n, v)
if availability.get(key) != 'unknown':
continue
parent = evolves_from[n]
if parent and availability.get((parent, v)) in obtainable:
found = methods(n, v)
if found:
availability[key], notes[key] = 'evolve', f"Evolve {names[parent]}: {' or '.join(found)}"
changed = True
continue
if game['breeding'] and not parent:
parents = [m for m in chain_members[chain_of[n]] if m != n
and availability.get((m, v)) in obtainable
and NO_EGGS not in egg_groups.get(m, {NO_EGGS})]
if parents:
incense = f' holding a {INCENSE[n]}' if n in INCENSE else ''
availability[key], notes[key] = 'breed', f'Breed {names[parents[0]]}{incense}'
changed = True
for n in mythical:
if availability.get((n, v)) == 'unknown':
availability[(n, v)] = 'event_only'
notes[(n, v)] = 'Mythical: normally only through an event distribution or special unlock'
for n, parent in evolves_from.items():
if parent and (n, v) in availability and (parent, v) in availability:
for method in methods(n, v):
evolutions.setdefault((parent, n, method), []).append(v)
entries = []
for (n, v), value in sorted(availability.items()):
entries.append([n, v, value, notes[(n, v)]] if (n, v) in notes else [n, v, value])
source = {'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION}
catalog = dict(
source=dict(source, scope='All species and game versions in this snapshot; default-form types/stats. '
'Evolve/breed/event availability is derived from evolution, egg group and mythical data. '
'Unknown means presence without a confirmed route. Missing pairs have no source record, not proven absence.'),
pokemon=mons, games=games, entries=entries,
evolutions=[[a, b, versions, method] for (a, b, method), versions in sorted(evolutions.items())])
# Methods and conditions repeat thousands of times; store them once and reference by index.
methods_used = sorted({e[4] for e in encounters})
conditions_used = sorted({c for e in encounters for c in e[9]})
for e in encounters:
e[4] = methods_used.index(e[4])
e[9] = [conditions_used.index(c) for c in e[9]]
location_catalog = dict(source=dict(source, scope='Locations and encounters aggregated per species, game, '
'area, method, form and non-default condition. Rarity is the summed slot '
'percentage. Encounter rows are [species, version, location, area, method index, '
'min level, max level, rarity, form, condition indexes].'),
locations=locations, methods=methods_used, conditions=conditions_used,
encounters=encounters)
return catalog, location_catalog
def main():
@@ -86,10 +407,16 @@ def main():
(directory / f'{table}.csv').write_bytes(response.read())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
catalog = build(directory)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-catalog.json'
target.write_text(json.dumps(catalog, ensure_ascii=False, separators=(',', ':')) + '\n')
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, {len(catalog['entries'])} entries")
catalog, locations = build(directory)
data = Path(__file__).resolve().parent.parent / 'data'
for name, content in [('pokemon-catalog.json', catalog), ('pokemon-locations.json', locations)]:
(data / name).write_text(json.dumps(content, ensure_ascii=False, separators=(',', ':')) + '\n')
counts = {}
for entry in catalog['entries']:
counts[entry[2]] = counts.get(entry[2], 0) + 1
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, "
f"{len(catalog['entries'])} entries {counts}, {len(catalog['evolutions'])} evolution methods, "
f"{len(locations['locations'])} locations, {len(locations['encounters'])} encounters")
if __name__ == '__main__':

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Build image-space map markers and assemble the three SV in-game maps.
Requires Pillow and beautifulsoup4. Cached source pages/tiles: /tmp/game-map-art.
The map artwork is from the games; source image-map coordinates are adapted from
Serebii with attribution. Hand-placed landmarks are explicitly approximate.
"""
from pathlib import Path
import argparse
import json
import re
import urllib.request
from PIL import Image
from bs4 import BeautifulSoup
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache',type=Path,default=Path('/tmp/game-map-art'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
def fetch(url,name):
p=args.cache/name
if not p.exists():p.write_bytes(urllib.request.urlopen(url,timeout=45).read())
return p
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower().replace('é','e')).strip('-')
original=json.loads((ROOT/'data/pokemon-atlas.json').read_text())['regions']
locations=json.loads((ROOT/'data/pokemon-locations.json').read_text())['locations']
def node(name,x,y,region):
key=slug(name);aliases={'mount-coronet':'mt-coronet','pokemon-league':'sinnoh-pokemon-league','lake-of-rage':'lake-of-rage','mt-battle':'mt-battle','pokemon-hq-lab':'pokemon-hq-lab'}
key=aliases.get(key,key)
found=next((p for p in locations if slug(p['name'])==slug(name) and (p['region'] or '').lower()==region),None)
if not found:found=next((p for p in locations if p['slug'] in [key,region+'-'+key] and (p['region'] or '').lower()==region),None)
return dict(slug=found['slug'] if found else key,name=found['name'] if found else name,x=round(x,3),y=round(y,3),kind='town' if re.search(r'Town|City|Village|Area|League',name) else 'route' if 'Route' in name else 'landmark',cells=[])
maps={}
for region in original:
p=ROOT/f'static/game-art/maps/{region}.webp'
if region in ['paldea','kitakami','blueberry']:
remote='terarium' if region=='blueberry' else region
canvas=Image.new('RGB',(2048,2048))
for x in range(8):
for y in range(8):
tile=fetch(f'https://www.serebii.net/pokearth/{remote}/map/tile_2-{x}-{y}.png',f'{remote}-tile-{x}-{y}.png')
canvas.paste(Image.open(tile),(x*256,y*256))
canvas.save(p,format='WEBP',quality=90,method=6)
im=Image.open(p);w,h=im.size
source='https://www.serebii.net/pokearth/'+region+'/'
if region=='blueberry':source='https://www.serebii.net/pokearth/terarium/'
if region=='orre':source='https://pokemonrevolution.net/forum/topic/147410-orre-region/'
nodes=[]
if region in ['sinnoh','hoenn','kanto','johto','unova','kalos']:
if region in ['kalos','unova']:page=fetch('https://www.serebii.net/pokearth/','pokearth.html')
else:page=fetch(source,region+'.html')
soup=BeautifulSoup(page.read_bytes(),'html.parser')
area_map=soup.find('map',attrs={'name':re.compile('^'+region+'$',re.I)})
if area_map:
for a in area_map.find_all('area'):
values=list(map(float,re.findall(r'[\d.]+',a.get('coords',''))))
if len(values)<4:continue
xs=values[::2];ys=values[1::2]
# Bounding-box centre remains inside rectangular routes and town markers.
x=(min(xs)+max(xs))/2;y=(min(ys)+max(ys))/2
name=a.get('title','').strip()
if name and 0<=x<=w and 0<=y<=h:nodes.append(node(name,x,y,region))
nodes=list({n['slug']:n for n in nodes}.values())
maps[region]=dict(name=original[region]['name'],image=f'/game-art/maps/{region}.webp',width=w,height=h,nodes=nodes,source=source,credit='Nintendo / The Pokémon Company; map imagery and location coordinates via Serebii' if region!='orre' else 'Nintendo / Genius Sonority; Pokémon XD screenshot via Pokémon Revolution forum',precision='Regional map · location markers aligned to the source image' if nodes else 'Game map · approximate landmark markers',pixelated=w<500)
# Reviewed landmark centres as percentages of the image, not the old schematic.
manual={
'paldea':'Cabo Poco,52,93;Los Platos,51,85;Mesagoza,49,78;Cortondo,31,77;Artazon,69,78;Levincia,83,58;Cascarrafa,30,58;Porto Marinada,14,48;Medali,35,40;Montenevera,51,27;Alfornada,15,89;Glaseado Mountain,55,31;Casseroya Lake,25,28;Area Zero,48,61;Pokemon League,45,72',
'kitakami':'Mossui Town,56,77;Loyalty Plaza,43,80;Apple Hills,38,75;Kitakami Hall,67,58;Oni Mountain,49,49;Crystal Pool,48,50;Timeless Woods,75,28;Fellhorn Gorge,81,46;Paradise Barrens,27,29;Revelers Road,64,70',
'blueberry':'Central Plaza,50,50;Savanna Biome,72,70;Coastal Biome,72,30;Canyon Biome,30,70;Polar Biome,30,30',
'hisui':'Jubilife Village,31,55;Obsidian Fieldlands,27,70;Crimson Mirelands,70,68;Cobalt Coastlands,86,35;Coronet Highlands,52,43;Alabaster Icelands,37.5,18;Temple of Sinnoh,36,10;Prelude Beach,26,61',
'galar':'Postwick,27.5,68.5;Wedgehurst,27.5,63.5;Wild Area,26.1,54.2;Motostoke,25.7,46.3;Turffield,12.4,40.9;Hulbury,38.5,40.8;Hammerlocke,26.2,34.5;Stow on Side,12.6,31.3;Ballonlea,10.4,24.3;Circhester,43.3,26.5;Spikemuth,47.4,34.7;Wyndon,26.2,9.4;Isle of Armor,76.9,35.2;Crown Tundra,26.1,84.5',
'alola':'Iki Town,14.5,40.4;Hauoli City,12,48.3;Melemele Sea,18.8,55;Heahea City,30.8,32;Paniola Town,34.3,25.8;Royal Avenue,37.8,32.5;Konikoni City,26.3,28.8;Malie City,91.3,62.9;Tapu Village,78.5,73.3;Po Town,79,55;Mount Lanakila,82.8,64.6;Seafolk Village,56.8,37.9;Poni Wilds,57.8,33.3;Vast Poni Canyon,67,19.6;Aether Paradise,38.3,61.7',
'orre':'Phenac City,73,61.2;Pyrite Town,56,44;Agate Village,39,30;Mt Battle,54,19.8;Realgam Tower,59,55.6;Outskirt Stand,87.8,61.7;Snagem Hideout,84,36;Cipher Lab,73.4,27.7;Gateon Port,12.8,31.9;Pokemon HQ Lab,19.5,19.6;Rock Poke Spot,86.7,48.5;Oasis Poke Spot,57.5,31.9;Cave Poke Spot,38.3,47;Citadark Isle,31,57.7;Orre Colosseum,22.6,46.5'
}
for region,places in manual.items():
m=maps[region]
m['nodes']=[node(name,float(x)*m['width']/100,float(y)*m['height']/100,region) for name,x,y in (p.split(',') for p in places.split(';'))]
(ROOT/'data/pokemon-map-art.json').write_text(json.dumps(maps,ensure_ascii=False,indent=2)+'\n')
print({r:len(m['nodes']) for r,m in maps.items()})

View File

@@ -0,0 +1,119 @@
"""Reproducible factual PokéAPI import: regional dexes, learnsets and species biology.
Uses the same pinned revision as the encounter catalog. Reuses cached CSV files.
Never infers encounter absence from missing data.
"""
import argparse
import concurrent.futures
import csv
import json
from pathlib import Path
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_abilities', 'ability_names', 'pokemon_egg_groups',
'egg_group_prose', 'growth_rates', 'pokemon_stats', 'pokemon_dex_numbers', 'pokedex_prose',
'pokedexes', 'pokedex_version_groups', 'versions', 'version_group_regions', 'regions',
'moves', 'move_names', 'type_names', 'move_damage_class_prose', 'move_targets',
'pokemon_moves', 'pokemon_move_method_prose']
def build(directory):
def rows(name):
with (directory / f'{name}.csv').open() as f:
yield from csv.DictReader(f)
def names(table, key):
return {int(r[key]): r['name'] for r in rows(table) if r['local_language_id'] == '9'}
def integer(v):
return int(v) if v else None
defaults = {int(r['id']): r for r in rows('pokemon') if r['is_default'] == '1'}
species_for = {pid: int(r['species_id']) for pid, r in defaults.items()}
abilities, eggs, evs = {}, {}, {}
ability_names = names('ability_names', 'ability_id')
egg_names = names('egg_group_prose', 'egg_group_id')
growth = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('growth_rates')}
for r in rows('pokemon_abilities'):
if int(r['pokemon_id']) in species_for:
abilities.setdefault(species_for[int(r['pokemon_id'])], []).append(dict(name=ability_names[int(r['ability_id'])], hidden=r['is_hidden'] == '1'))
for r in rows('pokemon_egg_groups'):
eggs.setdefault(int(r['species_id']), []).append(egg_names[int(r['egg_group_id'])])
for r in rows('pokemon_stats'):
pid = int(r['pokemon_id'])
if pid in species_for:
evs.setdefault(species_for[pid], [0]*6)[int(r['stat_id'])-1] = int(r['effort'])
by_species = {int(r['species_id']): r for r in defaults.values()}
facts = []
for s in rows('pokemon_species'):
number = int(s['id'])
p = by_species[number]
facts.append(dict(number=number, facts=dict(height=int(p['height']), weight=int(p['weight']),
baseExperience=integer(p['base_experience']), captureRate=int(s['capture_rate']),
baseHappiness=integer(s['base_happiness']), growthRate=growth[int(s['growth_rate_id'])],
genderRate=int(s['gender_rate']), hatchCounter=integer(s['hatch_counter']),
abilities=abilities.get(number, []), eggGroups=eggs.get(number, []), evYield=evs.get(number, []))))
versions = list(rows('versions'))
dex_names = names('pokedex_prose', 'pokedex_id')
dex_slugs = {int(r['id']): r['identifier'] for r in rows('pokedexes')}
dex_groups = {}
for r in rows('pokedex_version_groups'):
dex_groups.setdefault(int(r['pokedex_id']), []).append(int(r['version_group_id']))
dexes = []
for r in rows('pokemon_dex_numbers'):
dex = int(r['pokedex_id'])
if dex == 1:
continue
for v in versions:
if int(v['version_group_id']) in dex_groups.get(dex, []):
dexes.append([int(r['species_id']), int(v['id']), dex_slugs[dex], dex_names[dex], int(r['pokedex_number'])])
regions = {int(r['id']): r['identifier'] for r in rows('regions')}
group_regions = {}
for r in rows('version_group_regions'):
group_regions.setdefault(int(r['version_group_id']), []).append(regions[int(r['region_id'])])
games = [dict(sourceId=int(v['id']), versionGroup=int(v['version_group_id']), regions=group_regions.get(int(v['version_group_id']), [])) for v in versions]
move_names = names('move_names', 'move_id')
type_names = names('type_names', 'type_id')
damage = names('move_damage_class_prose', 'move_damage_class_id')
targets = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('move_targets')}
moves = [dict(id=int(r['id']), name=move_names.get(int(r['id']), r['identifier']), type=type_names[int(r['type_id'])],
category=damage[int(r['damage_class_id'])], power=integer(r['power']), accuracy=integer(r['accuracy']),
pp=integer(r['pp']), priority=int(r['priority']), target=targets[int(r['target_id'])]) for r in rows('moves')]
method_names = names('pokemon_move_method_prose', 'pokemon_move_method_id')
learnsets = {}
for r in rows('pokemon_moves'):
pid = int(r['pokemon_id'])
if pid not in species_for:
continue
key = (species_for[pid], int(r['version_group_id']))
learnsets.setdefault(key, set()).add((int(r['move_id']), method_names[int(r['pokemon_move_method_id'])], int(r['level'])))
return dict(source=dict(repository='https://github.com/PokeAPI/pokeapi', revision=REVISION,
scope='Default forms; regional dex numbers, species biology and version-specific learnsets. Move battle values are current snapshot values, not historical per-generation values.'),
facts=facts, dexes=dexes, games=games, moves=moves,
learnsets=[[species, group, sorted(entries)] for (species, group), entries in sorted(learnsets.items())])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--cache', type=Path, default=Path('/tmp/pokemon-research-' + REVISION))
args = parser.parse_args()
args.cache.mkdir(parents=True, exist_ok=True)
def download(table):
path = args.cache / f'{table}.csv'
if path.exists():
return
with urllib.request.urlopen(f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv', timeout=120) as response:
content = response.read()
path.write_bytes(content)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
result = build(args.cache)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-research.json'
target.write_text(json.dumps(result, ensure_ascii=False, separators=(',', ':')) + '\n')
print({k: len(v) for k, v in result.items() if isinstance(v, list)})
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Check picture coverage, decodability, dimensions, and manifest integrity offline."""
import json
from pathlib import Path
from PIL import Image
ROOT=Path(__file__).resolve().parents[1]
manifest=json.loads((ROOT/'data/game-images.json').read_text())
paths={r['path'] for r in manifest}
assert len(paths)==len(manifest), 'Duplicate image paths'
for game in json.loads((ROOT/'data/pokemon-catalog.json').read_text())['games']:
assert f'/game-logos/pokemon/{game["slug"]}.webp' in paths, game['slug']
for game in json.loads((ROOT/'data/call-of-duty.json').read_text())['games']:
assert f'/game-logos/cod/{game["slug"]}.webp' in paths, game['slug']
for kind in ['sprite','shiny','artwork']:
for number in range(1,1026):assert f'/game-art/pokemon/{kind}/{number}.webp' in paths
for slug in ['pokemon','call-of-duty','osu']:assert f'/game-logos/{slug}.webp' in paths
for row in json.loads((ROOT/'data/cod-art.json').read_text()):
assert row['path'] in paths, (row['game'],row['name'])
for path in json.loads((ROOT/'data/cod-achievement-art.json').read_text()).values():
assert path in paths, path
for region,m in json.loads((ROOT/'data/pokemon-map-art.json').read_text()).items():
paths.add(m['image'])
for point in m['nodes']:
assert 0 <= point['x'] <= m['width'] and 0 <= point['y'] <= m['height'], (region,point)
for_size=Image.open(ROOT/'static'/m['image'].lstrip('/'))
assert for_size.size==(m['width'],m['height']),region
for path in paths:
with Image.open(ROOT/'static'/path.lstrip('/')) as im:
assert im.format=='WEBP',path
im.load()
assert min(im.size)>0,path
print(f'{len(paths):,} local WebP images checked: all game pictures, 1,025 × 3 Pokémon images, 13 maps, and downloaded COD artwork.')

View File

@@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS cs_market_items (
id serial PRIMARY KEY,
market_hash_name text NOT NULL,
name text NOT NULL,
icon_url text,
item_type text,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS cs_market_items_hash_idx ON cs_market_items (market_hash_name);
CREATE INDEX IF NOT EXISTS cs_market_items_name_idx ON cs_market_items (name);
CREATE TABLE IF NOT EXISTS cs_item_dimensions (
item_id integer NOT NULL REFERENCES cs_market_items(id) ON DELETE CASCADE,
key text NOT NULL,
value text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS cs_item_dimensions_unique_idx ON cs_item_dimensions (item_id, key);
CREATE INDEX IF NOT EXISTS cs_item_dimensions_filter_idx ON cs_item_dimensions (key, value, item_id);
CREATE TABLE IF NOT EXISTS cs_price_streams (
source text NOT NULL,
currency text NOT NULL,
kind text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS cs_price_streams_unique_idx
ON cs_price_streams (source, currency, kind);
CREATE TABLE IF NOT EXISTS cs_price_observations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL REFERENCES cs_market_items(id) ON DELETE CASCADE,
source text NOT NULL,
currency text NOT NULL,
observed_at timestamptz NOT NULL,
price_cents integer NOT NULL CHECK (price_cents >= 0),
volume integer CHECK (volume IS NULL OR volume >= 0),
kind text NOT NULL,
imported_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS cs_price_observations_unique_idx
ON cs_price_observations (item_id, source, currency, observed_at, kind);
CREATE INDEX IF NOT EXISTS cs_price_observations_lookup_idx
ON cs_price_observations (item_id, source, currency, observed_at);
CREATE INDEX IF NOT EXISTS cs_price_observations_time_idx
ON cs_price_observations (observed_at);

View File

@@ -30,8 +30,34 @@ try {
to_regclass('public.list_item_completions') is not null as list_completions,
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id,
(select count(*) from information_schema.columns
where table_name in ('animals', 'sightings', 'photos')
and column_name = 'id' and data_type = 'integer') as animal_serial_ids
where table_name in ('animals', 'sightings', 'photos')
and column_name = 'id' and data_type = 'integer') as animal_serial_ids,
(select count(*) from information_schema.tables
where table_schema = 'public' and table_name in (
'money_accounts', 'money_transactions', 'money_recurring', 'money_forecasts',
'money_utility_settings', 'money_utility_readings', 'money_wishlist', 'money_insurances',
'money_projection_settings'
)) as money_tables,
(select count(*) from information_schema.columns
where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start,
(select count(*) from information_schema.tables
where table_schema = 'public' and table_name in (
'pokemon_locations', 'pokemon_encounters', 'pokemon_evolutions',
'tracker_games', 'tracker_items', 'tracker_progress'
)) as game_tracker_tables,
(select count(*) from information_schema.tables where table_schema='public' and table_name in (
'pokemon_research','pokemon_dex_numbers','pokemon_moves','pokemon_learnsets','pokemon_game_research',
'pokemon_journeys','pokemon_catches','pokemon_milestones','tracker_ranks','tracker_weapon_ranks',
'tracker_challenges','tracker_challenge_progress','osu_entries','pokemon_seen'
)) as tracker_journal_tables,
(select count(*) from information_schema.columns where table_name='pokemon_journeys' and column_name='completed') as tracker_completion_column,
(select count(*) from information_schema.columns
where table_name = 'pokemon_game_entries' and column_name = 'source') as entry_source,
(select count(*) from information_schema.columns
where (table_name = 'money_accounts' and column_name = 'kind')
or (table_name = 'money_recurring' and column_name in ('starts_on', 'ends_on'))
or (table_name = 'money_wishlist' and column_name = 'priority')
or (table_name = 'money_projection_settings' and column_name = 'include_recurring')) as money_projection_columns
`;
if (
!Number(schema.user_role) ||
@@ -41,7 +67,14 @@ try {
!Number(schema.list_slug) ||
!schema.list_completions ||
!Number(schema.topic_link_id) ||
Number(schema.animal_serial_ids) !== 3
Number(schema.animal_serial_ids) !== 3 ||
Number(schema.money_tables) !== 9 ||
!Number(schema.money_billing_start) ||
Number(schema.money_projection_columns) !== 5 ||
Number(schema.game_tracker_tables) !== 6 ||
Number(schema.tracker_journal_tables) !== 14 ||
!Number(schema.tracker_completion_column) ||
!Number(schema.entry_source)
) {
throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database'
@@ -51,15 +84,49 @@ try {
select
(select count(*) from pokemon) as pokemon,
(select count(*) from games where source_id is not null) as games,
(select count(*) from pokemon_game_entries) as entries
(select count(*) from pokemon_game_entries) as entries,
(select count(*) from pokemon_encounters) as encounters,
(select count(*) from tracker_games where franchise = 'call-of-duty') as cod_games,
(select count(*) from pokemon_research) as pokemon_research,
(select count(*) from pokemon_moves) as pokemon_moves,
(select count(*) from pokemon_learnsets) as pokemon_learnsets,
(select count(*) from pokemon_dex_numbers) as pokemon_dex_numbers,
(select count(*) from tracker_challenges) as tracker_challenges
`;
if (
Number(catalog.pokemon) < 1025 ||
Number(catalog.games) < 53 ||
Number(catalog.entries) < 27267
Number(catalog.entries) < 27267 ||
Number(catalog.encounters) < 60000
) {
throw new Error('Pokémon catalog has not been seeded in this database');
}
if (
Number(catalog.pokemon_research) < 1025 ||
Number(catalog.pokemon_moves) < 937 ||
Number(catalog.pokemon_learnsets) < 11000 ||
Number(catalog.pokemon_dex_numbers) < 16000 ||
Number(catalog.tracker_challenges) < 3600
)
throw new Error(
'Run db:migrate:tracker-journal, db:seed:research and db:seed:challenges before deploying the tracker expansion'
);
if (Number(catalog.cod_games) < 7) {
throw new Error('Call of Duty catalog has not been seeded in this database');
}
const [bo2] = await client`select rules,
(select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
(select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
from tracker_games g where slug='black-ops-2'`;
if (
!bo2 ||
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
bo2.rules.rankCaps?.weaponLevelsByClass?.['Assault Rifles'] !== 17 ||
bo2.weapons < 40 ||
bo2.pictures < 39
) {
throw new Error('Seed the updated BO2 catalog and artwork before deploying');
}
} finally {
await client.end();
}

View File

@@ -17,6 +17,11 @@ esac
image="git.stanrunge.dev/stan/personal-website:$sha"
compose="$directory/compose.prod.yml"
if [ "$directory" = /opt/personal-website-staging ]; then
environment_name=staging
else
environment_name=production
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0
rollback() {
@@ -28,6 +33,47 @@ rollback() {
}
trap rollback ERR
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/print-db-server-version.ts)
case "$db_version" in
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
esac
if [ "$db_version" -ge 100000 ]; then
db_major=$((db_version / 10000))
else
db_major="$((db_version / 10000)).$(((db_version / 100) % 100))"
fi
backup_image="postgres:${db_major}-alpine"
backup_dir=/var/backups/personal-website
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
sudo docker pull "$backup_image"
# The deploy account can run Docker, but has no general host-file sudo access.
# A bind volume creates the root-owned directory; restrict it before dumping.
sudo docker run --rm --network backend --env-file "$environment" \
--volume "$backup_dir:/backups" \
--env "BACKUP_FILE=$backup_name" "$backup_image" \
sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_URL"'
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
run_database_script() {
echo "Running $1"
sudo docker run --rm --network backend --env-file "$environment" "$image" bun "$1"
}
run_database_script scripts/migrate-auth-catalog.ts
run_database_script scripts/migrate-foundation.ts
run_database_script scripts/migrate-money-tracker.ts
run_database_script scripts/migrate-game-tracker.ts
run_database_script scripts/migrate-tracker-journal.ts
run_database_script scripts/seed-catalog.ts
run_database_script scripts/seed-cod-catalog.ts
run_database_script scripts/seed-pokemon-research.ts
run_database_script scripts/seed-cod-challenges.ts
run_database_script scripts/seed-cod-art.ts
sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/deploy-preflight.ts "$origin"
@@ -45,6 +91,8 @@ fi
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/login" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/catch-em-all" >/dev/null
"$origin/projects/game-tracker/pokemon" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
trap - ERR
echo "Deployed $sha to $origin"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Download the reviewed artwork manifest, preserving aspect ratio, as local WebP.
Requires Pillow. Existing valid files are reused; failed downloads never replace files.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import io
import json
from pathlib import Path
import time
import urllib.request
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[1]
def download(row):
target = ROOT / 'static' / row['path'].lstrip('/')
if target.exists():
with Image.open(target) as img:
if img.format == 'WEBP':
return row['id'], None
for attempt in range(3):
try:
request = urllib.request.Request(row['url'], headers={'User-Agent': 'PersonalGameJournal/1.0 (image catalog)'})
with urllib.request.urlopen(request, timeout=45) as response:
content = response.read()
with Image.open(io.BytesIO(content)) as image:
image = ImageOps.exif_transpose(image).convert('RGBA')
image.thumbnail((row.get('maxSize', 800),) * 2, Image.Resampling.LANCZOS)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix('.tmp')
image.save(temporary, format='WEBP', lossless=row.get('lossless', False), quality=88, method=6)
temporary.replace(target)
return row['id'], None
except Exception as error:
if attempt == 2:
return row['id'], str(error)
time.sleep(attempt + 1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--workers', type=int, default=6)
args = parser.parse_args()
rows = json.loads((ROOT / 'data/game-images.json').read_text())
rows = [r for r in rows if args.filter in r['id']]
failed = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
for index, (name, error) in enumerate(pool.map(download, rows), 1):
if error:
failed.append((name, error))
print('FAILED', name, error, flush=True)
elif index % 100 == 0 or len(rows) < 100:
print(f'{index}/{len(rows)} {name}', flush=True)
print(f'{len(rows) - len(failed)}/{len(rows)} images ready', flush=True)
if failed:
raise SystemExit(1)
if __name__ == '__main__':
main()

View File

@@ -9,6 +9,7 @@ WHERE slug IS NULL;
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;

View File

@@ -0,0 +1,127 @@
-- Game tracker: owned Pokémon locations/encounters/evolutions and checklist games.
-- Additive and repeatable. Apply before seeding (`npm run db:seed`, `npm run db:seed:cod`).
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'evolve';
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'breed';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolves_from integer;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolution_chain integer;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_baby boolean NOT NULL DEFAULT false;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_legendary boolean NOT NULL DEFAULT false;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_mythical boolean NOT NULL DEFAULT false;
ALTER TABLE games ADD COLUMN IF NOT EXISTS slug text;
ALTER TABLE games ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
ALTER TABLE games ADD COLUMN IF NOT EXISTS breeding boolean NOT NULL DEFAULT true;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'games_slug_key') THEN
ALTER TABLE games ADD CONSTRAINT games_slug_key UNIQUE (slug);
END IF;
END $$;
-- Seeds used to mark their own rows with a "PokéAPI:" note prefix; anything else
-- was hand-maintained and must survive reseeding.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'pokemon_game_entries' AND column_name = 'source') THEN
ALTER TABLE pokemon_game_entries ADD COLUMN source text NOT NULL DEFAULT 'pokeapi';
UPDATE pokemon_game_entries SET source = 'manual' WHERE note IS NULL OR note NOT LIKE 'PokéAPI:%';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS pokemon_locations (
id serial PRIMARY KEY,
source_id integer UNIQUE,
slug text NOT NULL,
name text NOT NULL,
region text,
description text,
image_key text,
image_credit text,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pokemon_encounters (
id serial PRIMARY KEY,
pokemon_id integer NOT NULL,
game_id integer NOT NULL,
location_id integer NOT NULL,
area text,
method text NOT NULL,
min_level integer,
max_level integer,
rarity integer,
form text,
conditions text[] NOT NULL DEFAULT '{}'::text[],
note text,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pokemon_encounters_pokemon_id_pokemon_id_fkey FOREIGN KEY (pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_encounters_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
CONSTRAINT pokemon_encounters_location_id_pokemon_locations_id_fkey FOREIGN KEY (location_id) REFERENCES pokemon_locations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS pokemon_encounters_pokemon_id_game_id_index ON pokemon_encounters (pokemon_id, game_id);
CREATE INDEX IF NOT EXISTS pokemon_encounters_location_id_index ON pokemon_encounters (location_id);
CREATE TABLE IF NOT EXISTS pokemon_evolutions (
id serial PRIMARY KEY,
from_pokemon_id integer NOT NULL,
to_pokemon_id integer NOT NULL,
game_id integer NOT NULL,
method text NOT NULL,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique UNIQUE (from_pokemon_id, to_pokemon_id, game_id, method),
CONSTRAINT pokemon_evolutions_from_pokemon_id_pokemon_id_fkey FOREIGN KEY (from_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_evolutions_to_pokemon_id_pokemon_id_fkey FOREIGN KEY (to_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_evolutions_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS pokemon_evolutions_game_id_index ON pokemon_evolutions (game_id);
CREATE TABLE IF NOT EXISTS tracker_games (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE,
franchise text NOT NULL,
name text NOT NULL,
short_name text NOT NULL,
release_year integer,
sort_order integer NOT NULL DEFAULT 0,
rules jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tracker_items (
id serial PRIMARY KEY,
game_id integer NOT NULL,
key text NOT NULL,
kind text NOT NULL,
category text NOT NULL,
name text NOT NULL,
description text,
icon text,
post_launch boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tracker_items_game_id_key_unique UNIQUE (game_id, key),
CONSTRAINT tracker_items_game_id_tracker_games_id_fkey FOREIGN KEY (game_id) REFERENCES tracker_games(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tracker_items_game_id_index ON tracker_items (game_id);
CREATE TABLE IF NOT EXISTS tracker_progress (
id serial PRIMARY KEY,
user_id text NOT NULL,
item_id integer NOT NULL,
milestone text NOT NULL DEFAULT 'done',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tracker_progress_user_id_item_id_milestone_unique UNIQUE (user_id, item_id, milestone),
CONSTRAINT tracker_progress_user_id_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT tracker_progress_item_id_tracker_items_id_fkey FOREIGN KEY (item_id) REFERENCES tracker_items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tracker_progress_user_id_index ON tracker_progress (user_id);

287
scripts/ingest-cs-prices.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* Steam Community Market ingestion. Run after db:migrate:cs-prices.
*
* bun scripts/ingest-cs-prices.ts catalog [--limit=500] [--start=0]
* STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... bun scripts/ingest-cs-prices.ts history [--limit=100] [--start=0]
* bun scripts/ingest-cs-prices.ts import ./observations.jsonl
*
* The first two modes use Steam's public market web endpoints, which are not a
* contracted API and may rate limit or change. `import` remains provider-neutral.
*/
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
import { dimensionsFromName, type Dimensions } from '../src/lib/cs-prices/taxonomy';
import { steamDate } from '../src/lib/cs-prices/steam';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
const mode = process.argv[2];
const flags = new Map(
process.argv
.slice(3)
.filter((arg) => arg.startsWith('--'))
.map((arg) => {
const [key, value] = arg.slice(2).split('=');
return [key, value ?? ''];
})
);
const start = Math.max(0, Number(flags.get('start') ?? 0));
const limit = Math.max(1, Number(flags.get('limit') ?? 1_000_000_000));
const delayMs = Math.max(1000, Number(flags.get('delay-ms') ?? 2000));
const sweepTime = new Date();
const knownStreams = new Set<string>();
type Observation = {
marketHashName: string;
name?: string;
source: string;
currency: string;
observedAt: string;
priceCents: number;
volume?: number | null;
kind: string;
dimensions?: Dimensions;
};
function validObservation(value: unknown): Observation {
if (!value || typeof value !== 'object') throw new Error('Observation must be an object');
const row = value as Record<string, unknown>;
for (const key of ['marketHashName', 'source', 'currency', 'observedAt', 'kind']) {
if (typeof row[key] !== 'string' || !row[key]) throw new Error(`Invalid ${key}`);
}
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(row.source as string)) throw new Error('Invalid source');
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(row.kind as string)) throw new Error('Invalid kind');
if (!/^[A-Z]{3}$/.test(row.currency as string)) throw new Error('Invalid currency');
if (row.name != null && typeof row.name !== 'string') throw new Error('Invalid name');
if (
!Number.isInteger(row.priceCents) ||
(row.priceCents as number) < 0 ||
(row.priceCents as number) > 2_147_483_647
)
throw new Error('Invalid priceCents');
if (
row.volume != null &&
(!Number.isInteger(row.volume) ||
(row.volume as number) < 0 ||
(row.volume as number) > 2_147_483_647)
)
throw new Error('Invalid volume');
if (Number.isNaN(Date.parse(row.observedAt as string))) throw new Error('Invalid observedAt');
if (
row.dimensions != null &&
(typeof row.dimensions !== 'object' ||
Array.isArray(row.dimensions) ||
Object.values(row.dimensions).some((value) => typeof value !== 'string'))
)
throw new Error('Invalid dimensions');
return row as Observation;
}
async function itemId(
name: string,
displayName = name,
iconUrl?: string,
itemType?: string,
extraDimensions: Dimensions = {}
) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs_market_items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
name = EXCLUDED.name,
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
last_seen_at = now()
RETURNING id`;
const inferred = dimensionsFromName(name);
if (itemType) inferred.steam_type = itemType;
for (const [key, value] of Object.entries(inferred)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO NOTHING`;
}
for (const [key, value] of Object.entries(extraDimensions)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
}
return row.id;
}
async function saveObservation(row: Observation, knownItemId?: number) {
const item =
knownItemId ??
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
await ensureStream(row.source, row.currency, row.kind);
await sql`
INSERT INTO cs_price_observations
(item_id, source, currency, observed_at, price_cents, volume, kind)
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents,
volume = EXCLUDED.volume`;
}
async function ensureStream(source: string, currency: string, kind: string) {
const key = `${source}\0${currency}\0${kind}`;
if (knownStreams.has(key)) return;
await sql`INSERT INTO cs_price_streams (source, currency, kind)
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
knownStreams.add(key);
}
async function saveHistoryRows(
item: { id: number; market_hash_name: string },
prices: [string, number, string][]
) {
await ensureStream('steam-market', 'USD', 'sale-median');
const rows = prices
.filter(([, price]) => Number.isFinite(price) && price >= 0)
.map(([date, price, volume]) => ({
item_id: item.id,
source: 'steam-market',
currency: 'USD',
observed_at: new Date(steamDate(date)),
price_cents: Math.round(price * 100),
volume: Number(volume.replaceAll(',', '')) || 0,
kind: 'sale-median'
}));
for (let offset = 0; offset < rows.length; offset += 500) {
await sql`INSERT INTO cs_price_observations ${sql(rows.slice(offset, offset + 500))}
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
}
async function steamJson<T>(url: URL, authenticated = false): Promise<T> {
for (let attempt = 0; attempt < 5; attempt++) {
const cookie = authenticated
? `sessionid=${process.env.STEAM_SESSIONID ?? ''}; steamLoginSecure=${process.env.STEAM_LOGIN_SECURE ?? ''}`
: undefined;
const response = await fetch(url, {
headers: {
'User-Agent': 'cs-prices/1.0 personal historical archive',
...(cookie ? { Cookie: cookie } : {})
}
});
if (response.ok) {
const json = await response.json();
if (!json || json.success === false) throw new Error(`Steam rejected ${url.pathname}`);
return json as T;
}
if (response.status !== 429 && response.status < 500)
throw new Error(`Steam HTTP ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** attempt));
}
throw new Error(`Steam request failed after retries: ${url.pathname}`);
}
const pause = () => new Promise((resolve) => setTimeout(resolve, delayMs));
async function catalog() {
let offset = start;
let processed = 0;
while (processed < limit) {
const url = new URL('https://steamcommunity.com/market/search/render/');
url.search = new URLSearchParams({
appid: '730',
currency: '1',
country: 'US',
norender: '1',
count: String(Math.min(100, limit - processed)),
start: String(offset),
sort_column: 'name',
sort_dir: 'asc'
}).toString();
const page = await steamJson<{
total_count: number;
results: {
hash_name: string;
name: string;
sell_price?: number;
sell_listings?: number;
asset_description?: { icon_url?: string; type?: string };
}[];
}>(url);
if (!page.results?.length) break;
for (const entry of page.results) {
if (!entry.hash_name) continue;
const id = await itemId(
entry.hash_name,
entry.name || entry.hash_name,
entry.asset_description?.icon_url,
entry.asset_description?.type
);
if (Number.isSafeInteger(entry.sell_price) && (entry.sell_price ?? -1) >= 0) {
await saveObservation(
{
marketHashName: entry.hash_name,
source: 'steam-market',
currency: 'USD',
observedAt: sweepTime.toISOString(),
priceCents: entry.sell_price!,
volume: entry.sell_listings,
kind: 'lowest-listing'
},
id
);
}
}
processed += page.results.length;
offset += page.results.length;
console.log(`Catalog: ${offset}/${page.total_count} listings`);
if (offset >= page.total_count) break;
await pause();
}
}
async function history() {
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
const items = await sql<{ id: number; market_hash_name: string }[]>`
SELECT id, market_hash_name FROM cs_market_items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
let done = 0;
for (const item of items) {
const url = new URL('https://steamcommunity.com/market/pricehistory/');
url.search = new URLSearchParams({
appid: '730',
market_hash_name: item.market_hash_name,
currency: '1',
country: 'US'
}).toString();
const data = await steamJson<{ prices: [string, number, string][] }>(url, true);
await saveHistoryRows(item, data.prices ?? []);
console.log(
`History: ${++done}/${items.length} ${item.market_hash_name} (${data.prices?.length ?? 0} points)`
);
await pause();
}
}
async function importFile(path: string) {
const lines = (await readFile(path, 'utf8')).split(/\r?\n/);
let imported = 0;
for (const [index, line] of lines.entries()) {
if (!line.trim()) continue;
try {
await saveObservation(validObservation(JSON.parse(line)));
imported++;
} catch (cause) {
throw new Error(`Invalid observation on line ${index + 1}: ${String(cause)}`, { cause });
}
}
console.log(`Imported ${imported} observations`);
}
try {
if (mode === 'catalog') await catalog();
else if (mode === 'history') await history();
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
else throw new Error('Usage: ingest-cs-prices.ts catalog|history|import <file>');
await sql`ANALYZE cs_market_items, cs_item_dimensions, cs_price_observations`;
} finally {
await sql.end();
}

View File

@@ -0,0 +1,25 @@
-- One-off bridge from the pre-foundation site schema to the current table names.
-- Run after the Animaldex UUID conversion, if needed, and before the auth,
-- foundation, and catalog migrations. Review and back up each database first.
-- Renaming keeps existing rows and foreign keys; lists did not exist in this schema.
BEGIN;
ALTER TABLE public.game RENAME TO games;
ALTER TABLE public.pokemon_game_entry RENAME TO pokemon_game_entries;
ALTER TABLE public.user_entry RENAME TO user_entries;
ALTER TABLE public.user_game RENAME TO user_games;
CREATE TABLE public.lists (
id serial PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE public.list_items (
id serial PRIMARY KEY,
list_id integer NOT NULL REFERENCES public.lists(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE INDEX list_items_list_id_idx ON public.list_items(list_id);
COMMIT;

View File

@@ -0,0 +1 @@
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;

View File

@@ -0,0 +1,13 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(await readFile(new URL('./cs-prices-migration.sql', import.meta.url), 'utf8'));
});
console.log('CS prices migration applied');
} finally {
await client.end();
}

View File

@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./game-tracker-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Game tracker migration applied');
} finally {
await client.end();
}

View File

@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./money-tracker-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Money tracker migration applied');
} finally {
await client.end();
}

View File

@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./tracker-journal-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Tracker journal migration applied');
} finally {
await client.end();
}

View File

@@ -0,0 +1,120 @@
CREATE TABLE IF NOT EXISTS money_accounts (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
opening_balance_cents integer NOT NULL DEFAULT 0,
kind text NOT NULL DEFAULT 'spendable',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS money_accounts_user_id_idx ON money_accounts (user_id);
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'spendable';
CREATE TABLE IF NOT EXISTS money_transactions (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
account_id integer NOT NULL REFERENCES money_accounts(id),
date date NOT NULL,
description text NOT NULL,
amount_cents integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_transactions_user_date_idx ON money_transactions (user_id, date, id);
CREATE INDEX IF NOT EXISTS money_transactions_account_id_idx ON money_transactions (account_id);
CREATE TABLE IF NOT EXISTS money_recurring (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
amount_cents integer NOT NULL,
cadence text NOT NULL,
starts_on date,
ends_on date,
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_recurring_user_id_idx ON money_recurring (user_id);
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS starts_on date;
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS ends_on date;
CREATE TABLE IF NOT EXISTS money_forecasts (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
date date NOT NULL,
description text NOT NULL,
amount_cents integer NOT NULL,
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_forecasts_user_date_idx ON money_forecasts (user_id, date);
CREATE TABLE IF NOT EXISTS money_utility_settings (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
kind text NOT NULL,
rate_per_unit numeric(12, 4) NOT NULL DEFAULT 0,
monthly_advance_cents integer NOT NULL DEFAULT 0,
billing_start date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, kind)
);
ALTER TABLE money_utility_settings ADD COLUMN IF NOT EXISTS billing_start date;
CREATE TABLE IF NOT EXISTS money_utility_readings (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
kind text NOT NULL,
date date NOT NULL,
reading numeric(12, 3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_utility_readings_user_kind_date_idx ON money_utility_readings (user_id, kind, date);
CREATE TABLE IF NOT EXISTS money_wishlist (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
estimated_cents integer,
priority integer NOT NULL DEFAULT 3,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_wishlist_user_id_idx ON money_wishlist (user_id);
ALTER TABLE money_wishlist ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 3;
CREATE TABLE IF NOT EXISTS money_projection_settings (
id serial PRIMARY KEY,
user_id text NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
investment_account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
reserve_cents integer NOT NULL DEFAULT 100000,
annual_return_percent numeric(5, 2) NOT NULL DEFAULT 5.00,
contribution_mode text NOT NULL DEFAULT 'excess',
include_recurring integer NOT NULL DEFAULT 1,
fixed_monthly_cents integer NOT NULL DEFAULT 0,
end_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE money_projection_settings ADD COLUMN IF NOT EXISTS include_recurring integer NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS money_insurances (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
provider text,
coverage text,
premium_cents integer,
cadence text NOT NULL DEFAULT 'monthly',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id);
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS color text;

View File

@@ -0,0 +1,10 @@
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
const [row] = await client`select current_setting('server_version_num')::integer as version`;
console.log(row.version);
} finally {
await client.end();
}

View File

@@ -1,25 +1,67 @@
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import postgres from 'postgres';
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all';
import {
pokemon,
games,
pokemonGameEntries,
pokemonLocations,
pokemonEncounters,
pokemonEvolutions
} from '../src/lib/server/db/schema/catch-em-all';
type Availability = 'catchable' | 'unknown' | 'evolve' | 'breed' | 'event_only';
type Catalog = {
source: { revision: string };
pokemon: (typeof pokemon.$inferInsert)[];
games: { sourceId: number; name: string; generation: number }[];
entries: [number, number, 'catchable' | 'unknown'][];
games: {
sourceId: number;
name: string;
slug: string;
generation: number;
sortOrder: number;
breeding: boolean;
}[];
entries: [number, number, Availability, string?][];
evolutions: [from: number, to: number, versions: number[], method: string][];
};
type Locations = {
locations: { sourceId: number; slug: string; name: string; region: string | null }[];
methods: string[];
conditions: string[];
encounters: [
number: number,
version: number,
location: number,
area: string | null,
method: number,
minLevel: number,
maxLevel: number,
rarity: number | null,
form: string | null,
conditions: number[]
][];
};
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8')
);
const read = async <T>(name: string): Promise<T> =>
JSON.parse(await readFile(new URL(`../data/${name}`, import.meta.url), 'utf8'));
const catalog = await read<Catalog>('pokemon-catalog.json');
const places = await read<Locations>('pokemon-locations.json');
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
async function inBatches<T>(rows: T[], insert: (batch: T[]) => Promise<unknown>) {
for (let offset = 0; offset < rows.length; offset += 1000)
await insert(rows.slice(offset, offset + 1000));
}
try {
await db.transaction(async (tx) => {
const pokemonIds = new Map<number, number>();
const gameIds = new Map<number, number>();
const locationIds = new Map<number, number>();
for (const row of catalog.pokemon) {
const [saved] = await tx
.insert(pokemon)
@@ -47,38 +89,110 @@ try {
.returning();
gameIds.set(row.sourceId, saved.id);
}
for (let offset = 0; offset < catalog.entries.length; offset += 1000) {
const rows = catalog.entries
.slice(offset, offset + 1000)
.map(([number, version, availability]) => {
const pokemonId = pokemonIds.get(number),
gameId = gameIds.get(version);
if (!pokemonId || !gameId)
throw new Error(`Unknown catalog reference ${number}/${version}`);
return {
pokemonId,
gameId,
availability,
note:
availability === 'catchable'
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
};
});
await tx
const lookup = (map: Map<number, number>, key: number, kind: string) => {
const id = map.get(key);
if (!id) throw new Error(`Unknown catalog ${kind} ${key}`);
return id;
};
await inBatches(catalog.entries, (batch) =>
tx
.insert(pokemonGameEntries)
.values(rows)
.values(
batch.map(([number, version, availability, note]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
availability,
note: note ?? null,
source:
availability === 'catchable' || availability === 'unknown'
? ('pokeapi' as const)
: ('derived' as const)
}))
)
.onConflictDoUpdate({
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
set: { availability: sql`excluded.availability`, note: sql`excluded.note` },
// Preserve hand-maintained availability and notes.
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'`
});
set: {
availability: sql`excluded.availability`,
note: sql`excluded.note`,
source: sql`excluded.source`
},
// Admin corrections are never overwritten.
setWhere: ne(pokemonGameEntries.source, 'manual')
})
);
for (const row of places.locations) {
const [saved] = await tx
.insert(pokemonLocations)
.values(row)
.onConflictDoUpdate({
target: pokemonLocations.sourceId,
// descriptions and pictures are site-owned and kept
set: { slug: row.slug, name: row.name, region: row.region },
setWhere: ne(pokemonLocations.source, 'manual')
})
.returning();
locationIds.set(row.sourceId, saved?.id ?? 0);
}
for (const [sourceId, id] of locationIds) {
if (id) continue;
const [manual] = await tx
.select({ id: pokemonLocations.id })
.from(pokemonLocations)
.where(eq(pokemonLocations.sourceId, sourceId));
locationIds.set(sourceId, manual.id);
}
// Encounters and evolutions have no natural key; replace imported rows wholesale.
await tx.delete(pokemonEncounters).where(ne(pokemonEncounters.source, 'manual'));
await inBatches(places.encounters, (batch) =>
tx.insert(pokemonEncounters).values(
batch.map(
([
number,
version,
location,
area,
method,
minLevel,
maxLevel,
rarity,
form,
conditions
]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
locationId: lookup(locationIds, location, 'location'),
area,
method: places.methods[method],
minLevel,
maxLevel,
rarity,
form,
conditions: conditions.map((index) => places.conditions[index])
})
)
)
);
await tx.delete(pokemonEvolutions).where(ne(pokemonEvolutions.source, 'manual'));
const evolutions = catalog.evolutions.flatMap(([from, to, versions, method]) =>
versions.map((version) => ({
fromPokemonId: lookup(pokemonIds, from, 'Pokémon'),
toPokemonId: lookup(pokemonIds, to, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
method
}))
);
await inBatches(evolutions, (batch) =>
tx.insert(pokemonEvolutions).values(batch).onConflictDoNothing()
);
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries, ` +
`${places.locations.length} locations, ${places.encounters.length} encounters and ` +
`${evolutions.length} evolution methods from ${catalog.source.revision}`
);
});
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
);
} finally {
await client.end();
}

23
scripts/seed-cod-art.ts Normal file
View File

@@ -0,0 +1,23 @@
import postgres from 'postgres';
import artwork from '../data/cod-art.json';
import achievementArt from '../data/cod-achievement-art.json';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
await sql.begin(async (tx) => {
for (const image of artwork) {
const rows =
await tx`update tracker_items i set icon=${image.path} from tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
}
for (const [source, path] of Object.entries(achievementArt)) {
await tx`update tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
}
});
console.log(
`Seeded ${artwork.length} weapon pictures and ${Object.keys(achievementArt).length} achievement icons; progress preserved.`
);
} finally {
await sql.end();
}

164
scripts/seed-cod-catalog.ts Normal file
View File

@@ -0,0 +1,164 @@
import achievementArt from '../data/cod-achievement-art.json';
const achievementImages: Record<string, string> = achievementArt;
import artwork from '../data/cod-art.json';
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
import postgres from 'postgres';
import {
trackerGames,
trackerItems,
trackerProgress
} from '../src/lib/server/db/schema/game-tracker';
import { trackerWeaponRanks } from '../src/lib/server/db/schema/tracker-journal';
import type { MasteryRules } from '../src/lib/game-tracker/mastery';
type Catalog = {
games: (Omit<MasteryRules, 'classes'> & {
slug: string;
name: string;
shortName: string;
releaseYear: number;
classes: { name: string; weapons: string[]; postLaunch?: string[] }[];
achievements: {
key: string;
category: string;
name: string;
description: string | null;
icon: string;
}[];
})[];
};
// Keys are derived from weapon names, so renaming a weapon starts its progress over.
const weaponKey = (name: string) =>
`weapon:${name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`;
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/call-of-duty.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
try {
await db.transaction(async (tx) => {
for (const [order, game] of catalog.games.entries()) {
const rules: MasteryRules = {
milestones: game.milestones,
tiers: game.tiers,
classes: game.classes.map((c) => c.name),
steamApps: game.steamApps,
rankCaps: game.rankCaps
};
const values = {
slug: game.slug,
franchise: 'call-of-duty',
name: game.name,
shortName: game.shortName,
releaseYear: game.releaseYear,
sortOrder: order,
rules
};
const [saved] = await tx
.insert(trackerGames)
.values(values)
.onConflictDoUpdate({ target: trackerGames.slug, set: values })
.returning();
const weapons = game.classes.flatMap((c) =>
[
...c.weapons.map((name) => ({ name, postLaunch: false })),
...(c.postLaunch ?? []).map((name) => ({ name, postLaunch: true }))
].map((weapon) => ({ ...weapon, category: c.name }))
);
const items = [
...weapons.map((weapon, index) => ({
gameId: saved.id,
key: weaponKey(weapon.name),
kind: 'weapon' as const,
category: weapon.category,
name: weapon.name,
description: null,
icon:
artwork.find((a) => a.game === game.slug && a.key === weaponKey(weapon.name))?.path ??
null,
postLaunch: weapon.postLaunch,
sortOrder: index
})),
...game.achievements.map((achievement, index) => ({
gameId: saved.id,
key: achievement.key,
kind: 'achievement' as const,
category: achievement.category,
name: achievement.name,
description: achievement.description,
icon: achievementImages[achievement.icon] ?? achievement.icon,
postLaunch: false,
sortOrder: index
}))
];
await tx
.insert(trackerItems)
.values(items)
.onConflictDoUpdate({
target: [trackerItems.gameId, trackerItems.key],
set: {
kind: sql`excluded.kind`,
category: sql`excluded.category`,
name: sql`excluded.name`,
description: sql`excluded.description`,
icon: sql`excluded.icon`,
postLaunch: sql`excluded.post_launch`,
sortOrder: sql`excluded.sort_order`
}
});
// Items dropped from the catalog disappear unless someone has progress on them.
const stale = await tx
.select({ id: trackerItems.id, name: trackerItems.name })
.from(trackerItems)
.where(
and(
eq(trackerItems.gameId, saved.id),
notInArray(
trackerItems.key,
items.map((item) => item.key)
)
)
);
if (stale.length) {
const kept = await tx
.selectDistinct({ id: trackerProgress.itemId })
.from(trackerProgress)
.where(
inArray(
trackerProgress.itemId,
stale.map((item) => item.id)
)
);
const ranked = await tx
.select({ id: trackerWeaponRanks.itemId })
.from(trackerWeaponRanks)
.where(
inArray(
trackerWeaponRanks.itemId,
stale.map((item) => item.id)
)
);
const keep = new Set([...kept, ...ranked].map((row) => row.id));
const remove = stale.filter((item) => !keep.has(item.id)).map((item) => item.id);
if (remove.length) await tx.delete(trackerItems).where(inArray(trackerItems.id, remove));
for (const item of stale.filter((item) => keep.has(item.id)))
console.warn(
`${game.shortName}: kept "${item.name}", which has progress but left the catalog`
);
}
console.log(
`${game.shortName}: ${weapons.length} weapons, ${game.achievements.length} achievements`
);
}
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/cod-challenges.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
for (const [slug, rows] of Object.entries(catalog.games) as [
string,
{
key: string;
name: string;
category: string;
requirement: string;
reward: string | null;
icon: string | null;
target: number | null;
sourceUrl: string;
}[]
][]) {
const [game] =
await tx`select id from tracker_games where slug=${slug} and franchise='call-of-duty'`;
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows
.slice(offset, offset + 500)
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
await tx`insert into tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
}
// Retire removed source definitions only when nobody has saved progress.
if (rows.length)
await tx`delete from tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from tracker_challenge_progress p where p.challenge_id=c.id)`;
console.log(`${slug}: ${rows.length} challenge rows`);
}
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/pokemon-research.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
const species = new Map(
(await tx`select id, number from pokemon`).map((r) => [r.number, r.id])
);
const games = new Map(
(await tx`select id, source_id from games`).map((r) => [r.source_id, r.id])
);
const speciesId = (n: number) => {
const id = species.get(n);
if (!id) throw new Error(`Seed species ${n} first`);
return id;
};
const gameId = (n: number) => {
const id = games.get(n);
if (!id) throw new Error(`Seed game ${n} first`);
return id;
};
for (const row of catalog.facts)
await tx`insert into pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
for (const row of catalog.games) {
if (!games.has(row.sourceId)) continue;
await tx`insert into pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
}
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
const batch = catalog.dexes
.slice(offset, offset + 1000)
.filter((r: number[]) => games.has(r[1]))
.map(([n, v, d, name, number]: [number, number, string, string, number]) => ({
pokemon_id: speciesId(n),
game_id: gameId(v),
dex: d,
dex_name: name,
number
}));
if (batch.length)
await tx`insert into pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
}
for (const row of catalog.moves)
await tx`insert into pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
const batch = catalog.learnsets
.slice(offset, offset + 500)
.map(([n, version_group, moves]: [number, number, never]) => ({
pokemon_id: speciesId(n),
version_group,
moves: tx.json(moves)
}));
await tx`insert into pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
}
console.log(
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
);
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,71 @@
-- Additive, repeatable journal and factual catalog expansion. Existing progress is preserved.
CREATE TABLE IF NOT EXISTS pokemon_seen (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE,
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(user_id, game_id, pokemon_id)
);
CREATE INDEX IF NOT EXISTS pokemon_seen_user_idx ON pokemon_seen(user_id);
CREATE TABLE IF NOT EXISTS pokemon_research (
pokemon_id integer PRIMARY KEY REFERENCES pokemon(id) ON DELETE CASCADE, facts jsonb NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_dex_numbers (
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, dex text NOT NULL, dex_name text NOT NULL, number integer NOT NULL, UNIQUE(pokemon_id,game_id,dex)
);
CREATE TABLE IF NOT EXISTS pokemon_moves (
id integer PRIMARY KEY, name text NOT NULL, type text NOT NULL, category text NOT NULL, power integer, accuracy integer, pp integer, priority integer NOT NULL, target text NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_learnsets (
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, version_group integer NOT NULL, moves jsonb NOT NULL, UNIQUE(pokemon_id,version_group)
);
CREATE TABLE IF NOT EXISTS pokemon_game_research (
game_id integer PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, version_group integer NOT NULL, regions text[] NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_journeys (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), location text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
);
CREATE TABLE IF NOT EXISTS pokemon_catches (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, nickname text NOT NULL DEFAULT '', level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 100), moves text[] NOT NULL DEFAULT '{}' CHECK(cardinality(moves)<=4), party_slot integer CHECK(party_slot BETWEEN 1 AND 6), shiny boolean NOT NULL DEFAULT false, nature text, ability text, ball text, location text, obtained text NOT NULL DEFAULT 'caught', stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), notes text NOT NULL DEFAULT '', caught_on date, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,party_slot)
);
CREATE TABLE IF NOT EXISTS pokemon_milestones (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, key text NOT NULL, title text NOT NULL, category text NOT NULL, stage text NOT NULL DEFAULT 'story', done boolean NOT NULL DEFAULT false, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,key)
);
CREATE TABLE IF NOT EXISTS tracker_ranks (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
);
CREATE TABLE IF NOT EXISTS tracker_weapon_ranks (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES tracker_items(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,item_id)
);
CREATE TABLE IF NOT EXISTS tracker_challenges (
id serial PRIMARY KEY, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, key text NOT NULL, name text NOT NULL, category text NOT NULL, requirement text NOT NULL, reward text, icon text, target integer, source_url text NOT NULL, UNIQUE(game_id,key)
);
CREATE TABLE IF NOT EXISTS tracker_challenge_progress (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, challenge_id integer NOT NULL REFERENCES tracker_challenges(id) ON DELETE CASCADE, current integer NOT NULL DEFAULT 0 CHECK(current >= 0), done boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,challenge_id)
);
CREATE TABLE IF NOT EXISTS osu_entries (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, kind text NOT NULL CHECK(kind IN ('score','goal','training')), title text NOT NULL, mode text NOT NULL DEFAULT 'osu', beatmap_url text, score_url text, goal text NOT NULL DEFAULT 'pass', skill text NOT NULL DEFAULT 'stamina', bpm integer, duration integer, accuracy text, combo integer, misses integer, pp text, mods text NOT NULL DEFAULT 'NM', rate text NOT NULL DEFAULT '1.00', achieved boolean NOT NULL DEFAULT false, played_on date NOT NULL, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS pokemon_dex_game_idx ON pokemon_dex_numbers(game_id);
CREATE INDEX IF NOT EXISTS pokemon_catches_owner_idx ON pokemon_catches(user_id,game_id,pokemon_id);
CREATE INDEX IF NOT EXISTS osu_entries_owner_idx ON osu_entries(user_id,kind);
ALTER TABLE pokemon_journeys ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
-- Historical catches were necessarily seen; repeatable and non-destructive.
INSERT INTO pokemon_seen (user_id, game_id, pokemon_id)
SELECT u.user_id, e.game_id, e.pokemon_id FROM user_entries u
JOIN pokemon_game_entries e ON e.id = u.pokemon_game_entry_id
ON CONFLICT DO NOTHING;

View File

@@ -1,4 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn-svelte/tailwind.css';
@import '@fontsource-variable/montserrat';
@custom-variant dark (&:is(.dark *));
@plugin '@tailwindcss/typography';
@theme {
@@ -11,13 +16,50 @@
--color-text-muted: #a1a1a1;
--color-text-dim: #888;
--color-text-ghost: #666;
--color-accent: #ededed;
--color-accent: var(--accent);
--color-accent-hover: #fff;
--color-danger: #b44;
--font-heading: Montserrat, Arial, Helvetica, sans-serif;
--font-heading: 'Montserrat Variable', Arial, Helvetica, sans-serif;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-body: Montserrat, Arial, Helvetica, sans-serif;
--font-body: 'Montserrat Variable', Arial, Helvetica, sans-serif;
--font-sans: 'Montserrat Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
@@ -28,6 +70,38 @@
--site-text: #ededed;
--site-muted: #a1a1a1;
--site-border: #292929;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
:root[data-theme='light'] {
@@ -116,10 +190,12 @@
box-sizing: border-box;
margin: 0;
padding: 0;
@apply border-border outline-ring/50;
}
html {
scroll-behavior: smooth;
@apply font-sans;
}
section[id] {
@@ -134,6 +210,7 @@
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@apply bg-background text-foreground;
}
button,
@@ -143,6 +220,14 @@
font: inherit;
}
input:not([data-slot]):not([type='checkbox']):not([type='radio']):not([type='file']):not(
[type='hidden']
),
textarea:not([data-slot]),
select:not([data-slot]) {
@apply rounded-md border border-input bg-background px-2.5 py-1 text-foreground shadow-xs;
}
:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 3px;
@@ -157,3 +242,28 @@
text-decoration: none;
}
}
/* Keep the site's black and white palette across every shadcn component. */
.dark {
--background: #000;
--foreground: #ededed;
--card: #0a0a0a;
--card-foreground: #ededed;
--popover: #0a0a0a;
--popover-foreground: #ededed;
--primary: #ededed;
--primary-foreground: #000;
--secondary: #171717;
--secondary-foreground: #ededed;
--muted: #171717;
--muted-foreground: #a1a1a1;
--accent: #171717;
--accent-foreground: #ededed;
--border: #292929;
--input: #292929;
--ring: #ededed;
--sidebar: #0a0a0a;
--sidebar-foreground: #ededed;
--sidebar-accent: #171717;
--sidebar-border: #292929;
}

4
src/app.d.ts vendored
View File

@@ -10,7 +10,9 @@ declare global {
session: typeof import('$lib/server/auth').auth.$Infer.Session.session | null;
}
// interface PageData {}
// interface PageState {}
interface PageState {
animal?: import('$lib/animaldex/AnimalDetails.svelte').AnimalDetail;
}
// interface Platform {}
}
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark" data-theme="dark">
<head>
<meta name="theme-color" content="#000000" />
<script>
@@ -17,6 +17,10 @@
? 'light'
: 'dark'
: themePreference;
document.documentElement.classList.toggle(
'dark',
document.documentElement.dataset.theme === 'dark'
);
document.querySelector('meta[name="theme-color"]').content =
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
</script>
@@ -37,12 +41,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>

View File

@@ -0,0 +1,139 @@
<script lang="ts" module>
export interface AnimalDetail {
id: number;
species: string;
breed: string | null;
animalName: string | null;
description: string | null;
sightings: {
id: number;
seenAt: string;
reporterName: string | null;
lat: number;
lng: number;
photos: { id: number; url: string }[];
}[];
}
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import PawPrint from 'lucide-svelte/icons/paw-print';
let { animal }: { animal: AnimalDetail } = $props();
const title = $derived(animal.animalName || animal.species);
const allPhotos = $derived(animal.sightings.flatMap((s) => s.photos));
let activePhotoId = $state<number | null>(null);
const activePhoto = $derived(allPhotos.find((p) => p.id === activePhotoId) ?? allPhotos[0]);
const firstSeen = $derived(animal.sightings.at(-1)?.seenAt);
const lastSeen = $derived(animal.sightings[0]?.seenAt);
function formatDate(value: string) {
return new Date(value).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
</script>
<div class="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div>
<div class="aspect-[4/3] overflow-hidden rounded bg-[var(--site-surface)]">
{#if activePhoto}
<img src={activePhoto.url} alt={title} class="h-full w-full object-cover" />
{:else}
<div class="flex h-full items-center justify-center text-[var(--site-muted)]">
<PawPrint size={64} aria-hidden="true" />
</div>
{/if}
</div>
{#if allPhotos.length > 1}
<ul class="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="Photos">
{#each allPhotos as photo (photo.id)}
<li class="shrink-0">
<button
type="button"
onclick={() => (activePhotoId = photo.id)}
aria-label="Show photo"
aria-pressed={photo.id === activePhoto?.id}
class="block h-16 w-16 overflow-hidden rounded border-2 {photo.id === activePhoto?.id
? 'border-[var(--site-text)]'
: 'border-transparent opacity-70 hover:opacity-100'}"
>
<img src={photo.url} alt="" class="h-full w-full object-cover" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div>
<h2 class="text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h2>
<p class="mt-2 text-lg text-[var(--site-muted)]">
{animal.species}{animal.breed ? ` · ${animal.breed}` : ''}
</p>
{#if animal.description}
<p class="mt-5 text-base whitespace-pre-line">{animal.description}</p>
{/if}
<dl class="mt-6 grid grid-cols-3 gap-4 border-y border-[var(--site-border)] py-4">
<div>
<dt class="text-sm text-[var(--site-muted)]">sightings</dt>
<dd class="text-xl font-semibold">{animal.sightings.length}</dd>
</div>
{#if firstSeen}
<div>
<dt class="text-sm text-[var(--site-muted)]">first seen</dt>
<dd class="text-base font-medium">{formatDate(firstSeen)}</dd>
</div>
{/if}
{#if lastSeen}
<div>
<dt class="text-sm text-[var(--site-muted)]">last seen</dt>
<dd class="text-base font-medium">{formatDate(lastSeen)}</dd>
</div>
{/if}
</dl>
<a
href={resolve(`/projects/animaldex/register?animalId=${animal.id}`)}
class="mt-6 inline-flex items-center gap-2 rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
>
i saw this animal too <ArrowRight size={16} aria-hidden="true" />
</a>
<h3 class="mt-8 mb-2 text-lg font-semibold">sighting history</h3>
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each animal.sightings as s (s.id)}
<li class="flex items-center gap-3 py-3 text-base">
{#if s.photos[0]}
<button
type="button"
onclick={() => (activePhotoId = s.photos[0].id)}
aria-label="Show photo from {formatDate(s.seenAt)}"
class="h-10 w-10 shrink-0 overflow-hidden rounded"
>
<img src={s.photos[0].url} alt="" class="h-full w-full object-cover" />
</button>
{:else}
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-[var(--site-surface)] text-[var(--site-muted)]"
><PawPrint size={18} aria-hidden="true" /></span
>
{/if}
<span class="min-w-0">
<span class="block">{formatDate(s.seenAt)}</span>
{#if s.reporterName}<span class="block text-[var(--site-muted)]"
>by {s.reporterName}</span
>{/if}
</span>
</li>
{/each}
</ul>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,5 +1,6 @@
// How a Pokémon can be obtained in a game. Missing rows mean no source record;
// they do not prove absence. Unknown rows establish presence, not availability.
// New values go at the end: the client receives availability as an index.
// Shared between the db schema and the client, which receives availability as
// an index into this array to keep the page payload small.
export const availabilityValues = [
@@ -7,7 +8,26 @@ export const availabilityValues = [
'trade_only',
'transfer_only',
'event_only',
'unknown'
'unknown',
'evolve',
'breed'
] as const;
export type Availability = (typeof availabilityValues)[number];
export const availabilityLabels: Record<Availability, string> = {
catchable: 'catch it',
trade_only: 'trade only',
transfer_only: 'transfer only',
event_only: 'event only',
unknown: 'not confirmed',
evolve: 'evolve it',
breed: 'breed it'
};
// Obtainable inside the game itself, so it counts toward progress and can be ticked.
export const obtainableAvailability: ReadonlySet<Availability> = new Set([
'catchable',
'evolve',
'breed'
]);

View File

@@ -0,0 +1,340 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let moveSearch = $state('');
let moveMethod = $state('all');
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability
} from '$lib/availability';
import type { PokemonDetail } from '$lib/server/pokemon-detail';
let {
number,
gameId,
isAdmin = false,
onsaved
}: { number: number; gameId: number | null; isAdmin?: boolean; onsaved?: () => void } = $props();
let detail = $state<PokemonDetail | null>(null);
let loadError = $state('');
let formMessage = $state('');
let version = $state(0);
$effect(() => {
const controller = new AbortController();
const url = `/projects/game-tracker/pokemon/api/${number}${gameId ? `?game=${gameId}` : ''}`;
void version;
loadError = '';
fetch(url, { signal: controller.signal })
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((body: PokemonDetail) => (detail = body))
.catch((reason) => {
if (!controller.signal.aborted) loadError = `Could not load details (${reason}).`;
});
return () => controller.abort();
});
const current = $derived(detail?.pokemon.number === number ? detail : null);
const steps = $derived(
current
? current.chain
.map((member) => ({
member,
from: current.chain.find((other) => other.number === member.evolvesFrom)
}))
// without a game, show the whole chain; with one, only steps that exist in it
.filter(({ member, from }) => from && (!gameId || (member.inGame && from.inGame)))
.map(({ member, from }) => {
const methods = current.evolutions
.filter((evolution) => evolution.to === member.number)
.map((evolution) => evolution.method);
return { from: from?.name ?? '?', to: member.name, number: member.number, methods };
})
: []
);
const obtainableGames = $derived(
current?.games.filter((game) => obtainableAvailability.has(game.availability)) ?? []
);
const otherGames = $derived(
current?.games.filter((game) => !obtainableAvailability.has(game.availability)) ?? []
);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
{#if loadError}
<p role="alert" class="text-red-500">{loadError}</p>
{:else if !current}
<p class="text-[var(--site-muted)]">loading…</p>
{:else}
{@const species = current.pokemon}
<div class="flex flex-wrap items-center gap-5">
<img
src={pokemonImage(species.number, 'artwork')}
alt=""
width="120"
height="120"
class="h-28 w-28 shrink-0"
/>
<div class="min-w-0">
<p class="text-[var(--site-muted)]">#{species.number} · generation {species.generation}</p>
<h2 class="text-3xl font-semibold">{species.name}</h2>
<p class="mt-1 text-[var(--site-muted)]">
{species.type1}{species.type2 ? ` / ${species.type2}` : ''}
{#if species.isMythical}· mythical{:else if species.isLegendary}· legendary{/if}
{#if species.isBaby}· baby{/if}
</p>
</div>
</div>
<section class="mt-6 rounded-xl border p-4">
<h3 class="font-semibold">Base stats</h3>
<div class="mt-3 grid gap-2">
{#each current.stats as stat (stat.name)}<div
class="grid grid-cols-[5rem_2rem_1fr] items-center gap-3 text-xs"
>
<span>{stat.name}</span><span class="font-semibold tabular-nums">{stat.value}</span>
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-emerald-500/70"
style:width={`${(stat.value / 255) * 100}%`}
></div>
</div>
</div>{/each}
</div>
<p class="mt-3 text-xs text-muted-foreground">
Total: {current.stats.reduce((sum, s) => sum + s.value, 0)} · default form, current catalog values
</p>
</section>
{#if current.facts}
<section class="mt-5">
<h3 class="font-semibold">Species research</h3>
<dl class="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-muted-foreground">Abilities</dt>
<dd>
{current.facts.abilities
.map((a) => `${a.name}${a.hidden ? ' (hidden)' : ''}`)
.join(', ')}
</dd>
</div>
<div>
<dt class="text-muted-foreground">Height / weight</dt>
<dd>{current.facts.height / 10} m · {current.facts.weight / 10} kg</dd>
</div>
<div>
<dt class="text-muted-foreground">Egg groups</dt>
<dd>{current.facts.eggGroups.join(', ')}</dd>
</div>
<div>
<dt class="text-muted-foreground">Growth rate</dt>
<dd>{current.facts.growthRate}</dd>
</div>
<div>
<dt class="text-muted-foreground">Catch rate / base friendship</dt>
<dd>{current.facts.captureRate} / {current.facts.baseHappiness ?? '—'}</dd>
</div>
<div>
<dt class="text-muted-foreground">EV yield</dt>
<dd>
{current.facts.evYield
.map((v, i) => (v ? `${v} ${current.stats[i]?.name}` : ''))
.filter(Boolean)
.join(', ') || 'None'}
</dd>
</div>
</dl>
</section>
{/if}
<section class="mt-6">
<h3 class="font-semibold">Moves in {current.entry?.game ?? 'this game'}</h3>
{#if current.learnset.length}
<div class="mt-3 grid grid-cols-2 gap-3">
<Input
type="search"
aria-label="Search moves"
placeholder="Search moves…"
bind:value={moveSearch}
/><NativeSelect aria-label="Learn method" class="w-full" bind:value={moveMethod}
><option value="all">All methods</option
>{#each [...new Set(current.learnset.map((m) => m.method))] as method (method)}<option
value={method}>{method}</option
>{/each}</NativeSelect
>
</div>
<div class="mt-3 max-h-72 overflow-auto">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Move</Table.Head><Table.Head>Learn</Table.Head><Table.Head
>Power</Table.Head
><Table.Head>Accuracy</Table.Head><Table.Head>PP</Table.Head></Table.Row
></Table.Header
><Table.Body
>{#each current.learnset.filter((m) => (moveMethod === 'all' || moveMethod === m.method) && `${m.name} ${m.type}`
.toLowerCase()
.includes(moveSearch.toLowerCase())) as move, i (`${move.id}-${move.method}-${move.level}-${i}`)}<Table.Row
><Table.Cell
><span class="font-medium">{move.name}</span><span
class="block text-xs text-muted-foreground">{move.type} · {move.category}</span
></Table.Cell
><Table.Cell>{move.method}{move.level ? ` · Lv. ${move.level}` : ''}</Table.Cell
><Table.Cell>{move.power ?? '—'}</Table.Cell><Table.Cell
>{move.accuracy === null ? '—' : `${move.accuracy}%`}</Table.Cell
><Table.Cell>{move.pp ?? '—'}</Table.Cell></Table.Row
>{/each}</Table.Body
></Table.Root
>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Learning methods are game-specific. Move power, accuracy and PP use the current catalog
snapshot.
</p>
{:else}<p class="mt-2 text-sm text-muted-foreground">
No learnset is recorded for this game.
</p>{/if}
</section>
{#if current.entry}
<section class="mt-6">
<h3 class="font-semibold">in {current.entry.game}</h3>
<p class="mt-1">
<span class="font-medium">{availabilityLabels[current.entry.availability]}</span>
{#if current.entry.note}<span class="text-[var(--site-muted)]">
· {current.entry.note}</span
>{/if}
</p>
{#if current.entry.source === 'manual'}<p class="mt-1 text-sm text-[var(--site-muted)]">
corrected by hand
</p>{/if}
</section>
{:else if gameId}
<p class="mt-6 text-[var(--site-muted)]">no catalog record for this game.</p>
{/if}
{#if steps.length}
<section class="mt-6">
<h3 class="font-semibold">evolution</h3>
<ul class="mt-2">
{#each steps as step (step.number)}
<li class="border-t border-[var(--site-border)] py-2">
<span class:font-semibold={step.number === species.number}>{step.from} → {step.to}</span
>
<span class="block text-[var(--site-muted)]">
{step.methods.length ? step.methods.join(' or ') : 'not possible in this game'}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if current.encounters.length}
<section class="mt-6">
<h3 class="font-semibold">where to find it</h3>
<ul class="mt-2">
{#each current.encounters as encounter, index (index)}
<li class="border-t border-[var(--site-border)] py-2">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(encounter.locationId)
})}
class="underline underline-offset-4">{encounter.location}</a
>{#if encounter.area}<span class="text-[var(--site-muted)]">
· {encounter.area}</span
>{/if}
<span class="block text-[var(--site-muted)]">
{[
encounter.method,
levels(encounter.minLevel, encounter.maxLevel),
encounter.rarity ? `${encounter.rarity}%` : '',
encounter.form ?? '',
...encounter.conditions,
encounter.note ?? ''
]
.filter(Boolean)
.join(' · ')}
</span>
</li>
{/each}
</ul>
</section>
{:else if current.entry?.availability === 'catchable'}
<p class="mt-6 text-[var(--site-muted)]">no encounter details recorded.</p>
{/if}
<section class="mt-6">
<h3 class="font-semibold">other games</h3>
<p class="mt-1">
{obtainableGames.length
? `obtainable in ${obtainableGames.map((game) => game.game).join(', ')}`
: 'not obtainable in any game on record'}
</p>
{#if otherGames.length}<p class="mt-1 text-[var(--site-muted)]">
also present in {otherGames.map((game) => game.game).join(', ')}
</p>{/if}
</section>
{#if isAdmin && current.entry}
<form
method="POST"
action="?/saveEntry"
class="mt-8 border-t border-[var(--site-border)] pt-5"
use:enhance={() => {
formMessage = '';
return async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
formMessage = 'saved';
version++;
onsaved?.();
} else if (result.type === 'failure')
formMessage = String(result.data?.message ?? 'Could not save');
};
}}
>
<h3 class="font-semibold">correct this entry</h3>
<input type="hidden" name="entryId" value={current.entry.id} />
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
<label class="block"
>availability
<NativeSelect
name="availability"
value={current.entry.availability}
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each availabilityValues as value (value)}<option {value}
>{availabilityLabels[value]}</option
>{/each}
</NativeSelect>
</label>
<label class="block"
>note
<input
name="note"
value={current.entry.note ?? ''}
maxlength="500"
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
<label class="mt-3 flex items-center gap-2 text-[var(--site-muted)]"
><input type="checkbox" name="reset" /> let the catalog manage this entry again</label
>
<div class="mt-4 flex items-center gap-4">
<button class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>save</button
>
{#if formMessage}<p role="status" class="text-[var(--site-muted)]">{formMessage}</p>{/if}
</div>
</form>
{/if}
{/if}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { availabilityValues } from '$lib/availability';
import { summarizeProgress } from './progress';
const catchable = availabilityValues.indexOf('catchable');
const transferOnly = availabilityValues.indexOf('transfer_only');
const evolve = availabilityValues.indexOf('evolve');
const breed = availabilityValues.indexOf('breed');
const pokemon = [
{
id: 1,
entries: [
[11, 1, catchable],
[12, 2, catchable]
]
},
{
id: 2,
entries: [
[21, 1, catchable],
[22, 2, transferOnly]
]
},
{ id: 3, entries: [[31, 2, transferOnly]] }
] as const;
describe('Catchemall progress', () => {
it('counts Pokémon obtained by evolving or breeding as catchable', () => {
const chain = [
{ id: 7, entries: [[71, 1, catchable]] },
{ id: 9, entries: [[91, 1, evolve]] },
{ id: 172, entries: [[1721, 1, breed]] },
{ id: 151, entries: [[1511, 1, availabilityValues.indexOf('event_only')]] }
] as const;
expect(summarizeProgress(chain, new Set([1]), new Set([91]))).toMatchObject({
catchableEntries: 3,
caughtEntries: 1,
catchablePokemon: 3
});
});
it('counts unique Pokémon and completed games only from selected catchable entries', () => {
expect(summarizeProgress(pokemon, new Set([1, 2, 3]), new Set([11, 12, 31]))).toEqual({
catchableEntries: 3,
caughtEntries: 2,
catchablePokemon: 2,
seenPokemon: 1,
completedPokemon: 1,
catchableGames: 2,
completedGames: 1
});
});
it('recomputes all denominators when the selected games change', () => {
expect(summarizeProgress(pokemon, new Set([2]), new Set([11, 12, 21]))).toEqual({
catchableEntries: 1,
caughtEntries: 1,
catchablePokemon: 1,
seenPokemon: 1,
completedPokemon: 1,
catchableGames: 1,
completedGames: 1
});
expect(summarizeProgress(pokemon, new Set([3]), new Set([11, 12, 21]))).toEqual({
catchableEntries: 0,
caughtEntries: 0,
catchablePokemon: 0,
seenPokemon: 0,
completedPokemon: 0,
catchableGames: 0,
completedGames: 0
});
});
});

View File

@@ -0,0 +1,56 @@
import { availabilityValues, obtainableAvailability } from '$lib/availability';
type PokemonWithEntries = {
id: number;
entries: readonly (readonly [id: number, gameId: number, availability: number, note?: string])[];
};
export function summarizeProgress(
pokemon: readonly PokemonWithEntries[],
selectedGameIds: ReadonlySet<number>,
caughtIds: ReadonlySet<number>
) {
let catchableEntries = 0;
let caughtEntries = 0;
let catchablePokemon = 0;
let seenPokemon = 0;
let completedPokemon = 0;
const games = new Map<number, { total: number; caught: number }>();
for (const species of pokemon) {
let speciesEntries = 0;
let speciesCaught = 0;
for (const [entryId, gameId, availability] of species.entries) {
if (
!selectedGameIds.has(gameId) ||
!obtainableAvailability.has(availabilityValues[availability])
)
continue;
speciesEntries++;
const game = games.get(gameId) ?? { total: 0, caught: 0 };
game.total++;
if (caughtIds.has(entryId)) {
speciesCaught++;
game.caught++;
}
games.set(gameId, game);
}
if (speciesEntries === 0) continue;
catchableEntries += speciesEntries;
caughtEntries += speciesCaught;
catchablePokemon++;
if (speciesCaught > 0) seenPokemon++;
if (speciesCaught === speciesEntries) completedPokemon++;
}
return {
catchableEntries,
caughtEntries,
catchablePokemon,
seenPokemon,
completedPokemon,
catchableGames: games.size,
completedGames: [...games.values()].filter((game) => game.caught === game.total).length
};
}

View File

@@ -3,6 +3,11 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
let {
mode,
providers,
@@ -67,83 +72,90 @@
</script>
<section class="mx-auto my-12 max-w-md px-4 sm:my-20">
<h1 class="mb-8 text-3xl font-semibold tracking-tight">
{mode === 'register' ? 'create account' : 'sign in'}
</h1>
{#if message || oauthError}
<p role="alert" class="mb-4 rounded bg-red-50 p-3 text-red-800">
{message ||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
</p>
{/if}
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
{#if mode === 'register'}
<label class="flex flex-col gap-1"
>Name<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="name"
autocomplete="name"
required
maxlength="100"
bind:value={name}
/></label
>
{/if}
<label class="flex flex-col gap-1"
>Email<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
/></label
<Card.Root>
<Card.Header
><Card.Title class="text-2xl">{mode === 'register' ? 'create account' : 'sign in'}</Card.Title
><Card.Description>Use your account to save and manage your work.</Card.Description
></Card.Header
>
<label class="flex flex-col gap-1"
>Password<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="password"
type="password"
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
required
minlength={mode === 'register' ? 12 : undefined}
maxlength="128"
bind:value={password}
/></label
>
{#if mode === 'register'}<p class="text-sm text-gray-600">Use at least 12 characters.</p>{/if}
<button
disabled={!ready || busy}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-50"
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</button
>
</form>
{#if providers.google || providers.osu}
<div class="my-6 flex flex-col gap-3 border-t border-[var(--site-border)] pt-4">
{#if providers.google}<button
disabled={!ready || busy}
onclick={() => social('google')}
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
>Continue with Google</button
>{/if}
{#if providers.osu}<button
disabled={!ready || busy}
onclick={() => social('osu')}
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
>Continue with osu!</button
>{/if}
</div>
{/if}
<p class="mt-6 text-base">
<a
class="underline underline-offset-4"
href={resolve(
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
>
</p>
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
<Card.Content>
{#if message || oauthError}
<p
role="alert"
class="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{message ||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
</p>
{/if}
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
{#if mode === 'register'}
<div class="space-y-2">
<Label for="auth-name">Name</Label><Input
id="auth-name"
disabled={!ready || busy}
name="name"
autocomplete="name"
required
maxlength={100}
bind:value={name}
/>
</div>
{/if}
<div class="space-y-2">
<Label for="auth-email">Email</Label><Input
id="auth-email"
disabled={!ready || busy}
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
/>
</div>
<div class="space-y-2">
<Label for="auth-password">Password</Label><Input
id="auth-password"
disabled={!ready || busy}
name="password"
type="password"
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
required
minlength={mode === 'register' ? 12 : undefined}
maxlength={128}
bind:value={password}
/>{#if mode === 'register'}<p class="text-sm text-muted-foreground">
Use at least 12 characters.
</p>{/if}
</div>
<Button type="submit" disabled={!ready || busy} class="w-full"
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</Button
>
</form>
{#if providers.google || providers.osu}
<div class="my-6 flex flex-col gap-3">
<Separator />
{#if providers.google}<Button
variant="outline"
disabled={!ready || busy}
onclick={() => social('google')}>Continue with Google</Button
>{/if}
{#if providers.osu}<Button
variant="outline"
disabled={!ready || busy}
onclick={() => social('osu')}>Continue with osu!</Button
>{/if}
</div>
{/if}
<p class="mt-6 text-base">
<a
class="underline underline-offset-4"
href={resolve(
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
>
</p>
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
</Card.Content>
</Card.Root>
</section>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
let {
icon: Icon,
@@ -30,13 +31,9 @@
copied = false;
}}
>
<button
onclick={copy}
aria-label={`Copy ${label}: ${value}`}
class="flex h-10 w-10 cursor-pointer items-center justify-center text-xl transition hover:text-[var(--site-text)]"
>
<Icon />
</button>
<Button variant="ghost" size="icon-lg" onclick={copy} aria-label={`Copy ${label}: ${value}`}>
<Icon class="size-6" />
</Button>
<span
role="tooltip"

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { Progress } from '$lib/components/ui/progress/index.js';
let {
value,
max,
@@ -12,32 +14,4 @@
} = $props();
</script>
<progress class:small {value} max={max || 1} aria-label={label}></progress>
<style>
progress {
display: block;
width: 100%;
height: 0.625rem;
appearance: none;
border: 0;
border-radius: 9999px;
overflow: hidden;
background: var(--site-border);
}
progress.small {
height: 0.375rem;
}
progress::-webkit-progress-bar {
background: var(--site-border);
border-radius: 9999px;
}
progress::-webkit-progress-value {
background: var(--site-text);
border-radius: 9999px;
}
progress::-moz-progress-bar {
background: var(--site-text);
border-radius: 9999px;
}
</style>
<Progress {value} max={max || 1} aria-label={label} class={small ? 'h-1.5' : 'h-2.5'} />

View File

@@ -0,0 +1,54 @@
<script lang="ts" module>
const images = import.meta.glob<string>('../assets/projects/*.{webp,png,jpg,jpeg,avif,svg}', {
eager: true,
query: '?url',
import: 'default'
});
const extensions = ['webp', 'png', 'jpg', 'jpeg', 'avif', 'svg'];
</script>
<script lang="ts">
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import Globe2 from 'lucide-svelte/icons/globe-2';
import Languages from 'lucide-svelte/icons/languages';
import Music2 from 'lucide-svelte/icons/music-2';
import PawPrint from 'lucide-svelte/icons/paw-print';
import Trophy from 'lucide-svelte/icons/trophy';
import Wallet from 'lucide-svelte/icons/wallet';
let { name, class: className = 'size-5' }: { name: string; class?: string } = $props();
const icons = {
'vash esports': Trophy,
'game tracker': Gamepad2,
animaldex: PawPrint,
'osu! map finder': Music2,
'derby lens': ChartNoAxesCombined,
'world opinions': Globe2,
'language learner': Languages,
'money tracker': Wallet,
'cs prices': CandlestickChart
};
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
const filename = $derived(
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
);
const image = $derived(
extensions
.map((extension) => images[`../assets/projects/${filename}.${extension}`])
.find(Boolean)
);
</script>
{#if image}
<img src={image} alt="" class="size-full rounded-sm object-cover" />
{:else}
<Icon class={className} aria-hidden="true" />
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
size = 'default',
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), '', className)}
{...restProps}
/>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'outline',
size = 'default',
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), '', className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import AlertDialogPortal from './alert-dialog-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
size = 'default',
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: 'default' | 'sm';
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPortal>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn(
'font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,40 @@
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Media from './alert-dialog-media.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Portal from './alert-dialog-portal.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-action"
class={cn('absolute top-2.5 right-3', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: "grid gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current'
}
},
defaultVariants: {
variant: 'default'
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
role="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,17 @@
import Action from './alert-action.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
import Root from './alert.svelte';
export { alertVariants, type AlertVariant } from './alert.svelte';
export {
Root,
Description,
Title,
Action,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
Action as AlertAction
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline'
}
},
defaultVariants: {
variant: 'default'
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
href,
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';

View File

@@ -1,25 +1,91 @@
<script lang="ts">
import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from './index.js';
import { cn } from '$lib/utils.js';
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
type $$Props = Props;
type $$Events = Events;
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default:
'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-9',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
let className: $$Props['class'] = undefined;
export let variant: $$Props['variant'] = 'default';
export let size: $$Props['size'] = 'default';
export let builders: $$Props['builders'] = [];
export { className as class };
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<!-- Callers resolve internal routes before passing href; external links are valid here too. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -1,48 +1,17 @@
import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from './button.svelte';
const buttonVariants = tv({
base: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
Root,
type Props,
type Events,
type ButtonProps as Props,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn('flex flex-col gap-3 px-(--card-spacing)', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn(
'rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center',
className
)}
{...restProps}
>
{@render children?.()}
</div>

Some files were not shown because too many files have changed in this diff Show More