Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
This commit is contained in:
@@ -8,3 +8,13 @@ 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
|
||||
|
||||
438
README.md
438
README.md
@@ -7,6 +7,86 @@ This is my personal website, including:
|
||||
- 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
|
||||
@@ -32,3 +112,361 @@ It runs entirely in the browser: no database tables, migrations or API keys.
|
||||
- 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.
|
||||
|
||||
@@ -600,6 +600,20 @@
|
||||
"name": "Call of Duty: Black Ops II",
|
||||
"shortName": "Black Ops II",
|
||||
"releaseYear": 2012,
|
||||
"rankCaps": {
|
||||
"player": { "level": 55, "prestige": 10 },
|
||||
"weaponPrestige": 2,
|
||||
"weaponLevelsByClass": {
|
||||
"Assault Rifles": 17,
|
||||
"Submachine Guns": 16,
|
||||
"Light Machine Guns": 16,
|
||||
"Sniper Rifles": 10,
|
||||
"Shotguns": 10,
|
||||
"Pistols": 10
|
||||
},
|
||||
"weaponLevelOverrides": { "Ballista": 11, "Crossbow": 6 },
|
||||
"sourceUrl": "https://w.atwiki.jp/blackops2_cod/pages/47.html"
|
||||
},
|
||||
"steamApps": [
|
||||
{
|
||||
"id": 202970,
|
||||
|
||||
620
data/cod-achievement-art.json
Normal file
620
data/cod-achievement-art.json
Normal 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"
|
||||
}
|
||||
1820
data/cod-art.json
Normal file
1820
data/cod-art.json
Normal file
File diff suppressed because it is too large
Load Diff
36766
data/cod-challenges.json
Normal file
36766
data/cod-challenges.json
Normal file
File diff suppressed because it is too large
Load Diff
39352
data/game-images.json
Normal file
39352
data/game-images.json
Normal file
File diff suppressed because it is too large
Load Diff
5568
data/pokemon-atlas.json
Normal file
5568
data/pokemon-atlas.json
Normal file
File diff suppressed because it is too large
Load Diff
3894
data/pokemon-map-art.json
Normal file
3894
data/pokemon-map-art.json
Normal file
File diff suppressed because it is too large
Load Diff
59
data/pokemon-releases.json
Normal file
59
data/pokemon-releases.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
data/pokemon-research.json
Normal file
1
data/pokemon-research.json
Normal file
File diff suppressed because one or more lines are too long
126
docs/game-tracker.md
Normal file
126
docs/game-tracker.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 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 recorded player/weapon ranks, weapon mastery, achievements and challenges. 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 here represents the level-55 prestige cycle and does not model the post-Master level counter.
|
||||
|
||||
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; verified numeric caps for every weapon are not yet cataloged. Search, class filters and short pages keep active weapons nearby. 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.
|
||||
|
||||
202 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 | 33 / 38 |
|
||||
| Ghosts | 20 / 40 |
|
||||
| Advanced Warfare | 34 / 35 |
|
||||
| Black Ops III | 31 / 31 |
|
||||
| Infinite Warfare | 20 / 35 |
|
||||
| WWII | 31 / 31 |
|
||||
|
||||
The 202nd picture is Ghosts' DLC Maverick-A2. 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.
|
||||
12
package.json
12
package.json
@@ -28,7 +28,17 @@
|
||||
"auth:role": "bun scripts/set-admin.ts",
|
||||
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
|
||||
"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"
|
||||
"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",
|
||||
|
||||
15
playwright.tracker.config.ts
Normal file
15
playwright.tracker.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
91
scripts/build-cod-challenges.py
Normal file
91
scripts/build-cod-challenges.py
Normal 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()
|
||||
85
scripts/build-pokemon-atlas.py
Normal file
85
scripts/build-pokemon-atlas.py
Normal 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()})
|
||||
81
scripts/build-pokemon-map-art.py
Normal file
81
scripts/build-pokemon-map-art.py
Normal 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()})
|
||||
119
scripts/build-pokemon-research.py
Normal file
119
scripts/build-pokemon-research.py
Normal 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()
|
||||
32
scripts/check-game-images.py
Normal file
32
scripts/check-game-images.py
Normal 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.')
|
||||
@@ -45,6 +45,12 @@ try {
|
||||
'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
|
||||
@@ -66,6 +72,8 @@ try {
|
||||
!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(
|
||||
@@ -78,7 +86,12 @@ try {
|
||||
(select count(*) from games where source_id is not null) as games,
|
||||
(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 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 ||
|
||||
@@ -88,6 +101,16 @@ try {
|
||||
) {
|
||||
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');
|
||||
}
|
||||
|
||||
60
scripts/download-game-images.py
Normal file
60
scripts/download-game-images.py
Normal 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()
|
||||
15
scripts/migrate-tracker-journal.ts
Normal file
15
scripts/migrate-tracker-journal.ts
Normal 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();
|
||||
}
|
||||
23
scripts/seed-cod-art.ts
Normal file
23
scripts/seed-cod-art.ts
Normal 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();
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
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';
|
||||
@@ -46,7 +49,8 @@ try {
|
||||
milestones: game.milestones,
|
||||
tiers: game.tiers,
|
||||
classes: game.classes.map((c) => c.name),
|
||||
steamApps: game.steamApps
|
||||
steamApps: game.steamApps,
|
||||
rankCaps: game.rankCaps
|
||||
};
|
||||
const values = {
|
||||
slug: game.slug,
|
||||
@@ -76,7 +80,9 @@ try {
|
||||
category: weapon.category,
|
||||
name: weapon.name,
|
||||
description: null,
|
||||
icon: null,
|
||||
icon:
|
||||
artwork.find((a) => a.game === game.slug && a.key === weaponKey(weapon.name))?.path ??
|
||||
null,
|
||||
postLaunch: weapon.postLaunch,
|
||||
sortOrder: index
|
||||
})),
|
||||
@@ -87,7 +93,7 @@ try {
|
||||
category: achievement.category,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
icon: achievementImages[achievement.icon] ?? achievement.icon,
|
||||
postLaunch: false,
|
||||
sortOrder: index
|
||||
}))
|
||||
|
||||
41
scripts/seed-cod-challenges.ts
Normal file
41
scripts/seed-cod-challenges.ts
Normal 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();
|
||||
}
|
||||
65
scripts/seed-pokemon-research.ts
Normal file
65
scripts/seed-pokemon-research.ts
Normal 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();
|
||||
}
|
||||
71
scripts/tracker-journal-migration.sql
Normal file
71
scripts/tracker-journal-migration.sql
Normal 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;
|
||||
BIN
src/lib/assets/projects/animaldex.webp
Normal file
BIN
src/lib/assets/projects/animaldex.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 293 KiB |
@@ -1,4 +1,11 @@
|
||||
<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 {
|
||||
@@ -71,7 +78,7 @@
|
||||
{@const species = current.pokemon}
|
||||
<div class="flex flex-wrap items-center gap-5">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{species.number}.png"
|
||||
src={pokemonImage(species.number, 'artwork')}
|
||||
alt=""
|
||||
width="120"
|
||||
height="120"
|
||||
@@ -88,6 +95,114 @@
|
||||
</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>
|
||||
@@ -191,7 +306,7 @@
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<label class="block"
|
||||
>availability
|
||||
<select
|
||||
<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"
|
||||
@@ -199,7 +314,7 @@
|
||||
{#each availabilityValues as value (value)}<option {value}
|
||||
>{availabilityLabels[value]}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label class="block"
|
||||
>note
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
23
src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
Normal file
23
src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...restProps} />
|
||||
20
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
20
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
src/lib/components/ui/alert-dialog/alert-dialog.svelte
Normal file
7
src/lib/components/ui/alert-dialog/alert-dialog.svelte
Normal 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} />
|
||||
40
src/lib/components/ui/alert-dialog/index.ts
Normal file
40
src/lib/components/ui/alert-dialog/index.ts
Normal 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
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(false),
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||
13
src/lib/components/ui/collapsible/index.ts
Normal file
13
src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Content from './collapsible-content.svelte';
|
||||
import Trigger from './collapsible-trigger.svelte';
|
||||
import Root from './collapsible.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger
|
||||
};
|
||||
12
src/lib/components/ui/native-select/index.ts
Normal file
12
src/lib/components/ui/native-select/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import OptGroup from './native-select-opt-group.svelte';
|
||||
import Option from './native-select-option.svelte';
|
||||
import Root from './native-select.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Option,
|
||||
OptGroup,
|
||||
Root as NativeSelect,
|
||||
Option as NativeSelectOption,
|
||||
OptGroup as NativeSelectOptGroup
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLOptgroupAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOptgroupAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<optgroup bind:this={ref} data-slot="native-select-opt-group" {...restProps}>
|
||||
{@render children?.()}
|
||||
</optgroup>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLOptionAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOptionAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<option
|
||||
bind:this={ref}
|
||||
data-slot="native-select-option"
|
||||
class={cn('bg-[Canvas] text-[CanvasText]', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</option>
|
||||
40
src/lib/components/ui/native-select/native-select.svelte
Normal file
40
src/lib/components/ui/native-select/native-select.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||
|
||||
type NativeSelectProps = Omit<WithElementRef<HTMLSelectAttributes>, 'size'> & {
|
||||
size?: 'sm' | 'default';
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
size = 'default',
|
||||
children,
|
||||
...restProps
|
||||
}: NativeSelectProps = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('group/native-select relative w-fit has-[select:disabled]:opacity-50', className)}
|
||||
data-slot="native-select-wrapper"
|
||||
data-size={size}
|
||||
>
|
||||
<select
|
||||
bind:value
|
||||
bind:this={ref}
|
||||
data-slot="native-select"
|
||||
data-size={size}
|
||||
class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 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 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent py-1 pr-8 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-8 outline-none disabled:pointer-events-none disabled:cursor-not-allowed"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground top-1/2 right-2.5 size-4 -translate-y-1/2 pointer-events-none absolute select-none"
|
||||
aria-hidden
|
||||
data-slot="native-select-icon"
|
||||
/>
|
||||
</div>
|
||||
7
src/lib/components/ui/slider/index.ts
Normal file
7
src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './slider.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider
|
||||
};
|
||||
50
src/lib/components/ui/slider/slider.svelte
Normal file
50
src/lib/components/ui/slider/slider.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = 'horizontal',
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
data-slot="slider-track"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
'bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full'
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbItems as thumb (thumb.index)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb.index}
|
||||
class="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
183
src/lib/game-tracker/CatchEditor.svelte
Normal file
183
src/lib/game-tracker/CatchEditor.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { stages, obtainedMethods, type Stage } from './journal';
|
||||
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
species,
|
||||
gameId,
|
||||
record = null,
|
||||
stage = 'story',
|
||||
location = '',
|
||||
signedIn,
|
||||
ondelete,
|
||||
ondetails
|
||||
}: {
|
||||
open?: boolean;
|
||||
species: { id: number; number: number; name: string };
|
||||
gameId: number;
|
||||
record?: PokemonCatch | null;
|
||||
stage?: Stage;
|
||||
location?: string;
|
||||
signedIn: boolean;
|
||||
ondelete?: () => void;
|
||||
ondetails?: () => void;
|
||||
} = $props();
|
||||
let message = $state('');
|
||||
let saving = $state(false);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>{record ? 'Meet' : 'Register'} {record?.nickname || species.name}</Dialog.Title
|
||||
><Dialog.Description
|
||||
>#{species.number} · {species.name}. Keep this Pokémon’s story, moves and party position
|
||||
together.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if ondetails}<Button variant="link" class="justify-self-start px-0" onclick={ondetails}
|
||||
>All catches, moves and species research →</Button
|
||||
>{/if}
|
||||
{#if message}<p role="alert" class="text-destructive">{message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveCatch"
|
||||
class="space-y-5"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
message = '';
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
if (result.type === 'success') open = false;
|
||||
else
|
||||
message =
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save. Try again.';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={gameId} /><input
|
||||
type="hidden"
|
||||
name="pokemonId"
|
||||
value={species.id}
|
||||
/>{#if record}<input type="hidden" name="id" value={record.id} />{/if}
|
||||
<div class="flex items-center gap-4 rounded-xl bg-muted/40 p-3">
|
||||
<img
|
||||
src={pokemonImage(species.number, record?.shiny ? 'shiny' : 'sprite')}
|
||||
alt={species.name}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
<div class="grid flex-1 grid-cols-[1fr_5rem] gap-3">
|
||||
<label
|
||||
>Nickname<Input
|
||||
name="nickname"
|
||||
value={record?.nickname ?? ''}
|
||||
maxlength={50}
|
||||
placeholder={species.name}
|
||||
/></label
|
||||
><label
|
||||
>Level<Input
|
||||
type="number"
|
||||
name="level"
|
||||
min={1}
|
||||
max={100}
|
||||
required
|
||||
value={record?.level ?? 5}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label
|
||||
>Party position<NativeSelect
|
||||
name="partySlot"
|
||||
value={record?.partySlot ?? ''}
|
||||
class="mt-1 w-full"
|
||||
><option value="">In the PC</option>{#each [1, 2, 3, 4, 5, 6] as slot (slot)}<option
|
||||
value={slot}>Team slot {slot}</option
|
||||
>{/each}</NativeSelect
|
||||
><span class="text-xs text-muted-foreground"
|
||||
>An occupied slot moves its current Pokémon to the PC.</span
|
||||
></label
|
||||
><label
|
||||
>Obtained by<NativeSelect
|
||||
name="obtained"
|
||||
value={record?.obtained ?? 'caught'}
|
||||
class="mt-1 w-full"
|
||||
>{#each obtainedMethods as method (method)}<option value={method}>{method}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-2 font-medium">Current moves</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each [0, 1, 2, 3] as slot (slot)}<Input
|
||||
name="moves"
|
||||
aria-label={`Move ${slot + 1}`}
|
||||
placeholder={`Move ${slot + 1}`}
|
||||
value={record?.moves[slot] ?? ''}
|
||||
maxlength={60}
|
||||
/>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label>Nature<Input name="nature" value={record?.nature ?? ''} maxlength={150} /></label
|
||||
><label>Ability<Input name="ability" value={record?.ability ?? ''} maxlength={150} /></label
|
||||
><label>Poké Ball<Input name="ball" value={record?.ball ?? ''} maxlength={150} /></label
|
||||
><label
|
||||
>Caught on<Input
|
||||
type="date"
|
||||
name="caughtOn"
|
||||
value={record?.caughtOn ?? new Date().toISOString().slice(0, 10)}
|
||||
/></label
|
||||
><label
|
||||
>Location<Input
|
||||
name="location"
|
||||
value={record?.location ?? location}
|
||||
maxlength={150}
|
||||
/></label
|
||||
><label
|
||||
>Collection stage<NativeSelect name="stage" value={record?.stage ?? stage} class="w-full"
|
||||
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<label class="flex items-center gap-2"
|
||||
><Checkbox name="shiny" value="on" checked={record?.shiny ?? false} />Shiny Pokémon</label
|
||||
>
|
||||
<label class="block"
|
||||
>Notes<Textarea
|
||||
name="notes"
|
||||
value={record?.notes ?? ''}
|
||||
maxlength={2000}
|
||||
placeholder="A lucky encounter, a trade partner, a plan for evolution…"
|
||||
/></label
|
||||
>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!signedIn || saving}
|
||||
>{saving ? 'Saving…' : record ? 'Save Pokémon' : 'Add to my collection'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form>
|
||||
{#if record && ondelete}<Button
|
||||
variant="ghost"
|
||||
class="justify-self-start text-destructive"
|
||||
onclick={ondelete}>Remove this catch record</Button
|
||||
>{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
91
src/lib/game-tracker/EncounterGrid.svelte
Normal file
91
src/lib/game-tracker/EncounterGrid.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from './game-art';
|
||||
let {
|
||||
groups,
|
||||
caughtNumbers,
|
||||
onselect
|
||||
}: {
|
||||
groups: [
|
||||
number,
|
||||
{
|
||||
name: string;
|
||||
type1: string;
|
||||
type2: string | null;
|
||||
minLevel: number | null;
|
||||
maxLevel: number | null;
|
||||
}[]
|
||||
][];
|
||||
caughtNumbers: Set<number>;
|
||||
onselect: (number: number) => void;
|
||||
} = $props();
|
||||
let width = $state(600);
|
||||
let height = $state(400);
|
||||
// Fit every species, including broad locations with hundreds of records, into one overview.
|
||||
const columns = $derived(
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
groups.length,
|
||||
Math.ceil(Math.sqrt(((groups.length * width) / Math.max(1, height)) * 0.7))
|
||||
)
|
||||
)
|
||||
);
|
||||
const rows = $derived(Math.max(1, Math.ceil(groups.length / columns)));
|
||||
const cellWidth = $derived(width / columns);
|
||||
const cellHeight = $derived(height / rows);
|
||||
const showName = $derived(cellWidth >= 58 && cellHeight >= 42);
|
||||
const showLevel = $derived(cellWidth >= 65 && cellHeight >= 64);
|
||||
const spriteSize = $derived(
|
||||
Math.max(
|
||||
12,
|
||||
Math.min(48, cellWidth - 8, cellHeight - (showName ? 14 : 0) - (showLevel ? 12 : 0) - 8)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="h-full min-h-0 w-full" bind:clientWidth={width} bind:clientHeight={height}>
|
||||
<div
|
||||
class="encounter-grid grid gap-1"
|
||||
role="group"
|
||||
aria-label="All Pokémon in this area"
|
||||
style:grid-template-columns={`repeat(${columns}, minmax(0,1fr))`}
|
||||
style:grid-template-rows={`repeat(${rows}, minmax(0,1fr))`}
|
||||
style:height={`min(100%, ${rows * 108}px)`}
|
||||
>
|
||||
{#each groups as [number, encounters] (number)}
|
||||
{@const p = encounters[0]}
|
||||
{@const levels = encounters
|
||||
.flatMap((e) => [e.minLevel, e.maxLevel])
|
||||
.filter((n): n is number => n !== null)}
|
||||
{@const min = levels.length ? Math.min(...levels) : null}
|
||||
{@const max = levels.length ? Math.max(...levels) : null}
|
||||
<button
|
||||
class="relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-0.5 hover:bg-muted focus-visible:z-10 focus-visible:outline-2"
|
||||
class:border-emerald-500={caughtNumbers.has(number)}
|
||||
aria-label={`${p.name} ${p.type1}${p.type2 ? ` / ${p.type2}` : ''} encounters${caughtNumbers.has(number) ? ', caught' : ''}`}
|
||||
title={`${p.name} · Lv. ${min ?? '?'}${max !== min ? `–${max}` : ''}${caughtNumbers.has(number) ? ' · caught' : ''}`}
|
||||
onclick={() => onselect(number)}
|
||||
>
|
||||
{#if caughtNumbers.has(number)}<span
|
||||
class="absolute top-0 right-1 text-[10px] text-emerald-600"
|
||||
aria-hidden="true">✓</span
|
||||
>{/if}
|
||||
<img
|
||||
src={pokemonImage(number)}
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
style:width={`${spriteSize}px`}
|
||||
style:height={`${spriteSize}px`}
|
||||
class="shrink-0 object-contain"
|
||||
/>
|
||||
{#if showName}<span class="block w-full truncate text-center text-[10px] font-medium"
|
||||
>{p.name}</span
|
||||
>{/if}
|
||||
{#if showLevel}<span class="text-[10px] text-muted-foreground"
|
||||
>Lv. {min ?? '?'}{max !== min ? `–${max}` : ''}</span
|
||||
>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
24
src/lib/game-tracker/GameLogo.svelte
Normal file
24
src/lib/game-tracker/GameLogo.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { base } from '$app/paths';
|
||||
let {
|
||||
slug,
|
||||
name,
|
||||
class: className = ''
|
||||
}: { slug: string; name: string; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md',
|
||||
slug === 'call-of-duty' ? 'bg-zinc-900 p-2' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${base}/game-logos/${slug}.webp`}
|
||||
alt={`${name} artwork`}
|
||||
class={`max-h-full max-w-full object-contain ${slug === 'osu' ? 'rounded-full bg-[#ff66aa]' : ''}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
732
src/lib/game-tracker/PokemonJournal.svelte
Normal file
732
src/lib/game-tracker/PokemonJournal.svelte
Normal file
@@ -0,0 +1,732 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { untrack } from 'svelte';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import PokemonProgress from './PokemonProgress.svelte';
|
||||
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
|
||||
import PokemonMap from './PokemonMap.svelte';
|
||||
import CatchEditor from './CatchEditor.svelte';
|
||||
import { stages, firstCatches, type Stage } from './journal';
|
||||
import { gameRegions, initialLocation, milestonesFor } from './pokemon-world';
|
||||
import {
|
||||
availabilityLabels,
|
||||
availabilityValues,
|
||||
obtainableAvailability
|
||||
} from '$lib/availability';
|
||||
import type { PageData } from '../../routes/projects/game-tracker/pokemon/$types';
|
||||
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
|
||||
let { data }: { data: PageData } = $props();
|
||||
const game = $derived(data.activeGame!);
|
||||
const gameProgress = $derived(data.progress.games.find((g) => g.id === game.id));
|
||||
const seenIds = new SvelteSet(untrack(() => data.seenIds));
|
||||
$effect(() => {
|
||||
for (const id of data.seenIds) seenIds.add(id);
|
||||
});
|
||||
const seenNumbers = $derived(
|
||||
new Set(data.pokemon.filter((p) => seenIds.has(p.id)).map((p) => p.number))
|
||||
);
|
||||
const pendingSeen = new SvelteSet<number>();
|
||||
async function setSeen(number: number, seen: boolean) {
|
||||
const species = speciesByNumber.get(number);
|
||||
if (!species || pendingSeen.has(number)) return;
|
||||
pendingSeen.add(number);
|
||||
message = '';
|
||||
const body = new FormData();
|
||||
body.set('gameId', String(game.id));
|
||||
body.set('pokemonId', String(species.id));
|
||||
body.set('seen', seen ? 'on' : 'off');
|
||||
try {
|
||||
const result = deserialize(await (await fetch('?/setSeen', { method: 'POST', body })).text());
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save sighting.')
|
||||
: 'Could not save sighting.'
|
||||
);
|
||||
if (seen) seenIds.add(species.id);
|
||||
else seenIds.delete(species.id);
|
||||
pendingSeen.delete(number);
|
||||
void invalidateAll().catch(() => {
|
||||
message = 'Sighting saved. Reload to refresh the progress totals.';
|
||||
});
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Could not save sighting.';
|
||||
} finally {
|
||||
pendingSeen.delete(number);
|
||||
}
|
||||
}
|
||||
const regions = $derived(gameRegions(game.slug ?? '', data.regions));
|
||||
let stage = $state<Stage>(untrack(() => data.journey?.stage ?? 'story'));
|
||||
let location = $state(
|
||||
untrack(
|
||||
() =>
|
||||
data.journey?.location ??
|
||||
initialLocation(
|
||||
data.activeGame?.slug ?? '',
|
||||
gameRegions(data.activeGame?.slug ?? '', data.regions)[0] ?? ''
|
||||
)
|
||||
)
|
||||
);
|
||||
let tab = $state('adventure');
|
||||
let search = $state('');
|
||||
let dex = $state(untrack(() => data.dexNumbers[0]?.dex ?? 'national'));
|
||||
let status = $state('all');
|
||||
let pageNumber = $state(1);
|
||||
let pcPage = $state(1);
|
||||
let viewportWidth = $state(1280);
|
||||
const dexPageSize = $derived(viewportWidth < 640 ? 18 : viewportWidth < 1024 ? 36 : 60);
|
||||
const pcPageSize = $derived(viewportWidth < 640 ? 12 : 30);
|
||||
let message = $state('');
|
||||
let saving = $state(false);
|
||||
const pending = new SvelteSet<number>();
|
||||
const caught = $derived(new Set(data.caughtIds));
|
||||
const dexes = $derived([...new Map(data.dexNumbers.map((d) => [d.dex, d.dexName])).entries()]);
|
||||
const regionalNumbers = $derived(
|
||||
new Map(data.dexNumbers.filter((d) => d.dex === dex).map((d) => [d.pokemonId, d.number]))
|
||||
);
|
||||
const regionalOrder = $derived(
|
||||
new Map(
|
||||
data.pokemon.flatMap((p) => {
|
||||
const d = data.dexNumbers.find((d) => d.pokemonId === p.id);
|
||||
return d ? [[p.number, d.number] as const] : [];
|
||||
})
|
||||
)
|
||||
);
|
||||
const speciesByNumber = $derived(new Map(data.pokemon.map((p) => [p.number, p])));
|
||||
const first = $derived(firstCatches(data.catches.map((c) => c.catch)));
|
||||
const caughtNumbers = $derived(
|
||||
new Set(
|
||||
data.pokemon
|
||||
.filter((p) => p.entries.some(([id, g]) => g === game.id && caught.has(id)))
|
||||
.map((p) => p.number)
|
||||
)
|
||||
);
|
||||
const party = $derived(
|
||||
data.catches
|
||||
.filter((c) => c.catch.partySlot !== null)
|
||||
.sort((a, b) => a.catch.partySlot! - b.catch.partySlot!)
|
||||
);
|
||||
const inGame = $derived(data.pokemon.filter((p) => p.entries.some(([, g]) => g === game.id)));
|
||||
const owned = $derived(inGame.filter((p) => caughtNumbers.has(p.number)));
|
||||
const filtered = $derived(
|
||||
inGame
|
||||
.filter((p) => {
|
||||
if (dex !== 'national' && !regionalNumbers.has(p.id)) return false;
|
||||
if (
|
||||
(status === 'caught' && !caughtNumbers.has(p.number)) ||
|
||||
(status === 'missing' && caughtNumbers.has(p.number))
|
||||
)
|
||||
return false;
|
||||
return `${p.name} ${p.number} ${p.type1} ${p.type2 ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim());
|
||||
})
|
||||
.sort((a, b) =>
|
||||
dex === 'national'
|
||||
? a.number - b.number
|
||||
: (regionalNumbers.get(a.id) ?? 0) - (regionalNumbers.get(b.id) ?? 0)
|
||||
)
|
||||
);
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(filtered.length / dexPageSize)));
|
||||
const pcSpecies = $derived(
|
||||
owned.filter((p) =>
|
||||
`${p.name} ${p.number} ${first.get(p.id)?.nickname ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())
|
||||
)
|
||||
);
|
||||
const visiblePc = $derived(pcSpecies.slice((pcPage - 1) * pcPageSize, pcPage * pcPageSize));
|
||||
$effect(() => {
|
||||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||||
});
|
||||
$effect(() => {
|
||||
if (pcPage > Math.max(1, Math.ceil(pcSpecies.length / pcPageSize))) pcPage = 1;
|
||||
});
|
||||
const milestones = $derived([
|
||||
...milestonesFor(game.slug ?? '', regions[0] ?? '').map((m) => ({
|
||||
...m,
|
||||
done: data.milestones.find((r) => r.key === m.key)?.done ?? false
|
||||
})),
|
||||
...data.milestones.filter((m) => m.key.startsWith('custom:'))
|
||||
]);
|
||||
const stageMilestones = $derived(milestones.filter((m) => m.stage === stage));
|
||||
let milestoneOpen = $state(false);
|
||||
let milestoneMessage = $state('');
|
||||
let detailsNumber = $state<number | null>(null);
|
||||
let catchNumber = $state<number | null>(null);
|
||||
let catchOpen = $state(false);
|
||||
let catchRecord = $state<PokemonCatch | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
const catchSpecies = $derived(catchNumber === null ? null : speciesByNumber.get(catchNumber));
|
||||
function editCatch(number: number, record: PokemonCatch | null = null) {
|
||||
catchNumber = number;
|
||||
catchRecord = record;
|
||||
catchOpen = true;
|
||||
}
|
||||
function openSpecies(number: number) {
|
||||
detailsNumber = number;
|
||||
}
|
||||
async function toggle(entryId: number, checked: boolean) {
|
||||
if (pending.has(entryId)) return;
|
||||
pending.add(entryId);
|
||||
message = '';
|
||||
const body = new FormData();
|
||||
body.set('entryId', String(entryId));
|
||||
if (checked) body.set('caught', 'on');
|
||||
try {
|
||||
const r = deserialize(
|
||||
await (await fetch('?/togglePokemonCaught', { method: 'POST', body })).text()
|
||||
);
|
||||
if (r.type !== 'success')
|
||||
throw new Error(
|
||||
r.type === 'failure' ? String(r.data?.message ?? 'Could not save.') : 'Could not save.'
|
||||
);
|
||||
await invalidateAll();
|
||||
} catch (e) {
|
||||
message = e instanceof Error ? e.message : 'Could not save.';
|
||||
} finally {
|
||||
pending.delete(entryId);
|
||||
}
|
||||
}
|
||||
async function removeCatch() {
|
||||
if (!catchRecord) return;
|
||||
saving = true;
|
||||
const body = new FormData();
|
||||
body.set('id', String(catchRecord.id));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/deleteCatch', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success') throw new Error('Could not remove catch.');
|
||||
await invalidateAll();
|
||||
catchOpen = false;
|
||||
deleteOpen = false;
|
||||
} catch (e) {
|
||||
message = e instanceof Error ? e.message : 'Could not remove catch.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={viewportWidth} />
|
||||
|
||||
<PokemonProgress progress={data.progress} gameId={game.id} />
|
||||
|
||||
{#if message}<p role="alert" class="mt-4 text-destructive">{message}</p>{/if}
|
||||
<div class="mt-2 grid items-center gap-2 lg:grid-cols-[minmax(280px,.8fr)_minmax(0,2fr)]">
|
||||
<div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveJourney"
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
message = '';
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
message =
|
||||
result.type === 'success'
|
||||
? 'Adventure saved.'
|
||||
: result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="location"
|
||||
value={location}
|
||||
/>
|
||||
<label class="min-w-40 max-w-xs flex-1 text-xs font-medium"
|
||||
>My playthrough<NativeSelect class="mt-1 w-full" name="stage" bind:value={stage}
|
||||
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={!data.user || saving}
|
||||
>Save my place</Button
|
||||
>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setCompleted"
|
||||
use:enhance
|
||||
class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} />
|
||||
<input type="hidden" name="completed" value={data.journey?.completed ? 'off' : 'on'} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="h-5 text-[10px]"
|
||||
disabled={!data.user ||
|
||||
!hydrated ||
|
||||
(!!gameProgress?.story.total && gameProgress.story.value === gameProgress.story.total)}
|
||||
>{gameProgress?.completed ? '✓ Story complete' : 'Mark story complete'}</Button
|
||||
>
|
||||
{#if gameProgress}<span
|
||||
>{gameProgress.story.value} / {gameProgress.story.total} story milestones</span
|
||||
>{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="rounded-xl border bg-card p-2" aria-label="Current team">
|
||||
<div class="mb-1 flex items-center justify-between px-1 text-xs">
|
||||
<h2 class="font-semibold">Current team</h2>
|
||||
<span class="text-xs text-muted-foreground">{party.length} / 6 in your team</span>
|
||||
</div>
|
||||
<div class={`grid grid-cols-6 gap-1 ${party.length ? '' : 'max-sm:hidden'}`}>
|
||||
{#each [1, 2, 3, 4, 5, 6] as slot (slot)}
|
||||
{@const member = party.find((p) => p.catch.partySlot === slot)}
|
||||
{#if member}<button
|
||||
class="flex min-w-0 items-center gap-1 rounded-lg border bg-muted/30 p-1 text-left transition hover:bg-muted max-sm:flex-col max-sm:text-center"
|
||||
onclick={() => editCatch(member.number, member.catch)}
|
||||
><img
|
||||
src={pokemonImage(member.number, member.catch.shiny ? 'shiny' : 'sprite')}
|
||||
alt={member.name}
|
||||
width="40"
|
||||
height="40"
|
||||
class="size-10 shrink-0"
|
||||
/><span class="min-w-0 max-w-full flex-1"
|
||||
><span class="block truncate text-[11px] font-semibold"
|
||||
>{member.catch.nickname || member.name}</span
|
||||
><span class="block text-[11px] text-muted-foreground"
|
||||
>Lv. {member.catch.level}{member.catch.shiny ? ' · ✦' : ''}</span
|
||||
></span
|
||||
></button
|
||||
>
|
||||
{:else}<button
|
||||
class="flex min-h-12 items-center justify-center gap-1 rounded-lg border border-dashed text-muted-foreground hover:bg-muted/30 max-sm:flex-col"
|
||||
onclick={() => (tab = 'pc')}
|
||||
aria-label={`Choose a Pokémon for team slot ${slot}`}
|
||||
><span class="text-xl">+</span><span class="mt-1 text-xs">Slot {slot}</span></button
|
||||
>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={tab} class="mt-3">
|
||||
<Tabs.List
|
||||
class="sticky top-0 z-20 mb-3 flex h-auto w-full flex-wrap justify-start gap-1 border-b bg-background/95 p-1"
|
||||
><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="adventure"
|
||||
class="px-3 py-2.5"
|
||||
aria-label="Explore the map"
|
||||
><MapIcon class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Map</span><span
|
||||
class="hidden sm:inline">Explore the map</span
|
||||
></Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="dex" class="px-3 py-2.5"
|
||||
><BookOpen class="mr-1.5 size-4" aria-hidden="true" />Pokédex</Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="pc" class="px-3 py-2.5"
|
||||
><Archive class="mr-1.5 size-4" aria-hidden="true" />My PC · {owned.length}</Tabs.Trigger
|
||||
><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="milestones"
|
||||
class="px-3 py-2.5"
|
||||
aria-label="Badges & milestones"
|
||||
><Trophy class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Badges</span><span
|
||||
class="hidden sm:inline">Badges & milestones</span
|
||||
></Tabs.Trigger
|
||||
></Tabs.List
|
||||
>
|
||||
<Tabs.Content value="adventure">
|
||||
<PokemonMap
|
||||
{stage}
|
||||
{regionalOrder}
|
||||
gameId={game.id}
|
||||
{regions}
|
||||
locations={data.locations}
|
||||
bind:location
|
||||
{caughtNumbers}
|
||||
{seenNumbers}
|
||||
{pendingSeen}
|
||||
onseen={setSeen}
|
||||
onpokemon={openSpecies}
|
||||
oncatch={(number) => editCatch(number)}
|
||||
canRegister={!!data.user && hydrated}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="dex">
|
||||
{#if gameProgress}<div class="mb-3 grid grid-cols-2 gap-4">
|
||||
<ProgressMeter
|
||||
label={dex === 'national' ? 'Game collection' : 'Regional dex caught'}
|
||||
progress={dex === 'national' ? gameProgress.collection : gameProgress.regional}
|
||||
compact
|
||||
/><ProgressMeter
|
||||
label={dex === 'national' ? 'Seen in this game' : 'Regional dex seen'}
|
||||
progress={dex === 'national' ? gameProgress.seen : gameProgress.regionalSeen}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>{/if}
|
||||
<div class="mb-5 grid gap-3 sm:grid-cols-[1fr_1fr_10rem]">
|
||||
<Input
|
||||
aria-label="Search Pokémon"
|
||||
type="search"
|
||||
placeholder="Name, number or type…"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
/><NativeSelect
|
||||
aria-label="Pokédex order"
|
||||
class="w-full"
|
||||
bind:value={dex}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
>{#each dexes as [key, name] (key)}<option value={key}>Regional · {name}</option
|
||||
>{/each}<option value="national">National Pokédex</option></NativeSelect
|
||||
><NativeSelect
|
||||
aria-label="Catch filter"
|
||||
class="w-full"
|
||||
bind:value={status}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
><option value="all">All Pokémon</option><option value="missing">Still to catch</option
|
||||
><option value="caught">Caught</option></NativeSelect
|
||||
>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Showing {Math.min(dexPageSize, filtered.length - (pageNumber - 1) * dexPageSize)} of {filtered.length}
|
||||
Pokémon · {dex === 'national' ? 'National order' : 'Regional order'}{!dexes.length
|
||||
? ' · No regional Pokédex is recorded for this title.'
|
||||
: ''}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{#each filtered.slice((pageNumber - 1) * dexPageSize, pageNumber * dexPageSize) as p (p.id)}
|
||||
{@const entry = p.entries.find(([, g]) => g === game.id)!}{@const code =
|
||||
availabilityValues[entry[2]]}{@const isCaught = caught.has(entry[0])}
|
||||
<article
|
||||
class={`overflow-hidden rounded-xl border bg-card ${isCaught ? 'border-emerald-600/40' : ''}`}
|
||||
>
|
||||
<button
|
||||
class="w-full p-2 text-left hover:bg-muted/40"
|
||||
onclick={() => openSpecies(p.number)}
|
||||
><div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>#{dex === 'national' ? p.number : regionalNumbers.get(p.id)}</span
|
||||
>{#if isCaught}<span class="text-emerald-600">✓ caught</span>{/if}
|
||||
</div>
|
||||
<img
|
||||
src={pokemonImage(p.number)}
|
||||
alt=""
|
||||
width="96"
|
||||
height="96"
|
||||
loading="lazy"
|
||||
class="mx-auto size-12"
|
||||
/>
|
||||
<p class="truncate text-sm font-semibold">{p.name}</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{p.type1}{p.type2 ? ` / ${p.type2}` : ''}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={entry[3]}>
|
||||
{availabilityLabels[code]}
|
||||
</p></button
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-t px-3 py-2">
|
||||
<label class="flex items-center gap-1 text-[10px]"
|
||||
><Checkbox
|
||||
aria-label={`Seen ${p.name} in ${game.name}`}
|
||||
checked={isCaught || seenIds.has(p.id)}
|
||||
disabled={!hydrated || !data.user || isCaught || pendingSeen.has(p.number)}
|
||||
onCheckedChange={(v) => setSeen(p.number, v)}
|
||||
/>seen</label
|
||||
>
|
||||
<label class="flex items-center gap-2 text-xs"
|
||||
><Checkbox
|
||||
aria-label={`Caught ${p.name} in ${game.name}`}
|
||||
checked={isCaught}
|
||||
disabled={!data.user ||
|
||||
pending.has(entry[0]) ||
|
||||
(!isCaught && !obtainableAvailability.has(code))}
|
||||
onCheckedChange={(v) => toggle(entry[0], v)}
|
||||
/>caught</label
|
||||
><Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => editCatch(p.number)}>+ log</Button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
{:else}<p class="col-span-full py-10 text-center text-muted-foreground">
|
||||
No Pokémon match these filters.
|
||||
</p>{/each}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" disabled={pageNumber === 1} onclick={() => pageNumber--}
|
||||
>Previous</Button
|
||||
><span class="text-sm text-muted-foreground">{pageNumber} / {pageCount}</span><Button
|
||||
variant="outline"
|
||||
disabled={pageNumber === pageCount}
|
||||
onclick={() => pageNumber++}>Next</Button
|
||||
>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pc">
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Your living collection</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
National order · first registered catch per species · {data.catches.length} individual records
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
class="max-w-xs"
|
||||
aria-label="Search your PC"
|
||||
placeholder="Species or nickname…"
|
||||
bind:value={search}
|
||||
oninput={() => (pcPage = 1)}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
{#if gameProgress}<ProgressMeter
|
||||
label="PC collection"
|
||||
progress={gameProgress.collection}
|
||||
/>{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
{#each visiblePc as p (p.id)}{@const record = first.get(p.id)}{@const copies =
|
||||
data.catches.filter((c) => c.catch.pokemonId === p.id).length}
|
||||
<button
|
||||
class="relative rounded-xl border bg-card p-2 text-center hover:bg-muted/60"
|
||||
onclick={() => (record ? editCatch(p.number, record) : openSpecies(p.number))}
|
||||
><span class="absolute top-2 left-2 text-[10px] text-muted-foreground">#{p.number}</span
|
||||
>{#if copies > 1}<span class="absolute top-2 right-2 text-[10px]">×{copies}</span
|
||||
>{/if}<img
|
||||
src={pokemonImage(p.number, record?.shiny ? 'shiny' : 'sprite')}
|
||||
alt={p.name}
|
||||
width="56"
|
||||
height="56"
|
||||
loading="lazy"
|
||||
class="mx-auto"
|
||||
/><span class="block truncate text-xs font-semibold">{record?.nickname || p.name}</span
|
||||
><span class="text-[10px] text-muted-foreground"
|
||||
>{record
|
||||
? `Lv. ${record.level}${record.partySlot ? ' · team' : ''}`
|
||||
: 'Add catch details'}</span
|
||||
></button
|
||||
>
|
||||
{:else}<div class="col-span-full rounded-xl border border-dashed px-5 py-12 text-center">
|
||||
<h3 class="font-semibold">A new adventure starts with one companion.</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Register your starter in the Pokédex, or explore a route to find your next catch.
|
||||
</p>
|
||||
<Button class="mt-4" variant="outline" onclick={() => (tab = 'dex')}
|
||||
>Find my starter</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if pcSpecies.length > pcPageSize}<div class="mt-5 flex justify-between">
|
||||
<Button variant="outline" disabled={pcPage === 1} onclick={() => pcPage--}
|
||||
>Previous box</Button
|
||||
><span>Box {pcPage} / {Math.ceil(pcSpecies.length / pcPageSize)}</span><Button
|
||||
variant="outline"
|
||||
disabled={pcPage * pcPageSize >= pcSpecies.length}
|
||||
onclick={() => pcPage++}>Next box</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="milestones">
|
||||
{#if gameProgress}<div class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<ProgressMeter
|
||||
label="Story milestones"
|
||||
progress={gameProgress.story}
|
||||
color="amber"
|
||||
/><ProgressMeter
|
||||
label="Badges & trials"
|
||||
progress={gameProgress.badges}
|
||||
color="amber"
|
||||
/><ProgressMeter
|
||||
label="Postgame"
|
||||
progress={gameProgress.postgame}
|
||||
color="violet"
|
||||
/><ProgressMeter label="Transfers & events" progress={gameProgress.transfers} color="sky" />
|
||||
</div>{/if}
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Milestones along the way</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{stageMilestones.filter((m) => m.done).length} / {stageMilestones.length} · {stages.find(
|
||||
(s) => s.value === stage
|
||||
)?.label}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => (milestoneOpen = true)}>+ Custom milestone</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each stageMilestones as m (m.key)}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveMilestone"
|
||||
use:enhance
|
||||
class="flex items-center gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="key"
|
||||
value={m.key}
|
||||
/><input type="hidden" name="title" value={m.title} /><input
|
||||
type="hidden"
|
||||
name="category"
|
||||
value={m.category}
|
||||
/><input type="hidden" name="stage" value={m.stage} /><input
|
||||
type="hidden"
|
||||
name="done"
|
||||
value={m.done ? '' : 'on'}
|
||||
/><Button
|
||||
type="submit"
|
||||
variant={m.done ? 'secondary' : 'outline'}
|
||||
size="icon"
|
||||
disabled={!hydrated || !data.user}
|
||||
aria-label={`${m.done ? 'Undo' : 'Complete'} ${m.title}`}
|
||||
>{m.done ? '✓' : m.category === 'badge' ? '◇' : '○'}</Button
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{m.title}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{m.category}</p>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Root
|
||||
open={detailsNumber !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) detailsNumber = null;
|
||||
}}
|
||||
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Pokédex field notes</Dialog.Title><Dialog.Description
|
||||
>Encounters, evolution, stats and your own catches in {game.name}.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if detailsNumber !== null}
|
||||
{@const p = speciesByNumber.get(detailsNumber)}
|
||||
<div class="rounded-xl border p-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-semibold">My {p?.name} collection</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => editCatch(detailsNumber!)}>Register a catch</Button
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each data.catches.filter((c) => c.number === detailsNumber) as c (c.catch.id)}<button
|
||||
class="flex w-full items-center justify-between rounded-lg bg-muted/40 p-3 text-left"
|
||||
onclick={() => editCatch(c.number, c.catch)}
|
||||
><span
|
||||
><span class="font-semibold">{c.catch.nickname || c.name}</span><span
|
||||
class="mt-1 block text-xs text-muted-foreground"
|
||||
>{c.catch.moves.join(' · ') || 'No moves recorded'}</span
|
||||
></span
|
||||
><span class="text-xs"
|
||||
>Lv. {c.catch.level}{c.catch.partySlot ? ` · slot ${c.catch.partySlot}` : ''}</span
|
||||
></button
|
||||
>{:else}<p class="text-sm text-muted-foreground">
|
||||
No individual catches registered yet.
|
||||
</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<PokemonDetails
|
||||
number={detailsNumber}
|
||||
gameId={game.id}
|
||||
isAdmin={data.isAdmin}
|
||||
onsaved={() => invalidateAll()}
|
||||
/>
|
||||
{/if}
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
{#if catchSpecies}{#key `${catchSpecies.id}-${catchRecord?.id ?? 'new'}`}<CatchEditor
|
||||
bind:open={catchOpen}
|
||||
species={catchSpecies}
|
||||
gameId={game.id}
|
||||
record={catchRecord}
|
||||
{stage}
|
||||
location={data.locations.find((p) => p.slug === location)?.name ?? location}
|
||||
signedIn={!!data.user}
|
||||
ondelete={() => (deleteOpen = true)}
|
||||
ondetails={() => {
|
||||
catchOpen = false;
|
||||
detailsNumber = catchNumber;
|
||||
}}
|
||||
/>{/key}{/if}
|
||||
<AlertDialog.Root bind:open={deleteOpen}
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Remove this catch record?</AlertDialog.Title><AlertDialog.Description
|
||||
>This removes its nickname, moves and party slot. Other catches and the historical caught
|
||||
checkmark remain saved.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep Pokémon</AlertDialog.Cancel><Button
|
||||
variant="destructive"
|
||||
onclick={removeCatch}
|
||||
disabled={saving}>Remove record</Button
|
||||
></AlertDialog.Footer
|
||||
></AlertDialog.Content
|
||||
></AlertDialog.Root
|
||||
>
|
||||
<Dialog.Root bind:open={milestoneOpen}
|
||||
><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Set a new milestone</Dialog.Title><Dialog.Description
|
||||
>A battle, a ribbon, a rare encounter, or your own challenge.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if milestoneMessage}<p role="alert" class="text-destructive">{milestoneMessage}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveMilestone"
|
||||
class="space-y-4"
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
milestoneOpen = false;
|
||||
milestoneMessage = '';
|
||||
} else
|
||||
milestoneMessage =
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save milestone.')
|
||||
: 'Could not save milestone.';
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="stage"
|
||||
value={stage}
|
||||
/><input type="hidden" name="category" value="event" /><label class="block"
|
||||
>Milestone<Input name="title" required maxlength={200} /></label
|
||||
><Button type="submit">Add milestone</Button>
|
||||
</form></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
534
src/lib/game-tracker/PokemonMap.svelte
Normal file
534
src/lib/game-tracker/PokemonMap.svelte
Normal file
@@ -0,0 +1,534 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { untrack } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { Slider } from '$lib/components/ui/slider/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import EncounterGrid from './EncounterGrid.svelte';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import { fitViewport } from './fit-viewport';
|
||||
import { type AtlasNode } from './pokemon-world';
|
||||
import mapArtwork from '../../../data/pokemon-map-art.json';
|
||||
const regionMaps: Record<string, (typeof mapArtwork)['sinnoh']> = mapArtwork;
|
||||
type Place = { id: number; slug: string; name: string; region: string | null; count: number };
|
||||
type Encounter = {
|
||||
pokemonId: number;
|
||||
number: number;
|
||||
name: string;
|
||||
type1: string;
|
||||
type2: string | null;
|
||||
area: string | null;
|
||||
method: string;
|
||||
minLevel: number | null;
|
||||
maxLevel: number | null;
|
||||
rarity: number | null;
|
||||
conditions: string[];
|
||||
form: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
let {
|
||||
gameId,
|
||||
regions,
|
||||
locations,
|
||||
location = $bindable(''),
|
||||
caughtNumbers,
|
||||
seenNumbers,
|
||||
pendingSeen,
|
||||
onseen,
|
||||
onpokemon,
|
||||
oncatch,
|
||||
canRegister,
|
||||
stage,
|
||||
regionalOrder
|
||||
}: {
|
||||
stage: string;
|
||||
regionalOrder: Map<number, number>;
|
||||
gameId: number;
|
||||
regions: string[];
|
||||
locations: Place[];
|
||||
location?: string;
|
||||
caughtNumbers: Set<number>;
|
||||
seenNumbers: Set<number>;
|
||||
pendingSeen: Set<number>;
|
||||
onseen: (number: number, seen: boolean) => void;
|
||||
onpokemon: (number: number) => void;
|
||||
oncatch: (number: number) => void;
|
||||
canRegister: boolean;
|
||||
} = $props();
|
||||
let region = $state(
|
||||
untrack(() => {
|
||||
const saved = locations.find((p) => p.slug === location)?.region?.toLowerCase();
|
||||
return saved && regions.includes(saved) ? saved : (regions[0] ?? '');
|
||||
})
|
||||
);
|
||||
let zoom = $state(1);
|
||||
let filter = $state('');
|
||||
let method = $state('all');
|
||||
let area = $state('all');
|
||||
let mobileView = $state('encounters');
|
||||
let overviewOpen = $state(false);
|
||||
let encounterNumber = $state<number | null>(null);
|
||||
const detailRows = $derived(groups.find(([n]) => n === encounterNumber)?.[1] ?? []);
|
||||
const areas = $derived([...new Set(encounters.flatMap((e) => (e.area ? [e.area] : [])))]);
|
||||
let encounters = $state<Encounter[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
let retry = $state(0);
|
||||
let includeSpecial = $derived(stage !== 'story');
|
||||
let viewport: HTMLDivElement;
|
||||
let viewportWidth = $state(640);
|
||||
let viewportHeight = $state(560);
|
||||
let showNames = $state(false);
|
||||
const mapWidth = $derived(
|
||||
map ? Math.min(viewportWidth, (viewportHeight * map.width) / map.height) * zoom : viewportWidth
|
||||
);
|
||||
let dragging: { x: number; y: number; left: number; top: number } | null = null;
|
||||
const map = $derived(regionMaps[region]);
|
||||
const places = $derived(
|
||||
locations
|
||||
.filter((p) => p.region?.toLowerCase() === region || (regions.length === 1 && p.count > 0))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
);
|
||||
const selected = $derived(
|
||||
locations.find(
|
||||
(p) => p.slug === location && (p.region?.toLowerCase() === region || p.count > 0)
|
||||
)
|
||||
);
|
||||
const selectedLocationId = $derived(selected?.id);
|
||||
const node = $derived(map?.nodes.find((n) => n.slug === location));
|
||||
const methods = $derived([...new Set(encounters.map((e) => e.method))]);
|
||||
const special = (e: Encounter) =>
|
||||
e.conditions.some((c) => /pok.radar|swarm|slot 2|national|radio/i.test(c));
|
||||
const groups = $derived(
|
||||
[
|
||||
...Map.groupBy(
|
||||
encounters.filter(
|
||||
(e) =>
|
||||
(area === 'all' || e.area === area) &&
|
||||
(method === 'all' || e.method === method) &&
|
||||
(includeSpecial || !special(e))
|
||||
),
|
||||
(e) => e.number
|
||||
)
|
||||
].sort(([a], [b]) => (regionalOrder.get(a) ?? a + 10000) - (regionalOrder.get(b) ?? b + 10000))
|
||||
);
|
||||
const hiddenSpecial = $derived(encounters.filter(special).length);
|
||||
const placeMap = $derived(new Map(places.map((p) => [p.slug, p])));
|
||||
const choices = $derived(
|
||||
[
|
||||
...new Map(
|
||||
[
|
||||
...places.map((p) => ({ slug: p.slug, name: p.name, count: p.count })),
|
||||
...(map?.nodes ?? [])
|
||||
.filter((n) => !placeMap.has(n.slug))
|
||||
.map((n) => ({ slug: n.slug, name: n.name, count: 0 }))
|
||||
].map((p) => [p.slug, p])
|
||||
).values()
|
||||
].filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
);
|
||||
$effect(() => {
|
||||
if (!regions.includes(region)) region = regions[0] ?? '';
|
||||
});
|
||||
$effect(() => {
|
||||
const id = selectedLocationId;
|
||||
const game = gameId;
|
||||
void retry;
|
||||
encounters = [];
|
||||
method = 'all';
|
||||
area = 'all';
|
||||
encounterNumber = null;
|
||||
loadError = '';
|
||||
loading = !!id;
|
||||
if (!id) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${base}/projects/game-tracker/pokemon/api/encounters?game=${game}&location=${id}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Could not load this location.');
|
||||
return r.json();
|
||||
})
|
||||
.then((rows: Encounter[]) => {
|
||||
if (!controller.signal.aborted) encounters = rows;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) loadError = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) loading = false;
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
function choose(point: AtlasNode) {
|
||||
location = point.slug;
|
||||
mobileView = 'encounters';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tabs.Root bind:value={mobileView} class="mb-2 lg:hidden"
|
||||
><Tabs.List class="w-full"
|
||||
><Tabs.Trigger value="encounters" class="flex-1">Area Pokémon</Tabs.Trigger><Tabs.Trigger
|
||||
value="map"
|
||||
class="flex-1">Region map</Tabs.Trigger
|
||||
></Tabs.List
|
||||
></Tabs.Root
|
||||
>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)]">
|
||||
<section
|
||||
class={`min-w-0 overflow-hidden rounded-xl border bg-card ${mobileView === 'map' ? '' : 'max-lg:hidden'}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-[.18em] text-muted-foreground uppercase">
|
||||
Field atlas
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold">{map?.name ?? 'Region map'}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if regions.length > 1}<NativeSelect
|
||||
aria-label="Map region"
|
||||
bind:value={region}
|
||||
onchange={() => {
|
||||
location = '';
|
||||
zoom = 1;
|
||||
}}
|
||||
>{#each regions as r (r)}<option value={r}>{regionMaps[r]?.name ?? r}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Zoom out"
|
||||
disabled={zoom <= 1}
|
||||
onclick={() => (zoom = Math.max(1, zoom - 0.5))}>−</Button
|
||||
>
|
||||
<div class="hidden w-24 sm:block">
|
||||
<Slider
|
||||
type="single"
|
||||
min={1}
|
||||
max={4}
|
||||
step={0.5}
|
||||
bind:value={zoom}
|
||||
aria-label="Map zoom"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => (zoom = 1)} aria-label="Reset map zoom"
|
||||
>{Math.round(zoom * 100)}%</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Zoom in"
|
||||
disabled={zoom >= 4}
|
||||
onclick={() => (zoom = Math.min(4, zoom + 0.5))}>+</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scrolling and the location picker offer keyboard alternatives to dragging. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (scrollable map viewport must be keyboard focusable) -->
|
||||
<div
|
||||
bind:this={viewport}
|
||||
bind:clientWidth={viewportWidth}
|
||||
bind:clientHeight={viewportHeight}
|
||||
class="atlas-viewport h-80 overflow-auto"
|
||||
use:fitViewport={{ minimum: 180, bottom: 64 }}
|
||||
role="region"
|
||||
aria-label={`${map?.name} interactive map`}
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => {
|
||||
if (e.target instanceof Element && e.target.closest('button')) return;
|
||||
dragging = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
left: viewport.scrollLeft,
|
||||
top: viewport.scrollTop
|
||||
};
|
||||
viewport.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onpointermove={(e) => {
|
||||
if (dragging) {
|
||||
viewport.scrollLeft = dragging.left - (e.clientX - dragging.x);
|
||||
viewport.scrollTop = dragging.top - (e.clientY - dragging.y);
|
||||
}
|
||||
}}
|
||||
onpointerup={() => (dragging = null)}
|
||||
onpointercancel={() => (dragging = null)}
|
||||
>
|
||||
{#if map}
|
||||
<div
|
||||
class="atlas-canvas relative mx-auto"
|
||||
style:width={`${mapWidth}px`}
|
||||
style:height={`${(mapWidth * map.height) / map.width}px`}
|
||||
>
|
||||
<img
|
||||
src={`${base}${map.image}`}
|
||||
alt={`${map.name} region map from the Pokémon games`}
|
||||
width={map.width}
|
||||
height={map.height}
|
||||
draggable="false"
|
||||
class="absolute inset-0 h-full w-full select-none"
|
||||
style:image-rendering={map.pixelated ? 'pixelated' : 'auto'}
|
||||
/>
|
||||
{#each map.nodes as point (point.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="map-pin absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm transition-colors focus-visible:z-30 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
class:selected={location === point.slug}
|
||||
class:town={point.kind === 'town'}
|
||||
style:left={`${(point.x / map.width) * 100}%`}
|
||||
style:top={`${(point.y / map.height) * 100}%`}
|
||||
title={`${point.name} · ${placeMap.get(point.slug)?.count ?? 0} recorded species`}
|
||||
aria-label={`Explore ${point.name}`}
|
||||
aria-pressed={location === point.slug}
|
||||
onclick={() => choose(point)}
|
||||
>
|
||||
<span
|
||||
class="pin-label pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-background/95 px-1.5 py-0.5 text-[10px] font-semibold text-foreground shadow-sm"
|
||||
class:visible-label={(showNames && point.kind === 'town') ||
|
||||
location === point.slug}>{point.name}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
class="hidden pointer-events-none absolute bottom-4 left-4 rounded-lg border bg-background/90 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span class="mr-2 text-amber-500">●</span>selected location · drag to explore
|
||||
</div>
|
||||
</div>
|
||||
{:else}<p class="p-8 text-muted-foreground">
|
||||
No regional map is recorded for this title yet.
|
||||
</p>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap justify-between gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<label class="flex items-center gap-2"><Checkbox bind:checked={showNames} />Town names</label
|
||||
><span>{map?.precision}</span>{#if map}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={map.source} target="_blank" rel="noreferrer" class="underline">map source ↗</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class={`min-w-0 rounded-xl border bg-card p-3 ${mobileView === 'encounters' ? '' : 'max-lg:hidden'}`}
|
||||
aria-label="Area encounters"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">
|
||||
{selected?.name ?? node?.name ?? 'Choose a location'}
|
||||
</h2>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
{groups.length} Pokémon · {groups.filter(([n]) => caughtNumbers.has(n)).length} caught
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (overviewOpen = true)}
|
||||
disabled={!groups.length}>Expand encounters</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Find a location"
|
||||
placeholder="Find a route…"
|
||||
bind:value={filter}
|
||||
/>
|
||||
<NativeSelect class="w-full" aria-label="Encounter location" bind:value={location}
|
||||
><option value="">Choose a location</option>{#each choices as p (p.slug)}<option
|
||||
value={p.slug}>{p.name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
{#if areas.length > 1}<NativeSelect aria-label="Encounter area" bind:value={area}
|
||||
><option value="all">All areas / floors</option>{#each areas as a (a)}<option value={a}
|
||||
>{a}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
{#if methods.length > 1}<NativeSelect aria-label="Encounter method" bind:value={method}
|
||||
><option value="all">All encounter methods</option>{#each methods as m (m)}<option
|
||||
value={m}>{m}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-4">
|
||||
<ProgressMeter
|
||||
label="Area caught"
|
||||
progress={{
|
||||
value: groups.filter(([n]) => caughtNumbers.has(n)).length,
|
||||
total: groups.length
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Area seen"
|
||||
progress={{
|
||||
value: groups.filter(([n]) => caughtNumbers.has(n) || seenNumbers.has(n)).length,
|
||||
total: groups.length
|
||||
}}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
{#if hiddenSpecial}<label class="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground"
|
||||
><Checkbox bind:checked={includeSpecial} />Include special conditions (radar, swarms, other
|
||||
cartridges)</label
|
||||
>{/if}
|
||||
<div class="mt-2 h-64" use:fitViewport={{ bottom: 48, minimum: 120 }} aria-live="polite">
|
||||
{#if loading}<p class="py-8 text-sm text-muted-foreground">Searching the tall grass…</p>
|
||||
{:else if loadError}<p role="alert">{loadError}</p>
|
||||
<Button variant="outline" onclick={() => retry++}>Try again</Button>
|
||||
{:else if groups.length}<EncounterGrid
|
||||
{groups}
|
||||
{caughtNumbers}
|
||||
onselect={(number) => (encounterNumber = number)}
|
||||
/>
|
||||
{:else}<div class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||
{location
|
||||
? 'No encounters are recorded here for these filters. Check the location guide for gifts, special events or missing data.'
|
||||
: 'Pick a marker or choose a location to explore encounters.'}
|
||||
</div>{/if}
|
||||
</div>
|
||||
{#if selected || node}<a
|
||||
class="mt-2 inline-block text-[10px] text-muted-foreground underline"
|
||||
href={`https://bulbapedia.bulbagarden.net/wiki/${encodeURIComponent((selected?.name ?? node?.name ?? '').startsWith('Route') ? `${map?.name ?? ''} ${selected?.name ?? node?.name}` : (selected?.name ?? node?.name ?? ''))}`}
|
||||
target="_blank"
|
||||
rel="noreferrer">Location guide on Bulbapedia ↗</a
|
||||
>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={overviewOpen}
|
||||
><Dialog.Content class="flex h-[90dvh] max-w-[96vw] flex-col gap-3 p-4 sm:max-w-[96vw]"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{selected?.name ?? node?.name} · all {groups.length} Pokémon</Dialog.Title
|
||||
><Dialog.Description
|
||||
>Every species matching your area and method filters. Tap a portrait for levels, encounter
|
||||
conditions and catch registration.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<EncounterGrid {groups} {caughtNumbers} onselect={(number) => (encounterNumber = number)} />
|
||||
</div></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
<Dialog.Root
|
||||
open={encounterNumber !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) encounterNumber = null;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title
|
||||
>{detailRows[0]?.name ?? 'Pokémon'} · {selected?.name ?? node?.name}</Dialog.Title
|
||||
><Dialog.Description>All recorded encounters matching your filters.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if encounterNumber !== null}<div class="flex items-center gap-3">
|
||||
<img src={pokemonImage(encounterNumber)} alt="" width="64" height="64" />
|
||||
<p class="text-sm">
|
||||
{detailRows[0]?.type1}{detailRows[0]?.type2
|
||||
? ` / ${detailRows[0].type2}`
|
||||
: ''}{caughtNumbers.has(encounterNumber) ? ' · ✓ caught' : ''}
|
||||
</p>
|
||||
</div>{/if}
|
||||
<div class="space-y-2">
|
||||
{#each detailRows as row, i (i)}<div class="rounded-lg border p-3 text-xs">
|
||||
<p class="font-medium">
|
||||
{row.method}{row.area ? ` · ${row.area}` : ''} · Lv. {row.minLevel ??
|
||||
'?'}{row.maxLevel !== row.minLevel ? `–${row.maxLevel}` : ''}{row.rarity !== null
|
||||
? ` · ${row.rarity}%`
|
||||
: ''}
|
||||
</p>
|
||||
{#if row.conditions.length || row.form || row.note}<p class="mt-1 text-muted-foreground">
|
||||
{[row.form, ...row.conditions, row.note].filter(Boolean).join(' · ')}
|
||||
</p>{/if}
|
||||
</div>{/each}
|
||||
</div>
|
||||
<Dialog.Footer
|
||||
><Button
|
||||
variant="secondary"
|
||||
disabled={!canRegister ||
|
||||
(encounterNumber !== null &&
|
||||
(caughtNumbers.has(encounterNumber) || pendingSeen.has(encounterNumber)))}
|
||||
onclick={() => {
|
||||
if (encounterNumber !== null) onseen(encounterNumber, !seenNumbers.has(encounterNumber));
|
||||
}}
|
||||
>{encounterNumber !== null &&
|
||||
(seenNumbers.has(encounterNumber) || caughtNumbers.has(encounterNumber))
|
||||
? '✓ Seen · undo'
|
||||
: 'Mark as seen'}</Button
|
||||
><Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
const n = encounterNumber!;
|
||||
encounterNumber = null;
|
||||
overviewOpen = false;
|
||||
onpokemon(n);
|
||||
}}>Species research</Button
|
||||
><Button
|
||||
disabled={!canRegister}
|
||||
onclick={() => {
|
||||
const n = encounterNumber!;
|
||||
encounterNumber = null;
|
||||
overviewOpen = false;
|
||||
oncatch(n);
|
||||
}}>Register a catch</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
.atlas-viewport {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in oklch, var(--background), #549da3 12%),
|
||||
color-mix(in oklch, var(--background), #8fa965 10%)
|
||||
);
|
||||
cursor: grab;
|
||||
}
|
||||
.atlas-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.map-pin {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #6ca68e;
|
||||
border-color: var(--background);
|
||||
}
|
||||
.map-pin.town {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #ebc481;
|
||||
}
|
||||
.map-pin.selected {
|
||||
background: #f59e0b;
|
||||
border-color: #fff;
|
||||
z-index: 20;
|
||||
box-shadow: 0 0 0 5px #f59e0b40;
|
||||
}
|
||||
.pin-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.map-pin .pin-label.visible-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
.map-pin.selected .pin-label {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.visible-label,
|
||||
.map-pin:hover .pin-label,
|
||||
.map-pin:focus-visible .pin-label {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
104
src/lib/game-tracker/PokemonProgress.svelte
Normal file
104
src/lib/game-tracker/PokemonProgress.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import type { pokemonProgress } from './progress';
|
||||
let { progress, gameId }: { progress: ReturnType<typeof pokemonProgress>; gameId: number } =
|
||||
$props();
|
||||
let open = $state(false);
|
||||
const current = $derived(progress.games.find((g) => g.id === gameId));
|
||||
</script>
|
||||
|
||||
<section class="mt-2 rounded-lg border bg-card px-3 py-2.5" aria-label="Pokémon completion">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 class="text-xs font-semibold">Quick progress</h2>
|
||||
<Button variant="ghost" size="xs" class="h-6 gap-1 text-[10px]" onclick={() => (open = true)}
|
||||
><ChartNoAxesCombined class="size-3.5" aria-hidden="true" />Full breakdown</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-4">
|
||||
{#if current}<ProgressMeter
|
||||
label={`${current.name} completion`}
|
||||
progress={current.overall}
|
||||
color="amber"
|
||||
compact
|
||||
hint="Collection, story, postgame and transfers/events contribute equally where available."
|
||||
/>{/if}
|
||||
<ProgressMeter
|
||||
label="National collection"
|
||||
progress={progress.caught}
|
||||
compact
|
||||
hint={`${progress.caught.value} / ${progress.caught.total} unique species caught across selected games.`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Species seen at least once"
|
||||
progress={progress.seen}
|
||||
color="sky"
|
||||
compact
|
||||
hint={`${progress.seen.value} / ${progress.seen.total} species. Catches count as seen.`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Games completed"
|
||||
progress={progress.completed}
|
||||
color="violet"
|
||||
compact
|
||||
hint={`${progress.completed.value} / ${progress.completed.total} stories cleared.`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog.Root bind:open
|
||||
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Your Pokémon progress</Dialog.Title><Dialog.Description
|
||||
>Every catch, discovery and milestone adds up. Collection totals exclude unconfirmed
|
||||
availability unless you have recorded a catch.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<ProgressMeter label="National collection" progress={progress.caught} />
|
||||
<ProgressMeter label="Species seen at least once" progress={progress.seen} color="sky" />
|
||||
<ProgressMeter label="Games completed" progress={progress.completed} color="violet" />
|
||||
<ProgressMeter
|
||||
label="All adventures"
|
||||
progress={progress.overall}
|
||||
color="amber"
|
||||
detail="Equal weight per selected game"
|
||||
/>
|
||||
<ProgressMeter label="All species / game pairs" progress={progress.collection} />
|
||||
</div>
|
||||
{#if current}
|
||||
<h3 class="border-t pt-4 font-semibold">{current.name} · your adventure</h3>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<ProgressMeter label="Game collection" progress={current.collection} />
|
||||
<ProgressMeter
|
||||
label="Obtainable in-game"
|
||||
progress={current.inGame}
|
||||
hint="Catch, evolve and breed; trades, events and transfers have their own collection stage."
|
||||
/>
|
||||
<ProgressMeter label="Seen in this game" progress={current.seen} color="sky" />
|
||||
{#if current.regional.total}<ProgressMeter
|
||||
label="Regional dex caught"
|
||||
progress={current.regional}
|
||||
/><ProgressMeter
|
||||
label="Regional dex seen"
|
||||
progress={current.regionalSeen}
|
||||
color="sky"
|
||||
/>{/if}
|
||||
{#each [{ label: 'Story milestones', progress: current.story }, { label: 'Badges & trials', progress: current.badges }, { label: 'League battles', progress: current.league }, { label: 'Boss battles', progress: current.bosses }, { label: 'Postgame milestones', progress: current.postgame }, { label: 'Trades, transfers & events', progress: current.transfers }, { label: 'Personal milestones', progress: current.custom }] as metric (metric.label)}
|
||||
{#if metric.progress.total}<ProgressMeter
|
||||
label={metric.label}
|
||||
progress={metric.progress}
|
||||
color="amber"
|
||||
/>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Game completion averages collection, story, postgame and transfer/event milestones. Games
|
||||
completed counts stories you mark cleared, or games with every default story milestone
|
||||
finished. These percentages describe the imported catalog and your selected games.
|
||||
</p>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
58
src/lib/game-tracker/ProgressMeter.svelte
Normal file
58
src/lib/game-tracker/ProgressMeter.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
import { fraction, type ProgressCount } from './progress';
|
||||
let {
|
||||
label,
|
||||
progress,
|
||||
detail,
|
||||
hint = '',
|
||||
color = 'emerald',
|
||||
compact = false
|
||||
}: {
|
||||
label: string;
|
||||
progress: ProgressCount;
|
||||
detail?: string;
|
||||
hint?: string;
|
||||
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
const percent = $derived(fraction(progress) * 100);
|
||||
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
|
||||
const display = $derived(
|
||||
percent === 100
|
||||
? '100'
|
||||
: percent === 0
|
||||
? '0'
|
||||
: percent < 0.01
|
||||
? '<0.01'
|
||||
: Math.min(99.99, percent).toFixed(2)
|
||||
);
|
||||
const colors = {
|
||||
emerald: '[&_[data-slot=progress-indicator]]:bg-emerald-500',
|
||||
sky: '[&_[data-slot=progress-indicator]]:bg-sky-500',
|
||||
amber: '[&_[data-slot=progress-indicator]]:bg-amber-500',
|
||||
violet: '[&_[data-slot=progress-indicator]]:bg-violet-500',
|
||||
pink: '[&_[data-slot=progress-indicator]]:bg-pink-500'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-1" title={hint}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
|
||||
>{label}</span
|
||||
>
|
||||
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
|
||||
>{display}%{percent === 100 ? ' ✓' : ''}</span
|
||||
>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
max={100}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
|
||||
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
|
||||
/>
|
||||
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
||||
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
|
||||
</p>{/if}
|
||||
</div>
|
||||
46
src/lib/game-tracker/WeaponArtwork.svelte
Normal file
46
src/lib/game-tracker/WeaponArtwork.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import artwork from '../../../data/cod-art.json';
|
||||
let { game, name, icon }: { game: string; name: string; icon: string | null } = $props();
|
||||
const source = $derived(
|
||||
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
|
||||
);
|
||||
const src = $derived(source ? `${base}${source.path}` : icon);
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger
|
||||
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
|
||||
aria-label={`View ${name} artwork`}
|
||||
><img
|
||||
{src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="112"
|
||||
height="64"
|
||||
class="h-16 w-28 object-contain"
|
||||
/></Dialog.Trigger
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{name}</Dialog.Title><Dialog.Description
|
||||
>{source?.credit ?? 'Weapon artwork'}</Dialog.Description
|
||||
></Dialog.Header
|
||||
><img
|
||||
{src}
|
||||
alt={`${name} weapon reference for ${game.replaceAll('-', ' ')}`}
|
||||
class="max-h-[65dvh] w-full object-contain"
|
||||
/>{#if source}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={source.source}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-muted-foreground underline">Image source ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}</Dialog.Content
|
||||
>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
138
src/lib/game-tracker/WeaponRankControls.svelte
Normal file
138
src/lib/game-tracker/WeaponRankControls.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { deserialize } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
let {
|
||||
itemId,
|
||||
name,
|
||||
level,
|
||||
prestige,
|
||||
levelCap = null,
|
||||
prestigeCap = 1000,
|
||||
prestigeRequiresMaxLevel = false,
|
||||
compact = false,
|
||||
disabled,
|
||||
onupdate,
|
||||
onedit
|
||||
}: {
|
||||
itemId: number;
|
||||
name: string;
|
||||
level: number;
|
||||
prestige: number;
|
||||
levelCap?: number | null;
|
||||
prestigeCap?: number;
|
||||
prestigeRequiresMaxLevel?: boolean;
|
||||
compact?: boolean;
|
||||
disabled: boolean;
|
||||
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
|
||||
onedit: () => void;
|
||||
} = $props();
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
async function adjust(field: 'level' | 'prestige', delta: number) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
saved = false;
|
||||
const body = new FormData();
|
||||
body.set('itemId', String(itemId));
|
||||
body.set('field', field);
|
||||
body.set('delta', String(delta));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.'
|
||||
);
|
||||
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
|
||||
onupdate(rank);
|
||||
saved = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Could not save. Try again.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} level controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level down ${name}`}
|
||||
disabled={disabled || saving || level <= 1}
|
||||
onclick={() => adjust('level', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={compact
|
||||
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
|
||||
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
|
||||
aria-label={`Edit ${name} level and prestige`}
|
||||
disabled={disabled || saving}
|
||||
onclick={onedit}
|
||||
><span
|
||||
class={compact ? 'text-[8px] text-muted-foreground' : 'text-[10px] text-muted-foreground'}
|
||||
>LEVEL</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
|
||||
></Button
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level up ${name}`}
|
||||
disabled={disabled || saving || level >= (levelCap ?? 10000)}
|
||||
onclick={() => adjust('level', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} prestige controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige down ${name}`}
|
||||
disabled={disabled || saving || prestige <= 0}
|
||||
onclick={() => adjust('prestige', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<span class="min-w-12 text-center tabular-nums"
|
||||
><span
|
||||
class={compact
|
||||
? 'block text-[8px] text-muted-foreground'
|
||||
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground">/{prestigeCap}</span>{/if}</span
|
||||
></span
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige up ${name}`}
|
||||
disabled={disabled || saving || prestige >= prestigeCap || (prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
|
||||
onclick={() => adjust('prestige', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
|
||||
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
|
||||
</p>
|
||||
</div>
|
||||
28
src/lib/game-tracker/fit-viewport.ts
Normal file
28
src/lib/game-tracker/fit-viewport.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Size a companion panel from its real page position, allowing room for its footer. */
|
||||
export function fitViewport(node: HTMLElement, { minimum = 140, maximum = 560, bottom = 48 } = {}) {
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!node.getClientRects().length) return;
|
||||
const top = node.getBoundingClientRect().top + window.scrollY;
|
||||
const height = Math.round(
|
||||
Math.max(minimum, Math.min(maximum, window.innerHeight - top - bottom))
|
||||
);
|
||||
if (node.style.height !== `${height}px`) node.style.height = `${height}px`;
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
// Ancestors capture team, filters, tabs and responsive header changes above the panel.
|
||||
for (let parent = node.parentElement; parent; parent = parent.parentElement)
|
||||
observer.observe(parent);
|
||||
window.addEventListener('resize', measure);
|
||||
measure();
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
src/lib/game-tracker/game-art.ts
Normal file
15
src/lib/game-tracker/game-art.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import releases from '../../../data/pokemon-releases.json';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
const dates: Record<string, string> = releases.dates;
|
||||
export function pokemonRelease(slug: string | null) {
|
||||
return dates[slug ?? ''] ?? '';
|
||||
}
|
||||
export function chronologicalPokemon<T extends { slug: string | null; id: number }>(games: T[]) {
|
||||
return [...games].sort(
|
||||
(a, b) => pokemonRelease(a.slug).localeCompare(pokemonRelease(b.slug)) || a.id - b.id
|
||||
);
|
||||
}
|
||||
export function pokemonImage(number: number, kind: 'sprite' | 'shiny' | 'artwork' = 'sprite') {
|
||||
return `${base}/game-art/pokemon/${kind}/${number}.webp`;
|
||||
}
|
||||
78
src/lib/game-tracker/journal.test.ts
Normal file
78
src/lib/game-tracker/journal.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allowedOsuUrl, boundedInteger, firstCatches, validDate } from './journal';
|
||||
import { gameRegions, milestonesFor, regionMaps } from './pokemon-world';
|
||||
|
||||
describe('journal input and collection rules', () => {
|
||||
it('keeps the first registered catch even when a duplicate has an earlier caught date', () => {
|
||||
const original = { id: 2, pokemonId: 387, nickname: 'Sprout', caughtOn: '2026-09-25' };
|
||||
const duplicate = { id: 8, pokemonId: 387, nickname: 'Other', caughtOn: '2020-01-01' };
|
||||
const input = [duplicate, original, { ...original, id: 9, pokemonId: 399 }];
|
||||
expect(firstCatches(input).get(387)).toBe(original);
|
||||
expect(firstCatches(input).size).toBe(2);
|
||||
expect(input[0]).toBe(duplicate);
|
||||
});
|
||||
it('rejects malformed or impossible numeric values', () => {
|
||||
for (const value of [null, '', '-1', '1e2', '1.5', ' 2', 'NaN', '101'])
|
||||
expect(boundedInteger(value, 1, 100)).toBeNull();
|
||||
expect(boundedInteger('100', 1, 100)).toBe(100);
|
||||
expect(boundedInteger('0', 0, 100)).toBe(0);
|
||||
});
|
||||
it('accepts actual calendar dates including leap days', () => {
|
||||
expect(validDate('2024-02-29')).toBe(true);
|
||||
for (const date of [
|
||||
'2026-02-29',
|
||||
'2026-04-31',
|
||||
'0000-01-01',
|
||||
'2026-13-01',
|
||||
'2026-09-25T00:00:00Z'
|
||||
])
|
||||
expect(validDate(date)).toBe(false);
|
||||
});
|
||||
it('accepts osu links and rejects lookalike hosts, credentials and unrelated routes', () => {
|
||||
expect(allowedOsuUrl('https://osu.ppy.sh/beatmapsets/123#osu/456', 'beatmap')).toBe(true);
|
||||
expect(allowedOsuUrl('https://osu.ppy.sh/scores/osu/123', 'score')).toBe(true);
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'http://osu.ppy.sh/beatmaps/1',
|
||||
'https://osu.ppy.sh.evil.test/beatmaps/1',
|
||||
'https://user@osu.ppy.sh/beatmaps/1',
|
||||
'https://osu.ppy.sh:123/beatmaps/1',
|
||||
'https://osu.ppy.sh/users/1'
|
||||
])
|
||||
expect(allowedOsuUrl(url, 'beatmap')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('atlas and milestone integrity', () => {
|
||||
it('has unique locations and finite map coordinates in every region', () => {
|
||||
for (const map of Object.values(regionMaps)) {
|
||||
expect(new Set(map.nodes.map((n) => n.slug)).size).toBe(map.nodes.length);
|
||||
expect(map.source).toMatch(/^https:\/\//);
|
||||
for (const node of map.nodes)
|
||||
expect(Number.isFinite(node.x) && Number.isFinite(node.y)).toBe(true);
|
||||
}
|
||||
expect(regionMaps.sinnoh.nodes.some((n) => n.slug === 'sinnoh-route-201')).toBe(true);
|
||||
});
|
||||
it('uses the right region and version-specific leaders', () => {
|
||||
expect(gameRegions('legends-arceus', ['sinnoh'])).toEqual(['hisui']);
|
||||
expect(gameRegions('heartgold', ['kanto', 'johto'])).toEqual(['johto', 'kanto']);
|
||||
expect(gameRegions('unknown', ['unknown'])).toEqual([]);
|
||||
expect(
|
||||
milestonesFor('platinum', 'sinnoh').filter((m) => m.category === 'badge')[2].title
|
||||
).toContain('Fantina');
|
||||
expect(
|
||||
milestonesFor('diamond', 'sinnoh').filter((m) => m.category === 'badge')[2].title
|
||||
).toContain('Maylene');
|
||||
expect(milestonesFor('emerald', 'hoenn').some((m) => m.title === 'Champion · Wallace')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
milestonesFor('ultra-sun', 'alola').some((m) => m.title === 'Elite Four · Molayne')
|
||||
).toBe(true);
|
||||
expect(
|
||||
milestonesFor('heartgold', 'johto').filter(
|
||||
(m) => m.stage === 'postgame' && m.category === 'badge'
|
||||
)
|
||||
).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
67
src/lib/game-tracker/journal.ts
Normal file
67
src/lib/game-tracker/journal.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const stages = [
|
||||
{
|
||||
value: 'story',
|
||||
label: 'The adventure',
|
||||
description: 'Catch as you go, earn badges and become Champion.'
|
||||
},
|
||||
{
|
||||
value: 'postgame',
|
||||
label: 'Beyond the credits',
|
||||
description: 'Explore the postgame and finish what you can in this game.'
|
||||
},
|
||||
{
|
||||
value: 'collection',
|
||||
label: 'Complete the collection',
|
||||
description: 'Trades, transfers and event Pokémon.'
|
||||
}
|
||||
] as const;
|
||||
export type Stage = (typeof stages)[number]['value'];
|
||||
export const obtainedMethods = [
|
||||
'caught',
|
||||
'starter',
|
||||
'gift',
|
||||
'evolved',
|
||||
'bred',
|
||||
'trade',
|
||||
'transfer',
|
||||
'event'
|
||||
] as const;
|
||||
export const isStage = (value: string): value is Stage => stages.some((s) => s.value === value);
|
||||
|
||||
export function boundedInteger(value: FormDataEntryValue | null, min: number, max: number) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number >= min && number <= max ? number : null;
|
||||
}
|
||||
export function validDate(value: string) {
|
||||
return (
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
Number(value.slice(0, 4)) > 0 &&
|
||||
!Number.isNaN(Date.parse(value)) &&
|
||||
new Date(value).toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
export function firstCatches<T extends { id: number; pokemonId: number }>(catches: T[]) {
|
||||
const first = new Map<number, T>();
|
||||
for (const caught of [...catches].sort((a, b) => a.id - b.id))
|
||||
if (!first.has(caught.pokemonId)) first.set(caught.pokemonId, caught);
|
||||
return first;
|
||||
}
|
||||
export function allowedOsuUrl(value: string, kind: 'beatmap' | 'score') {
|
||||
if (!value) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.hostname === 'osu.ppy.sh' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.port &&
|
||||
(kind === 'beatmap'
|
||||
? /^\/(beatmaps|b|beatmapsets)\/\d+\/?$/.test(url.pathname)
|
||||
: /^\/scores\/(?:osu\/|taiko\/|fruits\/|mania\/)?\d+\/?$/.test(url.pathname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export type MasteryRules = {
|
||||
tiers: Tier[];
|
||||
classes: string[];
|
||||
steamApps: { id: number; label: string }[];
|
||||
rankCaps?: {
|
||||
player: { level: number; prestige: number };
|
||||
weaponPrestige: number;
|
||||
weaponLevelsByClass: Record<string, number>;
|
||||
weaponLevelOverrides?: Record<string, number>;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Weapon = { id: number; name: string; category: string; postLaunch: boolean };
|
||||
|
||||
305
src/lib/game-tracker/pokemon-world.ts
Normal file
305
src/lib/game-tracker/pokemon-world.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import atlas from '../../../data/pokemon-atlas.json';
|
||||
export type AtlasNode = {
|
||||
slug: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
kind: string;
|
||||
cells: number[][];
|
||||
};
|
||||
export type RegionMap = {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
nodes: AtlasNode[];
|
||||
source: string;
|
||||
precision: string;
|
||||
};
|
||||
export const regionMaps: Record<string, RegionMap> = atlas.regions;
|
||||
export function gameRegions(slug: string, regions: string[]) {
|
||||
if (['gold', 'silver', 'crystal', 'heartgold', 'soulsilver'].includes(slug))
|
||||
return ['johto', 'kanto'];
|
||||
if (slug === 'legends-arceus') return ['hisui'];
|
||||
if (slug.includes('teal-mask')) return ['kitakami'];
|
||||
if (slug.includes('indigo-disk')) return ['blueberry'];
|
||||
if (['colosseum', 'xd'].includes(slug)) return ['orre'];
|
||||
return regions.filter((r) => r in regionMaps);
|
||||
}
|
||||
export function initialLocation(slug: string, region: string) {
|
||||
if (['diamond', 'pearl'].includes(slug)) return 'lake-verity';
|
||||
return (
|
||||
(
|
||||
{
|
||||
sinnoh: 'sinnoh-route-201',
|
||||
kanto: 'pallet-town',
|
||||
johto: 'new-bark-town',
|
||||
hoenn: 'hoenn-route-101',
|
||||
unova: slug.endsWith('-2') ? 'aspertia-city' : 'nuvema-town',
|
||||
kalos: 'aquacorde-town',
|
||||
alola: 'alola-route-1',
|
||||
galar: 'postwick',
|
||||
hisui: 'obsidian-fieldlands',
|
||||
paldea: 'cabo-poco',
|
||||
orre: 'phenac-city',
|
||||
kitakami: 'mossui-town',
|
||||
blueberry: 'central-plaza'
|
||||
} as Record<string, string>
|
||||
)[region] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
export type Milestone = {
|
||||
key: string;
|
||||
title: string;
|
||||
category: string;
|
||||
stage: 'story' | 'postgame' | 'collection';
|
||||
};
|
||||
export function milestonesFor(slug: string, region: string): Milestone[] {
|
||||
const rows: Milestone[] = [];
|
||||
const add = (title: string, category: string, stage: Milestone['stage'] = 'story') =>
|
||||
rows.push({
|
||||
key: `${category}:${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||
title,
|
||||
category,
|
||||
stage
|
||||
});
|
||||
const badges: Record<string, string[]> = {
|
||||
kanto: [
|
||||
'Boulder · Brock',
|
||||
'Cascade · Misty',
|
||||
'Thunder · Lt. Surge',
|
||||
'Rainbow · Erika',
|
||||
'Soul · Koga',
|
||||
'Marsh · Sabrina',
|
||||
'Volcano · Blaine',
|
||||
'Earth · Giovanni'
|
||||
],
|
||||
johto: [
|
||||
'Zephyr · Falkner',
|
||||
'Hive · Bugsy',
|
||||
'Plain · Whitney',
|
||||
'Fog · Morty',
|
||||
'Storm · Chuck',
|
||||
'Mineral · Jasmine',
|
||||
'Glacier · Pryce',
|
||||
'Rising · Clair'
|
||||
],
|
||||
hoenn: [
|
||||
'Stone · Roxanne',
|
||||
'Knuckle · Brawly',
|
||||
'Dynamo · Wattson',
|
||||
'Heat · Flannery',
|
||||
'Balance · Norman',
|
||||
'Feather · Winona',
|
||||
'Mind · Tate & Liza',
|
||||
`Rain · ${slug === 'emerald' ? 'Juan' : 'Wallace'}`
|
||||
],
|
||||
sinnoh:
|
||||
slug === 'platinum'
|
||||
? [
|
||||
'Coal · Roark',
|
||||
'Forest · Gardenia',
|
||||
'Relic · Fantina',
|
||||
'Cobble · Maylene',
|
||||
'Fen · Crasher Wake',
|
||||
'Mine · Byron',
|
||||
'Icicle · Candice',
|
||||
'Beacon · Volkner'
|
||||
]
|
||||
: [
|
||||
'Coal · Roark',
|
||||
'Forest · Gardenia',
|
||||
'Cobble · Maylene',
|
||||
'Fen · Crasher Wake',
|
||||
'Relic · Fantina',
|
||||
'Mine · Byron',
|
||||
'Icicle · Candice',
|
||||
'Beacon · Volkner'
|
||||
],
|
||||
unova: slug.endsWith('-2')
|
||||
? [
|
||||
'Basic · Cheren',
|
||||
'Toxic · Roxie',
|
||||
'Insect · Burgh',
|
||||
'Bolt · Elesa',
|
||||
'Quake · Clay',
|
||||
'Jet · Skyla',
|
||||
'Legend · Drayden',
|
||||
'Wave · Marlon'
|
||||
]
|
||||
: [
|
||||
'Trio · Cilan / Chili / Cress',
|
||||
'Basic · Lenora',
|
||||
'Insect · Burgh',
|
||||
'Bolt · Elesa',
|
||||
'Quake · Clay',
|
||||
'Jet · Skyla',
|
||||
'Freeze · Brycen',
|
||||
`Legend · ${slug === 'white' ? 'Iris' : 'Drayden'}`
|
||||
],
|
||||
kalos: [
|
||||
'Bug · Viola',
|
||||
'Cliff · Grant',
|
||||
'Rumble · Korrina',
|
||||
'Plant · Ramos',
|
||||
'Voltage · Clemont',
|
||||
'Fairy · Valerie',
|
||||
'Psychic · Olympia',
|
||||
'Iceberg · Wulfric'
|
||||
],
|
||||
galar: [
|
||||
'Grass · Milo',
|
||||
'Water · Nessa',
|
||||
'Fire · Kabu',
|
||||
slug === 'shield' ? 'Ghost · Allister' : 'Fighting · Bea',
|
||||
'Fairy · Opal',
|
||||
slug === 'shield' ? 'Ice · Melony' : 'Rock · Gordie',
|
||||
'Dark · Piers',
|
||||
'Dragon · Raihan'
|
||||
],
|
||||
paldea: [
|
||||
'Bug · Katy',
|
||||
'Grass · Brassius',
|
||||
'Electric · Iono',
|
||||
'Water · Kofu',
|
||||
'Normal · Larry',
|
||||
'Ghost · Ryme',
|
||||
'Psychic · Tulip',
|
||||
'Ice · Grusha'
|
||||
]
|
||||
};
|
||||
if (
|
||||
/legends-za|mega-dimension|champions|isle-of-armor|crown-tundra|teal-mask|indigo-disk/.test(
|
||||
slug
|
||||
)
|
||||
) {
|
||||
if (slug.includes('isle-of-armor'))
|
||||
['Master dojo trials', 'Tower of Darkness / Waters', 'Final battle with Mustard'].forEach(
|
||||
(t) => add(t, 'boss')
|
||||
);
|
||||
if (slug.includes('crown-tundra'))
|
||||
[
|
||||
'Calyrex expedition',
|
||||
'Legendary giants expedition',
|
||||
'Legendary birds expedition',
|
||||
'Galarian Star Tournament'
|
||||
].forEach((t) => add(t, 'event'));
|
||||
if (slug.includes('teal-mask'))
|
||||
['Festival of Masks', 'Loyal Three battles', 'Ogerpon', 'Final battle with Kieran'].forEach(
|
||||
(t) => add(t, 'boss')
|
||||
);
|
||||
if (slug.includes('indigo-disk'))
|
||||
['Crispin', 'Amarys', 'Lacey', 'Drayton', 'Kieran', 'Terapagos'].forEach((t) =>
|
||||
add(t, 'boss')
|
||||
);
|
||||
} else {
|
||||
(badges[region] ?? []).forEach((t) => add(t, 'badge'));
|
||||
const leagues: Record<string, string[]> = {
|
||||
kanto: ['Lorelei', 'Bruno', 'Agatha', 'Lance'],
|
||||
johto: ['Will', 'Koga', 'Bruno', 'Karen'],
|
||||
hoenn: ['Sidney', 'Phoebe', 'Glacia', 'Drake'],
|
||||
sinnoh: ['Aaron', 'Bertha', 'Flint', 'Lucian'],
|
||||
unova: ['Shauntal', 'Grimsley', 'Caitlin', 'Marshal'],
|
||||
kalos: ['Malva', 'Siebold', 'Wikstrom', 'Drasna'],
|
||||
alola: [slug.startsWith('ultra-') ? 'Molayne' : 'Hala', 'Olivia', 'Acerola', 'Kahili'],
|
||||
paldea: ['Rika', 'Poppy', 'Larry', 'Hassel']
|
||||
};
|
||||
(leagues[region] ?? []).forEach((t) => add(`Elite Four · ${t}`, 'league'));
|
||||
const champion: Record<string, string> = {
|
||||
kanto: slug.startsWith('lets-go') ? 'Trace' : 'Blue',
|
||||
johto: 'Lance',
|
||||
hoenn: slug === 'emerald' ? 'Wallace' : 'Steven',
|
||||
sinnoh: 'Cynthia',
|
||||
unova: slug.endsWith('-2') ? 'Iris' : 'Alder',
|
||||
kalos: 'Diantha',
|
||||
galar: 'Leon',
|
||||
paldea: 'Geeta'
|
||||
};
|
||||
if (champion[region])
|
||||
add(
|
||||
`Champion · ${champion[region]}`,
|
||||
'league',
|
||||
region === 'unova' && !slug.endsWith('-2') ? 'postgame' : 'story'
|
||||
);
|
||||
if (region === 'sinnoh') {
|
||||
[
|
||||
'Commander Mars · Valley Windworks',
|
||||
'Commander Jupiter · Eterna',
|
||||
'Team Galactic · Lake battles',
|
||||
'Cyrus · Galactic HQ',
|
||||
slug === 'platinum' ? 'Cyrus · Distortion World' : 'Cyrus · Spear Pillar'
|
||||
].forEach((t) => add(t, 'boss'));
|
||||
['National Pokédex', 'Battle Zone', 'Stark Mountain · Heatran', 'Battle Tower'].forEach((t) =>
|
||||
add(t, 'event', 'postgame')
|
||||
);
|
||||
}
|
||||
if (region === 'johto') {
|
||||
['Brock', 'Misty', 'Lt. Surge', 'Erika', 'Janine', 'Sabrina', 'Blaine', 'Blue'].forEach((t) =>
|
||||
add(`Kanto badge · ${t}`, 'badge', 'postgame')
|
||||
);
|
||||
add('Red · Mt. Silver', 'boss', 'postgame');
|
||||
}
|
||||
if (region === 'kanto') {
|
||||
add('Team Rocket · Silph Co.', 'boss');
|
||||
add('Cerulean Cave · Mewtwo', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'hoenn') {
|
||||
add('Team Aqua / Magma finale', 'boss');
|
||||
add('Legendary weather Pokémon', 'event');
|
||||
if (['omega-ruby', 'alpha-sapphire'].includes(slug))
|
||||
add('Delta Episode', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'unova') ['N', 'Ghetsis'].forEach((t) => add(`Team Plasma · ${t}`, 'boss'));
|
||||
if (region === 'kalos') add('Lysandre · Team Flare finale', 'boss');
|
||||
if (region === 'alola') {
|
||||
[
|
||||
'Verdant Cavern',
|
||||
'Brooklet Hill',
|
||||
'Wela Volcano Park',
|
||||
'Lush Jungle',
|
||||
'Hokulani Observatory',
|
||||
'Thrifty Megamart',
|
||||
'Vast Poni Canyon'
|
||||
].forEach((t) => add(`Trial · ${t}`, 'badge'));
|
||||
['Hala', 'Olivia', 'Nanu', 'Hapu'].forEach((t) => add(`Grand trial · ${t}`, 'boss'));
|
||||
add(slug.startsWith('ultra-') ? 'Ultra Necrozma' : 'Lusamine', 'boss');
|
||||
add(slug.startsWith('ultra-') ? 'Title match · Hau' : 'Title match · Kukui', 'league');
|
||||
}
|
||||
if (region === 'galar')
|
||||
['Champion Cup semifinals', 'Champion Cup finals', 'Rose', 'Eternatus'].forEach((t) =>
|
||||
add(t, 'boss')
|
||||
);
|
||||
if (region === 'hisui') {
|
||||
['Kleavor', 'Lilligant', 'Arcanine', 'Electrode', 'Avalugg'].forEach((t) =>
|
||||
add(`Noble · ${t}`, 'boss')
|
||||
);
|
||||
add('Dialga & Palkia', 'boss');
|
||||
add('Volo & Giratina', 'boss', 'postgame');
|
||||
add('Arceus', 'boss', 'postgame');
|
||||
}
|
||||
if (region === 'paldea') {
|
||||
[
|
||||
'Klawf',
|
||||
'Bombirdier',
|
||||
'Orthworm',
|
||||
'Great Tusk / Iron Treads',
|
||||
'Dondozo & Tatsugiri'
|
||||
].forEach((t) => add(`Titan · ${t}`, 'boss'));
|
||||
['Giacomo', 'Mela', 'Atticus', 'Ortega', 'Eri', 'Cassiopeia'].forEach((t) =>
|
||||
add(`Team Star · ${t}`, 'boss')
|
||||
);
|
||||
['Arven', 'Nemona', 'The Way Home'].forEach((t) => add(t, 'boss'));
|
||||
add('Academy Ace Tournament', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'orre')
|
||||
(slug === 'xd'
|
||||
? ['Lovrina', 'Snattle', 'Gorigan', 'Greevil']
|
||||
: ['Miror B.', 'Dakim', 'Venus', 'Ein', 'Nascour', 'Evice']
|
||||
).forEach((t) => add(`Cipher · ${t}`, 'boss'));
|
||||
}
|
||||
add('Finish my in-game collection', 'collection', 'postgame');
|
||||
['Trade evolutions', 'Version exclusives', 'Transfers from other games', 'Event Pokémon'].forEach(
|
||||
(t) => add(t, 'collection', 'collection')
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
108
src/lib/game-tracker/progress.test.ts
Normal file
108
src/lib/game-tracker/progress.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { combinedProgress, fraction, challengeProgress, pokemonProgress } from './progress';
|
||||
import { milestonesFor } from './pokemon-world';
|
||||
|
||||
const input: Parameters<typeof pokemonProgress>[0] = {
|
||||
games: [{ id: 1, name: 'Platinum', slug: 'platinum', regions: ['sinnoh'] }],
|
||||
entries: [
|
||||
{ id: 1, gameId: 1, pokemonId: 387, availability: 'catchable' },
|
||||
{ id: 2, gameId: 1, pokemonId: 399, availability: 'catchable' },
|
||||
{ id: 3, gameId: 1, pokemonId: 1, availability: 'transfer_only' },
|
||||
{ id: 4, gameId: 1, pokemonId: 99, availability: 'unknown' },
|
||||
{ id: 5, gameId: 2, pokemonId: 387, availability: 'catchable' }
|
||||
],
|
||||
caughtIds: [],
|
||||
seen: [],
|
||||
journeys: [],
|
||||
milestones: [],
|
||||
dex: [
|
||||
{ gameId: 1, pokemonId: 387 },
|
||||
{ gameId: 1, pokemonId: 399 },
|
||||
{ gameId: 1, pokemonId: 387 }
|
||||
],
|
||||
speciesTotal: 1025
|
||||
};
|
||||
|
||||
describe('tracker completion calculations', () => {
|
||||
it('balances categories, omits empty catalogs and clamps fractions', () => {
|
||||
expect(
|
||||
fraction(
|
||||
combinedProgress([
|
||||
{ value: 1, total: 2 },
|
||||
{ value: 100, total: 100 },
|
||||
{ value: 0, total: 0 }
|
||||
])
|
||||
)
|
||||
).toBe(0.75);
|
||||
expect(fraction({ value: 3, total: 2 })).toBe(1);
|
||||
expect(fraction({ value: 0, total: 0 })).toBe(0);
|
||||
expect(fraction(combinedProgress([]))).toBe(0);
|
||||
});
|
||||
it('credits partial challenge targets and explicit unlocks without overcounting', () => {
|
||||
const result = challengeProgress(
|
||||
[
|
||||
{ id: 1, target: 100 },
|
||||
{ id: 2, target: null },
|
||||
{ id: 3, target: 5 }
|
||||
],
|
||||
[
|
||||
{ challengeId: 1, current: 25, done: false },
|
||||
{ challengeId: 2, current: 0, done: true },
|
||||
{ challengeId: 3, current: 99, done: false }
|
||||
]
|
||||
);
|
||||
expect(result).toEqual({ value: 2.25, total: 3 });
|
||||
});
|
||||
it('counts a species once globally, once per game, and caught implies seen', () => {
|
||||
const result = pokemonProgress({
|
||||
...input,
|
||||
games: [...input.games, { id: 2, name: 'Diamond', slug: 'diamond', regions: ['sinnoh'] }],
|
||||
caughtIds: [1, 5],
|
||||
seen: [
|
||||
{ gameId: 1, pokemonId: 387 },
|
||||
{ gameId: 1, pokemonId: 399 }
|
||||
]
|
||||
});
|
||||
expect(result.caught.value).toBe(1);
|
||||
expect(result.seen.value).toBe(2);
|
||||
expect(result.collection.value).toBe(2);
|
||||
expect(result.games[0].regional).toEqual({ value: 1, total: 2 });
|
||||
});
|
||||
it('includes confirmed trade/transfer targets, excludes unknowns and scopes selected games', () => {
|
||||
const result = pokemonProgress({
|
||||
...input,
|
||||
caughtIds: [1, 5],
|
||||
seen: [{ gameId: 2, pokemonId: 999 }]
|
||||
});
|
||||
expect(result.games[0].collection).toEqual({ value: 1, total: 3 });
|
||||
expect(result.games[0].inGame).toEqual({ value: 1, total: 2 });
|
||||
expect(result.seen.value).toBe(1);
|
||||
expect(pokemonProgress({ ...input, games: [] }).completed).toEqual({ value: 0, total: 0 });
|
||||
});
|
||||
it('allows real catches to confirm unknown availability', () => {
|
||||
expect(pokemonProgress({ ...input, caughtIds: [4] }).games[0].collection).toEqual({
|
||||
value: 1,
|
||||
total: 4
|
||||
});
|
||||
});
|
||||
it('credits story completion without pretending all collection tasks are complete', () => {
|
||||
const before = pokemonProgress(input);
|
||||
const after = pokemonProgress({ ...input, journeys: [{ gameId: 1, completed: true }] });
|
||||
expect(after.completed.value).toBe(1);
|
||||
expect(fraction(after.games[0].overall)).toBeGreaterThan(fraction(before.games[0].overall));
|
||||
expect(fraction(after.games[0].overall)).toBeLessThan(1);
|
||||
expect(after.games[0].badges.value).toBe(0);
|
||||
});
|
||||
it('infers a cleared game from all default story milestones, not arbitrary custom ones', () => {
|
||||
const milestones = milestonesFor('platinum', 'sinnoh')
|
||||
.filter((m) => m.stage === 'story')
|
||||
.map((m) => ({ ...m, gameId: 1, done: true }));
|
||||
expect(pokemonProgress({ ...input, milestones }).completed.value).toBe(1);
|
||||
expect(
|
||||
pokemonProgress({
|
||||
...input,
|
||||
milestones: [{ gameId: 1, key: 'custom:win', stage: 'story', category: 'boss', done: true }]
|
||||
}).completed.value
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
125
src/lib/game-tracker/progress.ts
Normal file
125
src/lib/game-tracker/progress.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { obtainableAvailability, type Availability } from '$lib/availability';
|
||||
import { gameRegions, milestonesFor } from './pokemon-world';
|
||||
|
||||
export type ProgressCount = { value: number; total: number };
|
||||
export const fraction = ({ value, total }: ProgressCount) =>
|
||||
total > 0 ? Math.max(0, Math.min(1, value / total)) : 0;
|
||||
export function combinedProgress(parts: ProgressCount[]): ProgressCount {
|
||||
const available = parts.filter((p) => p.total > 0);
|
||||
return { value: available.reduce((n, p) => n + fraction(p), 0), total: available.length };
|
||||
}
|
||||
export function sumProgress(parts: ProgressCount[]): ProgressCount {
|
||||
return parts.reduce((a, p) => ({ value: a.value + p.value, total: a.total + p.total }), {
|
||||
value: 0,
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
export function challengeProgress(
|
||||
challenges: { id: number; target: number | null }[],
|
||||
progress: { challengeId: number; current: number; done: boolean }[]
|
||||
) {
|
||||
const saved = new Map(progress.map((p) => [p.challengeId, p]));
|
||||
return {
|
||||
value: challenges.reduce((n, c) => {
|
||||
const p = saved.get(c.id);
|
||||
return n + (p?.done ? 1 : fraction({ value: p?.current ?? 0, total: c.target ?? 0 }));
|
||||
}, 0),
|
||||
total: challenges.length
|
||||
};
|
||||
}
|
||||
|
||||
export function pokemonProgress(input: {
|
||||
games: { id: number; name: string; slug: string | null; regions: string[] }[];
|
||||
entries: { id: number; gameId: number; pokemonId: number; availability: Availability }[];
|
||||
caughtIds: number[];
|
||||
seen: { gameId: number; pokemonId: number }[];
|
||||
journeys: { gameId: number; completed: boolean }[];
|
||||
milestones: { gameId: number; key: string; stage: string; category: string; done: boolean }[];
|
||||
dex: { gameId: number; pokemonId: number }[];
|
||||
speciesTotal: number;
|
||||
}) {
|
||||
const caught = new Set(input.caughtIds);
|
||||
const entries = Map.groupBy(input.entries, (e) => e.gameId);
|
||||
const sightings = Map.groupBy(input.seen, (e) => e.gameId);
|
||||
const milestones = Map.groupBy(input.milestones, (m) => m.gameId);
|
||||
const dexes = Map.groupBy(input.dex, (d) => d.gameId);
|
||||
const allCaught = new Set<number>();
|
||||
const allSeen = new Set<number>();
|
||||
const games = input.games.map((game) => {
|
||||
const list = entries.get(game.id) ?? [];
|
||||
const caughtSpecies = new Set(list.filter((e) => caught.has(e.id)).map((e) => e.pokemonId));
|
||||
const seenSpecies = new Set([
|
||||
...caughtSpecies,
|
||||
...(sightings.get(game.id) ?? []).map((s) => s.pokemonId)
|
||||
]);
|
||||
for (const id of caughtSpecies) allCaught.add(id);
|
||||
for (const id of seenSpecies) allSeen.add(id);
|
||||
const collect = (ids: number[], source = caughtSpecies): ProgressCount => {
|
||||
const unique = new Set(ids);
|
||||
return { value: [...unique].filter((id) => source.has(id)).length, total: unique.size };
|
||||
};
|
||||
const collection = collect(
|
||||
list
|
||||
.filter((e) => e.availability !== 'unknown' || caughtSpecies.has(e.pokemonId))
|
||||
.map((e) => e.pokemonId)
|
||||
);
|
||||
const inGame = collect(
|
||||
list.filter((e) => obtainableAvailability.has(e.availability)).map((e) => e.pokemonId)
|
||||
);
|
||||
const seen = collect(
|
||||
list.map((e) => e.pokemonId),
|
||||
seenSpecies
|
||||
);
|
||||
const regionalIds = (dexes.get(game.id) ?? []).map((d) => d.pokemonId);
|
||||
const regional = collect(regionalIds);
|
||||
const regionalSeen = collect(regionalIds, seenSpecies);
|
||||
const saved = new Map((milestones.get(game.id) ?? []).map((m) => [m.key, m]));
|
||||
const defaults = milestonesFor(
|
||||
game.slug ?? '',
|
||||
gameRegions(game.slug ?? '', game.regions)[0] ?? ''
|
||||
);
|
||||
const count = (rows: { key: string }[]): ProgressCount => ({
|
||||
value: rows.filter((m) => saved.get(m.key)?.done).length,
|
||||
total: rows.length
|
||||
});
|
||||
const story = count(defaults.filter((m) => m.stage === 'story'));
|
||||
const postgame = count(defaults.filter((m) => m.stage === 'postgame'));
|
||||
const transfers = count(defaults.filter((m) => m.stage === 'collection'));
|
||||
const badges = count(defaults.filter((m) => m.category === 'badge'));
|
||||
const league = count(defaults.filter((m) => m.category === 'league'));
|
||||
const bosses = count(defaults.filter((m) => m.category === 'boss'));
|
||||
const custom = count([...saved.values()].filter((m) => m.key.startsWith('custom:')));
|
||||
const completed =
|
||||
input.journeys.some((j) => j.gameId === game.id && j.completed) ||
|
||||
(story.total > 0 && story.value === story.total);
|
||||
const adventure = { value: story.value + Number(completed), total: story.total + 1 };
|
||||
return {
|
||||
id: game.id,
|
||||
name: game.name,
|
||||
slug: game.slug,
|
||||
collection,
|
||||
inGame,
|
||||
seen,
|
||||
regional,
|
||||
regionalSeen,
|
||||
story,
|
||||
adventure,
|
||||
postgame,
|
||||
transfers,
|
||||
badges,
|
||||
league,
|
||||
bosses,
|
||||
custom,
|
||||
completed,
|
||||
overall: combinedProgress([collection, adventure, postgame, transfers])
|
||||
};
|
||||
});
|
||||
return {
|
||||
games,
|
||||
caught: { value: allCaught.size, total: input.speciesTotal },
|
||||
seen: { value: allSeen.size, total: input.speciesTotal },
|
||||
completed: { value: games.filter((g) => g.completed).length, total: games.length },
|
||||
collection: sumProgress(games.map((g) => g.collection)),
|
||||
overall: combinedProgress(games.map((g) => g.overall))
|
||||
};
|
||||
}
|
||||
43
src/lib/game-tracker/rank-limits.ts
Normal file
43
src/lib/game-tracker/rank-limits.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { MasteryRules } from './mastery';
|
||||
|
||||
export type RankLimits = {
|
||||
maxLevel: number | null;
|
||||
maxPrestige: number;
|
||||
canPrestigeOnlyAtMaxLevel: boolean;
|
||||
rankable: boolean;
|
||||
};
|
||||
|
||||
const capsFor = (rules: MasteryRules) => rules.rankCaps;
|
||||
|
||||
export function playerRankLimits(rules: MasteryRules): RankLimits {
|
||||
const caps = capsFor(rules)?.player;
|
||||
return {
|
||||
maxLevel: caps?.level ?? null,
|
||||
maxPrestige: caps?.prestige ?? 1000,
|
||||
canPrestigeOnlyAtMaxLevel: false,
|
||||
rankable: true
|
||||
};
|
||||
}
|
||||
|
||||
export function weaponRankLimits(
|
||||
rules: MasteryRules,
|
||||
weapon: Pick<{ category: string; name: string }, 'category' | 'name'>
|
||||
): RankLimits {
|
||||
const caps = capsFor(rules);
|
||||
if (!caps) {
|
||||
return {
|
||||
maxLevel: null,
|
||||
maxPrestige: 1000,
|
||||
canPrestigeOnlyAtMaxLevel: false,
|
||||
rankable: true
|
||||
};
|
||||
}
|
||||
const maxLevel =
|
||||
caps.weaponLevelOverrides?.[weapon.name] ?? caps.weaponLevelsByClass[weapon.category] ?? null;
|
||||
return {
|
||||
maxLevel,
|
||||
maxPrestige: caps.weaponPrestige,
|
||||
canPrestigeOnlyAtMaxLevel: true,
|
||||
rankable: maxLevel !== null
|
||||
};
|
||||
}
|
||||
@@ -39,12 +39,17 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/projects/game-tracker': {
|
||||
title: 'Game Tracker',
|
||||
description:
|
||||
'Completionist progress for every Pokémon in every game and Call of Duty achievements, weapons and camos.'
|
||||
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'
|
||||
},
|
||||
'/projects/game-tracker/pokemon': {
|
||||
title: 'Catchemall',
|
||||
title: 'Pokémon journal',
|
||||
description:
|
||||
'Track which Pokémon you have caught across 53 games, with where to find them and how to evolve or breed them.'
|
||||
'Explore Pokémon encounters and regional Pokédexes, record catches and teams, and track badges across your playthroughs.'
|
||||
},
|
||||
'/projects/game-tracker/osu': {
|
||||
title: 'osu! training',
|
||||
description:
|
||||
'Keep memorable scores, beatmap goals and a training journal for speed, stamina and consistency.'
|
||||
},
|
||||
'/projects/game-tracker/pokemon/locations': {
|
||||
title: 'Pokémon locations',
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './game-tracker';
|
||||
export * from './tracker-journal';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
|
||||
299
src/lib/server/db/schema/tracker-journal.ts
Normal file
299
src/lib/server/db/schema/tracker-journal.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
unique
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { user } from './auth';
|
||||
import { games, pokemon } from './catch-em-all';
|
||||
import { trackerGames, trackerItems } from './game-tracker';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const pokemonResearch = pgTable('pokemon_research', {
|
||||
pokemonId: integer('pokemon_id')
|
||||
.primaryKey()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
facts: jsonb('facts')
|
||||
.$type<{
|
||||
height: number;
|
||||
weight: number;
|
||||
baseExperience: number | null;
|
||||
captureRate: number;
|
||||
baseHappiness: number | null;
|
||||
growthRate: string;
|
||||
genderRate: number;
|
||||
hatchCounter: number | null;
|
||||
eggGroups: string[];
|
||||
abilities: { name: string; hidden: boolean }[];
|
||||
evYield: number[];
|
||||
}>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const pokemonDexNumbers = pgTable(
|
||||
'pokemon_dex_numbers',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
dex: text('dex').notNull(),
|
||||
dexName: text('dex_name').notNull(),
|
||||
number: integer('number').notNull()
|
||||
},
|
||||
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonMoves = pgTable('pokemon_moves', {
|
||||
id: integer('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(),
|
||||
category: text('category').notNull(),
|
||||
power: integer('power'),
|
||||
accuracy: integer('accuracy'),
|
||||
pp: integer('pp'),
|
||||
priority: integer('priority').notNull(),
|
||||
target: text('target').notNull()
|
||||
});
|
||||
|
||||
export const pokemonLearnsets = pgTable(
|
||||
'pokemon_learnsets',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
versionGroup: integer('version_group').notNull(),
|
||||
moves: jsonb('moves').$type<[moveId: number, method: string, level: number][]>().notNull()
|
||||
},
|
||||
(t) => [unique().on(t.pokemonId, t.versionGroup)]
|
||||
);
|
||||
|
||||
export const pokemonGameResearch = pgTable('pokemon_game_research', {
|
||||
gameId: integer('game_id')
|
||||
.primaryKey()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
versionGroup: integer('version_group').notNull(),
|
||||
regions: text('regions').array().notNull()
|
||||
});
|
||||
|
||||
export const pokemonJourneys = pgTable(
|
||||
'pokemon_journeys',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
|
||||
.notNull()
|
||||
.default('story'),
|
||||
location: text('location'),
|
||||
completed: boolean('completed').notNull().default(false),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonSeen = pgTable(
|
||||
'pokemon_seen',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
|
||||
);
|
||||
|
||||
export const pokemonCatches = pgTable(
|
||||
'pokemon_catches',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
nickname: text('nickname').notNull().default(''),
|
||||
level: integer('level').notNull().default(1),
|
||||
moves: text('moves').array().notNull().default([]),
|
||||
partySlot: integer('party_slot'),
|
||||
shiny: boolean('shiny').notNull().default(false),
|
||||
nature: text('nature'),
|
||||
ability: text('ability'),
|
||||
ball: text('ball'),
|
||||
location: text('location'),
|
||||
obtained: text('obtained').notNull().default('caught'),
|
||||
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
|
||||
.notNull()
|
||||
.default('story'),
|
||||
notes: text('notes').notNull().default(''),
|
||||
caughtOn: date('caught_on'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.userId, t.gameId, t.pokemonId),
|
||||
unique().on(t.userId, t.gameId, t.partySlot),
|
||||
check('pokemon_catch_level', sql`${t.level} between 1 and 100`),
|
||||
check('pokemon_party_slot', sql`${t.partySlot} between 1 and 6`),
|
||||
check('pokemon_four_moves', sql`cardinality(${t.moves}) <= 4`)
|
||||
]
|
||||
);
|
||||
|
||||
export const pokemonMilestones = pgTable(
|
||||
'pokemon_milestones',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
category: text('category').notNull(),
|
||||
stage: text('stage').notNull().default('story'),
|
||||
done: boolean('done').notNull().default(false),
|
||||
notes: text('notes').notNull().default(''),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId, t.key)]
|
||||
);
|
||||
|
||||
export const trackerRanks = pgTable(
|
||||
'tracker_ranks',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
level: integer('level').notNull().default(1),
|
||||
prestige: integer('prestige').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
unique().on(t.userId, t.gameId),
|
||||
check(
|
||||
'tracker_rank_range',
|
||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const trackerWeaponRanks = pgTable(
|
||||
'tracker_weapon_ranks',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => trackerItems.id, { onDelete: 'cascade' }),
|
||||
level: integer('level').notNull().default(1),
|
||||
prestige: integer('prestige').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
unique().on(t.userId, t.itemId),
|
||||
check(
|
||||
'tracker_weapon_rank_range',
|
||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const trackerChallenges = pgTable(
|
||||
'tracker_challenges',
|
||||
{
|
||||
id: id(),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(),
|
||||
name: text('name').notNull(),
|
||||
category: text('category').notNull(),
|
||||
requirement: text('requirement').notNull(),
|
||||
reward: text('reward'),
|
||||
icon: text('icon'),
|
||||
target: integer('target'),
|
||||
sourceUrl: text('source_url').notNull()
|
||||
},
|
||||
(t) => [unique().on(t.gameId, t.key)]
|
||||
);
|
||||
|
||||
export const trackerChallengeProgress = pgTable(
|
||||
'tracker_challenge_progress',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
challengeId: integer('challenge_id')
|
||||
.notNull()
|
||||
.references(() => trackerChallenges.id, { onDelete: 'cascade' }),
|
||||
current: integer('current').notNull().default(0),
|
||||
done: boolean('done').notNull().default(false),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.challengeId)]
|
||||
);
|
||||
|
||||
export const osuEntries = pgTable(
|
||||
'osu_entries',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
kind: text('kind', { enum: ['score', 'goal', 'training'] }).notNull(),
|
||||
title: text('title').notNull(),
|
||||
mode: text('mode').notNull().default('osu'),
|
||||
beatmapUrl: text('beatmap_url'),
|
||||
scoreUrl: text('score_url'),
|
||||
goal: text('goal').notNull().default('pass'),
|
||||
skill: text('skill').notNull().default('stamina'),
|
||||
bpm: integer('bpm'),
|
||||
duration: integer('duration'),
|
||||
accuracy: text('accuracy'),
|
||||
combo: integer('combo'),
|
||||
misses: integer('misses'),
|
||||
pp: text('pp'),
|
||||
mods: text('mods').notNull().default('NM'),
|
||||
rate: text('rate').notNull().default('1.00'),
|
||||
achieved: boolean('achieved').notNull().default(false),
|
||||
playedOn: date('played_on').notNull(),
|
||||
notes: text('notes').notNull().default(''),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [index().on(t.userId, t.kind)]
|
||||
);
|
||||
|
||||
export type PokemonCatch = typeof pokemonCatches.$inferSelect;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import { pokemonResearch, pokemonGameResearch, pokemonLearnsets, pokemonMoves } from './db/schema';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
@@ -104,8 +105,50 @@ export async function pokemonDetail(number: number, gameId: number | null) {
|
||||
)
|
||||
: [];
|
||||
|
||||
const [research] = await db
|
||||
.select()
|
||||
.from(pokemonResearch)
|
||||
.where(eq(pokemonResearch.pokemonId, species.id));
|
||||
const [gameResearch] = gameId
|
||||
? await db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId))
|
||||
: [];
|
||||
const [learnset] = gameResearch
|
||||
? await db
|
||||
.select()
|
||||
.from(pokemonLearnsets)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonLearnsets.pokemonId, species.id),
|
||||
eq(pokemonLearnsets.versionGroup, gameResearch.versionGroup)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const moveIds = [...new Set((learnset?.moves ?? []).map((m) => m[0]))];
|
||||
const moves = moveIds.length
|
||||
? await db.select().from(pokemonMoves).where(inArray(pokemonMoves.id, moveIds))
|
||||
: [];
|
||||
const moveMap = new Map(moves.map((m) => [m.id, m]));
|
||||
|
||||
const numbers = new Map(members.map((member) => [member.id, member.number]));
|
||||
return {
|
||||
facts: research?.facts ?? null,
|
||||
stats: [
|
||||
{ name: 'HP', value: species.hp },
|
||||
{ name: 'Attack', value: species.attack },
|
||||
{ name: 'Defense', value: species.defense },
|
||||
{ name: 'Sp. Atk', value: species.specialAttack },
|
||||
{ name: 'Sp. Def', value: species.specialDefense },
|
||||
{ name: 'Speed', value: species.speed }
|
||||
],
|
||||
learnset: (learnset?.moves ?? [])
|
||||
.flatMap(([id, method, level]) => {
|
||||
const move = moveMap.get(id);
|
||||
return move ? [{ ...move, method, level }] : [];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.method.localeCompare(b.method) || a.level - b.level || a.name.localeCompare(b.name)
|
||||
),
|
||||
pokemon: {
|
||||
number: species.number,
|
||||
name: species.name,
|
||||
|
||||
278
src/lib/server/pokemon-journal.ts
Normal file
278
src/lib/server/pokemon-journal.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from '@sveltejs/kit';
|
||||
import { db } from './db';
|
||||
import {
|
||||
games,
|
||||
pokemon,
|
||||
pokemonCatches,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonSeen,
|
||||
pokemonGameEntries,
|
||||
userEntries
|
||||
} from './db/schema';
|
||||
import { boundedInteger, isStage, obtainedMethods, validDate } from '$lib/game-tracker/journal';
|
||||
|
||||
// Serialize mutations for one user's game, including competing party assignments.
|
||||
async function lockGame(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
userId: string,
|
||||
gameId: number
|
||||
) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${gameId})`);
|
||||
}
|
||||
|
||||
export const pokemonJournalActions = {
|
||||
setSeen: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to record a sighting.' });
|
||||
const form = await request.formData();
|
||||
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
|
||||
const pokemonId = boundedInteger(form.get('pokemonId'), 1, 2147483647);
|
||||
const seen = form.get('seen');
|
||||
if (!gameId || !pokemonId || !['on', 'off'].includes(String(seen)))
|
||||
return fail(400, { message: 'Invalid sighting.' });
|
||||
const [entry] = await db
|
||||
.select({ id: pokemonGameEntries.id })
|
||||
.from(pokemonGameEntries)
|
||||
.where(
|
||||
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
|
||||
);
|
||||
if (!entry) return fail(404, { message: 'Species not found in this game.' });
|
||||
return db.transaction(async (tx) => {
|
||||
await lockGame(tx, locals.user!.id, gameId);
|
||||
if (seen === 'on')
|
||||
await tx
|
||||
.insert(pokemonSeen)
|
||||
.values({ userId: locals.user!.id, gameId, pokemonId })
|
||||
.onConflictDoNothing();
|
||||
else {
|
||||
const [caught] = await tx
|
||||
.select({ id: userEntries.id })
|
||||
.from(userEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(userEntries.userId, locals.user!.id),
|
||||
eq(userEntries.pokemonGameEntryId, entry.id)
|
||||
)
|
||||
);
|
||||
if (caught) return fail(400, { message: 'Caught Pokémon also count as seen.' });
|
||||
await tx
|
||||
.delete(pokemonSeen)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonSeen.userId, locals.user!.id),
|
||||
eq(pokemonSeen.gameId, gameId),
|
||||
eq(pokemonSeen.pokemonId, pokemonId)
|
||||
)
|
||||
);
|
||||
}
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
setCompleted: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to record a completed game.' });
|
||||
const form = await request.formData();
|
||||
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
|
||||
if (!gameId || !['on', 'off'].includes(String(form.get('completed'))))
|
||||
return fail(400, { message: 'Invalid completion state.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
const completed = form.get('completed') === 'on';
|
||||
await db
|
||||
.insert(pokemonJourneys)
|
||||
.values({ userId: locals.user.id, gameId, completed })
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
|
||||
set: { completed, updatedAt: new Date() }
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveCatch: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to register a catch.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const pokemonId = boundedInteger(data.get('pokemonId'), 1, 2147483647);
|
||||
const id = data.get('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||||
const level = boundedInteger(data.get('level'), 1, 100);
|
||||
const partySlot = data.get('partySlot') ? boundedInteger(data.get('partySlot'), 1, 6) : null;
|
||||
const text = (key: string) => String(data.get(key) ?? '').trim();
|
||||
const stage = text('stage');
|
||||
const obtained = text('obtained');
|
||||
const moves = data
|
||||
.getAll('moves')
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
!gameId ||
|
||||
!pokemonId ||
|
||||
!level ||
|
||||
(data.get('id') && !id) ||
|
||||
(data.get('partySlot') && !partySlot) ||
|
||||
!isStage(stage) ||
|
||||
!(obtainedMethods as readonly string[]).includes(obtained) ||
|
||||
moves.length > 4 ||
|
||||
moves.some((m) => m.length > 60) ||
|
||||
new Set(moves).size !== moves.length ||
|
||||
text('nickname').length > 50 ||
|
||||
text('notes').length > 2000 ||
|
||||
['location', 'nature', 'ability', 'ball'].some((k) => text(k).length > 150) ||
|
||||
(text('caughtOn') && !validDate(text('caughtOn')))
|
||||
)
|
||||
return fail(400, {
|
||||
message:
|
||||
'Check the catch details: level 1–100, up to four different moves and a valid date.'
|
||||
});
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(pokemonGameEntries)
|
||||
.where(
|
||||
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
|
||||
);
|
||||
if (!entry) return fail(400, { message: 'This species has no catalog entry for this game.' });
|
||||
const userId = locals.user.id;
|
||||
const values = {
|
||||
userId,
|
||||
gameId,
|
||||
pokemonId,
|
||||
level,
|
||||
partySlot,
|
||||
stage,
|
||||
obtained,
|
||||
moves,
|
||||
nickname: text('nickname'),
|
||||
notes: text('notes'),
|
||||
shiny: data.get('shiny') === 'on',
|
||||
location: text('location') || null,
|
||||
nature: text('nature') || null,
|
||||
ability: text('ability') || null,
|
||||
ball: text('ball') || null,
|
||||
caughtOn: text('caughtOn') || null
|
||||
};
|
||||
return db.transaction(async (tx) => {
|
||||
await lockGame(tx, userId, gameId);
|
||||
if (id) {
|
||||
const [owned] = await tx
|
||||
.select({ id: pokemonCatches.id })
|
||||
.from(pokemonCatches)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.id, id),
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, gameId),
|
||||
eq(pokemonCatches.pokemonId, pokemonId)
|
||||
)
|
||||
);
|
||||
if (!owned) return fail(404, { message: 'Catch not found.' });
|
||||
}
|
||||
if (partySlot)
|
||||
await tx
|
||||
.update(pokemonCatches)
|
||||
.set({ partySlot: null })
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, gameId),
|
||||
eq(pokemonCatches.partySlot, partySlot)
|
||||
)
|
||||
);
|
||||
if (id)
|
||||
await tx
|
||||
.update(pokemonCatches)
|
||||
.set(values)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, userId)));
|
||||
else await tx.insert(pokemonCatches).values(values);
|
||||
await tx
|
||||
.insert(userEntries)
|
||||
.values({ userId, pokemonGameEntryId: entry.id })
|
||||
.onConflictDoNothing();
|
||||
await tx.insert(pokemonSeen).values({ userId, gameId, pokemonId }).onConflictDoNothing();
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
deleteCatch: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to edit your catches.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid catch.' });
|
||||
const [owned] = await db
|
||||
.select()
|
||||
.from(pokemonCatches)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user.id)));
|
||||
if (!owned) return fail(404, { message: 'Catch not found.' });
|
||||
await db.transaction(async (tx) => {
|
||||
await lockGame(tx, locals.user!.id, owned.gameId);
|
||||
await tx
|
||||
.delete(pokemonCatches)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user!.id)));
|
||||
// The historical caught checkmark deliberately survives deleting a journal record.
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveJourney: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save your adventure.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const stage = String(data.get('stage') ?? '');
|
||||
const location = String(data.get('location') ?? '').trim();
|
||||
if (!gameId || !isStage(stage) || location.length > 150)
|
||||
return fail(400, { message: 'Invalid journey.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
await db
|
||||
.insert(pokemonJourneys)
|
||||
.values({ userId: locals.user.id, gameId, stage, location: location || null })
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
|
||||
set: { stage, location: location || null, updatedAt: new Date() }
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveMilestone: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to track milestones.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const text = (k: string) => String(data.get(k) ?? '').trim();
|
||||
const key = text('key') || `custom:${crypto.randomUUID()}`;
|
||||
if (
|
||||
!gameId ||
|
||||
!isStage(text('stage')) ||
|
||||
!text('title') ||
|
||||
text('title').length > 200 ||
|
||||
text('notes').length > 2000 ||
|
||||
key.length > 200 ||
|
||||
!['badge', 'league', 'boss', 'event', 'collection'].includes(text('category'))
|
||||
)
|
||||
return fail(400, { message: 'Check the milestone details.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
gameId,
|
||||
key,
|
||||
title: text('title'),
|
||||
category: text('category'),
|
||||
stage: text('stage'),
|
||||
notes: text('notes'),
|
||||
done: data.get('done') === 'on'
|
||||
};
|
||||
await db
|
||||
.insert(pokemonMilestones)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonMilestones.userId, pokemonMilestones.gameId, pokemonMilestones.key],
|
||||
set: values
|
||||
});
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
export async function catchesFor(userId: string | undefined, gameId: number) {
|
||||
if (!userId) return [];
|
||||
return db
|
||||
.select({ catch: pokemonCatches, number: pokemon.number, name: pokemon.name })
|
||||
.from(pokemonCatches)
|
||||
.innerJoin(pokemon, eq(pokemon.id, pokemonCatches.pokemonId))
|
||||
.where(and(eq(pokemonCatches.userId, userId), eq(pokemonCatches.gameId, gameId)))
|
||||
.orderBy(asc(pokemon.number), asc(pokemonCatches.id));
|
||||
}
|
||||
95
src/lib/server/tracker-progress.ts
Normal file
95
src/lib/server/tracker-progress.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { eq, count } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { pokemonProgress } from '$lib/game-tracker/progress';
|
||||
import {
|
||||
games,
|
||||
pokemon,
|
||||
pokemonGameEntries,
|
||||
pokemonGameResearch,
|
||||
pokemonDexNumbers,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonSeen,
|
||||
userEntries,
|
||||
userGames,
|
||||
catchEmAllSettings
|
||||
} from './db/schema';
|
||||
|
||||
export async function loadPokemonProgress(userId?: string) {
|
||||
const [
|
||||
gameRows,
|
||||
entries,
|
||||
research,
|
||||
dex,
|
||||
caught,
|
||||
seen,
|
||||
journeys,
|
||||
milestones,
|
||||
selected,
|
||||
settings,
|
||||
totals
|
||||
] = await Promise.all([
|
||||
db.select({ id: games.id, name: games.name, slug: games.slug }).from(games),
|
||||
db
|
||||
.select({
|
||||
id: pokemonGameEntries.id,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
pokemonId: pokemonGameEntries.pokemonId,
|
||||
availability: pokemonGameEntries.availability
|
||||
})
|
||||
.from(pokemonGameEntries),
|
||||
db
|
||||
.select({ gameId: pokemonGameResearch.gameId, regions: pokemonGameResearch.regions })
|
||||
.from(pokemonGameResearch),
|
||||
db
|
||||
.select({ gameId: pokemonDexNumbers.gameId, pokemonId: pokemonDexNumbers.pokemonId })
|
||||
.from(pokemonDexNumbers),
|
||||
userId
|
||||
? db
|
||||
.select({ id: userEntries.pokemonGameEntryId })
|
||||
.from(userEntries)
|
||||
.where(eq(userEntries.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: pokemonSeen.gameId, pokemonId: pokemonSeen.pokemonId })
|
||||
.from(pokemonSeen)
|
||||
.where(eq(pokemonSeen.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: pokemonJourneys.gameId, completed: pokemonJourneys.completed })
|
||||
.from(pokemonJourneys)
|
||||
.where(eq(pokemonJourneys.userId, userId))
|
||||
: [],
|
||||
userId ? db.select().from(pokemonMilestones).where(eq(pokemonMilestones.userId, userId)) : [],
|
||||
userId
|
||||
? db.select({ gameId: userGames.gameId }).from(userGames).where(eq(userGames.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ id: catchEmAllSettings.id })
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, userId))
|
||||
: [],
|
||||
db.select({ count: count() }).from(pokemon)
|
||||
]);
|
||||
const configured = settings.length > 0 || selected.length > 0;
|
||||
const ids = new Set(selected.map((g) => g.gameId));
|
||||
const regions = new Map(research.map((r) => [r.gameId, r.regions]));
|
||||
return {
|
||||
...pokemonProgress({
|
||||
games: gameRows
|
||||
.filter((g) => !configured || ids.has(g.id))
|
||||
.map((g) => ({ ...g, regions: regions.get(g.id) ?? [] })),
|
||||
entries,
|
||||
dex,
|
||||
caughtIds: caught.map((c) => c.id),
|
||||
seen,
|
||||
journeys,
|
||||
milestones,
|
||||
speciesTotal: totals[0].count
|
||||
}),
|
||||
configured
|
||||
};
|
||||
}
|
||||
@@ -223,6 +223,8 @@
|
||||
>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each projects as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
@@ -241,6 +243,7 @@
|
||||
>
|
||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -343,7 +346,10 @@
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
|
||||
<footer class="mt-16 border-t border-[var(--site-border)]">
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
@@ -374,3 +380,21 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) and (min-height: 650px) {
|
||||
.tracker-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tracker-footer > div {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tracker-footer nav {
|
||||
display: none;
|
||||
}
|
||||
.tracker-footer > div > div {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { calendarDate, relativeTime } from '$lib/relative-time';
|
||||
@@ -27,19 +33,25 @@
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">blog</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new article</summary>
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance
|
||||
class="absolute top-full right-0 z-10 mt-3 w-72 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
{#if data.isAdmin}<Dialog.Root>
|
||||
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
|
||||
>new article</Dialog.Trigger
|
||||
>
|
||||
<label class="block"
|
||||
>title<Input name="title" required maxlength={256} class="mt-2" /></label
|
||||
<Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>new article</Dialog.Title><Dialog.Description
|
||||
>Give your draft a title, then continue writing.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<Button type="submit">create draft</Button>
|
||||
</form>
|
||||
</details>{/if}
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<label class="block"
|
||||
>title<Input name="title" required maxlength={256} class="mt-2" /></label
|
||||
>
|
||||
<Button type="submit">create draft</Button>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mb-8 space-y-4">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
@@ -108,16 +109,36 @@
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<details class="mt-12 border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">delete article</summary>
|
||||
<form method="POST" action="?/delete" class="mt-3 space-y-3">
|
||||
<p class="text-base">
|
||||
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
|
||||
</p>
|
||||
<label class="block text-base"
|
||||
>article title<Input name="confirmTitle" required autocomplete="off" class="mt-2" /></label
|
||||
>
|
||||
<Button type="submit" variant="destructive">delete article</Button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="mt-12 border-t border-[var(--site-border)] pt-5">
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="text-base text-muted-foreground"
|
||||
>delete article</AlertDialog.Trigger
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete article?</AlertDialog.Title><AlertDialog.Description
|
||||
>This permanently removes the article.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>
|
||||
<form method="POST" action="?/delete" class="mt-3 space-y-3">
|
||||
<p class="text-base">
|
||||
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
|
||||
</p>
|
||||
<label class="block text-base"
|
||||
>article title<Input
|
||||
name="confirmTitle"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-2"
|
||||
/></label
|
||||
>
|
||||
<AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep article</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">delete article</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form>
|
||||
</AlertDialog.Content></AlertDialog.Root
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
@@ -45,30 +52,44 @@
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/newList"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
{#if data.isAdmin}<Dialog.Root>
|
||||
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
|
||||
>new list</Dialog.Trigger
|
||||
>
|
||||
<label class="block"
|
||||
>name<Input name="name" required maxlength={200} class="mt-2" /></label
|
||||
<Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>new list</Dialog.Title><Dialog.Description
|
||||
>Create a collection or track progress through a list.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<label class="block"
|
||||
>type<select
|
||||
name="type"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
><option value="normal">normal</option><option value="progress">progress</option
|
||||
></select
|
||||
></label
|
||||
{#if form?.message}<p role="alert" class="text-destructive">
|
||||
{form.message}
|
||||
</p>{/if}{#if uploadError}<p role="alert" class="text-destructive">
|
||||
{uploadError}
|
||||
</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/newList"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="space-y-4"
|
||||
>
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
<Button type="submit" disabled={uploading}>create list</Button>
|
||||
</form>
|
||||
</details>{/if}
|
||||
<label class="block"
|
||||
>name<Input name="name" required maxlength={200} class="mt-2" /></label
|
||||
>
|
||||
<label class="block"
|
||||
>type<NativeSelect
|
||||
name="type"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
><option value="normal">normal</option><option value="progress">progress</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
<Button type="submit" disabled={uploading}>create list</Button>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
let addOpen = $state(false);
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
@@ -28,7 +37,10 @@
|
||||
update: () => Promise<void>;
|
||||
}) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingItem = null;
|
||||
if (result.type === 'success') {
|
||||
editingItem = null;
|
||||
addOpen = false;
|
||||
}
|
||||
};
|
||||
uploadError = '';
|
||||
const picture = formData.get('picture');
|
||||
@@ -65,12 +77,6 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') editingItem = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/lists')}
|
||||
@@ -94,11 +100,11 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.isAdmin}<button
|
||||
{#if data.isAdmin}<Button
|
||||
type="button"
|
||||
onclick={() => (managing = !managing)}
|
||||
aria-pressed={managing}
|
||||
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
|
||||
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
|
||||
@@ -123,9 +129,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-b border-[var(--site-border)] pb-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
|
||||
<div class="mt-5 space-y-5">
|
||||
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
||||
<Collapsible.Trigger class="cursor-pointer text-base text-[var(--site-muted)]"
|
||||
>list settings</Collapsible.Trigger
|
||||
>
|
||||
<Collapsible.Content class="mt-5 space-y-5">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateList"
|
||||
@@ -134,65 +142,78 @@
|
||||
class="grid gap-3 sm:grid-cols-2 sm:items-end"
|
||||
>
|
||||
<label
|
||||
>List name<input
|
||||
>List name<Input
|
||||
name="name"
|
||||
value={data.list.name}
|
||||
required
|
||||
maxlength="200"
|
||||
maxlength={200}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Slug<input
|
||||
>Slug<Input
|
||||
name="slug"
|
||||
value={data.list.slug}
|
||||
required
|
||||
maxlength="120"
|
||||
maxlength={120}
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Type<select name="type" class="block w-full rounded border p-2">
|
||||
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
|
||||
<option value="normal" selected={!data.list.isProgress}>Normal</option>
|
||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
||||
</select></label
|
||||
</NativeSelect></label
|
||||
>
|
||||
<div class="sm:col-span-2">
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
><Checkbox name="removePicture" value="on" /> Remove current picture</label
|
||||
>{/if}
|
||||
</div>
|
||||
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
|
||||
>Save list</button
|
||||
<Button type="submit" disabled={uploading} class="rounded border p-2 disabled:opacity-50"
|
||||
>Save list</Button
|
||||
>
|
||||
</form>
|
||||
<details class="border-t border-[var(--site-border)] pt-4">
|
||||
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
|
||||
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
|
||||
<p class="text-sm">
|
||||
This deletes the list, its items, and everyone’s saved progress. Type <strong
|
||||
>{data.list.name}</strong
|
||||
> to confirm.
|
||||
</p>
|
||||
<label class="block"
|
||||
>List name<input
|
||||
name="confirmName"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded border p-2"
|
||||
/></label
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="font-medium text-destructive"
|
||||
>Delete this list</AlertDialog.Trigger
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete this list?</AlertDialog.Title><AlertDialog.Description
|
||||
>Deleting this list also removes its items and saved progress.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>
|
||||
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
|
||||
<p class="text-sm">
|
||||
This deletes the list, its items, and everyone’s saved progress. Type <strong
|
||||
>{data.list.name}</strong
|
||||
> to confirm.
|
||||
</p>
|
||||
<label class="block"
|
||||
>List name<Input
|
||||
name="confirmName"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep list</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">Delete list</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form>
|
||||
</AlertDialog.Content></AlertDialog.Root
|
||||
>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
<label class="block">
|
||||
<span class="sr-only">Search list items</span>
|
||||
<input
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="Search list items"
|
||||
@@ -223,19 +244,20 @@
|
||||
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
|
||||
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
|
||||
>
|
||||
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if data.isAdmin && managing}<button
|
||||
{#if data.isAdmin && managing}<Button
|
||||
type="button"
|
||||
onclick={() => (editingItem = item)}
|
||||
class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
|
||||
aria-label={`Edit ${item.name}`}>Edit</button
|
||||
aria-label={`Edit ${item.name}`}>Edit</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</li>
|
||||
@@ -243,20 +265,21 @@
|
||||
{search ? 'No matching items' : 'no items yet'}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
{#if data.isAdmin && managing && editingItem}
|
||||
<div class="fixed inset-0 z-40 bg-black/60"></div>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Edit ${editingItem.name}`}
|
||||
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
|
||||
<Dialog.Root
|
||||
open={!!(data.isAdmin && managing && editingItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) editingItem = null;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Edit item</Dialog.Title><Dialog.Description
|
||||
>Update the name, picture and description.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold">Edit item</h2>
|
||||
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
|
||||
>
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
|
||||
{#if editingItem}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateItem"
|
||||
@@ -266,30 +289,32 @@
|
||||
>
|
||||
<input type="hidden" name="itemId" value={editingItem.id} />
|
||||
<label
|
||||
>Name<input
|
||||
>Name<Input
|
||||
name="name"
|
||||
value={editingItem.name}
|
||||
required
|
||||
maxlength="500"
|
||||
maxlength={500}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone label="Replace picture (optional)" />
|
||||
{#if editingItem.pictureUrl}<label class="text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
><Checkbox name="removePicture" value="on" /> Remove current picture</label
|
||||
>{/if}
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
>Description (optional)<Textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
|
||||
></label
|
||||
maxlength={5000}
|
||||
rows={3}
|
||||
class="block w-full rounded border p-2"
|
||||
value={editingItem.description ?? ''}
|
||||
/></label
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Save item</button
|
||||
>Save item</Button
|
||||
>
|
||||
</form>
|
||||
<form
|
||||
@@ -298,51 +323,66 @@
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingItem = null;
|
||||
if (result.type === 'success') {
|
||||
editingItem = null;
|
||||
addOpen = false;
|
||||
}
|
||||
}}
|
||||
class="mt-5 border-t border-[var(--site-border)] pt-4"
|
||||
>
|
||||
<input type="hidden" name="itemId" value={editingItem.id} />
|
||||
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
|
||||
>Remove item</button
|
||||
<Button
|
||||
type="submit"
|
||||
class="text-sm text-red-700 underline"
|
||||
aria-label={`Remove ${editingItem.name}`}>Remove item</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}</Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer font-medium">add item</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-5 grid gap-3"
|
||||
>
|
||||
<label
|
||||
>Name<input
|
||||
name="name"
|
||||
required
|
||||
maxlength="500"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
<Dialog.Root bind:open={addOpen}
|
||||
><Dialog.Trigger class="underline underline-offset-4">add item</Dialog.Trigger><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Add item</Dialog.Title><Dialog.Description
|
||||
>Add a new entry to this list.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<ListPictureDropzone />
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2"></textarea></label
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-5 grid gap-3"
|
||||
>
|
||||
<button
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Add item</button
|
||||
>
|
||||
</form>
|
||||
</details>
|
||||
<label
|
||||
>Name<Input
|
||||
name="name"
|
||||
required
|
||||
maxlength={500}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone />
|
||||
<label
|
||||
>Description (optional)<Textarea
|
||||
name="description"
|
||||
maxlength={5000}
|
||||
rows={3}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Add item</Button
|
||||
>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import PawPrint from 'lucide-svelte/icons/paw-print';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
|
||||
interface TrailPoint {
|
||||
@@ -35,7 +35,6 @@
|
||||
}
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let detailsDialog: HTMLDialogElement;
|
||||
let map: MaptilerMap | null = null;
|
||||
let destroyed = false;
|
||||
let htmlMarkers: MaptilerMarker[] = [];
|
||||
@@ -79,18 +78,6 @@
|
||||
openAnimal(animalId);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.animal) {
|
||||
if (!detailsDialog.open) detailsDialog.showModal();
|
||||
} else if (detailsDialog.open) {
|
||||
detailsDialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
function onDialogClose() {
|
||||
if (page.state.animal) history.back();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -417,34 +404,28 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<dialog
|
||||
bind:this={detailsDialog}
|
||||
onclose={onDialogClose}
|
||||
onclick={(event) => {
|
||||
if (event.target === detailsDialog) detailsDialog.close();
|
||||
<Dialog.Root
|
||||
open={!!page.state.animal}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && page.state.animal) history.back();
|
||||
}}
|
||||
aria-label={page.state.animal
|
||||
? page.state.animal.animalName || page.state.animal.species
|
||||
: 'Animal details'}
|
||||
class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-5xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-bg)] p-0 text-[var(--site-text)] backdrop:bg-black/70"
|
||||
>
|
||||
{#if page.state.animal}
|
||||
<div class="sticky top-0 z-10 flex justify-end gap-4 bg-[var(--site-bg)] px-5 py-3 sm:px-8">
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title
|
||||
>{page.state.animal?.animalName ||
|
||||
page.state.animal?.species ||
|
||||
'Animal details'}</Dialog.Title
|
||||
>
|
||||
<Dialog.Description>Sightings, photos and the story so far.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if page.state.animal}
|
||||
<a
|
||||
href={resolve('/projects/animaldex/[id=integer]', { id: String(page.state.animal.id) })}
|
||||
data-sveltekit-reload
|
||||
class="text-base underline underline-offset-4">open page</a
|
||||
class="underline underline-offset-4">open page</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => detailsDialog.close()}
|
||||
aria-label="Close animal details"
|
||||
class="flex h-8 w-8 items-center justify-center rounded hover:bg-[var(--site-surface)]"
|
||||
><X size={20} aria-hidden="true" /></button
|
||||
>
|
||||
</div>
|
||||
<div class="px-5 pb-8 sm:px-8">
|
||||
<AnimalDetails animal={page.state.animal} />
|
||||
</div>
|
||||
{/if}
|
||||
</dialog>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
@@ -275,7 +276,7 @@
|
||||
<Badge variant="secondary">{selected.length} / 1000</Badge>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span>Group by</span><select
|
||||
><span>Group by</span><NativeSelect
|
||||
value={dimension}
|
||||
onchange={(event) => changeDimension(event.currentTarget.value)}
|
||||
>
|
||||
@@ -283,7 +284,7 @@
|
||||
{#each data.dimensions as option (option.key)}<option value={option.key}
|
||||
>{labels[option.key] ?? option.key.replaceAll('_', ' ')}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
</NativeSelect></label
|
||||
>
|
||||
<label class="field"
|
||||
><span
|
||||
@@ -347,21 +348,21 @@
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label
|
||||
>Source<select
|
||||
>Source<NativeSelect
|
||||
value={sourceChoice}
|
||||
onchange={(event) => changeSource(event.currentTarget.value)}
|
||||
>{#each sourceOptions as option (option)}<option value={option}
|
||||
>{option.replace('|', ' · ')}</option
|
||||
>{/each}</select
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label
|
||||
>Range<select bind:value={range}
|
||||
>Range<NativeSelect bind:value={range}
|
||||
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
|
||||
value="7d">7 days</option
|
||||
><option value="30d">30 days</option><option value="1y">1 year</option><option
|
||||
value="5y">5 years</option
|
||||
><option value="all">All history</option><option value="custom">Custom</option
|
||||
></select
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
@@ -504,7 +505,7 @@
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
select {
|
||||
:global([data-slot='native-select-wrapper']) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-text);
|
||||
@@ -587,7 +588,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.controls select {
|
||||
.controls :global([data-slot='native-select-wrapper']) {
|
||||
min-width: 140px;
|
||||
}
|
||||
.custom-range {
|
||||
|
||||
@@ -1,80 +1,88 @@
|
||||
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
catchEmAllSettings,
|
||||
pokemonGameEntries,
|
||||
trackerGames,
|
||||
trackerItems,
|
||||
trackerProgress,
|
||||
userEntries,
|
||||
userGames
|
||||
trackerWeaponRanks,
|
||||
trackerRanks,
|
||||
trackerChallenges,
|
||||
trackerChallengeProgress,
|
||||
osuEntries
|
||||
} from '$lib/server/db/schema';
|
||||
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
||||
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
|
||||
import { summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
|
||||
|
||||
async function pokemonSummary(userId: string | undefined) {
|
||||
let gameIds: number[] | null = null;
|
||||
if (userId) {
|
||||
const [settings] = await db
|
||||
.select({ id: catchEmAllSettings.id })
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, userId));
|
||||
const selected = await db
|
||||
.select({ id: userGames.gameId })
|
||||
.from(userGames)
|
||||
.where(eq(userGames.userId, userId));
|
||||
if (settings || selected.length) gameIds = selected.map((row) => row.id);
|
||||
}
|
||||
const scope =
|
||||
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
|
||||
const [totals] = await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId),
|
||||
games: countDistinct(pokemonGameEntries.gameId)
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.where(scope);
|
||||
const [caught] = userId
|
||||
? await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId)
|
||||
})
|
||||
.from(userEntries)
|
||||
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
|
||||
.where(and(eq(userEntries.userId, userId), scope))
|
||||
: [{ entries: 0, species: 0 }];
|
||||
return { totals, caught };
|
||||
const progress = await loadPokemonProgress(userId);
|
||||
return {
|
||||
...progress,
|
||||
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
|
||||
...g,
|
||||
releaseYear: pokemonRelease(g.slug).slice(0, 4)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function callOfDutySummary(userId: string | undefined) {
|
||||
const [gamesList, items, progress] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(eq(trackerGames.franchise, 'call-of-duty'))
|
||||
.orderBy(asc(trackerGames.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
gameId: trackerItems.gameId,
|
||||
kind: trackerItems.kind,
|
||||
name: trackerItems.name,
|
||||
category: trackerItems.category,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.where(eq(trackerProgress.userId, userId))
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(eq(trackerGames.franchise, 'call-of-duty'))
|
||||
.orderBy(asc(trackerGames.releaseYear), asc(trackerGames.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
gameId: trackerItems.gameId,
|
||||
kind: trackerItems.kind,
|
||||
name: trackerItems.name,
|
||||
category: trackerItems.category,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.where(eq(trackerProgress.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerWeaponRanks.itemId, prestige: trackerWeaponRanks.prestige })
|
||||
.from(trackerWeaponRanks)
|
||||
.where(eq(trackerWeaponRanks.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: trackerRanks.gameId })
|
||||
.from(trackerRanks)
|
||||
.where(eq(trackerRanks.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
db
|
||||
.select({
|
||||
id: trackerChallenges.id,
|
||||
gameId: trackerChallenges.gameId,
|
||||
target: trackerChallenges.target
|
||||
})
|
||||
.from(trackerChallenges),
|
||||
userId
|
||||
? db
|
||||
.select({
|
||||
challengeId: trackerChallengeProgress.challengeId,
|
||||
current: trackerChallengeProgress.current,
|
||||
done: trackerChallengeProgress.done
|
||||
})
|
||||
.from(trackerChallengeProgress)
|
||||
.where(eq(trackerChallengeProgress.userId, userId))
|
||||
: []
|
||||
]);
|
||||
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
|
||||
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
|
||||
const byGame = Map.groupBy(items, (item) => item.gameId);
|
||||
return gamesList.map((game) => {
|
||||
const list = byGame.get(game.id) ?? [];
|
||||
@@ -84,7 +92,28 @@ async function callOfDutySummary(userId: string | undefined) {
|
||||
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
|
||||
(id, milestone) => done.has(`${id}:${milestone}`)
|
||||
);
|
||||
|
||||
const weaponProgress = sumProgress(summary.milestones);
|
||||
const challengesProgress = challengeProgress(
|
||||
challenges.filter((c) => c.gameId === game.id),
|
||||
challengeRows
|
||||
);
|
||||
const ranksProgress = {
|
||||
value:
|
||||
ranks.filter((rank) =>
|
||||
list.some((item) => item.kind === 'weapon' && item.id === rank.itemId)
|
||||
).length + Number(playerRanks.some((rank) => rank.gameId === game.id)),
|
||||
total: list.filter((item) => item.kind === 'weapon').length + 1
|
||||
};
|
||||
return {
|
||||
overall: combinedProgress([
|
||||
weaponProgress,
|
||||
ranksProgress,
|
||||
summary.achievements,
|
||||
challengesProgress
|
||||
]),
|
||||
weaponProgress,
|
||||
challengesProgress,
|
||||
slug: game.slug,
|
||||
name: game.shortName,
|
||||
releaseYear: game.releaseYear,
|
||||
@@ -108,9 +137,23 @@ async function callOfDutySummary(userId: string | undefined) {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const [pokemon, callOfDuty] = await Promise.all([
|
||||
const [pokemon, callOfDuty, osu] = await Promise.all([
|
||||
pokemonSummary(locals.user?.id),
|
||||
callOfDutySummary(locals.user?.id)
|
||||
callOfDutySummary(locals.user?.id),
|
||||
locals.user
|
||||
? db
|
||||
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
|
||||
.from(osuEntries)
|
||||
.where(eq(osuEntries.userId, locals.user.id))
|
||||
: []
|
||||
]);
|
||||
return { pokemon, callOfDuty };
|
||||
return {
|
||||
pokemon,
|
||||
callOfDuty,
|
||||
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
|
||||
osuGoals: {
|
||||
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
|
||||
total: osu.filter((e) => e.kind === 'goal').length
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,107 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
|
||||
Call of Duty.
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
|
||||
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
|
||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
|
||||
</div>
|
||||
{#if !data.user}<a
|
||||
class="text-sm text-muted-foreground underline"
|
||||
href={resolve('/login?returnTo=/projects/game-tracker')}>Sign in to save progress</a
|
||||
>{:else}<a
|
||||
class="text-xs text-muted-foreground underline"
|
||||
href={resolve('/projects/game-tracker/credits')}>Artwork credits</a
|
||||
>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
|
||||
aria-label="Your completion dashboard"
|
||||
>
|
||||
<ProgressMeter
|
||||
label="National collection"
|
||||
progress={data.pokemon.caught}
|
||||
detail={`${data.pokemon.caught.value} unique species caught`}
|
||||
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Species seen at least once"
|
||||
progress={data.pokemon.seen}
|
||||
color="sky"
|
||||
detail={`${data.pokemon.seen.value} species discovered`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Pokémon games completed"
|
||||
progress={data.pokemon.completed}
|
||||
color="amber"
|
||||
hint="Games marked story complete, or with all default story milestones checked."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Call of Duty overall"
|
||||
progress={data.codOverall}
|
||||
color="violet"
|
||||
detail="Mastery · achievements · challenges"
|
||||
/>
|
||||
</div>
|
||||
<div class="series-grid grid min-h-0 flex-1 gap-4">
|
||||
<section
|
||||
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="pokemon-series"
|
||||
>
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/game-tracker')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-semibold">Pokémon</h2>
|
||||
<Card.Root class="mt-4 py-0 transition-colors hover:bg-muted/50"
|
||||
><Card.Content class="p-0"
|
||||
><a href={resolve('/projects/game-tracker/pokemon')} class="block p-5">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3">
|
||||
<p class="text-xl font-semibold">catchemall</p>
|
||||
<div class="w-32">
|
||||
<ProgressBar
|
||||
value={data.pokemon.caught.entries}
|
||||
max={data.pokemon.totals.entries}
|
||||
label="Pokémon caught"
|
||||
small
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
|
||||
>
|
||||
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
|
||||
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
|
||||
<ProgressMeter
|
||||
label="Across every adventure"
|
||||
progress={data.pokemon.overall}
|
||||
color="amber"
|
||||
compact
|
||||
hint="Equal weight per game; collection and story stages each contribute."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Every game’s collection"
|
||||
progress={data.pokemon.collection}
|
||||
compact
|
||||
hint="Each confirmed species/game pairing counts once, including trades and events."
|
||||
/>
|
||||
</div>
|
||||
{#if data.pokemon.games.length}
|
||||
<div class="collection-grid grid grid-cols-2 gap-2">
|
||||
{#each data.pokemon.games as game (game.id)}
|
||||
<a
|
||||
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
|
||||
href={resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
|
||||
title={`${game.name} · ${game.releaseYear}`}
|
||||
>
|
||||
<GameLogo
|
||||
slug={`pokemon/${game.slug}`}
|
||||
name={game.name}
|
||||
class="game-cover h-10 w-10"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
|
||||
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
|
||||
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
|
||||
</p>
|
||||
<ProgressMeter
|
||||
label="Game progress"
|
||||
progress={game.overall}
|
||||
color="amber"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<a
|
||||
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
>Choose your games in “set rules” to build your collection here.</a
|
||||
>{/if}
|
||||
</section>
|
||||
<section
|
||||
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="cod-series"
|
||||
>
|
||||
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
|
||||
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
|
||||
<div>
|
||||
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{data.callOfDuty.length} campaigns of progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 px-1">
|
||||
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
|
||||
</div>
|
||||
<div class="cod-rows grid min-h-0 gap-1.5">
|
||||
{#each data.callOfDuty as game (game.slug)}
|
||||
<a
|
||||
href={resolve(`/projects/game-tracker/call-of-duty/${game.slug}`)}
|
||||
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
|
||||
>
|
||||
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-tight font-medium">{game.name}</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{game.releaseYear}<span class="ml-2"
|
||||
>{game.achievements.value}/{game.achievements.total} achievements</span
|
||||
>
|
||||
</p>
|
||||
<ProgressMeter
|
||||
label={`${game.name} progress`}
|
||||
progress={game.overall}
|
||||
color="violet"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="osu-series"
|
||||
>
|
||||
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
|
||||
<div>
|
||||
<h2 id="osu-series" class="font-semibold">osu!</h2>
|
||||
<p class="text-xs text-muted-foreground">One more attempt</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/osu')}
|
||||
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
|
||||
><div class="flex items-center gap-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
|
||||
<div>
|
||||
<p class="font-semibold">osu!</p>
|
||||
<p class="text-xs text-muted-foreground">2007</p>
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
|
||||
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
|
||||
.totals.games} games
|
||||
</p>
|
||||
</a></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
<Button variant="link" href={resolve('/projects/game-tracker/pokemon/locations')} class="mt-3"
|
||||
>browse Pokémon locations</Button
|
||||
>
|
||||
</section>
|
||||
|
||||
<section class="mt-12">
|
||||
<h2 class="text-2xl font-semibold">Call of Duty</h2>
|
||||
<ul class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{#each data.callOfDuty as game (game.slug)}
|
||||
<li>
|
||||
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
|
||||
><Card.Content class="p-0"
|
||||
><a
|
||||
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
|
||||
class="block h-full p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<p class="text-xl font-semibold">{game.name}</p>
|
||||
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
|
||||
</div>
|
||||
<dl class="mt-4 grid gap-3">
|
||||
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3 text-sm text-[var(--site-muted)]"
|
||||
>
|
||||
<dt class="truncate">{row.label}</dt>
|
||||
<div class="w-32">
|
||||
<ProgressBar value={row.value} max={row.total} label={row.label} small />
|
||||
</div>
|
||||
<dd class="min-w-12 text-right tabular-nums">{row.value}/{row.total}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if game.tiers.length}
|
||||
<p class="mt-4 text-[var(--site-muted)]">
|
||||
{game.tiers
|
||||
.map((tier) =>
|
||||
tier.scope === 'class'
|
||||
? `${tier.label} ${tier.value}/${tier.total} classes`
|
||||
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
|
||||
)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
</a></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<p class="mt-4 text-sm">Your training room →</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Scores, dream FCs, stamina and speed. Build your next personal best.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
|
||||
</div></a
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.series-grid {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
.game-row {
|
||||
min-height: 64px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.series-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.series-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import achievementArt from '../../../../../../data/cod-achievement-art.json';
|
||||
const achievementImages: Record<string, string> = achievementArt;
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
|
||||
import {
|
||||
trackerGames,
|
||||
trackerItems,
|
||||
trackerProgress,
|
||||
trackerRanks,
|
||||
trackerWeaponRanks,
|
||||
trackerChallenges,
|
||||
trackerChallengeProgress
|
||||
} from '$lib/server/db/schema';
|
||||
import { boundedInteger } from '$lib/game-tracker/journal';
|
||||
import { appliesTo } from '$lib/game-tracker/mastery';
|
||||
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function findGame(slug: string) {
|
||||
@@ -36,9 +48,61 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
|
||||
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
|
||||
: [];
|
||||
const [ranks, weaponRanks, challenges, challengeProgress] = await Promise.all([
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(trackerRanks)
|
||||
.where(and(eq(trackerRanks.userId, locals.user.id), eq(trackerRanks.gameId, game.id)))
|
||||
: [],
|
||||
locals.user
|
||||
? db
|
||||
.select({
|
||||
itemId: trackerWeaponRanks.itemId,
|
||||
level: trackerWeaponRanks.level,
|
||||
prestige: trackerWeaponRanks.prestige
|
||||
})
|
||||
.from(trackerWeaponRanks)
|
||||
.innerJoin(trackerItems, eq(trackerItems.id, trackerWeaponRanks.itemId))
|
||||
.where(
|
||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerItems.gameId, game.id))
|
||||
)
|
||||
: [],
|
||||
db
|
||||
.select()
|
||||
.from(trackerChallenges)
|
||||
.where(eq(trackerChallenges.gameId, game.id))
|
||||
.orderBy(asc(trackerChallenges.category), asc(trackerChallenges.id)),
|
||||
locals.user
|
||||
? db
|
||||
.select({
|
||||
challengeId: trackerChallengeProgress.challengeId,
|
||||
current: trackerChallengeProgress.current,
|
||||
done: trackerChallengeProgress.done
|
||||
})
|
||||
.from(trackerChallengeProgress)
|
||||
.innerJoin(
|
||||
trackerChallenges,
|
||||
eq(trackerChallenges.id, trackerChallengeProgress.challengeId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerChallengeProgress.userId, locals.user.id),
|
||||
eq(trackerChallenges.gameId, game.id)
|
||||
)
|
||||
)
|
||||
: []
|
||||
]);
|
||||
return {
|
||||
rank: ranks[0] ?? null,
|
||||
weaponRanks,
|
||||
challenges,
|
||||
challengeProgress,
|
||||
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
|
||||
items,
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
icon: item.icon ? (achievementImages[item.icon] ?? item.icon) : null
|
||||
})),
|
||||
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
|
||||
seo: {
|
||||
title: `${game.shortName} · Game Tracker`,
|
||||
@@ -48,6 +112,151 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
adjustWeaponRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' });
|
||||
const game = await findGame(params.slug);
|
||||
const form = await request.formData();
|
||||
const itemId = boundedInteger(form.get('itemId'), 1, 2147483647);
|
||||
const field = form.get('field');
|
||||
const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null;
|
||||
if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null)
|
||||
return fail(400, { message: 'Invalid weapon adjustment.' });
|
||||
const [item] = await db
|
||||
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
|
||||
.from(trackerItems)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerItems.id, itemId),
|
||||
eq(trackerItems.gameId, game.id),
|
||||
eq(trackerItems.kind, 'weapon')
|
||||
)
|
||||
);
|
||||
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
|
||||
const limits = weaponRankLimits(game.rules, item);
|
||||
if (!limits.rankable)
|
||||
return fail(400, {
|
||||
message: 'This weapon does not have a weapon-rank progression in this game.'
|
||||
});
|
||||
const maxLevel = limits.maxLevel ?? 10000;
|
||||
if (field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel) {
|
||||
const [current] = await db
|
||||
.select({ level: trackerWeaponRanks.level, prestige: trackerWeaponRanks.prestige })
|
||||
.from(trackerWeaponRanks)
|
||||
.where(
|
||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
|
||||
);
|
||||
if (!current || current.level < maxLevel || current.prestige >= limits.maxPrestige)
|
||||
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
|
||||
}
|
||||
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
|
||||
const [rank] = await db
|
||||
.insert(trackerWeaponRanks)
|
||||
.values({
|
||||
userId: locals.user.id,
|
||||
itemId,
|
||||
level: field === 'level' ? Math.max(1, Math.min(maxLevel, 1 + delta)) : 1,
|
||||
prestige: field === 'prestige' ? Math.max(0, delta) : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
|
||||
set: {
|
||||
...(field === 'level'
|
||||
? {
|
||||
level: sql`greatest(1, least(${maxLevel}, ${trackerWeaponRanks.level} + ${delta}))`
|
||||
}
|
||||
: {
|
||||
prestige: sql`greatest(0, least(${limits.maxPrestige}, ${trackerWeaponRanks.prestige} + ${delta}))`,
|
||||
...(delta > 0 ? { level: 1 } : {})
|
||||
}),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
.returning({
|
||||
itemId: trackerWeaponRanks.itemId,
|
||||
level: trackerWeaponRanks.level,
|
||||
prestige: trackerWeaponRanks.prestige
|
||||
});
|
||||
return { rank };
|
||||
},
|
||||
saveRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save your rank.' });
|
||||
const game = await findGame(params.slug);
|
||||
const data = await request.formData();
|
||||
const level = boundedInteger(data.get('level'), 1, 10000);
|
||||
const prestige = boundedInteger(data.get('prestige'), 0, 1000);
|
||||
const itemId = data.get('itemId') ? boundedInteger(data.get('itemId'), 1, 2147483647) : null;
|
||||
if (level === null || prestige === null || (data.get('itemId') && !itemId))
|
||||
return fail(400, { message: 'Enter a valid level and prestige.' });
|
||||
if (itemId) {
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(trackerItems)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerItems.id, itemId),
|
||||
eq(trackerItems.gameId, game.id),
|
||||
eq(trackerItems.kind, 'weapon')
|
||||
)
|
||||
);
|
||||
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
|
||||
const limits = weaponRankLimits(game.rules, item);
|
||||
if (
|
||||
!limits.rankable ||
|
||||
(limits.maxLevel !== null && level > limits.maxLevel) ||
|
||||
prestige > limits.maxPrestige
|
||||
)
|
||||
return fail(400, { message: 'Enter a rank within this weapon’s level and prestige caps.' });
|
||||
await db
|
||||
.insert(trackerWeaponRanks)
|
||||
.values({ userId: locals.user.id, itemId, level, prestige })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
|
||||
set: { level, prestige, updatedAt: new Date() }
|
||||
});
|
||||
} else {
|
||||
const limits = playerRankLimits(game.rules);
|
||||
if ((limits.maxLevel !== null && level > limits.maxLevel) || prestige > limits.maxPrestige)
|
||||
return fail(400, { message: 'Enter a rank within this player level and prestige caps.' });
|
||||
await db
|
||||
.insert(trackerRanks)
|
||||
.values({ userId: locals.user.id, gameId: game.id, level, prestige })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerRanks.userId, trackerRanks.gameId],
|
||||
set: { level, prestige, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
return { saved: true };
|
||||
},
|
||||
saveChallenge: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to track challenges.' });
|
||||
const game = await findGame(params.slug);
|
||||
const data = await request.formData();
|
||||
const challengeId = boundedInteger(data.get('challengeId'), 1, 2147483647);
|
||||
const current = boundedInteger(data.get('current'), 0, 2147483647);
|
||||
if (!challengeId || current === null)
|
||||
return fail(400, { message: 'Invalid challenge progress.' });
|
||||
const [challenge] = await db
|
||||
.select()
|
||||
.from(trackerChallenges)
|
||||
.where(and(eq(trackerChallenges.id, challengeId), eq(trackerChallenges.gameId, game.id)));
|
||||
if (!challenge) return fail(404, { message: 'Challenge not found.' });
|
||||
if (challenge.target !== null && current > challenge.target)
|
||||
return fail(400, { message: 'Progress cannot exceed the target.' });
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
challengeId,
|
||||
current,
|
||||
done: data.get('done') === 'on' || (challenge.target !== null && current >= challenge.target)
|
||||
};
|
||||
await db
|
||||
.insert(trackerChallengeProgress)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerChallengeProgress.userId, trackerChallengeProgress.challengeId],
|
||||
set: values
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
toggle: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||
const game = await findGame(params.slug);
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
<script lang="ts">
|
||||
import WeaponRankControls from '$lib/game-tracker/WeaponRankControls.svelte';
|
||||
import WeaponArtwork from '$lib/game-tracker/WeaponArtwork.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import Target from 'lucide-svelte/icons/target';
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import { combinedProgress, sumProgress, challengeProgress } from '$lib/game-tracker/progress';
|
||||
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
|
||||
let rankOpen = $state(false);
|
||||
let rankWeapon = $state<number | null>(null);
|
||||
let challengeSearch = $state('');
|
||||
let challengeCategory = $state('all');
|
||||
let rankSaving = $state(false);
|
||||
let challengePage = $state(1);
|
||||
let weaponSearch = $state('');
|
||||
let weaponClass = $state('all');
|
||||
let overviewOpen = $state(false);
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { data, form }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const done = $derived(new SvelteSet(data.done));
|
||||
const pending = new SvelteSet<string>();
|
||||
@@ -16,7 +41,10 @@
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
|
||||
const weaponRanks = $derived(new SvelteMap(data.weaponRanks.map((r) => [r.itemId, r])));
|
||||
const isDone = (id: number, milestone: string) =>
|
||||
done.has(`${id}:${milestone}`) ||
|
||||
(milestone === 'maxLevel' && (weaponRanks.get(id)?.prestige ?? 0) > 0);
|
||||
|
||||
async function toggle(itemId: number, milestone: string, checked: boolean) {
|
||||
const key = `${itemId}:${milestone}`;
|
||||
@@ -55,6 +83,24 @@
|
||||
isDone
|
||||
)
|
||||
);
|
||||
const masteryProgress = $derived(sumProgress(summary.milestones));
|
||||
const unlockProgress = $derived(challengeProgress(data.challenges, data.challengeProgress));
|
||||
const rankProgress = $derived({
|
||||
value: weaponRanks.size + Number(!!data.rank),
|
||||
total: weapons.length + 1
|
||||
});
|
||||
const overall = $derived(
|
||||
combinedProgress([masteryProgress, rankProgress, summary.achievements, unlockProgress])
|
||||
);
|
||||
const classesProgress = $derived(
|
||||
rules.classes.map((category) => {
|
||||
const members = weapons.filter((w) => w.category === category);
|
||||
const tasks = members.flatMap((w) =>
|
||||
rules.milestones.filter((m) => appliesTo(m, w)).map((m) => isDone(w.id, m.key))
|
||||
);
|
||||
return { category, value: tasks.filter(Boolean).length, total: tasks.length };
|
||||
})
|
||||
);
|
||||
type Bar = {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -70,39 +116,74 @@
|
||||
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
|
||||
}))
|
||||
]);
|
||||
const classes = $derived(
|
||||
rules.classes
|
||||
.map((name) => {
|
||||
const list = weapons.filter((weapon) => weapon.category === name);
|
||||
const milestones = rules.milestones.filter((milestone) =>
|
||||
list.some((weapon) => appliesTo(milestone, weapon))
|
||||
);
|
||||
const tiers = rules.tiers
|
||||
.filter((tier) => tier.scope === 'class')
|
||||
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
|
||||
.filter((entry) => entry.count);
|
||||
return { name, list, milestones, tiers };
|
||||
})
|
||||
.filter((entry) => entry.list.length)
|
||||
const weaponRows = $derived(
|
||||
weapons.filter(
|
||||
(w) =>
|
||||
(weaponClass === 'all' || w.category === weaponClass) &&
|
||||
w.name.toLowerCase().includes(weaponSearch.toLowerCase().trim()) &&
|
||||
(!hideDone ||
|
||||
rules.milestones.filter((m) => appliesTo(m, w)).some((m) => !isDone(w.id, m.key)))
|
||||
)
|
||||
);
|
||||
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
|
||||
|
||||
const tabs = ['weapons', 'achievements'] as const;
|
||||
const tabs = ['weapons', 'achievements', 'challenges'] as const;
|
||||
let tab = $state<(typeof tabs)[number]>('weapons');
|
||||
let hideDone = $state(false);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
const rank = $derived(rankWeapon ? weaponRanks.get(rankWeapon) : data.rank);
|
||||
const selectedWeapon = $derived(data.items.find((w) => w.id === rankWeapon));
|
||||
const playerLimits = $derived(playerRankLimits(rules));
|
||||
const rankLimits = $derived(
|
||||
rankWeapon && selectedWeapon ? weaponRankLimits(rules, selectedWeapon) : playerLimits
|
||||
);
|
||||
const challengeCategories = $derived([...new Set(data.challenges.map((c) => c.category))]);
|
||||
const challengeRows = $derived(
|
||||
data.challenges.filter(
|
||||
(c) =>
|
||||
(challengeCategory === 'all' || c.category === challengeCategory) &&
|
||||
`${c.name} ${c.requirement} ${c.reward ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(challengeSearch.toLowerCase()) &&
|
||||
(!hideDone || !data.challengeProgress.find((p) => p.challengeId === c.id)?.done)
|
||||
)
|
||||
);
|
||||
$effect(() => {
|
||||
if (challengePage > Math.max(1, Math.ceil(challengeRows.length / 36))) challengePage = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{weapons.length} weapons · {achievements.length} achievements
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<GameLogo slug={`cod/${data.game.slug}`} name={data.game.shortName} class="h-12 w-24" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{data.game.shortName}</h1>
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
Level {data.rank?.level ?? '—'}{playerLimits.maxLevel ? ` / ${playerLimits.maxLevel}` : ''} ·
|
||||
Prestige {data.rank?.prestige ?? '—'}{data.game.rules.rankCaps
|
||||
? ` / ${playerLimits.maxPrestige}`
|
||||
: ''}{data.rank?.level === playerLimits.maxLevel &&
|
||||
data.rank?.prestige === playerLimits.maxPrestige &&
|
||||
data.game.rules.rankCaps
|
||||
? ' · Prestige Master'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => {
|
||||
rankWeapon = null;
|
||||
rankOpen = true;
|
||||
}}>Update my rank</Button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-[var(--site-muted)]">
|
||||
{weapons.length} weapons · {achievements.length} achievements · {data.challenges.length} challenges
|
||||
& unlocks
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
@@ -110,124 +191,170 @@
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
{#if form?.message}<p role="alert" class="mt-4 text-destructive">{form.message}</p>{/if}
|
||||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||||
|
||||
<div
|
||||
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
class="mt-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-5"
|
||||
aria-label="Game completion"
|
||||
>
|
||||
{#each bars as bar (bar.label)}
|
||||
<div class="min-w-0" title={bar.description}>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{bar.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
|
||||
</div>
|
||||
{/each}
|
||||
<ProgressMeter
|
||||
label="Game completion"
|
||||
progress={overall}
|
||||
color="violet"
|
||||
compact
|
||||
hint="Recorded ranks, weapon mastery, achievements and challenges contribute equally. Partial challenge targets count."
|
||||
/>
|
||||
<ProgressMeter label="Weapon mastery" progress={masteryProgress} color="amber" compact />
|
||||
<ProgressMeter
|
||||
label="Player & weapon ranks"
|
||||
progress={rankProgress}
|
||||
color="pink"
|
||||
compact
|
||||
hint="Percentage of this game’s player and weapon ranks that you have entered."
|
||||
/>
|
||||
<ProgressMeter label="Achievements" progress={summary.achievements} color="sky" compact />
|
||||
<ProgressMeter label="Challenges & unlocks" progress={unlockProgress} compact />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div
|
||||
class="sticky top-0 z-20 mt-4 flex flex-wrap items-center justify-between gap-3 border-y bg-background/95 py-2"
|
||||
>
|
||||
<Tabs.Root bind:value={tab}
|
||||
><Tabs.List
|
||||
>{#each tabs as name (name)}<Tabs.Trigger value={name}>{name}</Tabs.Trigger
|
||||
>{#each tabs as name (name)}<Tabs.Trigger disabled={!hydrated} value={name}>
|
||||
{#if name === 'weapons'}<Crosshair class="mr-1.5 size-4" aria-hidden="true" />Weapons
|
||||
{:else if name === 'achievements'}<Trophy
|
||||
class="mr-1.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>Achievements
|
||||
{:else}<Target class="mr-1.5 size-4" aria-hidden="true" />Challenges{/if}</Tabs.Trigger
|
||||
>{/each}</Tabs.List
|
||||
></Tabs.Root
|
||||
>
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
|
||||
hide finished
|
||||
<Button variant="ghost" size="sm" onclick={() => (overviewOpen = true)}
|
||||
>Progress overview</Button
|
||||
>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||
<Checkbox bind:checked={hideDone} />
|
||||
Hide finished
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tab === 'weapons'}
|
||||
{#if rules.tiers.length || rules.milestones.length}
|
||||
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
|
||||
{[...rules.milestones, ...rules.tiers]
|
||||
.filter((rule) => rule.description)
|
||||
.map((rule) => `${rule.label}: ${rule.description}`)
|
||||
.join('. ')}. DLC weapons don’t count toward class or overall camos.
|
||||
</p>
|
||||
{/if}
|
||||
{#each classes as group (group.name)}
|
||||
{@const rows = group.list.filter(
|
||||
(weapon) =>
|
||||
!hideDone ||
|
||||
group.milestones.some(
|
||||
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
|
||||
)
|
||||
)}
|
||||
<section class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-2xl font-semibold">{group.name}</h2>
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{#each group.tiers as { tier, count } (tier.key)}
|
||||
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
|
||||
>{tier.label}
|
||||
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
|
||||
<div class="my-3 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
class="min-w-40 flex-1"
|
||||
aria-label="Find a weapon"
|
||||
type="search"
|
||||
placeholder="Find a weapon…"
|
||||
bind:value={weaponSearch}
|
||||
/>
|
||||
<NativeSelect aria-label="Weapon class" bind:value={weaponClass}
|
||||
><option value="all">All weapon classes</option>{#each rules.classes as name (name)}<option
|
||||
value={name}>{name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
aria-label="Weapon progress"
|
||||
>
|
||||
{#each weaponRows as weapon (weapon.id)}
|
||||
{@const current = weaponRanks.get(weapon.id)}
|
||||
{@const limits = weaponRankLimits(rules, weapon)}
|
||||
{@const tiers = summary.mastery.weaponTiers(weapon)}
|
||||
{@const allMilestones = rules.milestones.filter((m) => appliesTo(m, weapon))}
|
||||
<article
|
||||
class="weapon-card min-w-0 rounded-lg border bg-card p-2.5"
|
||||
aria-label={weapon.name}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<WeaponArtwork game={data.game.slug} name={weapon.name} icon={weapon.icon} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-sm font-semibold" title={weapon.name}>{weapon.name}</h2>
|
||||
<p class="truncate text-[10px] text-muted-foreground" title={weapon.category}>
|
||||
{weapon.category}{weapon.postLaunch ? ' · DLC' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{#if limits.rankable}
|
||||
<WeaponRankControls
|
||||
itemId={weapon.id}
|
||||
name={weapon.name}
|
||||
level={current?.level ?? 1}
|
||||
prestige={current?.prestige ?? 0}
|
||||
levelCap={limits.maxLevel}
|
||||
prestigeCap={limits.maxPrestige}
|
||||
prestigeRequiresMaxLevel={limits.canPrestigeOnlyAtMaxLevel}
|
||||
compact
|
||||
disabled={!hydrated || !data.user}
|
||||
onupdate={(r) => weaponRanks.set(r.itemId, r)}
|
||||
onedit={() => {
|
||||
rankWeapon = weapon.id;
|
||||
rankOpen = true;
|
||||
}}
|
||||
/>
|
||||
{:else}<p class="py-1 text-[10px] text-muted-foreground">No weapon level rank</p>{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<ProgressMeter
|
||||
label={`${weapon.name} mastery`}
|
||||
progress={{
|
||||
value: allMilestones.filter((m) => isDone(weapon.id, m.key)).length,
|
||||
total: allMilestones.length
|
||||
}}
|
||||
color="amber"
|
||||
compact
|
||||
/>
|
||||
{#if tiers.length}<span
|
||||
class="shrink-0 text-[10px] text-amber-600"
|
||||
title="Mastery tier"
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
{#if rows.length}
|
||||
<table class="mt-3 w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
|
||||
<th scope="col" class="py-2 font-medium">weapon</th>
|
||||
{#each group.milestones as milestone (milestone.key)}<th
|
||||
scope="col"
|
||||
class="w-20 py-2 text-center font-medium sm:w-28"
|
||||
title={milestone.description}>{milestone.label}</th
|
||||
{tiers.map((t) => t.label).join(' · ')}
|
||||
</span>{/if}
|
||||
</div>
|
||||
{#if allMilestones.length}<Collapsible.Root class="mt-2 border-t pt-1.5">
|
||||
<Collapsible.Trigger
|
||||
class="group flex w-full items-center justify-between rounded py-1 text-left text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span
|
||||
>Camos & milestones <span class="ml-1 tabular-nums"
|
||||
>{allMilestones.filter((m) => isDone(weapon.id, m.key))
|
||||
.length}/{allMilestones.length}</span
|
||||
></span
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-1 grid grid-cols-2 gap-x-2">
|
||||
{#each allMilestones as milestone (milestone.key)}<label
|
||||
class="flex min-h-7 items-center gap-1.5 text-[10px]"
|
||||
><Checkbox
|
||||
class="size-3.5"
|
||||
checked={isDone(weapon.id, milestone.key)}
|
||||
disabled={!hydrated ||
|
||||
!data.user ||
|
||||
pending.has(`${weapon.id}:${milestone.key}`)}
|
||||
aria-label={`${weapon.name}: ${milestone.label}`}
|
||||
title={milestone.description}
|
||||
onCheckedChange={(checked) => toggle(weapon.id, milestone.key, checked)}
|
||||
/>{milestone.label}</label
|
||||
>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as weapon (weapon.id)}
|
||||
{@const tiers = summary.mastery.weaponTiers(weapon)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
{weapon.name}
|
||||
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
|
||||
>DLC</span
|
||||
>{/if}
|
||||
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
|
||||
>{tiers.map((tier) => tier.label).join(' · ')}</span
|
||||
>{/if}
|
||||
</th>
|
||||
{#each group.milestones as milestone (milestone.key)}
|
||||
<td class="py-2 text-center">
|
||||
{#if appliesTo(milestone, weapon)}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(weapon.id, milestone.key)}
|
||||
disabled={!hydrated ||
|
||||
!data.user ||
|
||||
pending.has(`${weapon.id}:${milestone.key}`)}
|
||||
aria-label={`${weapon.name}: ${milestone.label}`}
|
||||
title={data.user
|
||||
? milestone.description
|
||||
: 'Sign in to save your progress'}
|
||||
onchange={(event) =>
|
||||
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>{/if}
|
||||
</article>
|
||||
{:else}<p class="col-span-full py-8 text-center text-muted-foreground">
|
||||
No weapons match these filters.
|
||||
</p>{/each}
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Showing {weaponRows.length} of {weapons.length} weapons · Changes save immediately. Prestige + starts
|
||||
the next prestige at level 1.
|
||||
</p>
|
||||
{:else if tab === 'achievements'}
|
||||
{#each categories as [category, list] (category)}
|
||||
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
|
||||
<section class="mt-10">
|
||||
@@ -237,6 +364,17 @@
|
||||
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressMeter
|
||||
label={`${category} achievements`}
|
||||
progress={{
|
||||
value: list.filter((item) => isDone(item.id, 'done')).length,
|
||||
total: list.length
|
||||
}}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<ul class="mt-3">
|
||||
{#each rows as item (item.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
@@ -256,13 +394,12 @@
|
||||
>{item.description}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(item.id, 'done')}
|
||||
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
|
||||
aria-label={`Unlocked ${item.name}`}
|
||||
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
|
||||
onCheckedChange={(checked) => toggle(item.id, 'done', checked)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
@@ -274,5 +411,230 @@
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Search challenges"
|
||||
placeholder="Search challenges, emblems and calling cards…"
|
||||
bind:value={challengeSearch}
|
||||
oninput={() => (challengePage = 1)}
|
||||
/><NativeSelect
|
||||
class="w-full"
|
||||
aria-label="Challenge category"
|
||||
bind:value={challengeCategory}
|
||||
onchange={() => (challengePage = 1)}
|
||||
><option value="all">All categories</option
|
||||
>{#each challengeCategories as category (category)}<option value={category}
|
||||
>{category}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{#each challengeRows.slice((challengePage - 1) * 36, challengePage * 36) as challenge (challenge.id)}
|
||||
{@const progress = data.challengeProgress.find((p) => p.challengeId === challenge.id)}
|
||||
<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex gap-3">
|
||||
{#if challenge.icon}<img
|
||||
src={challenge.icon}
|
||||
alt=""
|
||||
width="80"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
class="h-12 w-20 object-contain"
|
||||
/>{/if}
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">{challenge.category}</p>
|
||||
<h2 class="font-semibold">{challenge.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-muted-foreground">{challenge.requirement}</p>
|
||||
{#if challenge.reward}<p class="mt-2 text-sm">Unlock: {challenge.reward}</p>{/if}
|
||||
<div class="mt-3">
|
||||
<ProgressMeter
|
||||
label={`${challenge.name} progress`}
|
||||
progress={{
|
||||
value: progress?.done ? (challenge.target ?? 1) : (progress?.current ?? 0),
|
||||
total: challenge.target ?? 1
|
||||
}}
|
||||
detail={progress?.done
|
||||
? 'Unlocked ✓'
|
||||
: challenge.target
|
||||
? `${progress?.current ?? 0} / ${challenge.target}`
|
||||
: 'Not unlocked yet'}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveChallenge"
|
||||
use:enhance
|
||||
class="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="challengeId"
|
||||
value={challenge.id}
|
||||
/>{#if challenge.target}<label class="flex-1 text-xs"
|
||||
>Progress / {challenge.target}<Input
|
||||
type="number"
|
||||
name="current"
|
||||
min={0}
|
||||
max={challenge.target}
|
||||
value={progress?.current ?? 0}
|
||||
required
|
||||
/></label
|
||||
>{:else}<input type="hidden" name="current" value="0" />{/if}<label
|
||||
class="mb-2 flex items-center gap-2 text-sm"
|
||||
><Checkbox name="done" value="on" checked={progress?.done ?? false} />Unlocked</label
|
||||
><Button type="submit" size="sm" variant="outline" disabled={!hydrated || !data.user}
|
||||
>Save</Button
|
||||
>
|
||||
</form>
|
||||
<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={challenge.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="mt-3 inline-block text-xs text-muted-foreground underline"
|
||||
>Source & requirements ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
</article>
|
||||
{:else}<p class="py-8 text-muted-foreground">
|
||||
{data.challenges.length
|
||||
? 'No matching challenges.'
|
||||
: 'No multiplayer challenge catalog has been imported for this game yet.'}
|
||||
</p>{/each}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" disabled={challengePage === 1} onclick={() => challengePage--}
|
||||
>Previous</Button
|
||||
><span class="text-sm text-muted-foreground"
|
||||
>{challengeRows.length} challenges · {challengePage} / {Math.max(
|
||||
1,
|
||||
Math.ceil(challengeRows.length / 36)
|
||||
)}</span
|
||||
><Button
|
||||
variant="outline"
|
||||
disabled={challengePage * 36 >= challengeRows.length}
|
||||
onclick={() => challengePage++}>Next</Button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-5 text-xs text-muted-foreground">
|
||||
Requirements adapted from <a
|
||||
href="https://callofduty.fandom.com/wiki/Challenges"
|
||||
class="underline">Call of Duty Wiki contributors</a
|
||||
>
|
||||
under <a href="https://www.fandom.com/licensing" class="underline">CC BY-SA</a>. Each card
|
||||
links to its source. This catalog includes confirmed source rows; coverage is not exhaustive.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={rankOpen}
|
||||
><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{selectedWeapon?.name ?? 'Player'} level & prestige</Dialog.Title
|
||||
><Dialog.Description
|
||||
>Record your current rank in {data.game.shortName}. Weapon prestige also records that max
|
||||
level has been reached.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveRank"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
rankSaving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
rankSaving = false;
|
||||
if (result.type === 'success') rankOpen = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if rankWeapon}<input type="hidden" name="itemId" value={rankWeapon} />{/if}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Current level<Input
|
||||
type="number"
|
||||
name="level"
|
||||
min={1}
|
||||
max={rankLimits.maxLevel ?? 10000}
|
||||
value={rank?.level ?? 1}
|
||||
required
|
||||
/></label
|
||||
><label
|
||||
>Prestige<Input
|
||||
type="number"
|
||||
name="prestige"
|
||||
min={0}
|
||||
max={rankLimits.maxPrestige}
|
||||
value={rank?.prestige ?? 0}
|
||||
required
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (rankOpen = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={rankSaving || !data.user}>{rankSaving ? 'Saving…' : 'Save rank'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
|
||||
<Dialog.Root bind:open={overviewOpen}
|
||||
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Progress overview</Dialog.Title><Dialog.Description
|
||||
>Mastery, camos and achievements in {data.game.shortName}.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
|
||||
<div
|
||||
class="grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#each bars as bar (bar.label)}
|
||||
<ProgressMeter label={bar.label} progress={bar} color="amber" hint={bar.description} />
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="font-semibold">Weapon classes</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each classesProgress as group (group.category)}<ProgressMeter
|
||||
label={group.category}
|
||||
progress={group}
|
||||
color="amber"
|
||||
/>{/each}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<ProgressMeter
|
||||
label="Ranks entered"
|
||||
progress={rankProgress}
|
||||
color="pink"
|
||||
detail="Your player rank plus a saved rank for each weapon."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Challenges & unlocks"
|
||||
progress={unlockProgress}
|
||||
detail={`${data.challengeProgress.filter((p) => p.done).length} unlocked · partial targets count too`}
|
||||
/><ProgressMeter
|
||||
label="Weapons prestiged"
|
||||
progress={{
|
||||
value: weapons.filter((w) => (weaponRanks.get(w.id)?.prestige ?? 0) > 0).length,
|
||||
total: weapons.length
|
||||
}}
|
||||
color="violet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{[...rules.milestones, ...rules.tiers]
|
||||
.filter((r) => r.description)
|
||||
.map((r) => `${r.label}: ${r.description}`)
|
||||
.join('. ')}. DLC weapons do not gate class or overall camos.
|
||||
</p>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
|
||||
15
src/routes/projects/game-tracker/credits/+page.server.ts
Normal file
15
src/routes/projects/game-tracker/credits/+page.server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import images from '../../../../../data/game-images.json';
|
||||
import maps from '../../../../../data/pokemon-map-art.json';
|
||||
export function load() {
|
||||
const sources = new Map<string, { source: string; credit: string; count: number }>();
|
||||
for (const image of images) {
|
||||
const existing = sources.get(image.source);
|
||||
if (existing) existing.count++;
|
||||
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
|
||||
}
|
||||
return {
|
||||
sources: [...sources.values()],
|
||||
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
|
||||
total: images.length
|
||||
};
|
||||
}
|
||||
41
src/routes/projects/game-tracker/credits/+page.svelte
Normal file
41
src/routes/projects/game-tracker/credits/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<!-- Download manifest links point to reviewed external image sources. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<a href={resolve('/projects/game-tracker')} class="text-sm text-muted-foreground hover:underline"
|
||||
>← Game tracker</a
|
||||
>
|
||||
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
|
||||
<p class="mt-3 text-muted-foreground">
|
||||
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
|
||||
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
|
||||
same region; later-region landmark positions are approximate.
|
||||
</p>
|
||||
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{#each data.maps as map (map.name)}<li>
|
||||
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
|
||||
>{map.name}</a
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">{map.credit}</p>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
|
||||
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
|
||||
from Steam.
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{#each data.sources as item (item.source)}<li>
|
||||
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>{item.credit}</a
|
||||
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { osuEntries } from '$lib/server/db/schema';
|
||||
import { allowedOsuUrl, boundedInteger, validDate } from '$lib/game-tracker/journal';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => ({
|
||||
entries: locals.user
|
||||
? await db
|
||||
.select()
|
||||
.from(osuEntries)
|
||||
.where(eq(osuEntries.userId, locals.user.id))
|
||||
.orderBy(desc(osuEntries.playedOn), desc(osuEntries.id))
|
||||
: [],
|
||||
seo: {
|
||||
title: 'osu! training · Game Tracker',
|
||||
description:
|
||||
'A personal journal for memorable scores, beatmap goals and speed, stamina, aim and reading practice.'
|
||||
}
|
||||
});
|
||||
export const actions = {
|
||||
save: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save training.' });
|
||||
const data = await request.formData();
|
||||
const t = (k: string) => String(data.get(k) ?? '').trim();
|
||||
const id = t('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||||
const kind = t('kind');
|
||||
const mode = t('mode');
|
||||
const goal = t('goal') || 'pass';
|
||||
const skill = t('skill') || 'stamina';
|
||||
if (
|
||||
(t('id') && !id) ||
|
||||
!['score', 'goal', 'training'].includes(kind) ||
|
||||
!['osu', 'taiko', 'fruits', 'mania'].includes(mode) ||
|
||||
!['pass', 'fc', 'accuracy', 'rank', 'custom'].includes(goal) ||
|
||||
!['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'].includes(
|
||||
skill
|
||||
) ||
|
||||
!t('title') ||
|
||||
t('title').length > 250 ||
|
||||
t('notes').length > 5000 ||
|
||||
t('mods').length > 80 ||
|
||||
!validDate(t('playedOn'))
|
||||
)
|
||||
return fail(400, { message: 'Check the title, date and training details.' });
|
||||
if (!allowedOsuUrl(t('beatmapUrl'), 'beatmap') || !allowedOsuUrl(t('scoreUrl'), 'score'))
|
||||
return fail(400, { message: 'Use an https://osu.ppy.sh beatmap or score link.' });
|
||||
const optionalInt = (key: string, min: number, max: number) =>
|
||||
t(key) ? boundedInteger(data.get(key), min, max) : null;
|
||||
const bpm = optionalInt('bpm', 1, 2000),
|
||||
duration = optionalInt('duration', 1, 86400),
|
||||
combo = optionalInt('combo', 0, 1000000),
|
||||
misses = optionalInt('misses', 0, 1000000);
|
||||
const decimal = (key: string, min: number, max: number) =>
|
||||
!t(key) ||
|
||||
(/^\d+(\.\d{1,4})?$/.test(t(key)) && Number(t(key)) >= min && Number(t(key)) <= max);
|
||||
if (
|
||||
(t('bpm') && bpm === null) ||
|
||||
(t('duration') && duration === null) ||
|
||||
(t('combo') && combo === null) ||
|
||||
(t('misses') && misses === null) ||
|
||||
!decimal('accuracy', 0, 100) ||
|
||||
!decimal('pp', 0, 100000) ||
|
||||
!t('rate') ||
|
||||
!decimal('rate', 0.1, 4) ||
|
||||
(kind === 'training' && (!bpm || !duration))
|
||||
)
|
||||
return fail(400, {
|
||||
message:
|
||||
'Training needs BPM and duration. Check numeric fields, accuracy (0–100) and rate (0.1–4).'
|
||||
});
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
kind: kind as 'score' | 'goal' | 'training',
|
||||
mode,
|
||||
goal,
|
||||
skill,
|
||||
title: t('title'),
|
||||
notes: t('notes'),
|
||||
bpm,
|
||||
duration,
|
||||
combo,
|
||||
misses,
|
||||
accuracy: t('accuracy') || null,
|
||||
pp: t('pp') || null,
|
||||
rate: t('rate'),
|
||||
mods: t('mods') || 'NM',
|
||||
beatmapUrl: t('beatmapUrl') || null,
|
||||
scoreUrl: t('scoreUrl') || null,
|
||||
playedOn: t('playedOn'),
|
||||
achieved: data.get('achieved') === 'on'
|
||||
};
|
||||
if (id) {
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set(values)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
if (!updated.length) return fail(404, { message: 'Entry not found.' });
|
||||
} else await db.insert(osuEntries).values(values);
|
||||
return { saved: true };
|
||||
},
|
||||
toggleGoal: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to update goals.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid goal.' });
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set({ achieved: data.get('achieved') === 'on' })
|
||||
.where(
|
||||
and(
|
||||
eq(osuEntries.id, id),
|
||||
eq(osuEntries.userId, locals.user.id),
|
||||
eq(osuEntries.kind, 'goal')
|
||||
)
|
||||
)
|
||||
.returning({ id: osuEntries.id });
|
||||
return updated.length ? { saved: true } : fail(404, { message: 'Goal not found.' });
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to remove entries.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid entry.' });
|
||||
const removed = await db
|
||||
.delete(osuEntries)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
return removed.length ? { saved: true } : fail(404, { message: 'Entry not found.' });
|
||||
}
|
||||
} satisfies Actions;
|
||||
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
@@ -0,0 +1,603 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
type Entry = (typeof data.entries)[number];
|
||||
let tab = $state('goals');
|
||||
let open = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
let editing = $state<Entry | null>(null);
|
||||
let kind = $state<'score' | 'goal' | 'training'>('goal');
|
||||
let saving = $state(false);
|
||||
let search = $state('');
|
||||
let modeFilter = $state('all');
|
||||
let skillFilter = $state('stamina');
|
||||
let minDuration = $state(30);
|
||||
const skills = ['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'];
|
||||
const modeName: Record<string, string> = {
|
||||
osu: 'osu!',
|
||||
taiko: 'osu!taiko',
|
||||
fruits: 'osu!catch',
|
||||
mania: 'osu!mania'
|
||||
};
|
||||
const scoped = $derived(
|
||||
data.entries.filter(
|
||||
(e) =>
|
||||
(modeFilter === 'all' || e.mode === modeFilter) &&
|
||||
`${e.title} ${e.notes} ${e.mods}`.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
);
|
||||
const scores = $derived(scoped.filter((e) => e.kind === 'score'));
|
||||
const goals = $derived(scoped.filter((e) => e.kind === 'goal'));
|
||||
const sessions = $derived(scoped.filter((e) => e.kind === 'training'));
|
||||
const allGoals = $derived(data.entries.filter((e) => e.kind === 'goal'));
|
||||
const eligible = $derived(
|
||||
sessions.filter((s) => s.skill === skillFilter && (s.duration ?? 0) >= minDuration)
|
||||
);
|
||||
const best = $derived(eligible.reduce((n, e) => Math.max(n, (e.bpm ?? 0) * Number(e.rate)), 0));
|
||||
const chart = $derived(
|
||||
[...eligible].sort((a, b) => a.playedOn.localeCompare(b.playedOn) || a.id - b.id).slice(-30)
|
||||
);
|
||||
const maxBpm = $derived(Math.max(1, ...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))));
|
||||
const minBpm = $derived(
|
||||
Math.max(0, Math.min(...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))) - 20)
|
||||
);
|
||||
function create(next: typeof kind, entry: Entry | null = null) {
|
||||
kind = next;
|
||||
editing = entry;
|
||||
open = true;
|
||||
}
|
||||
function bpm(entry: Entry) {
|
||||
return Math.round((entry.bpm ?? 0) * Number(entry.rate) * 10) / 10;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-sm text-muted-foreground hover:text-foreground">← game tracker</a
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold tracking-[.2em] text-pink-500 uppercase">One more try</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight">osu! training</h1>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Chase the FC. Build your stamina. Remember the scores that mattered.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create(tab === 'scores' ? 'score' : tab === 'training' ? 'training' : 'goal')}
|
||||
>+ {tab === 'scores'
|
||||
? 'Record a score'
|
||||
: tab === 'training'
|
||||
? 'Log practice'
|
||||
: 'Set a goal'}</Button
|
||||
>
|
||||
</div>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a class="underline" href={resolve('/login?returnTo=/projects/game-tracker/osu')}>Sign in</a> to
|
||||
start your training journal.
|
||||
</p>{/if}
|
||||
{#if form?.message && !open && !deleteOpen}<p class="mt-4 text-destructive" role="alert">
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
<div class="my-3 grid grid-cols-3 gap-2">
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{data.entries.filter((e) => e.kind === 'score').length}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">memorable scores</p>
|
||||
</div>
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{data.entries.filter((e) => e.kind === 'goal' && e.achieved).length}<span
|
||||
class="text-base text-muted-foreground"
|
||||
>
|
||||
/ {data.entries.filter((e) => e.kind === 'goal').length}</span
|
||||
>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">goals achieved</p>
|
||||
</div>
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{Math.round(
|
||||
data.entries
|
||||
.filter((e) => e.kind === 'training')
|
||||
.reduce((n, e) => n + (e.duration ?? 0), 0) / 60
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">minutes of logged practice</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-4">
|
||||
<ProgressMeter
|
||||
label="All map goals"
|
||||
progress={{ value: allGoals.filter((g) => g.achieved).length, total: allGoals.length }}
|
||||
color="pink"
|
||||
compact
|
||||
/>
|
||||
{#each [{ kind: 'pass', label: 'Pass goals' }, { kind: 'fc', label: 'Full combo goals' }, { kind: 'accuracy', label: 'Accuracy goals' }] as group (group.kind)}
|
||||
<ProgressMeter
|
||||
label={group.label}
|
||||
progress={{
|
||||
value: allGoals.filter((g) => g.goal === group.kind && g.achieved).length,
|
||||
total: allGoals.filter((g) => g.goal === group.kind).length
|
||||
}}
|
||||
color="pink"
|
||||
compact
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-5 flex gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
aria-label="Search osu journal"
|
||||
placeholder="Search maps, scores or notes…"
|
||||
/><NativeSelect aria-label="Game mode" bind:value={modeFilter}
|
||||
><option value="all">All modes</option
|
||||
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}>{label}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<Tabs.Root bind:value={tab}
|
||||
><Tabs.List
|
||||
><Tabs.Trigger disabled={!hydrated} value="goals">Map goals</Tabs.Trigger><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="scores">Score showcase</Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="training">Training log</Tabs.Trigger></Tabs.List
|
||||
>
|
||||
<Tabs.Content value="goals" class="mt-5"
|
||||
><div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each goals as goal (goal.id)}<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant={goal.achieved ? 'secondary' : 'outline'}
|
||||
>{goal.achieved ? '✓ achieved' : goal.goal.toUpperCase()}</Badge
|
||||
><span class="text-xs text-muted-foreground">{modeName[goal.mode]} · {goal.mods}</span
|
||||
>
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-semibold">{goal.title}</h2>
|
||||
{#if goal.notes}<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{goal.notes}
|
||||
</p>{/if}
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
{#if goal.beatmapUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={goal.beatmapUrl} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>Open beatmap ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => create('goal', goal)}>Edit</Button
|
||||
>
|
||||
<form method="POST" action="?/toggleGoal" use:enhance class="ml-auto">
|
||||
<input type="hidden" name="id" value={goal.id} /><input
|
||||
type="hidden"
|
||||
name="achieved"
|
||||
value={goal.achieved ? '' : 'on'}
|
||||
/><Button type="submit" variant={goal.achieved ? 'outline' : 'default'} size="sm"
|
||||
>{goal.achieved ? 'Reopen' : 'Achieved!'}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</article>{:else}<div
|
||||
class="col-span-full rounded-xl border border-dashed p-12 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">What’s your next breakthrough?</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
A first pass, a clean FC, 99% accuracy, or a challenge of your own.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create('goal')}>Set a goal</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div></Tabs.Content
|
||||
>
|
||||
<Tabs.Content value="scores" class="mt-5"
|
||||
><div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each scores as score (score.id)}<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="secondary">{score.mods}</Badge><span
|
||||
class="text-xs text-muted-foreground"
|
||||
>{score.playedOn} · {modeName[score.mode]}</span
|
||||
>
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-semibold">{score.title}</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-5">
|
||||
{#if score.accuracy}<p class="text-2xl font-semibold">
|
||||
{score.accuracy}<span class="text-sm text-muted-foreground">%</span>
|
||||
</p>{/if}{#if score.pp}<p class="text-2xl font-semibold text-pink-500">
|
||||
{score.pp}<span class="text-sm"> pp</span>
|
||||
</p>{/if}{#if score.combo !== null}<p class="text-2xl font-semibold">
|
||||
{score.combo}<span class="text-sm text-muted-foreground">x</span>
|
||||
</p>{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{score.misses !== null ? `${score.misses} misses · ` : ''}{score.rate}× rate
|
||||
</p>
|
||||
{#if score.notes}<p class="mt-3 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{score.notes}
|
||||
</p>{/if}
|
||||
<div class="mt-4 flex gap-4">
|
||||
{#if score.scoreUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={score.scoreUrl} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>View score ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}{#if score.beatmapUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={score.beatmapUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm underline">Beatmap ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => create('score', score)}>Edit</Button
|
||||
>
|
||||
</div>
|
||||
</article>{:else}<div
|
||||
class="col-span-full rounded-xl border border-dashed p-12 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">Some scores deserve a place on the wall.</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Keep the play, your accuracy, mods, pp, and why it mattered.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create('score')}>Record a score</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div></Tabs.Content
|
||||
>
|
||||
<Tabs.Content value="training" class="mt-5">
|
||||
<section class="rounded-xl border p-5">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-widest text-muted-foreground uppercase">
|
||||
Sustained pace
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold">
|
||||
{best || '—'} <span class="text-base text-muted-foreground">effective BPM</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<label class="text-xs"
|
||||
>Skill<NativeSelect bind:value={skillFilter} class="mt-1"
|
||||
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label class="text-xs"
|
||||
>Minimum seconds<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
bind:value={minDuration}
|
||||
class="mt-1 w-28"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Best recorded pace for the selected mode and skill, sustained for at least {minDuration} seconds.
|
||||
Effective BPM = map BPM × playback rate.
|
||||
</p>
|
||||
{#if chart.length > 1}<svg
|
||||
viewBox="0 0 640 150"
|
||||
role="img"
|
||||
aria-label={`Last ${chart.length} qualifying ${skillFilter} sessions, ${minBpm} to ${maxBpm} effective BPM`}
|
||||
class="mt-5 h-40 w-full overflow-visible"
|
||||
><line x1="10" x2="630" y1="135" y2="135" stroke="currentColor" opacity=".2" /><polyline
|
||||
points={chart
|
||||
.map(
|
||||
(e, i) =>
|
||||
`${10 + (i / (chart.length - 1)) * 620},${130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}`
|
||||
)
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke="#ec4899"
|
||||
stroke-width="2"
|
||||
/>{#each chart as e, i (e.id)}<circle
|
||||
cx={10 + (i / (chart.length - 1)) * 620}
|
||||
cy={130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}
|
||||
r="4"
|
||||
fill="#ec4899"
|
||||
><title>{e.playedOn} · {bpm(e)} BPM · {e.duration}s · {e.title}</title></circle
|
||||
>{/each}</svg
|
||||
>{:else}<p class="mt-5 rounded-lg bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
Log two qualifying sessions to see your pace over time.
|
||||
</p>{/if}
|
||||
</section>
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<Table.Root
|
||||
><Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Session</Table.Head><Table.Head>Skill</Table.Head><Table.Head
|
||||
>BPM</Table.Head
|
||||
><Table.Head>Duration</Table.Head><Table.Head>Accuracy</Table.Head><Table.Head
|
||||
></Table.Head></Table.Row
|
||||
></Table.Header
|
||||
><Table.Body
|
||||
>{#each sessions as session (session.id)}<Table.Row
|
||||
><Table.Cell
|
||||
><span class="font-medium">{session.title}</span><span
|
||||
class="block text-xs text-muted-foreground"
|
||||
>{session.playedOn} · {modeName[session.mode]}</span
|
||||
></Table.Cell
|
||||
><Table.Cell>{session.skill}</Table.Cell><Table.Cell
|
||||
>{bpm(session)}<span class="block text-xs text-muted-foreground"
|
||||
>{session.bpm} × {session.rate}</span
|
||||
></Table.Cell
|
||||
><Table.Cell>{session.duration}s</Table.Cell><Table.Cell
|
||||
>{session.accuracy ? `${session.accuracy}%` : '—'}</Table.Cell
|
||||
><Table.Cell
|
||||
><Button size="sm" variant="ghost" onclick={() => create('training', session)}
|
||||
>Edit</Button
|
||||
></Table.Cell
|
||||
></Table.Row
|
||||
>{:else}<Table.Row
|
||||
><Table.Cell colspan={6} class="py-10 text-center text-muted-foreground"
|
||||
>Log a practice session to start building your training history.</Table.Cell
|
||||
></Table.Row
|
||||
>{/each}</Table.Body
|
||||
></Table.Root
|
||||
>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open
|
||||
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title
|
||||
>{editing ? 'Edit' : kind === 'score' ? 'Record' : kind === 'goal' ? 'Set' : 'Log'}
|
||||
{kind === 'training' ? 'practice session' : kind}</Dialog.Title
|
||||
><Dialog.Description
|
||||
>{kind === 'goal'
|
||||
? 'Give yourself something worth chasing.'
|
||||
: kind === 'score'
|
||||
? 'Save the numbers and the story behind this play.'
|
||||
: 'Track the pace you can sustain and what you want to work on next.'}</Dialog.Description
|
||||
></Dialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#key `${kind}-${editing?.id ?? 'new'}`}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
if (result.type === 'success') open = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="kind" value={kind} />{#if editing}<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={editing.id}
|
||||
/>{/if}
|
||||
<label class="block"
|
||||
>{kind === 'training' ? 'Session name' : 'Map / achievement'}<Input
|
||||
name="title"
|
||||
required
|
||||
maxlength={250}
|
||||
value={editing?.title ?? ''}
|
||||
placeholder={kind === 'training'
|
||||
? '180 BPM stream practice'
|
||||
: 'Artist — Title [Difficulty]'}
|
||||
/></label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Mode<NativeSelect name="mode" value={editing?.mode ?? 'osu'} class="w-full"
|
||||
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}
|
||||
>{label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label
|
||||
>Date<Input
|
||||
type="date"
|
||||
name="playedOn"
|
||||
required
|
||||
value={editing?.playedOn ?? new Date().toISOString().slice(0, 10)}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<label class="block"
|
||||
>Beatmap link (optional)<Input
|
||||
type="url"
|
||||
name="beatmapUrl"
|
||||
placeholder="https://osu.ppy.sh/beatmaps/…"
|
||||
value={editing?.beatmapUrl ?? ''}
|
||||
/></label
|
||||
>
|
||||
{#if kind === 'score'}<label class="block"
|
||||
>Score link (optional)<Input
|
||||
type="url"
|
||||
name="scoreUrl"
|
||||
placeholder="https://osu.ppy.sh/scores/…"
|
||||
value={editing?.scoreUrl ?? ''}
|
||||
/></label
|
||||
>{/if}
|
||||
{#if kind === 'goal'}<div class="flex items-end gap-4">
|
||||
<label class="flex-1"
|
||||
>Goal<NativeSelect class="w-full" name="goal" value={editing?.goal ?? 'pass'}
|
||||
><option value="pass">Pass</option><option value="fc">Full combo</option><option
|
||||
value="accuracy">Accuracy target</option
|
||||
><option value="rank">Rank / pp target</option><option value="custom"
|
||||
>Personal challenge</option
|
||||
></NativeSelect
|
||||
></label
|
||||
><label class="mb-2 flex items-center gap-2"
|
||||
><Checkbox
|
||||
name="achieved"
|
||||
value="on"
|
||||
checked={editing?.achieved ?? false}
|
||||
/>Achieved</label
|
||||
>
|
||||
</div>{/if}
|
||||
{#if kind === 'training'}<label class="block"
|
||||
>Skill<NativeSelect name="skill" value={editing?.skill ?? 'stamina'} class="w-full"
|
||||
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>{/if}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Mods<Input
|
||||
name="mods"
|
||||
value={editing?.mods ?? 'NM'}
|
||||
maxlength={80}
|
||||
placeholder="NM, HD, DT…"
|
||||
/></label
|
||||
><label
|
||||
>Playback rate<Input
|
||||
type="number"
|
||||
name="rate"
|
||||
min={0.1}
|
||||
max={4}
|
||||
step="0.01"
|
||||
required
|
||||
value={Number(editing?.rate ?? 1)}
|
||||
/></label
|
||||
><label
|
||||
>Map BPM{kind !== 'training' ? ' (optional)' : ''}<Input
|
||||
type="number"
|
||||
name="bpm"
|
||||
min={1}
|
||||
max={2000}
|
||||
required={kind === 'training'}
|
||||
value={editing?.bpm ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Duration (seconds)<Input
|
||||
type="number"
|
||||
name="duration"
|
||||
min={1}
|
||||
max={86400}
|
||||
required={kind === 'training'}
|
||||
value={editing?.duration ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Accuracy %<Input
|
||||
type="number"
|
||||
name="accuracy"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={editing?.accuracy ? Number(editing.accuracy) : undefined}
|
||||
/></label
|
||||
>{#if kind !== 'training'}<label
|
||||
>pp<Input
|
||||
type="number"
|
||||
name="pp"
|
||||
min={0}
|
||||
max={100000}
|
||||
step="0.01"
|
||||
value={editing?.pp ? Number(editing.pp) : undefined}
|
||||
/></label
|
||||
><label
|
||||
>Max combo<Input
|
||||
type="number"
|
||||
name="combo"
|
||||
min={0}
|
||||
max={1000000}
|
||||
value={editing?.combo ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Misses<Input
|
||||
type="number"
|
||||
name="misses"
|
||||
min={0}
|
||||
max={1000000}
|
||||
value={editing?.misses ?? undefined}
|
||||
/></label
|
||||
>{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the actual playback rate, including DT, HT or custom rate changes. BPM is the map’s
|
||||
original tempo.
|
||||
</p>
|
||||
<label class="block"
|
||||
>Notes / target<Textarea
|
||||
name="notes"
|
||||
maxlength={5000}
|
||||
value={editing?.notes ?? ''}
|
||||
placeholder="What felt good? What needs work? What is the next target?"
|
||||
/></label
|
||||
>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save entry'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form>{/key}
|
||||
{#if editing}<Button
|
||||
variant="ghost"
|
||||
class="justify-self-start text-destructive"
|
||||
onclick={() => (deleteOpen = true)}>Delete entry</Button
|
||||
>{/if}
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
<AlertDialog.Root bind:open={deleteOpen}
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete this entry?</AlertDialog.Title><AlertDialog.Description
|
||||
>This removes “{editing?.title}” from your journal.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
deleteOpen = false;
|
||||
open = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editing?.id ?? ''} /><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep entry</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">Delete entry</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form></AlertDialog.Content
|
||||
></AlertDialog.Root
|
||||
>
|
||||
@@ -1,3 +1,16 @@
|
||||
import { pokemonJournalActions, catchesFor } from '$lib/server/pokemon-journal';
|
||||
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
||||
import {
|
||||
pokemonCatches,
|
||||
pokemonDexNumbers,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonGameResearch,
|
||||
pokemonLocations,
|
||||
pokemonEncounters,
|
||||
pokemonSeen
|
||||
} from '$lib/server/db/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
@@ -14,13 +27,15 @@ import { requireAdmin } from '$lib/server/authorization';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const [pokemonRows, entryRows, gameRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: pokemonTable.id,
|
||||
number: pokemonTable.number,
|
||||
name: pokemonTable.name
|
||||
name: pokemonTable.name,
|
||||
type1: pokemonTable.type1,
|
||||
type2: pokemonTable.type2
|
||||
})
|
||||
.from(pokemonTable)
|
||||
.orderBy(asc(pokemonTable.number)),
|
||||
@@ -34,7 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
})
|
||||
.from(pokemonGameEntries),
|
||||
db
|
||||
.select({ id: games.id, name: games.name, generation: games.generation })
|
||||
.select({ id: games.id, name: games.name, slug: games.slug, generation: games.generation })
|
||||
.from(games)
|
||||
.orderBy(asc(games.generation), asc(games.id))
|
||||
]);
|
||||
@@ -69,8 +84,83 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
selectedGameIds = gameRows.map((r) => r.id);
|
||||
}
|
||||
|
||||
const visible =
|
||||
gamesConfigured || selectedGameIds.length
|
||||
? gameRows.filter((g) => selectedGameIds.includes(g.id))
|
||||
: gameRows;
|
||||
const requested = url.searchParams.get('game');
|
||||
const activeGame =
|
||||
visible.find((g) => g.slug === requested || String(g.id) === requested) ??
|
||||
visible.find((g) => g.slug === 'platinum') ??
|
||||
visible[0] ??
|
||||
null;
|
||||
const gameId = activeGame?.id ?? -1;
|
||||
const [
|
||||
dexNumbers,
|
||||
catches,
|
||||
journeyRows,
|
||||
milestones,
|
||||
researchRows,
|
||||
locations,
|
||||
progress,
|
||||
seenRows
|
||||
] = await Promise.all([
|
||||
db.select().from(pokemonDexNumbers).where(eq(pokemonDexNumbers.gameId, gameId)),
|
||||
catchesFor(locals.user?.id, gameId),
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(pokemonJourneys)
|
||||
.where(
|
||||
and(eq(pokemonJourneys.userId, locals.user.id), eq(pokemonJourneys.gameId, gameId))
|
||||
)
|
||||
: [],
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(pokemonMilestones)
|
||||
.where(
|
||||
and(eq(pokemonMilestones.userId, locals.user.id), eq(pokemonMilestones.gameId, gameId))
|
||||
)
|
||||
: [],
|
||||
db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId)),
|
||||
db
|
||||
.select({
|
||||
id: pokemonLocations.id,
|
||||
slug: pokemonLocations.slug,
|
||||
name: pokemonLocations.name,
|
||||
region: pokemonLocations.region,
|
||||
count: sql<number>`count(distinct ${pokemonEncounters.pokemonId})`.mapWith(Number)
|
||||
})
|
||||
.from(pokemonLocations)
|
||||
.leftJoin(
|
||||
pokemonEncounters,
|
||||
and(
|
||||
eq(pokemonEncounters.locationId, pokemonLocations.id),
|
||||
eq(pokemonEncounters.gameId, gameId)
|
||||
)
|
||||
)
|
||||
.groupBy(pokemonLocations.id),
|
||||
loadPokemonProgress(locals.user?.id),
|
||||
locals.user
|
||||
? db
|
||||
.select({ pokemonId: pokemonSeen.pokemonId })
|
||||
.from(pokemonSeen)
|
||||
.where(and(eq(pokemonSeen.userId, locals.user.id), eq(pokemonSeen.gameId, gameId)))
|
||||
: []
|
||||
]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
seenIds: seenRows.map((r) => r.pokemonId),
|
||||
pokemon,
|
||||
activeGame,
|
||||
dexNumbers,
|
||||
catches,
|
||||
journey: journeyRows[0] ?? null,
|
||||
milestones,
|
||||
regions: researchRows[0]?.regions ?? [],
|
||||
locations,
|
||||
games: gameRows,
|
||||
caughtIds,
|
||||
selectedGameIds,
|
||||
@@ -84,6 +174,7 @@ function entryId(data: FormData) {
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
...pokemonJournalActions,
|
||||
togglePokemonCaught: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||
|
||||
@@ -91,32 +182,58 @@ export const actions = {
|
||||
const id = entryId(data);
|
||||
if (!id) return fail(400, { message: 'Invalid entry' });
|
||||
|
||||
// only obtainable entries can be toggled — the client disables the others
|
||||
// but we can't trust that
|
||||
// Verify the entry and allow historical checkmarks to be cleared even
|
||||
// when source availability has since changed.
|
||||
const [entry] = await db
|
||||
.select({ availability: pokemonGameEntries.availability })
|
||||
.select({
|
||||
availability: pokemonGameEntries.availability,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
pokemonId: pokemonGameEntries.pokemonId
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.where(eq(pokemonGameEntries.id, id));
|
||||
if (!entry) return fail(404, { message: 'Unknown entry' });
|
||||
if (!obtainableAvailability.has(entry.availability)) {
|
||||
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
|
||||
}
|
||||
|
||||
if (data.has('caught') && data.get('caught') !== 'on')
|
||||
return fail(400, { message: 'Invalid caught state' });
|
||||
const caught = data.get('caught') === 'on';
|
||||
if (caught && !obtainableAvailability.has(entry.availability))
|
||||
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
|
||||
|
||||
if (caught) {
|
||||
await db
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: id, userId: locals.user.id })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(userEntries)
|
||||
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
|
||||
}
|
||||
return { saved: true };
|
||||
const userId = locals.user.id;
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${entry.gameId})`);
|
||||
if (caught) {
|
||||
await tx
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: id, userId })
|
||||
.onConflictDoNothing();
|
||||
await tx
|
||||
.insert(pokemonSeen)
|
||||
.values({ userId, gameId: entry.gameId, pokemonId: entry.pokemonId })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
const linked = await tx
|
||||
.select({ id: pokemonCatches.id })
|
||||
.from(pokemonCatches)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, entry.gameId),
|
||||
eq(pokemonCatches.pokemonId, entry.pokemonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (linked.length)
|
||||
return fail(400, {
|
||||
message: 'Remove the registered catches before clearing the caught checkmark.'
|
||||
});
|
||||
await tx
|
||||
.delete(userEntries)
|
||||
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, userId)));
|
||||
}
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
|
||||
setGames: async ({ request, locals }) => {
|
||||
|
||||
@@ -1,474 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
availabilityLabels,
|
||||
availabilityValues,
|
||||
obtainableAvailability,
|
||||
type Availability
|
||||
} from '$lib/availability';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageProps } from './$types';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
|
||||
import { summarizeProgress } from '$lib/catch-em-all/progress';
|
||||
|
||||
type Entry = { id: number; availability: Availability; note: string | null };
|
||||
type Game = { id: number; name: string };
|
||||
let { data, form }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const caught = $derived(new SvelteSet(data.caughtIds));
|
||||
const pending = new SvelteSet<number>();
|
||||
let saveError = $state('');
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
|
||||
async function toggleCaught(entryId: number, checked: boolean) {
|
||||
if (pending.has(entryId)) return;
|
||||
pending.add(entryId);
|
||||
saveError = '';
|
||||
if (checked) caught.add(entryId);
|
||||
else caught.delete(entryId);
|
||||
const body = new FormData();
|
||||
body.set('entryId', String(entryId));
|
||||
if (checked) body.set('caught', 'on');
|
||||
let failed: boolean;
|
||||
try {
|
||||
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
||||
const result = deserialize(await response.text());
|
||||
failed = result.type !== 'success';
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
pending.delete(entryId);
|
||||
if (failed) {
|
||||
saveError = 'Could not save your catch. Please try again, or log in again.';
|
||||
if (checked) caught.delete(entryId);
|
||||
else caught.add(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
const entryMap = $derived(
|
||||
new Map(
|
||||
data.pokemon.map((pokemon) => [
|
||||
pokemon.id,
|
||||
new Map(
|
||||
pokemon.entries.map(([id, gameId, code, note]) => [
|
||||
gameId,
|
||||
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
||||
])
|
||||
)
|
||||
])
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { chronologicalPokemon } from '$lib/game-tracker/game-art';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import PokemonJournal from '$lib/game-tracker/PokemonJournal.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let rulesOpen = $state(false);
|
||||
let stripWidth = $state(1000);
|
||||
const coverRows = $derived(visibleGames.length > Math.floor((stripWidth + 4) / 68) ? 2 : 1);
|
||||
let saving = $state(false);
|
||||
const draft = new SvelteSet<number>();
|
||||
const visibleGames = $derived(
|
||||
chronologicalPokemon(
|
||||
data.gamesConfigured
|
||||
? data.games.filter((g) => data.selectedGameIds.includes(g.id))
|
||||
: data.games
|
||||
)
|
||||
);
|
||||
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
||||
const visibleGames = $derived(
|
||||
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
|
||||
);
|
||||
let viewGameId = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (!visibleGames.some((game) => game.id === viewGameId))
|
||||
viewGameId = visibleGames[0]?.id ?? null;
|
||||
});
|
||||
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
||||
|
||||
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||||
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
|
||||
const progressBars = $derived([
|
||||
{
|
||||
label: 'seen at least once',
|
||||
value: progress.seenPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'caught in every game it’s in',
|
||||
value: progress.completedPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'games completed',
|
||||
value: progress.completedGames,
|
||||
total: progress.catchableGames,
|
||||
unit: 'games'
|
||||
}
|
||||
]);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let showUnobtainable = $state(false);
|
||||
let pageNumber = $state(1);
|
||||
const pageSize = 25;
|
||||
const filteredPokemon = $derived(
|
||||
data.pokemon.filter((pokemon) => {
|
||||
const availability =
|
||||
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
|
||||
if (
|
||||
!showUnobtainable &&
|
||||
viewGameId !== null &&
|
||||
!(availability && obtainableAvailability.has(availability))
|
||||
)
|
||||
return false;
|
||||
return `${pokemon.number} ${pokemon.name}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase());
|
||||
})
|
||||
);
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
|
||||
$effect(() => {
|
||||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||||
});
|
||||
const visiblePokemon = $derived(
|
||||
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
|
||||
);
|
||||
const gamesByGeneration = $derived(
|
||||
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
|
||||
);
|
||||
|
||||
let rulesDialog: HTMLDialogElement;
|
||||
const draft = new SvelteSet<number>();
|
||||
const generations = $derived([...Map.groupBy(data.games, (g) => g.generation)]);
|
||||
function openRules() {
|
||||
draft.clear();
|
||||
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
|
||||
draft.add(id);
|
||||
rulesDialog.showModal();
|
||||
}
|
||||
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
||||
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
|
||||
if (obtainableAvailability.has(entry.availability)) return null;
|
||||
const reason = {
|
||||
trade_only: 'Only obtainable by trading in this game',
|
||||
transfer_only: 'Only obtainable by transferring from another game',
|
||||
event_only: 'Only obtainable through an event distribution',
|
||||
unknown: 'Availability has not been confirmed'
|
||||
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
|
||||
return entry.note ? `${reason} (${entry.note})` : reason;
|
||||
}
|
||||
|
||||
let detailsDialog: HTMLDialogElement;
|
||||
let detailNumber = $state<number | null>(null);
|
||||
function openDetails(number: number) {
|
||||
detailNumber = number;
|
||||
detailsDialog.showModal();
|
||||
for (const game of data.games)
|
||||
if (!data.gamesConfigured || data.selectedGameIds.includes(game.id)) draft.add(game.id);
|
||||
rulesOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 lg:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.pokemon.length} Pokémon · {data.games.length} games
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" type="button" onclick={openRules}>set rules</Button>
|
||||
</div>
|
||||
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||||
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
|
||||
the Pokémon catalog has not been loaded yet.
|
||||
</p>{/if}
|
||||
|
||||
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">overall progress</p>
|
||||
<p class="text-2xl font-semibold tabular-nums">
|
||||
{percent(progress.caughtEntries, progress.catchableEntries)}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar
|
||||
value={progress.caughtEntries}
|
||||
max={progress.catchableEntries}
|
||||
label="Overall catch progress"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
|
||||
</p>
|
||||
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
|
||||
{#each progressBars as item (item.label)}
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{item.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={item.value} max={item.total} label={item.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{item.value} of {item.total}
|
||||
{item.unit}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-6 text-base text-[var(--site-muted)]">
|
||||
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label class="block"
|
||||
>search Pokémon
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
placeholder="name or number"
|
||||
class="mt-2 h-11"
|
||||
/>
|
||||
</label>
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
bind:value={viewGameId}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
disabled={!visibleGames.length}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnobtainable}
|
||||
onchange={(event) => {
|
||||
showUnobtainable = event.currentTarget.checked;
|
||||
pageNumber = 1;
|
||||
}}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
show Pokémon you can’t get in this game
|
||||
</label>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations')}
|
||||
class="underline underline-offset-4">browse locations</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if viewGame}
|
||||
<div class="mt-8">
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
|
||||
<thead
|
||||
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
|
||||
><th scope="col" class="py-3 font-medium">Pokémon</th><th
|
||||
scope="col"
|
||||
class="py-3 text-center font-medium">caught</th
|
||||
></tr
|
||||
></thead
|
||||
<div class="flex items-center gap-3">
|
||||
{#if data.activeGame}<GameLogo
|
||||
slug={`pokemon/${data.activeGame.slug}`}
|
||||
name={data.activeGame.name}
|
||||
class="h-12 w-12"
|
||||
/>{/if}
|
||||
<div>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
|
||||
>
|
||||
<tbody>
|
||||
{#each visiblePokemon as pokemon (pokemon.id)}
|
||||
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
|
||||
{@const reason = tooltip(entry, viewGame)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDetails(pokemon.number)}
|
||||
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="40"
|
||||
height="40"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0"
|
||||
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
|
||||
>{pokemon.name}
|
||||
{#if entry && reason === null}<span
|
||||
class="block truncate text-sm text-[var(--site-muted)]"
|
||||
>{availabilityLabels[entry.availability]}{entry.note
|
||||
? ` · ${entry.note}`
|
||||
: ''}</span
|
||||
>{/if}</span
|
||||
>
|
||||
</button>
|
||||
</th>
|
||||
<td class="py-2 text-center">
|
||||
{#if entry && reason === null}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={caught.has(entry.id)}
|
||||
disabled={!hydrated || !data.user || pending.has(entry.id)}
|
||||
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
|
||||
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
|
||||
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
title={reason ?? undefined}
|
||||
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
|
||||
class="text-[var(--site-muted)]"
|
||||
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
|
||||
pageNumber * pageSize,
|
||||
filteredPokemon.length
|
||||
)} of {filteredPokemon.length}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={pageNumber === 1}
|
||||
onclick={() => (pageNumber -= 1)}>previous</Button
|
||||
<h1 class="text-xl font-semibold">
|
||||
Pokémon journal <span class="text-sm font-normal text-muted-foreground"
|
||||
>· {data.activeGame?.name ?? 'Choose a game'}</span
|
||||
>
|
||||
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={pageNumber === pageCount}
|
||||
onclick={() => (pageNumber += 1)}>next</Button
|
||||
>
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.pokemon.length}
|
||||
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
|
||||
{/if}
|
||||
<div
|
||||
class={`game-covers order-3 col-span-2 overflow-x-auto pb-1 ${visibleGames.length > 30 ? 'lg:col-span-3' : 'lg:order-none lg:col-span-1'}`}
|
||||
bind:clientWidth={stripWidth}
|
||||
role="group"
|
||||
aria-label="Choose Pokémon game"
|
||||
>
|
||||
<div
|
||||
class="grid gap-1"
|
||||
style:grid-template-columns={`repeat(${Math.max(1, Math.ceil(visibleGames.length / coverRows))}, minmax(40px, 1fr))`}
|
||||
style:max-width={`${Math.ceil(visibleGames.length / coverRows) * 64}px`}
|
||||
>
|
||||
{#each visibleGames as game (game.id)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!hydrated}
|
||||
aria-pressed={data.activeGame?.id === game.id}
|
||||
aria-label={`Play ${game.name}`}
|
||||
title={game.name}
|
||||
class={`game-cover-button h-12 min-w-0 rounded-md border p-1 ${data.activeGame?.id === game.id ? 'border-amber-500 bg-amber-500/15 ring-1 ring-amber-500' : 'border-transparent opacity-65 hover:opacity-100'}`}
|
||||
onclick={() =>
|
||||
goto(resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`), {
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
})}
|
||||
>
|
||||
<GameLogo slug={`pokemon/${game.slug}`} name={game.name} class="h-10 w-full" />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end lg:col-start-3"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hydrated}
|
||||
onclick={openRules}>set rules</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
|
||||
<summary class="cursor-pointer">about availability</summary>
|
||||
<p class="mt-3 max-w-3xl">
|
||||
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
|
||||
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
|
||||
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
|
||||
game but no route is confirmed, and “—” means there is no record. Neither proves it is
|
||||
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
|
||||
</p>
|
||||
</details>
|
||||
{#if !data.user}<p class="mt-4 text-sm">
|
||||
<a href={resolve('/login?returnTo=/projects/game-tracker/pokemon')} class="underline"
|
||||
>Sign in</a
|
||||
> to save catches, teams and adventures.
|
||||
</p>{/if}
|
||||
{#if form?.message && !rulesOpen}<p role="alert" class="mt-4 text-destructive">
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
{#if data.activeGame}{#key data.activeGame.id}<PokemonJournal {data} />{/key}{:else}<p
|
||||
class="mt-8 text-muted-foreground"
|
||||
>
|
||||
No games selected. Use “set rules” to choose your next adventure.
|
||||
</p>{/if}
|
||||
<p class="mt-4 text-[10px] leading-relaxed text-muted-foreground">
|
||||
Species, regional Pokédexes, moves and encounters from <a
|
||||
href="https://pokeapi.co/"
|
||||
class="underline">PokéAPI</a
|
||||
>. Encounter coverage varies by game; missing records do not prove that a Pokémon is
|
||||
unavailable. Stats and move battle values describe the catalog’s default forms and current
|
||||
snapshot. Maps identify their source and coverage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
bind:this={detailsDialog}
|
||||
onclose={() => (detailNumber = null)}
|
||||
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
|
||||
>close</button
|
||||
<Dialog.Root bind:open={rulesOpen}>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Set rules</Dialog.Title><Dialog.Description
|
||||
>Choose the games in your collection. Your saved catches stay with each game.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
</div>
|
||||
{#if detailNumber !== null}
|
||||
<PokemonDetails
|
||||
number={detailNumber}
|
||||
gameId={viewGameId}
|
||||
isAdmin={data.isAdmin}
|
||||
onsaved={() => invalidateAll()}
|
||||
/>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
bind:this={rulesDialog}
|
||||
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<h2 class="text-2xl font-semibold">set rules</h2>
|
||||
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
|
||||
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mt-5 flex gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
draft.clear();
|
||||
for (const game of data.games) draft.add(game.id);
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
for (const game of data.games) draft.add(game.id);
|
||||
}}>Select all</Button
|
||||
><Button variant="ghost" size="sm" onclick={() => draft.clear()}>Clear selection</Button>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setGames"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
saving = false;
|
||||
if (result.type === 'success') rulesOpen = false;
|
||||
};
|
||||
}}
|
||||
class="underline underline-offset-4">all</button
|
||||
>
|
||||
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
|
||||
>none</button
|
||||
>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setGames"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') rulesDialog.close();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
|
||||
{#each gamesByGeneration as [generation, games] (generation)}
|
||||
<fieldset>
|
||||
<legend class="mb-2 font-semibold"
|
||||
><label class="flex items-center gap-2"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={games.every((game) => draft.has(game.id))}
|
||||
onchange={(event) => {
|
||||
for (const game of games) {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}
|
||||
}}
|
||||
/>generation {generation}</label
|
||||
></legend
|
||||
>
|
||||
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="gameIds"
|
||||
value={game.id}
|
||||
checked={draft.has(game.id)}
|
||||
onchange={(event) => {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}}
|
||||
/>{game.name}</label
|
||||
>{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-5">
|
||||
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
|
||||
<button
|
||||
disabled={!data.user}
|
||||
title={data.user ? undefined : 'Sign in to save rules'}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
|
||||
>save</button
|
||||
<div class="grid max-h-[55dvh] gap-6 overflow-auto pr-2 sm:grid-cols-2">
|
||||
{#each generations as [generation, games] (generation)}
|
||||
<fieldset>
|
||||
<legend class="mb-3"
|
||||
><label class="flex items-center gap-2 font-semibold"
|
||||
><Checkbox
|
||||
checked={games.every((g) => draft.has(g.id))}
|
||||
indeterminate={games.some((g) => draft.has(g.id)) &&
|
||||
!games.every((g) => draft.has(g.id))}
|
||||
onCheckedChange={(checked) => {
|
||||
for (const g of games)
|
||||
if (checked) draft.add(g.id);
|
||||
else draft.delete(g.id);
|
||||
}}
|
||||
/>Generation {generation}</label
|
||||
></legend
|
||||
>
|
||||
<div class="space-y-2">
|
||||
{#each games as g (g.id)}<label class="flex items-center gap-3 rounded-lg border p-2"
|
||||
><Checkbox
|
||||
name="gameIds"
|
||||
value={String(g.id)}
|
||||
checked={draft.has(g.id)}
|
||||
onCheckedChange={(checked) => (checked ? draft.add(g.id) : draft.delete(g.id))}
|
||||
/><GameLogo slug={`pokemon/${g.slug}`} name={g.name} class="h-8 w-16" /><span
|
||||
class="text-sm">{g.name}</span
|
||||
></label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>{/each}
|
||||
</div>
|
||||
<Dialog.Footer class="mt-6"
|
||||
><Button variant="outline" onclick={() => (rulesOpen = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save rules'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { pokemon, pokemonEncounters } from '$lib/server/db/schema';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const gameId = Number(url.searchParams.get('game'));
|
||||
const locationId = Number(url.searchParams.get('location'));
|
||||
if (![gameId, locationId].every((n) => Number.isSafeInteger(n) && n > 0 && n <= 2147483647))
|
||||
error(400, 'Invalid game or location');
|
||||
const rows = await db
|
||||
.select({
|
||||
pokemonId: pokemon.id,
|
||||
number: pokemon.number,
|
||||
name: pokemon.name,
|
||||
type1: pokemon.type1,
|
||||
type2: pokemon.type2,
|
||||
area: pokemonEncounters.area,
|
||||
method: pokemonEncounters.method,
|
||||
minLevel: pokemonEncounters.minLevel,
|
||||
maxLevel: pokemonEncounters.maxLevel,
|
||||
rarity: pokemonEncounters.rarity,
|
||||
conditions: pokemonEncounters.conditions,
|
||||
form: pokemonEncounters.form,
|
||||
note: pokemonEncounters.note
|
||||
})
|
||||
.from(pokemonEncounters)
|
||||
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
|
||||
.where(and(eq(pokemonEncounters.gameId, gameId), eq(pokemonEncounters.locationId, locationId)))
|
||||
.orderBy(asc(pokemon.number), asc(pokemonEncounters.method), asc(pokemonEncounters.minLevel));
|
||||
return json(rows, { headers: { 'Cache-Control': 'public, max-age=3600' } });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
@@ -39,13 +40,13 @@
|
||||
</label>
|
||||
<label class="block"
|
||||
>region
|
||||
<select
|
||||
<NativeSelect
|
||||
bind:value={region}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
<option value="">all regions</option>
|
||||
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-5 flex cursor-pointer items-center gap-3">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
@@ -124,12 +126,12 @@
|
||||
{#if games.length}
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
<NativeSelect
|
||||
bind:value={gameId}
|
||||
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
>
|
||||
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -139,7 +141,7 @@
|
||||
{#each list as encounter, index (index)}
|
||||
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.number}.png"
|
||||
src={pokemonImage(encounter.number)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="40"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
let resetTarget = $state<{ code: LangCode; name: string } | null>(null);
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import Flame from 'lucide-svelte/icons/flame';
|
||||
@@ -40,7 +42,7 @@
|
||||
}
|
||||
|
||||
function reset(code: LangCode, name: string) {
|
||||
if (confirm(`Delete all ${name} progress? This can't be undone.`)) progress.reset(code);
|
||||
resetTarget = { code, name };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -247,3 +249,25 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={!!resetTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Reset {resetTarget?.name}?</AlertDialog.Title><AlertDialog.Description
|
||||
>This deletes all progress for this language and cannot be undone.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Cancel</AlertDialog.Cancel><AlertDialog.Action
|
||||
onclick={() => {
|
||||
if (resetTarget) progress.reset(resetTarget.code);
|
||||
resetTarget = null;
|
||||
}}>Reset progress</AlertDialog.Action
|
||||
></AlertDialog.Footer
|
||||
></AlertDialog.Content
|
||||
>
|
||||
</AlertDialog.Root>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -486,21 +486,6 @@
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
.money-dialog {
|
||||
width: min(440px, calc(100% - 28px));
|
||||
max-height: min(90vh, 850px);
|
||||
overflow-y: auto;
|
||||
margin: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
box-shadow: 0 18px 60px #0006;
|
||||
}
|
||||
.money-dialog::backdrop {
|
||||
background: #0009;
|
||||
}
|
||||
.money-dialog .dialog-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
@@ -52,7 +53,7 @@
|
||||
{#if data.editable}
|
||||
<form method="POST" action="?/addTopic" class="flex flex-wrap items-end gap-2">
|
||||
<div class="min-w-48 flex-1 space-y-2">
|
||||
<Label for="topic-id">Add existing topic</Label><select
|
||||
<Label for="topic-id">Add existing topic</Label><NativeSelect
|
||||
id="topic-id"
|
||||
name="topic-id"
|
||||
required
|
||||
@@ -60,7 +61,7 @@
|
||||
><option value="">Choose a topic</option
|
||||
>{#each data.topics as topic (topic.id)}<option value={topic.id}
|
||||
>{topic.name}</option
|
||||
>{/each}</select
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<Button type="submit" variant="outline">Add</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user