feat: build compact BO2 grind companion with cumulative ranks
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
@@ -601,7 +601,10 @@
|
|||||||
"shortName": "Black Ops II",
|
"shortName": "Black Ops II",
|
||||||
"releaseYear": 2012,
|
"releaseYear": 2012,
|
||||||
"rankCaps": {
|
"rankCaps": {
|
||||||
"player": { "level": 55, "prestige": 10 },
|
"player": {
|
||||||
|
"level": 55,
|
||||||
|
"prestige": 10
|
||||||
|
},
|
||||||
"weaponPrestige": 2,
|
"weaponPrestige": 2,
|
||||||
"weaponLevelsByClass": {
|
"weaponLevelsByClass": {
|
||||||
"Assault Rifles": 17,
|
"Assault Rifles": 17,
|
||||||
@@ -611,7 +614,10 @@
|
|||||||
"Shotguns": 10,
|
"Shotguns": 10,
|
||||||
"Pistols": 10
|
"Pistols": 10
|
||||||
},
|
},
|
||||||
"weaponLevelOverrides": { "Ballista": 11, "Crossbow": 6 },
|
"weaponLevelOverrides": {
|
||||||
|
"Ballista": 11,
|
||||||
|
"Crossbow": 6
|
||||||
|
},
|
||||||
"sourceUrl": "https://w.atwiki.jp/blackops2_cod/pages/47.html"
|
"sourceUrl": "https://w.atwiki.jp/blackops2_cod/pages/47.html"
|
||||||
},
|
},
|
||||||
"steamApps": [
|
"steamApps": [
|
||||||
@@ -708,7 +714,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Specials",
|
"name": "Specials",
|
||||||
"weapons": ["Ballistic Knife", "Crossbow", "Combat Knife"]
|
"weapons": ["Ballistic Knife", "Crossbow", "Combat Knife", "Assault Shield"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"achievements": [
|
"achievements": [
|
||||||
|
|||||||
@@ -53,15 +53,6 @@
|
|||||||
"credit": "Activision / Infinity Ward; promotional image via EAA FPS News",
|
"credit": "Activision / Infinity Ward; promotional image via EAA FPS News",
|
||||||
"path": "/game-art/call-of-duty/infinite-warfare-nv4.webp"
|
"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",
|
"game": "black-ops",
|
||||||
"key": "weapon:m16",
|
"key": "weapon:m16",
|
||||||
@@ -80,15 +71,6 @@
|
|||||||
"credit": "Activision; game render / screenshot via Thypix",
|
"credit": "Activision; game render / screenshot via Thypix",
|
||||||
"path": "/game-art/call-of-duty/black-ops-2-scar-h.webp"
|
"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",
|
"game": "black-ops-3",
|
||||||
"key": "weapon:dingo",
|
"key": "weapon:dingo",
|
||||||
@@ -1816,5 +1798,77 @@
|
|||||||
"source": "https://dotesports.com/call-of-duty/news/all-weapons-guide-cod-wwii-18469",
|
"source": "https://dotesports.com/call-of-duty/news/all-weapons-guide-cod-wwii-18469",
|
||||||
"credit": "Activision / Sledgehammer Games; loadout screenshot via Dot Esports",
|
"credit": "Activision / Sledgehammer Games; loadout screenshot via Dot Esports",
|
||||||
"path": "/game-art/call-of-duty/wwii-us-shovel.webp"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
90
data/cod-rank-art.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 1,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804661214/9FD6B03A8AB4ABE1D52ADEB9A14D8DBB72054910/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 2,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804661581/1D2C4EAD9F7EBCE0955B6BBE0FEA78A643B817C7/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 3,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804661865/38F7010DD1543AA9A08C584D1B90DD7C9C138BA1/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 4,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804662398/E36D618159E6474536E21AFD5873BB652658C010/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 5,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804662706/45B72C58EB62C84E20A50AEDF2539069BFBF4531/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 6,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804663180/AB4D41F43A0A68C3417F5DBB1ADA54365F217A49/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 7,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804668104/BC9E371A435F8CCD160A22ED70AABB4E747E78E4/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 8,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804668443/6985C3A4733C6CC5E19EEDC53E95FBE705752C70/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 9,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804669343/BCBD2BC8411DA714684CE5E01C24F4E3DA948F5F/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": 10,
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804670653/CD1052286A32FDA566E1849D3B6F02ADB22C45CC/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"game": "black-ops-2",
|
||||||
|
"prestige": "master",
|
||||||
|
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp",
|
||||||
|
"url": "https://images.steamusercontent.com/ugc/2506892135804671070/305118EBAF387C239C6CBF2D8C808E1E6305D3E7/",
|
||||||
|
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
|
||||||
|
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
`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.
|
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 |
|
| Base weapon picture coverage | Pictures / weapons |
|
||||||
| ---------------------------- | -----------------: |
|
| ---------------------------- | -----------------: |
|
||||||
| Black Ops | 32 / 42 |
|
| Black Ops | 32 / 42 |
|
||||||
| Black Ops II | 33 / 38 |
|
| Black Ops II | 38 / 39 |
|
||||||
| Ghosts | 20 / 40 |
|
| Ghosts | 20 / 40 |
|
||||||
| Advanced Warfare | 34 / 35 |
|
| Advanced Warfare | 34 / 35 |
|
||||||
| Black Ops III | 31 / 31 |
|
| Black Ops III | 31 / 31 |
|
||||||
| Infinite Warfare | 20 / 35 |
|
| Infinite Warfare | 20 / 35 |
|
||||||
| WWII | 31 / 31 |
|
| 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.
|
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.
|
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.
|
||||||
|
|||||||
33
scripts/audit-tracker-data.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||||
|
const tables = [
|
||||||
|
'user_entries',
|
||||||
|
'user_games',
|
||||||
|
'catch_em_all_settings',
|
||||||
|
'pokemon_journeys',
|
||||||
|
'pokemon_catches',
|
||||||
|
'pokemon_milestones',
|
||||||
|
'pokemon_seen',
|
||||||
|
'tracker_progress',
|
||||||
|
'tracker_ranks',
|
||||||
|
'tracker_weapon_ranks',
|
||||||
|
'tracker_challenge_progress',
|
||||||
|
'osu_entries'
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`;
|
||||||
|
if (!exists.present) continue;
|
||||||
|
const [row] = await client.unsafe(`select count(*)::int as rows,
|
||||||
|
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
|
||||||
|
from (select row_to_json(t)::text as record from "${table}" t) s`);
|
||||||
|
result[table] = row;
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
@@ -114,6 +114,19 @@ try {
|
|||||||
if (Number(catalog.cod_games) < 7) {
|
if (Number(catalog.cod_games) < 7) {
|
||||||
throw new Error('Call of Duty catalog has not been seeded in this database');
|
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 {
|
} finally {
|
||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ esac
|
|||||||
|
|
||||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||||
compose="$directory/compose.prod.yml"
|
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)
|
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||||
switched=0
|
switched=0
|
||||||
rollback() {
|
rollback() {
|
||||||
@@ -28,6 +33,47 @@ rollback() {
|
|||||||
}
|
}
|
||||||
trap rollback ERR
|
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" \
|
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||||
bun scripts/deploy-preflight.ts "$origin"
|
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
|
"$origin/login" >/dev/null
|
||||||
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||||
"$origin/projects/game-tracker/pokemon" >/dev/null
|
"$origin/projects/game-tracker/pokemon" >/dev/null
|
||||||
|
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||||
|
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
|
||||||
trap - ERR
|
trap - ERR
|
||||||
echo "Deployed $sha to $origin"
|
echo "Deployed $sha to $origin"
|
||||||
|
|||||||
10
scripts/print-db-server-version.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||||
|
try {
|
||||||
|
const [row] = await client`select current_setting('server_version_num')::integer as version`;
|
||||||
|
console.log(row.version);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
trackerItems,
|
trackerItems,
|
||||||
trackerProgress
|
trackerProgress
|
||||||
} from '../src/lib/server/db/schema/game-tracker';
|
} 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';
|
import type { MasteryRules } from '../src/lib/game-tracker/mastery';
|
||||||
|
|
||||||
type Catalog = {
|
type Catalog = {
|
||||||
@@ -136,7 +137,16 @@ try {
|
|||||||
stale.map((item) => item.id)
|
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);
|
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));
|
if (remove.length) await tx.delete(trackerItems).where(inArray(trackerItems.id, remove));
|
||||||
for (const item of stale.filter((item) => keep.has(item.id)))
|
for (const item of stale.filter((item) => keep.has(item.id)))
|
||||||
|
|||||||
34
src/lib/game-tracker/PlayerRankBadge.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import ChevronsUp from 'lucide-svelte/icons/chevrons-up';
|
||||||
|
import Award from 'lucide-svelte/icons/award';
|
||||||
|
let { game, level, prestige }: { game: string; level: number; prestige: number } = $props();
|
||||||
|
const master = $derived(game === 'black-ops-2' && prestige === 10 && level === 55);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4" aria-label="Player level and prestige">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ChevronsUp class="size-7 text-amber-500" aria-label="Level icon" role="img" />
|
||||||
|
<div>
|
||||||
|
<span class="block text-[9px] tracking-widest text-muted-foreground">LEVEL</span><strong
|
||||||
|
class="text-sm tabular-nums">{level}</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if game === 'black-ops-2' && prestige > 0 && prestige <= 10}
|
||||||
|
<img
|
||||||
|
src={`${base}/game-art/call-of-duty/ranks/black-ops-2-prestige-${master ? 11 : prestige}.webp`}
|
||||||
|
alt={master ? 'Prestige Master emblem' : `Prestige ${prestige} emblem`}
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
class="size-9 object-contain"
|
||||||
|
/>
|
||||||
|
{:else}<Award class="size-7 text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
|
||||||
|
<div>
|
||||||
|
<span class="block text-[9px] tracking-widest text-muted-foreground">PRESTIGE</span><strong
|
||||||
|
class="text-sm tabular-nums">{master ? 'Master' : prestige}</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import artwork from '../../../data/cod-art.json';
|
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(
|
const source = $derived(
|
||||||
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
|
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
width="112"
|
width="112"
|
||||||
height="64"
|
height="64"
|
||||||
class="h-16 w-28 object-contain"
|
class={compact ? 'h-6 w-12 object-contain' : 'h-16 w-28 object-contain'}
|
||||||
/></Dialog.Trigger
|
/></Dialog.Trigger
|
||||||
>
|
>
|
||||||
<Dialog.Content class="sm:max-w-3xl"
|
<Dialog.Content class="sm:max-w-3xl"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
prestigeCap = 1000,
|
prestigeCap = 1000,
|
||||||
prestigeRequiresMaxLevel = false,
|
prestigeRequiresMaxLevel = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
dense = false,
|
||||||
disabled,
|
disabled,
|
||||||
onupdate,
|
onupdate,
|
||||||
onedit
|
onedit
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
prestigeCap?: number;
|
prestigeCap?: number;
|
||||||
prestigeRequiresMaxLevel?: boolean;
|
prestigeRequiresMaxLevel?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
dense?: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
|
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
|
||||||
onedit: () => void;
|
onedit: () => void;
|
||||||
@@ -61,78 +63,117 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-1">
|
{#if dense}
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="relative flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
<button
|
||||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
type="button"
|
||||||
aria-label={`${name} level controls`}
|
class="rounded px-1 py-1 text-[11px] tabular-nums hover:bg-accent"
|
||||||
|
aria-label={`Edit ${name} level and prestige`}
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onclick={onedit}
|
||||||
|
title="Edit level and prestige"
|
||||||
>
|
>
|
||||||
<Button
|
<span class="sr-only">LEVEL</span>{level}{#if levelCap}<span class="text-muted-foreground"
|
||||||
variant="ghost"
|
>/{levelCap}</span
|
||||||
size="icon"
|
>{/if}
|
||||||
class={compact ? 'size-7' : 'size-10'}
|
</button>
|
||||||
aria-label={`Level down ${name}`}
|
<Button
|
||||||
disabled={disabled || saving || level <= 1}
|
variant="secondary"
|
||||||
onclick={() => adjust('level', -1)}
|
size="icon"
|
||||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
class="size-6"
|
||||||
>
|
aria-label={`Level up ${name}`}
|
||||||
<Button
|
disabled={disabled || saving || level >= (levelCap ?? 10000)}
|
||||||
variant="ghost"
|
onclick={() => adjust('level', 1)}><Plus class="size-3" /></Button
|
||||||
class={compact
|
>
|
||||||
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
|
<span
|
||||||
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
|
role="status"
|
||||||
aria-label={`Edit ${name} level and prestige`}
|
class={error
|
||||||
disabled={disabled || saving}
|
? 'absolute right-0 top-full z-20 w-52 rounded border bg-destructive p-2 text-xs text-white'
|
||||||
onclick={onedit}
|
: 'sr-only'}>{saving ? 'Saving…' : error || (saved ? 'Saved' : '')}</span
|
||||||
><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>
|
</div>
|
||||||
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
|
{:else}
|
||||||
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
|
<div class="space-y-1">
|
||||||
</p>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
</div>
|
<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>
|
||||||
|
{/if}
|
||||||
|
|||||||
84
src/lib/game-tracker/rank-progress.test.ts
Normal file
@@ -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<string, number | null> = {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/lib/game-tracker/rank-progress.ts
Normal file
@@ -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<number, Rank>
|
||||||
|
) {
|
||||||
|
return sumProgress(weapons.map((w) => rankProgress(weaponRankLimits(rules, w), ranks.get(w.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameRanksProgress(
|
||||||
|
rules: MasteryRules,
|
||||||
|
weapons: readonly Weapon[],
|
||||||
|
ranks: ReadonlyMap<number, Rank>,
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -349,6 +349,7 @@
|
|||||||
<footer
|
<footer
|
||||||
class="mt-16 border-t border-[var(--site-border)]"
|
class="mt-16 border-t border-[var(--site-border)]"
|
||||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||||
|
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
|
||||||
>
|
>
|
||||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
<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">
|
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||||
@@ -382,6 +383,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@media (min-width: 1100px) and (min-height: 700px) {
|
||||||
|
.cod-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (min-width: 1024px) and (min-height: 650px) {
|
@media (min-width: 1024px) and (min-height: 650px) {
|
||||||
.tracker-footer {
|
.tracker-footer {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '$lib/server/db/schema';
|
} from '$lib/server/db/schema';
|
||||||
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
||||||
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
|
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
|
||||||
|
import { gameRanksProgress, hasReachedMaxLevel } from '$lib/game-tracker/rank-progress';
|
||||||
import { summarizeGame } from '$lib/game-tracker/mastery';
|
import { summarizeGame } from '$lib/game-tracker/mastery';
|
||||||
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
|
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
@@ -53,13 +54,21 @@ async function callOfDutySummary(userId: string | undefined) {
|
|||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
userId
|
userId
|
||||||
? db
|
? db
|
||||||
.select({ itemId: trackerWeaponRanks.itemId, prestige: trackerWeaponRanks.prestige })
|
.select({
|
||||||
|
itemId: trackerWeaponRanks.itemId,
|
||||||
|
prestige: trackerWeaponRanks.prestige,
|
||||||
|
level: trackerWeaponRanks.level
|
||||||
|
})
|
||||||
.from(trackerWeaponRanks)
|
.from(trackerWeaponRanks)
|
||||||
.where(eq(trackerWeaponRanks.userId, userId))
|
.where(eq(trackerWeaponRanks.userId, userId))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
userId
|
userId
|
||||||
? db
|
? db
|
||||||
.select({ gameId: trackerRanks.gameId })
|
.select({
|
||||||
|
gameId: trackerRanks.gameId,
|
||||||
|
level: trackerRanks.level,
|
||||||
|
prestige: trackerRanks.prestige
|
||||||
|
})
|
||||||
.from(trackerRanks)
|
.from(trackerRanks)
|
||||||
.where(eq(trackerRanks.userId, userId))
|
.where(eq(trackerRanks.userId, userId))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
@@ -86,6 +95,11 @@ async function callOfDutySummary(userId: string | undefined) {
|
|||||||
const byGame = Map.groupBy(items, (item) => item.gameId);
|
const byGame = Map.groupBy(items, (item) => item.gameId);
|
||||||
return gamesList.map((game) => {
|
return gamesList.map((game) => {
|
||||||
const list = byGame.get(game.id) ?? [];
|
const list = byGame.get(game.id) ?? [];
|
||||||
|
const weapons = list.filter((i) => i.kind === 'weapon');
|
||||||
|
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
|
||||||
|
for (const weapon of weapons)
|
||||||
|
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
|
||||||
|
done.add(`${weapon.id}:maxLevel`);
|
||||||
const summary = summarizeGame(
|
const summary = summarizeGame(
|
||||||
game.rules,
|
game.rules,
|
||||||
list.filter((item) => item.kind === 'weapon'),
|
list.filter((item) => item.kind === 'weapon'),
|
||||||
@@ -98,13 +112,12 @@ async function callOfDutySummary(userId: string | undefined) {
|
|||||||
challenges.filter((c) => c.gameId === game.id),
|
challenges.filter((c) => c.gameId === game.id),
|
||||||
challengeRows
|
challengeRows
|
||||||
);
|
);
|
||||||
const ranksProgress = {
|
const ranksProgress = gameRanksProgress(
|
||||||
value:
|
game.rules,
|
||||||
ranks.filter((rank) =>
|
weapons,
|
||||||
list.some((item) => item.kind === 'weapon' && item.id === rank.itemId)
|
rankMap,
|
||||||
).length + Number(playerRanks.some((rank) => rank.gameId === game.id)),
|
playerRanks.find((r) => r.gameId === game.id)
|
||||||
total: list.filter((item) => item.kind === 'weapon').length + 1
|
);
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
overall: combinedProgress([
|
overall: combinedProgress([
|
||||||
weaponProgress,
|
weaponProgress,
|
||||||
|
|||||||
@@ -145,8 +145,10 @@ export const actions = {
|
|||||||
.where(
|
.where(
|
||||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
|
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
|
||||||
);
|
);
|
||||||
if (!current || current.level < maxLevel || current.prestige >= limits.maxPrestige)
|
if (!current || current.level < maxLevel)
|
||||||
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
|
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
|
||||||
|
if (current.prestige >= limits.maxPrestige)
|
||||||
|
return fail(400, { message: `This weapon is already at prestige ${limits.maxPrestige}.` });
|
||||||
}
|
}
|
||||||
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
|
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
|
||||||
const [rank] = await db
|
const [rank] = await db
|
||||||
@@ -169,13 +171,18 @@ export const actions = {
|
|||||||
...(delta > 0 ? { level: 1 } : {})
|
...(delta > 0 ? { level: 1 } : {})
|
||||||
}),
|
}),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
}
|
},
|
||||||
|
setWhere:
|
||||||
|
field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel
|
||||||
|
? sql`${trackerWeaponRanks.level} >= ${maxLevel} and ${trackerWeaponRanks.prestige} < ${limits.maxPrestige}`
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
itemId: trackerWeaponRanks.itemId,
|
itemId: trackerWeaponRanks.itemId,
|
||||||
level: trackerWeaponRanks.level,
|
level: trackerWeaponRanks.level,
|
||||||
prestige: trackerWeaponRanks.prestige
|
prestige: trackerWeaponRanks.prestige
|
||||||
});
|
});
|
||||||
|
if (!rank) return fail(400, { message: 'Rank changed. Refresh before prestiging again.' });
|
||||||
return { rank };
|
return { rank };
|
||||||
},
|
},
|
||||||
saveRank: async ({ request, locals, params }) => {
|
saveRank: async ({ request, locals, params }) => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import weaponArt from '../../../../../data/cod-art.json';
|
||||||
|
import rankArt from '../../../../../data/cod-rank-art.json';
|
||||||
import images from '../../../../../data/game-images.json';
|
import images from '../../../../../data/game-images.json';
|
||||||
import maps from '../../../../../data/pokemon-map-art.json';
|
import maps from '../../../../../data/pokemon-map-art.json';
|
||||||
export function load() {
|
export function load() {
|
||||||
const sources = new Map<string, { source: string; credit: string; count: number }>();
|
const sources = new Map<string, { source: string; credit: string; count: number }>();
|
||||||
for (const image of images) {
|
for (const image of [...images, ...weaponArt, ...rankArt]) {
|
||||||
const existing = sources.get(image.source);
|
const existing = sources.get(image.source);
|
||||||
if (existing) existing.count++;
|
if (existing) existing.count++;
|
||||||
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
|
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
|
||||||
@@ -10,6 +12,6 @@ export function load() {
|
|||||||
return {
|
return {
|
||||||
sources: [...sources.values()],
|
sources: [...sources.values()],
|
||||||
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
|
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
|
||||||
total: images.length
|
total: images.length + weaponArt.length + rankArt.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
static/game-art/call-of-duty/black-ops-2-an-94-bo2.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-assault-shield-bo2.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-dsr-50-bo2.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-mp7-bo2.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-peacekeeper-bo2.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-skorpion-evo-bo2.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
static/game-art/call-of-duty/black-ops-2-vector-k10-bo2.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
static/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
@@ -227,7 +227,8 @@ test('weapon/player ranks, challenge progress and osu goals/scores/training pers
|
|||||||
await page.getByLabel('Prestige', { exact: true }).fill('3');
|
await page.getByLabel('Prestige', { exact: true }).fill('3');
|
||||||
await page.getByRole('button', { name: 'Save rank' }).click();
|
await page.getByRole('button', { name: 'Save rank' }).click();
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
await expect(page.getByText('Level 42 · Prestige 3')).toBeVisible();
|
await expect(page.getByLabel('Player level and prestige')).toContainText('LEVEL42');
|
||||||
|
await expect(page.getByLabel('Player level and prestige')).toContainText('PRESTIGE3');
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await action(page.request, cod + '?/saveRank', {
|
await action(page.request, cod + '?/saveRank', {
|
||||||
@@ -250,8 +251,10 @@ test('weapon/player ranks, challenge progress and osu goals/scores/training pers
|
|||||||
await expect(
|
await expect(
|
||||||
weaponCard.getByRole('button', { name: `Edit ${weapon.name} level and prestige` })
|
weaponCard.getByRole('button', { name: `Edit ${weapon.name} level and prestige` })
|
||||||
).toContainText('17');
|
).toContainText('17');
|
||||||
await expect(weaponCard).toContainText('Max level reached');
|
await weaponCard.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||||||
await page.getByRole('tab', { name: 'challenges', exact: true }).click();
|
await expect(page.getByRole('dialog')).toContainText('Max level reached');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
||||||
await expect(page.locator('article')).toHaveCount(36);
|
await expect(page.locator('article')).toHaveCount(36);
|
||||||
const goal = {
|
const goal = {
|
||||||
kind: 'goal',
|
kind: 'goal',
|
||||||
@@ -526,7 +529,8 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
|||||||
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
await page.goto(cod);
|
await page.goto(cod);
|
||||||
const card = page.getByRole('article', { name: weapon.name, exact: true });
|
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||||||
|
const card = page.getByRole('dialog', { name: weapon.name, exact: true });
|
||||||
const level = card.getByRole('button', { name: `Edit ${weapon.name} level and prestige` });
|
const level = card.getByRole('button', { name: `Edit ${weapon.name} level and prestige` });
|
||||||
await expect(card.getByRole('button', { name: `Level down ${weapon.name}` })).toBeDisabled();
|
await expect(card.getByRole('button', { name: `Level down ${weapon.name}` })).toBeDisabled();
|
||||||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||||||
@@ -552,6 +556,7 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
|||||||
);
|
);
|
||||||
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||||||
await expect(level).toHaveText(/LEVEL5/);
|
await expect(level).toHaveText(/LEVEL5/);
|
||||||
await page.route('**/*adjustWeaponRank', (route) => route.abort());
|
await page.route('**/*adjustWeaponRank', (route) => route.abort());
|
||||||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||||||
@@ -589,6 +594,7 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
|||||||
).status
|
).status
|
||||||
).toBe(401);
|
).toBe(401);
|
||||||
await anonymous.dispose();
|
await anonymous.dispose();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
const cards = await page
|
const cards = await page
|
||||||
.locator('.weapon-card')
|
.locator('.weapon-card')
|
||||||
.evaluateAll((cards) => cards.map((c) => c.getBoundingClientRect().bottom));
|
.evaluateAll((cards) => cards.map((c) => c.getBoundingClientRect().bottom));
|
||||||
@@ -748,7 +754,7 @@ test('visible progress reacts to sightings, catches and completed stories withou
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('progressbar', { name: 'Badges & trials', exact: true })
|
page.getByRole('progressbar', { name: 'Badges & trials', exact: true })
|
||||||
).toHaveAttribute('aria-valuenow', '12.5');
|
).toHaveAttribute('aria-valuenow', '12.5');
|
||||||
await page.getByRole('button', { name: 'All progress ↗', exact: true }).click();
|
await page.getByRole('button', { name: 'Full breakdown', exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog', { name: 'Your Pokémon progress' }).getByRole('progressbar')
|
page.getByRole('dialog', { name: 'Your Pokémon progress' }).getByRole('progressbar')
|
||||||
).toHaveCount(16);
|
).toHaveCount(16);
|
||||||
@@ -799,7 +805,7 @@ test('partial COD challenges and osu goals visibly advance completion meters', a
|
|||||||
await page.goto(cod);
|
await page.goto(cod);
|
||||||
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
||||||
await expect(overall).toHaveAttribute('aria-valuenow', '0');
|
await expect(overall).toHaveAttribute('aria-valuenow', '0');
|
||||||
await page.getByRole('tab', { name: 'challenges', exact: true }).click();
|
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
||||||
await page.getByLabel('Search challenges').fill(challenge.name);
|
await page.getByLabel('Search challenges').fill(challenge.name);
|
||||||
const card = page
|
const card = page
|
||||||
.getByRole('article')
|
.getByRole('article')
|
||||||
@@ -815,7 +821,7 @@ test('partial COD challenges and osu goals visibly advance completion meters', a
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
||||||
.toBeGreaterThan(partial);
|
.toBeGreaterThan(partial);
|
||||||
await page.getByRole('tab', { name: 'weapons', exact: true }).click();
|
await page.getByRole('tab', { name: 'Weapons', exact: true }).click();
|
||||||
await page.screenshot({ path: '/tmp/tracker-progress-cod.png' });
|
await page.screenshot({ path: '/tmp/tracker-progress-cod.png' });
|
||||||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||||||
kind: 'goal',
|
kind: 'goal',
|
||||||
@@ -839,3 +845,155 @@ test('partial COD challenges and osu goals visibly advance completion meters', a
|
|||||||
page.getByRole('progressbar', { name: 'Full combo goals', exact: true })
|
page.getByRole('progressbar', { name: 'Full combo goals', exact: true })
|
||||||
).toHaveAttribute('aria-valuenow', '100');
|
).toHaveAttribute('aria-valuenow', '100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('BO2 armoury fits every gun, enforces caps and advances all rank progress with each level', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const userId = await register(page.request);
|
||||||
|
const cod = '/projects/game-tracker/call-of-duty/black-ops-2';
|
||||||
|
const [m27] =
|
||||||
|
await sql`select i.id from tracker_items i join tracker_games g on g.id=i.game_id where g.slug='black-ops-2' and i.name='M27'`;
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await page.goto(cod);
|
||||||
|
await expect(page.locator('.weapon-card')).toHaveCount(40);
|
||||||
|
for (const [width, height] of [
|
||||||
|
[1280, 720],
|
||||||
|
[1920, 1080]
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
const bounds = await page.locator('.weapon-card').evaluateAll((cards) =>
|
||||||
|
cards.map((c) => {
|
||||||
|
const b = c.getBoundingClientRect();
|
||||||
|
const section = c.closest('section')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
name: c.getAttribute('aria-label'),
|
||||||
|
fits:
|
||||||
|
b.bottom <= section.bottom + 1 &&
|
||||||
|
b.bottom <= innerHeight &&
|
||||||
|
b.right <= innerWidth &&
|
||||||
|
b.height >= 29
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(bounds.filter((b) => !b.fits)).toEqual([]);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBeLessThanOrEqual(
|
||||||
|
height
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator('.weapon-card img')
|
||||||
|
.evaluateAll((images) =>
|
||||||
|
images.every(
|
||||||
|
(i) => (i as HTMLImageElement).complete && (i as HTMLImageElement).naturalWidth > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
for (const name of [
|
||||||
|
'M27',
|
||||||
|
'AN-94',
|
||||||
|
'DSR 50',
|
||||||
|
'Peacekeeper',
|
||||||
|
'MP7',
|
||||||
|
'Vector K10',
|
||||||
|
'Skorpion EVO'
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('article', { name, exact: true })
|
||||||
|
.getByRole('button', { name: `View ${name} artwork` })
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
const card = page.getByRole('article', { name: 'M27', exact: true });
|
||||||
|
const levels = page.getByRole('progressbar', { name: 'Weapon levels', exact: true });
|
||||||
|
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
||||||
|
await expect(levels).toHaveAttribute('aria-valuenow', '0');
|
||||||
|
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||||||
|
await expect(card.getByRole('button', { name: 'Edit M27 level and prestige' })).toHaveText(
|
||||||
|
'LEVEL2/17'
|
||||||
|
);
|
||||||
|
const first = Number(await levels.getAttribute('aria-valuenow'));
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
const before = Number(await overall.getAttribute('aria-valuenow'));
|
||||||
|
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => Number(await levels.getAttribute('aria-valuenow')))
|
||||||
|
.toBeGreaterThan(first);
|
||||||
|
await expect
|
||||||
|
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
||||||
|
.toBeGreaterThan(before);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await action(page.request, cod + '?/saveRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
level: '18',
|
||||||
|
prestige: '2'
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await action(page.request, cod + '?/saveRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
level: '17',
|
||||||
|
prestige: '3'
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await action(page.request, cod + '?/adjustWeaponRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
field: 'prestige',
|
||||||
|
delta: '1'
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(400);
|
||||||
|
await action(page.request, cod + '?/saveRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
level: '17',
|
||||||
|
prestige: '0'
|
||||||
|
});
|
||||||
|
const simultaneous = await Promise.all(
|
||||||
|
[1, 2].map(() =>
|
||||||
|
action(page.request, cod + '?/adjustWeaponRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
field: 'prestige',
|
||||||
|
delta: '1'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(simultaneous.filter((r) => r.status === 200)).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
||||||
|
)[0]
|
||||||
|
).toMatchObject({ level: 1, prestige: 1 });
|
||||||
|
await page.reload();
|
||||||
|
await expect(card).toContainText('18/51');
|
||||||
|
await action(page.request, cod + '?/saveRank', {
|
||||||
|
itemId: String(m27.id),
|
||||||
|
level: '17',
|
||||||
|
prestige: '2'
|
||||||
|
});
|
||||||
|
await action(page.request, cod + '?/saveRank', { level: '42', prestige: '3' });
|
||||||
|
await page.reload();
|
||||||
|
await expect(card).toContainText('51/51');
|
||||||
|
await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
|
||||||
|
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
|
||||||
|
const progress = await overall.getAttribute('aria-valuenow');
|
||||||
|
await page.goto('/projects/game-tracker');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('progressbar', { name: 'Black Ops II progress', exact: true })
|
||||||
|
).toHaveAttribute('aria-valuenow', progress!);
|
||||||
|
await page.goto(cod);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await page.screenshot({ path: '/tmp/bo2-verified-desktop.png' });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.screenshot({ path: '/tmp/bo2-verified-mobile.png', fullPage: true });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||||
|
.toBeLessThanOrEqual(390);
|
||||||
|
});
|
||||||
|
|||||||