From 185295d54eb7617b30034858d0bade530fe6eff3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 16 May 2022 17:52:54 +0300 Subject: [PATCH] User page initial with SQLite3 (#822) * Clean away prisma migrations * Way to migrate WIP * SQLite3 seeding script initial * Fetch tournament data in loader * CheckinActions new loader data model * Virtual banner text color columns * Logged in user * Count teams * ownTeam * Map pool tab fully working * Teams tab * Fix timestamp default * Register page * Manage team page * Camel case checkedInTimestamp * Clean slate * Add .nvmrc * Add favicon * Package lock file version 2 * Update tsconfig * Add Tailwind * Add StrictMode * Add background color * Auth without DB * Revert "Add Tailwind" This reverts commit 204713c6024c1fed1771f1d6d08956aefd4f5599. * Auth with DB * Switch back to tilde absolute import * Import layout * Camel case for database columns * Move auth routes to folder * User popover links working * Import linters * User page initial * User edit page with country * Script to delete db files before migration in dev * Remove "youtubeName" column * Correct avatar size on desktop * Fix SubNav not spanning the whole page * Remove duplicate files * Update README --- .env.example | 18 +- .eslintrc.js | 2 + .github/workflows/main.yml | 2 - .gitignore | 8 +- .nvmrc | 2 +- .prettierignore | 2 +- LICENSE | 2 +- README.md | 41 +- app/components/AddPlayers.tsx | 109 - app/components/Alert.tsx | 25 - app/components/Avatar.tsx | 41 +- app/components/Button.tsx | 2 + app/components/Catcher.tsx | 49 - app/components/Chat/Message.tsx | 35 - app/components/Chat/index.tsx | 92 - app/components/Chat/useChat.ts | 117 - app/components/Combobox.tsx | 58 - app/components/Draggable.tsx | 35 - app/components/FormErrorMessage.tsx | 9 - app/components/FormInfoText.tsx | 5 - app/components/Label.tsx | 10 - app/components/Layout/UserItem.tsx | 48 - app/components/Main.tsx | 5 + app/components/Modal.tsx | 49 - app/components/ModeImage.tsx | 19 - app/components/Navigate.tsx | 11 - app/components/PleaseLogin.tsx | 23 - app/components/SubNav.tsx | 4 +- app/components/SubmitButton.tsx | 51 - app/components/Tab.tsx | 44 - app/components/WeaponImage.tsx | 18 - app/components/icons/ArrowUp.tsx | 2 +- app/components/icons/Twitch.tsx | 14 + app/components/icons/User.tsx | 16 + app/components/icons/YouTube.tsx | 14 + .../{Layout => layout}/DrawingSection.tsx | 2 + .../{Layout => layout}/HamburgerButton.tsx | 1 + app/components/{Layout => layout}/Menu.tsx | 29 +- .../{Layout => layout}/MobileMenu.tsx | 1 + .../{Layout => layout}/SearchInput.tsx | 1 - app/components/layout/UserItem.tsx | 49 + app/components/{Layout => layout}/index.tsx | 13 +- app/components/play/DetailedPlayers.tsx | 50 - app/components/play/FinishedGroup.tsx | 41 - app/components/play/GroupCard.tsx | 209 - app/components/play/GroupMembers.tsx | 105 - app/components/play/LFGGroupSelector.tsx | 128 - app/components/play/LookingInfoText.tsx | 65 - app/components/play/MapList.tsx | 238 - app/components/play/MatchTeams.tsx | 144 - .../play/SplatnetIcon/AutoBombRush.tsx | 244 - app/components/play/SplatnetIcon/Baller.tsx | 38 - .../play/SplatnetIcon/BooyahBomb.tsx | 83 - .../play/SplatnetIcon/BubbleBlower.tsx | 23 - .../play/SplatnetIcon/BurstBombRush.tsx | 91 - .../play/SplatnetIcon/ColorDefs.tsx | 1878 - .../play/SplatnetIcon/CurlingBombRush.tsx | 82 - app/components/play/SplatnetIcon/Deaths.tsx | 29 - app/components/play/SplatnetIcon/InkArmor.tsx | 27 - app/components/play/SplatnetIcon/InkStorm.tsx | 43 - app/components/play/SplatnetIcon/Inkjet.tsx | 64 - app/components/play/SplatnetIcon/Kills.tsx | 32 - .../play/SplatnetIcon/Splashdown.tsx | 55 - .../play/SplatnetIcon/SplatBombRush.tsx | 187 - app/components/play/SplatnetIcon/Stingray.tsx | 64 - .../play/SplatnetIcon/SuctionBombRush.tsx | 184 - .../play/SplatnetIcon/TentaMissiles.tsx | 143 - .../play/SplatnetIcon/UltraStamp.tsx | 179 - app/components/play/SplatnetIcon/index.tsx | 192 - .../tournament/ActionSectionWrapper.tsx | 35 - app/components/tournament/BracketActions.tsx | 111 - app/components/tournament/CheckinActions.tsx | 152 - .../tournament/DuringMatchActions.tsx | 104 - .../tournament/DuringMatchActionsRosters.tsx | 123 - .../tournament/EliminationBracket.tsx | 152 - .../tournament/EliminationBracketMatch.tsx | 103 - .../tournament/FancyStageBanner.tsx | 55 - app/components/tournament/InfoBanner.tsx | 103 - app/components/tournament/TeamRoster.tsx | 123 - .../tournament/TeamRosterInputs.tsx | 131 - .../tournament/TeamRosterInputsCheckboxes.tsx | 51 - app/components/u/SocialLink.tsx | 56 + app/constants.ts | 230 - app/core/DiscordStrategy.server.ts | 136 + app/core/authenticator.server.ts | 10 + app/core/common/permissions.ts | 5 - app/core/lanista.ts | 53 - app/core/mmr/leaderboards.test.ts | 133 - app/core/mmr/leaderboards.ts | 91 - app/core/mmr/utils.test.ts | 174 - app/core/mmr/utils.ts | 266 - app/core/play/mapList.test.ts | 50 - app/core/play/mapList.ts | 116 - app/core/play/playerInfos/data.json | 3898 - .../play/playerInfos/playerInfos.server.ts | 41 - app/core/play/utils.test.ts | 195 - app/core/play/utils.ts | 352 - app/core/play/validators.test.ts | 31 - app/core/play/validators.ts | 102 - app/core/session.server.ts | 14 + app/core/stages/stages.ts | 72 - app/core/tournament/algorithms.test.ts | 177 - app/core/tournament/algorithms.ts | 262 - app/core/tournament/bracket.test.ts | 215 - app/core/tournament/bracket.ts | 520 - app/core/tournament/mapList.test.ts | 96 - app/core/tournament/mapList.ts | 217 - app/core/tournament/permissions.ts | 10 - app/core/tournament/utils.test.ts | 74 - app/core/tournament/utils.ts | 76 - app/core/tournament/validators.ts | 58 - app/db/index.ts | 5 + app/db/models/users.ts | 67 + app/db/sql.ts | 8 + app/db/types.ts | 12 + app/entry.client.tsx | 3 +- app/entry.server.tsx | 2 +- app/hooks/common.ts | 127 - app/hooks/useBracketDataWithEvents.ts | 65 - app/hooks/useOnClickOutside.ts | 22 + app/hooks/useSocketEvent.ts | 16 - app/hooks/useTournamentRounds/index.ts | 136 - app/hooks/useTournamentRounds/types.ts | 55 - app/hooks/useUser.ts | 8 + app/hooks/useWindowSize.ts | 31 + app/models/ChatMessage.server.ts | 29 - app/models/GameDetail.server.ts | 52 - app/models/LFGGroup.server.ts | 391 - app/models/LFGMatch.server.ts | 268 - app/models/Skill.server.ts | 61 - app/models/Tournament.server.ts | 169 - app/models/TournamentBracket.server.ts | 52 - app/models/TournamentMatch.server.ts | 330 - app/models/TournamentTeam.server.ts | 71 - app/models/TournamentTeamMember.server.ts | 39 - app/models/TrustRelationship.server.ts | 42 - app/models/User.server.ts | 39 - app/root.tsx | 144 +- app/routes/auth/callback.tsx | 15 + app/routes/auth/index.tsx | 6 + app/routes/auth/logout.tsx | 6 + app/routes/chat.ts | 82 - app/routes/healthz.ts | 17 - app/routes/index.tsx | 39 +- app/routes/join.tsx | 205 - app/routes/leaderboards.tsx | 132 - app/routes/links.tsx | 3 - app/routes/match-details.ts | 147 - app/routes/play.tsx | 46 - app/routes/play/add-players.tsx | 210 - app/routes/play/history.$id.tsx | 205 - app/routes/play/index.tsx | 214 - app/routes/play/looking.tsx | 481 - app/routes/play/match.$id.tsx | 645 - app/routes/play/rules.tsx | 79 - app/routes/play/settings.tsx | 196 - app/routes/to/$organization.$tournament.tsx | 207 - .../bracket.$bid.tsx | 190 - .../bracket.$bid/match.$num.tsx | 468 - .../to/$organization.$tournament/index.tsx | 15 - .../$organization.$tournament/join-team.tsx | 247 - .../$organization.$tournament/manage-team.tsx | 207 - .../to/$organization.$tournament/manage.tsx | 252 - .../to/$organization.$tournament/map-pool.tsx | 70 - .../to/$organization.$tournament/register.tsx | 161 - .../to/$organization.$tournament/seeds.tsx | 329 - .../to/$organization.$tournament/start.tsx | 418 - .../to/$organization.$tournament/teams.tsx | 34 - app/routes/u.$identifier.tsx | 17 + app/routes/u.$identifier/edit.tsx | 93 + app/routes/u.$identifier/index.tsx | 88 + app/services/bracket.test.ts | 31 - app/services/bracket.ts | 556 - app/services/tournament.ts | 220 - app/styles/common.css | 68 + app/styles/global.css | 543 +- app/styles/join.css | 6 - app/styles/layout.css | 17 +- app/styles/leaderboard.css | 56 - app/styles/play-add-players.css | 12 - app/styles/play-layout.css | 203 - app/styles/play-looking.css | 83 - app/styles/play-match-history.css | 78 - app/styles/play-match.css | 1242 - app/styles/play-settings.css | 58 - app/styles/play.css | 131 - app/styles/tournament-bracket.css | 401 - app/styles/tournament-join-team.css | 6 - app/styles/tournament-manage-team.css | 21 - app/styles/tournament-manage.css | 28 - app/styles/tournament-map-pool.css | 64 - app/styles/tournament-match.css | 39 - app/styles/tournament-register.css | 18 - app/styles/tournament-seeds.css | 86 - app/styles/tournament-start.css | 102 - app/styles/tournament.css | 235 - app/styles/u-edit.css | 10 + app/styles/u.css | 80 + app/utils/assertType.ts | 3 - app/utils/db.server.ts | 24 - app/utils/images.ts | 1 + app/utils/index.ts | 227 - app/utils/redis.server.ts | 40 - app/utils/remix.ts | 48 + app/utils/schemas.ts | 47 - app/utils/socketContext.tsx | 18 - app/utils/sorters.ts | 6 - app/utils/testUtils.ts | 13 - app/utils/types.ts | 5 + app/utils/urls.ts | 73 +- app/utils/zod.ts | 17 + cypress.json | 6 - cypress/fixtures/example.json | 5 - cypress/integration/navigation.spec.ts | 37 - .../tournament-before-start.spec.ts | 47 - .../integration/tournament-check-in.spec.ts | 26 - cypress/plugins/index.cjs | 22 - cypress/support/index.ts | 43 - migrations/000-initial.sql | 12 + migrations/index.mjs | 15 + openskill.d.ts | 13 - package-lock.json | 18375 ++-- package.json | 114 +- prisma/client.ts | 5 - .../20220217204400_initial/migration.sql | 514 - .../migration.sql | 23 - .../migration.sql | 3 - .../20220308221343_add_details/migration.sql | 67 - .../migration.sql | 29 - .../20220320111410_add_user_fc/migration.sql | 2 - .../migration.sql | 15 - .../migration.sql | 5 - .../migration.sql | 11 - prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 389 - prisma/seed/index.ts | 24 - prisma/seed/script.ts | 643 - prisma/seed/users.json | 76690 ---------------- public/favicon.ico | Bin 0 -> 15406 bytes public/img/favicon.ico | Bin 24876 -> 0 bytes public/img/modes/CB.webp | Bin 3378 -> 0 bytes public/img/modes/RM.webp | Bin 5230 -> 0 bytes public/img/modes/SZ.webp | Bin 8582 -> 0 bytes public/img/modes/TC.webp | Bin 4328 -> 0 bytes public/img/modes/TW.webp | Bin 6472 -> 0 bytes public/img/stage-banners/ancho-v-games.png | Bin 444124 -> 0 bytes public/img/stage-banners/arowana-mall.png | Bin 424283 -> 0 bytes .../stage-banners/blackbelly-skatepark.png | Bin 1702605 -> 0 bytes public/img/stage-banners/camp-triggerfish.png | Bin 498121 -> 0 bytes public/img/stage-banners/goby-arena.png | Bin 464794 -> 0 bytes .../img/stage-banners/humpback-pump-track.png | Bin 1634274 -> 0 bytes .../img/stage-banners/inkblot-art-academy.png | Bin 1312485 -> 0 bytes public/img/stage-banners/kelp-dome.png | Bin 534836 -> 0 bytes public/img/stage-banners/makomart.png | Bin 413333 -> 0 bytes public/img/stage-banners/manta-maria.png | Bin 1469176 -> 0 bytes public/img/stage-banners/moray-towers.png | Bin 426398 -> 0 bytes .../img/stage-banners/musselforge-fitness.png | Bin 555657 -> 0 bytes .../img/stage-banners/new-albacore-hotel.png | Bin 501750 -> 0 bytes public/img/stage-banners/piranha-pit.png | Bin 472069 -> 0 bytes public/img/stage-banners/port-mackerel.png | Bin 471910 -> 0 bytes .../stage-banners/shellendorf-institute.png | Bin 467735 -> 0 bytes public/img/stage-banners/skipper-pavilion.png | Bin 407000 -> 0 bytes public/img/stage-banners/snapper-canal.png | Bin 457402 -> 0 bytes .../img/stage-banners/starfish-mainstage.png | Bin 452162 -> 0 bytes .../img/stage-banners/sturgeon-shipyard.png | Bin 384378 -> 0 bytes public/img/stage-banners/the-reef.png | Bin 495993 -> 0 bytes public/img/stage-banners/wahoo-world.png | Bin 583521 -> 0 bytes .../img/stage-banners/walleye-warehouse.png | Bin 559179 -> 0 bytes public/img/stages/ancho-v-games.webp | Bin 24316 -> 0 bytes public/img/stages/arowana-mall.webp | Bin 24348 -> 0 bytes public/img/stages/blackbelly-skatepark.webp | Bin 25056 -> 0 bytes public/img/stages/camp-triggerfish.webp | Bin 25206 -> 0 bytes public/img/stages/goby-arena.webp | Bin 25252 -> 0 bytes public/img/stages/humpback-pump-track.webp | Bin 25604 -> 0 bytes public/img/stages/inkblot-art-academy.webp | Bin 22732 -> 0 bytes public/img/stages/kelp-dome.webp | Bin 34168 -> 0 bytes public/img/stages/makomart.webp | Bin 32164 -> 0 bytes public/img/stages/manta-maria.webp | Bin 24482 -> 0 bytes public/img/stages/moray-towers.webp | Bin 27852 -> 0 bytes public/img/stages/musselforge-fitness.webp | Bin 28132 -> 0 bytes public/img/stages/new-albacore-hotel.webp | Bin 26740 -> 0 bytes public/img/stages/piranha-pit.webp | Bin 20750 -> 0 bytes public/img/stages/port-mackerel.webp | Bin 24232 -> 0 bytes public/img/stages/shellendorf-institute.webp | Bin 24354 -> 0 bytes public/img/stages/skipper-pavilion.webp | Bin 23738 -> 0 bytes public/img/stages/snapper-canal.webp | Bin 25892 -> 0 bytes public/img/stages/starfish-mainstage.webp | Bin 27150 -> 0 bytes public/img/stages/sturgeon-shipyard.webp | Bin 24302 -> 0 bytes public/img/stages/the-reef.webp | Bin 28528 -> 0 bytes public/img/stages/wahoo-world.webp | Bin 25374 -> 0 bytes public/img/stages/walleye-warehouse.webp | Bin 29250 -> 0 bytes public/img/weapons/52 Gal Deco.webp | Bin 3724 -> 0 bytes public/img/weapons/52 Gal.webp | Bin 2356 -> 0 bytes public/img/weapons/96 Gal Deco.webp | Bin 4054 -> 0 bytes public/img/weapons/96 Gal.webp | Bin 2558 -> 0 bytes public/img/weapons/Aerospray MG.webp | Bin 3412 -> 0 bytes public/img/weapons/Aerospray PG.webp | Bin 4624 -> 0 bytes public/img/weapons/Aerospray RG.webp | Bin 4184 -> 0 bytes .../weapons/Ballpoint Splatling Nouveau.webp | Bin 3442 -> 0 bytes public/img/weapons/Ballpoint Splatling.webp | Bin 3102 -> 0 bytes public/img/weapons/Bamboozler 14 Mk I.webp | Bin 2288 -> 0 bytes public/img/weapons/Bamboozler 14 Mk II.webp | Bin 2796 -> 0 bytes public/img/weapons/Bamboozler 14 Mk III.webp | Bin 3748 -> 0 bytes public/img/weapons/Blaster.webp | Bin 2632 -> 0 bytes public/img/weapons/Bloblobber Deco.webp | Bin 3214 -> 0 bytes public/img/weapons/Bloblobber.webp | Bin 2416 -> 0 bytes public/img/weapons/Carbon Roller Deco.webp | Bin 4186 -> 0 bytes public/img/weapons/Carbon Roller.webp | Bin 3506 -> 0 bytes public/img/weapons/Cherry H-3 Nozzlenose.webp | Bin 4002 -> 0 bytes public/img/weapons/Clash Blaster Neo.webp | Bin 3786 -> 0 bytes public/img/weapons/Clash Blaster.webp | Bin 3232 -> 0 bytes public/img/weapons/Classic Squiffer.webp | Bin 2456 -> 0 bytes public/img/weapons/Clear Dapple Dualies.webp | Bin 5370 -> 0 bytes public/img/weapons/Custom Blaster.webp | Bin 3270 -> 0 bytes .../img/weapons/Custom Dualie Squelchers.webp | Bin 3896 -> 0 bytes .../img/weapons/Custom E-liter 4K Scope.webp | Bin 3294 -> 0 bytes public/img/weapons/Custom E-liter 4K.webp | Bin 2976 -> 0 bytes public/img/weapons/Custom Explosher.webp | Bin 4884 -> 0 bytes public/img/weapons/Custom Goo Tuber.webp | Bin 3574 -> 0 bytes .../img/weapons/Custom Hydra Splatling.webp | Bin 4414 -> 0 bytes public/img/weapons/Custom Jet Squelcher.webp | Bin 2886 -> 0 bytes public/img/weapons/Custom Range Blaster.webp | Bin 3872 -> 0 bytes .../img/weapons/Custom Splattershot Jr.webp | Bin 3538 -> 0 bytes .../img/weapons/Dapple Dualies Nouveau.webp | Bin 4798 -> 0 bytes public/img/weapons/Dapple Dualies.webp | Bin 4412 -> 0 bytes public/img/weapons/Dark Tetra Dualies.webp | Bin 4724 -> 0 bytes public/img/weapons/Dualie Squelchers.webp | Bin 3092 -> 0 bytes public/img/weapons/Dynamo Roller.webp | Bin 3164 -> 0 bytes public/img/weapons/E-liter 4K Scope.webp | Bin 2478 -> 0 bytes public/img/weapons/E-liter 4K.webp | Bin 2148 -> 0 bytes public/img/weapons/Enperry Splat Dualies.webp | Bin 4114 -> 0 bytes public/img/weapons/Explosher.webp | Bin 3790 -> 0 bytes public/img/weapons/Firefin Splat Charger.webp | Bin 2480 -> 0 bytes public/img/weapons/Firefin Splatterscope.webp | Bin 2798 -> 0 bytes public/img/weapons/Flingza Roller.webp | Bin 2906 -> 0 bytes public/img/weapons/Foil Flingza Roller.webp | Bin 3564 -> 0 bytes public/img/weapons/Foil Squeezer.webp | Bin 4070 -> 0 bytes .../img/weapons/Forge Splattershot Pro.webp | Bin 2778 -> 0 bytes public/img/weapons/Fresh Squiffer.webp | Bin 3838 -> 0 bytes public/img/weapons/Glooga Dualies Deco.webp | Bin 5556 -> 0 bytes public/img/weapons/Glooga Dualies.webp | Bin 4246 -> 0 bytes public/img/weapons/Gold Dynamo Roller.webp | Bin 3768 -> 0 bytes public/img/weapons/Goo Tuber.webp | Bin 2756 -> 0 bytes public/img/weapons/Grim Range Blaster.webp | Bin 3982 -> 0 bytes public/img/weapons/Grizzco Blaster.webp | Bin 3236 -> 0 bytes public/img/weapons/Grizzco Brella.webp | Bin 4498 -> 0 bytes public/img/weapons/Grizzco Charger.webp | Bin 2802 -> 0 bytes public/img/weapons/Grizzco Slosher.webp | Bin 4414 -> 0 bytes public/img/weapons/H-3 Nozzlenose D.webp | Bin 3458 -> 0 bytes public/img/weapons/H-3 Nozzlenose.webp | Bin 2582 -> 0 bytes public/img/weapons/Heavy Splatling Deco.webp | Bin 3902 -> 0 bytes public/img/weapons/Heavy Splatling Remix.webp | Bin 4082 -> 0 bytes public/img/weapons/Heavy Splatling.webp | Bin 2844 -> 0 bytes public/img/weapons/Hero Blaster Replica.webp | Bin 2370 -> 0 bytes public/img/weapons/Hero Brella Replica.webp | Bin 2960 -> 0 bytes public/img/weapons/Hero Charger Replica.webp | Bin 1682 -> 0 bytes public/img/weapons/Hero Dualie Replicas.webp | Bin 3652 -> 0 bytes public/img/weapons/Hero Roller Replica.webp | Bin 3494 -> 0 bytes public/img/weapons/Hero Shot Replica.webp | Bin 2994 -> 0 bytes public/img/weapons/Hero Slosher Replica.webp | Bin 3038 -> 0 bytes .../img/weapons/Hero Splatling Replica.webp | Bin 3118 -> 0 bytes public/img/weapons/Herobrush Replica.webp | Bin 2092 -> 0 bytes public/img/weapons/Hydra Splatling.webp | Bin 3476 -> 0 bytes public/img/weapons/Inkbrush Nouveau.webp | Bin 2586 -> 0 bytes public/img/weapons/Inkbrush.webp | Bin 2062 -> 0 bytes public/img/weapons/Jet Squelcher.webp | Bin 1902 -> 0 bytes public/img/weapons/Kensa 52 Gal.webp | Bin 3598 -> 0 bytes public/img/weapons/Kensa Charger.webp | Bin 2246 -> 0 bytes public/img/weapons/Kensa Dynamo Roller.webp | Bin 3628 -> 0 bytes public/img/weapons/Kensa Glooga Dualies.webp | Bin 5594 -> 0 bytes public/img/weapons/Kensa L-3 Nozzlenose.webp | Bin 3168 -> 0 bytes public/img/weapons/Kensa Luna Blaster.webp | Bin 2690 -> 0 bytes public/img/weapons/Kensa Mini Splatling.webp | Bin 3604 -> 0 bytes public/img/weapons/Kensa Octobrush.webp | Bin 2410 -> 0 bytes public/img/weapons/Kensa Rapid Blaster.webp | Bin 2998 -> 0 bytes .../img/weapons/Kensa Sloshing Machine.webp | Bin 4030 -> 0 bytes public/img/weapons/Kensa Splat Dualies.webp | Bin 4598 -> 0 bytes public/img/weapons/Kensa Splat Roller.webp | Bin 3118 -> 0 bytes public/img/weapons/Kensa Splatterscope.webp | Bin 2662 -> 0 bytes public/img/weapons/Kensa Splattershot Jr.webp | Bin 3256 -> 0 bytes .../img/weapons/Kensa Splattershot Pro.webp | Bin 2588 -> 0 bytes public/img/weapons/Kensa Splattershot.webp | Bin 3338 -> 0 bytes .../img/weapons/Kensa Undercover Brella.webp | Bin 2530 -> 0 bytes public/img/weapons/Krak-On Splat Roller.webp | Bin 3390 -> 0 bytes public/img/weapons/L-3 Nozzlenose D.webp | Bin 3390 -> 0 bytes public/img/weapons/L-3 Nozzlenose.webp | Bin 2452 -> 0 bytes public/img/weapons/Light Tetra Dualies.webp | Bin 5082 -> 0 bytes public/img/weapons/Luna Blaster Neo.webp | Bin 2204 -> 0 bytes public/img/weapons/Luna Blaster.webp | Bin 1704 -> 0 bytes public/img/weapons/Mini Splatling.webp | Bin 3012 -> 0 bytes public/img/weapons/N-ZAP '83.webp | Bin 2840 -> 0 bytes public/img/weapons/N-ZAP '85.webp | Bin 1348 -> 0 bytes public/img/weapons/N-ZAP '89.webp | Bin 2478 -> 0 bytes public/img/weapons/Nautilus 47.webp | Bin 2186 -> 0 bytes public/img/weapons/Nautilus 79.webp | Bin 2636 -> 0 bytes public/img/weapons/Neo Splash-o-matic.webp | Bin 3232 -> 0 bytes public/img/weapons/Neo Sploosh-o-matic.webp | Bin 3802 -> 0 bytes public/img/weapons/New Squiffer.webp | Bin 3532 -> 0 bytes public/img/weapons/Octo Shot Replica.webp | Bin 2154 -> 0 bytes public/img/weapons/Octobrush Nouveau.webp | Bin 2590 -> 0 bytes public/img/weapons/Octobrush.webp | Bin 1952 -> 0 bytes public/img/weapons/Permanent Inkbrush.webp | Bin 3306 -> 0 bytes public/img/weapons/Range Blaster.webp | Bin 3108 -> 0 bytes public/img/weapons/Rapid Blaster Deco.webp | Bin 3222 -> 0 bytes .../img/weapons/Rapid Blaster Pro Deco.webp | Bin 3832 -> 0 bytes public/img/weapons/Rapid Blaster Pro.webp | Bin 2858 -> 0 bytes public/img/weapons/Rapid Blaster.webp | Bin 2222 -> 0 bytes public/img/weapons/Slosher Deco.webp | Bin 3532 -> 0 bytes public/img/weapons/Slosher.webp | Bin 2752 -> 0 bytes public/img/weapons/Sloshing Machine Neo.webp | Bin 4002 -> 0 bytes public/img/weapons/Sloshing Machine.webp | Bin 3232 -> 0 bytes public/img/weapons/Soda Slosher.webp | Bin 5114 -> 0 bytes public/img/weapons/Sorella Brella.webp | Bin 3030 -> 0 bytes public/img/weapons/Splash-o-matic.webp | Bin 2670 -> 0 bytes public/img/weapons/Splat Brella.webp | Bin 3346 -> 0 bytes public/img/weapons/Splat Charger.webp | Bin 1526 -> 0 bytes public/img/weapons/Splat Dualies.webp | Bin 3386 -> 0 bytes public/img/weapons/Splat Roller.webp | Bin 2528 -> 0 bytes public/img/weapons/Splatterscope.webp | Bin 1910 -> 0 bytes public/img/weapons/Splattershot Jr.webp | Bin 2232 -> 0 bytes public/img/weapons/Splattershot Pro.webp | Bin 2128 -> 0 bytes public/img/weapons/Splattershot.webp | Bin 2730 -> 0 bytes public/img/weapons/Sploosh-o-matic 7.webp | Bin 4342 -> 0 bytes public/img/weapons/Sploosh-o-matic.webp | Bin 3276 -> 0 bytes public/img/weapons/Squeezer.webp | Bin 2986 -> 0 bytes public/img/weapons/Tenta Brella.webp | Bin 3954 -> 0 bytes public/img/weapons/Tenta Camo Brella.webp | Bin 5238 -> 0 bytes public/img/weapons/Tenta Sorella Brella.webp | Bin 4300 -> 0 bytes public/img/weapons/Tentatek Splattershot.webp | Bin 3536 -> 0 bytes public/img/weapons/Tri-Slosher Nouveau.webp | Bin 3628 -> 0 bytes public/img/weapons/Tri-Slosher.webp | Bin 2818 -> 0 bytes public/img/weapons/Undercover Brella.webp | Bin 2440 -> 0 bytes .../weapons/Undercover Sorella Brella.webp | Bin 2552 -> 0 bytes public/img/weapons/Zink Mini Splatling.webp | Bin 3856 -> 0 bytes public/svg/background-pattern.svg | 2 +- remix.config.js | 5 +- scripts/delete-db-files.mjs | 22 + scripts/recalculateSkills.ts | 147 - server/auth.ts | 149 - server/index.ts | 85 - server/mock-auth.ts | 63 - server/seed.ts | 17 - tsconfig.json | 15 +- 443 files changed, 7630 insertions(+), 117366 deletions(-) delete mode 100644 app/components/AddPlayers.tsx delete mode 100644 app/components/Alert.tsx delete mode 100644 app/components/Catcher.tsx delete mode 100644 app/components/Chat/Message.tsx delete mode 100644 app/components/Chat/index.tsx delete mode 100644 app/components/Chat/useChat.ts delete mode 100644 app/components/Combobox.tsx delete mode 100644 app/components/Draggable.tsx delete mode 100644 app/components/FormErrorMessage.tsx delete mode 100644 app/components/FormInfoText.tsx delete mode 100644 app/components/Label.tsx delete mode 100644 app/components/Layout/UserItem.tsx create mode 100644 app/components/Main.tsx delete mode 100644 app/components/Modal.tsx delete mode 100644 app/components/ModeImage.tsx delete mode 100644 app/components/Navigate.tsx delete mode 100644 app/components/PleaseLogin.tsx delete mode 100644 app/components/SubmitButton.tsx delete mode 100644 app/components/Tab.tsx delete mode 100644 app/components/WeaponImage.tsx create mode 100644 app/components/icons/Twitch.tsx create mode 100644 app/components/icons/User.tsx create mode 100644 app/components/icons/YouTube.tsx rename app/components/{Layout => layout}/DrawingSection.tsx (99%) rename app/components/{Layout => layout}/HamburgerButton.tsx (98%) rename app/components/{Layout => layout}/Menu.tsx (77%) rename app/components/{Layout => layout}/MobileMenu.tsx (97%) rename app/components/{Layout => layout}/SearchInput.tsx (98%) create mode 100644 app/components/layout/UserItem.tsx rename app/components/{Layout => layout}/index.tsx (84%) delete mode 100644 app/components/play/DetailedPlayers.tsx delete mode 100644 app/components/play/FinishedGroup.tsx delete mode 100644 app/components/play/GroupCard.tsx delete mode 100644 app/components/play/GroupMembers.tsx delete mode 100644 app/components/play/LFGGroupSelector.tsx delete mode 100644 app/components/play/LookingInfoText.tsx delete mode 100644 app/components/play/MapList.tsx delete mode 100644 app/components/play/MatchTeams.tsx delete mode 100644 app/components/play/SplatnetIcon/AutoBombRush.tsx delete mode 100644 app/components/play/SplatnetIcon/Baller.tsx delete mode 100644 app/components/play/SplatnetIcon/BooyahBomb.tsx delete mode 100644 app/components/play/SplatnetIcon/BubbleBlower.tsx delete mode 100644 app/components/play/SplatnetIcon/BurstBombRush.tsx delete mode 100644 app/components/play/SplatnetIcon/ColorDefs.tsx delete mode 100644 app/components/play/SplatnetIcon/CurlingBombRush.tsx delete mode 100644 app/components/play/SplatnetIcon/Deaths.tsx delete mode 100644 app/components/play/SplatnetIcon/InkArmor.tsx delete mode 100644 app/components/play/SplatnetIcon/InkStorm.tsx delete mode 100644 app/components/play/SplatnetIcon/Inkjet.tsx delete mode 100644 app/components/play/SplatnetIcon/Kills.tsx delete mode 100644 app/components/play/SplatnetIcon/Splashdown.tsx delete mode 100644 app/components/play/SplatnetIcon/SplatBombRush.tsx delete mode 100644 app/components/play/SplatnetIcon/Stingray.tsx delete mode 100644 app/components/play/SplatnetIcon/SuctionBombRush.tsx delete mode 100644 app/components/play/SplatnetIcon/TentaMissiles.tsx delete mode 100644 app/components/play/SplatnetIcon/UltraStamp.tsx delete mode 100644 app/components/play/SplatnetIcon/index.tsx delete mode 100644 app/components/tournament/ActionSectionWrapper.tsx delete mode 100644 app/components/tournament/BracketActions.tsx delete mode 100644 app/components/tournament/CheckinActions.tsx delete mode 100644 app/components/tournament/DuringMatchActions.tsx delete mode 100644 app/components/tournament/DuringMatchActionsRosters.tsx delete mode 100644 app/components/tournament/EliminationBracket.tsx delete mode 100644 app/components/tournament/EliminationBracketMatch.tsx delete mode 100644 app/components/tournament/FancyStageBanner.tsx delete mode 100644 app/components/tournament/InfoBanner.tsx delete mode 100644 app/components/tournament/TeamRoster.tsx delete mode 100644 app/components/tournament/TeamRosterInputs.tsx delete mode 100644 app/components/tournament/TeamRosterInputsCheckboxes.tsx create mode 100644 app/components/u/SocialLink.tsx create mode 100644 app/core/DiscordStrategy.server.ts create mode 100644 app/core/authenticator.server.ts delete mode 100644 app/core/common/permissions.ts delete mode 100644 app/core/lanista.ts delete mode 100644 app/core/mmr/leaderboards.test.ts delete mode 100644 app/core/mmr/leaderboards.ts delete mode 100644 app/core/mmr/utils.test.ts delete mode 100644 app/core/mmr/utils.ts delete mode 100644 app/core/play/mapList.test.ts delete mode 100644 app/core/play/mapList.ts delete mode 100644 app/core/play/playerInfos/data.json delete mode 100644 app/core/play/playerInfos/playerInfos.server.ts delete mode 100644 app/core/play/utils.test.ts delete mode 100644 app/core/play/utils.ts delete mode 100644 app/core/play/validators.test.ts delete mode 100644 app/core/play/validators.ts create mode 100644 app/core/session.server.ts delete mode 100644 app/core/stages/stages.ts delete mode 100644 app/core/tournament/algorithms.test.ts delete mode 100644 app/core/tournament/algorithms.ts delete mode 100644 app/core/tournament/bracket.test.ts delete mode 100644 app/core/tournament/bracket.ts delete mode 100644 app/core/tournament/mapList.test.ts delete mode 100644 app/core/tournament/mapList.ts delete mode 100644 app/core/tournament/permissions.ts delete mode 100644 app/core/tournament/utils.test.ts delete mode 100644 app/core/tournament/utils.ts delete mode 100644 app/core/tournament/validators.ts create mode 100644 app/db/index.ts create mode 100644 app/db/models/users.ts create mode 100644 app/db/sql.ts create mode 100644 app/db/types.ts delete mode 100644 app/hooks/common.ts delete mode 100644 app/hooks/useBracketDataWithEvents.ts create mode 100644 app/hooks/useOnClickOutside.ts delete mode 100644 app/hooks/useSocketEvent.ts delete mode 100644 app/hooks/useTournamentRounds/index.ts delete mode 100644 app/hooks/useTournamentRounds/types.ts create mode 100644 app/hooks/useUser.ts create mode 100644 app/hooks/useWindowSize.ts delete mode 100644 app/models/ChatMessage.server.ts delete mode 100644 app/models/GameDetail.server.ts delete mode 100644 app/models/LFGGroup.server.ts delete mode 100644 app/models/LFGMatch.server.ts delete mode 100644 app/models/Skill.server.ts delete mode 100644 app/models/Tournament.server.ts delete mode 100644 app/models/TournamentBracket.server.ts delete mode 100644 app/models/TournamentMatch.server.ts delete mode 100644 app/models/TournamentTeam.server.ts delete mode 100644 app/models/TournamentTeamMember.server.ts delete mode 100644 app/models/TrustRelationship.server.ts delete mode 100644 app/models/User.server.ts create mode 100644 app/routes/auth/callback.tsx create mode 100644 app/routes/auth/index.tsx create mode 100644 app/routes/auth/logout.tsx delete mode 100644 app/routes/chat.ts delete mode 100644 app/routes/healthz.ts delete mode 100644 app/routes/join.tsx delete mode 100644 app/routes/leaderboards.tsx delete mode 100644 app/routes/links.tsx delete mode 100644 app/routes/match-details.ts delete mode 100644 app/routes/play.tsx delete mode 100644 app/routes/play/add-players.tsx delete mode 100644 app/routes/play/history.$id.tsx delete mode 100644 app/routes/play/index.tsx delete mode 100644 app/routes/play/looking.tsx delete mode 100644 app/routes/play/match.$id.tsx delete mode 100644 app/routes/play/rules.tsx delete mode 100644 app/routes/play/settings.tsx delete mode 100644 app/routes/to/$organization.$tournament.tsx delete mode 100644 app/routes/to/$organization.$tournament/bracket.$bid.tsx delete mode 100644 app/routes/to/$organization.$tournament/bracket.$bid/match.$num.tsx delete mode 100644 app/routes/to/$organization.$tournament/index.tsx delete mode 100644 app/routes/to/$organization.$tournament/join-team.tsx delete mode 100644 app/routes/to/$organization.$tournament/manage-team.tsx delete mode 100644 app/routes/to/$organization.$tournament/manage.tsx delete mode 100644 app/routes/to/$organization.$tournament/map-pool.tsx delete mode 100644 app/routes/to/$organization.$tournament/register.tsx delete mode 100644 app/routes/to/$organization.$tournament/seeds.tsx delete mode 100644 app/routes/to/$organization.$tournament/start.tsx delete mode 100644 app/routes/to/$organization.$tournament/teams.tsx create mode 100644 app/routes/u.$identifier.tsx create mode 100644 app/routes/u.$identifier/edit.tsx create mode 100644 app/routes/u.$identifier/index.tsx delete mode 100644 app/services/bracket.test.ts delete mode 100644 app/services/bracket.ts delete mode 100644 app/services/tournament.ts create mode 100644 app/styles/common.css delete mode 100644 app/styles/join.css delete mode 100644 app/styles/leaderboard.css delete mode 100644 app/styles/play-add-players.css delete mode 100644 app/styles/play-layout.css delete mode 100644 app/styles/play-looking.css delete mode 100644 app/styles/play-match-history.css delete mode 100644 app/styles/play-match.css delete mode 100644 app/styles/play-settings.css delete mode 100644 app/styles/play.css delete mode 100644 app/styles/tournament-bracket.css delete mode 100644 app/styles/tournament-join-team.css delete mode 100644 app/styles/tournament-manage-team.css delete mode 100644 app/styles/tournament-manage.css delete mode 100644 app/styles/tournament-map-pool.css delete mode 100644 app/styles/tournament-match.css delete mode 100644 app/styles/tournament-register.css delete mode 100644 app/styles/tournament-seeds.css delete mode 100644 app/styles/tournament-start.css delete mode 100644 app/styles/tournament.css create mode 100644 app/styles/u-edit.css create mode 100644 app/styles/u.css delete mode 100644 app/utils/assertType.ts delete mode 100644 app/utils/db.server.ts create mode 100644 app/utils/images.ts delete mode 100644 app/utils/index.ts delete mode 100644 app/utils/redis.server.ts create mode 100644 app/utils/remix.ts delete mode 100644 app/utils/schemas.ts delete mode 100644 app/utils/socketContext.tsx delete mode 100644 app/utils/sorters.ts delete mode 100644 app/utils/testUtils.ts create mode 100644 app/utils/types.ts create mode 100644 app/utils/zod.ts delete mode 100644 cypress.json delete mode 100644 cypress/fixtures/example.json delete mode 100644 cypress/integration/navigation.spec.ts delete mode 100644 cypress/integration/tournament-before-start.spec.ts delete mode 100644 cypress/integration/tournament-check-in.spec.ts delete mode 100644 cypress/plugins/index.cjs delete mode 100644 cypress/support/index.ts create mode 100644 migrations/000-initial.sql create mode 100644 migrations/index.mjs delete mode 100644 openskill.d.ts delete mode 100644 prisma/client.ts delete mode 100644 prisma/migrations/20220217204400_initial/migration.sql delete mode 100644 prisma/migrations/20220225161008_add_lfg_group_status/migration.sql delete mode 100644 prisma/migrations/20220305103335_add_mini_bio_weapons/migration.sql delete mode 100644 prisma/migrations/20220308221343_add_details/migration.sql delete mode 100644 prisma/migrations/20220317164944_add_chat_messages/migration.sql delete mode 100644 prisma/migrations/20220320111410_add_user_fc/migration.sql delete mode 100644 prisma/migrations/20220327142556_clean_up_tournament_team/migration.sql delete mode 100644 prisma/migrations/20220409090909_tournament_match_add_number/migration.sql delete mode 100644 prisma/migrations/20220410094657_conclude_canceled/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 prisma/schema.prisma delete mode 100644 prisma/seed/index.ts delete mode 100644 prisma/seed/script.ts delete mode 100644 prisma/seed/users.json create mode 100644 public/favicon.ico delete mode 100644 public/img/favicon.ico delete mode 100644 public/img/modes/CB.webp delete mode 100644 public/img/modes/RM.webp delete mode 100644 public/img/modes/SZ.webp delete mode 100644 public/img/modes/TC.webp delete mode 100644 public/img/modes/TW.webp delete mode 100644 public/img/stage-banners/ancho-v-games.png delete mode 100644 public/img/stage-banners/arowana-mall.png delete mode 100644 public/img/stage-banners/blackbelly-skatepark.png delete mode 100644 public/img/stage-banners/camp-triggerfish.png delete mode 100644 public/img/stage-banners/goby-arena.png delete mode 100644 public/img/stage-banners/humpback-pump-track.png delete mode 100644 public/img/stage-banners/inkblot-art-academy.png delete mode 100644 public/img/stage-banners/kelp-dome.png delete mode 100644 public/img/stage-banners/makomart.png delete mode 100644 public/img/stage-banners/manta-maria.png delete mode 100644 public/img/stage-banners/moray-towers.png delete mode 100644 public/img/stage-banners/musselforge-fitness.png delete mode 100644 public/img/stage-banners/new-albacore-hotel.png delete mode 100644 public/img/stage-banners/piranha-pit.png delete mode 100644 public/img/stage-banners/port-mackerel.png delete mode 100644 public/img/stage-banners/shellendorf-institute.png delete mode 100644 public/img/stage-banners/skipper-pavilion.png delete mode 100644 public/img/stage-banners/snapper-canal.png delete mode 100644 public/img/stage-banners/starfish-mainstage.png delete mode 100644 public/img/stage-banners/sturgeon-shipyard.png delete mode 100644 public/img/stage-banners/the-reef.png delete mode 100644 public/img/stage-banners/wahoo-world.png delete mode 100644 public/img/stage-banners/walleye-warehouse.png delete mode 100644 public/img/stages/ancho-v-games.webp delete mode 100644 public/img/stages/arowana-mall.webp delete mode 100644 public/img/stages/blackbelly-skatepark.webp delete mode 100644 public/img/stages/camp-triggerfish.webp delete mode 100644 public/img/stages/goby-arena.webp delete mode 100644 public/img/stages/humpback-pump-track.webp delete mode 100644 public/img/stages/inkblot-art-academy.webp delete mode 100644 public/img/stages/kelp-dome.webp delete mode 100644 public/img/stages/makomart.webp delete mode 100644 public/img/stages/manta-maria.webp delete mode 100644 public/img/stages/moray-towers.webp delete mode 100644 public/img/stages/musselforge-fitness.webp delete mode 100644 public/img/stages/new-albacore-hotel.webp delete mode 100644 public/img/stages/piranha-pit.webp delete mode 100644 public/img/stages/port-mackerel.webp delete mode 100644 public/img/stages/shellendorf-institute.webp delete mode 100644 public/img/stages/skipper-pavilion.webp delete mode 100644 public/img/stages/snapper-canal.webp delete mode 100644 public/img/stages/starfish-mainstage.webp delete mode 100644 public/img/stages/sturgeon-shipyard.webp delete mode 100644 public/img/stages/the-reef.webp delete mode 100644 public/img/stages/wahoo-world.webp delete mode 100644 public/img/stages/walleye-warehouse.webp delete mode 100644 public/img/weapons/52 Gal Deco.webp delete mode 100644 public/img/weapons/52 Gal.webp delete mode 100644 public/img/weapons/96 Gal Deco.webp delete mode 100644 public/img/weapons/96 Gal.webp delete mode 100644 public/img/weapons/Aerospray MG.webp delete mode 100644 public/img/weapons/Aerospray PG.webp delete mode 100644 public/img/weapons/Aerospray RG.webp delete mode 100644 public/img/weapons/Ballpoint Splatling Nouveau.webp delete mode 100644 public/img/weapons/Ballpoint Splatling.webp delete mode 100644 public/img/weapons/Bamboozler 14 Mk I.webp delete mode 100644 public/img/weapons/Bamboozler 14 Mk II.webp delete mode 100644 public/img/weapons/Bamboozler 14 Mk III.webp delete mode 100644 public/img/weapons/Blaster.webp delete mode 100644 public/img/weapons/Bloblobber Deco.webp delete mode 100644 public/img/weapons/Bloblobber.webp delete mode 100644 public/img/weapons/Carbon Roller Deco.webp delete mode 100644 public/img/weapons/Carbon Roller.webp delete mode 100644 public/img/weapons/Cherry H-3 Nozzlenose.webp delete mode 100644 public/img/weapons/Clash Blaster Neo.webp delete mode 100644 public/img/weapons/Clash Blaster.webp delete mode 100644 public/img/weapons/Classic Squiffer.webp delete mode 100644 public/img/weapons/Clear Dapple Dualies.webp delete mode 100644 public/img/weapons/Custom Blaster.webp delete mode 100644 public/img/weapons/Custom Dualie Squelchers.webp delete mode 100644 public/img/weapons/Custom E-liter 4K Scope.webp delete mode 100644 public/img/weapons/Custom E-liter 4K.webp delete mode 100644 public/img/weapons/Custom Explosher.webp delete mode 100644 public/img/weapons/Custom Goo Tuber.webp delete mode 100644 public/img/weapons/Custom Hydra Splatling.webp delete mode 100644 public/img/weapons/Custom Jet Squelcher.webp delete mode 100644 public/img/weapons/Custom Range Blaster.webp delete mode 100644 public/img/weapons/Custom Splattershot Jr.webp delete mode 100644 public/img/weapons/Dapple Dualies Nouveau.webp delete mode 100644 public/img/weapons/Dapple Dualies.webp delete mode 100644 public/img/weapons/Dark Tetra Dualies.webp delete mode 100644 public/img/weapons/Dualie Squelchers.webp delete mode 100644 public/img/weapons/Dynamo Roller.webp delete mode 100644 public/img/weapons/E-liter 4K Scope.webp delete mode 100644 public/img/weapons/E-liter 4K.webp delete mode 100644 public/img/weapons/Enperry Splat Dualies.webp delete mode 100644 public/img/weapons/Explosher.webp delete mode 100644 public/img/weapons/Firefin Splat Charger.webp delete mode 100644 public/img/weapons/Firefin Splatterscope.webp delete mode 100644 public/img/weapons/Flingza Roller.webp delete mode 100644 public/img/weapons/Foil Flingza Roller.webp delete mode 100644 public/img/weapons/Foil Squeezer.webp delete mode 100644 public/img/weapons/Forge Splattershot Pro.webp delete mode 100644 public/img/weapons/Fresh Squiffer.webp delete mode 100644 public/img/weapons/Glooga Dualies Deco.webp delete mode 100644 public/img/weapons/Glooga Dualies.webp delete mode 100644 public/img/weapons/Gold Dynamo Roller.webp delete mode 100644 public/img/weapons/Goo Tuber.webp delete mode 100644 public/img/weapons/Grim Range Blaster.webp delete mode 100644 public/img/weapons/Grizzco Blaster.webp delete mode 100644 public/img/weapons/Grizzco Brella.webp delete mode 100644 public/img/weapons/Grizzco Charger.webp delete mode 100644 public/img/weapons/Grizzco Slosher.webp delete mode 100644 public/img/weapons/H-3 Nozzlenose D.webp delete mode 100644 public/img/weapons/H-3 Nozzlenose.webp delete mode 100644 public/img/weapons/Heavy Splatling Deco.webp delete mode 100644 public/img/weapons/Heavy Splatling Remix.webp delete mode 100644 public/img/weapons/Heavy Splatling.webp delete mode 100644 public/img/weapons/Hero Blaster Replica.webp delete mode 100644 public/img/weapons/Hero Brella Replica.webp delete mode 100644 public/img/weapons/Hero Charger Replica.webp delete mode 100644 public/img/weapons/Hero Dualie Replicas.webp delete mode 100644 public/img/weapons/Hero Roller Replica.webp delete mode 100644 public/img/weapons/Hero Shot Replica.webp delete mode 100644 public/img/weapons/Hero Slosher Replica.webp delete mode 100644 public/img/weapons/Hero Splatling Replica.webp delete mode 100644 public/img/weapons/Herobrush Replica.webp delete mode 100644 public/img/weapons/Hydra Splatling.webp delete mode 100644 public/img/weapons/Inkbrush Nouveau.webp delete mode 100644 public/img/weapons/Inkbrush.webp delete mode 100644 public/img/weapons/Jet Squelcher.webp delete mode 100644 public/img/weapons/Kensa 52 Gal.webp delete mode 100644 public/img/weapons/Kensa Charger.webp delete mode 100644 public/img/weapons/Kensa Dynamo Roller.webp delete mode 100644 public/img/weapons/Kensa Glooga Dualies.webp delete mode 100644 public/img/weapons/Kensa L-3 Nozzlenose.webp delete mode 100644 public/img/weapons/Kensa Luna Blaster.webp delete mode 100644 public/img/weapons/Kensa Mini Splatling.webp delete mode 100644 public/img/weapons/Kensa Octobrush.webp delete mode 100644 public/img/weapons/Kensa Rapid Blaster.webp delete mode 100644 public/img/weapons/Kensa Sloshing Machine.webp delete mode 100644 public/img/weapons/Kensa Splat Dualies.webp delete mode 100644 public/img/weapons/Kensa Splat Roller.webp delete mode 100644 public/img/weapons/Kensa Splatterscope.webp delete mode 100644 public/img/weapons/Kensa Splattershot Jr.webp delete mode 100644 public/img/weapons/Kensa Splattershot Pro.webp delete mode 100644 public/img/weapons/Kensa Splattershot.webp delete mode 100644 public/img/weapons/Kensa Undercover Brella.webp delete mode 100644 public/img/weapons/Krak-On Splat Roller.webp delete mode 100644 public/img/weapons/L-3 Nozzlenose D.webp delete mode 100644 public/img/weapons/L-3 Nozzlenose.webp delete mode 100644 public/img/weapons/Light Tetra Dualies.webp delete mode 100644 public/img/weapons/Luna Blaster Neo.webp delete mode 100644 public/img/weapons/Luna Blaster.webp delete mode 100644 public/img/weapons/Mini Splatling.webp delete mode 100644 public/img/weapons/N-ZAP '83.webp delete mode 100644 public/img/weapons/N-ZAP '85.webp delete mode 100644 public/img/weapons/N-ZAP '89.webp delete mode 100644 public/img/weapons/Nautilus 47.webp delete mode 100644 public/img/weapons/Nautilus 79.webp delete mode 100644 public/img/weapons/Neo Splash-o-matic.webp delete mode 100644 public/img/weapons/Neo Sploosh-o-matic.webp delete mode 100644 public/img/weapons/New Squiffer.webp delete mode 100644 public/img/weapons/Octo Shot Replica.webp delete mode 100644 public/img/weapons/Octobrush Nouveau.webp delete mode 100644 public/img/weapons/Octobrush.webp delete mode 100644 public/img/weapons/Permanent Inkbrush.webp delete mode 100644 public/img/weapons/Range Blaster.webp delete mode 100644 public/img/weapons/Rapid Blaster Deco.webp delete mode 100644 public/img/weapons/Rapid Blaster Pro Deco.webp delete mode 100644 public/img/weapons/Rapid Blaster Pro.webp delete mode 100644 public/img/weapons/Rapid Blaster.webp delete mode 100644 public/img/weapons/Slosher Deco.webp delete mode 100644 public/img/weapons/Slosher.webp delete mode 100644 public/img/weapons/Sloshing Machine Neo.webp delete mode 100644 public/img/weapons/Sloshing Machine.webp delete mode 100644 public/img/weapons/Soda Slosher.webp delete mode 100644 public/img/weapons/Sorella Brella.webp delete mode 100644 public/img/weapons/Splash-o-matic.webp delete mode 100644 public/img/weapons/Splat Brella.webp delete mode 100644 public/img/weapons/Splat Charger.webp delete mode 100644 public/img/weapons/Splat Dualies.webp delete mode 100644 public/img/weapons/Splat Roller.webp delete mode 100644 public/img/weapons/Splatterscope.webp delete mode 100644 public/img/weapons/Splattershot Jr.webp delete mode 100644 public/img/weapons/Splattershot Pro.webp delete mode 100644 public/img/weapons/Splattershot.webp delete mode 100644 public/img/weapons/Sploosh-o-matic 7.webp delete mode 100644 public/img/weapons/Sploosh-o-matic.webp delete mode 100644 public/img/weapons/Squeezer.webp delete mode 100644 public/img/weapons/Tenta Brella.webp delete mode 100644 public/img/weapons/Tenta Camo Brella.webp delete mode 100644 public/img/weapons/Tenta Sorella Brella.webp delete mode 100644 public/img/weapons/Tentatek Splattershot.webp delete mode 100644 public/img/weapons/Tri-Slosher Nouveau.webp delete mode 100644 public/img/weapons/Tri-Slosher.webp delete mode 100644 public/img/weapons/Undercover Brella.webp delete mode 100644 public/img/weapons/Undercover Sorella Brella.webp delete mode 100644 public/img/weapons/Zink Mini Splatling.webp create mode 100644 scripts/delete-db-files.mjs delete mode 100644 scripts/recalculateSkills.ts delete mode 100644 server/auth.ts delete mode 100644 server/index.ts delete mode 100644 server/mock-auth.ts delete mode 100644 server/seed.ts diff --git a/.env.example b/.env.example index 99737ecd0..80ab9ab4f 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,7 @@ -// replace with your own PostgreSQL database connection string -DATABASE_URL=postgresql://sendou@localhost:5432/sendou_ink?schema=public +PORT=5800 +BASE_URL=http://localhost:5800 -// uncomment below if you have Redis running -// REDIS_URL=redis://localhost:6379 - -// these are needed for logging in. -// you can get them by making an application on https://discord.com/developers +// auth +SESSION_SECRET=secret DISCORD_CLIENT_ID= -DISCORD_CLIENT_SECRET= -COOKIE_SECRET= -FRONT_PAGE_URL=http://localhost:3000/ -LANISTA_TOKEN=lanista -LANISTA_URL= -LANISTA_URL_TOKEN= \ No newline at end of file +DISCORD_CLIENT_SECRET= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 123a53485..39e955a74 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,8 @@ module.exports = { "plugin:react/recommended", "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", ], rules: { "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e880b1854..a004a6579 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,5 @@ jobs: run: npm run lint:ts - name: Stylelint run: npm run lint:styles - - name: Unit tests - run: npm run test:unit - name: Typecheck run: npm run typecheck diff --git a/.gitignore b/.gitignore index ae6fe14fe..2beb96a84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ node_modules -.env /.cache -/server/build -/public/build /build -backup.sql \ No newline at end of file +/public/build +.env + +db.sqlite3* \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 6276cf12f..7fd023741 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.2 +v16.15.0 diff --git a/.prettierignore b/.prettierignore index 378eac25d..c795b054e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -build +build \ No newline at end of file diff --git a/LICENSE b/LICENSE index f288702d2..e72bfddab 100644 --- a/LICENSE +++ b/LICENSE @@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. +. \ No newline at end of file diff --git a/README.md b/README.md index 254a6b8eb..1e7a7305e 100644 --- a/README.md +++ b/README.md @@ -4,45 +4,28 @@ Note: This is the WIP Splatoon 3 version of the site. To see the current live ve Prerequisites: [nvm](https://github.com/nvm-sh/nvm) -1. Use `nvm use` to switch to the correct Node version. -2. Run `npm i` to install the dependencies. +There is a sequence of commands you need to run: + +1. `nvm use` to switch to the correct Node version. +2. `npm i` to install the dependencies. 3. Make a copy of `.env.example` that's called `.env` and fill it with values. +4. `npm run migrate` to set up the database tables. +5. `npm run dev` to run both the server and frontend. -- You can check [Prisma's guide](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database) on how to get PostgreSQL 14 set up and running locally. -- Run `npm run migration:apply:dev` to set up the tables of your database. -- Run `npm run seed` to seed the database with some test data. - -4. Run `npm run dev` to run both the server and frontend. - -## File structure +## Project structure ``` sendou.ink/ ├── app/ -│ ├── components/ -- Components shared between many routes +│ ├── components/ -- React components │ ├── core/ -- Core business logic +│ ├── db/ -- Database layer │ ├── hooks/ -- React hooks -│ ├── models/ -- Calls to database │ ├── routes/ -- Routes see: https://remix.run/docs/en/v1/guides/routing │ ├── styles/ -- All .css files of the project for styling -│ ├── utils/ -- Random helper functions used in many places -│ └── constants.ts -- Global constants of the projects +│ └── utils/ -- Random helper functions used in many places +├── migrations/ -- Database migrations ├── cypress/ -- see: https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Folder-structure -├── prisma/ -- Prisma related files -│ ├── migrations/ -- Database migrations via Prisma Migrate -│ ├── seed/ -- Seeding logic for tests and development -│ ├── client.ts -- Global import of the Prisma object -│ └── schema.prisma -- Database table schema ├── public/ -- Images, built assets etc. static files to be served as is -└── server/ -- Express.js server-side logic that is not handled in Remix e.g. auth +└── scripts/ -- Stand-alone scripts to be run outside of the app ``` - -## Seeding script variations - -You can give a variation as a flag to the seeding script changing what exactly is put in the database. For example `npm run seed -- -v=check-in` seeds the database with a variation where check-in is in progress. - -## Commands - -### Convert .png to .webp - -`cwebp -q 80 image.png -o image.webp` diff --git a/app/components/AddPlayers.tsx b/app/components/AddPlayers.tsx deleted file mode 100644 index 6481a06b5..000000000 --- a/app/components/AddPlayers.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Form } from "@remix-run/react"; -import { useBaseURL, useTimeoutState } from "~/hooks/common"; -import { FindManyByTrustReceiverId } from "~/models/TrustRelationship.server"; -import { Button } from "./Button"; -import { FormErrorMessage } from "./FormErrorMessage"; -import { Label } from "./Label"; -import { SubmitButton } from "./SubmitButton"; - -export function AddPlayers({ - pathname, - inviteCode, - addUserError, - trustingUsers, - hiddenInputs, - tinyButtons = false, - legendText, -}: { - pathname: string; - inviteCode: string; - addUserError?: string; - trustingUsers: FindManyByTrustReceiverId; - hiddenInputs: { name: string; value: string }[]; - tinyButtons?: boolean; - legendText: string; -}) { - const baseURL = useBaseURL(); - const urlWithInviteCode = `${baseURL}${pathname}?code=${inviteCode}`; - - return ( -
- {legendText} -
- - - -
- {trustingUsers.length > 0 && ( -
-
- {hiddenInputs.map((input) => ( - - ))} - - - - - Add - - -
- )} -
- ); -} - -function CopyToClipboardButton({ - urlWithInviteCode, - tiny, -}: { - urlWithInviteCode: string; - tiny: boolean; -}) { - const [showCopied, setShowCopied] = useTimeoutState(false); - - return ( - - ); -} diff --git a/app/components/Alert.tsx b/app/components/Alert.tsx deleted file mode 100644 index cd44790e7..000000000 --- a/app/components/Alert.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import clsx from "clsx"; -import { AlertIcon } from "./icons/Alert"; -import { SuccessIcon } from "./icons/Success"; - -// TODO: should flex-dir column on mobile -export function Alert(props: { - children: React.ReactNode; - type: "warning" | "info" | "success"; - className?: string; - rightAction?: React.ReactNode; - "data-cy"?: string; -}) { - return ( -
- {(props.type === "warning" || props.type === "info") && } - {props.type === "success" && } - {props.children} - {props.rightAction} -
- ); -} diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 055bfcc28..d98aa5527 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -1,27 +1,30 @@ import clsx from "clsx"; -import { MyCSSProperties } from "~/utils"; +import type { User } from "~/db/types"; export function Avatar({ - user, + discordId, + discordAvatar, size, -}: { - user: { discordId: string; discordAvatar: string | null }; - size?: "tiny" | "mini"; + className, +}: Pick & { + className?: string; + size?: "lg"; }) { - const style: MyCSSProperties = { - "--_avatar-size": - size === "tiny" ? "2rem" : size === "mini" ? "1.5rem" : undefined, - }; + // TODO: just show text... my profile? + // TODO: also show this if discordAvatar is stale and 404's + if (!discordAvatar) return
; + + const dimensions = size === "lg" ? 125 : 44; + return ( -
- {user.discordAvatar && ( - - )} -
+ My avatar ); } diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 7842d582a..31f543903 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -27,6 +27,7 @@ export function Button(props: ButtonProps) { tiny, className, icon, + type = "button", ...rest } = props; return ( @@ -44,6 +45,7 @@ export function Button(props: ButtonProps) { tiny, })} disabled={props.disabled || loading} + type={type} {...rest} > {icon && React.cloneElement(icon, { className: "button-icon" })} diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx deleted file mode 100644 index dd0dbe749..000000000 --- a/app/components/Catcher.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useCatch, useLocation } from "@remix-run/react"; -import { getLogInUrl } from "~/utils"; -import { useUser } from "~/hooks/common"; -import { Button } from "./Button"; -import { discordUrl } from "~/utils/urls"; - -// TODO: some nice art -export function Catcher() { - const caught = useCatch(); - const user = useUser(); - const location = useLocation(); - - switch (caught.status) { - case 401: - return ( -
-

401 Unauthorized

- {user ? ( -

- If you need assistance you can ask for help on{" "} - - our Discord - -

- ) : ( -
-

- You should try{" "} - -

-
- )} -
- ); - // case 404: - // message = ( - //

Oops! Looks like you tried to visit a page that does not exist.

- // ); - // break; - - default: - console.error(caught); - throw new Error( - `${caught.status} - ${caught.statusText},${String(caught.data).trim()}` - ); - } -} diff --git a/app/components/Chat/Message.tsx b/app/components/Chat/Message.tsx deleted file mode 100644 index 37981f2a4..000000000 --- a/app/components/Chat/Message.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import clsx from "clsx"; -import { ChatLoaderData } from "~/routes/chat"; -import { Unpacked, ValueOf } from "~/utils"; -import { ChatProps } from "."; - -export function Message({ - data, - sending, - user, -}: { - data: Omit, "roomId" | "id">; - sending?: boolean; - user: ValueOf; -}) { - return ( -
  • -
    -
    {user.name}
    - {user.info && ( -
    {user.info}
    - )} -
    - -
    - - {data.content} -
    -
  • - ); -} diff --git a/app/components/Chat/index.tsx b/app/components/Chat/index.tsx deleted file mode 100644 index a6c201e38..000000000 --- a/app/components/Chat/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { MAX_CHAT_MESSAGE_LENGTH } from "~/constants"; -import { useUser } from "~/hooks/common"; -import { chatRoute } from "~/utils/urls"; -import { Button } from "../Button"; -import { CrossIcon } from "../icons/Cross"; -import { SpeechBubbleIcon } from "../icons/SpeechBubble"; -import { Message } from "./Message"; -import useChat from "./useChat"; - -export interface ChatProps { - id: string; - users: { [id: string]: { info?: string | null; name: string } }; -} - -export function Chat({ id, users }: ChatProps) { - const user = useUser(); - const { - messages, - sentMessage, - containerRef, - formRef, - inputRef, - actionFetcher, - isOpen, - toggleOpen, - unreadCount, - } = useChat(id); - - if (!user || !messages) return null; - - return ( - <> - {isOpen && ( -
    -
      - {messages - ?.filter((message) => users[message.sender.id]) - .map((message) => ( - - ))} - {sentMessage && users[user.id] && ( - - )} -
    - - - - - -
    - )} - - {unreadCount > 0 && ( -
    {unreadCount}
    - )} - - ); -} diff --git a/app/components/Chat/useChat.ts b/app/components/Chat/useChat.ts deleted file mode 100644 index b1b21c626..000000000 --- a/app/components/Chat/useChat.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { useFetcher } from "@remix-run/react"; -import invariant from "tiny-invariant"; -import { useUser } from "~/hooks/common"; -import { useSocketEvent } from "~/hooks/useSocketEvent"; -import { ChatActionData, ChatLoaderData } from "~/routes/chat"; -import { Unpacked } from "~/utils"; -import { chatRoute } from "~/utils/urls"; - -export default function useChat(id: string) { - const [isOpen, setIsOpen] = React.useState(false); - const [messagesAfterLoad, setMessagesAfterLoad] = React.useState< - ChatLoaderData["messages"] - >([]); - const [unreadCount, setUnreadCount] = React.useState(0); - const loaderFetcher = useFetcher(); - const actionFetcher = useFetcher(); - const containerRef = React.useRef(null); - const formRef = React.useRef(null); - const inputRef = React.useRef(null); - - const user = useUser(); - invariant(user, "!user"); - - const eventHandler = React.useCallback( - (data: Unpacked) => { - if (data.sender.id === user.id) return; - setMessagesAfterLoad((messages) => [...messages, data]); - if (!isOpen) { - setUnreadCount((count) => count + 1); - } - }, - [isOpen, user.id] - ); - - useSocketEvent(`chat-${id}`, eventHandler); - - React.useEffect(() => { - loaderFetcher.load(chatRoute([id])); - }, [id]); - - // open chat on data load if there are messages - React.useEffect(() => { - if (!loaderFetcher.data) return; - - if (loaderFetcher.data.messages.length > 0) { - setIsOpen(true); - } - }, [loaderFetcher.data]); - - // after sending message reset and refocus input so user can keep typing - React.useEffect(() => { - if (actionFetcher.submission) { - formRef.current?.reset(); - inputRef.current?.focus(); - } - }, [actionFetcher.submission]); - - React.useEffect(() => { - if (!actionFetcher.data) return; - - setMessagesAfterLoad((messagesAfterLoad) => { - if (!actionFetcher.data) return [...messagesAfterLoad]; - return [...messagesAfterLoad, actionFetcher.data.createdMessage]; - }); - }, [actionFetcher.data]); - - const messages = React.useMemo( - () => - loaderFetcher.data - ? [...loaderFetcher.data.messages, ...messagesAfterLoad].sort( - (a, b) => a.createdAtTimestamp - b.createdAtTimestamp - ) - : undefined, - [loaderFetcher.data, messagesAfterLoad] - ); - - const sentMessage = React.useMemo(() => { - const newMessageContent = actionFetcher.submission?.formData.get("message"); - if ( - typeof newMessageContent !== "string" || - actionFetcher.state !== "submitting" - ) { - return; - } - - return newMessageContent; - }, [actionFetcher]); - - const toggleOpen = React.useCallback(() => { - setIsOpen((open) => { - if (!open) { - setUnreadCount(0); - return true; - } - - return false; - }); - }, []); - - React.useEffect(() => { - if (!containerRef.current) return; - containerRef.current.scrollTop = containerRef.current.scrollHeight; - }, [messages, sentMessage, isOpen]); - - return { - messages, - sentMessage, - containerRef, - formRef, - inputRef, - actionFetcher, - isOpen, - toggleOpen, - unreadCount, - }; -} diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx deleted file mode 100644 index 887ce0fff..000000000 --- a/app/components/Combobox.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Combobox as HeadlessCombobox } from "@headlessui/react"; -import * as React from "react"; -import Fuse from "fuse.js"; -import clsx from "clsx"; - -const MAX_RESULTS_SHOWN = 6; - -export function Combobox({ - options, - onChange, - inputName, - placeholder, -}: { - options: string[] | readonly string[]; - onChange: (value: string) => void; - inputName: string; - placeholder: string; -}) { - const [query, setQuery] = React.useState(""); - - const filteredOptions = (() => { - if (!query) return []; - - const fuse = new Fuse(options); - return fuse - .search(query) - .slice(0, MAX_RESULTS_SHOWN) - .map((res) => res.item); - })(); - - return ( - - setQuery(event.target.value)} - placeholder={placeholder} - className="combobox-input" - name={inputName} - /> - - {filteredOptions.map((option) => ( - - {({ active }) => ( -
  • {option}
  • - )} -
    - ))} -
    -
    - ); -} diff --git a/app/components/Draggable.tsx b/app/components/Draggable.tsx deleted file mode 100644 index 0cffa773c..000000000 --- a/app/components/Draggable.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import * as React from "react"; - -export function Draggable({ - id, - disabled, - liClassName, - children, -}: { - id: string; - disabled: boolean; - liClassName: string; - children: React.ReactNode; -}) { - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id, disabled }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
  • - {children} -
  • - ); -} diff --git a/app/components/FormErrorMessage.tsx b/app/components/FormErrorMessage.tsx deleted file mode 100644 index 2008e7a0e..000000000 --- a/app/components/FormErrorMessage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export function FormErrorMessage({ errorMsg }: { errorMsg?: string }) { - if (!errorMsg) return null; - - return ( -

    - {errorMsg} -

    - ); -} diff --git a/app/components/FormInfoText.tsx b/app/components/FormInfoText.tsx deleted file mode 100644 index 76183e8ee..000000000 --- a/app/components/FormInfoText.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from "react"; - -export function FormInfoText({ children }: { children: React.ReactNode }) { - return

    {children}

    ; -} diff --git a/app/components/Label.tsx b/app/components/Label.tsx deleted file mode 100644 index 7f1b7ca40..000000000 --- a/app/components/Label.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import clsx from "clsx"; - -export interface LabelProps - extends React.LabelHTMLAttributes { - className?: string; -} - -export function Label({ className, ...rest }: LabelProps) { - return
    -
    {children}
    + {children} ); }); diff --git a/app/components/play/DetailedPlayers.tsx b/app/components/play/DetailedPlayers.tsx deleted file mode 100644 index cabced2d9..000000000 --- a/app/components/play/DetailedPlayers.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { LFGMatchLoaderData } from "~/routes/play/match.$id"; -import { Unpacked } from "~/utils"; -import { WeaponImage } from "../WeaponImage"; -import SplatnetIcon from "./SplatnetIcon"; - -export function DetailedPlayers({ - players, - bravo, -}: { - players: Unpacked< - NonNullable["detail"]>["teams"] - >["players"]; - bravo?: boolean; -}) { - return ( -
    - {players - .sort((a, b) => b.assists + b.kills - (a.assists + a.kills)) - .map((player) => ( -
    - -
    - {player.name} -
    - {player.paint}p -
    -
    -
    - - - -
    -
    - ))} -
    - ); -} diff --git a/app/components/play/FinishedGroup.tsx b/app/components/play/FinishedGroup.tsx deleted file mode 100644 index abc5c0398..000000000 --- a/app/components/play/FinishedGroup.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Form, useLoaderData } from "@remix-run/react"; -import { LookingLoaderData } from "~/routes/play/looking"; -import { Button } from "../Button"; -import { Chat } from "../Chat"; -import { GroupCard } from "./GroupCard"; - -export function FinishedGroup() { - const data = useLoaderData(); - - return ( -
    - {data.ownGroup.members && ( - [ - m.id, - { name: m.discordName, info: m.friendCode }, - ]) - )} - /> - )} -
    - -
    -
    -
    - -
    -
    -
    - ); -} diff --git a/app/components/play/GroupCard.tsx b/app/components/play/GroupCard.tsx deleted file mode 100644 index a7941afef..000000000 --- a/app/components/play/GroupCard.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import clsx from "clsx"; -import { useFetcher } from "@remix-run/react"; -import { Button, ButtonProps } from "~/components/Button"; -import type { - LookingActionSchema, - LookingLoaderDataGroup, -} from "~/routes/play/looking"; -import { ArrowUpIcon } from "../icons/ArrowUp"; -import { GroupMembers } from "./GroupMembers"; - -export function GroupCard({ - group, - action, - showAction, - ranked, - ownGroup, - isOwnGroup = false, -}: { - group: LookingLoaderDataGroup; - action?: Exclude; - showAction: boolean; - ranked?: boolean; - ownGroup?: { - ranked?: boolean; - league: boolean; - }; - isOwnGroup?: boolean; -}) { - const fetcher = useFetcher(); - - const buttonText = (otherGroupRanked = false) => { - switch (action) { - case "LEAVE_GROUP": - return "Leave group"; - case "LIKE": - if (ownGroup?.league) return "Let's play league?"; - - // when we ask for other team to group up it takes their ranked status - return otherGroupRanked ? "Let's play ranked?" : "Let's scrim?"; - case "UNLIKE": - return "Undo"; - case "UNITE_GROUPS": - if (ownGroup?.league) return "Group up"; - - // when we group up the new group takes our ranked status - return ownGroup?.ranked ? "Group up (ranked)" : "Group up (scrim)"; - case "MATCH_UP": - if (ownGroup?.league) return "Match up"; - - // when we match up with other group it takes their ranked status - return otherGroupRanked ? "Match up (ranked)" : "Match up (scrim)"; - case "LOOK_AGAIN": - return "Stop looking"; - default: - throw new Error(`Invalid group action type: ${action ?? "UNDEFINED"}`); - } - }; - const buttonVariant = (): ButtonProps["variant"] => { - switch (action) { - case "LEAVE_GROUP": - case "LOOK_AGAIN": - return "minimal-destructive"; - case "UNLIKE": - return "destructive"; - default: - return undefined; - } - }; - - return ( - -
    - {typeof ranked === "boolean" && ( -
    - {ranked ? "Ranked" : "Scrim"} -
    - )} - - {group.MMRRelation && !group.replay && ( - - )} - {group.replay &&
    Replay
    } - - {action === "UNITE_GROUPS" && ( - - )} - {showAction && ( - - )} -
    -
    - ); -} - -function MMRRelation({ - relation, -}: { - relation: NonNullable; -}) { - const labelText = () => { - switch (relation) { - case "LOT_LOWER": { - return "A lot lower"; - } - case "LOWER": { - return "Lower"; - } - case "BIT_LOWER": { - return "A bit lower"; - } - case "CLOSE": { - return "Close"; - } - case "BIT_HIGHER": { - return "A bit higher"; - } - case "HIGHER": { - return "Higher"; - } - case "LOT_HIGHER": { - return "A lot higher"; - } - } - }; - const gridColumn = () => { - const relationsOrdered: NonNullable< - LookingLoaderDataGroup["MMRRelation"] - >[] = [ - "LOT_LOWER", - "LOWER", - "BIT_LOWER", - "CLOSE", - "BIT_HIGHER", - "HIGHER", - "LOT_HIGHER", - ]; - - return { - gridColumn: `${relationsOrdered.indexOf(relation) + 1} / ${ - relationsOrdered.indexOf(relation) + 2 - }`, - }; - }; - - return ( -
    -
    - {labelText()} SP -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    - ); -} diff --git a/app/components/play/GroupMembers.tsx b/app/components/play/GroupMembers.tsx deleted file mode 100644 index 4bd04770e..000000000 --- a/app/components/play/GroupMembers.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { LookingLoaderDataGroup } from "~/routes/play/looking"; -import { layoutIcon } from "~/utils"; -import { oldSendouInkUserProfile } from "~/utils/urls"; -import { Avatar } from "../Avatar"; -import { Popover } from "../Popover"; -import { WeaponImage } from "../WeaponImage"; - -export function GroupMembers({ - members, -}: { - members: LookingLoaderDataGroup["members"]; -}) { - return ( -
    - -
    - ); -} - -function Contents({ members }: { members: LookingLoaderDataGroup["members"] }) { - if (!members) { - return ( - <> - {new Array(4).fill(null).map((_, i) => { - return ( -
    - - ??? -
    - ); - })} - - ); - } - - return ( - <> - {members?.map((member) => { - return ( -
    - - - - {member.discordName}{" "} - {member.captain && ( - C - )} - - - {member.MMR && ( -
    SP: {member.MMR}
    - )} - {member.peakXP && ( -
    - {" "} - {member.peakXP} -
    - )} - {member.peakLP && ( -
    - {" "} - {member.peakLP} -
    - )} - {member.weapons && ( -
    - {member.weapons.map((wpn) => ( - - ))}{" "} -
    - )} -
    - {member.miniBio && ( - {member.miniBio} - )} -
    -
    - ); - })} - - ); -} diff --git a/app/components/play/LFGGroupSelector.tsx b/app/components/play/LFGGroupSelector.tsx deleted file mode 100644 index 3be582e80..000000000 --- a/app/components/play/LFGGroupSelector.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from "react"; -import { RadioGroup } from "@headlessui/react"; -import { layoutIcon } from "~/utils"; -import clsx from "clsx"; -import { PlayFrontPageLoader } from "~/routes/play/index"; -import { UsersIcon } from "../icons/Users"; - -const OPTIONS = [ - { - type: "VERSUS-RANKED", - image: "rotations", - text: "Versus", - explanation: "Ranked", - }, - { - type: "VERSUS-UNRANKED", - image: "rotations", - text: "Versus", - explanation: "Scrim", - }, - { - type: "TWIN", - image: "rotations", - text: "Twin", - explanation: "League Battle", - }, - { - type: "QUAD", - image: "rotations", - text: "Quad", - explanation: "League Battle", - }, -] as const; - -type Type = "VERSUS-RANKED" | "VERSUS-UNRANKED" | "TWIN" | "QUAD"; - -export function LFGGroupSelector({ - counts, -}: { - counts: PlayFrontPageLoader["counts"]; -}) { - const [selectedType, setSelectedType] = React.useState("VERSUS-RANKED"); - - const count = ( - type: "VERSUS-RANKED" | "VERSUS-UNRANKED" | "TWIN" | "QUAD" - ) => { - switch (type) { - case "VERSUS-RANKED": - case "VERSUS-UNRANKED": - return counts["VERSUS"]; - case "QUAD": - return counts["QUAD"]; - case "TWIN": - return counts["TWIN"]; - } - }; - - return ( - <> - - - {OPTIONS.map((option, i) => { - return ( - 1 })} - value={option.type} - > - {({ checked }) => ( -
    - - {/* TODO: remove */} - -
    - )} -
    - ); - })} -
    - - ); -} diff --git a/app/components/play/LookingInfoText.tsx b/app/components/play/LookingInfoText.tsx deleted file mode 100644 index 87b0f5f71..000000000 --- a/app/components/play/LookingInfoText.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { Form, useLoaderData } from "@remix-run/react"; -import { - groupExpirationStatus, - groupWillBeInactiveAt, -} from "~/core/play/utils"; -import { LookingLoaderData } from "~/routes/play/looking"; -import { Button } from "../Button"; - -const CONTAINER_CLASSNAME = "play-looking__info-text"; - -export function LookingInfoText({ lastUpdated }: { lastUpdated: Date }) { - const [, forceUpdate] = React.useState(Math.random()); - const data = useLoaderData(); - - React.useEffect(() => { - const timer = setInterval(() => { - forceUpdate(Math.random()); - }, 10000); // 10 seconds - - return () => clearInterval(timer); - }, []); - - if (groupExpirationStatus(data.lastActionAtTimestamp)) { - const text = - groupExpirationStatus(data.lastActionAtTimestamp) === "EXPIRED" - ? "Your group has been hidden due to inactivity" - : `Without any activity your group will be hidden at ${groupWillBeInactiveAt( - data.lastActionAtTimestamp - ).toLocaleTimeString("en", { hour: "numeric", minute: "numeric" })}`; - return ( -
    -
    - {text}. Click{" "} - {" "} - if you are still looking. -
    -
    - ); - } - - return ( -
    - Last updated:{" "} - {lastUpdated.toLocaleTimeString("en", { - hour: "numeric", - minute: "numeric", - second: "numeric", - })} -
    - ); -} diff --git a/app/components/play/MapList.tsx b/app/components/play/MapList.tsx deleted file mode 100644 index 6b8b4cafe..000000000 --- a/app/components/play/MapList.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { Mode } from "@prisma/client"; -import clsx from "clsx"; -import clone from "just-clone"; -import * as React from "react"; -import { Form, useLoaderData } from "@remix-run/react"; -import { scoreValid } from "~/core/play/validators"; -import { LFGMatchLoaderData } from "~/routes/play/match.$id"; -import { userFullDiscordName } from "~/utils"; -import { Button } from "../Button"; -import { ModeImage } from "../ModeImage"; -import { SubmitButton } from "../SubmitButton"; - -const NO_RESULT = "NO_RESULT"; - -interface MapListProps { - mapList: { - name: string; - mode: Mode; - }[]; - reportedWinnerIds: string[]; - canSubmitScore: boolean; - groupIds: { - our: string; - their: string; - }; -} -export function MapList({ - mapList, - reportedWinnerIds, - canSubmitScore, - groupIds, -}: MapListProps) { - const [winners, setWinners] = React.useState(reportedWinnerIds); - const [cancelModeEnabled, setCancelModeEnabled] = React.useState(false); - - const updateWinners = (winnerId: string, index: number) => { - const newWinners = clone(winners); - - // we make sure this option is only available for the last score - if (winnerId === NO_RESULT) { - newWinners.pop(); - } else if (index === newWinners.length) { - newWinners.push(winnerId); - } else { - newWinners[index] = winnerId; - } - - setWinners(newWinners); - }; - - const selectInvisible = (index: number) => { - if (scoreValid(winners, mapList.length) && winners.length <= index) { - return true; - } - if (index > winners.length) return true; - - return false; - }; - - if (cancelModeEnabled) - return ( - setCancelModeEnabled(false)} /> - ); - - return ( -
      -

      Map list

      -
      Best of {mapList.length}
      - {canSubmitScore && ( -
    1. - Winner -
    2. - )} - {mapList.map((stage, i) => { - return ( -
    3. - {canSubmitScore && ( - - )} - - {i + 1}){" "} - {stage.name} -
    4. - ); - })} - {canSubmitScore && ( - setCancelModeEnabled(true)} - /> - )} -
    - ); -} - -function Submitter({ - mapList, - winners, - groupIds, - isFirstTimeReporting, - enableCancelMode, -}: { - mapList: MapListProps["mapList"]; - winners: string[]; - groupIds: { - our: string; - their: string; - }; - isFirstTimeReporting: boolean; - enableCancelMode: () => void; -}) { - const warningText = scoreValid(winners, mapList.length) - ? undefined - : "Report more maps to submit the score"; - - if (warningText) { - return ( -
    - {warningText} -
    -
    - or{" "} - -
    -
    -
    - ); - } - - const score = winners.reduce( - (acc: [number, number], winnerId) => { - if (winnerId === groupIds.our) acc[0]++; - else acc[1]++; - - return acc; - }, - [0, 0] - ); - - return ( -
    -
    - - - Submit {score.join("-")} - -
    -
    - ); -} - -function CancelMatch({ disableCancelMode }: { disableCancelMode: () => void }) { - const data = useLoaderData(); - - return ( -
    -

    Cancel match

    -

    - You should only cancel the match if one player can't be reached - (give them at least 15 minutes to answer) or becomes unavailable to play - (either before the set or in the middle of it). -

    -

    - When canceling the match the team with 4 players available to play gains - SP as if they had played and won the set. The player who is not - available to play loses SP as if they played and lost the set. The - teammates of the player who left will not have a change in their - SP's. -

    -
    -

    Choose missing player

    -
    - {data.groups - .flatMap((g) => g.members) - .map((m) => ( - - - - - ))} -
    -
    - - -
    -
    -
    - ); -} diff --git a/app/components/play/MatchTeams.tsx b/app/components/play/MatchTeams.tsx deleted file mode 100644 index 6184a1cf0..000000000 --- a/app/components/play/MatchTeams.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import clsx from "clsx"; -import { useLoaderData } from "@remix-run/react"; -import { LFGMatchLoaderData } from "~/routes/play/match.$id"; -import { userFullDiscordName } from "~/utils"; -import { weaponsInGameOrder } from "~/utils/sorters"; -import { - oldSendouInkPlayerProfile, - oldSendouInkUserProfile, -} from "~/utils/urls"; -import { Avatar } from "../Avatar"; -import { WeaponImage } from "../WeaponImage"; - -// TODO: make the whole thing one grid to align stuff better -export function MatchTeams() { - const data = useLoaderData(); - - const detailedGroups = (() => { - if (!data.mapList.some((map) => map.detail)) return; - - const result: { - [groupId: string]: { - weapons: Set; - name: string; - principalId: string; - }[]; - } = { [data.groups[0].id]: [], [data.groups[1].id]: [] }; - - for (const stage of data.mapList) { - if (typeof stage.winner !== "number") break; - - for (const team of stage.detail?.teams ?? []) { - const groupId = - data.groups[team.isWinner ? stage.winner : Number(!stage.winner)].id; - - for (const player of team.players) { - const playerObj = result[groupId].find( - (p) => p.principalId === player.principalId - ); - if (playerObj) playerObj.weapons.add(player.weapon); - else { - result[groupId].push({ - name: player.name, - principalId: player.principalId, - weapons: new Set([player.weapon]), - }); - } - } - } - } - - return result; - })(); - - return ( -
    - {data.groups.map((g, i) => { - return ( -
    - {detailedGroups ? ( - <> - {detailedGroups[g.id].map((player) => ( - -
    - - {player.name} - - - {Array.from(player.weapons) - .sort(weaponsInGameOrder) - .map((weapon) => ( - - ))} - -
    -
    - ))} - - - ) : ( - g.members.map((user) => ( - -
    - - - {user.discordName} - -
    -
    - )) - )} - {data.scores && ( -
    - {data.scores[i]} -
    - )} -
    - ); - })} -
    - ); -} diff --git a/app/components/play/SplatnetIcon/AutoBombRush.tsx b/app/components/play/SplatnetIcon/AutoBombRush.tsx deleted file mode 100644 index 27c7503ae..000000000 --- a/app/components/play/SplatnetIcon/AutoBombRush.tsx +++ /dev/null @@ -1,244 +0,0 @@ -export function AutoBombRush() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Baller.tsx b/app/components/play/SplatnetIcon/Baller.tsx deleted file mode 100644 index f42983dca..000000000 --- a/app/components/play/SplatnetIcon/Baller.tsx +++ /dev/null @@ -1,38 +0,0 @@ -export function Baller() { - return ( - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/BooyahBomb.tsx b/app/components/play/SplatnetIcon/BooyahBomb.tsx deleted file mode 100644 index bb5223095..000000000 --- a/app/components/play/SplatnetIcon/BooyahBomb.tsx +++ /dev/null @@ -1,83 +0,0 @@ -export function BooyahBomb() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/BubbleBlower.tsx b/app/components/play/SplatnetIcon/BubbleBlower.tsx deleted file mode 100644 index 374b7532d..000000000 --- a/app/components/play/SplatnetIcon/BubbleBlower.tsx +++ /dev/null @@ -1,23 +0,0 @@ -export function BubbleBlower() { - return ( - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/BurstBombRush.tsx b/app/components/play/SplatnetIcon/BurstBombRush.tsx deleted file mode 100644 index fb92fedbd..000000000 --- a/app/components/play/SplatnetIcon/BurstBombRush.tsx +++ /dev/null @@ -1,91 +0,0 @@ -export function BurstBombRush() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/ColorDefs.tsx b/app/components/play/SplatnetIcon/ColorDefs.tsx deleted file mode 100644 index 5a3167e8f..000000000 --- a/app/components/play/SplatnetIcon/ColorDefs.tsx +++ /dev/null @@ -1,1878 +0,0 @@ -export function ColorDefs() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/CurlingBombRush.tsx b/app/components/play/SplatnetIcon/CurlingBombRush.tsx deleted file mode 100644 index 339a90c66..000000000 --- a/app/components/play/SplatnetIcon/CurlingBombRush.tsx +++ /dev/null @@ -1,82 +0,0 @@ -export function CurlingBombRush() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Deaths.tsx b/app/components/play/SplatnetIcon/Deaths.tsx deleted file mode 100644 index 0d90abbd2..000000000 --- a/app/components/play/SplatnetIcon/Deaths.tsx +++ /dev/null @@ -1,29 +0,0 @@ -export function Deaths() { - return ( - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/InkArmor.tsx b/app/components/play/SplatnetIcon/InkArmor.tsx deleted file mode 100644 index 362480901..000000000 --- a/app/components/play/SplatnetIcon/InkArmor.tsx +++ /dev/null @@ -1,27 +0,0 @@ -export function InkArmor() { - return ( - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/InkStorm.tsx b/app/components/play/SplatnetIcon/InkStorm.tsx deleted file mode 100644 index f33190405..000000000 --- a/app/components/play/SplatnetIcon/InkStorm.tsx +++ /dev/null @@ -1,43 +0,0 @@ -export function InkStorm() { - return ( - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Inkjet.tsx b/app/components/play/SplatnetIcon/Inkjet.tsx deleted file mode 100644 index 6dda57925..000000000 --- a/app/components/play/SplatnetIcon/Inkjet.tsx +++ /dev/null @@ -1,64 +0,0 @@ -export function Inkjet() { - return ( - - - - Specials used - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Kills.tsx b/app/components/play/SplatnetIcon/Kills.tsx deleted file mode 100644 index ceded8954..000000000 --- a/app/components/play/SplatnetIcon/Kills.tsx +++ /dev/null @@ -1,32 +0,0 @@ -export function Kills() { - return ( - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Splashdown.tsx b/app/components/play/SplatnetIcon/Splashdown.tsx deleted file mode 100644 index 0af7e2a80..000000000 --- a/app/components/play/SplatnetIcon/Splashdown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -export function Splashdown() { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/SplatBombRush.tsx b/app/components/play/SplatnetIcon/SplatBombRush.tsx deleted file mode 100644 index 9172acc81..000000000 --- a/app/components/play/SplatnetIcon/SplatBombRush.tsx +++ /dev/null @@ -1,187 +0,0 @@ -export function SplatBombRush() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/Stingray.tsx b/app/components/play/SplatnetIcon/Stingray.tsx deleted file mode 100644 index 553d5fd77..000000000 --- a/app/components/play/SplatnetIcon/Stingray.tsx +++ /dev/null @@ -1,64 +0,0 @@ -export function Stingray() { - return ( - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/SuctionBombRush.tsx b/app/components/play/SplatnetIcon/SuctionBombRush.tsx deleted file mode 100644 index 02594a91c..000000000 --- a/app/components/play/SplatnetIcon/SuctionBombRush.tsx +++ /dev/null @@ -1,184 +0,0 @@ -export function SuctionBombRush() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/TentaMissiles.tsx b/app/components/play/SplatnetIcon/TentaMissiles.tsx deleted file mode 100644 index b597d5dee..000000000 --- a/app/components/play/SplatnetIcon/TentaMissiles.tsx +++ /dev/null @@ -1,143 +0,0 @@ -export function TentaMissiles() { - return ( - - - - Specials used - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/UltraStamp.tsx b/app/components/play/SplatnetIcon/UltraStamp.tsx deleted file mode 100644 index 91e458188..000000000 --- a/app/components/play/SplatnetIcon/UltraStamp.tsx +++ /dev/null @@ -1,179 +0,0 @@ -export function UltraStamp() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/app/components/play/SplatnetIcon/index.tsx b/app/components/play/SplatnetIcon/index.tsx deleted file mode 100644 index fc50602cf..000000000 --- a/app/components/play/SplatnetIcon/index.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import clsx from "clsx"; -import { AutoBombRush } from "./AutoBombRush"; -import { Baller } from "./Baller"; -import { BooyahBomb } from "./BooyahBomb"; -import { BubbleBlower } from "./BubbleBlower"; -import { BurstBombRush } from "./BurstBombRush"; -import { ColorDefs } from "./ColorDefs"; -import { CurlingBombRush } from "./CurlingBombRush"; -import { Deaths } from "./Deaths"; -import { InkArmor } from "./InkArmor"; -import { Inkjet } from "./Inkjet"; -import { InkStorm } from "./InkStorm"; -import { Kills } from "./Kills"; -import { Splashdown } from "./Splashdown"; -import { SplatBombRush } from "./SplatBombRush"; -import { Stingray } from "./Stingray"; -import { SuctionBombRush } from "./SuctionBombRush"; -import { TentaMissiles } from "./TentaMissiles"; -import { UltraStamp } from "./UltraStamp"; - -const iconToId = { - "Sploosh-o-matic": Splashdown, - "Neo Sploosh-o-matic": TentaMissiles, - "Sploosh-o-matic 7": UltraStamp, - "Splattershot Jr.": InkArmor, - "Custom Splattershot Jr.": InkStorm, - "Kensa Splattershot Jr.": BubbleBlower, - "Splash-o-matic": Inkjet, - "Neo Splash-o-matic": SuctionBombRush, - "Aerospray MG": CurlingBombRush, - "Aerospray RG": Baller, - "Aerospray PG": BooyahBomb, - Splattershot: Splashdown, - "Hero Shot Replica": Splashdown, - "Tentatek Splattershot": Inkjet, - "Octo Shot Replica": Inkjet, - "Kensa Splattershot": TentaMissiles, - ".52 Gal": Baller, - ".52 Gal Deco": Stingray, - "Kensa .52 Gal": BooyahBomb, - "N-ZAP '85": InkArmor, - "N-ZAP '89": TentaMissiles, - "N-ZAP '83": InkStorm, - "Splattershot Pro": InkStorm, - "Forge Splattershot Pro": BubbleBlower, - "Kensa Splattershot Pro": BooyahBomb, - ".96 Gal": InkArmor, - ".96 Gal Deco": Splashdown, - "Jet Squelcher": TentaMissiles, - "Custom Jet Squelcher": Stingray, - "Luna Blaster": Baller, - "Luna Blaster Neo": SuctionBombRush, - "Kensa Luna Blaster": InkStorm, - Blaster: Splashdown, - "Hero Blaster Replica": Splashdown, - "Custom Blaster": Inkjet, - "Range Blaster": InkStorm, - "Custom Range Blaster": BubbleBlower, - "Grim Range Blaster": TentaMissiles, - "Clash Blaster": Stingray, - "Clash Blaster Neo": TentaMissiles, - "Rapid Blaster": SplatBombRush, - "Rapid Blaster Deco": Inkjet, - "Kensa Rapid Blaster": Baller, - "Rapid Blaster Pro": InkStorm, - "Rapid Blaster Pro Deco": InkArmor, - "L-3 Nozzlenose": Baller, - "L-3 Nozzlenose D": Inkjet, - "Kensa L-3 Nozzlenose": UltraStamp, - "H-3 Nozzlenose": TentaMissiles, - "H-3 Nozzlenose D": InkArmor, - "Cherry H-3 Nozzlenose": BubbleBlower, - Squeezer: Stingray, - "Foil Squeezer": BubbleBlower, - "Carbon Roller": InkStorm, - "Carbon Roller Deco": AutoBombRush, - "Splat Roller": Splashdown, - "Hero Roller Replica": Splashdown, - "Krak-On Splat Roller": Baller, - "Kensa Splat Roller": BubbleBlower, - "Dynamo Roller": Stingray, - "Gold Dynamo Roller": InkArmor, - "Kensa Dynamo Roller": BooyahBomb, - "Flingza Roller": SplatBombRush, - "Foil Flingza Roller": TentaMissiles, - Inkbrush: Splashdown, - "Inkbrush Nouveau": Baller, - "Permanent Inkbrush": InkArmor, - Octobrush: Inkjet, - "Herobrush Replica": Inkjet, - "Octobrush Nouveau": TentaMissiles, - "Kensa Octobrush": UltraStamp, - "Classic Squiffer": InkArmor, - "New Squiffer": Baller, - "Fresh Squiffer": Inkjet, - "Splat Charger": Stingray, - "Hero Charger Replica": Stingray, - "Firefin Splat Charger": SuctionBombRush, - "Kensa Charger": Baller, - Splatterscope: Stingray, - "Firefin Splatterscope": SuctionBombRush, - "Kensa Splatterscope": Baller, - "E-liter 4K": InkStorm, - "Custom E-liter 4K": BubbleBlower, - "E-liter 4K Scope": InkStorm, - "Custom E-liter 4K Scope": BubbleBlower, - "Bamboozler 14 Mk I": TentaMissiles, - "Bamboozler 14 Mk II": BurstBombRush, - "Bamboozler 14 Mk III": BubbleBlower, - "Goo Tuber": Splashdown, - "Custom Goo Tuber": Inkjet, - Slosher: TentaMissiles, - "Hero Slosher Replica": TentaMissiles, - "Slosher Deco": Baller, - "Soda Slosher": BurstBombRush, - "Tri-Slosher": InkArmor, - "Tri-Slosher Nouveau": InkStorm, - "Sloshing Machine": Stingray, - "Sloshing Machine Neo": SplatBombRush, - "Kensa Sloshing Machine": Splashdown, - Bloblobber: InkStorm, - "Bloblobber Deco": SuctionBombRush, - Explosher: BubbleBlower, - "Custom Explosher": Baller, - "Mini Splatling": TentaMissiles, - "Zink Mini Splatling": InkStorm, - "Kensa Mini Splatling": UltraStamp, - "Heavy Splatling": Stingray, - "Hero Splatling Replica": Stingray, - "Heavy Splatling Deco": BubbleBlower, - "Heavy Splatling Remix": BooyahBomb, - "Hydra Splatling": Splashdown, - "Custom Hydra Splatling": InkArmor, - "Ballpoint Splatling": Inkjet, - "Ballpoint Splatling Nouveau": InkStorm, - "Nautilus 47": Baller, - "Nautilus 79": Inkjet, - "Dapple Dualies": SuctionBombRush, - "Dapple Dualies Nouveau": InkStorm, - "Clear Dapple Dualies": Splashdown, - "Splat Dualies": TentaMissiles, - "Hero Dualie Replicas": TentaMissiles, - "Enperry Splat Dualies": Inkjet, - "Kensa Splat Dualies": Baller, - "Glooga Dualies": Inkjet, - "Glooga Dualies Deco": Baller, - "Kensa Glooga Dualies": InkArmor, - "Dualie Squelchers": TentaMissiles, - "Custom Dualie Squelchers": InkStorm, - "Dark Tetra Dualies": Splashdown, - "Light Tetra Dualies": AutoBombRush, - "Splat Brella": InkStorm, - "Hero Brella Replica": InkStorm, - "Sorella Brella": SplatBombRush, - "Tenta Brella": BubbleBlower, - "Tenta Sorella Brella": CurlingBombRush, - "Tenta Camo Brella": UltraStamp, - "Undercover Brella": Splashdown, - "Undercover Sorella Brella": Baller, - "Kensa Undercover Brella": InkArmor, - kills: Kills, - deaths: Deaths, -} as const; - -const SplatnetIcon = ({ - icon, - count, - smallCount, - bravo, -}: { - icon: keyof typeof iconToId; - count: number; - smallCount?: number; - bravo?: boolean; -}) => { - const Component = iconToId[icon]; - return ( -
    - -
    - x - {count} - {smallCount ? ( - ({smallCount}) - ) : null} -
    - -
    - ); -}; - -export default SplatnetIcon; diff --git a/app/components/tournament/ActionSectionWrapper.tsx b/app/components/tournament/ActionSectionWrapper.tsx deleted file mode 100644 index c60ff4b58..000000000 --- a/app/components/tournament/ActionSectionWrapper.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import clsx from "clsx"; -import { MyCSSProperties } from "~/utils"; - -export function ActionSectionWrapper({ - children, - icon, - ...rest -}: { - children: React.ReactNode; - icon?: "warning" | "info" | "success" | "error"; - "justify-center"?: boolean; - "data-cy"?: string; -}) { - // todo: flex-dir: column on mobile - const style: MyCSSProperties | undefined = icon - ? { - "--action-section-icon-color": `var(--theme-${icon})`, - } - : undefined; - return ( -
    -
    - {children} -
    -
    - ); -} diff --git a/app/components/tournament/BracketActions.tsx b/app/components/tournament/BracketActions.tsx deleted file mode 100644 index 09b30eb87..000000000 --- a/app/components/tournament/BracketActions.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Form, useMatches } from "@remix-run/react"; -import invariant from "tiny-invariant"; -import { allMatchesReported, matchIsOver } from "~/core/tournament/utils"; -import type { BracketModified } from "~/services/bracket"; -import type { FindTournamentByNameForUrlI } from "~/services/tournament"; -import { useUser } from "~/hooks/common"; -import { ActionSectionWrapper } from "./ActionSectionWrapper"; -import { DuringMatchActions } from "./DuringMatchActions"; -import { Button } from "../Button"; -import { isTournamentAdmin } from "~/core/tournament/validators"; - -export function BracketActions({ data }: { data: BracketModified }) { - const user = useUser(); - const [, parentRoute] = useMatches(); - const tournament = parentRoute.data as FindTournamentByNameForUrlI; - - const ownTeam = tournament.teams.find((team) => - team.members.some(({ member }) => member.id === user?.id) - ); - - if ( - !tournament.concluded && - isTournamentAdmin({ - userId: user?.id, - organization: tournament.organizer, - }) && - allMatchesReported(data) - ) - return ( -
    - -
    - -
    -
    - ); - - if (tournament.concluded || !ownTeam) return null; - - const allMatches = data.rounds.flatMap((round, roundI) => - round.matches.map((match) => ({ - ...match, - round, - isFirstRound: roundI === 0, - })) - ); - const currentMatch = allMatches.find((match) => { - const hasBothParticipants = match.participants?.every( - (p) => typeof p === "string" - ); - const isOwnMatch = match.participants?.some((p) => p === ownTeam.name); - - return ( - hasBothParticipants && - isOwnMatch && - !matchIsOver({ bestOf: match.round.stages.length, score: match.score }) - ); - }); - - if (currentMatch) { - return ( - - ); - } - - const nextMatch = allMatches.find((match) => { - const participantsCount = match.participants?.reduce( - (acc, cur) => acc + (cur === null ? 1 : 0), - 0 - ); - return ( - participantsCount === 1 && - match.participants?.some((p) => p === ownTeam.name) && - !match.isFirstRound - ); - }); - - // we are out of the tournament - if (!nextMatch) return null; - - const matchWeAreWaitingFor = allMatches.find( - (match) => - [match.winnerDestinationMatchId, match.loserDestinationMatchId].includes( - nextMatch.id - ) && !match.participants?.includes(ownTeam.name) - ); - invariant(matchWeAreWaitingFor, "matchWeAreWaitingFor is undefined"); - - if (matchWeAreWaitingFor.participants?.filter(Boolean).length !== 2) { - return ( - - Waiting on match number {matchWeAreWaitingFor.number} (missing teams) - - ); - } - - return ( - - Waiting on {matchWeAreWaitingFor.participants[0]} vs. - {matchWeAreWaitingFor.participants[1]} - - {(matchWeAreWaitingFor.score ?? [0, 0]).join("-")} - Best of{" "} - {matchWeAreWaitingFor.round.stages.length} - - - ); -} diff --git a/app/components/tournament/CheckinActions.tsx b/app/components/tournament/CheckinActions.tsx deleted file mode 100644 index 30b52ebb8..000000000 --- a/app/components/tournament/CheckinActions.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// TODO: Warning: Text content did not match. Server: "57" Client: "56" -import * as React from "react"; -import { Form, useLoaderData, useTransition } from "@remix-run/react"; -import { - checkInClosesDate, - TOURNAMENT_TEAM_ROSTER_MIN_SIZE, -} from "~/constants"; -import { TournamentAction } from "~/routes/to/$organization.$tournament"; -import type { FindTournamentByNameForUrlI } from "~/services/tournament"; -import { useUser } from "~/hooks/common"; -import { Button } from "../Button"; -import { AlertIcon } from "../icons/Alert"; -import { CheckInIcon } from "../icons/CheckIn"; -import { ErrorIcon } from "../icons/Error"; -import { SuccessIcon } from "../icons/Success"; -import { ActionSectionWrapper } from "./ActionSectionWrapper"; - -// TODO: warning when not registered but check in is open -export function CheckinActions() { - const tournament = useLoaderData(); - const user = useUser(); - const transition = useTransition(); - - const timeInMinutesBeforeCheckInCloses = React.useCallback(() => { - return Math.floor( - (checkInClosesDate(tournament.startTime).getTime() - - new Date().getTime()) / - (1000 * 60) - ); - }, [tournament.startTime]); - const [minutesTillCheckInCloses, setMinutesTillCheckInCloses] = - React.useState(timeInMinutesBeforeCheckInCloses()); - - React.useEffect(() => { - const timeout = setInterval(() => { - setMinutesTillCheckInCloses(timeInMinutesBeforeCheckInCloses()); - }, 1000 * 15); - - return () => clearTimeout(timeout); - }, []); - - const ownTeam = tournament.teams.find((team) => - team.members.some( - ({ member, captain }) => captain && member.id === user?.id - ) - ); - - const tournamentHasStarted = tournament.brackets.some((b) => b.rounds.length); - if (!ownTeam || tournamentHasStarted) { - return null; - } - - if (ownTeam.checkedInTime) { - return ( - - Your team is checked in! - - ); - } - - const checkInHasStarted = new Date(tournament.checkInStartTime) < new Date(); - const teamHasEnoughMembers = - ownTeam.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE; - - if (!checkInHasStarted && !teamHasEnoughMembers) { - return ( - - You need at least 4 players in your roster to play - - ); - } - - const differenceInMinutesBetweenCheckInAndStart = Math.floor( - (new Date(tournament.startTime).getTime() - - new Date(tournament.checkInStartTime).getTime()) / - (1000 * 60) - ); - - if (!checkInHasStarted && teamHasEnoughMembers) { - return ( - - Check-in starts{" "} - {differenceInMinutesBetweenCheckInAndStart} minutes before the - tournament starts - - ); - } - - if ( - checkInHasStarted && - !teamHasEnoughMembers && - minutesTillCheckInCloses > 0 - ) { - return ( - - You need at least 4 players in your roster to play. - Check-in is open for {minutesTillCheckInCloses} more{" "} - {minutesTillCheckInCloses > 1 ? "minutes" : "minute"} - - ); - } - - if ( - checkInHasStarted && - teamHasEnoughMembers && - minutesTillCheckInCloses > 0 - ) { - return ( - - {minutesTillCheckInCloses > 1 ? ( - <> - Check-in is open for {minutesTillCheckInCloses} more - minutes - - ) : ( - <> - Check-in closes in less than a minute - - )} -
    - - - -
    -
    - ); - } - - return ( - - Check-in has closed. Your team is not checked in - - ); -} diff --git a/app/components/tournament/DuringMatchActions.tsx b/app/components/tournament/DuringMatchActions.tsx deleted file mode 100644 index 9014eb57a..000000000 --- a/app/components/tournament/DuringMatchActions.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Form, useMatches } from "@remix-run/react"; -import invariant from "tiny-invariant"; -import type { BracketModified } from "~/services/bracket"; -import type { FindTournamentByNameForUrlI } from "~/services/tournament"; -import { Unpacked } from "~/utils"; -import { Chat } from "../Chat"; -import { SubmitButton } from "../SubmitButton"; -import { ActionSectionWrapper } from "./ActionSectionWrapper"; -import { DuringMatchActionsRosters } from "./DuringMatchActionsRosters"; -import { FancyStageBanner } from "./FancyStageBanner"; - -export function DuringMatchActions({ - ownTeam, - currentMatch, - currentRound, -}: { - ownTeam: Unpacked; - currentMatch: Unpacked["matches"]>; - currentRound: Unpacked; -}) { - const [, parentRoute] = useMatches(); - const { teams } = parentRoute.data as FindTournamentByNameForUrlI; - - const opponentTeam = teams.find( - (team) => - [currentMatch.participants?.[0], currentMatch.participants?.[1]].includes( - team.name - ) && team.id !== ownTeam.id - ); - invariant(opponentTeam, "opponentTeam is undefined"); - - const currentPosition = - currentMatch.score?.reduce((acc, cur) => acc + cur, 1) ?? 1; - const currentStage = currentRound.stages.find( - (s) => s.position === currentPosition - ); - invariant(currentStage, "currentStage is undefined"); - const { stage } = currentStage; - - const roundInfos = [ - <> - {currentMatch.score?.join("-")} (Best of{" "} - {currentRound.stages.length}) - , - ]; - - return ( - <> - [ - m.member.id, - { - name: m.member.discordName, - info: m.member.friendCode, - }, - ]) - )} - /> -
    - - {currentPosition > 1 && ( -
    - - - -
    - - Undo last score - -
    -
    - )} -
    - - - -
    - - ); -} diff --git a/app/components/tournament/DuringMatchActionsRosters.tsx b/app/components/tournament/DuringMatchActionsRosters.tsx deleted file mode 100644 index 4dd930c35..000000000 --- a/app/components/tournament/DuringMatchActionsRosters.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import * as React from "react"; -import { Form } from "@remix-run/react"; -import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants"; -import type { FindTournamentByNameForUrlI } from "~/services/tournament"; -import { Unpacked } from "~/utils"; -import { SubmitButton } from "../SubmitButton"; -import { TeamRosterInputs } from "./TeamRosterInputs"; - -export function DuringMatchActionsRosters({ - ownTeam, - opponentTeam, - matchId, - position, -}: { - ownTeam: Unpacked; - opponentTeam: Unpacked; - matchId: string; - position: number; -}) { - const [checkedPlayers, setCheckedPlayers] = React.useState< - [string[], string[]] - >(checkedPlayersInitialState([ownTeam, opponentTeam])); - const [winnerId, setWinnerId] = React.useState(); - - return ( -
    -
    - -
    - - - - - - setWinnerId(undefined)} - /> -
    -
    -
    - ); - - function winningTeam() { - if (!winnerId) return; - if (ownTeam.id === winnerId) return ownTeam.name; - if (opponentTeam.id === winnerId) return opponentTeam.name; - - throw new Error("No winning team matching the id"); - } -} - -// TODO: remember what previously selected for our team -function checkedPlayersInitialState([teamOne, teamTwo]: [ - Unpacked, - Unpacked -]): [string[], string[]] { - const result: [string[], string[]] = [[], []]; - - if (teamOne.members.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE) { - result[0].push(...teamOne.members.map(({ member }) => member.id)); - } - - if (teamTwo.members.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE) { - result[1].push(...teamTwo.members.map(({ member }) => member.id)); - } - - return result; -} - -function ReportScoreButtons({ - checkedPlayers, - winnerName, - clearWinner, -}: { - checkedPlayers: string[][]; - winnerName?: string; - clearWinner: () => void; -}) { - if ( - !checkedPlayers.every( - (team) => team.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE - ) - ) { - return ( -

    - Please choose exactly {TOURNAMENT_TEAM_ROSTER_MIN_SIZE}+ - {TOURNAMENT_TEAM_ROSTER_MIN_SIZE} players to report score -

    - ); - } - - if (!winnerName) { - return ( -

    - Please select the winning team -

    - ); - } - - return ( - - Report {winnerName} win - - ); -} diff --git a/app/components/tournament/EliminationBracket.tsx b/app/components/tournament/EliminationBracket.tsx deleted file mode 100644 index 9b00bc00d..000000000 --- a/app/components/tournament/EliminationBracket.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import clsx from "clsx"; -import invariant from "tiny-invariant"; -import { matchIsOver } from "~/core/tournament/utils"; -import type { BracketModified } from "~/services/bracket"; -import { MyCSSProperties, Unpacked } from "~/utils"; -import { EliminationBracketMatch } from "./EliminationBracketMatch"; - -export function EliminationBracket({ - rounds, - ownTeamName, -}: { - rounds: BracketModified["rounds"]; - ownTeamName?: string; -}) { - const style: MyCSSProperties = { - "--brackets-columns": rounds.length, - "--brackets-max-matches": rounds[0].matches.length, - }; - return ( -
    -
    - {rounds.map((round, i) => ( - - ))} - {rounds.map((round, roundI) => { - const nextRound: Unpacked | undefined = - rounds[roundI + 1]; - const amountOfMatchesBetweenRoundsEqual = - round.matches.length === nextRound?.matches.length; - const drawStraightLines = - round.matches.length === 1 || amountOfMatchesBetweenRoundsEqual; - - const style: MyCSSProperties = { - "--brackets-bottom-border-length": drawStraightLines - ? 0 - : undefined, - "--brackets-column-matches": round.matches.length, - "--height-override": drawStraightLines ? "1px" : undefined, - }; - return ( -
    -
    - {round.matches.map((match) => { - return ( -
    -
    - {roundI !== rounds.length - 1 && - theKindOfLinesToDraw({ - amountOfMatchesBetweenRoundsEqual, - round, - roundI, - }).map((className, i) => ( -
    - ))} -
    -
    - ); - })} -
    -
    - ); -} - -function theKindOfLinesToDraw({ - round, - roundI, - amountOfMatchesBetweenRoundsEqual, -}: { - round: Unpacked; - roundI: number; - amountOfMatchesBetweenRoundsEqual: boolean; -}): (undefined | "no-line" | "bottom-only" | "top-only")[] { - return new Array( - amountOfMatchesBetweenRoundsEqual - ? round.matches.length - : Math.ceil(round.matches.length / 2) - ) - .fill(null) - .map((_, lineI) => { - // lines 0 1 2 3 - // rounds 0 1 2 3 4 5 6 7 - if (roundI !== 0) { - return undefined; - } - // TODO: better identifier for losers - if (round.name.includes("Losers")) { - return round.matches[lineI]?.number === 0 ? "no-line" : undefined; - } - const matchOne = round.matches[lineI * 2]; - const matchTwo = round.matches[lineI * 2 + 1]; - invariant(matchOne, "matchOne is undefined"); - invariant(matchTwo, "matchTwo is undefined"); - - if ( - matchOne.participants?.includes(null) && - matchTwo.participants?.includes(null) - ) { - return "no-line"; - } - if (matchOne.participants?.includes(null)) return "bottom-only"; - if (matchTwo.participants?.includes(null)) return "top-only"; - return undefined; - }); -} - -function RoundInfo({ - title, - bestOf, - status, - isLast, -}: { - title: string; - bestOf: number; - status: "DONE" | "INPROGRESS" | "UPCOMING"; - isLast?: boolean; -}) { - return ( -
    -
    {title}
    - {status !== "DONE" && ( -
    Bo{bestOf}
    - )} -
    - ); -} diff --git a/app/components/tournament/EliminationBracketMatch.tsx b/app/components/tournament/EliminationBracketMatch.tsx deleted file mode 100644 index 424877898..000000000 --- a/app/components/tournament/EliminationBracketMatch.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { Link } from "@remix-run/react"; -import type { BracketModified } from "~/services/bracket"; -import { Unpacked } from "~/utils"; - -export function EliminationBracketMatch({ - match, - hidden, - ownTeamName, - isOver, -}: { - match: Unpacked["matches"]>; - hidden?: boolean; - ownTeamName?: string; - isOver: boolean; -}) { - const cellText = (index: number) => { - if (match.participants?.[index]) return match.participants?.[index]; - const matchNumber = match.participantSourceMatches?.[index]; - if (typeof matchNumber === "number") { - return ( - {`Loser of match ${matchNumber}`} - ); - } - - return null; - }; - - const Container = ({ children }: { children: React.ReactNode }) => { - const hasBothParticipants = - (match.participants?.filter(Boolean).length ?? 0) > 1; - const atLeastOneStageReported = match.score?.some((s) => s > 0); - - if (hasBothParticipants && atLeastOneStageReported) - return ( - - {children} - - ); - - return <>{children}; - }; - - return ( - - - - ); -} diff --git a/app/components/tournament/FancyStageBanner.tsx b/app/components/tournament/FancyStageBanner.tsx deleted file mode 100644 index 38020a886..000000000 --- a/app/components/tournament/FancyStageBanner.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from "react"; -import type { Mode } from "@prisma/client"; -import { - modeToImageUrl, - MyCSSProperties, - stageNameToBannerImageUrl, -} from "~/utils"; -import clsx from "clsx"; -import { modesShortToLong } from "~/core/stages/stages"; - -export function FancyStageBanner({ - stage, - roundNumber, - infos, - children, -}: { - stage: { mode: Mode; name: string }; - roundNumber: number; - infos?: JSX.Element[]; - children?: React.ReactNode; -}) { - const style: MyCSSProperties = { - "--_tournament-bg-url": `url("${stageNameToBannerImageUrl(stage.name)}")`, - }; - - return ( - <> -
    -
    -

    - - {modesShortToLong[stage.mode]} on {stage.name} -

    -

    Stage {roundNumber}

    -
    - {children} -
    - {infos && ( -
    - {infos.map((info, i) => ( -
    {info}
    - ))} -
    - )} - - ); -} diff --git a/app/components/tournament/InfoBanner.tsx b/app/components/tournament/InfoBanner.tsx deleted file mode 100644 index 60d96cc73..000000000 --- a/app/components/tournament/InfoBanner.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Link, useLocation, useMatches } from "@remix-run/react"; -import { DiscordIcon } from "~/components/icons/Discord"; -import { TwitterIcon } from "~/components/icons/Twitter"; -import { resolveTournamentFormatString } from "~/core/tournament/bracket"; -import { FindTournamentByNameForUrlI } from "~/services/tournament"; - -export function InfoBanner() { - const [, parentRoute] = useMatches(); - const data = parentRoute.data as FindTournamentByNameForUrlI; - const location = useLocation(); - - const urlToTournamentFrontPage = location.pathname - .split("/") - .slice(0, 4) - .join("/"); - - return ( - <> -
    -
    -
    - - - {data.name} - -
    -
    - {data.organizer.twitter && ( - // TODO: broken on Safari - - - - )} - - - -
    -
    -
    -
    -
    -
    - Starting time -
    - -
    -
    -
    Format
    -
    {resolveTournamentFormatString(data.brackets)}
    -
    -
    -
    - Organizer -
    -
    {data.organizer.name}
    -
    -
    -
    -
    - - ); -} - -// TODO: https://github.com/remix-run/remix/issues/656 -function weekdayAndStartTime(date: string) { - return new Date(date).toLocaleString("en-US", { - weekday: "long", - hour: "numeric", - }); -} - -function shortMonthName(date: string) { - return new Date(date).toLocaleString("en-US", { month: "short" }); -} - -function dayNumber(date: string) { - return new Date(date).toLocaleString("en-US", { day: "numeric" }); -} - -function dateYYYYMMDD(date: string) { - return new Date(date).toISOString().split("T")[0]; -} diff --git a/app/components/tournament/TeamRoster.tsx b/app/components/tournament/TeamRoster.tsx deleted file mode 100644 index ae3cb1444..000000000 --- a/app/components/tournament/TeamRoster.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Form, useMatches, useTransition } from "@remix-run/react"; -import { tournamentHasNotStarted } from "~/core/tournament/validators"; -import { useUser } from "~/hooks/common"; -import { FindTournamentByNameForUrlI } from "~/services/tournament"; -import { Avatar } from "../Avatar"; -import { Button } from "../Button"; -import { SubmitButton } from "../SubmitButton"; - -export function TeamRoster({ - team, - showUnregister = false, - deleteMode = false, -}: { - team: { - id: string; - name: string; - members: { - captain: boolean; - member: { - id: string; - discordAvatar: string | null; - discordId: string; - discordName: string; - }; - }[]; - }; - deleteMode?: boolean; - showUnregister?: boolean; -}) { - const [, parentRoute] = useMatches(); - const tournament = parentRoute.data as FindTournamentByNameForUrlI; - const user = useUser(); - - const showDeleteButtons = (userToDeleteId: string) => { - return ( - tournamentHasNotStarted(tournament) && - deleteMode && - userToDeleteId !== user?.id - ); - }; - - const showUnregisterButton = () => { - return tournamentHasNotStarted(tournament) && showUnregister; - }; - - return ( -
    -
    - {team.name} - {showUnregisterButton() ? ( -
    - - - { - if ( - !confirm(`Unregister ${team.name} from ${tournament.name}?`) - ) { - e.preventDefault(); - } - }} - data-cy="unregister-button" - > - Unregister - -
    - ) : null} -
    -
    - {team.members - .sort((a, b) => Number(b.captain) - Number(a.captain)) - .map(({ member, captain }, i) => ( -
    -
    - {captain ? "C" : i + 1} -
    -
    - -
    -
    {member.discordName}
    - {showDeleteButtons(member.id) && ( - - )} -
    -
    -
    - ))} -
    -
    - ); -} - -function DeleteFromRosterButton({ - playerId, - teamId, -}: { - playerId: string; - teamId: string; -}) { - const transition = useTransition(); - return ( -
    - - - - -
    - ); -} diff --git a/app/components/tournament/TeamRosterInputs.tsx b/app/components/tournament/TeamRosterInputs.tsx deleted file mode 100644 index c77588d3f..000000000 --- a/app/components/tournament/TeamRosterInputs.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import clsx from "clsx"; -import clone from "just-clone"; -import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants"; -import { Label } from "../Label"; -import { TeamRosterInputsCheckboxes } from "./TeamRosterInputsCheckboxes"; -import * as React from "react"; - -/** Fields of a tournament team required to render `` */ -export interface TeamRosterInputTeam { - name: string; - id: string; - members: { - member: { - id: string; - discordName: string; - played?: boolean; - }; - }[]; -} - -export type TeamRosterInputsType = "DEFAULT" | "DISABLED" | "PRESENTATIONAL"; - -/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */ -export function TeamRosterInputs({ - teamUpper, - teamLower, - winnerId, - setWinnerId, - checkedPlayers, - setCheckedPlayers, - presentational = false, -}: { - teamUpper: TeamRosterInputTeam; - teamLower: TeamRosterInputTeam; - winnerId?: string | null; - setWinnerId?: (newId: string) => void; - checkedPlayers: [string[], string[]]; - setCheckedPlayers?: (newPlayerIds: [string[], string[]]) => void; - presentational?: boolean; -}) { - const inputMode = (team: TeamRosterInputTeam): TeamRosterInputsType => { - if (presentational) return "PRESENTATIONAL"; - - // Disabled in this case because we expect a result to have exactly - // TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it - // so there is no point to let user to change them around - if (team.members.length <= TOURNAMENT_TEAM_ROSTER_MIN_SIZE) { - return "DISABLED"; - } - - return "DEFAULT"; - }; - - return ( -
    - {[teamUpper, teamLower].map((team, teamI) => ( -
    -

    {team.name}

    - setWinnerId?.(team.id)} - /> - { - const newCheckedPlayers = () => { - const newPlayers = clone(checkedPlayers); - if (checkedPlayers.flat().includes(playerId)) { - newPlayers[teamI] = newPlayers[teamI].filter( - (id) => id !== playerId - ); - } else { - newPlayers[teamI].push(playerId); - } - - return newPlayers; - }; - setCheckedPlayers?.(newCheckedPlayers()); - }} - /> -
    - ))} -
    - ); -} - -/** Renders radio button to select winner, or in presentational mode just display the text "Winner" */ -function WinnerRadio({ - presentational, - teamId, - checked, - onChange, -}: { - presentational: boolean; - teamId: string; - checked: boolean; - onChange: () => void; -}) { - const id = React.useId(); - - if (presentational) { - return ( -
    - Winner -
    - ); - } - - return ( -
    - - -
    - ); -} diff --git a/app/components/tournament/TeamRosterInputsCheckboxes.tsx b/app/components/tournament/TeamRosterInputsCheckboxes.tsx deleted file mode 100644 index d6ea74725..000000000 --- a/app/components/tournament/TeamRosterInputsCheckboxes.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import clsx from "clsx"; -import { Label } from "../Label"; -import { TeamRosterInputsType, TeamRosterInputTeam } from "./TeamRosterInputs"; -import * as React from "react"; - -export function TeamRosterInputsCheckboxes({ - team, - checkedPlayers, - handlePlayerClick, - mode, -}: { - team: TeamRosterInputTeam; - checkedPlayers: string[]; - handlePlayerClick: (playerId: string) => void; - /** DEFAULT = inputs work, DISABLED = inputs disabled and look disabled, PRESENTATION = inputs disabled but look like in DEFAULT (without hover styles) */ - mode: TeamRosterInputsType; -}) { - const id = React.useId(); - - return ( -
    - {team.members.map(({ member }) => ( -
    - handlePlayerClick(member.id)} - />{" "} - -
    - ))} -
    - ); -} diff --git a/app/components/u/SocialLink.tsx b/app/components/u/SocialLink.tsx new file mode 100644 index 000000000..5e0fb2117 --- /dev/null +++ b/app/components/u/SocialLink.tsx @@ -0,0 +1,56 @@ +import clsx from "clsx"; +import { assertUnreachable } from "~/utils/types"; +import { TwitchIcon } from "../icons/Twitch"; +import { TwitterIcon } from "../icons/Twitter"; +import { YouTubeIcon } from "../icons/YouTube"; + +interface SocialLinkProps { + type: "youtube" | "twitter" | "twitch"; + identifier: string; +} +export function SocialLink({ + type, + identifier, +}: { + type: "youtube" | "twitter" | "twitch"; + identifier: string; +}) { + const href = () => { + switch (type) { + case "twitch": + return `https://www.twitch.tv/${identifier}`; + case "twitter": + return `https://www.twitter.com/${identifier}`; + case "youtube": + return `https://www.youtube.com/channel/${identifier}`; + default: + assertUnreachable(type); + } + }; + + return ( + + + + ); +} + +function SocialLinkIcon({ type }: Pick) { + switch (type) { + case "twitch": + return ; + case "twitter": + return ; + case "youtube": + return ; + default: + assertUnreachable(type); + } +} diff --git a/app/constants.ts b/app/constants.ts index 43c1bd9c9..c6b871bb7 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -1,44 +1,3 @@ -import { Ability } from "@prisma/client"; - -export const ADMIN_UUID = "ee2d82dd-624f-4b07-9d8d-ddee1f8fb36f"; - -export const ADMIN_TEST_DISCORD_ID = "79237403620945920"; -export const ADMIN_TEST_AVATAR = "fcfd65a3bea598905abb9ca25296816b"; -export const NZAP_UUID = "6cd9d01d-b724-498a-b706-eb70edd8a773"; -export const NZAP_TEST_DISCORD_ID = "455039198672453645"; -export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; - -export const PAGE_TITLE_KEY = "pageTitle"; - -export const ROOM_PASS_LENGTH = 4; -export const LFG_GROUP_FULL_SIZE = 4; -export const TOURNAMENT_TEAM_ROSTER_MIN_SIZE = 4; -export const TOURNAMENT_TEAM_ROSTER_MAX_SIZE = 6; -/** How many minutes before the start of the tournament check-in closes */ -export const TOURNAMENT_CHECK_IN_CLOSING_MINUTES_FROM_START = 10; -export const BEST_OF_OPTIONS = [3, 5, 7, 9] as const; - -/** How many minutes a group has to be inactive before being hidden from the looking page */ -export const LFG_GROUP_INACTIVE_MINUTES = 30; - -export const MMR_TOPX_VISIBILITY_CUTOFF = 50; -export const AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD = 7; - -export const LFG_AMOUNT_OF_STAGES_TO_GENERATE = 7; - -export const MINI_BIO_MAX_LENGTH = 280; -export const LFG_WEAPON_POOL_MAX_LENGTH = 3; - -export const CLOSE_MMR_LIMIT = 250; -export const BIT_HIGHER_MMR_LIMIT = 500; -export const HIGHER_MMR_LIMIT = 750; - -export const MAX_CHAT_MESSAGE_LENGTH = 280; - -export const checkInClosesDate = (startTime: string): Date => { - return new Date(new Date(startTime).getTime() - 1000 * 10); -}; - export const navItemsGrouped: { title: string; items: { @@ -80,192 +39,3 @@ export const navItemsGrouped: { ], }, ]; - -export const weapons = [ - "Sploosh-o-matic", - "Neo Sploosh-o-matic", - "Sploosh-o-matic 7", - "Splattershot Jr.", - "Custom Splattershot Jr.", - "Kensa Splattershot Jr.", - "Splash-o-matic", - "Neo Splash-o-matic", - "Aerospray MG", - "Aerospray RG", - "Aerospray PG", - "Splattershot", - "Tentatek Splattershot", - "Kensa Splattershot", - ".52 Gal", - ".52 Gal Deco", - "Kensa .52 Gal", - "N-ZAP '85", - "N-ZAP '89", - "N-ZAP '83", - "Splattershot Pro", - "Forge Splattershot Pro", - "Kensa Splattershot Pro", - ".96 Gal", - ".96 Gal Deco", - "Jet Squelcher", - "Custom Jet Squelcher", - "L-3 Nozzlenose", - "L-3 Nozzlenose D", - "Kensa L-3 Nozzlenose", - "H-3 Nozzlenose", - "H-3 Nozzlenose D", - "Cherry H-3 Nozzlenose", - "Squeezer", - "Foil Squeezer", - "Luna Blaster", - "Luna Blaster Neo", - "Kensa Luna Blaster", - "Blaster", - "Custom Blaster", - "Range Blaster", - "Custom Range Blaster", - "Grim Range Blaster", - "Rapid Blaster", - "Rapid Blaster Deco", - "Kensa Rapid Blaster", - "Rapid Blaster Pro", - "Rapid Blaster Pro Deco", - "Clash Blaster", - "Clash Blaster Neo", - "Carbon Roller", - "Carbon Roller Deco", - "Splat Roller", - "Krak-On Splat Roller", - "Kensa Splat Roller", - "Dynamo Roller", - "Gold Dynamo Roller", - "Kensa Dynamo Roller", - "Flingza Roller", - "Foil Flingza Roller", - "Inkbrush", - "Inkbrush Nouveau", - "Permanent Inkbrush", - "Octobrush", - "Octobrush Nouveau", - "Kensa Octobrush", - "Classic Squiffer", - "New Squiffer", - "Fresh Squiffer", - "Splat Charger", - "Firefin Splat Charger", - "Kensa Charger", - "Splatterscope", - "Firefin Splatterscope", - "Kensa Splatterscope", - "E-liter 4K", - "Custom E-liter 4K", - "E-liter 4K Scope", - "Custom E-liter 4K Scope", - "Bamboozler 14 Mk I", - "Bamboozler 14 Mk II", - "Bamboozler 14 Mk III", - "Goo Tuber", - "Custom Goo Tuber", - "Slosher", - "Slosher Deco", - "Soda Slosher", - "Tri-Slosher", - "Tri-Slosher Nouveau", - "Sloshing Machine", - "Sloshing Machine Neo", - "Kensa Sloshing Machine", - "Bloblobber", - "Bloblobber Deco", - "Explosher", - "Custom Explosher", - "Mini Splatling", - "Zink Mini Splatling", - "Kensa Mini Splatling", - "Heavy Splatling", - "Heavy Splatling Deco", - "Heavy Splatling Remix", - "Hydra Splatling", - "Custom Hydra Splatling", - "Ballpoint Splatling", - "Ballpoint Splatling Nouveau", - "Nautilus 47", - "Nautilus 79", - "Dapple Dualies", - "Dapple Dualies Nouveau", - "Clear Dapple Dualies", - "Splat Dualies", - "Enperry Splat Dualies", - "Kensa Splat Dualies", - "Glooga Dualies", - "Glooga Dualies Deco", - "Kensa Glooga Dualies", - "Dualie Squelchers", - "Custom Dualie Squelchers", - "Dark Tetra Dualies", - "Light Tetra Dualies", - "Splat Brella", - "Sorella Brella", - "Tenta Brella", - "Tenta Sorella Brella", - "Tenta Camo Brella", - "Undercover Brella", - "Undercover Sorella Brella", - "Kensa Undercover Brella", - // reskins - "Hero Shot Replica", - "Hero Blaster Replica", - "Hero Roller Replica", - "Herobrush Replica", - "Hero Charger Replica", - "Hero Slosher Replica", - "Hero Splatling Replica", - "Hero Dualie Replicas", - "Hero Brella Replica", - "Octo Shot Replica", -] as const; - -export const abilities: Ability[] = [ - "ISM", - "ISS", - "REC", - "RSU", - "SSU", - "SCU", - "SS", - "SPU", - "QR", - "QSJ", - "BRU", - "RES", - "BDU", - "MPU", - "OG", - "LDE", - "T", - "CB", - "NS", - "H", - "TI", - "RP", - "AD", - "SJ", - "OS", - "DR", - "EMPTY", -]; - -export const monthNames = [ - null, - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -] as const; diff --git a/app/core/DiscordStrategy.server.ts b/app/core/DiscordStrategy.server.ts new file mode 100644 index 000000000..878b6dbb5 --- /dev/null +++ b/app/core/DiscordStrategy.server.ts @@ -0,0 +1,136 @@ +import { DISCORD_AUTH_KEY } from "./authenticator.server"; +import { db } from "~/db"; +import type { User } from "~/db/types"; +import type { OAuth2Profile } from "remix-auth-oauth2"; +import { OAuth2Strategy } from "remix-auth-oauth2"; +import invariant from "tiny-invariant"; +import { z } from "zod"; + +interface DiscordExtraParams extends Record { + scope: string; +} + +export type LoggedInUser = Pick; + +const partialDiscordUserSchema = z.object({ + avatar: z.string().nullish(), + discriminator: z.string(), + id: z.string(), + username: z.string(), +}); +const partialDiscordConnectionsSchema = z.array( + z.object({ + visibility: z.number(), + verified: z.boolean(), + name: z.string(), + id: z.string(), + type: z.string(), + }) +); +const discordUserDetailsSchema = z.tuple([ + partialDiscordUserSchema, + partialDiscordConnectionsSchema, +]); + +export class DiscordStrategy extends OAuth2Strategy< + LoggedInUser, + OAuth2Profile, + DiscordExtraParams +> { + name = DISCORD_AUTH_KEY; + scope: string; + + constructor() { + invariant(process.env.DISCORD_CLIENT_ID); + invariant(process.env.DISCORD_CLIENT_SECRET); + invariant(process.env.BASE_URL); + + super( + { + authorizationURL: "https://discord.com/api/oauth2/authorize", + tokenURL: "https://discord.com/api/oauth2/token", + clientID: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + callbackURL: new URL("/auth/callback", process.env.BASE_URL).toString(), + }, + async ({ accessToken }) => { + const authHeader = ["Authorization", `Bearer ${accessToken}`]; + const discordResponses = await Promise.all([ + fetch("https://discord.com/api/users/@me", { + headers: [authHeader], + }), + fetch("https://discord.com/api/users/@me/connections", { + headers: [authHeader], + }), + ]); + + const [user, connections] = discordUserDetailsSchema.parse( + await Promise.all( + discordResponses.map((res) => { + if (!res.ok) throw new Error("Call to Discord API failed"); + + return res.json(); + }) + ) + ); + + const userFromDb = db.users.upsert({ + discordAvatar: user.avatar ?? null, + discordDiscriminator: user.discriminator, + discordId: user.id, + discordName: user.username, + ...this.parseConnections(connections), + }); + + return { + id: userFromDb.id, + discordId: userFromDb.discordId, + discordAvatar: userFromDb.discordAvatar, + }; + } + ); + + this.scope = "identify connections"; + } + + private parseConnections( + connections: z.infer + ) { + if (!connections) throw new Error("No connections"); + + const result: { + twitch: string | null; + twitter: string | null; + youtubeId: string | null; + } = { + twitch: null, + twitter: null, + youtubeId: null, + }; + + for (const connection of connections) { + if (connection.visibility !== 1 || !connection.verified) continue; + + switch (connection.type) { + case "twitch": + result.twitch = connection.name; + break; + case "twitter": + result.twitter = connection.name; + break; + case "youtube": + result.youtubeId = connection.id; + } + } + + return result; + } + + protected authorizationParams() { + const urlSearchParams: Record = { + scope: this.scope, + }; + + return new URLSearchParams(urlSearchParams); + } +} diff --git a/app/core/authenticator.server.ts b/app/core/authenticator.server.ts new file mode 100644 index 000000000..0b0273315 --- /dev/null +++ b/app/core/authenticator.server.ts @@ -0,0 +1,10 @@ +import { Authenticator } from "remix-auth"; +import { DiscordStrategy } from "./DiscordStrategy.server"; +import type { LoggedInUser } from "./DiscordStrategy.server"; +import { sessionStorage } from "./session.server"; + +export const DISCORD_AUTH_KEY = "discord"; + +export const authenticator = new Authenticator(sessionStorage); + +authenticator.use(new DiscordStrategy()); diff --git a/app/core/common/permissions.ts b/app/core/common/permissions.ts deleted file mode 100644 index 4f0a5c8bf..000000000 --- a/app/core/common/permissions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ADMIN_UUID } from "~/constants"; - -export function isAdmin(userId?: string) { - return userId === ADMIN_UUID; -} diff --git a/app/core/lanista.ts b/app/core/lanista.ts deleted file mode 100644 index b91d088d5..000000000 --- a/app/core/lanista.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Mode } from "@prisma/client"; -import { fetchTimeout } from "~/utils"; - -const LANISTA_REQUEST_TIMEOUT = 7000; - -/** Request Lanista to send match details to match-details endpoint. */ -export async function requestMatchDetails({ - matchId, - startTime, - endTime, - playerDiscordIds, - playedStages, -}: { - matchId: string; - startTime: Date; - endTime?: Date; - playerDiscordIds: string[]; - playedStages: { stage: string; mode: Mode }[]; -}) { - try { - if (process.env.NODE_ENV === "development") { - return; - } - if (!process.env.LANISTA_URL) { - throw new Error("process.env.LANISTA_URL not set"); - } - - const response = await fetchTimeout( - process.env.LANISTA_URL, - LANISTA_REQUEST_TIMEOUT, - { - body: JSON.stringify({ - maplist: playedStages, - requesterId: playerDiscordIds, - startTime: startTime.toISOString(), - endTime: (endTime ?? new Date()).toISOString(), - matchId, - token: process.env.LANISTA_URL_TOKEN, - }), - method: "post", - headers: [["Content-Type", "application/json"]], - } - ); - - if (!response.ok) { - throw new Error(`error code: ${response.status}`); - } - } catch (e) { - if (e instanceof Error) { - console.error("Sending match to Lanista failed: ", e.message); - } - } -} diff --git a/app/core/mmr/leaderboards.test.ts b/app/core/mmr/leaderboards.test.ts deleted file mode 100644 index ef575962c..000000000 --- a/app/core/mmr/leaderboards.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { UserLean } from "~/utils"; -import { skillsToLeaderboard } from "./leaderboards"; -import { muSigmaToSP } from "./utils"; - -const SkillsToLeaderboard = suite("skillsToLeaderboard()"); - -const USER: UserLean = { - discordAvatar: "", - discordDiscriminator: "1234", - discordId: "12341234", - discordName: "Sendou", - id: "1", -}; - -const USER_2: UserLean = { - discordAvatar: "", - discordDiscriminator: "12342", - discordId: "123412342", - discordName: "Sendou2", - id: "2", -}; - -SkillsToLeaderboard("Works with empty array", () => { - const players = skillsToLeaderboard([]); - - assert.not.ok(players.length); -}); - -SkillsToLeaderboard("Ignores skills if below amount required", () => { - const players = skillsToLeaderboard([ - { - createdAt: new Date(), - mu: 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - }, - ]); - - assert.not.ok(players.length); -}); - -SkillsToLeaderboard("Gets peak", () => { - const players = skillsToLeaderboard( - new Array(10).fill(null).map((_) => ({ - createdAt: new Date(), - mu: 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - })) - ); - - assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 })); -}); - -SkillsToLeaderboard("Ignores peaks at the start", () => { - const players = skillsToLeaderboard( - new Array(10).fill(null).map((_, i) => ({ - createdAt: new Date(), - mu: i === 0 ? 30 : 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - })) - ); - - assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 })); -}); - -SkillsToLeaderboard("Gets peak from in between", () => { - const players = skillsToLeaderboard( - new Array(10).fill(null).map((_, i) => ({ - createdAt: new Date(), - mu: i === 8 ? 30 : 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - })) - ); - - assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 30 })); -}); - -SkillsToLeaderboard("Calculates entries", () => { - const players = skillsToLeaderboard( - new Array(10).fill(null).map((_, i) => ({ - createdAt: new Date(), - mu: i === 8 ? 30 : 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - })) - ); - - assert.equal(players[0].entries, 10); -}); - -SkillsToLeaderboard("Orders by MMR", () => { - const players = skillsToLeaderboard( - new Array(10) - .fill(null) - .map((_, i) => ({ - createdAt: new Date(), - mu: i === 8 ? 30 : 10, - sigma: 2, - userId: "1", - user: USER, - amountOfSets: null, - })) - .concat( - new Array(10).fill(null).map((_) => ({ - createdAt: new Date(), - mu: 40, - sigma: 2, - userId: "2", - user: USER_2, - amountOfSets: null, - })) - ) - ); - - assert.equal(players[0].user.id, USER_2.id); -}); - -SkillsToLeaderboard.run(); diff --git a/app/core/mmr/leaderboards.ts b/app/core/mmr/leaderboards.ts deleted file mode 100644 index c9471fe10..000000000 --- a/app/core/mmr/leaderboards.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Skill } from "@prisma/client"; -import { AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD } from "~/constants"; -import { muSigmaToSP } from "./utils"; - -export interface LeaderboardEntry { - MMR: number; - user: { id: string; discordName: string }; - entries: number; -} -type SkillInput = Pick< - Skill, - "mu" | "sigma" | "userId" | "amountOfSets" | "createdAt" -> & { - user: { id: string; discordName: string }; -}; -type UserId = string; - -export function skillsToLeaderboard(skills: SkillInput[]): LeaderboardEntry[] { - const counts: Record = {}; - const peakMMR: Record = {}; - - for (const skill of skills.sort(sortSkillsByCreatedAt)) { - if (!counts[skill.userId]) { - counts[skill.userId] = skill.amountOfSets ?? 1; - } else { - counts[skill.userId] = counts[skill.userId] + (skill.amountOfSets ?? 1); - } - - if (counts[skill.userId] < AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD) { - continue; - } - - const MMR = muSigmaToSP(skill); - - if (!peakMMR[skill.userId] || peakMMR[skill.userId].MMR < MMR) { - peakMMR[skill.userId] = { - MMR, - user: { - discordName: skill.user.discordName, - id: skill.user.id, - }, - // we set this below - entries: 0, - }; - } - } - - for (const [userId, count] of Object.entries(counts)) { - if (!peakMMR[userId]) continue; - peakMMR[userId].entries = count; - } - - return Object.values(peakMMR).sort((a, b) => b.MMR - a.MMR); -} - -function sortSkillsByCreatedAt(a: SkillInput, b: SkillInput) { - return a.createdAt.getTime() - b.createdAt.getTime(); -} - -export function monthYearOptions() { - const FIRST_MONTH = 3; - const FIRST_YEAR = 2022; - - const result: { month: number; year: number }[] = []; - let month = new Date().getMonth() + 1; - let year = new Date().getFullYear(); - - do { - result.push({ month, year }); - - month--; - if (month === 0) { - month = 12; - year--; - } - } while (month >= FIRST_MONTH && year >= FIRST_YEAR); - - return result; -} - -export function monthYearIsValid({ - month, - year, -}: { - month: number; - year: number; -}) { - return monthYearOptions().some( - (option) => option.month === month && option.year === year - ); -} diff --git a/app/core/mmr/utils.test.ts b/app/core/mmr/utils.test.ts deleted file mode 100644 index 6f5cd4b8b..000000000 --- a/app/core/mmr/utils.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { suite } from "uvu"; -import { - adjustSkills, - averageTeamMMRs, - muSigmaToSP, - resolveOwnMMR, - teamSkillToExactMMR, -} from "./utils"; -import * as assert from "uvu/assert"; - -const AdjustSkills = suite("adjustSkills()"); -const ResolveOwnMMR = suite("resolveOwnMMR()"); -const TeamSkillToExactMMR = suite("teamSkillToExactMMR()"); -const AverageTeamMMRs = suite("averageTeamMMRs()"); - -const MU_AT_START = 20; -const SIGMA_AT_START = 4; - -AdjustSkills("Adjust skills to correct direction", () => { - const adjusted = adjustSkills({ - skills: ["w1", "w2", "l1", "l2"].map((userId) => ({ - mu: MU_AT_START, - sigma: SIGMA_AT_START, - userId, - })), - playerIds: { - losing: ["l1", "l2"], - winning: ["w1", "w2"], - }, - }); - - for (const skill of adjusted) { - if (skill.userId.startsWith("w")) { - if (skill.mu <= MU_AT_START || skill.sigma >= SIGMA_AT_START) { - throw new Error("Mu got worse or sigma more inaccurate after winning"); - } - } else { - if (skill.mu >= MU_AT_START || skill.sigma >= SIGMA_AT_START) { - throw new Error("Mu got better or sigma more inaccurate after losing"); - } - } - } -}); - -AdjustSkills("Handles missing skills", () => { - const adjusted = adjustSkills({ - skills: ["w2", "l1"].map((userId) => ({ - mu: MU_AT_START, - sigma: SIGMA_AT_START, - userId, - })), - playerIds: { - losing: ["l1", "l2"], - winning: ["w1", "w2"], - }, - }); - - assert.equal(adjusted.length, 4); -}); - -ResolveOwnMMR("Doesn't show own MMR if missing", () => { - const own = resolveOwnMMR({ - skills: [{ userId: "test2", mu: 20, sigma: 7 }], - user: { id: "test" }, - }); - const own2 = resolveOwnMMR({ - skills: [], - user: { id: "test" }, - }); - - assert.not.ok(own); - assert.not.ok(own2); -}); - -ResolveOwnMMR("Calculates own MMR stats correctly", () => { - const skills = new Array(9) - .fill(null) - .map((_, i) => ({ userId: `${i}`, mu: 20 + i, sigma: 7 })); - skills.push({ userId: "test", mu: 40, sigma: 7 }); - const own = resolveOwnMMR({ - skills, - user: { id: "test" }, - }); - - const valueShouldBe = muSigmaToSP({ mu: 40, sigma: 7 }); - - assert.equal(own?.topX, 5); - assert.equal(own?.value, valueShouldBe); -}); - -ResolveOwnMMR("Hides topX if not good", () => { - const skills = new Array(9) - .fill(null) - .map((_, i) => ({ userId: `${i}`, mu: 20 + i, sigma: 7 })); - skills.push({ userId: "test", mu: 1, sigma: 7 }); - const own = resolveOwnMMR({ - skills, - user: { id: "test" }, - }); - - assert.not.ok(own?.topX); -}); - -TeamSkillToExactMMR("Sums up MMR's", () => { - const skills = new Array(4) - .fill(null) - .map((_) => ({ mu: MU_AT_START, sigma: SIGMA_AT_START })); - - const teamMMR = teamSkillToExactMMR( - skills.map((s) => ({ user: { skill: [{ mu: s.mu, sigma: s.sigma }] } })) - ); - - assert.equal( - teamMMR, - muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) * 4 - ); -}); - -TeamSkillToExactMMR("Pads team MMR", () => { - const skills = new Array(3) - .fill(null) - .map((_) => ({ mu: MU_AT_START, sigma: SIGMA_AT_START })); - - const teamMMR = teamSkillToExactMMR( - skills.map((s) => ({ user: { skill: [{ mu: s.mu, sigma: s.sigma }] } })) - ); - - assert.equal( - teamMMR, - muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) * 3 + 1000 - ); - - const teamMMRNoSkills = teamSkillToExactMMR([]); - - assert.equal(teamMMRNoSkills, 4 * 1000); -}); - -AverageTeamMMRs("Gets average MMR", () => { - const MMRs = averageTeamMMRs({ - skills: [ - { mu: MU_AT_START, sigma: SIGMA_AT_START, userId: "1" }, - { mu: MU_AT_START + 1, sigma: SIGMA_AT_START, userId: "2" }, - ], - teams: [ - { id: "a", members: [{ member: { id: "1" } }, { member: { id: "2" } }] }, - ], - }); - - const expected = - (muSigmaToSP({ mu: MU_AT_START, sigma: SIGMA_AT_START }) + - muSigmaToSP({ mu: MU_AT_START + 1, sigma: SIGMA_AT_START })) / - 2; - assert.equal(MMRs["a"], Math.round(expected)); -}); - -AverageTeamMMRs("Handles teams with no skill", () => { - const MMRs = averageTeamMMRs({ - skills: [ - { mu: MU_AT_START, sigma: SIGMA_AT_START, userId: "1" }, - { mu: MU_AT_START + 1, sigma: SIGMA_AT_START, userId: "2" }, - ], - teams: [ - { id: "a", members: [{ member: { id: "1" } }, { member: { id: "2" } }] }, - { id: "b", members: [{ member: { id: "3" } }, { member: { id: "4" } }] }, - ], - }); - - assert.equal(Object.keys(MMRs).length, 1); -}); - -AdjustSkills.run(); -ResolveOwnMMR.run(); -TeamSkillToExactMMR.run(); -AverageTeamMMRs.run(); diff --git a/app/core/mmr/utils.ts b/app/core/mmr/utils.ts deleted file mode 100644 index 3a262f2f0..000000000 --- a/app/core/mmr/utils.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { Skill } from "@prisma/client"; -import clone from "just-clone"; -import { rating, ordinal, rate } from "openskill"; -import { LFG_GROUP_FULL_SIZE, MMR_TOPX_VISIBILITY_CUTOFF } from "~/constants"; -import { PlayFrontPageLoader } from "~/routes/play/index"; -import { SeedsLoaderData } from "~/routes/to/$organization.$tournament/seeds"; -import * as TournamentMatch from "~/models/TournamentMatch.server"; -import invariant from "tiny-invariant"; -import { Unpacked } from "~/utils"; - -const TAU = 0.3; - -/** Get first skill object of the array (should be ordered so that most recent skill is first) and convert it into MMR. */ -export function skillArrayToMMR( - skills: { - mu: number; - sigma: number; - }[] -) { - const skill: { mu: number; sigma: number } | undefined = skills[0]; - if (!skill) return; - - return muSigmaToSP(skill); -} - -export function muSigmaToSP(skill: { mu: number; sigma: number }) { - return toTwoDecimals(ordinal(rating(skill)) * 10 + 1000); -} - -interface TeamSkill { - user: { - skill: { - mu: number; - sigma: number; - }[]; - }; -} - -export function teamSkillToExactMMR(teamSkills: TeamSkill[]) { - let sum = 0; - - const teamSkillsClone = clone(teamSkills); - while (teamSkillsClone.length < LFG_GROUP_FULL_SIZE) { - teamSkillsClone.push({ user: { skill: [] } }); - } - - const defaultRating = rating(); - const skillsWithDefaults = teamSkillsClone.reduce((acc: TeamSkill[], cur) => { - if (cur.user.skill.length === 0) { - return [ - { - user: { - skill: [{ mu: defaultRating.mu, sigma: defaultRating.sigma }], - }, - }, - ...acc, - ]; - } - - return [cur, ...acc]; - }, []); - - for (const { user } of skillsWithDefaults) { - const MMR = skillArrayToMMR(user.skill); - if (!MMR) continue; - - sum += MMR; - } - - return toTwoDecimals(sum); -} - -export function toTwoDecimals(value: number) { - return Number(value.toFixed(2)); -} - -interface AdjustSkill { - mu: number; - sigma: number; - userId: string; -} -export function adjustSkills({ - skills, - playerIds, -}: { - skills: AdjustSkill[]; - playerIds: { - winning: string[]; - losing: string[]; - }; -}): AdjustSkill[] { - const mapToRatings = (id: string) => { - const skill = skills.find((s) => s.userId === id); - if (!skill) return rating(); - - return rating(skill); - }; - const winningTeam = playerIds.winning.map(mapToRatings); - const losingTeam = playerIds.losing.map(mapToRatings); - - const [ratedWinners, ratedLosers] = rate([winningTeam, losingTeam], { - tau: TAU, - preventSigmaIncrease: true, - }); - const ratedToReturnable = - (side: "winning" | "losing") => - (rating: Rating, i: number): AdjustSkill => ({ - mu: rating.mu, - sigma: rating.sigma, - userId: playerIds[side][i], - }); - - return [ - ...ratedWinners.map(ratedToReturnable("winning")), - ...ratedLosers.map(ratedToReturnable("losing")), - ]; -} - -export function adjustSkillsWithCancel({ - skills, - playerIds, - noUpdateUserIds, -}: { - skills: AdjustSkill[]; - playerIds: { - winning: string[]; - losing: string[]; - }; - noUpdateUserIds: string[]; -}) { - const allAdjusted = adjustSkills({ skills, playerIds }); - - return allAdjusted.filter((skill) => !noUpdateUserIds.includes(skill.userId)); -} - -export function resolveOwnMMR({ - skills, - user, -}: { - skills: { userId: string; mu: number; sigma: number }[]; - user?: { id: string }; -}): PlayFrontPageLoader["ownMMR"] { - if (!user) return; - - const ownSkillObj = skills.find((s) => s.userId === user.id); - if (!ownSkillObj) return; - - const ownSkill = muSigmaToSP(ownSkillObj); - const allSkills = skills.map((s) => muSigmaToSP(s)); - const ownPercentile = percentile(allSkills, ownSkill); - // can't be top 0% - const topX = Math.max(1, Math.round(100 - ownPercentile)); - - return { - value: ownSkill, - // we show the top x data only for those who have it good - // since probably nobody wants to know they are the bottom - // 10% or something - topX: topX > MMR_TOPX_VISIBILITY_CUTOFF ? undefined : topX, - }; -} - -// https://stackoverflow.com/a/69730272 -function percentile(arr: number[], val: number) { - let count = 0; - arr.forEach((v) => { - if (v < val) { - count++; - } else if (v == val) { - count += 0.5; - } - }); - return (100 * count) / arr.length; -} - -export function averageTeamMMRs({ - skills, - teams, -}: { - skills: Pick[]; - teams: { id: string; members: { member: { id: string } }[] }[]; -}) { - const result: SeedsLoaderData["MMRs"] = {}; - - for (const team of teams) { - let MMRSum = 0; - let playersWithSkill = 0; - for (const { member } of team.members) { - const skill = skills.find((s) => s.userId === member.id); - if (!skill) continue; - - MMRSum += muSigmaToSP(skill); - playersWithSkill++; - } - - if (playersWithSkill === 0) continue; - - result[team.id] = Math.round(MMRSum / playersWithSkill); - } - - return result; -} - -export function bracketToChangedMMRs({ - matches, - skills, -}: { - matches: TournamentMatch.AllTournamentMatchesWithRosterInfo; - skills: Pick[]; -}): Pick[] { - const result = Object.fromEntries( - skills.map((s) => [s.userId, { mu: s.mu, sigma: s.sigma, amountOfSets: 0 }]) - ); - - for (const match of matches) { - const currentSkills = Object.entries(result).map(([userId, skill]) => ({ - ...skill, - userId, - })); - const playerIds = winnersAndLosersOfTournamentMatch(match); - const newMMRs = adjustSkills({ skills: currentSkills, playerIds }); - - for (const newMMR of newMMRs) { - const newAmountOfSets = (result[newMMR.userId]?.amountOfSets ?? 0) + 1; - result[newMMR.userId] = { ...newMMR, amountOfSets: newAmountOfSets }; - } - } - - return Object.entries(result) - .map(([userId, skill]) => ({ ...skill, userId })) - .filter((skill) => skill.amountOfSets > 0); -} - -function winnersAndLosersOfTournamentMatch( - match: Unpacked -) { - const scores = match.results.reduce( - (acc, result) => { - acc[result.winner]++; - - return acc; - }, - { UPPER: 0, LOWER: 0 } - ); - invariant(scores.LOWER !== scores.UPPER, "scores.LOWER === scores.UPPER"); - const winner = scores.LOWER > scores.UPPER ? "LOWER" : "UPPER"; - - const playersWhoPlayedInSet = match.results.reduce((acc, result) => { - result.players.forEach((player) => acc.add(player.id)); - - return acc; - }, new Set()); - - return match.participants.reduce( - (acc: { winning: string[]; losing: string[] }, participant) => { - acc[winner === participant.order ? "winning" : "losing"].push( - ...participant.team.members - .map((m) => m.memberId) - .filter((id) => playersWhoPlayedInSet.has(id)) - ); - - return acc; - }, - { winning: [], losing: [] } - ); -} diff --git a/app/core/play/mapList.test.ts b/app/core/play/mapList.test.ts deleted file mode 100644 index f5a2c1653..000000000 --- a/app/core/play/mapList.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Mode } from "@prisma/client"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants"; -import { idToStage } from "../stages/stages"; -import { generateMapListForLfgMatch } from "./mapList"; - -const GenerateMapListForLfgMatch = suite("generateMapListForLfgMatch()"); - -GenerateMapListForLfgMatch("Right amount of SZ", () => { - const mapList = generateMapListForLfgMatch(); - - let amountOfSz = 0; - for (const stage of mapList) { - const stageObj = idToStage(stage.stageId); - if (stageObj.mode === "SZ") amountOfSz++; - } - - assert.equal(amountOfSz, Math.floor(LFG_AMOUNT_OF_STAGES_TO_GENERATE / 2)); -}); - -GenerateMapListForLfgMatch("Contains all modes", () => { - const mapList = generateMapListForLfgMatch(); - - const modes = new Set(); - for (const stage of mapList) { - const stageObj = idToStage(stage.stageId); - modes.add(stageObj.mode); - } - - assert.equal(modes.size, 4); -}); - -GenerateMapListForLfgMatch("No duplicate maps", () => { - const mapList = generateMapListForLfgMatch(); - - const maps = new Set(); - for (const stage of mapList) { - const stageObj = idToStage(stage.stageId); - if (maps.has(stageObj.name)) { - throw new Error(`Duplicate map: ${stageObj.name}`); - } - - maps.add(stageObj.name); - } - - assert.equal(maps.size, LFG_AMOUNT_OF_STAGES_TO_GENERATE); -}); - -GenerateMapListForLfgMatch.run(); diff --git a/app/core/play/mapList.ts b/app/core/play/mapList.ts deleted file mode 100644 index fe2ebd86c..000000000 --- a/app/core/play/mapList.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Mode } from "@prisma/client"; -import clone from "just-clone"; -import shuffle from "just-shuffle"; -import invariant from "tiny-invariant"; -import { LFG_AMOUNT_OF_STAGES_TO_GENERATE } from "~/constants"; -import { StageName, stageToId } from "../stages/stages"; - -const LEGAL_MODES: Mode[] = ["TC", "RM", "CB"]; - -// ⚠️ Every used mode needs to have at least AMOUNT_OF_STAGES_TO_GENERATE maps -const LEGAL_STAGES: Record = { - TW: [], - SZ: [ - "Ancho-V Games", - "Blackbelly Skatepark", - "Humpback Pump Track", - "Inkblot Art Academy", - "MakoMart", - "Manta Maria", - "Musselforge Fitness", - "New Albacore Hotel", - "Piranha Pit", - "Shellendorf Institute", - "Skipper Pavilion", - "Snapper Canal", - "Starfish Mainstage", - "Sturgeon Shipyard", - "The Reef", - "Wahoo World", - ], - TC: [ - "Ancho-V Games", - "Humpback Pump Track", - "Inkblot Art Academy", - "MakoMart", - "Manta Maria", - "Musselforge Fitness", - "Piranha Pit", - "Shellendorf Institute", - "Starfish Mainstage", - "Sturgeon Shipyard", - "The Reef", - ], - RM: [ - "Ancho-V Games", - "Blackbelly Skatepark", - "Humpback Pump Track", - "MakoMart", - "Manta Maria", - "Musselforge Fitness", - "Snapper Canal", - "Starfish Mainstage", - "Sturgeon Shipyard", - "The Reef", - ], - CB: [ - "Humpback Pump Track", - "Inkblot Art Academy", - "MakoMart", - "Manta Maria", - "Musselforge Fitness", - "New Albacore Hotel", - "Piranha Pit", - "Snapper Canal", - "Starfish Mainstage", - "Sturgeon Shipyard", - "The Reef", - ], -}; - -export function generateMapListForLfgMatch(): { - order: number; - stageId: number; -}[] { - const modesShuffled = shuffle(LEGAL_MODES); - const stagesShuffled = Object.fromEntries( - Object.entries(clone(LEGAL_STAGES)).map(([key, stages]) => [ - key, - shuffle(stages), - ]) - ) as Record; - const usedMaps = new Set(); - - const stageList: { name: string; mode: Mode }[] = []; - for (let i = 0; i < LFG_AMOUNT_OF_STAGES_TO_GENERATE; i++) { - const mode = (() => { - if (i !== 0 && i % 2 !== 0) { - return "SZ"; - } - - const result = modesShuffled.shift(); - invariant(result); - modesShuffled.push(result); - - return result; - })(); - - const name = (() => { - const stages = stagesShuffled[mode]; - // guaranteed to never run out of unused maps since every mode has at least AMOUNT_OF_STAGES_TO_GENERATE maps - while (usedMaps.has(stages[0])) { - stages.shift(); - } - - usedMaps.add(stages[0]); - return stages[0]; - })(); - - stageList.push({ name, mode }); - } - - return stageList.map((stage, i) => ({ - order: i + 1, - stageId: stageToId(stage), - })); -} diff --git a/app/core/play/playerInfos/data.json b/app/core/play/playerInfos/data.json deleted file mode 100644 index f9d1a1fa2..000000000 --- a/app/core/play/playerInfos/data.json +++ /dev/null @@ -1,3898 +0,0 @@ -{ - "386490504277393408": { - "peakLP": 2488.8 - }, - "167656802786344960": { - "peakXP": 2772.9, - "peakLP": 2593 - }, - "328641373655924739": { - "peakXP": 2700.6, - "peakLP": 2599.1 - }, - "352835824976658432": { - "peakXP": 2664.8, - "peakLP": 2475.8 - }, - "86905636402495488": { - "peakXP": 2586.4, - "peakLP": 2526.1 - }, - "313108618243473408": { - "peakLP": 2408.1 - }, - "125302469277384704": { - "peakXP": 2652.4, - "peakLP": 2567.6 - }, - "916323200386875432": { - "peakLP": 2442.2 - }, - "470429051258011663": { - "peakLP": 2347.9 - }, - "760247916194562098": { - "peakLP": 2428 - }, - "577090898882723853": { - "peakLP": 2386.8 - }, - "299974788427022336": { - "peakXP": 2781.8, - "peakLP": 2573.9 - }, - "531590779160887307": { - "peakLP": 2555.5 - }, - "430960977371201536": { - "peakLP": 2487.1 - }, - "551920446942085130": { - "peakLP": 2357.7 - }, - "729712289007271996": { - "peakLP": 2352.4 - }, - "553516171131879424": { - "peakLP": 2355.8 - }, - "342878560513425409": { - "peakLP": 2396.4 - }, - "312578580615921664": { - "peakLP": 2409.9 - }, - "518684215961845781": { - "peakLP": 2211.8 - }, - "420868429759905792": { - "peakLP": 2289.7 - }, - "400572498590302208": { - "peakLP": 2279.9 - }, - "345542757143805952": { - "peakLP": 2450.1 - }, - "159881946443284480": { - "peakLP": 2442.2 - }, - "355741887325536280": { - "peakLP": 2221.1 - }, - "478494512357048320": { - "peakLP": 2510.2 - }, - "326803655850459138": { - "peakLP": 2319.9 - }, - "615546880658505759": { - "peakLP": 2257.5 - }, - "486165435348418560": { - "peakLP": 2452.5 - }, - "297911745555464192": { - "peakLP": 2314.3 - }, - "279355551903907840": { - "peakLP": 2200.1 - }, - "686229736782823445": { - "peakLP": 2445.6 - }, - "81297914617401344": { - "peakLP": 2458.8 - }, - "437131246313930752": { - "peakLP": 2273.5 - }, - "194424499322617856": { - "peakLP": 2307.5 - }, - "677869084729147415": { - "peakLP": 2384.3 - }, - "701896681549070448": { - "peakXP": 2703.1, - "peakLP": 2626.4 - }, - "459352923961557002": { - "peakLP": 2400.2 - }, - "841746071679139842": { - "peakLP": 2284.1 - }, - "833211648965083146": { - "peakLP": 2313.4 - }, - "193171438985936898": { - "peakLP": 2404.3 - }, - "277507617633337344": { - "peakXP": 2807, - "peakLP": 2463.6 - }, - "408698720926957579": { - "peakXP": 2570.4, - "peakLP": 2559.8 - }, - "758751963860303884": { - "peakLP": 2444.2 - }, - "570524843242684436": { - "peakLP": 2500.5 - }, - "955536650363076668": { - "peakLP": 2427.5 - }, - "691689672933638144": { - "peakLP": 2434.5 - }, - "808345152976453653": { - "peakLP": 2338.3 - }, - "817549867072356352": { - "peakLP": 2497 - }, - "345881701739921409": { - "peakXP": 2634.2, - "peakLP": 2420.8 - }, - "366565286230491137": { - "peakXP": 2600.8, - "peakLP": 2443.4 - }, - "199763761026826241": { - "peakXP": 2811.7, - "peakLP": 2579.2 - }, - "108951379095072768": { - "peakXP": 2636.4, - "peakLP": 2647.4 - }, - "196683301988204545": { - "peakXP": 2544.1, - "peakLP": 2534.5 - }, - "516036546370207764": { - "peakLP": 2599.1 - }, - "267677923383705601": { - "peakLP": 2338 - }, - "377588155840462852": { - "peakLP": 2415.5 - }, - "251898832630185984": { - "peakLP": 2438.6 - }, - "152879023192670208": { - "peakXP": 2619.9, - "peakLP": 2592.1 - }, - "122759401613426689": { - "peakXP": 2705.2, - "peakLP": 2505 - }, - "495994616688934912": { - "peakLP": 2457.7 - }, - "381129695980290050": { - "peakXP": 2729.6, - "peakLP": 2575 - }, - "175324446821711872": { - "peakXP": 2562.1, - "peakLP": 2491.4 - }, - "308273491541622794": { - "peakXP": 2605.1, - "peakLP": 2478.5 - }, - "111997570968010752": { - "peakXP": 2575.2, - "peakLP": 2535.8 - }, - "339041223736164354": { - "peakXP": 2616.8, - "peakLP": 2570.3 - }, - "173842951686848522": { - "peakXP": 2600.6, - "peakLP": 2602.2 - }, - "107539006085926912": { - "peakLP": 2521.1 - }, - "446739718839861250": { - "peakLP": 2518.5 - }, - "91245511549403136": { - "peakXP": 2606, - "peakLP": 2454.9 - }, - "403345464138661888": { - "peakXP": 2609.4, - "peakLP": 2509.7 - }, - "353199183345352704": { - "peakXP": 2658.7, - "peakLP": 2594.7 - }, - "225238263667884052": { - "peakLP": 2226.6 - }, - "148399436287049728": { - "peakXP": 2888.4, - "peakLP": 2638 - }, - "110136136474595328": { - "peakXP": 2701.7, - "peakLP": 2658.1 - }, - "357960147399606295": { - "peakXP": 2713.1, - "peakLP": 2635.6 - }, - "336655945574645761": { - "peakLP": 2489.9 - }, - "135135051275829249": { - "peakXP": 2807.4, - "peakLP": 2576.4 - }, - "97288493029416960": { - "peakLP": 2427.4 - }, - "121421983186419712": { - "peakXP": 2567.4, - "peakLP": 2525.8 - }, - "253984054901342210": { - "peakLP": 2346.5 - }, - "393095320277614613": { - "peakXP": 2604.6, - "peakLP": 2594 - }, - "418496741088690177": { - "peakXP": 2703.7, - "peakLP": 2572.4 - }, - "340583035542175744": { - "peakXP": 2600.2, - "peakLP": 2527.5 - }, - "88071664130076672": { - "peakLP": 2463.4 - }, - "92909500100513792": { - "peakXP": 2813.8, - "peakLP": 2649.6 - }, - "409666846841896970": { - "peakLP": 2350.3 - }, - "161237342970052608": { - "peakLP": 2445 - }, - "171343171530260480": { - "peakXP": 2625.9, - "peakLP": 2535 - }, - "225610988576047105": { - "peakXP": 2605.4, - "peakLP": 2470.8 - }, - "200281013488844801": { - "peakLP": 2266.5 - }, - "288002715085307906": { - "peakXP": 2715.4, - "peakLP": 2530.1 - }, - "269354843422851072": { - "peakLP": 2408.3 - }, - "491067479452549143": { - "peakXP": 2809.7, - "peakLP": 2602.9 - }, - "125702580490731521": { - "peakXP": 2709.1, - "peakLP": 2574.1 - }, - "399978129965318145": { - "peakLP": 2394.3 - }, - "331170488602591242": { - "peakLP": 2614.5 - }, - "194751580225011713": { - "peakLP": 2422.6 - }, - "151761185279180800": { - "peakXP": 2611.7, - "peakLP": 2510.2 - }, - "96759608776888320": { - "peakLP": 2440.6 - }, - "335211569514545154": { - "peakLP": 2449.4 - }, - "147257248593346569": { - "peakLP": 2273.4 - }, - "534484857045254154": { - "peakLP": 2465.4 - }, - "402507675830714368": { - "peakLP": 2525.1 - }, - "395343063498883074": { - "peakXP": 2548.9, - "peakLP": 2611.4 - }, - "265120586387161088": { - "peakXP": 2608.2, - "peakLP": 2509 - }, - "302588425578610689": { - "peakXP": 2624.9, - "peakLP": 2562.6 - }, - "548381249518174228": { - "peakLP": 2218.8 - }, - "319135856403283969": { - "peakXP": 2605.3, - "peakLP": 2514.8 - }, - "433707823134670849": { - "peakLP": 2454.6 - }, - "358306343679033359": { - "peakXP": 2544.1, - "peakLP": 2591.2 - }, - "122730307312025600": { - "peakLP": 2500.3 - }, - "496577842746884099": { - "peakXP": 2703.4, - "peakLP": 2650.4 - }, - "720884483431202857": { - "peakXP": 2627.8, - "peakLP": 2563.4 - }, - "240879045359828993": { - "peakXP": 2604, - "peakLP": 2478.5 - }, - "757384505983238295": { - "peakLP": 2310.9 - }, - "296043113091170314": { - "peakLP": 2333 - }, - "334948440557617153": { - "peakLP": 2442.2 - }, - "338470854339854346": { - "peakXP": 2670.1, - "peakLP": 2579.7 - }, - "326295468931940353": { - "peakLP": 2557 - }, - "126463834490667008": { - "peakXP": 2828.4, - "peakLP": 2568.9 - }, - "140162271308414977": { - "peakXP": 2721.1, - "peakLP": 2541.2 - }, - "388789143171366923": { - "peakLP": 2558 - }, - "337384724689584130": { - "peakLP": 2364.4 - }, - "290623306997039105": { - "peakXP": 2805.7, - "peakLP": 2639.4 - }, - "303546189880754176": { - "peakXP": 2519.2, - "peakLP": 2543.4 - }, - "304337232808902668": { - "peakXP": 2703.6, - "peakLP": 2484.7 - }, - "201117557787197440": { - "peakLP": 2330.7 - }, - "369863034455195658": { - "peakLP": 2404.4 - }, - "317192068172742657": { - "peakXP": 2654.1, - "peakLP": 2533.8 - }, - "437977400739889163": { - "peakLP": 2464.7 - }, - "168477650497568778": { - "peakXP": 2823.8, - "peakLP": 2618.2 - }, - "90117027854823424": { - "peakLP": 2586.9 - }, - "210542754789457923": { - "peakXP": 2621.1, - "peakLP": 2363.5 - }, - "81226687773159424": { - "peakXP": 2623.8, - "peakLP": 2480.3 - }, - "388252752809164801": { - "peakLP": 2465.1 - }, - "160778600834924544": { - "peakXP": 2669, - "peakLP": 2606.1 - }, - "291263309762789376": { - "peakXP": 2831.7, - "peakLP": 2514.7 - }, - "181366773306753024": { - "peakXP": 2720.4, - "peakLP": 2440.1 - }, - "338530058572660737": { - "peakXP": 2769, - "peakLP": 2610.7 - }, - "173238005761441802": { - "peakLP": 2544.4 - }, - "406517090938388492": { - "peakXP": 2721.2, - "peakLP": 2498.9 - }, - "255880039311212557": { - "peakLP": 2350.3 - }, - "905470461691973642": { - "peakLP": 2370.6 - }, - "358780871375060993": { - "peakXP": 2685.3, - "peakLP": 2637.9 - }, - "379681632019415041": { - "peakLP": 2489.7 - }, - "361386338207006730": { - "peakXP": 2582.3, - "peakLP": 2459.7 - }, - "468490984427225099": { - "peakXP": 2644, - "peakLP": 2521.1 - }, - "163746054032850944": { - "peakXP": 2610.2, - "peakLP": 2567.8 - }, - "244152605561847808": { - "peakLP": 2485.2 - }, - "524592272411459584": { - "peakLP": 2459 - }, - "97804913941172224": { - "peakXP": 2809.5, - "peakLP": 2685.4 - }, - "414781435057274900": { - "peakXP": 2580.6, - "peakLP": 2666.2 - }, - "337605626458931200": { - "peakXP": 2636.4, - "peakLP": 2570.6 - }, - "186543007850299393": { - "peakXP": 2721, - "peakLP": 2558.7 - }, - "318271289708118016": { - "peakXP": 2675.3, - "peakLP": 2570.8 - }, - "161447642235273216": { - "peakXP": 2597.7, - "peakLP": 2511.7 - }, - "361742382963621889": { - "peakXP": 2702.9, - "peakLP": 2560.1 - }, - "391583460747378701": { - "peakLP": 2465.6 - }, - "300031188230471680": { - "peakXP": 2537.9, - "peakLP": 2512.1 - }, - "337280278030581761": { - "peakXP": 2700.9, - "peakLP": 2635.1 - }, - "439066642920505344": { - "peakXP": 2700.1, - "peakLP": 2625.8 - }, - "347127232839417856": { - "peakLP": 2529.4 - }, - "310039961309151233": { - "peakLP": 2413.5 - }, - "344900036372725760": { - "peakXP": 2673.3, - "peakLP": 2595.6 - }, - "417489824589676548": { - "peakXP": 2600.8, - "peakLP": 2566.1 - }, - "396528501718646784": { - "peakXP": 2631.2, - "peakLP": 2554.2 - }, - "212589040803774464": { - "peakXP": 2695.7, - "peakLP": 2604.6 - }, - "591724536110514186": { - "peakXP": 2738.7, - "peakLP": 2615.3 - }, - "194927608113135616": { - "peakXP": 2617.9, - "peakLP": 2536.5 - }, - "235819592851652608": { - "peakXP": 2638.8, - "peakLP": 2415.2 - }, - "331239483423064065": { - "peakLP": 2550.6 - }, - "348624503355473923": { - "peakXP": 2617.9, - "peakLP": 2465.3 - }, - "595437835104813057": { - "peakXP": 2604, - "peakLP": 2498.7 - }, - "428618743153688576": { - "peakLP": 2551.8 - }, - "133773498257637376": { - "peakLP": 2315.7 - }, - "228340094308712449": { - "peakXP": 2700.6, - "peakLP": 2579.2 - }, - "338753311022907404": { - "peakLP": 2416.9 - }, - "397208511848644619": { - "peakXP": 2807.8, - "peakLP": 2705 - }, - "78807997962260480": { - "peakXP": 2568.7, - "peakLP": 2589 - }, - "407995424801161219": { - "peakLP": 2459.1 - }, - "352198407684882433": { - "peakXP": 2561.6, - "peakLP": 2500.7 - }, - "443792531172622336": { - "peakXP": 2624.9, - "peakLP": 2540.4 - }, - "294918829874610176": { - "peakXP": 2735.6, - "peakLP": 2577.1 - }, - "550047199447351297": { - "peakLP": 2471.4 - }, - "161173517163954176": { - "peakLP": 2606.1 - }, - "304382991331753987": { - "peakXP": 2615, - "peakLP": 2446.8 - }, - "211689179891892234": { - "peakLP": 2497.8 - }, - "358623826721898496": { - "peakLP": 2592.3 - }, - "177493749830647808": { - "peakXP": 2604.5, - "peakLP": 2554.7 - }, - "443616724085112842": { - "peakXP": 2743.9 - }, - "533507436993052693": { - "peakLP": 2265.4 - }, - "228019100008316948": { - "peakLP": 2428.6 - }, - "392432544982761472": { - "peakXP": 2611.9, - "peakLP": 2467.4 - }, - "244550820337549312": { - "peakXP": 2659.7, - "peakLP": 2592.3 - }, - "133298574174846978": { - "peakXP": 2582.2, - "peakLP": 2489.1 - }, - "309099705151913984": { - "peakLP": 2495.8 - }, - "368889460378697730": { - "peakLP": 2301.1 - }, - "142824456551792640": { - "peakXP": 2748.8, - "peakLP": 2604.7 - }, - "350225756082798593": { - "peakXP": 2647.7, - "peakLP": 2615.3 - }, - "273846298090799105": { - "peakXP": 2614.2, - "peakLP": 2577.1 - }, - "82217881147805696": { - "peakLP": 2484.4 - }, - "721848238470332527": { - "peakLP": 2349.2 - }, - "592392288471351296": { - "peakLP": 2463.9 - }, - "329713886519361537": { - "peakXP": 2557.6, - "peakLP": 2315.5 - }, - "327949100681134081": { - "peakXP": 2637.3, - "peakLP": 2614 - }, - "527665691009089547": { - "peakXP": 2672.8, - "peakLP": 2611.6 - }, - "513869802947739679": { - "peakXP": 2610, - "peakLP": 2591.6 - }, - "186814039739990016": { - "peakXP": 2613.5, - "peakLP": 2496.4 - }, - "280029594327711754": { - "peakXP": 2658.4, - "peakLP": 2555.2 - }, - "117677626012860421": { - "peakXP": 2506.6, - "peakLP": 2529.7 - }, - "259676097652719616": { - "peakLP": 2282.5 - }, - "234670444849004554": { - "peakLP": 2414.9 - }, - "110449267704893440": { - "peakLP": 2353.7 - }, - "199558778381795330": { - "peakXP": 2700.6, - "peakLP": 2539.4 - }, - "303840534605070338": { - "peakLP": 2315.5 - }, - "176317226196926464": { - "peakXP": 2663.8, - "peakLP": 2455.6 - }, - "262647368183185408": { - "peakLP": 2510.8 - }, - "265816182374924298": { - "peakXP": 2985.3, - "peakLP": 2603.4 - }, - "138375977926393857": { - "peakXP": 2535.6, - "peakLP": 2549.7 - }, - "265491280115662858": { - "peakXP": 2606.4, - "peakLP": 2541.3 - }, - "207150814735761408": { - "peakXP": 2654.6, - "peakLP": 2547.8 - }, - "191679011896688640": { - "peakXP": 2618.9, - "peakLP": 2375 - }, - "180468279565877249": { - "peakXP": 2561.5, - "peakLP": 2518.8 - }, - "453094947298476032": { - "peakLP": 2442.3 - }, - "131639435551965184": { - "peakLP": 2531.8 - }, - "349217035374559233": { - "peakXP": 2802.4, - "peakLP": 2530.1 - }, - "547411365921423361": { - "peakXP": 2550.6, - "peakLP": 2616 - }, - "363063716637442049": { - "peakXP": 2610.8, - "peakLP": 2515.7 - }, - "248807429880545281": { - "peakLP": 2291.8 - }, - "383774228937179136": { - "peakXP": 2687.3, - "peakLP": 2646.6 - }, - "193772132675616770": { - "peakXP": 2703.3, - "peakLP": 2495.1 - }, - "178312208747462657": { - "peakXP": 2686.3, - "peakLP": 2541.5 - }, - "120697671336984577": { - "peakXP": 2531.7, - "peakLP": 2419.7 - }, - "377510502139691018": { - "peakXP": 2610.9, - "peakLP": 2624 - }, - "119900302647230466": { - "peakLP": 2509.1 - }, - "163332614281756672": { - "peakXP": 2705.7, - "peakLP": 2614.5 - }, - "424687347607797763": { - "peakLP": 2593.9 - }, - "414435389198041089": { - "peakXP": 2590.8, - "peakLP": 2611.7 - }, - "413497313626030110": { - "peakXP": 2800.5, - "peakLP": 2651.2 - }, - "448493571419537428": { - "peakXP": 2660.8, - "peakLP": 2635.6 - }, - "375448280106139659": { - "peakLP": 2274.9 - }, - "163771047068303360": { - "peakLP": 2485.8 - }, - "535901772171051009": { - "peakXP": 2605.8, - "peakLP": 2407.2 - }, - "303617890656190465": { - "peakLP": 2418.5 - }, - "263187744010797058": { - "peakLP": 2560.5 - }, - "487106907644100625": { - "peakLP": 2384.2 - }, - "304357002782441472": { - "peakLP": 2410.9 - }, - "309543134495244308": { - "peakXP": 2638.7, - "peakLP": 2536.1 - }, - "421048707790798849": { - "peakLP": 2387.4 - }, - "162411264197263360": { - "peakXP": 2632, - "peakLP": 2523.5 - }, - "138400548633313281": { - "peakXP": 2612, - "peakLP": 2490.9 - }, - "365592817462149122": { - "peakLP": 2498.3 - }, - "294122163957399552": { - "peakXP": 2645.6, - "peakLP": 2492.7 - }, - "97393875760451584": { - "peakXP": 2698.2, - "peakLP": 2668.5 - }, - "319987516906405890": { - "peakXP": 2609.6, - "peakLP": 2440.9 - }, - "500601101981057032": { - "peakXP": 2546.7, - "peakLP": 2534.4 - }, - "291295899387428886": { - "peakLP": 2486.4 - }, - "203704363141562368": { - "peakXP": 2751.5, - "peakLP": 2591.4 - }, - "351412592335650817": { - "peakXP": 2703.8, - "peakLP": 2505.4 - }, - "193047839721390080": { - "peakLP": 2478.5 - }, - "386456366308524045": { - "peakLP": 2488.1 - }, - "303025648849387520": { - "peakXP": 2573.7, - "peakLP": 2519 - }, - "432594198479241236": { - "peakLP": 2293.4 - }, - "535477916029550603": { - "peakXP": 2564.2, - "peakLP": 2549.7 - }, - "379381005989052427": { - "peakXP": 2712.3, - "peakLP": 2506.7 - }, - "365669143413915648": { - "peakXP": 2536.9, - "peakLP": 2439.8 - }, - "325891192636047361": { - "peakLP": 2476.2 - }, - "396876576446218273": { - "peakXP": 2747.8, - "peakLP": 2587.7 - }, - "151192098962407424": { - "peakXP": 2731.1, - "peakLP": 2614.1 - }, - "211476740512546828": { - "peakXP": 2629.1, - "peakLP": 2501.1 - }, - "222801823273058304": { - "peakXP": 2811.8, - "peakLP": 2524.5 - }, - "368901738763124736": { - "peakLP": 2497.9 - }, - "326532846959329280": { - "peakXP": 2610.7, - "peakLP": 2481.2 - }, - "129931199383601153": { - "peakXP": 2843.6, - "peakLP": 2603.4 - }, - "226762834825052171": { - "peakLP": 2300.7 - }, - "90162085337497600": { - "peakXP": 2715.5, - "peakLP": 2587 - }, - "601212946420334635": { - "peakXP": 2702.4, - "peakLP": 2542.4 - }, - "390997871886991365": { - "peakXP": 2718.6, - "peakLP": 2558.6 - }, - "122767217266917378": { - "peakXP": 2710.1, - "peakLP": 2603.3 - }, - "230797102844870656": { - "peakLP": 2420.2 - }, - "150438175863603201": { - "peakXP": 2536.7, - "peakLP": 2539 - }, - "482984260131749889": { - "peakLP": 2449.4 - }, - "206476405201043458": { - "peakXP": 2706.1, - "peakLP": 2602.6 - }, - "532353768633663499": { - "peakXP": 2621.5, - "peakLP": 2591.3 - }, - "339191951251341322": { - "peakXP": 2457.1, - "peakLP": 2550.1 - }, - "133978257006526464": { - "peakLP": 2423 - }, - "195601840040181760": { - "peakXP": 2618.3, - "peakLP": 2573.8 - }, - "151144919136862210": { - "peakLP": 2464.8 - }, - "103017450437632000": { - "peakXP": 2628.5, - "peakLP": 2498.9 - }, - "96284227053551616": { - "peakLP": 2476.5 - }, - "283837101554794497": { - "peakLP": 2257.7 - }, - "538344443481161729": { - "peakXP": 2452, - "peakLP": 2474.8 - }, - "107207187742330880": { - "peakXP": 2644.7, - "peakLP": 2566.3 - }, - "329095803224457216": { - "peakLP": 2380.2 - }, - "172508727608344576": { - "peakLP": 2460.4 - }, - "82869386993471488": { - "peakXP": 2556.3, - "peakLP": 2560.1 - }, - "429561307423834113": { - "peakLP": 2525.1 - }, - "320613778708365322": { - "peakXP": 2607.5, - "peakLP": 2500.7 - }, - "148841598686461952": { - "peakLP": 2443.8 - }, - "123514351641559042": { - "peakXP": 2601.8, - "peakLP": 2511.5 - }, - "244246880442122250": { - "peakXP": 2708.8, - "peakLP": 2649.2 - }, - "119243905139998721": { - "peakXP": 2586.1, - "peakLP": 2511.5 - }, - "331154028626968588": { - "peakXP": 2600, - "peakLP": 2506.9 - }, - "264591659776344064": { - "peakXP": 2625.4, - "peakLP": 2409.6 - }, - "322196089467830273": { - "peakXP": 2637.6, - "peakLP": 2588.6 - }, - "165280702177345537": { - "peakXP": 2621.1, - "peakLP": 2530.1 - }, - "118304894619877380": { - "peakXP": 2611.5, - "peakLP": 2637.9 - }, - "165170281814556672": { - "peakXP": 2601.6, - "peakLP": 2506 - }, - "294326973558423552": { - "peakLP": 2544.4 - }, - "432295508959297547": { - "peakXP": 2580.6, - "peakLP": 2431.9 - }, - "332893262966685696": { - "peakXP": 2703.8, - "peakLP": 2581.1 - }, - "234748746309697547": { - "peakXP": 2609.5, - "peakLP": 2420.5 - }, - "263559967842238465": { - "peakLP": 2433.4 - }, - "315610730294411264": { - "peakXP": 2700.7, - "peakLP": 2626.5 - }, - "280249060332535808": { - "peakXP": 2612.9, - "peakLP": 2619.8 - }, - "322167739743469569": { - "peakLP": 2292.9 - }, - "195204472501305345": { - "peakLP": 2499.7 - }, - "358762572893126691": { - "peakXP": 2705.8, - "peakLP": 2465.3 - }, - "333766288969302018": { - "peakLP": 2513.2 - }, - "133616977968103424": { - "peakXP": 2651.5, - "peakLP": 2594.7 - }, - "296691965259546624": { - "peakXP": 2609.8, - "peakLP": 2413 - }, - "639208208233136141": { - "peakXP": 2707.6, - "peakLP": 2544.1 - }, - "410411841001357312": { - "peakLP": 2568.5 - }, - "477555922026102818": { - "peakXP": 2602.1, - "peakLP": 2635.6 - }, - "97810683386662912": { - "peakXP": 2573.3, - "peakLP": 2476.1 - }, - "434714136379785216": { - "peakLP": 2648 - }, - "392316684381323265": { - "peakLP": 2461.1 - }, - "596501599258542091": { - "peakLP": 2386 - }, - "407939462325207044": { - "peakLP": 2438 - }, - "242377369304694785": { - "peakLP": 2422.4 - }, - "128928922837450753": { - "peakXP": 2762, - "peakLP": 2578 - }, - "460230919358251039": { - "peakXP": 2620.3, - "peakLP": 2528.4 - }, - "426240863656476714": { - "peakXP": 2651.6, - "peakLP": 2518.8 - }, - "547281071448915991": { - "peakXP": 2722.3, - "peakLP": 2532 - }, - "217807481508724747": { - "peakLP": 2520.5 - }, - "280481835358617600": { - "peakXP": 2614.1, - "peakLP": 2611.4 - }, - "119620026767507456": { - "peakXP": 2605.7, - "peakLP": 2603.3 - }, - "394889445968576522": { - "peakXP": 2702.7, - "peakLP": 2511.4 - }, - "302813847813750785": { - "peakXP": 2773.7, - "peakLP": 2595.1 - }, - "226484699537735683": { - "peakXP": 2643.3, - "peakLP": 2651.8 - }, - "219108527212331009": { - "peakLP": 2256.9 - }, - "412203683024076800": { - "peakXP": 2600.3, - "peakLP": 2645.1 - }, - "236913830276497408": { - "peakXP": 2616.4, - "peakLP": 2591.2 - }, - "358726210806743051": { - "peakLP": 2467.9 - }, - "165182067188039680": { - "peakLP": 2498.7 - }, - "204752846560821249": { - "peakLP": 2440.6 - }, - "400722136593072129": { - "peakXP": 2602.4, - "peakLP": 2518.8 - }, - "94823756299505664": { - "peakLP": 2466.7 - }, - "228235320854970369": { - "peakXP": 2731.3, - "peakLP": 2646.6 - }, - "672507579888435211": { - "peakLP": 2472.9 - }, - "400908742532792321": { - "peakXP": 2591.6, - "peakLP": 2500 - }, - "358599984335159296": { - "peakLP": 2283.8 - }, - "491010931678117899": { - "peakXP": 2640, - "peakLP": 2547.3 - }, - "619566583970529314": { - "peakXP": 2709.1, - "peakLP": 2694.1 - }, - "246237917683515393": { - "peakLP": 2485.1 - }, - "151696700124299264": { - "peakXP": 2556.3, - "peakLP": 2484.8 - }, - "149235021947863040": { - "peakXP": 2608, - "peakLP": 2425.5 - }, - "107997505853067264": { - "peakXP": 2672, - "peakLP": 2608.6 - }, - "390265218401566720": { - "peakXP": 2629.4, - "peakLP": 2541.3 - }, - "125721905301946369": { - "peakXP": 2702.3, - "peakLP": 2472 - }, - "243834840615616513": { - "peakXP": 2615, - "peakLP": 2568 - }, - "132913124448665600": { - "peakXP": 2534.8, - "peakLP": 2483 - }, - "204018694878134273": { - "peakLP": 2447.9 - }, - "435872578704506881": { - "peakLP": 2452 - }, - "690043893148155911": { - "peakXP": 2552.9, - "peakLP": 2405.6 - }, - "436528474807730176": { - "peakXP": 2701, - "peakLP": 2467.1 - }, - "400648060931407873": { - "peakXP": 2604.9, - "peakLP": 2554.7 - }, - "303274843829305344": { - "peakLP": 2464.7 - }, - "505266229548810250": { - "peakXP": 2737.8, - "peakLP": 2639.3 - }, - "367010178551513089": { - "peakXP": 2643.8, - "peakLP": 2489.6 - }, - "274245305363464192": { - "peakXP": 2906.7, - "peakLP": 2561.4 - }, - "265548501826535426": { - "peakXP": 2635.4, - "peakLP": 2613 - }, - "528365370176831528": { - "peakXP": 2544.2, - "peakLP": 2492.2 - }, - "403161067389452288": { - "peakXP": 2778.4, - "peakLP": 2555 - }, - "180426935434739713": { - "peakXP": 2551.7, - "peakLP": 2451.7 - }, - "225002835274760192": { - "peakXP": 2605.3, - "peakLP": 2529.9 - }, - "215697775814246401": { - "peakXP": 2703.2, - "peakLP": 2524.2 - }, - "666948481679622144": { - "peakLP": 2556.8 - }, - "502662555177582601": { - "peakXP": 2709.5, - "peakLP": 2532 - }, - "536525538110734356": { - "peakLP": 2400.9 - }, - "239115671848484874": { - "peakLP": 2476.2 - }, - "340583143146913794": { - "peakLP": 2418.2 - }, - "215592070465716224": { - "peakLP": 2375.8 - }, - "106575806368591872": { - "peakXP": 2651.3, - "peakLP": 2434.8 - }, - "134517959124058112": { - "peakLP": 2483.2 - }, - "464452920533057558": { - "peakXP": 2602, - "peakLP": 2466.9 - }, - "205640541579247616": { - "peakXP": 2601.4, - "peakLP": 2510.6 - }, - "325650329972637696": { - "peakXP": 2623, - "peakLP": 2552.4 - }, - "433188712063434754": { - "peakXP": 2626.1, - "peakLP": 2541.3 - }, - "401894176498647041": { - "peakLP": 2391.3 - }, - "232965484692242433": { - "peakLP": 2415.8 - }, - "109129613405351936": { - "peakXP": 2540.2, - "peakLP": 2544.4 - }, - "179651976047362048": { - "peakXP": 2706.7, - "peakLP": 2544.8 - }, - "575098212642324481": { - "peakXP": 2561.4, - "peakLP": 2526.4 - }, - "133131294375804928": { - "peakXP": 2604.1, - "peakLP": 2418.8 - }, - "605748328604631052": { - "peakLP": 2578.5 - }, - "404640285612834817": { - "peakLP": 2599.5 - }, - "279808334171865089": { - "peakLP": 2492.2 - }, - "279889896951250945": { - "peakLP": 2421.2 - }, - "361456349495689216": { - "peakXP": 2601.5, - "peakLP": 2518.5 - }, - "417302110984732673": { - "peakLP": 2500.3 - }, - "159292866844164097": { - "peakLP": 2415.2 - }, - "403977358899544074": { - "peakLP": 2541.3 - }, - "155939325832462336": { - "peakLP": 2392.3 - }, - "279717180273328129": { - "peakLP": 2483.8 - }, - "481000806624133141": { - "peakLP": 2514.1 - }, - "115691513081626628": { - "peakXP": 2705.8, - "peakLP": 2502.8 - }, - "310168504043307008": { - "peakLP": 2496 - }, - "453360556762595328": { - "peakXP": 2706.2, - "peakLP": 2705 - }, - "262322571125587970": { - "peakLP": 2470.8 - }, - "443815041804795905": { - "peakLP": 2437.3 - }, - "421982544238739468": { - "peakXP": 2606.8, - "peakLP": 2517.1 - }, - "358314893297844244": { - "peakXP": 2605.1, - "peakLP": 2423.3 - }, - "485288403307331602": { - "peakXP": 2734.7, - "peakLP": 2651.8 - }, - "397119494511919114": { - "peakXP": 2704.8, - "peakLP": 2714 - }, - "119536481428701185": { - "peakLP": 2439.3 - }, - "456971201378779146": { - "peakLP": 2405.5 - }, - "567367373972176896": { - "peakLP": 2485.2 - }, - "783604791024156685": { - "peakLP": 2508.8 - }, - "392780007350730764": { - "peakXP": 2608, - "peakLP": 2568 - }, - "341452255909904394": { - "peakLP": 2494.3 - }, - "255004513205747712": { - "peakXP": 2705.2, - "peakLP": 2526.7 - }, - "769160946211880970": { - "peakLP": 2335.5 - }, - "200684079274786816": { - "peakLP": 2464 - }, - "427624529242882049": { - "peakLP": 2419 - }, - "342545211974746122": { - "peakLP": 2265.3 - }, - "127943241319317505": { - "peakXP": 2603.7, - "peakLP": 2551 - }, - "82258465682096128": { - "peakXP": 2649.9, - "peakLP": 2512.5 - }, - "693335473673863178": { - "peakXP": 2606.2, - "peakLP": 2526.7 - }, - "266650532695769089": { - "peakLP": 2388.6 - }, - "315405555416039425": { - "peakLP": 2421.5 - }, - "219448805936857088": { - "peakLP": 2339.3 - }, - "294968857582501898": { - "peakLP": 2539 - }, - "546125305476022289": { - "peakLP": 2314.9 - }, - "795163793310089247": { - "peakXP": 2601.5, - "peakLP": 2491.9 - }, - "164942617845432320": { - "peakXP": 2670.4, - "peakLP": 2418.3 - }, - "323240225943977985": { - "peakXP": 2589.6, - "peakLP": 2492.8 - }, - "338797831806779394": { - "peakLP": 2324 - }, - "554350848352714753": { - "peakXP": 2641.8, - "peakLP": 2440.6 - }, - "136618311386398720": { - "peakXP": 2565.6, - "peakLP": 2489.9 - }, - "194144473968541696": { - "peakLP": 2466.7 - }, - "705601135469264988": { - "peakXP": 2548.6 - }, - "269902331636744192": { - "peakLP": 2464 - }, - "917605275706462248": { - "peakLP": 2414.5 - }, - "653426751950553089": { - "peakLP": 2298.4 - }, - "223150152771305472": { - "peakLP": 2469.9 - }, - "268812447664701440": { - "peakLP": 2325.8 - }, - "355380080228433921": { - "peakXP": 2613.9, - "peakLP": 2409 - }, - "82195381156315136": { - "peakXP": 2701.3, - "peakLP": 2483.1 - }, - "122748862262673410": { - "peakLP": 2480.9 - }, - "284079446711861260": { - "peakLP": 2477.2 - }, - "152796728247058433": { - "peakXP": 2653.7, - "peakLP": 2614.5 - }, - "189021480527331328": { - "peakXP": 2661.4, - "peakLP": 2620.7 - }, - "505800753982603314": { - "peakXP": 2601.8, - "peakLP": 2323.8 - }, - "327183005598744579": { - "peakXP": 2610.6, - "peakLP": 2455.5 - }, - "114889120379043843": { - "peakXP": 2732, - "peakLP": 2486 - }, - "323881879575199745": { - "peakXP": 2593.4, - "peakLP": 2651.2 - }, - "214523097905102849": { - "peakLP": 2407.5 - }, - "93442510289907712": { - "peakXP": 2615.6, - "peakLP": 2575 - }, - "249466317554450432": { - "peakXP": 2686.9, - "peakLP": 2462.7 - }, - "401870080855506947": { - "peakXP": 2736.3, - "peakLP": 2637.9 - }, - "400325796386045953": { - "peakXP": 2556.6, - "peakLP": 2573.9 - }, - "265500149298626561": { - "peakXP": 2602.5, - "peakLP": 2441.7 - }, - "469973474065121301": { - "peakLP": 2509.2 - }, - "330044344738381834": { - "peakXP": 2632.7, - "peakLP": 2527.5 - }, - "158074707067928577": { - "peakXP": 2606.9, - "peakLP": 2521.1 - }, - "107263003031764992": { - "peakXP": 2702.3, - "peakLP": 2685.4 - }, - "334452189185572875": { - "peakLP": 2505.6 - }, - "418051841264189441": { - "peakLP": 2381.3 - }, - "412599483096891393": { - "peakXP": 2882.1, - "peakLP": 2620.7 - }, - "184415457078411265": { - "peakXP": 2695.6, - "peakLP": 2535.5 - }, - "79894491800014848": { - "peakXP": 2763.7, - "peakLP": 2660.5 - }, - "164748705713356800": { - "peakLP": 2554.7 - }, - "347926050950348811": { - "peakXP": 2812.5, - "peakLP": 2568.8 - }, - "131198236760342529": { - "peakXP": 2707.5, - "peakLP": 2544.3 - }, - "321429229914816515": { - "peakLP": 2443.8 - }, - "92367413483421696": { - "peakXP": 2608.9, - "peakLP": 2463.7 - }, - "549208055531831308": { - "peakLP": 2484.4 - }, - "403962004739588096": { - "peakXP": 2710.2, - "peakLP": 2281.4 - }, - "257318683423014913": { - "peakLP": 2567.9 - }, - "189190328719376395": { - "peakLP": 2274.8 - }, - "188975805265608704": { - "peakLP": 2429.9 - }, - "320297534759370752": { - "peakLP": 2509.2 - }, - "501462106802094091": { - "peakLP": 2500.6 - }, - "672934395891417098": { - "peakXP": 2703.2, - "peakLP": 2601.2 - }, - "307659621068308480": { - "peakXP": 2620.4, - "peakLP": 2522.7 - }, - "200777324520538113": { - "peakLP": 2353.7 - }, - "271300357253103616": { - "peakLP": 2415.3 - }, - "703682281067380817": { - "peakLP": 2298.3 - }, - "319245965498384387": { - "peakXP": 2603.9, - "peakLP": 2597.7 - }, - "426023282593103872": { - "peakLP": 2507.2 - }, - "659157766690308117": { - "peakLP": 2226.3 - }, - "367391033035849729": { - "peakLP": 2497.2 - }, - "233346892073926656": { - "peakXP": 2614.5, - "peakLP": 2571.2 - }, - "149504203654430720": { - "peakXP": 2648.2, - "peakLP": 2554.7 - }, - "167427360059162625": { - "peakXP": 2611.8, - "peakLP": 2448.1 - }, - "168151421013721088": { - "peakLP": 2416.7 - }, - "197973727743442944": { - "peakLP": 2496.7 - }, - "721521981979230289": { - "peakXP": 2615.9, - "peakLP": 2551 - }, - "437661561318932510": { - "peakXP": 2621.7, - "peakLP": 2490.1 - }, - "549760586850893825": { - "peakXP": 2660.8, - "peakLP": 2533.8 - }, - "374657412432658432": { - "peakLP": 2350.4 - }, - "285165427544293379": { - "peakXP": 2606.1, - "peakLP": 2618.4 - }, - "185903638034644992": { - "peakLP": 2515 - }, - "274429786456326144": { - "peakXP": 2636.3, - "peakLP": 2541 - }, - "543915575257202698": { - "peakLP": 2259.6 - }, - "107232503747596288": { - "peakLP": 2485.2 - }, - "547026466626338816": { - "peakLP": 2415.4 - }, - "462785588194443266": { - "peakLP": 2441.7 - }, - "463726823415480320": { - "peakLP": 2313.4 - }, - "507415412997226506": { - "peakXP": 2572.7, - "peakLP": 2523.5 - }, - "334871631015051264": { - "peakLP": 2371.3 - }, - "597518397290315807": { - "peakLP": 2355.7 - }, - "605333997106495499": { - "peakXP": 2624.9, - "peakLP": 2492.2 - }, - "125745173371027456": { - "peakXP": 2605.6, - "peakLP": 2608.6 - }, - "498710435915104266": { - "peakXP": 2646.2, - "peakLP": 2657.5 - }, - "720626395411382363": { - "peakLP": 2352.4 - }, - "369336491316346880": { - "peakLP": 2493.6 - }, - "424093594736001025": { - "peakLP": 2532.1 - }, - "406845086253580300": { - "peakLP": 2418.8 - }, - "452100472656887848": { - "peakXP": 2666.6, - "peakLP": 2492.8 - }, - "229636674215346176": { - "peakLP": 2377.2 - }, - "723959941466947616": { - "peakLP": 2536.4 - }, - "455295354318094347": { - "peakLP": 2291.8 - }, - "413788772237574145": { - "peakLP": 2473.9 - }, - "149181692916137984": { - "peakLP": 2495.6 - }, - "68811718473555968": { - "peakLP": 2292.8 - }, - "681164713400467614": { - "peakXP": 2648.2, - "peakLP": 2525.6 - }, - "411621214994432000": { - "peakLP": 2685.3 - }, - "358597619972440065": { - "peakXP": 2601.2, - "peakLP": 2464.2 - }, - "330599850049798146": { - "peakXP": 2610.7, - "peakLP": 2425.9 - }, - "579196153644449793": { - "peakLP": 2428.2 - }, - "734456065898315889": { - "peakXP": 2622.5, - "peakLP": 2528.4 - }, - "196374663608139780": { - "peakXP": 2601.2, - "peakLP": 2649.8 - }, - "543210029613056035": { - "peakLP": 2506.9 - }, - "753593759492472844": { - "peakXP": 2803.9, - "peakLP": 2685.4 - }, - "596373803878318102": { - "peakLP": 2285.8 - }, - "112730204220235776": { - "peakLP": 2211.8 - }, - "416642857646948367": { - "peakXP": 2577, - "peakLP": 2556.9 - }, - "267963609924108288": { - "peakLP": 2324.7 - }, - "720997831359463528": { - "peakXP": 2553.1, - "peakLP": 2459.8 - }, - "675752882682724352": { - "peakXP": 2658.1, - "peakLP": 2487.1 - }, - "600655259018330112": { - "peakLP": 2377 - }, - "219092034017820672": { - "peakXP": 2609.4, - "peakLP": 2479.2 - }, - "495290206799265831": { - "peakXP": 2612.9, - "peakLP": 2545.6 - }, - "178239358422220800": { - "peakLP": 2491.4 - }, - "574341164153700367": { - "peakLP": 2255.1 - }, - "133360321497792512": { - "peakLP": 2368.6 - }, - "282291151707439104": { - "peakXP": 2606, - "peakLP": 2645.1 - }, - "410310445337935875": { - "peakLP": 2466.8 - }, - "621119972873207840": { - "peakXP": 2637.1, - "peakLP": 2568.9 - }, - "417040760194465803": { - "peakLP": 2471.1 - }, - "416992682381148160": { - "peakXP": 2734.5, - "peakLP": 2523.7 - }, - "334507367813218306": { - "peakXP": 2671.5, - "peakLP": 2549.7 - }, - "727657273853476904": { - "peakXP": 2607.9, - "peakLP": 2471.6 - }, - "715044844271042601": { - "peakLP": 2245.6 - }, - "727641317546655864": { - "peakLP": 2378.4 - }, - "353262885721341954": { - "peakLP": 2372 - }, - "278293684779221002": { - "peakLP": 2366.5 - }, - "140942181023219713": { - "peakLP": 2485.7 - }, - "501567516662038538": { - "peakXP": 2601.8, - "peakLP": 2492.2 - }, - "253266410661347339": { - "peakXP": 2555.3, - "peakLP": 2574.7 - }, - "388079291356807187": { - "peakXP": 2660.4, - "peakLP": 2448.9 - }, - "386500682049650688": { - "peakLP": 2511.6 - }, - "526081844043579393": { - "peakXP": 2610.8, - "peakLP": 2393.4 - }, - "678848011090264075": { - "peakLP": 2484.9 - }, - "649694730921443368": { - "peakLP": 2508.1 - }, - "481884604676964362": { - "peakLP": 2226.5 - }, - "539966444688113664": { - "peakLP": 2367.1 - }, - "504269718106931211": { - "peakLP": 2337.9 - }, - "165221589560983552": { - "peakLP": 2406.9 - }, - "244152430177026049": { - "peakLP": 2355.7 - }, - "285411952338141185": { - "peakLP": 2555.2 - }, - "197735867337539584": { - "peakLP": 2422.4 - }, - "123995586138472448": { - "peakLP": 2476.5 - }, - "138866309655035904": { - "peakXP": 2805.4, - "peakLP": 2615.4 - }, - "438500441228181504": { - "peakLP": 2329.6 - }, - "381526900234911747": { - "peakXP": 2649.9, - "peakLP": 2638.1 - }, - "636314171113209877": { - "peakLP": 2285.3 - }, - "172780114663309313": { - "peakLP": 2212.1 - }, - "479125178962411520": { - "peakLP": 2429.1 - }, - "582583044012113920": { - "peakLP": 2394.8 - }, - "457473830022152212": { - "peakXP": 2653.1, - "peakLP": 2540.4 - }, - "459392726035726338": { - "peakLP": 2354.4 - }, - "273418210051751936": { - "peakXP": 2700.7, - "peakLP": 2556 - }, - "138034502692765696": { - "peakXP": 2620, - "peakLP": 2478.6 - }, - "87644325265035264": { - "peakXP": 2867.8, - "peakLP": 2478.2 - }, - "148491649088094208": { - "peakLP": 2301.6 - }, - "151006991387459594": { - "peakXP": 2805.6, - "peakLP": 2557.4 - }, - "296113530065649664": { - "peakLP": 2497.8 - }, - "232197030087360512": { - "peakLP": 2609.2 - }, - "140092601263980544": { - "peakXP": 2586.9, - "peakLP": 2631.3 - }, - "259526563324755968": { - "peakXP": 2600.8, - "peakLP": 2528.5 - }, - "140595308198494208": { - "peakLP": 2519 - }, - "94941539632943104": { - "peakXP": 2780.3, - "peakLP": 2536.2 - }, - "95659633523294208": { - "peakXP": 2610.2, - "peakLP": 2534.4 - }, - "526038317322600468": { - "peakXP": 2718.1, - "peakLP": 2529 - }, - "203955586394488832": { - "peakXP": 2614.1, - "peakLP": 2492.9 - }, - "526883393485406208": { - "peakXP": 2702.3, - "peakLP": 2546.3 - }, - "433708069654888448": { - "peakXP": 2617, - "peakLP": 2594.1 - }, - "190925734531825665": { - "peakXP": 2563.3, - "peakLP": 2512.5 - }, - "220344765240180738": { - "peakXP": 3018.5, - "peakLP": 2572.2 - }, - "363449275239366657": { - "peakLP": 2513.7 - }, - "181302130739642379": { - "peakLP": 2468.6 - }, - "124025554549735427": { - "peakXP": 2491, - "peakLP": 2499.8 - }, - "264880608562511882": { - "peakLP": 2231.5 - }, - "232451114593812480": { - "peakXP": 2655.4, - "peakLP": 2584.2 - }, - "350922638522187776": { - "peakXP": 2584.9, - "peakLP": 2514.7 - }, - "138728444073738240": { - "peakLP": 2651.2 - }, - "439290026149543956": { - "peakLP": 2210.6 - }, - "630992676266967050": { - "peakXP": 2607, - "peakLP": 2453.8 - }, - "200168079123021824": { - "peakLP": 2413.8 - }, - "219877773605470211": { - "peakXP": 2652.1, - "peakLP": 2570.3 - }, - "529286128474259476": { - "peakXP": 2701.1, - "peakLP": 2549.5 - }, - "709088738684305418": { - "peakLP": 2351.4 - }, - "171079819008802818": { - "peakLP": 2405.6 - }, - "468596581357060097": { - "peakXP": 2608.9, - "peakLP": 2524 - }, - "537269542515114035": { - "peakXP": 2622.7, - "peakLP": 2425.3 - }, - "731230341225775184": { - "peakLP": 2524.3 - }, - "342072996275683338": { - "peakXP": 2516, - "peakLP": 2483.4 - }, - "912412595900809276": { - "peakXP": 2603.4, - "peakLP": 2442.8 - }, - "220681972991787010": { - "peakLP": 2215.5 - }, - "343368716303728641": { - "peakLP": 2245.6 - }, - "96284351808937984": { - "peakXP": 2650.1, - "peakLP": 2541.7 - }, - "335761219086581760": { - "peakLP": 2257.9 - }, - "297233823937069068": { - "peakLP": 2333.5 - }, - "109962465965559808": { - "peakXP": 2740, - "peakLP": 2660.8 - }, - "241892796066299905": { - "peakLP": 2519.4 - }, - "497147537526882327": { - "peakXP": 2720.7, - "peakLP": 2559.5 - }, - "290580482607939585": { - "peakLP": 2508.4 - }, - "707552906701963395": { - "peakLP": 2496.8 - }, - "95665291681333248": { - "peakXP": 2705.2, - "peakLP": 2586.6 - }, - "214101222628655104": { - "peakXP": 2613.3, - "peakLP": 2371.8 - }, - "159817831402307584": { - "peakXP": 2616.9, - "peakLP": 2531.5 - }, - "339429839318810635": { - "peakLP": 2413 - }, - "262570272622772225": { - "peakXP": 2603.6, - "peakLP": 2460.7 - }, - "189125119937871872": { - "peakXP": 2753.2, - "peakLP": 2571.7 - }, - "161173670729875456": { - "peakXP": 2614.8, - "peakLP": 2520.5 - }, - "112851777048567808": { - "peakXP": 2575.1, - "peakLP": 2437.9 - }, - "308351095963648021": { - "peakXP": 2658.7, - "peakLP": 2584.2 - }, - "209858231584030721": { - "peakXP": 2695.6, - "peakLP": 2444 - }, - "211370564219174912": { - "peakXP": 2708.3, - "peakLP": 2492.3 - }, - "260602342309756940": { - "peakXP": 2907.5, - "peakLP": 2685.4 - }, - "285857051987542016": { - "peakXP": 2605.9, - "peakLP": 2651.2 - }, - "249257371182956544": { - "peakLP": 2521.1 - }, - "292338070886547457": { - "peakLP": 2423.3 - }, - "323496682920738817": { - "peakXP": 2808.9, - "peakLP": 2632.2 - }, - "216913385265037312": { - "peakLP": 2436.3 - }, - "170866242624815105": { - "peakXP": 2606.3, - "peakLP": 2651.4 - }, - "365194648949489665": { - "peakLP": 2451.9 - }, - "413801409025146900": { - "peakXP": 2627.7, - "peakLP": 2543.3 - }, - "738580607071944775": { - "peakXP": 2700.6, - "peakLP": 2591 - }, - "569507748077764648": { - "peakLP": 2328.4 - }, - "643923512171692064": { - "peakXP": 2610.1, - "peakLP": 2492.2 - }, - "219606661831065602": { - "peakLP": 2350.1 - }, - "388399453129670658": { - "peakXP": 2636.7, - "peakLP": 2483.9 - }, - "727210635531780136": { - "peakLP": 2471.7 - }, - "533510530321743872": { - "peakLP": 2409.5 - }, - "402500693530902529": { - "peakLP": 2390.5 - }, - "153749232690528256": { - "peakLP": 2390 - }, - "338085552437985290": { - "peakLP": 2562.2 - }, - "447088315535982614": { - "peakXP": 2575.7, - "peakLP": 2368.2 - }, - "239982876307750913": { - "peakLP": 2520.4 - }, - "672584711289307149": { - "peakXP": 2614.4, - "peakLP": 2568.9 - }, - "122767591403028480": { - "peakXP": 2607, - "peakLP": 2476.8 - }, - "572880022969516062": { - "peakLP": 2380.2 - }, - "324602032147267584": { - "peakXP": 2552.9, - "peakLP": 2433.6 - }, - "600886777129205770": { - "peakXP": 2704.3, - "peakLP": 2545.7 - }, - "364094048719339520": { - "peakXP": 2647, - "peakLP": 2462.9 - }, - "441376533077164043": { - "peakXP": 2618.8, - "peakLP": 2618.4 - }, - "485080775226556416": { - "peakLP": 2299.1 - }, - "601332262046531588": { - "peakXP": 2608, - "peakLP": 2495.2 - }, - "617531521498087488": { - "peakLP": 2370.2 - }, - "412096472574722048": { - "peakLP": 2435.9 - }, - "376464019659292684": { - "peakLP": 2571 - }, - "309425605831557120": { - "peakLP": 2396.9 - }, - "394214426754154497": { - "peakXP": 2606.8, - "peakLP": 2513.7 - }, - "398588322068299787": { - "peakLP": 2595.2 - }, - "603667186254479368": { - "peakLP": 2363.4 - }, - "407373085164044288": { - "peakXP": 2709.1, - "peakLP": 2529.5 - }, - "209722471425900556": { - "peakLP": 2380.1 - }, - "322516939706728459": { - "peakLP": 2572 - }, - "338284464524230656": { - "peakLP": 2459 - }, - "559390649166594060": { - "peakXP": 2700.7, - "peakLP": 2572.4 - }, - "551445135179644948": { - "peakXP": 2614.2, - "peakLP": 2465.6 - }, - "380496021127692298": { - "peakXP": 2550.7, - "peakLP": 2492.8 - }, - "158163424004538369": { - "peakXP": 2566.4, - "peakLP": 2694.1 - }, - "359491102438588419": { - "peakXP": 2602.4, - "peakLP": 2486.4 - }, - "229065870939389952": { - "peakLP": 2568.3 - }, - "724498097547509792": { - "peakLP": 2449.9 - }, - "463050803716947978": { - "peakXP": 2662.5, - "peakLP": 2629.5 - }, - "125301875863060480": { - "peakXP": 2761.8, - "peakLP": 2513.9 - }, - "229638846776737792": { - "peakLP": 2465.5 - }, - "617773181004152853": { - "peakLP": 2217.2 - }, - "675299701117157377": { - "peakXP": 2602.9, - "peakLP": 2523.7 - }, - "286216823492575233": { - "peakLP": 2346.8 - }, - "399281912134369281": { - "peakLP": 2322.5 - }, - "676232677430525990": { - "peakLP": 2347.3 - }, - "251451448783667203": { - "peakLP": 2409 - }, - "399314892902891524": { - "peakXP": 2642.6, - "peakLP": 2498.7 - }, - "473153382039814145": { - "peakXP": 2625.3, - "peakLP": 2525 - }, - "245716846140063744": { - "peakLP": 2320.7 - }, - "737495124086358049": { - "peakXP": 2666.7, - "peakLP": 2479.2 - }, - "228573709038911490": { - "peakLP": 2505.6 - }, - "343758521663225859": { - "peakXP": 2603.1, - "peakLP": 2411.9 - }, - "148203561392537600": { - "peakXP": 2608.9, - "peakLP": 2533.2 - }, - "705137811866517534": { - "peakLP": 2321.2 - }, - "81398293669617664": { - "peakLP": 2444.3 - }, - "467876387122970635": { - "peakXP": 2615, - "peakLP": 2506.9 - }, - "212673938474401796": { - "peakXP": 2706, - "peakLP": 2562.3 - }, - "204120701794254848": { - "peakLP": 2417.1 - }, - "576903053089964068": { - "peakLP": 2367.4 - }, - "274298949039292416": { - "peakXP": 2604.5, - "peakLP": 2615.4 - }, - "460407449799884810": { - "peakLP": 2413.3 - }, - "239803554569650177": { - "peakXP": 2746.3, - "peakLP": 2441.1 - }, - "351177972851539968": { - "peakLP": 2431.8 - }, - "489290081631469588": { - "peakXP": 2562.8, - "peakLP": 2522.2 - }, - "165894767454978049": { - "peakLP": 2519 - }, - "442385618450907148": { - "peakXP": 2694.2, - "peakLP": 2512.7 - }, - "694565721174442004": { - "peakLP": 2427.2 - }, - "406690221694910464": { - "peakLP": 2223.6 - }, - "380313945594134529": { - "peakLP": 2493 - }, - "395767605282996224": { - "peakLP": 2378.7 - }, - "343800838394740737": { - "peakLP": 2355.5 - }, - "324220328094072852": { - "peakLP": 2488.1 - }, - "402486930584895500": { - "peakLP": 2512.7 - }, - "209321218824863744": { - "peakXP": 2705.8, - "peakLP": 2470.8 - }, - "654521451948277761": { - "peakXP": 2586.9 - }, - "406649060993400845": { - "peakLP": 2411.7 - }, - "383819379474825216": { - "peakXP": 2610.5 - }, - "186892892952854529": { - "peakLP": 2400.3 - }, - "268512093987143691": { - "peakXP": 2705.1, - "peakLP": 2595.6 - }, - "429106528419708929": { - "peakXP": 2565.7, - "peakLP": 2272.6 - }, - "719693199681126442": { - "peakLP": 2504.5 - }, - "712120361117810700": { - "peakLP": 2367 - }, - "601122578085707787": { - "peakXP": 2641.5, - "peakLP": 2645.1 - }, - "214734548485931008": { - "peakLP": 2359.2 - }, - "605502479504769034": { - "peakLP": 2400.8 - }, - "494560762920960001": { - "peakLP": 2545.6 - }, - "350386286256848896": { - "peakLP": 2397.5 - }, - "574626573152419840": { - "peakLP": 2357.2 - }, - "352207524390240257": { - "peakLP": 2361.4 - }, - "266071344611983363": { - "peakLP": 2567.6 - }, - "695655956805058662": { - "peakLP": 2394.8 - }, - "446415020595609622": { - "peakLP": 2375.6 - }, - "121413545475833857": { - "peakLP": 2521.6 - }, - "568833290052567047": { - "peakLP": 2296.4 - }, - "742237990851444798": { - "peakLP": 2511.9 - }, - "659679801832898560": { - "peakLP": 2335.8 - }, - "781472576030638140": { - "peakLP": 2486.9 - }, - "415614839663362048": { - "peakLP": 2352.9 - }, - "384052924671721476": { - "peakLP": 2293.2 - }, - "661328509712728066": { - "peakXP": 2639.5, - "peakLP": 2546.7 - }, - "410566152394244106": { - "peakLP": 2219.3 - }, - "801874474370596884": { - "peakLP": 2345.8 - }, - "292477059136225280": { - "peakLP": 2237.4 - }, - "578346152605515807": { - "peakLP": 2389.7 - }, - "586743374007238667": { - "peakLP": 2398.9 - }, - "144680177354539008": { - "peakXP": 2600.9, - "peakLP": 2668.5 - }, - "164128696414896130": { - "peakLP": 2479.3 - }, - "427046121336995850": { - "peakXP": 2716.6, - "peakLP": 2615.6 - }, - "163423102074617856": { - "peakLP": 2409 - }, - "564366245009293330": { - "peakLP": 2264.1 - }, - "458128164519149568": { - "peakXP": 2701.1, - "peakLP": 2544.2 - }, - "799464305371381800": { - "peakLP": 2614 - }, - "88358837286871040": { - "peakLP": 2259.7 - }, - "65303547662573568": { - "peakLP": 2489.9 - }, - "441232639358402560": { - "peakXP": 2908.9, - "peakLP": 2647.4 - }, - "322793789532143618": { - "peakXP": 2909.5, - "peakLP": 2639.4 - }, - "114915087101329409": { - "peakLP": 2456.1 - }, - "470424843905400836": { - "peakXP": 2598.3, - "peakLP": 2467.3 - }, - "313448566486073346": { - "peakLP": 2415.7 - }, - "269827303591247875": { - "peakXP": 2565.1, - "peakLP": 2500.8 - }, - "568571495048019979": { - "peakLP": 2315.1 - }, - "79237403620945920": { - "peakXP": 2701, - "peakLP": 2570.2 - }, - "167655526300254209": { - "peakLP": 2538.5 - }, - "87686791515090944": { - "peakLP": 2456.6 - }, - "327168350759288832": { - "peakXP": 2703.4, - "peakLP": 2510.6 - }, - "726459443713409076": { - "peakXP": 2714.7, - "peakLP": 2533.8 - }, - "283645464572723201": { - "peakXP": 2701.4, - "peakLP": 2544.8 - }, - "725778044328870018": { - "peakLP": 2484.9 - }, - "536639683896016917": { - "peakLP": 2460.7 - }, - "806282522845249556": { - "peakLP": 2301.4 - }, - "515944342112960535": { - "peakLP": 2261.2 - }, - "400953240461639681": { - "peakLP": 2440.6 - }, - "220399257910247424": { - "peakLP": 2377.1 - }, - "419596743504560140": { - "peakLP": 2262.3 - }, - "254254067688275968": { - "peakLP": 2536.8 - }, - "479215189993062401": { - "peakLP": 2449.2 - }, - "523877916757590024": { - "peakLP": 2361.6 - }, - "524401105485037571": { - "peakLP": 2464 - }, - "517212692248002590": { - "peakLP": 2507.8 - }, - "287561416267923456": { - "peakLP": 2504 - }, - "423519639629463553": { - "peakXP": 2590, - "peakLP": 2467.1 - }, - "565572685204029473": { - "peakLP": 2360.7 - }, - "345026508311756811": { - "peakLP": 2400.9 - }, - "313118674242502656": { - "peakXP": 2703.1, - "peakLP": 2424.7 - }, - "805558789041291274": { - "peakLP": 2416 - }, - "687713101537083511": { - "peakLP": 2618.1 - }, - "215327834707722241": { - "peakLP": 2264.5 - }, - "190536017692983297": { - "peakLP": 2226.5 - }, - "184791233972011008": { - "peakXP": 2610.4, - "peakLP": 2516.8 - }, - "87607171906293760": { - "peakLP": 2428 - }, - "360259023389327361": { - "peakLP": 2412.5 - }, - "483796720824746015": { - "peakXP": 2612.6, - "peakLP": 2579.7 - }, - "463884394151673872": { - "peakLP": 2309.8 - }, - "409713830701170689": { - "peakXP": 2612.1, - "peakLP": 2581.7 - }, - "485851523247505409": { - "peakXP": 2607, - "peakLP": 2446.4 - }, - "358253763888283650": { - "peakLP": 2380.2 - }, - "408993809356161041": { - "peakLP": 2216.3 - }, - "658444942636744719": { - "peakLP": 2205.2 - }, - "175737660151693312": { - "peakLP": 2433.2 - }, - "784695580920119297": { - "peakXP": 2903.4, - "peakLP": 2525.2 - }, - "310515692501860354": { - "peakXP": 2538.9, - "peakLP": 2498.8 - }, - "196408087723245570": { - "peakLP": 2446.3 - }, - "290910961362141184": { - "peakLP": 2433.7 - }, - "246393455905996810": { - "peakLP": 2350.1 - }, - "217626685439213568": { - "peakLP": 2254.3 - }, - "698875563271520267": { - "peakLP": 2246.9 - }, - "631534013953343498": { - "peakLP": 2406.5 - }, - "526429851922726914": { - "peakXP": 2700.3, - "peakLP": 2630.2 - }, - "256536949756657664": { - "peakLP": 2337 - }, - "509504613913985037": { - "peakLP": 2478.5 - }, - "480776934297567243": { - "peakLP": 2357.6 - }, - "552658982414581770": { - "peakLP": 2609.2 - }, - "816155488198459402": { - "peakLP": 2302.8 - }, - "213761245734502400": { - "peakXP": 2602.5, - "peakLP": 2705 - }, - "177917167403139082": { - "peakXP": 2552.8, - "peakLP": 2403.3 - }, - "417725809436917761": { - "peakLP": 2445.1 - }, - "325517613633372160": { - "peakXP": 2550.2, - "peakLP": 2487.6 - }, - "133065373049290752": { - "peakLP": 2514.1 - }, - "453753483427053568": { - "peakXP": 2905.5, - "peakLP": 2624.5 - }, - "333105374192336907": { - "peakLP": 2560.8 - }, - "700900194010660935": { - "peakLP": 2391.4 - }, - "303590352382853131": { - "peakXP": 2589.3, - "peakLP": 2451.6 - }, - "243075163153760258": { - "peakLP": 2472.9 - }, - "407283879972438026": { - "peakLP": 2281.6 - }, - "257723000860704768": { - "peakLP": 2296.2 - }, - "655496708171759618": { - "peakLP": 2531.6 - }, - "685217132379897921": { - "peakLP": 2510.2 - }, - "198441879694606336": { - "peakLP": 2318.8 - }, - "417660499006849035": { - "peakXP": 2726.8, - "peakLP": 2537 - }, - "81154649993785344": { - "peakXP": 2807.6, - "peakLP": 2653.6 - }, - "114173213004529671": { - "peakLP": 2436.5 - }, - "133774291358449664": { - "peakXP": 2711.6, - "peakLP": 2660.5 - }, - "310587980009963521": { - "peakXP": 2630.9, - "peakLP": 2477 - }, - "296716066862792704": { - "peakXP": 2465.2, - "peakLP": 2484.8 - }, - "427160548510138383": { - "peakXP": 2609.1, - "peakLP": 2539.1 - }, - "349606301527048213": { - "peakLP": 2407.6 - }, - "211707835090862080": { - "peakLP": 2516.2 - }, - "171048205650231296": { - "peakLP": 2510.4 - }, - "151712199629471744": { - "peakXP": 2622.4, - "peakLP": 2484.8 - }, - "719827736918491156": { - "peakXP": 2701.2, - "peakLP": 2454.6 - }, - "138797169074372608": { - "peakLP": 2496 - }, - "291035057173430272": { - "peakLP": 2478.7 - }, - "140891915670388736": { - "peakLP": 2390.5 - }, - "452402089268871168": { - "peakLP": 2368.3 - }, - "214852665157353482": { - "peakXP": 2684.6 - }, - "319687833784942592": { - "peakLP": 2497.5 - }, - "195618756800675841": { - "peakLP": 2415.4 - }, - "551648409438781450": { - "peakLP": 2463.9 - }, - "172446329711099905": { - "peakLP": 2416.5 - }, - "326533352687534091": { - "peakLP": 2262.3 - }, - "369241351822376971": { - "peakLP": 2407 - }, - "103637471115382784": { - "peakLP": 2457.7 - }, - "767716380833087489": { - "peakLP": 2601.2 - }, - "442090485964603402": { - "peakLP": 2212.7 - }, - "372460989226156033": { - "peakLP": 2421.2 - }, - "527529995619532804": { - "peakLP": 2497.9 - }, - "245539356562030593": { - "peakLP": 2444.2 - }, - "371988688298835968": { - "peakXP": 2539.1, - "peakLP": 2486.3 - }, - "363604230952583168": { - "peakXP": 2554.1, - "peakLP": 2513.7 - }, - "509218110679679004": { - "peakXP": 2562.7, - "peakLP": 2615.4 - }, - "454478346667950083": { - "peakXP": 2605.3, - "peakLP": 2668.5 - }, - "393908122525368331": { - "peakXP": 2623.1, - "peakLP": 2610.7 - }, - "500104986584416266": { - "peakLP": 2444.2 - }, - "477527195942912010": { - "peakLP": 2432.5 - }, - "155388536965562368": { - "peakLP": 2216.7 - }, - "821496073687138395": { - "peakLP": 2371.9 - }, - "501495100715827202": { - "peakXP": 2801.7, - "peakLP": 2574.3 - }, - "261668940176949249": { - "peakLP": 2315.8 - }, - "109890696323842048": { - "peakLP": 2434.7 - }, - "132899611189706752": { - "peakXP": 2559.6, - "peakLP": 2402.6 - }, - "182493288941486082": { - "peakXP": 2661.3, - "peakLP": 2457.7 - }, - "403598012888252426": { - "peakXP": 2707.7, - "peakLP": 2634.7 - }, - "198084438821699584": { - "peakLP": 2472.4 - }, - "274693882221166597": { - "peakXP": 2585, - "peakLP": 2513.2 - }, - "293023646136664076": { - "peakLP": 2455.3 - }, - "334097139720388608": { - "peakXP": 2616.5, - "peakLP": 2591 - }, - "528043224124620802": { - "peakXP": 2649.9, - "peakLP": 2591 - }, - "774003960276713507": { - "peakXP": 2603.1, - "peakLP": 2546.9 - }, - "599613512863580190": { - "peakLP": 2334.3 - }, - "245195310593212417": { - "peakXP": 2802.3, - "peakLP": 2611.6 - }, - "207480800210452481": { - "peakXP": 2589.1, - "peakLP": 2501.1 - }, - "529957601169899532": { - "peakLP": 2503.8 - }, - "322388077157023754": { - "peakXP": 2632.2, - "peakLP": 2561.3 - }, - "164734950686326784": { - "peakXP": 2709, - "peakLP": 2624 - }, - "388778540545474570": { - "peakXP": 2702.5, - "peakLP": 2570.3 - }, - "376110228355416068": { - "peakLP": 2426.3 - }, - "178598074229194753": { - "peakLP": 2293.9 - }, - "109424874933518336": { - "peakLP": 2320.9 - }, - "346472666321911809": { - "peakXP": 2607.7, - "peakLP": 2448.1 - }, - "755487911788544150": { - "peakLP": 2290.5 - }, - "816528501112307792": { - "peakXP": 2603.7, - "peakLP": 2438.9 - }, - "666447443373588480": { - "peakXP": 2652.4, - "peakLP": 2418.2 - }, - "578978238429397003": { - "peakLP": 2326.2 - }, - "543978835356549131": { - "peakXP": 2543.5, - "peakLP": 2588.5 - }, - "415700770483208193": { - "peakLP": 2515.3 - }, - "118722378720215042": { - "peakLP": 2472.9 - }, - "267301405528686593": { - "peakLP": 2470.8 - }, - "188546037173059584": { - "peakLP": 2465.8 - }, - "600763686059245578": { - "peakLP": 2438.7 - }, - "292798858608508939": { - "peakLP": 2287.8 - }, - "630129312263307270": { - "peakLP": 2370.7 - }, - "800035534583365642": { - "peakLP": 2203.9 - }, - "687936094272225383": { - "peakLP": 2413 - }, - "398237251894509568": { - "peakXP": 2608.6, - "peakLP": 2539.5 - }, - "458731994071695381": { - "peakLP": 2392.6 - }, - "820522801839603729": { - "peakLP": 2365.4 - }, - "404370140155871232": { - "peakLP": 2410.4 - }, - "114994340320903175": { - "peakLP": 2326.8 - }, - "827177277518512178": { - "peakLP": 2496.9 - }, - "120689548224102402": { - "peakLP": 2341.1 - }, - "192407294942642176": { - "peakLP": 2432.6 - }, - "601108041097674752": { - "peakLP": 2442.2 - }, - "466435974763773954": { - "peakLP": 2525.8 - }, - "464467971574595585": { - "peakLP": 2374.1 - }, - "712659307912626176": { - "peakLP": 2305 - }, - "630418528981221396": { - "peakXP": 2644.6, - "peakLP": 2452.7 - }, - "469248610618310671": { - "peakLP": 2259.1 - }, - "785846497710571591": { - "peakLP": 2386.3 - }, - "811971184315990037": { - "peakLP": 2493.1 - }, - "356991205604720642": { - "peakLP": 2507.8 - }, - "140911330059091969": { - "peakXP": 2804.1, - "peakLP": 2606.1 - }, - "720324772931567678": { - "peakLP": 2328.5 - }, - "669126635374510080": { - "peakLP": 2217.4 - }, - "652196990343708717": { - "peakLP": 2390 - }, - "472902128390766614": { - "peakLP": 2267.5 - }, - "732054863189508206": { - "peakXP": 2599, - "peakLP": 2519.5 - }, - "523454472266055681": { - "peakLP": 2512.7 - }, - "290684737650163713": { - "peakLP": 2279.8 - }, - "351057290943791105": { - "peakXP": 2656.6, - "peakLP": 2466.9 - }, - "379020875552522240": { - "peakLP": 2455 - }, - "658636974952939530": { - "peakXP": 2804.4, - "peakLP": 2581.9 - }, - "302851443184893952": { - "peakXP": 2601.3, - "peakLP": 2540.4 - }, - "689418466943303693": { - "peakLP": 2449.1 - }, - "330531736318377994": { - "peakLP": 2305.5 - }, - "650548545723564073": { - "peakLP": 2334.3 - }, - "195346111387533312": { - "peakXP": 2844.7, - "peakLP": 2739.9 - }, - "589789457272799233": { - "peakLP": 2312 - }, - "750301045468692500": { - "peakXP": 2800.4, - "peakLP": 2701.7 - }, - "254943418755710976": { - "peakXP": 2649.2, - "peakLP": 2521.9 - }, - "353973462269952020": { - "peakLP": 2347.6 - }, - "106462785549897728": { - "peakLP": 2384 - }, - "701455218482872340": { - "peakLP": 2367 - }, - "277760673436401664": { - "peakLP": 2651.6 - }, - "412754879933841409": { - "peakLP": 2612.4 - }, - "300073469503340546": { - "peakXP": 2802.3, - "peakLP": 2536.5 - }, - "110554189838434304": { - "peakXP": 2706.8, - "peakLP": 2552.4 - }, - "295942124447334410": { - "peakXP": 2612.8, - "peakLP": 2506.5 - }, - "410586217248456714": { - "peakXP": 2603.1, - "peakLP": 2646.6 - }, - "465861093412175904": { - "peakLP": 2421.9 - }, - "200340904605777921": { - "peakLP": 2445.8 - }, - "269304206588903424": { - "peakXP": 2609.7, - "peakLP": 2513.7 - }, - "158733178713014273": { - "peakLP": 2499.3 - }, - "222407271609663489": { - "peakLP": 2367.8 - }, - "398270403342106625": { - "peakXP": 2643.7, - "peakLP": 2497.5 - }, - "818260674290909226": { - "peakLP": 2443.8 - }, - "782299836665298954": { - "peakLP": 2522.2 - }, - "736447990247456780": { - "peakLP": 2246.1 - }, - "729403659179393064": { - "peakLP": 2269 - }, - "532672071055179786": { - "peakLP": 2438.2 - }, - "120213357205651457": { - "peakLP": 2406.6 - }, - "721723467484889219": { - "peakLP": 2473.4 - }, - "627886387575914566": { - "peakLP": 2471.1 - }, - "280577118545641473": { - "peakLP": 2372.6 - }, - "208788589188743168": { - "peakLP": 2307.1 - }, - "335389149999661056": { - "peakLP": 2248.2 - }, - "347036855453220865": { - "peakXP": 2701.5, - "peakLP": 2597.7 - }, - "355108383831097344": { - "peakLP": 2535.8 - }, - "378613418875158530": { - "peakXP": 2662.5, - "peakLP": 2535.8 - }, - "811748642180431892": { - "peakXP": 2621.6, - "peakLP": 2537.1 - }, - "650132702418173952": { - "peakLP": 2519 - }, - "78615504511574016": { - "peakXP": 2565.4, - "peakLP": 2558.6 - }, - "404449307362721814": { - "peakLP": 2430.9 - }, - "112516958062198784": { - "peakLP": 2471.4 - }, - "359159730947620874": { - "peakLP": 2336.9 - }, - "176906806394748929": { - "peakXP": 2598.4, - "peakLP": 2511.8 - }, - "726578166076014662": { - "peakLP": 2523.6 - }, - "541035051320737792": { - "peakLP": 2366.5 - }, - "339868612221337602": { - "peakLP": 2285.7 - }, - "390679396002037762": { - "peakLP": 2355.5 - }, - "602179991631036426": { - "peakLP": 2201.8 - }, - "111837485352361984": { - "peakLP": 2394.1 - }, - "333537908407533569": { - "peakLP": 2455.3 - }, - "646261566953488395": { - "peakLP": 2543.4 - }, - "284196841333981184": { - "peakLP": 2568.9 - }, - "473626601817505835": { - "peakLP": 2220.2 - }, - "365094335018041344": { - "peakLP": 2226.6 - }, - "749369447432847410": { - "peakLP": 2220.2 - }, - "227118471119372291": { - "peakLP": 2402.5 - }, - "273808847930654730": { - "peakLP": 2284.5 - }, - "682718019860037657": { - "peakLP": 2300.5 - }, - "575399234082439178": { - "peakXP": 2649, - "peakLP": 2521.3 - }, - "318043672354488321": { - "peakXP": 2528.8, - "peakLP": 2472.4 - }, - "422176926187126785": { - "peakLP": 2394.6 - }, - "176876640263995393": { - "peakLP": 2444.3 - }, - "846381961231728660": { - "peakXP": 2614.5, - "peakLP": 2524.3 - }, - "838713926862635028": { - "peakLP": 2205.3 - }, - "623845739100897292": { - "peakXP": 2601, - "peakLP": 2550.9 - }, - "207121173237071872": { - "peakXP": 2552.7, - "peakLP": 2544.2 - }, - "244456158201380864": { - "peakXP": 2600.3, - "peakLP": 2411.3 - }, - "430831183090286622": { - "peakLP": 2529.7 - }, - "665725960301314051": { - "peakLP": 2479 - }, - "297668383636324352": { - "peakLP": 2303.6 - }, - "139823201353072640": { - "peakLP": 2317.8 - }, - "352544424687042570": { - "peakXP": 2665.7, - "peakLP": 2497.5 - }, - "219972682148478976": { - "peakLP": 2386.4 - }, - "185958939018395648": { - "peakLP": 2272.8 - }, - "717991398137135114": { - "peakLP": 2505.6 - }, - "142102815907643393": { - "peakXP": 2619.4, - "peakLP": 2437.4 - }, - "579449865696706569": { - "peakLP": 2438.3 - }, - "757878656491192320": { - "peakXP": 2619.6, - "peakLP": 2530.8 - }, - "692718684556230657": { - "peakLP": 2291.6 - }, - "162277584384557057": { - "peakXP": 2552.3, - "peakLP": 2517.9 - }, - "463069818850115594": { - "peakXP": 2647.5, - "peakLP": 2537 - }, - "279796913371349002": { - "peakLP": 2267.8 - }, - "397740587463213066": { - "peakLP": 2522.2 - }, - "778156111051751434": { - "peakLP": 2435.1 - }, - "661570438811353100": { - "peakLP": 2326.2 - }, - "134776998991101952": { - "peakLP": 2226.3 - }, - "557247973885476865": { - "peakLP": 2591.3 - }, - "502882775523917824": { - "peakLP": 2591.3 - }, - "119543386976944130": { - "peakLP": 2400.9 - }, - "476939860276543499": { - "peakLP": 2282.2 - }, - "695492230613565460": { - "peakLP": 2217.4 - }, - "307223649767915521": { - "peakLP": 2299.7 - }, - "309429991345487882": { - "peakLP": 2510.8 - }, - "480699966038212608": { - "peakLP": 2249.4 - }, - "581693209424560132": { - "peakLP": 2385.7 - }, - "470695447531487273": { - "peakLP": 2436.2 - }, - "457677854155472896": { - "peakLP": 2419.1 - }, - "352261865721823233": { - "peakLP": 2466.8 - }, - "270077648280092673": { - "peakXP": 2623.1, - "peakLP": 2537.9 - }, - "563813647823011863": { - "peakLP": 2518.6 - }, - "513123481634865152": { - "peakLP": 2406.3 - }, - "867766323974766622": { - "peakLP": 2394.8 - }, - "565828530496339978": { - "peakXP": 2686.2, - "peakLP": 2613.8 - }, - "408024088494211083": { - "peakLP": 2270.8 - }, - "98746624112599040": { - "peakLP": 2338.5 - }, - "449795083051728906": { - "peakXP": 2551, - "peakLP": 2567.9 - }, - "387706046421925888": { - "peakLP": 2568.5 - }, - "631428169047605279": { - "peakLP": 2354.9 - }, - "607499166620450828": { - "peakLP": 2460.3 - }, - "722362908365029399": { - "peakLP": 2204.3 - }, - "538576627488260096": { - "peakXP": 2662, - "peakLP": 2559.6 - }, - "403032386176024598": { - "peakLP": 2435.9 - }, - "584865748648722462": { - "peakLP": 2290.2 - }, - "466066141031432195": { - "peakLP": 2362.9 - }, - "656312084912406548": { - "peakXP": 2601.2, - "peakLP": 2592.3 - }, - "135472158024400896": { - "peakLP": 2401.2 - }, - "304401365785116674": { - "peakLP": 2378.4 - }, - "460742410876485633": { - "peakXP": 2603.6, - "peakLP": 2440.5 - }, - "110480228479946752": { - "peakLP": 2423.9 - }, - "152236469740634112": { - "peakLP": 2410.9 - }, - "407727468837863435": { - "peakLP": 2526.7 - }, - "429651155224887299": { - "peakLP": 2543.1 - }, - "305453601474609153": { - "peakLP": 2573.9 - }, - "351801877115043842": { - "peakLP": 2545.6 - }, - "333394535663009792": { - "peakLP": 2507.1 - }, - "116664610429468675": { - "peakLP": 2355.3 - }, - "917793800708030614": { - "peakXP": 2614.8, - "peakLP": 2649.8 - }, - "143874462570381312": { - "peakLP": 2204.4 - }, - "815358729649389569": { - "peakLP": 2427.8 - }, - "133793769681059840": { - "peakXP": 2564, - "peakLP": 2524.5 - }, - "317203945363734528": { - "peakXP": 2618.6, - "peakLP": 2553.9 - }, - "254546236827369484": { - "peakXP": 2613.1, - "peakLP": 2520.2 - }, - "756604643659743282": { - "peakLP": 2345.8 - }, - "774369435993243689": { - "peakLP": 2570.6 - }, - "345724432037183489": { - "peakLP": 2230.6 - }, - "576572519235190845": { - "peakLP": 2268.2 - }, - "351090217555329025": { - "peakLP": 2327.4 - }, - "866691404424413224": { - "peakXP": 2749.5, - "peakLP": 2698.2 - }, - "547758596063100929": { - "peakLP": 2423.7 - }, - "540986791671365642": { - "peakLP": 2330.8 - }, - "594651499775131648": { - "peakLP": 2208.5 - }, - "630097070308720650": { - "peakLP": 2416.3 - }, - "681983379054264355": { - "peakLP": 2212.1 - }, - "753129744488202331": { - "peakLP": 2443.7 - }, - "324214011522449418": { - "peakLP": 2533.5 - }, - "466135598948679680": { - "peakLP": 2539.1 - }, - "169132602387595264": { - "peakLP": 2282.9 - }, - "748271441757077686": { - "peakXP": 2711.9, - "peakLP": 2463.5 - }, - "638859249773969418": { - "peakLP": 2346.9 - }, - "601129896110063814": { - "peakLP": 2392.1 - }, - "236782011753299968": { - "peakXP": 2614.4, - "peakLP": 2505.6 - }, - "485270979686432769": { - "peakXP": 2710.7, - "peakLP": 2547.2 - }, - "736596437827190864": { - "peakLP": 2600.8 - }, - "477967061344190466": { - "peakLP": 2488.7 - }, - "910827288831074334": { - "peakLP": 2219.3 - }, - "367782834632654859": { - "peakLP": 2354.5 - }, - "780991238488588288": { - "peakXP": 2612.2, - "peakLP": 2357.4 - }, - "243757190781992962": { - "peakLP": 2387.6 - }, - "825959417446006806": { - "peakLP": 2372.7 - }, - "335966886393151488": { - "peakLP": 2299.9 - }, - "358295416078467072": { - "peakLP": 2458.9 - }, - "660902180425039882": { - "peakLP": 2455.2 - }, - "411620250027556897": { - "peakLP": 2369.5 - }, - "583720678767853568": { - "peakLP": 2558.4 - }, - "413960737267122177": { - "peakLP": 2387.2 - }, - "847758941797613608": { - "peakLP": 2243.9 - }, - "469920417436401715": { - "peakLP": 2208.8 - }, - "766828595732807680": { - "peakLP": 2438.3 - }, - "400631198747066378": { - "peakLP": 2468.6 - }, - "495600073661743124": { - "peakXP": 2610.4, - "peakLP": 2530.8 - }, - "358288941410942976": { - "peakXP": 2567.2, - "peakLP": 2519 - }, - "896459071614496789": { - "peakLP": 2551 - }, - "201298998504587265": { - "peakLP": 2301 - }, - "775820345411502090": { - "peakXP": 2638.4, - "peakLP": 2394.8 - }, - "99307516764123136": { - "peakLP": 2312.8 - }, - "929870642810060890": { - "peakXP": 2673.4, - "peakLP": 2531 - }, - "933668578564116540": { - "peakXP": 2582.8, - "peakLP": 2449.4 - }, - "503423444294041611": { - "peakXP": 2662.2, - "peakLP": 2291 - }, - "283480526336163840": { - "peakLP": 2410.3 - }, - "531111762892816394": { - "peakXP": 2610.1 - }, - "771687804249964584": { - "peakLP": 2202.4 - }, - "441712187199717378": { - "peakLP": 2243.7 - }, - "700061876666499203": { - "peakLP": 2406.9 - }, - "508663315992412183": { - "peakLP": 2274.1 - }, - "200683553955119108": { - "peakLP": 2464 - }, - "744658920928182375": { - "peakXP": 2530.9, - "peakLP": 2314.3 - }, - "341888710662553612": { - "peakXP": 2460.2, - "peakLP": 2396.6 - }, - "287491603017236490": { - "peakLP": 2416.6 - } -} diff --git a/app/core/play/playerInfos/playerInfos.server.ts b/app/core/play/playerInfos/playerInfos.server.ts deleted file mode 100644 index 4942b6393..000000000 --- a/app/core/play/playerInfos/playerInfos.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - LookingLoaderData, - LookingLoaderDataGroup, -} from "~/routes/play/looking"; -import rawInfos from "./data.json"; - -const infos = rawInfos as Partial< - Record ->; - -export function addInfoFromOldSendouInk( - type: "LEAGUE" | "SOLO", - data: LookingLoaderData -): LookingLoaderData { - return { - ...data, - ownGroup: mapGroup(data.ownGroup), - likedGroups: data.likedGroups.map(mapGroup), - neutralGroups: data.neutralGroups.map(mapGroup), - likerGroups: data.likerGroups.map(mapGroup), - }; - - function mapGroup(group: LookingLoaderDataGroup): LookingLoaderDataGroup { - return { - ...group, - members: group.members?.map((member) => { - const playerInfos = infos[member.discordId]; - return { - ...member, - peakXP: type === "SOLO" ? playerInfos?.peakXP : undefined, - peakLP: type === "LEAGUE" ? playerInfos?.peakLP : undefined, - }; - }), - }; - } -} - -export function userHasTop500Result({ discordId }: { discordId?: string }) { - if (!discordId) return false; - return Boolean(infos[discordId]?.peakXP); -} diff --git a/app/core/play/utils.test.ts b/app/core/play/utils.test.ts deleted file mode 100644 index 8bd092732..000000000 --- a/app/core/play/utils.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { BIT_HIGHER_MMR_LIMIT } from "~/constants"; -import { - calculateDifference, - groupsToWinningAndLosingPlayerIds, - isMatchReplay, - scoresAreIdentical, - uniteGroupInfo, - UniteGroupInfoArg, -} from "./utils"; - -const UniteGroupInfo = suite("uniteGroupInfo()"); -const ScoresAreIdentical = suite("scoresAreIdentical()"); -const GroupsToWinningAndLosingPlayerIds = suite( - "groupsToWinningAndLosingPlayerIds()" -); -const CalculateDifference = suite("calculateDifference()"); -const IsMatchReplay = suite("isMatchReplay()"); - -const SMALL_GROUP: UniteGroupInfoArg = { id: "small", memberCount: 1 }; -const BIG_GROUP: UniteGroupInfoArg = { id: "big", memberCount: 3 }; - -UniteGroupInfo("Removes captain if other group is smaller", () => { - const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, BIG_GROUP); - - assert.ok(removeCaptainsFromOther); -}); - -UniteGroupInfo("Doesn't remove captain if groups are same size", () => { - const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, SMALL_GROUP); - - assert.not.ok(removeCaptainsFromOther); -}); - -UniteGroupInfo("Bigger group survives", () => { - const { otherGroupId, survivingGroupId } = uniteGroupInfo( - BIG_GROUP, - SMALL_GROUP - ); - - assert.equal(survivingGroupId, "big"); - assert.equal(otherGroupId, "small"); -}); - -ScoresAreIdentical("Detects identical score", () => { - const result = scoresAreIdentical({ - stages: [ - { winnerGroupId: "a" }, - { winnerGroupId: "a" }, - { winnerGroupId: "a" }, - ], - winnerIds: ["a", "a", "a"], - }); - - assert.ok(result); -}); - -ScoresAreIdentical("Detects not identical score", () => { - const result = scoresAreIdentical({ - stages: [ - { winnerGroupId: "a" }, - { winnerGroupId: "a" }, - { winnerGroupId: "b" }, - ], - winnerIds: ["a", "b", "a"], - }); - const result2 = scoresAreIdentical({ - stages: [ - { winnerGroupId: "a" }, - { winnerGroupId: "a" }, - { winnerGroupId: "b" }, - ], - winnerIds: ["b", "b", "a"], - }); - const result3 = scoresAreIdentical({ - stages: [{ winnerGroupId: "a" }, { winnerGroupId: "a" }], - winnerIds: ["a", "a", "a"], - }); - - assert.not.ok(result); - assert.not.ok(result2); - assert.not.ok(result3); -}); - -GroupsToWinningAndLosingPlayerIds( - "Splits players to winning and losing", - () => { - const { winning, losing } = groupsToWinningAndLosingPlayerIds({ - winnerGroupIds: ["a", "b", "b"], - groups: [ - { id: "b", members: [{ user: { id: "m3" } }, { user: { id: "m4" } }] }, - { id: "a", members: [{ user: { id: "m1" } }, { user: { id: "m2" } }] }, - ], - }); - - assert.ok(winning.includes("m3")); - assert.ok(winning.includes("m4")); - assert.ok(losing.includes("m1")); - assert.ok(losing.includes("m2")); - } -); - -CalculateDifference("Close", () => { - assert.equal(calculateDifference({ ourMMR: 0, theirMMR: 0 }), "CLOSE"); - assert.equal(calculateDifference({ ourMMR: 0, theirMMR: 1 }), "CLOSE"); - assert.equal(calculateDifference({ ourMMR: 1, theirMMR: 0 }), "CLOSE"); -}); - -CalculateDifference("Higher/lower", () => { - assert.equal( - calculateDifference({ ourMMR: 0, theirMMR: 10_000 }), - "LOT_HIGHER" - ); - assert.equal( - calculateDifference({ ourMMR: 10_000, theirMMR: 0 }), - "LOT_LOWER" - ); -}); - -CalculateDifference("A bit higher/lower", () => { - assert.equal( - calculateDifference({ ourMMR: 0, theirMMR: BIT_HIGHER_MMR_LIMIT }), - "BIT_HIGHER" - ); - assert.equal( - calculateDifference({ ourMMR: 0, theirMMR: -BIT_HIGHER_MMR_LIMIT }), - "BIT_LOWER" - ); -}); - -const user = { id: "a" }; -const createMembers = (input: string[]) => input.map((v) => ({ memberId: v })); - -IsMatchReplay("Detects replays", () => { - assert.ok( - isMatchReplay({ - user, - recentMatch: { - groups: [ - { members: createMembers(["a", "b", "c", "d"]) }, - { members: createMembers(["e", "f", "g", "h"]) }, - ], - }, - group: { members: createMembers(["e", "f", "g", "h"]) }, - }) - ); - - assert.ok( - isMatchReplay({ - user, - recentMatch: { - groups: [ - { members: createMembers(["e", "f", "g", "h"]) }, - { members: createMembers(["a", "b", "c", "d"]) }, - ], - }, - group: { members: createMembers(["e", "f", "g", "1"]) }, - }) - ); -}); - -IsMatchReplay("Detects not replays", () => { - assert.not.ok( - isMatchReplay({ - user, - recentMatch: { - groups: [ - { members: createMembers(["a", "b", "c", "d"]) }, - { members: createMembers(["e", "f", "g", "h"]) }, - ], - }, - group: { members: createMembers(["1", "2", "3", "4"]) }, - }) - ); - - assert.not.ok( - isMatchReplay({ - user, - recentMatch: { - groups: [ - { members: createMembers(["a", "b", "c", "d"]) }, - { members: createMembers(["e", "f", "g", "h"]) }, - ], - }, - group: { members: createMembers(["e", "f", "3", "4"]) }, - }) - ); -}); - -UniteGroupInfo.run(); -ScoresAreIdentical.run(); -GroupsToWinningAndLosingPlayerIds.run(); -CalculateDifference.run(); -IsMatchReplay.run(); diff --git a/app/core/play/utils.ts b/app/core/play/utils.ts deleted file mode 100644 index ee6203af5..000000000 --- a/app/core/play/utils.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { LfgGroupStatus } from "@prisma/client"; -import { redirect } from "@remix-run/node"; -import invariant from "tiny-invariant"; -import { - BIT_HIGHER_MMR_LIMIT, - CLOSE_MMR_LIMIT, - HIGHER_MMR_LIMIT, - LFG_GROUP_FULL_SIZE, - LFG_GROUP_INACTIVE_MINUTES, -} from "~/constants"; -import * as LFGGroup from "~/models/LFGGroup.server"; -import * as LFGMatch from "~/models/LFGMatch.server"; -import { PlayFrontPageLoader } from "~/routes/play/index"; -import { - LookingLoaderData, - LookingLoaderDataGroup, -} from "~/routes/play/looking"; -import { Unpacked } from "~/utils"; -import { - sendouQAddPlayersPage, - sendouQFrontPage, - sendouQLookingPage, - sendouQMatchPage, -} from "~/utils/urls"; -import { skillArrayToMMR, teamSkillToExactMMR } from "../mmr/utils"; -import { canUniteWithGroup } from "./validators"; - -export interface UniteGroupInfoArg { - id: string; - memberCount: number; -} -export function uniteGroupInfo( - groupA: UniteGroupInfoArg, - groupB: UniteGroupInfoArg -): LFGGroup.UniteGroupsArgs { - const survivingGroupId = - groupA.memberCount > groupB.memberCount ? groupA.id : groupB.id; - const otherGroupId = survivingGroupId === groupA.id ? groupB.id : groupA.id; - - return { - survivingGroupId, - otherGroupId, - removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount, - }; -} - -/** Checks if the reported score is the same as score from the database */ -export function scoresAreIdentical({ - stages, - winnerIds, -}: { - stages: { winnerGroupId: string | null }[]; - winnerIds: string[]; -}): boolean { - const stagesWithWinner = stages.filter((stage) => stage.winnerGroupId); - if (stagesWithWinner.length !== winnerIds.length) return false; - - for (const [i, stage] of stagesWithWinner.entries()) { - if (!stage.winnerGroupId) break; - - if (stage.winnerGroupId !== winnerIds[i]) return false; - } - - return true; -} - -export function groupsToWinningAndLosingPlayerIds({ - winnerGroupIds, - groups, -}: { - winnerGroupIds: string[]; - groups: { id: string; members: { user: { id: string } }[] }[]; -}): { - winning: string[]; - losing: string[]; -} { - const occurences: Record = {}; - for (const groupId of winnerGroupIds) { - if (occurences[groupId]) occurences[groupId]++; - else occurences[groupId] = 1; - } - - const winnerGroupId = Object.entries(occurences) - .sort((a, b) => a[1] - b[1]) - .pop()?.[0]; - invariant(winnerGroupId, "winnerGroupId is undefined"); - - return groups.reduce( - (acc, group) => { - const ids = group.members.map((m) => m.user.id); - - if (group.id === winnerGroupId) acc.winning = ids; - else acc.losing = ids; - - return acc; - }, - { winning: [] as string[], losing: [] as string[] } - ); -} - -/** - * Group dates to compare against for expired status. E.g. if the group - * lastActionAt.getTime() is smaller than that of EXPIRED Date's then - * that group is expired - */ -export function groupExpiredDates(): Record< - "ALMOST_EXPIRED" | "EXPIRED", - Date -> { - const now = new Date(); - const thirtyMinutesAgo = new Date( - now.getTime() - 60_000 * LFG_GROUP_INACTIVE_MINUTES - ); - const now2 = new Date(); - const twentyMinutesAgo = new Date( - now2.getTime() - 60_000 * (LFG_GROUP_INACTIVE_MINUTES - 10) - ); - - return { EXPIRED: thirtyMinutesAgo, ALMOST_EXPIRED: twentyMinutesAgo }; -} - -export function groupWillBeInactiveAt(timestamp: number) { - return new Date(timestamp + 60_000 * LFG_GROUP_INACTIVE_MINUTES); -} - -export function groupExpirationStatus(lastActionAtTimestamp: number) { - const { EXPIRED: expiredDate, ALMOST_EXPIRED: almostExpiredDate } = - groupExpiredDates(); - if (expiredDate.getTime() > lastActionAtTimestamp) return "EXPIRED"; - if (almostExpiredDate.getTime() > lastActionAtTimestamp) { - return "ALMOST_EXPIRED"; - } -} - -export function otherGroupsForResponse({ - groups, - likes, - lookingForMatch, - ownGroup, - recentMatch, - user, -}: { - groups: LFGGroup.FindLookingAndOwnActive; - likes: { - given: Set; - received: Set; - }; - lookingForMatch: boolean; - ownGroup: Unpacked; - recentMatch: LFGMatch.RecentOfUser; - user: { id: string }; -}) { - return ( - groups - .filter( - (group) => - (lookingForMatch && group.members.length === LFG_GROUP_FULL_SIZE) || - canUniteWithGroup({ - ownGroupType: ownGroup.type, - ownGroupSize: ownGroup.members.length, - otherGroupSize: group.members.length, - }) - ) - .filter((group) => group.id !== ownGroup.id) - .filter(filterExpiredGroups) - // this should not happen.... but sometimes it does :) - .filter((g) => g.members.length > 0) - .map((group): LookingLoaderDataGroup => { - const ranked = () => { - if (lookingForMatch && !ownGroup.ranked) return false; - - return group.ranked ?? undefined; - }; - return { - id: group.id, - // When looking for a match ranked groups are censored - // and instead we only reveal their approximate skill level - members: - ownGroup.ranked && group.ranked && lookingForMatch - ? undefined - : group.members.map((m) => { - return { - miniBio: m.user.miniBio ?? undefined, - discordAvatar: m.user.discordAvatar, - discordId: m.user.discordId, - discordName: m.user.discordName, - discordDiscriminator: m.user.discordDiscriminator, - id: m.user.id, - captain: m.captain, - weapons: m.user.weapons, - MMR: skillArrayToMMR(m.user.skill), - }; - }), - ranked: ranked(), - replay: isMatchReplay({ user, group, recentMatch }), - MMRRelation: - ownGroup.ranked && - group.ranked && - group.members.length === LFG_GROUP_FULL_SIZE - ? resolveMMRRelation({ group, ownGroup }) - : undefined, - }; - }) - .reduce( - ( - acc: Omit< - LookingLoaderData, - "ownGroup" | "type" | "isCaptain" | "lastActionAtTimestamp" - >, - group - ) => { - // likesReceived first so that if both received like and - // given like then handle this edge case by just displaying the - // group as waiting like back - if (likes.received.has(group.id)) { - acc.likerGroups.push(group); - } else if (likes.given.has(group.id)) { - acc.likedGroups.push(group); - } else { - acc.neutralGroups.push(group); - } - return acc; - }, - { likedGroups: [], neutralGroups: [], likerGroups: [] } - ) - ); -} - -export function isMatchReplay({ - recentMatch, - user, - group, -}: { - recentMatch: { groups: { members: { memberId: string }[] }[] } | null; - user: { id: string }; - group: { members: { memberId: string }[] }; -}): boolean { - if (!recentMatch) return false; - - const opponentGroupOfRecent = recentMatch.groups.find((g) => - g.members.every((m) => m.memberId !== user.id) - ); - invariant( - opponentGroupOfRecent, - "Unexpected opponentGroupOfRecent undefined" - ); - - const memberIdsOfGroup = new Set(group.members.map((m) => m.memberId)); - let sameCount = 0; - for (const { memberId } of opponentGroupOfRecent.members) { - if (memberIdsOfGroup.has(memberId)) sameCount++; - } - - return sameCount > 2; -} - -export function filterExpiredGroups(group: { lastActionAt: Date }) { - const { EXPIRED: expiredDate } = groupExpiredDates(); - - return group.lastActionAt.getTime() > expiredDate.getTime(); -} - -export function countGroups( - groups: LFGGroup.FindLookingAndOwnActive -): PlayFrontPageLoader["counts"] { - return groups.filter(filterExpiredGroups).reduce( - (acc: PlayFrontPageLoader["counts"], group) => { - const memberCount = group.members.length; - - if (group.type === "QUAD" && memberCount !== 4) { - acc.QUAD += memberCount; - } else if (group.type === "TWIN" && memberCount !== 2) { - acc.TWIN += memberCount; - } else if (group.type === "VERSUS") { - acc["VERSUS"] += memberCount; - } - - return acc; - }, - { TWIN: 0, QUAD: 0, VERSUS: 0 } - ); -} - -function resolveMMRRelation({ - group, - ownGroup, -}: { - group: Unpacked; - ownGroup: Unpacked; -}): NonNullable { - return calculateDifference({ - ourMMR: teamSkillToExactMMR(ownGroup.members), - theirMMR: teamSkillToExactMMR(group.members), - }); -} - -export function calculateDifference({ - ourMMR, - theirMMR, -}: { - ourMMR: number; - theirMMR: number; -}): NonNullable { - const difference = Math.abs(ourMMR - theirMMR); - const ownIsBigger = ourMMR > theirMMR; - - if (difference <= CLOSE_MMR_LIMIT) return "CLOSE"; - - if (difference <= BIT_HIGHER_MMR_LIMIT && ownIsBigger) return "BIT_LOWER"; - if (difference <= BIT_HIGHER_MMR_LIMIT && !ownIsBigger) return "BIT_HIGHER"; - - if (difference <= HIGHER_MMR_LIMIT && ownIsBigger) return "LOWER"; - if (difference <= HIGHER_MMR_LIMIT && !ownIsBigger) return "HIGHER"; - - if (ownIsBigger) return "LOT_LOWER"; - if (!ownIsBigger) return "LOT_HIGHER"; - - throw new Error("Unexpected calculateMMRRelation scenario"); -} - -export function resolveRedirect({ - currentStatus = "INACTIVE", - currentPage, - matchId, -}: { - currentStatus?: LfgGroupStatus; - currentPage: LfgGroupStatus; - matchId?: string | null; -}) { - if (currentStatus === currentPage) return; - switch (currentStatus) { - case "INACTIVE": { - return redirect(sendouQFrontPage()); - } - case "LOOKING": { - return redirect(sendouQLookingPage()); - } - case "MATCH": { - invariant(matchId, "Unexpected no match id for redirect"); - return redirect(sendouQMatchPage(matchId)); - } - case "PRE_ADD": { - return redirect(sendouQAddPlayersPage()); - } - default: { - const exhaustive: never = currentStatus; - throw new Response(`Unknown status: ${JSON.stringify(exhaustive)}`, { - status: 500, - }); - } - } -} diff --git a/app/core/play/validators.test.ts b/app/core/play/validators.test.ts deleted file mode 100644 index 3e7575d83..000000000 --- a/app/core/play/validators.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { scoreValid } from "./validators"; - -const ScoreValidator = suite("scoreValid()"); - -ScoreValidator("Accepts valid scores", () => { - const winners = ["a", "b", "a", "a", "a", "a"]; - const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a"]; - const winners3 = ["a", "a", "a", "a", "a"]; - const winners4 = ["a", "a"]; - - assert.ok(scoreValid(winners, 9)); - assert.ok(scoreValid(winners2, 9)); - assert.ok(scoreValid(winners3, 9)); - assert.ok(scoreValid(winners4, 3)); -}); - -ScoreValidator("Rejects invalid scores", () => { - const winners = ["a", "b", "a", "a", "a", "a", "a"]; - const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a", "b"]; - const winners3 = ["a", "a", "a", "a", "a", "b"]; - const winners4 = ["a", "a", "a"]; - - assert.not.ok(scoreValid(winners, 9)); - assert.not.ok(scoreValid(winners2, 9)); - assert.not.ok(scoreValid(winners3, 9)); - assert.not.ok(scoreValid(winners4, 3)); -}); - -ScoreValidator.run(); diff --git a/app/core/play/validators.ts b/app/core/play/validators.ts deleted file mode 100644 index 386bce192..000000000 --- a/app/core/play/validators.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { LfgGroupType } from "@prisma/client"; -import { LFG_GROUP_FULL_SIZE } from "~/constants"; -import * as LFGGroup from "~/models/LFGGroup.server"; -import { isAdmin } from "../common/permissions"; - -export function isGroupAdmin({ - group, - user, -}: { - group?: { members: { captain: boolean; memberId: string }[] }; - user: { id: string }; -}): boolean { - return Boolean( - isAdmin(user.id) || - group?.members.some( - (member) => member.captain && member.memberId === user.id - ) - ); -} - -/** - * Checks that group size is suitable to be united with. E.g. if type of group - * is QUAD and your group has 2 members then a legal group to unite with would have - * 1 or 2 members. - */ -export function canUniteWithGroup({ - ownGroupType, - ownGroupSize, - otherGroupSize, -}: { - ownGroupType: LfgGroupType; - ownGroupSize: number; - otherGroupSize: number; -}): boolean { - const maxGroupSizeToConsider = - ownGroupType === "TWIN" - ? 2 - ownGroupSize - : LFG_GROUP_FULL_SIZE - ownGroupSize; - - return maxGroupSizeToConsider >= otherGroupSize; -} - -/** - * Is score valid? In a best of 9 examples of valid scores: - * 5-0, 5-1, 5-4; - * invalid scores: - * 6-0, 5-5, 4-3 - * */ -export function scoreValid(winners: string[], bestOf: number) { - const requiredWinsToTakeTheSet = Math.ceil(bestOf / 2); - const ids = Array.from(new Set(winners)); - if (ids.length > 2) return false; - - const scores = [0, 0]; - for (const [i, winnerId] of winners.entries()) { - if (winnerId === ids[0]) scores[0]++; - else scores[1]++; - - // it's not possible to report more maps once set has concluded - if ( - scores.some((score) => score === requiredWinsToTakeTheSet) && - i !== winners.length - 1 - ) { - return false; - } - } - - return ( - scores.some((score) => score === requiredWinsToTakeTheSet) && - scores.some((score) => score < requiredWinsToTakeTheSet) - ); -} - -export function matchIsUnranked(match: { stages: unknown[] }) { - return match.stages.length === 0; -} - -export function canPreAddToGroup(group: { - type: LfgGroupType; - members: unknown[]; -}) { - if (group.type === "VERSUS" && group.members.length < LFG_GROUP_FULL_SIZE) { - return true; - } - - // it doesn't make sense to fill a quad completely as it defeats the purpose - if (group.type === "QUAD" && group.members.length < LFG_GROUP_FULL_SIZE - 1) { - return true; - } - - return false; -} - -export function userIsNotInGroup({ - groups, - userId, -}: { - groups: LFGGroup.FindLookingAndOwnActive; - userId: string; -}) { - return groups.every((g) => g.members.every((m) => m.memberId !== userId)); -} diff --git a/app/core/session.server.ts b/app/core/session.server.ts new file mode 100644 index 000000000..e5449b706 --- /dev/null +++ b/app/core/session.server.ts @@ -0,0 +1,14 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +invariant(process.env.SESSION_SECRET); +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "_session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); diff --git a/app/core/stages/stages.ts b/app/core/stages/stages.ts deleted file mode 100644 index 7a2101408..000000000 --- a/app/core/stages/stages.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Mode } from "@prisma/client"; -import invariant from "tiny-invariant"; -import { ArrayElement } from "~/utils"; - -export type StageName = ArrayElement; -export const stages = [ - "The Reef", - "Musselforge Fitness", - "Starfish Mainstage", - "Humpback Pump Track", - "Inkblot Art Academy", - "Sturgeon Shipyard", - "Moray Towers", - "Port Mackerel", - "Manta Maria", - "Kelp Dome", - "Snapper Canal", - "Blackbelly Skatepark", - "MakoMart", - "Walleye Warehouse", - "Shellendorf Institute", - "Arowana Mall", - "Goby Arena", - "Piranha Pit", - "Camp Triggerfish", - "Wahoo World", - "New Albacore Hotel", - "Ancho-V Games", - "Skipper Pavilion", -] as const; - -export const modesShort: Mode[] = ["TW", "SZ", "TC", "RM", "CB"]; -export const modesShortToLong: Record = { - TW: "Turf War", - SZ: "Splat Zones", - TC: "Tower Control", - RM: "Rainmaker", - CB: "Clam Blitz", -} as const; - -export function stagesWithIds() { - const result: { name: string; mode: Mode; id: number }[] = []; - let id = 1; - - for (const mode of modesShort) { - for (const name of stages) { - result.push({ mode, name, id: id++ }); - } - } - - return result; -} - -export function stageToId({ - mode, - name, -}: { - mode: Mode; - name: string; -}): number { - const stageObj = stagesWithIds().find( - (stage) => stage.name === name && stage.mode === mode - ); - invariant(stageObj, `Unknown stage: ${mode} ${name}`); - return stageObj.id; -} - -export function idToStage(id: number) { - const stageObj = stagesWithIds().find((stage) => stage.id === id); - invariant(stageObj, `Unknown stage id: ${id}`); - return stageObj; -} diff --git a/app/core/tournament/algorithms.test.ts b/app/core/tournament/algorithms.test.ts deleted file mode 100644 index 43ba24c36..000000000 --- a/app/core/tournament/algorithms.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { - eliminationBracket, - fillParticipantsWithNullTillPowerOfTwo, - Match, - TeamIdentifier, -} from "./algorithms"; -import { countRounds } from "./bracket"; - -const AmountOfTeams = suite("Amount of teams"); -const Byes = suite("Byes"); -const Seeds = suite("Seeds"); -const BracketPaths = suite("Bracket path"); -const FillParticipantsWithNull = suite( - "fillParticipantsWithNullTillPowerOfTwo()" -); - -AmountOfTeams("Generates right amount of rounds (16 participants - SE)", () => { - const bracket16 = eliminationBracket(16, "SE"); - assert.equal(removeMatchesWithByes(bracket16.winners).length, 15); - assert.equal(removeMatchesWithByes(bracket16.losers).length, 0); -}); - -AmountOfTeams("Generates right amount of rounds (16 participants - DE)", () => { - const bracket16 = eliminationBracket(16, "DE"); - assert.equal(removeMatchesWithByes(bracket16.winners).length, 17); - assert.equal(removeMatchesWithByes(bracket16.losers).length, 14); -}); - -AmountOfTeams("Generates right amount of rounds (15 participants - DE)", () => { - const bracket15 = eliminationBracket(15, "DE"); - assert.equal(removeMatchesWithByes(bracket15.winners).length, 16); - assert.equal(removeMatchesWithByes(bracket15.losers).length, 14); // one bye -}); - -AmountOfTeams("Generates right amount of rounds (17 participants - DE)", () => { - const bracket17 = eliminationBracket(17, "DE"); - assert.equal(removeMatchesWithByes(bracket17.winners).length, 18); - assert.equal(removeMatchesWithByes(bracket17.losers).length, 30); - - assert.equal(removeMatchesWithByes(bracket17.winners).length, 18); - assert.equal(removeMatchesWithByes(bracket17.losers).length, 30); -}); - -AmountOfTeams("Same amount of rounds as next power of two", () => { - const bracket17 = eliminationBracket(17, "DE"); - const bracket32 = eliminationBracket(32, "DE"); - assert.equal(bracket17.winners.length, bracket32.winners.length); - assert.equal(bracket17.losers.length, bracket32.losers.length); -}); - -Byes("Right amount of byes", () => { - const bracket17 = eliminationBracket(17, "DE"); - assert.equal(countOpponentsWithByes(bracket17.winners), 15); -}); - -Byes("Correct team has bye", () => { - const bracket15 = eliminationBracket(15, "DE"); - assert.equal(teamWithBye(bracket15.winners), 1); -}); - -Seeds("First and second seed are spread apart", () => { - const bracket16 = eliminationBracket(16, "DE"); - assert.ok( - [bracket16.winners[0].upperTeam, bracket16.winners[0].lowerTeam].includes( - 1 - ) || - [bracket16.winners[0].upperTeam, bracket16.winners[0].lowerTeam].includes( - 2 - ) - ); - let lastMatchWithATeam = bracket16.winners[0]; - for (const match of bracket16.winners) { - if (!match.upperTeam) break; - - lastMatchWithATeam = match; - } - - assert.ok( - [lastMatchWithATeam.upperTeam, lastMatchWithATeam.lowerTeam].includes(1) || - [lastMatchWithATeam.upperTeam, lastMatchWithATeam.lowerTeam].includes(2) - ); -}); - -BracketPaths("Following winners", () => { - const bracket16 = eliminationBracket(16, "DE"); - const count = countRounds(bracket16); - - let latest: Match = bracket16.winners[0]; - let rounds = 0; - const roundIds = new Set(); - while (latest) { - rounds++; - roundIds.add(latest.id); - if (!latest.winnerDestinationMatch) { - break; - } - assert.equal(latest.side, "winners"); - assert.ok(latest.loserDestinationMatch); - latest = latest.winnerDestinationMatch; - } - - // no duplicate rounds - assert.equal(roundIds.size, rounds); - - assert.equal(rounds, count.winners); -}); - -BracketPaths("Following losers", () => { - const bracket16 = eliminationBracket(16, "DE"); - const count = countRounds(bracket16); - - let latest: Match | undefined = bracket16.winners[0]; - let rounds = 0; - let countWinnerDestNoLoserDest = 0; - const roundIds = new Set(); - while (latest) { - rounds++; - roundIds.add(latest.id); - if (!latest.winnerDestinationMatch && !latest.loserDestinationMatch) { - break; - } - if (latest.winnerDestinationMatch && !latest.loserDestinationMatch) { - countWinnerDestNoLoserDest++; - } - latest = latest.loserDestinationMatch ?? latest.winnerDestinationMatch; - } - - // no duplicate rounds - assert.equal(roundIds.size, rounds); - - assert.equal(rounds, count.losers + 3); // 3 rounds of winners: first round, grand finals & bracket reset - assert.equal(countWinnerDestNoLoserDest, count.losers); -}); - -FillParticipantsWithNull("17", () => { - const participants: TeamIdentifier[] = new Array(17) - .fill(null) - .map((_, i) => i + 1); - assert.equal(participants.length, 17); - fillParticipantsWithNullTillPowerOfTwo(participants); - assert.equal(participants.length, 32); - assert.equal( - participants.reduce((acc: number, cur) => acc + (cur === "BYE" ? 1 : 0), 0), - 32 - 17 - ); -}); - -function countOpponentsWithByes(matches: Match[]) { - const byes = matches.filter( - (match) => match.upperTeam === "BYE" || match.lowerTeam === "BYE" - ); - - return byes.length; -} - -function removeMatchesWithByes(matches: Match[]) { - return matches.filter( - (match) => match.upperTeam !== "BYE" && match.lowerTeam !== "BYE" - ); -} - -function teamWithBye(matches: Match[]) { - for (const match of matches) { - if (match.lowerTeam === "BYE") return match.upperTeam; - if (match.upperTeam === "BYE") return match.lowerTeam; - } - - throw new Error("No team with a BYE"); -} - -AmountOfTeams.run(); -Byes.run(); -Seeds.run(); -BracketPaths.run(); -FillParticipantsWithNull.run(); diff --git a/app/core/tournament/algorithms.ts b/app/core/tournament/algorithms.ts deleted file mode 100644 index afaed08eb..000000000 --- a/app/core/tournament/algorithms.ts +++ /dev/null @@ -1,262 +0,0 @@ -import invariant from "tiny-invariant"; -import { v4 as uuidv4 } from "uuid"; -import type { EliminationBracketSide } from "./bracket"; - -/** Singe/Double Elimination bracket algorithm that handles byes - * @link https://stackoverflow.com/a/59615574 */ -export function eliminationBracket( - participantCount: number, - type: "SE" | "DE" -) { - let participants: TeamIdentifier[] = new Array(participantCount) - .fill(null) - .map((_, i) => i + 1); - - fillParticipantsWithNullTillPowerOfTwo(participants); - - const matchesWQueue: Match[] = []; - const matchesLQueue: Match[] = []; - const backfillQ: Match[] = []; - let matchNumber = 1; - let matchPosition = 1; - - invariant( - powerOf2(participants.length), - "Unexpected participants length not power of two" - ); - const bracket: Bracket = { - winners: [], - losers: [], - participantCount, - participantsWithByesCount: participants.length, - }; - - const bracketSize = participants.length; - const seedList = seeds(bracketSize); - const seedTuples: [TeamIdentifier, number][] = participants.map((p, i) => [ - p, - i + 1, - ]); - participants = seedTuples - .sort(([_a, ai], [_b, bi]) => seedList.indexOf(bi) - seedList.indexOf(ai)) - .map(([p]) => p); - - // First round - for (let i = 1; i <= bracketSize / 2; i++) { - const upperTeam = participants.pop(); - const lowerTeam = participants.pop(); - invariant( - typeof upperTeam !== "undefined", - "Unexpected team1 is undefined in first round" - ); - invariant( - typeof lowerTeam !== "undefined", - "Unexpected team1 is undefined in first round" - ); - invariant( - !(upperTeam === "BYE" && lowerTeam === "BYE"), - "Unexpected both teams in the first round are BYEs" - ); - const firstRoundMatch = createMatch( - { - upperTeam, - lowerTeam, - side: "winners", - }, - upperTeam === "BYE" || lowerTeam === "BYE" - ); - - matchesWQueue.push(firstRoundMatch); - matchesLQueue.push(firstRoundMatch); - bracket.winners.push(firstRoundMatch); - } - - // Generate winners bracket matches - while (matchesWQueue.length > 1) { - const match1 = matchesWQueue.shift(); - const match2 = matchesWQueue.shift(); - invariant(match1, "Unexpected no match1 in winners bracket"); - invariant(match2, "Unexpected no match2 in winners bracket"); - - const winnersBracketMatch = createMatch({ - side: "winners", - }); - - match1.winnerDestinationMatch = winnersBracketMatch; - match2.winnerDestinationMatch = winnersBracketMatch; - - matchesWQueue.push(winnersBracketMatch); - bracket.winners.push(winnersBracketMatch); - // add match to backfill for Lower Queue - backfillQ.push(winnersBracketMatch); - } - - if (type === "SE") return bracket; - - let roundSwitch = bracketSize / 2; - let switcher = false; - let counter = 0; - let switchedCounter = 0; - - // Generate losers bracket matches - while (matchesLQueue.length > 0 && backfillQ.length > 0) { - let match1: Match | undefined; - let match2: Match | undefined; - - if (switcher) { - match1 = matchesLQueue.shift(); - match2 = backfillQ.shift(); - switchedCounter += 2; - if (switchedCounter === roundSwitch) { - // switch back - roundSwitch /= 2; - switcher = false; - // reset counters - switchedCounter = 0; - } - } else { - match1 = matchesLQueue.shift(); - match2 = matchesLQueue.shift(); - counter += 2; - if (counter === roundSwitch) { - switcher = true; - counter = 0; - } - } - - invariant(match1, "Unexpected no match1 in losers bracket"); - invariant(match2, "Unexpected no match2 in losers bracket"); - - const losersMatch = createMatch( - { - side: "losers", - }, - // If the first match already has a bye then this losers match will also be skipped - match1.lowerTeam === "BYE" || match1.upperTeam === "BYE" - ); - - match1[ - match1.side === "winners" - ? "loserDestinationMatch" - : "winnerDestinationMatch" - ] = losersMatch; - match2[ - match2.side === "winners" - ? "loserDestinationMatch" - : "winnerDestinationMatch" - ] = losersMatch; - - matchesLQueue.push(losersMatch); - bracket.losers.push(losersMatch); - } - - const match1 = matchesWQueue.shift(); - const match2 = matchesLQueue.shift(); - - invariant(match1, "Unexpected no match1 in final match"); - invariant(match2, "Unexpected no match2 in final match"); - - // Add final match and bracket reset - const grandFinals = createMatch({ - side: "winners", - }); - - match1.winnerDestinationMatch = grandFinals; - match2.winnerDestinationMatch = grandFinals; - - bracket.winners.push(grandFinals); - - const bracketReset = createMatch({ - side: "winners", - }); - - grandFinals.winnerDestinationMatch = bracketReset; - grandFinals.loserDestinationMatch = bracketReset; - - bracket.winners.push(bracketReset); - - return bracket; - - function createMatch( - args: Omit, - willBeSkipped?: boolean - ): Match { - const number = willBeSkipped ? 0 : matchNumber++; - const position = matchPosition++; - - return { - id: uuidv4(), - number, - position, - ...args, - }; - } -} - -export function fillParticipantsWithNullTillPowerOfTwo( - participants: TeamIdentifier[] -) { - while (!powerOf2(participants.length)) { - participants.push("BYE"); - } -} - -/** @link https://stackoverflow.com/a/30924333 */ -function powerOf2(v: number) { - return v && !(v & (v - 1)); -} - -function seeds(numberOfTeamsWithByes: number) { - const result: number[] = []; - - const limit = getBaseLog(2, numberOfTeamsWithByes) + 1; - invariant(Number.isInteger(limit), "Unexpected limit is not an integer"); - - branch(1, 1, limit); - - /** @link https://stackoverflow.com/a/41647548 */ - function branch(seed: number, level: number, limit: number) { - const levelSum = Math.pow(2, level) + 1; - - if (limit === level + 1) { - result.push(seed); - result.push(levelSum - seed); - return; - } else if (seed % 2 === 1) { - branch(seed, level + 1, limit); - branch(levelSum - seed, level + 1, limit); - } else { - branch(levelSum - seed, level + 1, limit); - branch(seed, level + 1, limit); - } - } - - return result; -} - -function getBaseLog(x: number, y: number) { - return Math.log(y) / Math.log(x); -} - -export type TeamIdentifier = number | "BYE"; - -export interface Match { - id: string; - /** Match number as displayed on bracket. 0 if match should not show. */ - number: number; - /** Match position that decides the order in which matches are displayed. No zeros. */ - position: number; - upperTeam?: TeamIdentifier; - lowerTeam?: TeamIdentifier; - winner?: TeamIdentifier; - loserDestinationMatch?: Match; - winnerDestinationMatch?: Match; - side: EliminationBracketSide; -} - -export interface Bracket { - winners: Match[]; - losers: Match[]; - participantCount: number; - participantsWithByesCount: number; -} diff --git a/app/core/tournament/bracket.test.ts b/app/core/tournament/bracket.test.ts deleted file mode 100644 index 789c1ca8b..000000000 --- a/app/core/tournament/bracket.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { TeamOrder } from ".prisma/client"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { mapPoolForTest } from "~/utils/testUtils"; -import { eliminationBracket } from "./algorithms"; -import { - countRounds, - getRoundNames, - getRoundsDefaultBestOf, - tournamentRoundsForDB, -} from "./bracket"; -import { generateMapListForRounds } from "./mapList"; - -const CountBracketRounds = suite("countRounds()"); -const RoundNames = suite("getRoundNames()"); -const TournamentRoundsForDB = suite("tournamentRoundsForDB()"); - -CountBracketRounds("Counts bracket (DE - 38)", () => { - const bracket = eliminationBracket(38, "DE"); - const count = countRounds(bracket); - - assert.equal(count, { winners: 8, losers: 9 }); -}); - -CountBracketRounds("Counts bracket (DE - 10)", () => { - const bracket = eliminationBracket(10, "DE"); - const count = countRounds(bracket); - - assert.equal(count, { winners: 6, losers: 5 }); -}); - -CountBracketRounds("Counts bracket (DE - 16)", () => { - const bracket = eliminationBracket(16, "DE"); - const count = countRounds(bracket); - - assert.equal(count, { winners: 6, losers: 6 }); -}); - -CountBracketRounds("Counts bracket (SE - 16)", () => { - const bracket = eliminationBracket(16, "SE"); - const count = countRounds(bracket); - - assert.equal(count, { winners: 4, losers: 0 }); -}); - -RoundNames("No bracket reset round for SE", () => { - const bracketSE = getRoundNames(eliminationBracket(16, "SE")); - const bracketDE = getRoundNames(eliminationBracket(16, "DE")); - - let hasBR = false; - for (const round of bracketDE.winners) { - if (round === "Bracket Reset") hasBR = true; - } - assert.ok(hasBR); - - hasBR = false; - for (const round of bracketSE.winners) { - if (round === "Bracket Reset") hasBR = true; - } - assert.not.ok(hasBR); -}); - -const testTournamentData = (type: "SE" | "DE", participantsCount: number) => { - const mapPool = mapPoolForTest(); - const bracket = eliminationBracket(participantsCount, type); - const rounds = getRoundsDefaultBestOf(bracket); - const mapList = generateMapListForRounds({ mapPool, rounds }); - - return { - mapPool, - bracket, - rounds, - mapList, - }; -}; - -TournamentRoundsForDB("Generates rounds correctly", () => { - const TEAM_COUNT = 24; - - const { bracket, mapList } = testTournamentData("DE", TEAM_COUNT); - const bracketForDb = tournamentRoundsForDB({ - mapList, - bracketType: "DE", - participantsSeeded: new Array(TEAM_COUNT) - .fill(null) - .map((_, i) => i + 1) - .map(String) - .map((id) => ({ id })), - }); - const roundsCounted = countRounds(bracket, false); - let max = -Infinity; - let min = Infinity; - const uniqueParticipants = new Set(); - - for (const round of bracketForDb) { - max = Math.max(max, round.position); - min = Math.min(min, round.position); - - for (const match of round.matches) { - for (const participant of match.participants) { - if (round.position !== 1 && round.position !== 2) { - throw new Error("Participant found not first two rounds"); - } - if (typeof participant.team === "string") { - uniqueParticipants.add(participant.team); - continue; - } - uniqueParticipants.add(participant.team.id); - } - } - } - - assert.equal(max, roundsCounted.winners); - assert.equal(min, -roundsCounted.losers); - assert.equal(uniqueParticipants.size, TEAM_COUNT + 1); // + BYE -}); - -TournamentRoundsForDB( - "Generates rounds correctly (many byes, correct amount of teams round 2)", - () => { - const TEAM_COUNT = 18; - - const { mapList } = testTournamentData("SE", TEAM_COUNT); - - const bracketForDb = tournamentRoundsForDB({ - mapList, - bracketType: "SE", - participantsSeeded: new Array(TEAM_COUNT) - .fill(null) - .map((_, i) => i + 1) - .map(String) - .map((id) => ({ id })), - }); - - const participantsInRoundTwo = bracketForDb[1].matches.reduce( - (acc, cur) => { - const participants = cur.participants.reduce( - (acc, cur) => acc + (cur.team === "BYE" ? 0 : 1), - 0 - ); - - return acc + participants; - }, - 0 - ); - - assert.equal(participantsInRoundTwo, 14); - } -); - -TournamentRoundsForDB("Advances bye to right spot", () => { - const TEAM_COUNT = 7; - const { mapList } = testTournamentData("DE", TEAM_COUNT); - - const bracketForDb = tournamentRoundsForDB({ - mapList, - bracketType: "SE", - participantsSeeded: new Array(TEAM_COUNT) - .fill(null) - .map((_, i) => i + 1) - .map(String) - .map((id) => ({ id })), - }); - - let roundTwoParticipants = 0; - let teamOrder: TeamOrder | null = null; - for (const round of bracketForDb) { - if (round.position !== 2) continue; - for (const match of round.matches) { - for (const participant of match.participants) { - roundTwoParticipants++; - teamOrder = participant.order; - } - } - } - - assert.equal(roundTwoParticipants, 1); - assert.equal(teamOrder, "UPPER"); -}); - -TournamentRoundsForDB( - "Has matching match for each loser destination match id", - () => { - const TEAM_COUNT = 11; - const { mapList } = testTournamentData("DE", 11); - - const bracketForDb = tournamentRoundsForDB({ - mapList, - bracketType: "DE", - participantsSeeded: new Array(TEAM_COUNT) - .fill(null) - .map((_, i) => i + 1) - .map(String) - .map((id) => ({ id })), - }); - - const matches = bracketForDb.flatMap((round) => round.matches); - const losers = matches - .filter((match) => !match.loserDestinationMatchId) - .flatMap((match) => match.id ?? []); - const loserDestinationMatchIds = matches.flatMap( - (match) => match.loserDestinationMatchId ?? [] - ); - - for (const id of loserDestinationMatchIds) { - if (!losers.includes(id)) { - throw new Error(`No matching losers match found for id: ${id}`); - } - } - } -); - -CountBracketRounds.run(); -RoundNames.run(); -TournamentRoundsForDB.run(); diff --git a/app/core/tournament/bracket.ts b/app/core/tournament/bracket.ts deleted file mode 100644 index c4345abf7..000000000 --- a/app/core/tournament/bracket.ts +++ /dev/null @@ -1,520 +0,0 @@ -import type { BracketType, Stage, TeamOrder } from ".prisma/client"; -import clone from "just-clone"; -import invariant from "tiny-invariant"; -import { v4 as uuidv4 } from "uuid"; -import { z } from "zod"; -import { FindInfoForModal } from "~/models/TournamentMatch.server"; -import { BracketMatchAction } from "~/routes/to/$organization.$tournament/bracket.$bid/match.$num"; -import { Unpacked } from "~/utils"; -import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "../../constants"; -import { FindTournamentByNameForUrlI } from "../../services/tournament"; -import { Bracket, eliminationBracket, Match } from "./algorithms"; -import { generateMapListForRounds } from "./mapList"; - -export function participantCountToRoundsInfo({ - bracket, - mapPool, -}: { - bracket: Bracket; - mapPool: Stage[]; -}): EliminationBracket< - { - name: string; - bestOf: BestOf; - mapList: Stage[]; - }[] -> { - const roundNames = getRoundNames(bracket); - const roundsDefaultBestOf = getRoundsDefaultBestOf(bracket); - const mapList = generateMapListForRounds({ - mapPool, - rounds: roundsDefaultBestOf, - }); - - // TODO: invariants - - return { - winners: roundNames.winners.map((roundName, i) => { - const bestOf = roundsDefaultBestOf.winners[i]; - const maps = mapList.winners[i]; - invariant(bestOf, "bestOf undefined in winners"); - invariant(maps, "maps undefined in winners"); - return { - name: roundName, - bestOf, - mapList: maps, - }; - }), - losers: roundNames.losers.map((roundName, i) => { - const bestOf = roundsDefaultBestOf.losers[i]; - const maps = mapList.losers[i]; - invariant(bestOf, "bestOf undefined in losers"); - invariant(maps, "bestOf undefined in losers"); - return { - name: roundName, - bestOf, - mapList: maps, - }; - }), - }; -} - -const WINNERS_DEFAULT = 5; -const WINNERS_FIRST_TWO_DEFAULT = 3; -const GRAND_FINALS_DEFAULT = 7; -const GRAND_FINALS_RESET_DEFAULT = 7; -const LOSERS_DEFAULT = 3; -const LOSERS_FINALS_DEFAULT = 5; - -export type BestOf = 3 | 5 | 7 | 9; - -export function getRoundsDefaultBestOf( - bracket: Bracket -): EliminationBracket { - const { winners: winnersRoundCount, losers: losersRoundCount } = - countRounds(bracket); - - return { - winners: new Array(winnersRoundCount).fill(null).map((_, i) => { - const isSE = losersRoundCount === 0; - if (i === 0) return WINNERS_FIRST_TWO_DEFAULT; - if (i === 1) return WINNERS_FIRST_TWO_DEFAULT; - if (i === winnersRoundCount - 2 + Number(isSE)) { - return GRAND_FINALS_DEFAULT; - } - if (i === winnersRoundCount - 1) return GRAND_FINALS_RESET_DEFAULT; - return WINNERS_DEFAULT; - }), - losers: new Array(losersRoundCount) - .fill(null) - .map((_, i) => - i === losersRoundCount - 1 ? LOSERS_FINALS_DEFAULT : LOSERS_DEFAULT - ), - }; -} - -export function winnersRoundNames(count: number, isSE: boolean) { - return new Array(count).fill(null).map((_, i) => { - if (i === count - 4 + Number(isSE)) { - return "Winners' Semifinals"; - } - if (i === count - 3 + Number(isSE)) return "Winners' Finals"; - if (i === count - 2 + Number(isSE)) return "Grand Finals"; - if (!isSE && i === count - 1) return "Bracket Reset"; - return `Winners' Round ${i + 1}`; - }); -} - -export function losersRoundNames(count: number) { - return new Array(count) - .fill(null) - .map((_, i) => - i === count - 1 ? "Losers' Finals" : `Losers' Round ${i + 1}` - ); -} - -export function getRoundNames(bracket: Bracket): EliminationBracket { - const { winners: winnersRoundCount, losers: losersRoundCount } = - countRounds(bracket); - - return { - winners: winnersRoundNames(winnersRoundCount, losersRoundCount === 0), - losers: losersRoundNames(losersRoundCount), - }; -} - -/** Returns round name (e.g. "Winners' Round 1") from positions - * - * @param position Position of the match to get the name for - * @param allPositions All positions of matches in the bracket. Positive integers are winners and negative losers. - * - */ -export function getRoundNameByPositions( - position: number, - allPositions: number[] -): string { - const winnersRoundCount = allPositions.reduce( - (sum, pos) => (pos > 0 ? 1 : 0) + sum, - 0 - ); - const losersRoundCount = allPositions.reduce( - (sum, pos) => (pos < 0 ? 1 : 0) + sum, - 0 - ); - - const allRoundNames = { - winners: winnersRoundNames(winnersRoundCount, losersRoundCount === 0), - losers: losersRoundNames(losersRoundCount), - }; - - const result = (() => { - const index = Math.abs(position) - 1; - if (position > 0) return allRoundNames.winners[index]; - - return allRoundNames.losers[index]; - })(); - invariant(result, "No round name found"); - - return result; -} - -export function countRounds( - bracket: Bracket, - skipFirstRoundLosersIfNotPlayed = true -): EliminationBracket { - const isDE = bracket.losers.length > 0; - let winners = isDE ? 2 : 0; - - for (let i = bracket.participantsWithByesCount; i > 1; i /= 2) { - winners++; - } - - if (!isDE) return { winners, losers: 0 }; - - const losersMatchIds = new Set(bracket.losers.map((match) => match.id)); - let losers = 0; - let losersMatch = bracket.losers[0]; - - while (true) { - losers++; - const match1 = losersMatch?.winnerDestinationMatch; - const match2 = losersMatch?.winnerDestinationMatch; - if (match1 && losersMatchIds.has(match1.id)) { - losersMatch = match1; - continue; - } else if (match2 && losersMatchIds.has(match2.id)) { - losersMatch = match2; - continue; - } - - break; - } - - let matchesWithByes = 0; - let matchesWithOpponent = 0; - - for (const match of bracket.winners) { - if (!match.upperTeam) break; - if (match.upperTeam === "BYE" || match.lowerTeam === "BYE") { - matchesWithByes++; - continue; - } - - matchesWithOpponent++; - } - - // First round of losers is not played if certain amount of byes - if ( - skipFirstRoundLosersIfNotPlayed && - matchesWithByes && - matchesWithByes >= matchesWithOpponent - ) { - losers--; - } - - return { winners, losers }; -} - -/** Resolve collection of brackets to string that can be shown to user */ -export function resolveTournamentFormatString( - brackets: { type: BracketType }[] -) { - invariant(brackets[0], "no brackets"); - return brackets[0].type === "DE" - ? "Double Elimination" - : "Single Elimination"; -} - -export function countParticipants(teams: FindTournamentByNameForUrlI["teams"]) { - return teams.reduce((acc, team) => { - if (!team.checkedInTime) return acc; - invariant( - team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE, - `Team with id ${team.id} has too small roster: ${team.members.length}` - ); - - return acc + 1; - }, 0); -} - -export type MapListIds = z.infer; -export const MapListIdsSchema = z.object({ - winners: z.array(z.array(z.object({ id: z.number() }))), - losers: z.array(z.array(z.object({ id: z.number() }))), -}); - -export interface TournamentRoundForDB { - id: string; - position: number; - stages: { - position: number; - stageId: number; - }[]; - matches: { - id: string; - number: number; - position: number; - winnerDestinationMatchId?: string; - loserDestinationMatchId?: string; - participants: { - team: { id: string } | "BYE"; - order: TeamOrder; - }[]; - }[]; -} -export function tournamentRoundsForDB({ - mapList, - bracketType, - participantsSeeded, -}: { - mapList: MapListIds; - bracketType: BracketType; - participantsSeeded: { id: string }[]; -}): TournamentRoundForDB[] { - const bracket = eliminationBracket(participantsSeeded.length, bracketType); - const result: TournamentRoundForDB[] = []; - - const groupedRounds = advanceByes(groupMatchesByRound(bracket)); - - for (const [sideI, side] of [ - groupedRounds.winners, - groupedRounds.losers, - ].entries()) { - const isWinners = sideI === 0; - for (const [roundI, round] of side.entries()) { - const position = isWinners ? roundI + 1 : -(roundI + 1); - const stagesRaw = mapList[isWinners ? "winners" : "losers"][roundI]; - // can be undefined if it's about unplayed first round of losers, - // we don't need to add any stages for that round - const stages = (stagesRaw ?? []).map((stage, i) => ({ - position: i + 1, - stageId: stage.id, - })); - - const matches = round.map((match) => { - return { - id: match.id, - position: match.position, - number: match.number, - winnerDestinationMatchId: match.winnerDestinationMatch?.id, - loserDestinationMatchId: match.loserDestinationMatch?.id, - participants: [match.upperTeam, match.lowerTeam].flatMap( - (team, i) => { - if (!team) return []; - const teamOrBye = - team === "BYE" - ? team - : { id: participantsSeeded[team - 1]?.id }; - invariant( - typeof teamOrBye === "string" || teamOrBye?.id, - `teamId is undefined - participantsSeeded: ${participantsSeeded.join( - "," - )}; team: ${team}` - ); - - return { - team: teamOrBye, - order: i === 0 ? "UPPER" : ("LOWER" as TeamOrder), - }; - } - ), - }; - }); - - result.push({ - id: uuidv4(), - position, - stages, - matches, - }); - } - } - - return result; -} - -function groupMatchesByRound(bracket: Bracket): EliminationBracket { - const { winners, losers } = countRounds(bracket, false); - - const result: EliminationBracket = { - winners: new Array(winners).fill(null).map(() => []), - losers: new Array(losers).fill(null).map(() => []), - }; - const matchesIncluded = new Set(); - for (const match of bracket.winners) { - // first round match - if (match.upperTeam && match.lowerTeam) { - search(match, "winners", 1); - search(match.loserDestinationMatch, "losers", 1); - } - } - - invariant( - matchesIncluded.size === bracket.winners.length + bracket.losers.length, - `matchesIncluded: ${matchesIncluded.size}; winners: ${bracket.winners.length}; losers: ${bracket.losers.length}` - ); - return result; - - function search( - match: Match | undefined, - side: EliminationBracketSide, - depth: number - ) { - if (!match) return; - if (matchesIncluded.has(match.id)) return; - - search(match.winnerDestinationMatch, side, depth + 1); - matchesIncluded.add(match.id); - invariant(result[side][depth - 1], "No rounds array for the match"); - result[side][depth - 1].push(match); - } -} - -function advanceByes( - rounds_: EliminationBracket -): EliminationBracket { - const result = clone(rounds_); - - const teamsForSecondRound = new Map< - number, - ["upperTeam" | "lowerTeam", number][] - >(); - for (const round of result.winners[0]) { - invariant(round.winnerDestinationMatch, "!round.winnerDestinationMatch"); - - const teamsForSecondRoundArr = - teamsForSecondRound.get(round.winnerDestinationMatch.number) ?? []; - let changed = false; - if ( - round.upperTeam && - round.upperTeam !== "BYE" && - round.lowerTeam === "BYE" - ) { - teamsForSecondRoundArr.push([ - resolveSide(round, round.winnerDestinationMatch, result), - round.upperTeam, - ]); - changed = true; - } else if ( - round.lowerTeam && - round.lowerTeam !== "BYE" && - round.upperTeam === "BYE" - ) { - teamsForSecondRoundArr.push([ - resolveSide(round, round.winnerDestinationMatch, result), - round.lowerTeam, - ]); - changed = true; - } - - if (changed) { - teamsForSecondRound.set( - round.winnerDestinationMatch.number, - teamsForSecondRoundArr - ); - } - } - - for (const [i, round] of result.winners[1].entries()) { - const teamForSecondRoundArr = teamsForSecondRound.get(round.number); - if (!teamForSecondRoundArr) continue; - - for (const teamForSecondRound of teamForSecondRoundArr) { - const [key, teamNumber] = teamForSecondRound; - result.winners[1][i] = { ...result.winners[1][i], [key]: teamNumber }; - } - } - - return result; -} - -function resolveSide( - currentMatch: Match, - destinationMatch: Match, - rounds: EliminationBracket -): "upperTeam" | "lowerTeam" { - const matchPositions = getWinnerDestinationMatchIdToMatchPositions( - rounds - ).get(destinationMatch.id); - const otherPosition = matchPositions?.find( - (num) => num !== currentMatch.position - ); - - invariant( - otherPosition, - `no otherPosition; matchPositions length was: ${ - matchPositions?.length ?? "NO_LENGTH" - }` - ); - - if (otherPosition > currentMatch.position) return "upperTeam"; - return "lowerTeam"; -} - -function getWinnerDestinationMatchIdToMatchPositions( - rounds: EliminationBracket -): Map { - return rounds.winners[0].reduce((map, round) => { - invariant( - round.winnerDestinationMatch, - "round.winnerDestinationMatch is undefined" - ); - if (!map.has(round.winnerDestinationMatch.id)) { - return map.set(round.winnerDestinationMatch.id, [round.position]); - } - - const arr = map.get(round.winnerDestinationMatch.id); - invariant(arr, "arr is undefined"); - arr.push(round.position); - - return map; - }, new Map()); -} - -export function newResultChangesWinner({ - oldResults, - newResults, -}: { - oldResults: Unpacked>["matchInfos"]; - newResults: BracketMatchAction["results"]; -}): boolean { - const oldWinnerIdCounts = oldResults.reduce( - (acc: Record, stage) => { - if (!stage.winnerId) return acc; - - if (!acc[stage.winnerId]) acc[stage.winnerId] = 1; - else acc[stage.winnerId]++; - - return acc; - }, - {} - ); - const countsToWinner = (counts: Record) => - Object.entries(counts).sort((a, b) => b[1] - a[1])?.[0][0]; - - const oldWinnerId = countsToWinner(oldWinnerIdCounts); - invariant(oldWinnerId, "!oldWinnerId"); - - const newWinnerIdCounts = newResults.reduce( - (acc: Record, stage) => { - if (!stage.winnerTeamId) return acc; - - if (!acc[stage.winnerTeamId]) acc[stage.winnerTeamId] = 1; - else acc[stage.winnerTeamId]++; - - return acc; - }, - {} - ); - const newWinnerId = countsToWinner(newWinnerIdCounts); - invariant(newWinnerId, "!newWinnerId"); - - return oldWinnerId !== newWinnerId; -} - -export type EliminationBracket = { - winners: T; - losers: T; -}; - -export type EliminationBracketSide = "winners" | "losers"; diff --git a/app/core/tournament/mapList.test.ts b/app/core/tournament/mapList.test.ts deleted file mode 100644 index 3fe2584fd..000000000 --- a/app/core/tournament/mapList.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Mode } from ".prisma/client"; -import clone from "just-clone"; -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { mapPoolForTest } from "~/utils/testUtils"; -import { eliminationBracket } from "./algorithms"; -import { getRoundsDefaultBestOf } from "./bracket"; -import { generateMapListForRounds } from "./mapList"; - -const MapListForRounds = suite("generateMapListMapForRounds()"); - -const ALL_MODES_LENGTH = 4; -const mapPool = mapPoolForTest(); -const bracket = eliminationBracket(100, "DE"); -const rounds = getRoundsDefaultBestOf(bracket); -const mapList = generateMapListForRounds({ mapPool, rounds }); - -MapListForRounds("No mode is repeated in the same round", () => { - for (const side of [mapList.winners, mapList.losers]) { - for (const round of side) { - for (const [i, stage] of round.entries()) { - if (i === 0) continue; - - assert.not.equal(stage.mode, round[i - 1]?.mode); - } - } - } -}); - -MapListForRounds("Should have all the map and mode combos", () => { - let mapPoolToEmpty = clone(mapPool); - for (const side of [mapList.winners, mapList.losers]) { - for (const round of side) { - for (const stage of round) { - mapPoolToEmpty = mapPoolToEmpty.filter( - (obj) => obj.name !== stage.name && obj.mode !== stage.mode - ); - } - } - } - - assert.equal(mapPoolToEmpty.length, 0); -}); - -MapListForRounds( - "Should not repeat mode (except SZ) in a round before other modes have appeared", - () => { - for (const side of [mapList.winners, mapList.losers]) { - for (const round of side) { - const modes: Mode[] = []; - for (const stage of round) { - if ( - modes.includes(stage.mode) && - modes.length < ALL_MODES_LENGTH && - stage.mode !== "SZ" - ) { - throw new Error(`Repeated mode: ${JSON.stringify(round, null, 2)}`); - } - modes.push(stage.mode); - } - } - } - } -); - -MapListForRounds( - "Should generate a map list even if only one map/mode combo (SZ)", - () => { - const mapListOfRepeatingNature = generateMapListForRounds({ - mapPool: [{ id: 1, mode: "SZ", name: "The Reef" }], - rounds, - }); - - assert.equal( - mapListOfRepeatingNature.winners.length, - mapList.winners.length - ); - } -); - -MapListForRounds( - "Should generate a map list even if only one map/mode combo (TC)", - () => { - const mapListOfRepeatingNature = generateMapListForRounds({ - mapPool: [{ id: 1, mode: "TC", name: "The Reef" }], - rounds, - }); - - assert.equal( - mapListOfRepeatingNature.winners.length, - mapList.winners.length - ); - } -); - -MapListForRounds.run(); diff --git a/app/core/tournament/mapList.ts b/app/core/tournament/mapList.ts deleted file mode 100644 index 1a2af443c..000000000 --- a/app/core/tournament/mapList.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Mode, Stage } from ".prisma/client"; -import shuffle from "just-shuffle"; -import invariant from "tiny-invariant"; -import clone from "just-clone"; -import { BestOf, EliminationBracket } from "./bracket"; - -// TODO: make this accept an array of bestOf instead of EliminationBracket -export function generateMapListForRounds({ - mapPool, - rounds, -}: { - mapPool: Stage[]; - rounds: EliminationBracket; -}): EliminationBracket { - const mapGenerator = getMapGenerator(); - const modes = mapPool.reduce((acc: [Mode, number][], cur) => { - if (cur.mode === "SZ") return acc; - if (acc.some(([mode]) => mode === cur.mode)) { - return acc.map((modeTuple) => - modeTuple[0] === cur.mode - ? ([modeTuple[0], ++modeTuple[1]] as [Mode, number]) - : modeTuple - ); - } - - acc.push([cur.mode, 1]); - return acc; - }, []); - let currentModes = clone(modes); - const hasSZ = mapPool.some((stage) => stage.mode === "SZ"); - const onlySZ = mapPool.every((stage) => stage.mode === "SZ"); - const allModesLength = modes.length + Number(hasSZ); - let stagesOfPreviousRound: string[] = []; - - return { - winners: rounds.winners.map((round) => { - const mapList = roundsMapList(round); - stagesOfPreviousRound = mapList.map((s) => s.name); - return mapList; - }), - losers: rounds.losers.map((round, i) => { - if (i === 0) stagesOfPreviousRound = []; - const mapList = roundsMapList(round); - stagesOfPreviousRound = mapList.map((s) => s.name); - return mapList; - }), - }; - - function roundsMapList(bestOf: BestOf): Stage[] { - const modes = onlySZ - ? new Array(bestOf).fill(null).map(() => "SZ" as Mode) - : resolveModes(bestOf); - const maps = resolveMaps(modes); - - return new Array(bestOf).fill(null).map((_, i) => { - const mode = modes[i]; - const name = maps[i]; - invariant(mode, "mode undefined"); - invariant(name, "name undefined"); - return { id: resolveId({ mode, name }), name, mode }; - }); - } - - function resolveModes(bestOf: BestOf): Mode[] { - let result: (Mode | null)[] = []; - /** 0, 1, 2, 3, 4 */ - const amountOfSZToAdd = !hasSZ ? 0 : Math.floor(bestOf / 2); - for (let _ = 0; _ < amountOfSZToAdd; _++) { - result.push("SZ"); - } - while (result.length < bestOf) { - result.push(null); - } - - result = shuffle(result); - - while (SZInInvalidPosition(result)) { - result = shuffle(result); - } - - const resultWithNoNull: Mode[] = []; - for (let i = 0; i < result.length; i++) { - const element = result[i]; - // Is SZ - if (element) { - resultWithNoNull.push(element); - continue; - } - - const previous = resultWithNoNull[i - 1]; - invariant(previous !== null, "previous is null"); - - if (currentModes.every(([_mode, count]) => count === 0)) { - currentModes = clone(modes); - } - resetCurrentModesIfWouldHaveToRepeatMode(previous); - currentModes = shuffle(currentModes); - currentModes.sort((a, b) => { - // Don't repeat a mode before all the modes have appeared in a round - const modesOfTheRound = Array.from(new Set(resultWithNoNull)); - if (modesOfTheRound.length < allModesLength) { - if ( - modesOfTheRound.includes(a[0]) && - modesOfTheRound.includes(b[0]) - ) { - return 0; - } - if (modesOfTheRound.includes(b[0])) return -1; - if (modesOfTheRound.includes(a[0])) return 1; - } - - return b[1] - a[1]; - }); - - const nextModeTuple = currentModes[0]; - invariant(nextModeTuple, "nextModeTuple undefined"); - - resultWithNoNull.push(nextModeTuple[0]); - nextModeTuple[1]--; - } - - return resultWithNoNull; - } - - function SZInInvalidPosition(modes: (Mode | null)[]) { - for (const [i, mode] of modes.entries()) { - if (i === 0) continue; - if (!mode) continue; - if (mode === modes[i - 1]) return true; - } - return false; - } - - function resetCurrentModesIfWouldHaveToRepeatMode(previous?: Mode | null) { - if (!previous) return; - const modesLeft = currentModes.flatMap(([mode, count]) => - count === 0 ? [] : mode - ); - if (modesLeft.length > 1) return; - invariant(modesLeft[0], "modesLeft[0] is undefined"); - if (modesLeft[0][0] !== previous) return; - - currentModes = clone(modes); - } - - interface MapPoolMap { - name: string; - desirability: number; - modes: Mode[]; - } - - function resolveMaps(modes: Mode[]) { - const result: string[] = []; - for (const mode of modes) { - mapGenerator.next(); - const nextMap = mapGenerator.next({ - mode, - stagesAlreadyIncludedThisRound: result, - }).value; - invariant(typeof nextMap === "string", "nextMap is not string"); - result.push(nextMap); - } - - return result; - } - - function* getMapGenerator(): Generator< - string | null, - undefined, - { mode: Mode; stagesAlreadyIncludedThisRound: string[] } - > { - let stages = mapPool.reduce((acc: MapPoolMap[], cur) => { - const match = acc.find((a) => a.name === cur.name); - if (!match) { - acc.push({ - desirability: 1, - name: cur.name, - modes: [cur.mode], - }); - } else { - match.modes.push(cur.mode); - match.desirability++; - } - - return acc; - }, []); - - while (true) { - const { mode, stagesAlreadyIncludedThisRound } = yield null; - stages = shuffle(stages); - stages.sort((a, b) => b.desirability - a.desirability); - let stage = stages.find( - (mapPoolMap) => - mapPoolMap.modes.includes(mode) && - !stagesAlreadyIncludedThisRound.includes(mapPoolMap.name) && - !stagesOfPreviousRound.includes(mapPoolMap.name) - ); - - // TODO: handle this fallback behavior smarter - if (!stage) { - stage = stages.find((mapPoolMap) => mapPoolMap.modes.includes(mode)); - } - invariant(stage, "stage is undefined"); - stage.desirability--; - - yield stage.name; - } - } - - function resolveId({ mode, name }: { mode: Mode; name: string }): number { - const mapPoolObject = mapPool.find( - (stage) => stage.mode === mode && stage.name === name - ); - invariant(mapPoolObject, "mapPoolObject is undefined"); - return mapPoolObject.id; - } -} diff --git a/app/core/tournament/permissions.ts b/app/core/tournament/permissions.ts deleted file mode 100644 index a0b57393a..000000000 --- a/app/core/tournament/permissions.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TODO: move to validators -export function canReportMatchScore({ - userId, - members, -}: { - userId: string; - members: { memberId: string }[]; -}) { - return members.some((member) => member.memberId === userId); -} diff --git a/app/core/tournament/utils.test.ts b/app/core/tournament/utils.test.ts deleted file mode 100644 index 39b51285a..000000000 --- a/app/core/tournament/utils.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { suite } from "uvu"; -import * as assert from "uvu/assert"; -import { sortTeamsBySeed } from "./utils"; - -const SortTeams = suite("sortTeamsBySeed()"); - -SortTeams("Sorts teams by seed", () => { - const seeds = ["3", "2", "1"]; - const teamsToSeed = [ - { id: "1", createdAt: "1639036511550" }, - { id: "2", createdAt: "1639036511550" }, - { id: "3", createdAt: "1639036511550" }, - ]; - - assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), [ - { id: "3", createdAt: "1639036511550" }, - { id: "2", createdAt: "1639036511550" }, - { id: "1", createdAt: "1639036511550" }, - ]); -}); - -SortTeams("Sorts teams by createdAt", () => { - const seeds: string[] = []; - const teamsToSeed = [ - { id: "1", createdAt: "1639036511550" }, - { id: "2", createdAt: "1639036511540" }, - { id: "3", createdAt: "1639036511530" }, - ]; - - assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), [ - { id: "3", createdAt: "1639036511530" }, - { id: "2", createdAt: "1639036511540" }, - { id: "1", createdAt: "1639036511550" }, - ]); -}); - -SortTeams("Sorts teams by seed and createdAt", () => { - const seeds: string[] = ["3"]; - const teamsToSeed = [ - { id: "1", createdAt: "1639036511550" }, - { id: "2", createdAt: "1639036511540" }, - { id: "3", createdAt: "1639036511560" }, - ]; - - assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), [ - { id: "3", createdAt: "1639036511560" }, - { id: "2", createdAt: "1639036511540" }, - { id: "1", createdAt: "1639036511550" }, - ]); -}); - -SortTeams("Can handle non-existent id in seeds", () => { - const seeds: string[] = ["4", "3", "2"]; - const teamsToSeed = [ - { id: "1", createdAt: "1639036511550" }, - { id: "2", createdAt: "1639036511540" }, - { id: "3", createdAt: "1639036511560" }, - ]; - - assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), [ - { id: "3", createdAt: "1639036511560" }, - { id: "2", createdAt: "1639036511540" }, - { id: "1", createdAt: "1639036511550" }, - ]); -}); - -SortTeams("Sorting works with empty arrays", () => { - const seeds: string[] = []; - const teamsToSeed: { id: string; createdAt: string }[] = []; - - assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), []); -}); - -SortTeams.run(); diff --git a/app/core/tournament/utils.ts b/app/core/tournament/utils.ts deleted file mode 100644 index 662113d2f..000000000 --- a/app/core/tournament/utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -import invariant from "tiny-invariant"; -import { BracketModified } from "~/services/bracket"; - -export function checkInHasStarted(checkInStartTime: string) { - return new Date(checkInStartTime) < new Date(); -} - -type Team = { members: ({ captain: boolean } & T)[] }; -export function captainOfTeam(team: Team) { - const result = team.members.find(({ captain }) => captain); - invariant(result, "Team has no captain"); - - return result; -} - -export function sortTeamsBySeed(seeds: string[]) { - return function ( - a: { id: string; createdAt: string | Date }, - b: { id: string; createdAt: string | Date } - ) { - const aSeed = seeds.indexOf(a.id); - const bSeed = seeds.indexOf(b.id); - - // if one team doesn't have seed and the other does - // the one with the seed takes priority - if (aSeed === -1 && bSeed !== -1) return 1; - if (aSeed !== -1 && bSeed === -1) return -1; - - // if both teams are unseeded the one who registered - // first gets to be seeded first as well - if (aSeed === -1 && bSeed === -1) { - return Number(a.createdAt) - Number(b.createdAt); - } - - // finally, consider the seeds - return aSeed - bSeed; - }; -} - -export function tournamentHasStarted( - brackets: { - rounds: { - position: number; - }[]; - }[] -) { - return brackets[0].rounds.length > 0; -} - -export interface MatchIsOverArgs { - bestOf: number; - score?: [upperTeamScore: number, lowerTeamScore: number]; -} -// TODO: move to validators -export function matchIsOver({ bestOf, score }: MatchIsOverArgs) { - if (!score) return false; - - const [upperTeamScore, lowerTeamScore] = score; - const half = bestOf / 2; - return upperTeamScore > half || lowerTeamScore > half; -} - -export function allMatchesReported(bracket: BracketModified) { - return bracket.rounds - .flatMap((r) => - r.matches.map((match) => ({ ...match, bestOf: r.stages.length })) - ) - .every((match) => { - const mapsToWin = Math.ceil(match.bestOf / 2); - - return match.score && match.score.some((score) => score >= mapsToWin); - }); -} - -export const friendCodeRegExpString = "^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$"; -export const friendCodeRegExp = new RegExp(friendCodeRegExpString, "i"); diff --git a/app/core/tournament/validators.ts b/app/core/tournament/validators.ts deleted file mode 100644 index 35c0f592e..000000000 --- a/app/core/tournament/validators.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { TOURNAMENT_TEAM_ROSTER_MAX_SIZE } from "~/constants"; -import { matchIsOver, MatchIsOverArgs } from "./utils"; - -interface IsTournamentAdminArgs { - userId?: string; - organization: { ownerId: string }; -} - -/** Checks that a user is considered an admin of the tournament. An admin can perform all sorts of actions that normal users can't. */ -export function isTournamentAdmin({ - // TODO: refactor to user - userId, - organization, -}: IsTournamentAdminArgs) { - return organization.ownerId === userId; -} - -export function canEditMatchResults({ - userId, - organization, - match, - tournamentConcluded, -}: IsTournamentAdminArgs & { - match: MatchIsOverArgs; - tournamentConcluded: boolean; -}) { - if (!isTournamentAdmin({ userId, organization })) return false; - if (!matchIsOver(match)) return false; - if (tournamentConcluded) return false; - - return true; -} - -/** Checks if tournament has not started meaning there is no bracket with rounds generated. */ -export function tournamentHasNotStarted(tournament: { - brackets: { rounds: unknown[] }[]; -}) { - return (tournament.brackets[0]?.rounds.length ?? 0) === 0; -} - -/** Checks if given user is captain of the team. Captain is considered the admin of the team. */ -export function isCaptainOfTheTeam( - user: { id: string }, - team: { members: { captain: boolean; memberId: string }[] } -) { - return team.members.some( - ({ memberId, captain }) => captain && memberId === user.id - ); -} - -/** Checks tournament team's member count is below the max roster size constant. */ -export function tournamentTeamIsNotFull(team: { members: unknown[] }) { - return team.members.length < TOURNAMENT_TEAM_ROSTER_MAX_SIZE; -} - -export function teamHasNotCheckedIn(team: { checkedInTime: Date | null }) { - return !team.checkedInTime; -} diff --git a/app/db/index.ts b/app/db/index.ts new file mode 100644 index 000000000..d86ac1314 --- /dev/null +++ b/app/db/index.ts @@ -0,0 +1,5 @@ +import * as users from "./models/users"; + +export const db = { + users, +}; diff --git a/app/db/models/users.ts b/app/db/models/users.ts new file mode 100644 index 000000000..86103551b --- /dev/null +++ b/app/db/models/users.ts @@ -0,0 +1,67 @@ +import { sql } from "../sql"; +import type { User } from "../types"; + +const upsertStm = sql.prepare(` + INSERT INTO + "User" ( + "discordId", + "discordName", + "discordDiscriminator", + "discordAvatar", + "twitch", + "twitter", + "youtubeId" + ) + VALUES ( + $discordId, + $discordName, + $discordDiscriminator, + $discordAvatar, + $twitch, + $twitter, + $youtubeId + ) + ON CONFLICT("discordId") DO UPDATE SET + "discordName" = excluded."discordName", + "discordDiscriminator" = excluded."discordDiscriminator", + "discordAvatar" = excluded."discordAvatar", + "twitch" = excluded."twitch", + "twitch" = excluded."twitch", + "youtubeId" = excluded."youtubeId" + RETURNING * +`); + +export function upsert( + input: Pick< + User, + | "discordId" + | "discordName" + | "discordDiscriminator" + | "discordAvatar" + | "twitch" + | "twitter" + | "youtubeId" + > +) { + return upsertStm.get(input) as User; +} + +const updateProfileStm = sql.prepare(` + UPDATE "User" + SET "country" = $country + WHERE "id" = $id +`); + +export function updateProfile(params: Pick) { + updateProfileStm.run(params); +} + +const findByIdentifierStm = sql.prepare(` + SELECT * + FROM "User" + WHERE "discordId" = $identifier +`); + +export function findByIdentifier(identifier: string) { + return findByIdentifierStm.get({ identifier }) as User | undefined; +} diff --git a/app/db/sql.ts b/app/db/sql.ts new file mode 100644 index 000000000..9ae04d15c --- /dev/null +++ b/app/db/sql.ts @@ -0,0 +1,8 @@ +import Database from "better-sqlite3"; + +// eslint-disable-next-line no-console +export const sql = new Database("db.sqlite3", { verbose: console.log }); + +sql.pragma("journal_mode = WAL"); +sql.pragma("foreign_keys = ON"); +sql.pragma("busy_timeout = 5000"); diff --git a/app/db/types.ts b/app/db/types.ts new file mode 100644 index 000000000..bfa796b7b --- /dev/null +++ b/app/db/types.ts @@ -0,0 +1,12 @@ +export interface User { + id: number; + discordId: string; + discordName: string; + discordDiscriminator: string; + discordAvatar: string | null; + twitch: string | null; + twitter: string | null; + youtubeId: string | null; + bio: string | null; + country: string | null; +} diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 11e5cc33a..3eec1fd0a 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,5 +1,4 @@ -import { hydrate } from "react-dom"; import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; -// TODO: should this be hydrateRoot? hydrate(, document); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 8a20ee1ee..5afa18235 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,6 +1,6 @@ -import { renderToString } from "react-dom/server"; import type { EntryContext } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; export default function handleRequest( request: Request, diff --git a/app/hooks/common.ts b/app/hooks/common.ts deleted file mode 100644 index fb018b8b8..000000000 --- a/app/hooks/common.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as React from "react"; -import { useLoaderData, useMatches, useNavigate } from "@remix-run/react"; -import { z } from "zod"; -import { LoggedInUserSchema } from "~/utils/schemas"; - -export const useUser = () => { - const [root] = useMatches(); - - const parsed = LoggedInUserSchema.parse(root.data); - return parsed?.user; -}; - -export const useBaseURL = () => { - const [root] = useMatches(); - - const parsed = z.object({ baseURL: z.string() }).parse(root.data); - return parsed.baseURL; -}; - -// TODO: fix causes memory leak -/** @link https://stackoverflow.com/a/64983274 */ -export const useTimeoutState = ( - defaultState: T -): [ - T, - (action: React.SetStateAction, opts?: { timeout: number }) => void -] => { - const [state, _setState] = React.useState(defaultState); - const [currentTimeoutId, setCurrentTimeoutId] = React.useState< - NodeJS.Timeout | undefined - >(); - - const setState = React.useCallback( - (action: React.SetStateAction, opts?: { timeout: number }) => { - if (currentTimeoutId != null) { - clearTimeout(currentTimeoutId); - } - - _setState(action); - - const id = setTimeout( - () => _setState(defaultState), - opts?.timeout ?? 4000 - ); - setCurrentTimeoutId(id); - }, - [currentTimeoutId, defaultState] - ); - return [state, setState]; -}; - -/** @link https://usehooks.com/useOnClickOutside/ */ -export function useOnClickOutside( - ref: React.RefObject, - handler: (event: MouseEvent | TouchEvent) => void -) { - React.useEffect(() => { - const listener = (event: MouseEvent | TouchEvent) => { - if (!ref.current || ref.current.contains(event.target as Node)) { - return; - } - handler(event); - }; - document.addEventListener("mousedown", listener); - document.addEventListener("touchstart", listener); - return () => { - document.removeEventListener("mousedown", listener); - document.removeEventListener("touchstart", listener); - }; - }, [ref, handler]); -} - -/** Refreshed loader data of the current route in an interval. - * @returns Timestamp last updated - */ -export function usePolling(pollingActive = true) { - const [lastUpdated, setLastUpdated] = React.useState(new Date()); - const data = useLoaderData(); - const navigate = useNavigate(); - - const INTERVAL = 20_000; // 20 seconds - - React.useEffect(() => { - if (!pollingActive) return; - const timer = setTimeout(() => { - navigate("."); - }, INTERVAL); - - return () => clearTimeout(timer); - }, [pollingActive, navigate, data]); - - React.useEffect(() => { - setLastUpdated(new Date()); - }, [data]); - - return lastUpdated; -} - -// https://usehooks.com/useWindowSize/ -export function useWindowSize() { - // Initialize state with undefined width/height so server and client renders match - // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ - const [windowSize, setWindowSize] = React.useState<{ - width?: number; - height?: number; - }>({ - width: undefined, - height: undefined, - }); - React.useEffect(() => { - // Handler to call on window resize - function handleResize() { - // Set window width/height to state - setWindowSize({ - width: window.innerWidth, - height: window.innerHeight, - }); - } - // Add event listener - window.addEventListener("resize", handleResize); - // Call handler right away so state gets updated with initial window size - handleResize(); - // Remove event listener on cleanup - return () => window.removeEventListener("resize", handleResize); - }, []); // Empty array ensures that effect is only run on mount - return windowSize; -} diff --git a/app/hooks/useBracketDataWithEvents.ts b/app/hooks/useBracketDataWithEvents.ts deleted file mode 100644 index ca053d6ce..000000000 --- a/app/hooks/useBracketDataWithEvents.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useLoaderData } from "@remix-run/react"; -import * as React from "react"; -import type { BracketModified } from "~/services/bracket"; -import { Unpacked } from "~/utils"; -import { useSocketEvent } from "./useSocketEvent"; - -export type BracketData = { - number: Unpacked["matches"]>["number"]; - participants: - | Unpacked["matches"]>["participants"] - | null; - score: - | Unpacked["matches"]>["score"] - | null; -}[]; - -export function useBracketDataWithEvents(): BracketModified { - const data = useLoaderData(); - const [dataWithEvents, setDataWithEvents] = React.useState(data); - - const eventHandler = React.useCallback( - (data: unknown) => { - const dataMap = (data as BracketData).reduce( - (map, bracketData) => map.set(bracketData.number, bracketData), - new Map>() - ); - setDataWithEvents({ - ...dataWithEvents, - rounds: dataWithEvents.rounds.map((round) => ({ - ...round, - matches: round.matches.map((match) => { - const bracketData = dataMap.get(match.number); - if (match.number !== bracketData?.number) return match; - // with null we overwrite - // with undefined we use the previous value - const getParticipants = () => { - if (bracketData.participants === null) return undefined; - if (bracketData.participants) return bracketData.participants; - return match.participants; - }; - const getScore = () => { - if (bracketData.score === null) return undefined; - if (bracketData.score) return bracketData.score; - return match.score; - }; - return { - id: match.id, - loserDestinationMatchId: match.loserDestinationMatchId, - winnerDestinationMatchId: match.winnerDestinationMatchId, - number: match.number, - participantSourceMatches: match.participantSourceMatches, - participants: getParticipants(), - score: getScore(), - }; - }), - })), - }); - }, - [dataWithEvents, setDataWithEvents] - ); - - useSocketEvent(`bracket-${data.id}`, eventHandler); - - return dataWithEvents; -} diff --git a/app/hooks/useOnClickOutside.ts b/app/hooks/useOnClickOutside.ts new file mode 100644 index 000000000..af3328868 --- /dev/null +++ b/app/hooks/useOnClickOutside.ts @@ -0,0 +1,22 @@ +import * as React from "react"; + +/** @link https://usehooks.com/useOnClickOutside/ */ +export function useOnClickOutside( + ref: React.RefObject, + handler: (event: MouseEvent | TouchEvent) => void +) { + React.useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(event); + }; + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +} diff --git a/app/hooks/useSocketEvent.ts b/app/hooks/useSocketEvent.ts deleted file mode 100644 index 0390a10a6..000000000 --- a/app/hooks/useSocketEvent.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from "react"; -import { useSocket } from "~/utils/socketContext"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useSocketEvent(event: string, handler: (data: any) => void) { - const socket = useSocket(); - - React.useEffect(() => { - if (!socket) return; - socket.on(event, handler); - - return () => { - socket.off(event); - }; - }, [socket, handler, event]); -} diff --git a/app/hooks/useTournamentRounds/index.ts b/app/hooks/useTournamentRounds/index.ts deleted file mode 100644 index e810c20ff..000000000 --- a/app/hooks/useTournamentRounds/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -import clone from "just-clone"; -import * as React from "react"; -import invariant from "tiny-invariant"; -import { generateMapListForRounds } from "~/core/tournament/mapList"; -import { StartLoaderData } from "~/routes/to/$organization.$tournament/start"; -import type { - UseTournamentRoundsAction, - UseTournamentRoundsState, -} from "./types"; - -// TODO: could save this to local storage but need to handle amount of rounds changing -export function useTournamentRounds(args: NonNullable) { - return React.useReducer( - (oldState: UseTournamentRoundsState, action: UseTournamentRoundsAction) => { - switch (action.type) { - case "START_EDITING_ROUND": { - const newState = clone(oldState); - newState.bracket[action.data.side] = newState.bracket[ - action.data.side - ].map((round, i) => - i === action.data.index - ? { ...round, editing: true, newMapList: [...round.mapList] } - : round - ); - - return calculateRoundsBeingEditedAndAdjustState(newState); - } - case "CANCEL_EDITING_ROUND": { - const newState = clone(oldState); - newState.bracket[action.data.side] = newState.bracket[ - action.data.side - ].map((round, i) => - i === action.data.index ? { ...round, editing: false } : round - ); - - return calculateRoundsBeingEditedAndAdjustState(newState); - } - case "SAVE_ROUND": { - const newState = clone(oldState); - newState.bracket[action.data.side] = newState.bracket[ - action.data.side - ].map((round, i) => { - if (i !== action.data.index) return round; - invariant(round.newMapList, "round.newMapList is undefined"); - return { ...round, editing: false, mapList: round.newMapList }; - }); - - return calculateRoundsBeingEditedAndAdjustState(newState); - } - case "EDIT_STAGE": { - const newState = clone(oldState); - const roundToEdit = newState.bracket[action.data.side].find( - (_, i) => i === action.data.index - ); - invariant( - roundToEdit?.editing, - "roundToEdit is undefined or doesn't have editing attribute" - ); - - let newMapList = roundToEdit.newMapList; - if (!newMapList) { - newMapList = roundToEdit.mapList; - } - - newMapList[action.data.stageNumber - 1] = action.data.newStage; - - return newState; - } - case "REGENERATE_MAP_LIST": { - return regenMapList({ oldState }); - } - case "SET_ROUND_BEST_OF": { - const newState = clone(oldState); - newState.bracket[action.data.side][action.data.index].bestOf = - action.data.newBestOf; - - return regenMapList({ oldState, newState }); - } - case "SHOW_ALERT": { - return { ...oldState, showAlert: true }; - } - default: { - return oldState; - } - } - }, - { bracket: args.initialState, showAlert: false } - ); - - function regenMapList({ - oldState, - newState: _newState, - }: { - oldState: UseTournamentRoundsState; - newState?: UseTournamentRoundsState; - }): UseTournamentRoundsState { - const newState = _newState ?? oldState; - const newMapLists = generateMapListForRounds({ - mapPool: args.mapPool, - rounds: { - winners: newState.bracket.winners.map((r) => r.bestOf), - losers: newState.bracket.losers.map((r) => r.bestOf), - }, - }); - - return { - showAlert: oldState.showAlert, - bracket: { - winners: newState.bracket.winners.map((round, i) => ({ - ...round, - mapList: newMapLists.winners[i], - })), - losers: newState.bracket.losers.map((round, i) => ({ - ...round, - mapList: newMapLists.losers[i], - })), - }, - }; - } - - function calculateRoundsBeingEditedAndAdjustState( - newState: UseTournamentRoundsState - ): UseTournamentRoundsState { - const beingEditedCount = [newState.bracket.winners, newState.bracket.losers] - .flat() - .reduce((acc, cur) => { - return acc + (cur.editing ? 1 : 0); - }, 0); - - return { - ...newState, - showAlert: beingEditedCount === 0 ? false : newState.showAlert, - actionButtonsDisabled: beingEditedCount > 0 ? true : false, - }; - } -} diff --git a/app/hooks/useTournamentRounds/types.ts b/app/hooks/useTournamentRounds/types.ts deleted file mode 100644 index 206c964a2..000000000 --- a/app/hooks/useTournamentRounds/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Stage } from ".prisma/client"; -import type { - BestOf, - EliminationBracket, - EliminationBracketSide, -} from "~/core/tournament/bracket"; -import { MyReducerAction } from "~/utils"; - -export type UseTournamentRoundsState = { - bracket: EliminationBracket< - { - bestOf: BestOf; - name: string; - mapList: Stage[]; - newMapList?: Stage[]; - editing?: boolean; - }[] - >; - showAlert: boolean; - actionButtonsDisabled?: boolean; -}; - -export interface UseTournamentRoundsArgs { - initialState: UseTournamentRoundsState["bracket"]; - mapPool: Stage[]; -} - -export type UseTournamentRoundsAction = - | MyReducerAction<"REGENERATE_MAP_LIST"> - | MyReducerAction< - "START_EDITING_ROUND", - { side: EliminationBracketSide; index: number } - > - | MyReducerAction< - "EDIT_STAGE", - { - side: EliminationBracketSide; - index: number; - stageNumber: number; - newStage: Stage; - } - > - | MyReducerAction< - "SAVE_ROUND", - { side: EliminationBracketSide; index: number } - > - | MyReducerAction< - "CANCEL_EDITING_ROUND", - { side: EliminationBracketSide; index: number } - > - | MyReducerAction< - "SET_ROUND_BEST_OF", - { newBestOf: BestOf; side: EliminationBracketSide; index: number } - > - | MyReducerAction<"SHOW_ALERT">; diff --git a/app/hooks/useUser.ts b/app/hooks/useUser.ts new file mode 100644 index 000000000..8ebd7d9e5 --- /dev/null +++ b/app/hooks/useUser.ts @@ -0,0 +1,8 @@ +import { useMatches } from "@remix-run/react"; +import type { RootLoaderData } from "~/root"; + +export const useUser = () => { + const [root] = useMatches(); + + return (root.data as RootLoaderData).user; +}; diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts new file mode 100644 index 000000000..50a98877c --- /dev/null +++ b/app/hooks/useWindowSize.ts @@ -0,0 +1,31 @@ +import * as React from "react"; + +// https://usehooks.com/useWindowSize/ +export function useWindowSize() { + // Initialize state with undefined width/height so server and client renders match + // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ + const [windowSize, setWindowSize] = React.useState<{ + width?: number; + height?: number; + }>({ + width: undefined, + height: undefined, + }); + React.useEffect(() => { + // Handler to call on window resize + function handleResize() { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + // Add event listener + window.addEventListener("resize", handleResize); + // Call handler right away so state gets updated with initial window size + handleResize(); + // Remove event listener on cleanup + return () => window.removeEventListener("resize", handleResize); + }, []); // Empty array ensures that effect is only run on mount + return windowSize; +} diff --git a/app/models/ChatMessage.server.ts b/app/models/ChatMessage.server.ts deleted file mode 100644 index d9676a69a..000000000 --- a/app/models/ChatMessage.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { db } from "~/utils/db.server"; - -export function create({ - content, - roomId, - userId, -}: { - content: string; - roomId: string; - userId: string; -}) { - return db.chatMessage.create({ - data: { - content, - roomId, - senderId: userId, - }, - }); -} - -export function findByRoomIds(roomIds: string[]) { - return db.chatMessage.findMany({ - where: { roomId: { in: roomIds } }, - orderBy: { createdAt: "asc" }, - include: { - sender: true, - }, - }); -} diff --git a/app/models/GameDetail.server.ts b/app/models/GameDetail.server.ts deleted file mode 100644 index 09f3fe12f..000000000 --- a/app/models/GameDetail.server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Ability } from "@prisma/client"; -import { db } from "~/utils/db.server"; - -export interface CreateGameDetailsInput { - id: string; - duration: number; - startedAt: Date; - lfgStageId: string; - teams: { - id: string; - isWinner: boolean; - score: number; - players: { - principalId: string; - name: string; - weapon: string; - mainAbilities: Ability[]; - subAbilities: Ability[]; - kills: number; - assists: number; - deaths: number; - specials: number; - paint: number; - gear: string[]; - }[]; - }[]; -} -export function create(details: CreateGameDetailsInput[]) { - return db.$transaction([ - db.gameDetail.createMany({ - data: details.map(({ teams: _teams, ...detail }) => detail), - }), - db.gameDetailTeam.createMany({ - data: details.flatMap((detail) => - detail.teams.map(({ players: _players, ...team }) => ({ - gameDetailId: detail.id, - ...team, - })) - ), - }), - db.gameDetailPlayer.createMany({ - data: details - .flatMap((detail) => detail.teams) - .flatMap((team) => - team.players.map((player) => ({ - gameDetailTeamId: team.id, - ...player, - })) - ), - }), - ]); -} diff --git a/app/models/LFGGroup.server.ts b/app/models/LFGGroup.server.ts deleted file mode 100644 index fd5d4c5b9..000000000 --- a/app/models/LFGGroup.server.ts +++ /dev/null @@ -1,391 +0,0 @@ -import type { LfgGroupStatus, LfgGroupType, Prisma } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; -import { generateMapListForLfgMatch } from "~/core/play/mapList"; -import { db } from "~/utils/db.server"; - -export function create({ - type, - ranked, - user, -}: { - type: LfgGroupType; - ranked?: boolean; - user: { id: string }; -}) { - return db.lfgGroup.create({ - data: { - type, - // TWIN starts looking immediately because it makes no sense - // to pre-add players to the group - status: type === "TWIN" ? "LOOKING" : "PRE_ADD", - ranked, - members: { - create: { - memberId: user.id, - captain: true, - }, - }, - }, - }); -} - -export function createPrefilled({ - ranked, - members, -}: { - ranked: boolean | null; - members: { memberId: string; captain: boolean }[]; -}) { - return db.lfgGroup.create({ - data: { - type: "VERSUS", - ranked, - status: "PRE_ADD", - members: { - createMany: { data: members }, - }, - }, - }); -} - -export async function like({ - likerId, - targetId, -}: { - likerId: string; - targetId: string; -}) { - try { - // not transaction because doesn't really matter - // if only one goes through and other not - await Promise.all([ - db.lfgGroupLike.create({ - data: { - likerId, - targetId, - }, - }), - db.lfgGroup.update({ - where: { id: likerId }, - data: { - lastActionAt: new Date(), - }, - }), - ]); - } catch (e) { - // No need for any errors if e.g. duplicate entry was tried or - // liked user stopped looking - if (e instanceof PrismaClientKnownRequestError) return; - throw e; - } -} - -export function unlike({ - likerId, - targetId, -}: { - likerId: string; - targetId: string; -}) { - // not transaction because doesn't really matter - // if only one goes through and other not - return Promise.all([ - db.lfgGroupLike.delete({ - where: { - likerId_targetId: { - likerId, - targetId, - }, - }, - }), - db.lfgGroup.update({ - where: { id: likerId }, - data: { - lastActionAt: new Date(), - }, - }), - ]); -} - -export function addMember({ - userId, - groupId, -}: { - userId: string; - groupId: string; -}) { - return db.lfgGroup.update({ - where: { id: groupId }, - data: { - members: { - create: { - memberId: userId, - }, - }, - }, - }); -} - -export interface UniteGroupsArgs { - survivingGroupId: string; - otherGroupId: string; - removeCaptainsFromOther: boolean; - unitedGroupIsRanked?: boolean; -} -export function uniteGroups({ - survivingGroupId, - otherGroupId, - removeCaptainsFromOther, - unitedGroupIsRanked, -}: UniteGroupsArgs) { - const survivingGroupUpdatedTimestamps = { - lastActionAt: new Date(), - createdAt: new Date(), - }; - - return db.$transaction([ - db.lfgGroupMember.updateMany({ - where: { groupId: otherGroupId }, - data: { - groupId: survivingGroupId, - captain: removeCaptainsFromOther ? false : undefined, - }, - }), - db.lfgGroup.delete({ where: { id: otherGroupId } }), - db.lfgGroupLike.deleteMany({ - where: { - OR: [{ likerId: survivingGroupId }, { targetId: survivingGroupId }], - }, - }), - db.lfgGroup.update({ - where: { id: survivingGroupId }, - data: - typeof unitedGroupIsRanked === "boolean" - ? { ranked: unitedGroupIsRanked, ...survivingGroupUpdatedTimestamps } - : survivingGroupUpdatedTimestamps, - }), - ]); -} - -export async function matchUp({ - groupIds, - ranked, -}: { - groupIds: [string, string]; - ranked?: boolean; -}) { - const match = await db.lfgGroupMatch.create({ - data: { - stages: ranked - ? { - createMany: { - data: generateMapListForLfgMatch(), - }, - } - : undefined, - }, - }); - - await db.lfgGroup.updateMany({ - where: { - id: { - in: groupIds, - }, - }, - data: { - matchId: match.id, - status: "MATCH", - }, - }); - - return match; -} - -export function findByInviteCode(inviteCode: string) { - return db.lfgGroup.findFirst({ - where: { - inviteCode, - status: "PRE_ADD", - }, - include: { - members: { - include: { - user: true, - }, - }, - }, - }); -} - -export function findById(id: string) { - return db.lfgGroup.findUnique({ where: { id }, include: { members: true } }); -} - -export type FindActiveByMember = Prisma.PromiseReturnType< - typeof findActiveByMember ->; -export function findActiveByMember(user: { id: string }) { - return db.lfgGroup.findFirst({ - where: { - status: { - not: "INACTIVE", - }, - members: { - some: { - memberId: user.id, - }, - }, - }, - include: { - members: { include: { user: true } }, - likedGroups: { - select: { - targetId: true, - }, - }, - likesReceived: { - select: { - likerId: true, - }, - }, - }, - }); -} - -export async function activeUserIds() { - const rows = await db.$queryRaw< - { - memberId: string; - status: LfgGroupStatus; - }[] - >` - SELECT "LfgGroupMember"."memberId", "LfgGroup".status - FROM "LfgGroupMember" - JOIN "LfgGroup" ON ("LfgGroupMember"."groupId" = "LfgGroup".id) - WHERE "LfgGroup".status != 'INACTIVE';`; - - return new Map( - rows.map((r) => [r.memberId, r.status]) - ); -} - -export type FindLookingAndOwnActive = Prisma.PromiseReturnType< - typeof findLookingAndOwnActive ->["groups"]; -// TODO: refactor -> true by default -export async function findLookingAndOwnActive( - userId?: string, - showPreAddMatch = false -) { - const mainFilter = showPreAddMatch - ? { - NOT: { - status: "INACTIVE" as const, - }, - } - : { - status: "LOOKING" as const, - }; - const where = userId - ? { - OR: [ - mainFilter, - { - members: { - some: { - memberId: userId, - }, - }, - NOT: { - status: "INACTIVE" as const, - }, - }, - ], - } - : mainFilter; - const groups = await db.lfgGroup.findMany({ - where, - include: { - members: { - include: { - user: { - include: { - skill: { - orderBy: { - createdAt: "desc", - }, - distinct: "userId", - }, - }, - }, - }, - }, - likedGroups: true, - likesReceived: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const ownGroup = groups.find((g) => - g.members.some((m) => m.user.id === userId) - ); - - return { groups, ownGroup }; -} - -export function startLooking(id: string) { - return db.lfgGroup.update({ - where: { - id, - }, - data: { - status: "LOOKING", - }, - }); -} - -export function setInactive(id: string) { - return db.lfgGroup.update({ - where: { - id, - }, - data: { - status: "INACTIVE", - }, - }); -} - -export function leaveGroup({ - groupId, - memberId, -}: { - groupId: string; - memberId: string; -}) { - // delete many so we don't throw in case - // the group was just integrated into another - // group - return db.lfgGroupMember.deleteMany({ - where: { - groupId, - memberId, - // no escaping group if match has been formed - group: { - matchId: null, - }, - }, - }); -} - -export function unexpire(groupId: string) { - return db.lfgGroup.update({ - where: { - id: groupId, - }, - data: { - lastActionAt: new Date(), - }, - }); -} diff --git a/app/models/LFGMatch.server.ts b/app/models/LFGMatch.server.ts deleted file mode 100644 index 60c8b1853..000000000 --- a/app/models/LFGMatch.server.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { adjustSkills, adjustSkillsWithCancel } from "~/core/mmr/utils"; -import { db } from "~/utils/db.server"; -import * as Skill from "~/models/Skill.server"; - -export type FindById = Prisma.PromiseReturnType; -export function findById(id: string) { - return db.lfgGroupMatch.findUnique({ - where: { id }, - select: { - createdAt: true, - cancelCausingUserId: true, - stages: { - select: { - id: true, - stage: { - select: { - id: true, - name: true, - mode: true, - }, - }, - winnerGroupId: true, - details: { - select: { - duration: true, - teams: { - select: { - id: true, - isWinner: true, - score: true, - players: { - select: { - principalId: true, - assists: true, - deaths: true, - kills: true, - paint: true, - specials: true, - name: true, - weapon: true, - }, - }, - }, - }, - }, - }, - }, - orderBy: { - order: "asc", - }, - }, - groups: { include: { members: { include: { user: true } } } }, - }, - }); -} - -export function findByUserId({ userId }: { userId: string }) { - return db.lfgGroupMatch.findMany({ - where: { - groups: { - some: { - members: { - some: { - memberId: userId, - }, - }, - }, - }, - stages: { - some: { - winnerGroupId: { - not: null, - }, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - include: { - groups: { - include: { - members: { - include: { - user: true, - }, - }, - }, - }, - stages: true, - }, - }); -} - -export type RecentOfUser = Prisma.PromiseReturnType; -export function recentOfUser(userId: string) { - const twoHoursAgo = () => { - const result = new Date(); - result.setHours(result.getHours() - 2); - - return result; - }; - return db.lfgGroupMatch.findFirst({ - where: { - groups: { - some: { - members: { - some: { - memberId: userId, - }, - }, - }, - }, - createdAt: { - gte: twoHoursAgo(), - }, - }, - orderBy: { - createdAt: "desc", - }, - include: { - groups: { - include: { - members: true, - }, - }, - }, - }); -} - -interface ReportScoreArgs { - UNSAFE_matchId: string; - /** Group ID's in order of stages win */ - UNSAFE_winnerGroupIds: string[]; - playerIds: { - winning: string[]; - losing: string[]; - }; - groupIds: string[]; -} -export async function reportScore({ - UNSAFE_matchId, - UNSAFE_winnerGroupIds, - playerIds, - groupIds, -}: ReportScoreArgs) { - const allPlayerIds = [...playerIds.winning, ...playerIds.losing]; - const skills = await Skill.findMostRecentByUserIds(allPlayerIds); - - const adjustedSkills = adjustSkills({ skills, playerIds }); - - return db.$transaction([ - Skill.createMany( - adjustedSkills.map((s) => ({ - ...s, - matchId: UNSAFE_matchId, - tournamentId: null, - amountOfSets: null, - })) - ), - db.lfgGroup.updateMany({ - where: { - id: { - in: groupIds, - }, - }, - data: { - status: "INACTIVE", - }, - }), - insertScores({ UNSAFE_matchId, UNSAFE_winnerGroupIds }), - ]); -} - -export async function overrideScores({ - UNSAFE_matchId, - UNSAFE_winnerGroupIds, -}: Pick) { - return db.$transaction([ - db.lfgGroupMatchStage.updateMany({ - where: { lfgGroupMatchId: UNSAFE_matchId }, - data: { winnerGroupId: null }, - }), - insertScores({ - UNSAFE_matchId, - UNSAFE_winnerGroupIds, - }), - ]); -} - -export function deleteMatch(id: string) { - return db.$transaction([ - db.lfgGroupMatchStage.deleteMany({ where: { lfgGroupMatchId: id } }), - db.lfgGroupMatch.delete({ where: { id } }), - ]); -} - -function insertScores({ - UNSAFE_matchId, - UNSAFE_winnerGroupIds, -}: Pick) { - // https://stackoverflow.com/a/26715934 - return db.$executeRawUnsafe(` - update "LfgGroupMatchStage" as lfg set - "winnerGroupId" = lfg2.winner_id - from (values - ${UNSAFE_winnerGroupIds.map( - (winnerId, i) => `('${UNSAFE_matchId}', ${i + 1}, '${winnerId}')` - ).join(",")} - ) as lfg2(lfg_group_match_id, "order", winner_id) - where lfg2.lfg_group_match_id = lfg."lfgGroupMatchId" and lfg2.order = lfg.order; - `); -} - -export async function cancel({ - matchId, - cancelCausingUserId, - groupIds, - playerIds, -}: { - matchId: string; - cancelCausingUserId: string; - groupIds: string[]; - playerIds: { - winning: string[]; - losing: string[]; - }; -}) { - const allPlayerIds = [...playerIds.winning, ...playerIds.losing]; - const skills = await Skill.findMostRecentByUserIds(allPlayerIds); - - const adjustedSkills = adjustSkillsWithCancel({ - skills, - playerIds, - // if someone quits / is a no show we don't punish their - // teammates for that but they get no change to skill. - // Winners still get their raised points - noUpdateUserIds: playerIds.losing.filter( - (id) => id !== cancelCausingUserId - ), - }); - - return db.$transaction([ - Skill.createMany( - adjustedSkills.map((s) => ({ - ...s, - matchId, - tournamentId: null, - amountOfSets: null, - })) - ), - db.lfgGroup.updateMany({ - where: { - id: { - in: groupIds, - }, - }, - data: { - status: "INACTIVE", - }, - }), - db.lfgGroupMatch.update({ - where: { id: matchId }, - data: { cancelCausingUserId }, - }), - ]); -} diff --git a/app/models/Skill.server.ts b/app/models/Skill.server.ts deleted file mode 100644 index f995cdbdb..000000000 --- a/app/models/Skill.server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Prisma, Skill } from "@prisma/client"; -import { db } from "~/utils/db.server"; - -export function createMany( - data: Pick< - Skill, - "mu" | "sigma" | "tournamentId" | "userId" | "amountOfSets" - >[] -) { - return db.skill.createMany({ - data, - }); -} - -export type FindAllMostRecent = Prisma.PromiseReturnType< - typeof findAllMostRecent ->; -export function findAllMostRecent(userIds?: string[]) { - return db.skill.findMany({ - orderBy: { - createdAt: "desc", - }, - distinct: "userId", - where: userIds - ? { - userId: { - in: userIds, - }, - } - : undefined, - }); -} - -export function findMostRecentByUserIds(ids: string[]) { - return db.skill.findMany({ - orderBy: { - createdAt: "desc", - }, - distinct: "userId", - where: { userId: { in: ids } }, - }); -} - -export function findAllByMonth({ - month, - year, -}: { - month: number; - year: number; -}) { - const from = new Date(year, month - 1, 1); - // https://stackoverflow.com/questions/222309/calculate-last-day-of-month - const to = new Date(Date.UTC(year, month, 0)); - - return db.skill.findMany({ - include: { user: true }, - where: { - AND: [{ createdAt: { gte: from } }, { createdAt: { lt: to } }], - }, - }); -} diff --git a/app/models/Tournament.server.ts b/app/models/Tournament.server.ts deleted file mode 100644 index fc4cbf209..000000000 --- a/app/models/Tournament.server.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Prisma } from ".prisma/client"; -import { db } from "~/utils/db.server"; - -export type FindById = Prisma.PromiseReturnType; -export function findById(id: string) { - return db.tournament.findUnique({ - where: { id }, - include: { - organizer: true, - brackets: { include: { rounds: true } }, - teams: { include: { members: true } }, - }, - }); -} - -export type FindByNameForUrl = Prisma.PromiseReturnType< - typeof findByNameForUrl ->; -export async function findByNameForUrl({ - tournamentNameForUrl, - organizationNameForUrl, - withInviteCodes = false, -}: { - tournamentNameForUrl: string; - organizationNameForUrl: string; - withInviteCodes?: boolean; -}) { - const tournaments = await db.tournament.findMany({ - where: { - nameForUrl: tournamentNameForUrl.toLowerCase(), - }, - select: { - id: true, - name: true, - nameForUrl: true, - description: true, - startTime: true, - checkInStartTime: true, - bannerBackground: true, - bannerTextHSLArgs: true, - seeds: true, - concluded: true, - organizer: { - select: { - name: true, - discordInvite: true, - twitter: true, - nameForUrl: true, - ownerId: true, - }, - }, - mapPool: { - select: { - id: true, - mode: true, - name: true, - }, - }, - brackets: { - select: { - id: true, - type: true, - rounds: { - select: { - position: true, - }, - }, - }, - }, - teams: { - select: { - checkedInTime: true, - id: true, - name: true, - createdAt: true, - inviteCode: withInviteCodes, - members: { - select: { - captain: true, - member: { - select: { - id: true, - discordAvatar: true, - discordName: true, - discordId: true, - discordDiscriminator: true, - friendCode: true, - }, - }, - }, - }, - }, - }, - }, - }); - - return tournaments.find( - (tournament) => - tournament.organizer.nameForUrl === organizationNameForUrl.toLowerCase() - ); -} - -export type OwnTeam = Prisma.PromiseReturnType; -export async function ownTeam({ - tournamentNameForUrl, - organizerNameForUrl, - user, -}: { - tournamentNameForUrl: string; - organizerNameForUrl: string; - user: { id: string }; -}) { - const tournaments = await db.tournament.findMany({ - where: { - organizer: { - nameForUrl: organizerNameForUrl.toLowerCase(), - }, - nameForUrl: tournamentNameForUrl.toLowerCase(), - }, - include: { - organizer: true, - teams: { - include: { - members: { - include: { - member: true, - }, - }, - }, - }, - }, - }); - - if (tournaments.length === 0) return null; - - const ownTeam = tournaments[0].teams.find((team) => - team.members.some(({ captain, member }) => captain && member.id === user.id) - ); - if (!ownTeam) null; - - return ownTeam; -} - -export type UpdateSeeds = Prisma.PromiseReturnType; -export function updateSeeds({ - tournamentId, - seeds, -}: { - tournamentId: string; - seeds: string[]; -}) { - return db.tournament.update({ - where: { - id: tournamentId, - }, - data: { - seeds, - }, - }); -} - -export function conclude(id: string) { - return db.tournament.update({ - where: { id }, - data: { - concluded: true, - }, - }); -} diff --git a/app/models/TournamentBracket.server.ts b/app/models/TournamentBracket.server.ts deleted file mode 100644 index 9c7d61334..000000000 --- a/app/models/TournamentBracket.server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Prisma } from ".prisma/client"; -import { db } from "~/utils/db.server"; - -export type FindById = Prisma.PromiseReturnType; -export function findById(bracketId: string) { - return db.tournamentBracket.findUnique({ - where: { id: bracketId }, - select: { - rounds: { - select: { - id: true, - position: true, - stages: { - select: { - id: true, - position: true, - stage: true, - }, - }, - matches: { - select: { - id: true, - number: true, - position: true, - winnerDestinationMatchId: true, - loserDestinationMatchId: true, - participants: { - select: { - team: { - select: { - id: true, - name: true, - }, - }, - order: true, - }, - }, - results: { - select: { - winner: true, - }, - }, - }, - orderBy: { - position: "asc", - }, - }, - }, - }, - }, - }); -} diff --git a/app/models/TournamentMatch.server.ts b/app/models/TournamentMatch.server.ts deleted file mode 100644 index 0fd6c41a3..000000000 --- a/app/models/TournamentMatch.server.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { Prisma, TeamOrder } from "@prisma/client"; -import { db } from "~/utils/db.server"; -import type { - Mode, - TournamentMatchGameResult, - TournamentTeamMember, - User, -} from "@prisma/client"; -import invariant from "tiny-invariant"; -import { TeamRosterInputTeam } from "~/components/tournament/TeamRosterInputs"; -import { getRoundNameByPositions } from "~/core/tournament/bracket"; -import { v4 as uuidv4 } from "uuid"; -import { MatchIsOverArgs } from "~/core/tournament/utils"; - -export type FindById = Prisma.PromiseReturnType; -export function findById(id: string) { - return db.tournamentMatch.findUnique({ - where: { id }, - include: { - round: { - include: { - stages: true, - }, - }, - results: true, - participants: { - include: { - team: { - include: { - members: true, - }, - }, - }, - }, - }, - }); -} - -export function createResult({ - roundStageId, - reporterId, - winner, - matchId, - playerIds, -}: { - roundStageId: string; - reporterId: string; - winner: TeamOrder; - matchId: string; - playerIds: string[]; -}) { - return db.tournamentMatchGameResult.create({ - data: { - roundStage: { - connect: { - id: roundStageId, - }, - }, - reporterId, - winner, - players: { - connect: playerIds.map((id) => ({ id })), - }, - match: { - connect: { - id: matchId, - }, - }, - }, - }); -} - -export function deleteResult(id: string) { - return db.tournamentMatchGameResult.delete({ where: { id } }); -} - -export function updateResults({ - matchId, - newResults, - reporterId, -}: { - matchId: string; - newResults: { - UNSAFE_playerIds: string[]; - roundStageId: string; - winnerOrder: TeamOrder; - }[]; - reporterId: string; -}) { - const newResultsWithIds = newResults.map((r) => ({ ...r, id: uuidv4() })); - - return db.$transaction([ - db.tournamentMatchGameResult.deleteMany({ - where: { - matchId, - }, - }), - db.tournamentMatchGameResult.createMany({ - data: newResultsWithIds.map((result) => ({ - id: result.id, - matchId, - reporterId, - roundStageId: result.roundStageId, - winner: result.winnerOrder, - })), - }), - db.$executeRawUnsafe(` - insert into "_TournamentMatchGameResultToUser" ("A", "B") values - ${newResultsWithIds - .flatMap((result) => - result.UNSAFE_playerIds.map( - (playerId) => `('${result.id}', '${playerId}')` - ) - ) - .join(", ")}; - `), - ]); -} - -export type CreateParticipantsData = { - matchId: string; - order: TeamOrder; - teamId: string; -}[]; -export function createParticipants(data: CreateParticipantsData) { - return db.tournamentMatchParticipant.createMany({ - data, - }); -} - -export type FindInfoForModal = - | { - id: string; - title: string; - scoreTitle: string; - roundName: string; - bestOf: MatchIsOverArgs["bestOf"]; - score: MatchIsOverArgs["score"]; - matchInfos: { - idForFrontend: string; - teamUpper: TeamRosterInputTeam; - teamLower: TeamRosterInputTeam; - winnerId?: string; - stage: { name: string; mode: Mode }; - }[]; - } - | undefined; -export async function findInfoForModal({ - bracketId, - matchNumber, -}: { - bracketId: string; - matchNumber: number; -}): Promise { - const tournamentRounds = await db.tournamentRound.findMany({ - where: { bracketId }, - include: { - matches: { - include: { - results: { include: { players: true } }, - participants: { - include: { - team: { include: { members: { include: { member: true } } } }, - }, - }, - }, - }, - stages: { include: { stage: true } }, - }, - }); - - const tournamentRound = tournamentRounds.find((round) => - round.matches.find((match) => match.number === matchNumber) - ); - const match = tournamentRound?.matches.find( - (match) => match.number === matchNumber - ); - - if (!tournamentRound || !match) return; - - const teamsOrdered = match.participants.sort((a, b) => - b.order.localeCompare(a.order) - ); - - const upperTeam = match.participants.find((p) => p.order === "UPPER"); - const lowerTeam = match.participants.find((p) => p.order === "LOWER"); - invariant(upperTeam && lowerTeam, "upper or lower team is undefined"); - - const matchInfos = tournamentRound.stages - .sort((a, b) => a.position - b.position) - .map((tournamentRoundStage) => { - /** Result of this one stage, if undefined means the stage was not played yet */ - const stageResult = match.results.find( - (r) => r.roundStageId === tournamentRoundStage.id - ); - - const membersWithPlayedInfo = playersOfMatch({ - stageResult, - upperTeamMembers: upperTeam.team.members, - lowerTeamMembers: lowerTeam.team.members, - }); - - return { - idForFrontend: uuidv4(), - teamUpper: { - name: upperTeam.team.name, - id: upperTeam.teamId, - members: membersWithPlayedInfo.upperTeamMembers, - }, - teamLower: { - name: lowerTeam.team.name, - id: lowerTeam.teamId, - members: membersWithPlayedInfo.lowerTeamMembers, - }, - winnerId: stageResult - ? stageResult.winner === "UPPER" - ? upperTeam.teamId - : lowerTeam.teamId - : undefined, - stage: { - name: tournamentRoundStage.stage.name, - mode: tournamentRoundStage.stage.mode, - }, - }; - }); - - const score = match.results.reduce( - (scores: [number, number], result) => { - if (result.winner === "UPPER") scores[0]++; - else scores[1]++; - return scores; - }, - [0, 0] - ); - const scoreTitle = score.join("-"); - - return { - id: match.id, - title: `${teamsOrdered[0].team.name} vs. ${teamsOrdered[1].team.name}`, - scoreTitle, - roundName: getRoundNameByPositions( - tournamentRound.position, - tournamentRounds.map((round) => round.position) - ), - matchInfos, - score, - bestOf: tournamentRound.stages.length, - }; -} - -/** Returns players grouped by team with info whether they played this stage or not */ -function playersOfMatch({ - stageResult, - upperTeamMembers, - lowerTeamMembers, -}: { - stageResult?: TournamentMatchGameResult & { - players: User[]; - }; - upperTeamMembers: (TournamentTeamMember & { - member: User; - })[]; - lowerTeamMembers: (TournamentTeamMember & { - member: User; - })[]; -}) { - if (!stageResult) return { upperTeamMembers, lowerTeamMembers }; - - const stageResultPlayerIds = stageResult.players.reduce( - (acc, cur) => acc.add(cur.id), - new Set() - ); - - return { - upperTeamMembers: upperTeamMembers.map(({ member }) => { - return { - member: { - id: member.id, - discordName: member.discordName, - played: stageResultPlayerIds.has(member.id), - }, - }; - }), - lowerTeamMembers: lowerTeamMembers.map(({ member }) => { - return { - member: { - id: member.id, - discordName: member.discordName, - played: stageResultPlayerIds.has(member.id), - }, - }; - }), - }; -} - -export type AllTournamentMatchesWithRosterInfo = Prisma.PromiseReturnType< - typeof allTournamentMatchesWithRosterInfo ->; -export function allTournamentMatchesWithRosterInfo(bracketId: string) { - return db.tournamentMatch.findMany({ - select: { - participants: { - select: { - team: { - select: { - members: { - select: { - memberId: true, - }, - }, - }, - }, - order: true, - }, - }, - results: { - select: { - players: { - select: { - id: true, - }, - }, - winner: true, - }, - }, - }, - where: { round: { bracketId } }, - orderBy: { position: "asc" }, - }); -} diff --git a/app/models/TournamentTeam.server.ts b/app/models/TournamentTeam.server.ts deleted file mode 100644 index b4a856c52..000000000 --- a/app/models/TournamentTeam.server.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Prisma } from ".prisma/client"; -import { db } from "~/utils/db.server"; - -export type Create = Prisma.PromiseReturnType; -export function create({ - userId, - teamName, - tournamentId, -}: { - userId: string; - teamName: string; - tournamentId: string; -}) { - return db.tournamentTeam.create({ - data: { - name: teamName.trim(), - tournamentId, - members: { - create: { - memberId: userId, - tournamentId, - captain: true, - }, - }, - }, - }); -} - -export type FindById = Prisma.PromiseReturnType; -export function findById(id: string) { - return db.tournamentTeam.findUnique({ - where: { id }, - include: { tournament: { include: { organizer: true } }, members: true }, - }); -} - -export type CheckIn = Prisma.PromiseReturnType; -export function checkIn(id: string) { - return db.tournamentTeam.update({ - where: { - id, - }, - data: { - checkedInTime: new Date(), - }, - }); -} - -export type CheckOut = Prisma.PromiseReturnType; -export function checkOut(id: string) { - return db.tournamentTeam.update({ - where: { - id, - }, - data: { - checkedInTime: null, - }, - }); -} - -export function unregister(id: string) { - // TODO: this should use cascades instead - return db.$transaction([ - db.tournamentTeamMember.deleteMany({ where: { teamId: id } }), - db.tournamentTeam.delete({ - where: { - id, - }, - }), - ]); -} diff --git a/app/models/TournamentTeamMember.server.ts b/app/models/TournamentTeamMember.server.ts deleted file mode 100644 index de9d152fd..000000000 --- a/app/models/TournamentTeamMember.server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Prisma } from ".prisma/client"; -import { db } from "~/utils/db.server"; - -export type JoinTeam = Prisma.PromiseReturnType; -export function joinTeam({ - memberId, - teamId, - tournamentId, -}: { - memberId: string; - teamId: string; - tournamentId: string; -}) { - return db.tournamentTeamMember.create({ - data: { - tournamentId, - teamId, - memberId, - }, - }); -} - -export type Del = Prisma.PromiseReturnType; -export function del({ - memberId, - tournamentId, -}: { - memberId: string; - tournamentId: string; -}) { - return db.tournamentTeamMember.delete({ - where: { - memberId_tournamentId: { - memberId, - tournamentId, - }, - }, - }); -} diff --git a/app/models/TrustRelationship.server.ts b/app/models/TrustRelationship.server.ts deleted file mode 100644 index d3baca172..000000000 --- a/app/models/TrustRelationship.server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Prisma } from ".prisma/client"; -import { db } from "~/utils/db.server"; - -export type FindManyByTrustReceiverId = Prisma.PromiseReturnType< - typeof findManyByTrustReceiverId ->; -export function findManyByTrustReceiverId(userId: string) { - return db.trustRelationships.findMany({ - where: { trustReceiverId: userId }, - select: { - trustGiver: { - select: { - id: true, - discordName: true, - }, - }, - }, - }); -} - -export type Upsert = Prisma.PromiseReturnType; -export function upsert({ - trustGiverId, - trustReceiverId, -}: { - trustGiverId: string; - trustReceiverId: string; -}) { - return db.trustRelationships.upsert({ - where: { - trustGiverId_trustReceiverId: { - trustGiverId, - trustReceiverId, - }, - }, - create: { - trustGiverId, - trustReceiverId, - }, - update: {}, - }); -} diff --git a/app/models/User.server.ts b/app/models/User.server.ts deleted file mode 100644 index f9c4c01d7..000000000 --- a/app/models/User.server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { db } from "~/utils/db.server"; - -export type FindTrusters = Prisma.PromiseReturnType; -export function findTrusters(userId: string) { - return db.trustRelationships.findMany({ - where: { trustReceiverId: userId }, - select: { - trustGiver: { - select: { - id: true, - discordName: true, - }, - }, - }, - }); -} - -export function findById(userId?: string) { - if (!userId) return; - return db.user.findUnique({ where: { id: userId } }); -} - -export function update({ - userId, - miniBio, - weapons, - friendCode, -}: { - userId: string; - miniBio: Nullable; - weapons: string[]; - friendCode: Nullable; -}) { - return db.user.update({ - where: { id: userId }, - data: { miniBio, weapons, friendCode }, - }); -} diff --git a/app/root.tsx b/app/root.tsx index 6d5780e8a..13ddf5ee5 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,9 @@ -import type { LinksFunction, LoaderFunction } from "@remix-run/node"; +import * as React from "react"; +import type { + LinksFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/node"; import { json } from "@remix-run/node"; import { Links, @@ -6,152 +11,59 @@ import { Meta, Outlet, Scripts, - useCatch, + ScrollRestoration, } from "@remix-run/react"; -import clsx from "clsx"; -import * as React from "react"; -import { io, Socket } from "socket.io-client"; +import { authenticator } from "~/core/authenticator.server"; import globalStyles from "~/styles/global.css"; import layoutStyles from "~/styles/layout.css"; import resetStyles from "~/styles/reset.css"; -import { LoggedInUser, LoggedInUserSchema } from "~/utils/schemas"; -import { Catcher } from "./components/Catcher"; -import { Layout } from "./components/Layout"; -import { SocketProvider } from "./utils/socketContext"; -import { discordUrl } from "./utils/urls"; +import commonStyles from "~/styles/common.css"; +import { Layout } from "./components/layout"; +import type { LoggedInUser } from "./core/DiscordStrategy.server"; export const links: LinksFunction = () => { return [ { rel: "stylesheet", href: resetStyles }, { rel: "stylesheet", href: globalStyles }, + { rel: "stylesheet", href: commonStyles }, { rel: "stylesheet", href: layoutStyles }, ]; }; -// export interface EnvironmentVariables { -// FF_ENABLE_CHAT?: "true" | "admin" | string; -// } +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "sendou.ink", + viewport: "width=device-width,initial-scale=1", +}); export interface RootLoaderData { user?: LoggedInUser; - baseURL: string; - // ENV: EnvironmentVariables; } -export const loader: LoaderFunction = ({ context }) => { - const data = LoggedInUserSchema.parse(context as unknown); - const baseURL = process.env.FRONT_PAGE_URL ?? "http://localhost:5800/"; +export const loader: LoaderFunction = async ({ request }) => { + const user = (await authenticator.isAuthenticated(request)) ?? undefined; - return json({ - user: data?.user, - baseURL, - // ENV: { - // FF_ENABLE_CHAT: process.env.FF_ENABLE_CHAT, - // }, - }); + return json({ user }); }; -export const unstable_shouldReload = () => false; - export default function App() { const [menuOpen, setMenuOpen] = React.useState(false); - const [socket, setSocket] = React.useState(); - - const children = React.useMemo(() => , []); - // const data = useLoaderData(); - - // TODO: for future optimization could only connect socket on sendouq/tournament pages - React.useEffect(() => { - const socket = io(); - setSocket(socket); - return () => { - socket.close(); - }; - }, []); - - return ( - - - - {children} - - - - ); -} - -function Document({ - children, - title, - disableBodyScroll = false, -}: // ENV, -{ - children: React.ReactNode; - title?: string; - disableBodyScroll?: boolean; - // ENV?: EnvironmentVariables; -}) { return ( - - - {title ? {title} : null} - - {children} - {/*