From c2bc9050c2d4453e0dac64279ec97445fd031def Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Thu, 8 Oct 2026 21:06:19 +0000 Subject: [PATCH] feat: build compact BO2 grind companion with cumulative ranks --- data/call-of-duty.json | 12 +- data/cod-art.json | 90 +- data/cod-rank-art.json | 90 ++ docs/game-tracker.md | 18 +- scripts/audit-tracker-data.ts | 33 + scripts/deploy-preflight.ts | 13 + scripts/deploy-remote.sh | 48 + scripts/print-db-server-version.ts | 10 + scripts/seed-cod-catalog.ts | 12 +- src/lib/game-tracker/PlayerRankBadge.svelte | 34 + src/lib/game-tracker/WeaponArtwork.svelte | 9 +- .../game-tracker/WeaponRankControls.svelte | 185 +-- src/lib/game-tracker/rank-progress.test.ts | 84 ++ src/lib/game-tracker/rank-progress.ts | 55 + src/routes/+layout.svelte | 6 + .../projects/game-tracker/+page.server.ts | 31 +- .../call-of-duty/[slug]/+page.server.ts | 11 +- .../call-of-duty/[slug]/+page.svelte | 1027 +++++++++++------ .../game-tracker/credits/+page.server.ts | 6 +- .../call-of-duty/black-ops-2-an-94-bo2.webp | Bin 0 -> 6802 bytes .../black-ops-2-assault-shield-bo2.webp | Bin 0 -> 3638 bytes .../black-ops-2-ballistic-knife-bo2.webp | Bin 0 -> 9702 bytes .../call-of-duty/black-ops-2-dsr-50-bo2.webp | Bin 0 -> 4800 bytes .../call-of-duty/black-ops-2-mp7-bo2.webp | Bin 0 -> 6230 bytes .../black-ops-2-peacekeeper-bo2.webp | Bin 0 -> 29920 bytes .../black-ops-2-skorpion-evo-bo2.webp | Bin 0 -> 38902 bytes .../black-ops-2-vector-k10-bo2.webp | Bin 0 -> 42904 bytes .../ranks/black-ops-2-prestige-1.webp | Bin 0 -> 7100 bytes .../ranks/black-ops-2-prestige-10.webp | Bin 0 -> 7718 bytes .../ranks/black-ops-2-prestige-11.webp | Bin 0 -> 7508 bytes .../ranks/black-ops-2-prestige-2.webp | Bin 0 -> 7494 bytes .../ranks/black-ops-2-prestige-3.webp | Bin 0 -> 6696 bytes .../ranks/black-ops-2-prestige-4.webp | Bin 0 -> 6032 bytes .../ranks/black-ops-2-prestige-5.webp | Bin 0 -> 5794 bytes .../ranks/black-ops-2-prestige-6.webp | Bin 0 -> 7376 bytes .../ranks/black-ops-2-prestige-7.webp | Bin 0 -> 6710 bytes .../ranks/black-ops-2-prestige-8.webp | Bin 0 -> 5678 bytes .../ranks/black-ops-2-prestige-9.webp | Bin 0 -> 6924 bytes tests/game-tracker.test.ts | 172 ++- 39 files changed, 1499 insertions(+), 447 deletions(-) create mode 100644 data/cod-rank-art.json create mode 100644 scripts/audit-tracker-data.ts create mode 100644 scripts/print-db-server-version.ts create mode 100644 src/lib/game-tracker/PlayerRankBadge.svelte create mode 100644 src/lib/game-tracker/rank-progress.test.ts create mode 100644 src/lib/game-tracker/rank-progress.ts create mode 100644 static/game-art/call-of-duty/black-ops-2-an-94-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-assault-shield-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-ballistic-knife-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-dsr-50-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-mp7-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-peacekeeper-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-skorpion-evo-bo2.webp create mode 100644 static/game-art/call-of-duty/black-ops-2-vector-k10-bo2.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp create mode 100644 static/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp diff --git a/data/call-of-duty.json b/data/call-of-duty.json index 95ddbd1..1c9cc7d 100644 --- a/data/call-of-duty.json +++ b/data/call-of-duty.json @@ -601,7 +601,10 @@ "shortName": "Black Ops II", "releaseYear": 2012, "rankCaps": { - "player": { "level": 55, "prestige": 10 }, + "player": { + "level": 55, + "prestige": 10 + }, "weaponPrestige": 2, "weaponLevelsByClass": { "Assault Rifles": 17, @@ -611,7 +614,10 @@ "Shotguns": 10, "Pistols": 10 }, - "weaponLevelOverrides": { "Ballista": 11, "Crossbow": 6 }, + "weaponLevelOverrides": { + "Ballista": 11, + "Crossbow": 6 + }, "sourceUrl": "https://w.atwiki.jp/blackops2_cod/pages/47.html" }, "steamApps": [ @@ -708,7 +714,7 @@ }, { "name": "Specials", - "weapons": ["Ballistic Knife", "Crossbow", "Combat Knife"] + "weapons": ["Ballistic Knife", "Crossbow", "Combat Knife", "Assault Shield"] } ], "achievements": [ diff --git a/data/cod-art.json b/data/cod-art.json index f006029..9a50d1f 100644 --- a/data/cod-art.json +++ b/data/cod-art.json @@ -53,15 +53,6 @@ "credit": "Activision / Infinity Ward; promotional image via EAA FPS News", "path": "/game-art/call-of-duty/infinite-warfare-nv4.webp" }, - { - "game": "black-ops-2", - "key": "weapon:an-94", - "name": "AN-94", - "url": "https://1.bp.blogspot.com/-pmdU9rbuv3g/UQBOItGs7PI/AAAAAAAAANU/3AnEO9VGXdk/s1600/an%2B94.png", - "source": "https://buenassensacionesgame.blogspot.com/2013/01/guia-de-armas-bo2-94.html", - "credit": "Activision / Treyarch; screenshot via Buenas Sensaciones Game", - "path": "/game-art/call-of-duty/black-ops-2-an-94.webp" - }, { "game": "black-ops", "key": "weapon:m16", @@ -80,15 +71,6 @@ "credit": "Activision; game render / screenshot via Thypix", "path": "/game-art/call-of-duty/black-ops-2-scar-h.webp" }, - { - "game": "black-ops-2", - "key": "weapon:dsr-50", - "name": "DSR 50", - "url": "https://thypix.com/wp-content/uploads/2020/12/call-of-duty-weapons-90.png", - "source": "https://thypix.com/en/call-of-duty-weapons-in-png-transparent-background/", - "credit": "Activision; game render / screenshot via Thypix", - "path": "/game-art/call-of-duty/black-ops-2-dsr-50.webp" - }, { "game": "black-ops-3", "key": "weapon:dingo", @@ -1816,5 +1798,77 @@ "source": "https://dotesports.com/call-of-duty/news/all-weapons-guide-cod-wwii-18469", "credit": "Activision / Sledgehammer Games; loadout screenshot via Dot Esports", "path": "/game-art/call-of-duty/wwii-us-shovel.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:an-94", + "name": "AN-94", + "url": "https://tweakers.net/camo/20dcffbcbb43331f99c11d5dce5d03974d07283c/?url=http%3A%2F%2Fimages2.wikia.nocookie.net%2F__cb20121210115861%2Fcallofduty%2Fimages%2Fthumb%2F3%2F34%2FAN-94_menu_icon_BOII.png%2F256px-AN-94_menu_icon_BOII.png", + "source": "https://gathering.tweakers.net/forum/list_messages/1536867?data%5Bfilter_images%5D=1", + "credit": "Activision / Treyarch; Black Ops II game artwork via Call of Duty Wiki / Tweakers", + "path": "/game-art/call-of-duty/black-ops-2-an-94-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:mp7", + "name": "MP7", + "url": "https://tweakers.net/camo/253796176c763d63a09049e7f9860dec920094db/?url=http%3A%2F%2Fimages3.wikia.nocookie.net%2F__cb20120930130321%2Fcallofduty%2Fimages%2Fthumb%2F4%2F4d%2FMP7_Menu_Icon_BOII.png%2F250px-MP7_Menu_Icon_BOII.png", + "source": "https://gathering.tweakers.net/forum/list_messages/1536867?data%5Bfilter_images%5D=1", + "credit": "Activision / Treyarch; Black Ops II game artwork via Call of Duty Wiki / Tweakers", + "path": "/game-art/call-of-duty/black-ops-2-mp7-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:vector-k10", + "name": "Vector K10", + "url": "https://tweakers.net/camo/d56eee92351b3ea12225c3cef36bcba8240de141/?url=http%3A%2F%2Fimages4.wikia.nocookie.net%2F__cb20121111141944%2Fcallofduty%2Fimages%2F8%2F89%2FVector_K10_Menu_Icon_BOII.png", + "source": "https://gathering.tweakers.net/forum/list_messages/1536867?data%5Bfilter_images%5D=1", + "credit": "Activision / Treyarch; Black Ops II game artwork via Call of Duty Wiki / Tweakers", + "path": "/game-art/call-of-duty/black-ops-2-vector-k10-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:dsr-50", + "name": "DSR 50", + "url": "https://static1.millenium.org/article_old/images/contenu/actus/JeuxVideo/Black_ops_2_guide_armement/sniper/256px-DSR_50_menu_icon_BOII.png", + "source": "https://www.millenium.org/guide/98823.html?page=6", + "credit": "Activision / Treyarch; Black Ops II game artwork via www.millenium.org", + "path": "/game-art/call-of-duty/black-ops-2-dsr-50-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:peacekeeper", + "name": "Peacekeeper", + "url": "https://image.pngaaa.com/349/5295349.png", + "source": "https://www.pngaaa.com/detail/5295349", + "credit": "Activision / Treyarch; Black Ops II game artwork via www.pngaaa.com", + "path": "/game-art/call-of-duty/black-ops-2-peacekeeper-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:skorpion-evo", + "name": "Skorpion EVO", + "url": "https://www.pngkey.com/png/full/186-1863286_skorpion-evo-call-of-duty-wiki-fandom-skorpion.png", + "source": "https://www.pngkey.com/detail/u2w7q8y3u2o0e6a9_skorpion-evo-call-of-duty-wiki-fandom-skorpion/", + "credit": "Activision / Treyarch; Black Ops II game artwork via www.pngkey.com", + "path": "/game-art/call-of-duty/black-ops-2-skorpion-evo-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:assault-shield", + "name": "Assault Shield", + "url": "https://tweakers.net/camo/6ae01ced7e27df4be7ceac493cbf06c325577207/?url=http%3A%2F%2Fimages1.wikia.nocookie.net%2F__cb20121214020229%2Fcallofduty%2Fimages%2Fthumb%2F0%2F05%2FAssault_Shield_Menu_Icon_BOII.png%2F250px-Assault_Shield_Menu_Icon_BOII.png", + "source": "https://gathering.tweakers.net/forum/list_messages/1536867?data%5Bfilter_images%5D=1", + "credit": "Activision / Treyarch; Black Ops II game artwork via Call of Duty Wiki / Tweakers", + "path": "/game-art/call-of-duty/black-ops-2-assault-shield-bo2.webp" + }, + { + "game": "black-ops-2", + "key": "weapon:ballistic-knife", + "name": "Ballistic Knife", + "url": "https://tweakers.net/camo/ff040905bb8e8b513c2677eddfcaf38c5af21ab2/?url=http%3A%2F%2Fimages1.wikia.nocookie.net%2F__cb20121214021127%2Fcallofduty%2Fimages%2F4%2F4b%2FBallistic_Knife_Create-a-Class_BOII.png", + "source": "https://gathering.tweakers.net/forum/list_messages/1536867?data%5Bfilter_images%5D=1", + "credit": "Activision / Treyarch; Black Ops II game artwork via Call of Duty Wiki / Tweakers", + "path": "/game-art/call-of-duty/black-ops-2-ballistic-knife-bo2.webp" } ] diff --git a/data/cod-rank-art.json b/data/cod-rank-art.json new file mode 100644 index 0000000..d1751b1 --- /dev/null +++ b/data/cod-rank-art.json @@ -0,0 +1,90 @@ +[ + { + "game": "black-ops-2", + "prestige": 1, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804661214/9FD6B03A8AB4ABE1D52ADEB9A14D8DBB72054910/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 2, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804661581/1D2C4EAD9F7EBCE0955B6BBE0FEA78A643B817C7/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 3, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804661865/38F7010DD1543AA9A08C584D1B90DD7C9C138BA1/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 4, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804662398/E36D618159E6474536E21AFD5873BB652658C010/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 5, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804662706/45B72C58EB62C84E20A50AEDF2539069BFBF4531/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 6, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804663180/AB4D41F43A0A68C3417F5DBB1ADA54365F217A49/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 7, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804668104/BC9E371A435F8CCD160A22ED70AABB4E747E78E4/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 8, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804668443/6985C3A4733C6CC5E19EEDC53E95FBE705752C70/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 9, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804669343/BCBD2BC8411DA714684CE5E01C24F4E3DA948F5F/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": 10, + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804670653/CD1052286A32FDA566E1849D3B6F02ADB22C45CC/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + }, + { + "game": "black-ops-2", + "prestige": "master", + "path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp", + "url": "https://images.steamusercontent.com/ugc/2506892135804671070/305118EBAF387C239C6CBF2D8C808E1E6305D3E7/", + "source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009", + "credit": "Activision / Treyarch; prestige emblem via Steam Community" + } +] diff --git a/docs/game-tracker.md b/docs/game-tracker.md index f33f600..f3a69a1 100644 --- a/docs/game-tracker.md +++ b/docs/game-tracker.md @@ -76,9 +76,9 @@ National caught/seen bars count each species once across the games selected in r 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. +COD completion equally weights cumulative player/weapon levels, weapon mastery, achievements and challenges where a denominator is known. Each recorded level counts immediately: `prestige × max level + current level`, out of `(max prestige + 1) × max level`. For example, M27 P0 L17 is 17/51, P1 L1 is 18/51, and P2 L17 is 51/51. Missing ranks contribute zero; launchers/melee without weapon levels and games without verified caps are excluded from rank denominators. Library and game totals share the same calculation; historical out-of-range records are bounded for display calculations, never rewritten. Each challenge contributes its fraction of a known target, or a completed/not-completed value when no target exists. Derived Diamond/Dark Matter tiers are shown separately and are not counted a second time in overall completion. Weapon cards, achievement groups, individual challenges and the full class breakdown show their own percentages. Black Ops II's catalog records a player cap of level 55 and prestige 10 (Prestige Master), a weapon prestige cap of 2, and attachment-rank caps by weapon class. Assault rifles cap at 17, SMGs and LMGs at 16, shotguns, pistols and most sniper rifles at 10, Ballista at 11 and Crossbow at 6. Launchers and the other melee specials have no attachment-rank progression. These caps are enforced by both the controls and server actions. osu! shows all-goal, pass, FC and accuracy completion from saved goals. -The BO2 cap list follows the [weapon-level unlock table](https://w.atwiki.jp/blackops2_cod/pages/47.html); the player Prestige Master threshold is documented by [Activision Support](https://support.activision.com/call-of-duty--black-ops-ii/articles/achieving-prestige-master-in-call-of-duty-black-ops-ii). The numeric player cap here represents the level-55 prestige cycle and does not model the post-Master level counter. +The BO2 cap list follows the [weapon-level unlock table](https://w.atwiki.jp/blackops2_cod/pages/47.html); the player Prestige Master threshold is documented by [Activision Support](https://support.activision.com/call-of-duty--black-ops-ii/articles/achieving-prestige-master-in-call-of-duty-black-ops-ii). The numeric player cap represents the level-55 prestige cycle; prestige 10 at level 55 displays the Master emblem. Re-run `bun run db:migrate:tracker-journal` before deploying this update: it adds `pokemon_seen` and `pokemon_journeys.completed` without deleting existing data. Deployment preflight checks both. @@ -86,25 +86,25 @@ Re-run `bun run db:migrate:tracker-journal` before deploying this update: it add 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. +Weapon cards combine artwork, name, level, prestige and camo progress. Level and prestige **− / +** buttons save immediately, display save failures, and use atomic database increments to preserve simultaneous updates from multiple devices. Prestige + starts the next prestige at level 1. Tap the level number for direct editing. Positive prestige infers that maximum level has been reached at least once, and existing max-level checkmarks remain recognized. Black Ops II player and weapon level/prestige caps are enforced in both controls and server actions. The shared COD companion board groups weapons by class. At desktop sizes of 1280×720 and above, all 40 BO2 weapons fit on one screen; other games use 32-weapon pages. Narrow screens use a readable vertical layout. Every gun has a still image. Select a weapon name to edit camos and use full rank controls; the compact + button saves a level immediately. All ten BO2 prestige emblems and the Master emblem are served locally beside a level badge. Hide finished requires every prestige run and applicable camo milestone, not just a first-run max-level checkmark. Overall mastery statistics are available in the **Progress overview** dialog. The imported snapshot has **3,674 challenges/operations**: Black Ops 1,489; Black Ops II 526; Ghosts 31; Advanced Warfare 75; Black Ops III 860; Infinite Warfare 329; WWII 364. This is a partial, attributed catalog, not an exhaustive inventory. Ghosts source rows with missing tier thresholds are excluded. Rewards without source text are left blank, and many emblem/calling-card pictures remain unavailable. `scripts/build-cod-challenges.py` imports rendered, line-numbered wiki snapshots with `--snapshots `. 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. +208 locally served WebP weapon pictures are registered in `data/cod-art.json`, covering all seven games, with a larger shadcn dialog preview and original image credits. The base weapon galleries for Black Ops III and WWII are complete. Other games remain partial. Images include renders, game screenshots, named variants and real weapon reference photos, identified in the preview credits. All 618 cataloged achievement icons have local WebP copies. Add verified images and manifest entries, then run `db:seed:art`; the regular COD catalog seed also retains manifest images. | Base weapon picture coverage | Pictures / weapons | | ---------------------------- | -----------------: | | Black Ops | 32 / 42 | -| Black Ops II | 33 / 38 | +| Black Ops II | 38 / 39 | | 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. +Additional pictures include BO2’s DLC Peacekeeper and Ghosts’ DLC Maverick-A2. BO2’s Combat Knife is the remaining entry without artwork; all guns have pictures. These counts do not imply complete DLC, camo, emblem or calling-card artwork. Challenge text is adapted from **Call of Duty Wiki contributors**, with per-record source links. The adapted challenge dataset retains the source’s [CC BY-SA terms](https://www.fandom.com/licensing), per [the wiki’s copyright policy](https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights). Game artwork retains its original owners’ rights; it is not covered by the challenge-text license. `data/cod-art.json` records each image’s source, download URL and credit. Pokémon artwork/sprites are downloaded from [PokéAPI’s sprite repository](https://github.com/PokeAPI/sprites) and served locally. @@ -124,3 +124,9 @@ npm run test:tracker ``` Tracker browser tests use the migrated **local development** database, start their own server on port 4174, create temporary users and delete them afterward. They check Platinum routes and dex ordering, duplicate catch policy, team slot reassignment, ownership, rank/challenge persistence, osu training calculations, dialog keyboard behavior, mobile page widths, local image loading, picture-button selection, and a readable two-column 53-game Pokémon library. Companion checks cover immediate weapon adjustments, concurrent updates, failed-save recovery, prestige inference, all-species encounter grid fitting, expanded encounters and catch registration from the map. Progress checks cover persisted sightings, caught/seen deduplication, completed stories, partial COD challenges, osu! goal completion, rules-scoped totals and direct cover switching. Do not point `test:tracker` at a shared or production database. + +## Deployment verification + +`deploy-remote.sh` takes a PostgreSQL-version-matched custom-format backup and validates its archive before running the repeatable migrations and catalog seeds on each environment. The COD seed retains stable IDs and preserves stale items with either checklist progress **or weapon ranks**. No saved player/weapon levels are reset or clamped in storage. `audit-tracker-data.ts` prints only row counts and fingerprints for saved tracker records; compare before/after each deployment (newly added journal tables and backfilled sightings are expected). BO2 catalog caps, weapon counts and image coverage are checked by deployment preflight. + +BO2 data sources: [attachment level unlocks](https://w.atwiki.jp/blackops2_cod/pages/47.html), [Peacekeeper attachments](https://callofduty.fandom.com/wiki/Peacekeeper), and [weapon prestige](https://callofduty.fandom.com/wiki/Prestige_Mode). Artwork URLs and credits are in `data/cod-art.json` and `data/cod-rank-art.json` and included in the public credits page. diff --git a/scripts/audit-tracker-data.ts b/scripts/audit-tracker-data.ts new file mode 100644 index 0000000..13de540 --- /dev/null +++ b/scripts/audit-tracker-data.ts @@ -0,0 +1,33 @@ +import postgres from 'postgres'; + +// Read-only fingerprints: lets a deployment verify saved progress without logging it. +if (!process.env.DB_URL) throw new Error('DB_URL is required'); +const client = postgres(process.env.DB_URL, { max: 1 }); +const tables = [ + 'user_entries', + 'user_games', + 'catch_em_all_settings', + 'pokemon_journeys', + 'pokemon_catches', + 'pokemon_milestones', + 'pokemon_seen', + 'tracker_progress', + 'tracker_ranks', + 'tracker_weapon_ranks', + 'tracker_challenge_progress', + 'osu_entries' +]; +try { + const result: Record = {}; + for (const table of tables) { + const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`; + if (!exists.present) continue; + const [row] = await client.unsafe(`select count(*)::int as rows, + md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint + from (select row_to_json(t)::text as record from "${table}" t) s`); + result[table] = row; + } + console.log(JSON.stringify(result, null, 2)); +} finally { + await client.end(); +} diff --git a/scripts/deploy-preflight.ts b/scripts/deploy-preflight.ts index 4103e8b..19a7e69 100644 --- a/scripts/deploy-preflight.ts +++ b/scripts/deploy-preflight.ts @@ -114,6 +114,19 @@ try { if (Number(catalog.cod_games) < 7) { throw new Error('Call of Duty catalog has not been seeded in this database'); } + const [bo2] = await client`select rules, + (select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons, + (select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures + from tracker_games g where slug='black-ops-2'`; + if ( + !bo2 || + bo2.rules.rankCaps?.weaponPrestige !== 2 || + bo2.rules.rankCaps?.weaponLevelsByClass?.['Assault Rifles'] !== 17 || + bo2.weapons < 40 || + bo2.pictures < 39 + ) { + throw new Error('Seed the updated BO2 catalog and artwork before deploying'); + } } finally { await client.end(); } diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index a037283..5a4b00c 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -17,6 +17,11 @@ esac image="git.stanrunge.dev/stan/personal-website:$sha" compose="$directory/compose.prod.yml" +if [ "$directory" = /opt/personal-website-staging ]; then + environment_name=staging +else + environment_name=production +fi previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) switched=0 rollback() { @@ -28,6 +33,47 @@ rollback() { } trap rollback ERR +db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \ + bun scripts/print-db-server-version.ts) +case "$db_version" in + *[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;; +esac +if [ "$db_version" -ge 100000 ]; then + db_major=$((db_version / 10000)) +else + db_major="$((db_version / 10000)).$(((db_version / 100) % 100))" +fi +backup_image="postgres:${db_major}-alpine" +backup_dir=/var/backups/personal-website +backup_stamp=$(date -u +%Y%m%dT%H%M%SZ) +backup_name="${environment_name}-${backup_stamp}-${sha}.dump" +sudo docker pull "$backup_image" +sudo install -d -m 0700 "$backup_dir" +sudo docker run --rm --network backend --env-file "$environment" \ + --mount "type=bind,source=$backup_dir,target=/backups" \ + --env "BACKUP_FILE=$backup_name" "$backup_image" \ + sh -eu -c 'pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_URL"' +sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \ + "$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null +sudo chmod 0600 "$backup_dir/$backup_name" +echo "Verified $environment_name database backup at $backup_dir/$backup_name" + +run_database_script() { + echo "Running $1" + sudo docker run --rm --network backend --env-file "$environment" "$image" bun "$1" +} + +run_database_script scripts/migrate-auth-catalog.ts +run_database_script scripts/migrate-foundation.ts +run_database_script scripts/migrate-money-tracker.ts +run_database_script scripts/migrate-game-tracker.ts +run_database_script scripts/migrate-tracker-journal.ts +run_database_script scripts/seed-catalog.ts +run_database_script scripts/seed-cod-catalog.ts +run_database_script scripts/seed-pokemon-research.ts +run_database_script scripts/seed-cod-challenges.ts +run_database_script scripts/seed-cod-art.ts + sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun scripts/deploy-preflight.ts "$origin" @@ -46,5 +92,7 @@ curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error "$origin/login" >/dev/null curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ "$origin/projects/game-tracker/pokemon" >/dev/null +curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ + "$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null trap - ERR echo "Deployed $sha to $origin" diff --git a/scripts/print-db-server-version.ts b/scripts/print-db-server-version.ts new file mode 100644 index 0000000..7b68445 --- /dev/null +++ b/scripts/print-db-server-version.ts @@ -0,0 +1,10 @@ +import postgres from 'postgres'; + +if (!process.env.DB_URL) throw new Error('DB_URL is required'); +const client = postgres(process.env.DB_URL, { max: 1 }); +try { + const [row] = await client`select current_setting('server_version_num')::integer as version`; + console.log(row.version); +} finally { + await client.end(); +} diff --git a/scripts/seed-cod-catalog.ts b/scripts/seed-cod-catalog.ts index 2002e2e..6be1c0e 100644 --- a/scripts/seed-cod-catalog.ts +++ b/scripts/seed-cod-catalog.ts @@ -10,6 +10,7 @@ import { trackerItems, trackerProgress } from '../src/lib/server/db/schema/game-tracker'; +import { trackerWeaponRanks } from '../src/lib/server/db/schema/tracker-journal'; import type { MasteryRules } from '../src/lib/game-tracker/mastery'; type Catalog = { @@ -136,7 +137,16 @@ try { stale.map((item) => item.id) ) ); - const keep = new Set(kept.map((row) => row.id)); + const ranked = await tx + .select({ id: trackerWeaponRanks.itemId }) + .from(trackerWeaponRanks) + .where( + inArray( + trackerWeaponRanks.itemId, + stale.map((item) => item.id) + ) + ); + const keep = new Set([...kept, ...ranked].map((row) => row.id)); const remove = stale.filter((item) => !keep.has(item.id)).map((item) => item.id); if (remove.length) await tx.delete(trackerItems).where(inArray(trackerItems.id, remove)); for (const item of stale.filter((item) => keep.has(item.id))) diff --git a/src/lib/game-tracker/PlayerRankBadge.svelte b/src/lib/game-tracker/PlayerRankBadge.svelte new file mode 100644 index 0000000..757a859 --- /dev/null +++ b/src/lib/game-tracker/PlayerRankBadge.svelte @@ -0,0 +1,34 @@ + + +
+
+ +
+ LEVEL{level} +
+
+
+ {#if game === 'black-ops-2' && prestige > 0 && prestige <= 10} + {master + {:else}{/if} +
+ PRESTIGE{master ? 'Master' : prestige} +
+
+
diff --git a/src/lib/game-tracker/WeaponArtwork.svelte b/src/lib/game-tracker/WeaponArtwork.svelte index b01f413..04c81b1 100644 --- a/src/lib/game-tracker/WeaponArtwork.svelte +++ b/src/lib/game-tracker/WeaponArtwork.svelte @@ -2,7 +2,12 @@ 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(); + let { + game, + name, + icon, + compact = false + }: { game: string; name: string; icon: string | null; compact?: boolean } = $props(); const source = $derived( artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase()) ); @@ -20,7 +25,7 @@ loading="lazy" width="112" height="64" - class="h-16 w-28 object-contain" + class={compact ? 'h-6 w-12 object-contain' : 'h-16 w-28 object-contain'} /> void; onedit: () => void; @@ -61,78 +63,117 @@ } -
-
-
+ - - -
-
LEVEL{level}{#if levelCap}/{levelCap}{/if} + + + {saving ? 'Saving…' : error || (saved ? 'Saved' : '')} - - PRESTIGE{prestige}{#if prestigeCap < 1000}/{prestigeCap}{/if} - -
-

- {saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')} -

-
+{:else} +
+
+
+ + + +
+
+ + PRESTIGE{prestige}{#if prestigeCap < 1000}/{prestigeCap}{/if} + +
+
+

+ {saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')} +

+
+{/if} diff --git a/src/lib/game-tracker/rank-progress.test.ts b/src/lib/game-tracker/rank-progress.test.ts new file mode 100644 index 0000000..1f88c27 --- /dev/null +++ b/src/lib/game-tracker/rank-progress.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import catalog from '../../../data/call-of-duty.json'; +import type { MasteryRules, Weapon } from './mastery'; +import { playerRankLimits, weaponRankLimits } from './rank-limits'; +import { + gameRanksProgress, + hasReachedMaxLevel, + rankProgress, + weaponFinished, + weaponLevelsProgress +} from './rank-progress'; + +const game = catalog.games.find((g) => g.slug === 'black-ops-2')!; +const rules = { ...game, classes: game.classes.map((c) => c.name) } as MasteryRules; +const m27: Weapon = { id: 1, name: 'M27', category: 'Assault Rifles', postLaunch: false }; +const mp7: Weapon = { id: 2, name: 'MP7', category: 'Submachine Guns', postLaunch: false }; + +describe('BO2 rank progression', () => { + it('counts every level in each of three weapon runs', () => { + const limits = weaponRankLimits(rules, m27); + expect(rankProgress(limits)).toEqual({ value: 0, total: 51 }); + for (let prestige = 0; prestige <= 2; prestige++) { + for (let level = 1; level <= 17; level++) { + expect(rankProgress(limits, { level, prestige })).toEqual({ + value: prestige * 17 + level, + total: 51 + }); + } + } + }); + it('weights the combined arsenal by levels and excludes unranked weapons', () => { + const rpg = { ...m27, id: 3, name: 'RPG', category: 'Launchers' }; + const ranks = new Map([ + [1, { level: 9, prestige: 1 }], + [2, { level: 8, prestige: 0 }] + ]); + expect(weaponLevelsProgress(rules, [m27, mp7, rpg], ranks)).toEqual({ value: 34, total: 99 }); + expect(gameRanksProgress(rules, [m27, mp7, rpg], ranks, { level: 20, prestige: 2 })).toEqual({ + value: 164, + total: 704 + }); + }); + it('uses class caps and individual exceptions', () => { + const expected: Record = { + M27: 17, + MP7: 16, + 'Vector K10': 16, + 'Skorpion EVO': 16, + Peacekeeper: 16, + 'DSR 50': 10, + Ballista: 11, + Crossbow: 6, + RPG: null, + 'Combat Knife': null + }; + for (const c of game.classes) + for (const name of [...c.weapons, ...('postLaunch' in c ? (c.postLaunch ?? []) : [])]) { + const limits = weaponRankLimits(rules, { name, category: c.name }); + if (name in expected) expect(limits.maxLevel, name).toBe(expected[name]); + expect(limits.maxPrestige).toBe(2); + } + expect(playerRankLimits(rules).maxLevel).toBe(55); + expect(rankProgress(playerRankLimits(rules), { level: 55, prestige: 10 })).toEqual({ + value: 605, + total: 605 + }); + }); + it('keeps first-run attachment completion separate from all prestige runs', () => { + expect(hasReachedMaxLevel(rules, m27, { level: 17, prestige: 0 })).toBe(true); + expect(hasReachedMaxLevel(rules, m27, { level: 1, prestige: 1 })).toBe(true); + expect(weaponFinished(rules, m27, { level: 17, prestige: 0 }, () => true)).toBe(false); + expect(weaponFinished(rules, m27, { level: 16, prestige: 2 }, () => true)).toBe(false); + expect(weaponFinished(rules, m27, { level: 17, prestige: 2 }, () => true)).toBe(true); + }); + it('bounds historic out-of-range values without mutating saved data', () => { + const rank = { level: 100, prestige: 999 }; + expect(rankProgress(weaponRankLimits(rules, m27), rank)).toEqual({ value: 51, total: 51 }); + expect(rank).toEqual({ level: 100, prestige: 999 }); + expect(rankProgress(weaponRankLimits({ ...rules, rankCaps: undefined }, m27), rank)).toEqual({ + value: 0, + total: 0 + }); + }); +}); diff --git a/src/lib/game-tracker/rank-progress.ts b/src/lib/game-tracker/rank-progress.ts new file mode 100644 index 0000000..9d659fd --- /dev/null +++ b/src/lib/game-tracker/rank-progress.ts @@ -0,0 +1,55 @@ +import { appliesTo, type MasteryRules, type Weapon } from './mastery'; +import { playerRankLimits, weaponRankLimits, type RankLimits } from './rank-limits'; +import { sumProgress, type ProgressCount } from './progress'; + +export type Rank = { level: number; prestige: number }; + +// A 17-level weapon with two prestiges has three runs: 51 level units. +// Unrecorded ranks contribute zero. Never rewrite saved historical values here. +export function rankProgress(limits: RankLimits, rank?: Rank | null): ProgressCount { + if (!limits.rankable || limits.maxLevel === null) return { value: 0, total: 0 }; + const total = limits.maxLevel * (limits.maxPrestige + 1); + if (!rank) return { value: 0, total }; + const prestige = Math.max(0, Math.min(limits.maxPrestige, rank.prestige)); + const level = Math.max(1, Math.min(limits.maxLevel, rank.level)); + return { value: prestige * limits.maxLevel + level, total }; +} + +export function weaponLevelsProgress( + rules: MasteryRules, + weapons: readonly Weapon[], + ranks: ReadonlyMap +) { + return sumProgress(weapons.map((w) => rankProgress(weaponRankLimits(rules, w), ranks.get(w.id)))); +} + +export function gameRanksProgress( + rules: MasteryRules, + weapons: readonly Weapon[], + ranks: ReadonlyMap, + player?: Rank | null +) { + return sumProgress([ + weaponLevelsProgress(rules, weapons, ranks), + rankProgress(playerRankLimits(rules), player) + ]); +} + +export function hasReachedMaxLevel(rules: MasteryRules, weapon: Weapon, rank?: Rank) { + const cap = weaponRankLimits(rules, weapon).maxLevel; + return !!rank && (rank.prestige > 0 || (cap !== null && rank.level >= cap)); +} + +// Keep a completed first-run attachment milestone distinct from finishing all prestiges. +export function weaponFinished( + rules: MasteryRules, + weapon: Weapon, + rank: Rank | undefined, + isDone: (id: number, milestone: string) => boolean +) { + const levels = rankProgress(weaponRankLimits(rules, weapon), rank); + return ( + (levels.total === 0 || levels.value === levels.total) && + rules.milestones.filter((m) => appliesTo(m, weapon)).every((m) => isDone(weapon.id, m.key)) + ); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 20ac096..2107c0b 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -349,6 +349,7 @@