diff --git a/.eslintrc.js b/.eslintrc.js index ae6f16f7b..7262d438f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,13 +44,6 @@ module.exports = { "Please import useTranslation from '~/hooks/useTranslation' instead.", }, ], - patterns: [ - { - group: ["~/features/*/*"], - message: - "Please import from '~/features' instead. Export from index.ts what should be exposed. See README for more information.", - }, - ], }, ], }, diff --git a/README.md b/README.md index ee36783d2..1f9d9db79 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,7 @@ Some common files: - feature-constants.ts - feature-schemas.server.ts -Note: Anything that should be accessed outside of that feature should be re-exported in the index.ts file. -Note 2: Currently the project is in progress to migrating this style so that's why not everything is using it yet. +Note: Currently the project is in progress to migrating this style so that's why not everything is using it yet. ## Commands diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx new file mode 100644 index 000000000..cba43c5ce --- /dev/null +++ b/app/components/Chart.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { type AxisOptions, Chart as ReactChart } from "react-charts"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { Theme, useTheme } from "~/modules/theme"; + +export default function Chart({ + options, +}: { + options: [ + { label: string; data: Array<{ primary: Date; secondary: number }> } + ]; +}) { + const theme = useTheme(); + const isMounted = useIsMounted(); + + const primaryAxis = React.useMemo< + AxisOptions<(typeof options)[number]["data"][number]> + >( + () => ({ + getValue: (datum) => datum.primary, + }), + [] + ); + + const secondaryAxes = React.useMemo< + AxisOptions<(typeof options)[number]["data"][number]>[] + >( + () => [ + { + getValue: (datum) => datum.secondary, + }, + ], + [] + ); + + if (!isMounted) return
; + + return ( +
+ +
+ ); +} diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index b68697f09..ee88691f2 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -46,6 +46,7 @@ interface ComboboxProps { id?: string; isLoading?: boolean; required?: boolean; + value?: ComboboxOption | null; initialValue: ComboboxOption | null; onChange?: (selectedOption: ComboboxOption | null) => void; fullWidth?: boolean; @@ -57,6 +58,7 @@ export function Combobox>({ options, inputName, placeholder, + value, initialValue, onChange, required, @@ -69,7 +71,7 @@ export function Combobox>({ }: ComboboxProps) { const { t } = useTranslation(); - const [selectedOption, setSelectedOption] = React.useState | null>(initialValue); const [query, setQuery] = React.useState(""); @@ -94,6 +96,8 @@ export function Combobox>({ return option?.label ?? ""; }; + const selectedOption = value ?? _selectedOption; + return (
, | "inputName" @@ -249,6 +254,7 @@ export function WeaponCombobox({ > & { initialWeaponId?: (typeof mainWeaponIds)[number]; weaponIdsToOmit?: Set; + value?: MainWeaponId | null; }) { const { t } = useTranslation("weapons"); @@ -264,6 +270,7 @@ export function WeaponCombobox({ options={mainWeaponIds .filter((id) => !weaponIdsToOmit?.has(id)) .map(idToWeapon)} + value={typeof value === "number" ? idToWeapon(value) : null} initialValue={ typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null } diff --git a/app/components/Image.tsx b/app/components/Image.tsx index 6b2435544..00f61c217 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -1,11 +1,14 @@ +import type { TierName } from "~/features/mmr/mmr-constants"; import { useTranslation } from "~/hooks/useTranslation"; import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists"; import { + TIER_PLUS_URL, mainWeaponImageUrl, modeImageUrl, outlinedFiveStarMainWeaponImageUrl, outlinedMainWeaponImageUrl, stageImageUrl, + tierImageUrl, } from "~/utils/urls"; interface ImageProps { @@ -61,12 +64,13 @@ export function Image({ type WeaponImageProps = { weaponSplId: MainWeaponId; variant: "badge" | "badge-5-star" | "build"; -} & Omit; +} & Omit; export function WeaponImage({ weaponSplId, variant, testId, + title, ...rest }: WeaponImageProps) { const { t } = useTranslation(["weapons"]); @@ -74,8 +78,8 @@ export function WeaponImage({ return ( {t(`weapons:MAIN_${weaponSplId}`)}; +} & Omit; -export function ModeImage({ mode, testId, ...rest }: ModeImageProps) { +export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) { const { t } = useTranslation(["game-misc"]); return ( {t(`game-misc:MODE_LONG_${mode}`)} @@ -124,3 +128,35 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) { /> ); } + +type TierImageProps = { + tier: { name: TierName; isPlus: boolean }; +} & Omit; + +export function TierImage({ tier, width = 200 }: TierImageProps) { + const title = `${tier.name}${tier.isPlus ? "+" : ""}`; + + const height = width * 0.8675; + + return ( +
+ {title} + {tier.isPlus ? ( + + ) : null} +
+ ); +} diff --git a/app/components/SubNav.tsx b/app/components/SubNav.tsx index 730404a79..78e3bd3f6 100644 --- a/app/components/SubNav.tsx +++ b/app/components/SubNav.tsx @@ -3,10 +3,22 @@ import type { LinkProps } from "@remix-run/react"; import clsx from "clsx"; import type * as React from "react"; -export function SubNav({ children }: { children: React.ReactNode }) { +export function SubNav({ + children, + secondary, +}: { + children: React.ReactNode; + secondary?: boolean; +}) { return (
- +
); } @@ -15,15 +27,40 @@ export function SubNavLink({ children, className, end = true, + secondary = false, + controlled = false, + active = false, ...props }: LinkProps & { end?: boolean; children: React.ReactNode; + secondary?: boolean; + controlled?: boolean; + active?: boolean; }) { return ( - -
{children}
-
+ + clsx("sub-nav__link__container", { + active: controlled ? active : state.isActive, + pending: state.isPending, + }) + } + end={end} + {...props} + > +
+ {children} +
+
); } diff --git a/app/components/SubmitButton.tsx b/app/components/SubmitButton.tsx index 9966832ca..106f56582 100644 --- a/app/components/SubmitButton.tsx +++ b/app/components/SubmitButton.tsx @@ -18,13 +18,27 @@ export function SubmitButton({ const isSubmitting = state ? state !== "idle" : navigation.state !== "idle"; + const name = () => { + if (rest.name) return rest.name; + if (_action) return "_action"; + + return undefined; + }; + + const value = () => { + if (rest.value) return rest.value; + if (_action) return _action; + + return undefined; + }; + return ( + + ) : null} + + + ); +} + +function GroupMember({ + member, + showActions, +}: { + member: NonNullable[number]; + showActions: boolean; +}) { + const fetcher = useFetcher(); + + return ( + + + + + {member.discordName} + + {member.plusTier ? ( +
+{member.plusTier}
+ ) : null} + {member.role === "REGULAR" && showActions ? ( + + Give manager + + ) : null} + {member.role === "MANAGER" && showActions ? ( + + Remove manager + + ) : null} +
+ ); +} diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx new file mode 100644 index 000000000..dab5c7667 --- /dev/null +++ b/app/features/sendouq/components/MemberAdder.tsx @@ -0,0 +1,79 @@ +import { useFetcher } from "@remix-run/react"; +import { useCopyToClipboard } from "react-use"; +import { Button } from "~/components/Button"; +import { SubmitButton } from "~/components/SubmitButton"; +import { + SENDOUQ_PREPARING_PAGE, + SENDOU_INK_BASE_URL, + sendouQInviteLink, +} from "~/utils/urls"; +import * as React from "react"; + +export function MemberAdder({ + inviteCode, + trustedPlayers, +}: { + inviteCode: string; + trustedPlayers: Array<{ + id: number; + discordName: string; + }>; +}) { + const [trustedUser, setTrustedUser] = React.useState(); + const fetcher = useFetcher(); + const inviteLink = `${SENDOU_INK_BASE_URL}${sendouQInviteLink(inviteCode)}`; + const [, copyToClipboard] = useCopyToClipboard(); + + React.useEffect(() => { + setTrustedUser(undefined); + }, [trustedPlayers]); + + return ( +
+ {trustedPlayers.length > 0 ? ( + + +
+ + + Add + +
+
+ ) : null} +
+ +
+ + +
+
+
+ ); +} diff --git a/app/features/sendouq/components/ModePrefenceIcons.tsx b/app/features/sendouq/components/ModePrefenceIcons.tsx new file mode 100644 index 000000000..ea2b6b6ea --- /dev/null +++ b/app/features/sendouq/components/ModePrefenceIcons.tsx @@ -0,0 +1,44 @@ +import { ModeImage } from "~/components/Image"; +import type { Group } from "~/db/types"; +import { assertUnreachable } from "~/utils/types"; + +export function ModePreferenceIcons({ + preference, +}: { + preference: Group["mapListPreference"]; +}) { + const comparisonSign = (() => { + switch (preference) { + case "SZ_ONLY": + case "ALL_MODES_ONLY": + return null; + case "NO_PREFERENCE": + return "="; + case "PREFER_ALL_MODES": + return "<"; + case "PREFER_SZ": + return ">"; + default: + assertUnreachable(preference); + } + })(); + + return ( + <> + {preference !== "ALL_MODES_ONLY" ? ( + + ) : null} + {comparisonSign ? ( + {comparisonSign} + ) : null} + {preference !== "SZ_ONLY" ? ( + <> + + + + + + ) : null} + + ); +} diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts new file mode 100644 index 000000000..97047abb2 --- /dev/null +++ b/app/features/sendouq/core/groups.server.ts @@ -0,0 +1,202 @@ +import invariant from "tiny-invariant"; +import type { Group, GroupLike } from "~/db/types"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import type { + DividedGroups, + DividedGroupsUncensored, + LookingGroup, + LookingGroupWithInviteCode, +} from "../q-types"; +import type { + SkillTierInterval, + TieredSkill, +} from "~/features/mmr/tiered.server"; + +export function divideGroups({ + groups, + ownGroupId, + likes, +}: { + groups: LookingGroupWithInviteCode[]; + ownGroupId: number; + likes: Pick[]; +}): DividedGroupsUncensored { + let own: LookingGroupWithInviteCode | null = null; + let neutral: LookingGroupWithInviteCode[] = []; + const likesReceived: LookingGroupWithInviteCode[] = []; + const likesGiven: LookingGroupWithInviteCode[] = []; + + const unneutralGroupIds = new Set(); + for (const like of likes) { + for (const group of groups) { + if (group.id === ownGroupId) continue; + + // handles edge case where they liked each other + // right after each other so the group didn't morph + // so instead it will look so that the group liked us + // and there is the option to morph + if (unneutralGroupIds.has(group.id)) continue; + + if (like.likerGroupId === group.id) { + likesReceived.push(group); + unneutralGroupIds.add(group.id); + break; + } + if (like.targetGroupId === group.id) { + likesGiven.push(group); + unneutralGroupIds.add(group.id); + break; + } + } + } + + for (const group of groups) { + if (group.id === ownGroupId) { + own = group; + continue; + } + + if (unneutralGroupIds.has(group.id)) continue; + + neutral.push(group); + } + + invariant(own && own.members, "own group not found"); + + return { + own, + neutral, + likesGiven, + likesReceived, + }; +} + +export function filterOutGroupsWithIncompatibleMapListPreference( + groups: DividedGroupsUncensored +): DividedGroupsUncensored { + if ( + groups.own.mapListPreference !== "SZ_ONLY" && + groups.own.mapListPreference !== "ALL_MODES_ONLY" + ) { + return groups; + } + + return { + ...groups, + neutral: groups.neutral.filter((group) => { + if ( + group.mapListPreference !== "SZ_ONLY" && + group.mapListPreference !== "ALL_MODES_ONLY" + ) { + return true; + } + + return group.mapListPreference === groups.own.mapListPreference; + }), + }; +} + +const censorGroupFully = ({ + inviteCode: _inviteCode, + ...group +}: LookingGroupWithInviteCode): LookingGroup => ({ + ...group, + members: undefined, + mapListPreference: undefined, +}); +const censorGroupPartly = ({ + inviteCode: _inviteCode, + ...group +}: LookingGroupWithInviteCode): LookingGroup => group; +export function censorGroups({ + groups, + showMembers, + showInviteCode, +}: { + groups: DividedGroupsUncensored; + showMembers: boolean; + showInviteCode: boolean; +}): DividedGroups { + return { + own: showInviteCode ? groups.own : censorGroupPartly(groups.own), + neutral: groups.neutral.map( + showMembers ? censorGroupPartly : censorGroupFully + ), + likesGiven: groups.likesGiven.map( + showMembers ? censorGroupPartly : censorGroupFully + ), + likesReceived: groups.likesReceived.map( + showMembers ? censorGroupPartly : censorGroupFully + ), + }; +} + +export function addSkillsToGroups({ + groups, + userSkills, + intervals, +}: { + groups: DividedGroupsUncensored; + userSkills: Record; + intervals: SkillTierInterval[]; +}): DividedGroupsUncensored { + const resolveGroupSkill = ( + group: LookingGroupWithInviteCode + ): TieredSkill["tier"] | undefined => { + if (group.members.length < FULL_GROUP_SIZE) return; + + const skills = group.members + .map((m) => userSkills[String(m.id)]) + .filter(Boolean); + const averageOrdinal = + skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length; + + return ( + intervals.find( + (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal + ) ?? { isPlus: false, name: "IRON" } + ); + }; + const addSkill = (group: LookingGroupWithInviteCode) => ({ + ...group, + members: group.members?.map((m) => ({ + ...m, + skill: userSkills[String(m.id)], + })), + tier: resolveGroupSkill(group), + }); + + return { + own: addSkill(groups.own), + neutral: groups.neutral.map(addSkill), + likesGiven: groups.likesGiven.map(addSkill), + likesReceived: groups.likesReceived.map(addSkill), + }; +} + +export function membersNeededForFull(currentSize: number) { + return FULL_GROUP_SIZE - currentSize; +} + +export function groupExpiryStatus( + group: Pick +): null | "EXPIRING_SOON" | "EXPIRED" { + // group expires in 30min without actions performed + const groupExpiresAt = + databaseTimestampToDate(group.latestActionAt).getTime() + 30 * 60 * 1000; + + const now = new Date().getTime(); + + if (now > groupExpiresAt) { + return "EXPIRED"; + } + + const tenMinutesFromNow = now + 10 * 60 * 1000; + + if (tenMinutesFromNow > groupExpiresAt) { + return "EXPIRING_SOON"; + } + + return null; +} diff --git a/app/features/sendouq/core/groups.ts b/app/features/sendouq/core/groups.ts new file mode 100644 index 000000000..43fe7bfe6 --- /dev/null +++ b/app/features/sendouq/core/groups.ts @@ -0,0 +1,39 @@ +import type { GroupMember } from "~/db/types"; +import type { LookingGroup } from "../q-types"; + +// logic is that team who is bigger decides the settings +// but if groups are the same size then the one who liked +// is basically consenting that other team's setting are used +export function groupAfterMorph({ + ourGroup, + theirGroup, + liker, +}: { + ourGroup: LookingGroup; + theirGroup: LookingGroup; + liker: "US" | "THEM"; +}) { + const ourMembers = ourGroup.members ?? []; + const theirMembers = theirGroup.members ?? []; + + // if one group is full no mapListPreference is returned and we are not gonna morph anything anymore + if (!theirGroup.mapListPreference) return theirGroup; + + if (ourMembers.length > theirMembers.length) { + return ourGroup; + } + + if (theirMembers.length > ourMembers.length) { + return theirGroup; + } + + if (liker === "US") { + return theirGroup; + } + + return ourGroup; +} + +export function hasGroupManagerPerms(role: GroupMember["role"]) { + return role === "OWNER" || role === "MANAGER"; +} diff --git a/app/features/sendouq/core/match.server.ts b/app/features/sendouq/core/match.server.ts new file mode 100644 index 000000000..72147dd2b --- /dev/null +++ b/app/features/sendouq/core/match.server.ts @@ -0,0 +1,104 @@ +import type { Group } from "~/db/types"; +import { MapPool } from "~/modules/map-pool-serializer"; +import { createTournamentMapList } from "~/modules/tournament-map-list-generator"; +import { SENDOUQ_BEST_OF } from "../q-constants"; +import type { LookingGroup } from "../q-types"; +import invariant from "tiny-invariant"; +import type { MatchById } from "../queries/findMatchById.server"; + +const filterMapPoolToSZ = (mapPool: MapPool) => + new MapPool(mapPool.stageModePairs.filter(({ mode }) => mode === "SZ")); +export function matchMapList({ + ourGroup, + theirGroup, + ourMapPool, + theirMapPool, +}: { + ourGroup: LookingGroup; + theirGroup: LookingGroup; + ourMapPool: MapPool; + theirMapPool: MapPool; +}) { + invariant(ourGroup.mapListPreference, "ourGroup.mapListPreference"); + invariant(theirGroup.mapListPreference, "theirGroup.mapListPreference"); + + const type = mapListType([ + ourGroup.mapListPreference, + theirGroup.mapListPreference, + ]); + + return createTournamentMapList({ + bestOf: SENDOUQ_BEST_OF, + seed: String(ourGroup.id), + modesIncluded: type === "SZ" ? ["SZ"] : ["SZ", "TC", "RM", "CB"], + tiebreakerMaps: new MapPool([]), + teams: [ + { + id: ourGroup.id, + maps: type === "SZ" ? filterMapPoolToSZ(ourMapPool) : ourMapPool, + }, + { + id: theirGroup.id, + maps: type === "SZ" ? filterMapPoolToSZ(theirMapPool) : theirMapPool, + }, + ], + }); +} + +// type score as const object +const typeScore = { + ALL_MODES_ONLY: -2, + PREFER_ALL_MODES: -1, + NO_PREFERENCE: 0, + PREFER_SZ: 1, + SZ_ONLY: 2, +} as const; +function mapListType( + preferences: [Group["mapListPreference"], Group["mapListPreference"]] +) { + const score = typeScore[preferences[0]] + typeScore[preferences[1]]; + + if (score < 0) return "ALL_MODES"; + if (score > 0) return "SZ"; + + return Math.random() < 0.5 ? "ALL_MODES" : "SZ"; +} + +export function compareMatchToReportedScores({ + match, + winners, + newReporterGroupId, + previousReporterGroupId, +}: { + match: MatchById; + winners: ("ALPHA" | "BRAVO")[]; + newReporterGroupId: number; + previousReporterGroupId?: number; +}) { + // match has not been reported before + if (!match.reportedByUserId) return "FIRST_REPORT"; + + const sameGroupReporting = newReporterGroupId === previousReporterGroupId; + const differentConstant = sameGroupReporting ? "FIX_PREVIOUS" : "DIFFERENT"; + for (const [ + i, + { winnerGroupId: previousWinnerGroupId }, + ] of match.mapList.entries()) { + const newWinner = winners[i] ?? null; + + if (!newWinner && !previousWinnerGroupId) continue; + + if (!newWinner && previousWinnerGroupId) return differentConstant; + if (newWinner && !previousWinnerGroupId) return differentConstant; + + const previousWinner = + previousWinnerGroupId === match.alphaGroupId ? "ALPHA" : "BRAVO"; + + if (previousWinner !== newWinner) return differentConstant; + } + + // same group reporting the same exact score + if (sameGroupReporting) return "DUPLICATE"; + + return "SAME"; +} diff --git a/app/features/sendouq/core/match.ts b/app/features/sendouq/core/match.ts new file mode 100644 index 000000000..b2cde8096 --- /dev/null +++ b/app/features/sendouq/core/match.ts @@ -0,0 +1,22 @@ +import { SENDOUQ_BEST_OF } from "../q-constants"; + +export function matchEndedAtIndex(scores: ("ALPHA" | "BRAVO")[]) { + let alphaCount = 0; + let bravoCount = 0; + let matchEndedAt = -1; + + const mapsToWin = Math.ceil(SENDOUQ_BEST_OF / 2); + for (const [i, winner] of scores.entries()) { + if (winner === "ALPHA") alphaCount++; + if (winner === "BRAVO") bravoCount++; + + if (alphaCount === mapsToWin || bravoCount === mapsToWin) { + matchEndedAt = i; + break; + } + } + + if (matchEndedAt === -1) return null; + + return matchEndedAt; +} diff --git a/app/features/sendouq/core/skills.server.ts b/app/features/sendouq/core/skills.server.ts new file mode 100644 index 000000000..64a1d02e5 --- /dev/null +++ b/app/features/sendouq/core/skills.server.ts @@ -0,0 +1,105 @@ +import invariant from "tiny-invariant"; +import type { GroupMatch, Skill, User } from "~/db/types"; +import { + queryCurrentTeamRating, + queryCurrentUserRating, + rate, + userIdsToIdentifier, +} from "~/features/mmr"; +import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server"; +import { previousOrCurrentSeason } from "~/features/mmr/season"; + +export function calculateMatchSkills({ + groupMatchId, + winner, + loser, +}: { + groupMatchId: GroupMatch["id"]; + winner: User["id"][]; + loser: User["id"][]; +}) { + const result: Array< + Pick< + Skill, + "groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId" + > + > = []; + + const season = previousOrCurrentSeason(new Date())?.nth; + invariant(typeof season === "number", "No ranked season for skills"); + + { + // individual skills + const [winnerTeamNew, loserTeamNew] = rate([ + winner.map((userId) => queryCurrentUserRating({ userId, season })), + loser.map((userId) => queryCurrentUserRating({ userId, season })), + ]); + + for (const [index, userId] of winner.entries()) { + result.push({ + groupMatchId: groupMatchId, + identifier: null, + mu: winnerTeamNew[index].mu, + season, + sigma: winnerTeamNew[index].sigma, + userId, + }); + } + + for (const [index, userId] of loser.entries()) { + result.push({ + groupMatchId: groupMatchId, + identifier: null, + mu: loserTeamNew[index].mu, + season, + sigma: loserTeamNew[index].sigma, + userId, + }); + } + } + + { + // team skills + const winnerTeamIdentifier = userIdsToIdentifier(winner); + const loserTeamIdentifier = userIdsToIdentifier(loser); + const [[winnerTeamNew], [loserTeamNew]] = rate( + [ + [queryCurrentTeamRating({ identifier: winnerTeamIdentifier, season })], + [queryCurrentTeamRating({ identifier: loserTeamIdentifier, season })], + ], + [ + [ + queryTeamPlayerRatingAverage({ + identifier: winnerTeamIdentifier, + season, + }), + ], + [ + queryTeamPlayerRatingAverage({ + identifier: loserTeamIdentifier, + season, + }), + ], + ] + ); + + result.push({ + groupMatchId: groupMatchId, + identifier: winnerTeamIdentifier, + mu: winnerTeamNew.mu, + season, + sigma: winnerTeamNew.sigma, + userId: null, + }); + result.push({ + groupMatchId: groupMatchId, + identifier: loserTeamIdentifier, + mu: loserTeamNew.mu, + season, + sigma: loserTeamNew.sigma, + userId: null, + }); + } + + return result; +} diff --git a/app/features/sendouq/core/summarizer.server.ts b/app/features/sendouq/core/summarizer.server.ts new file mode 100644 index 000000000..d91f4866e --- /dev/null +++ b/app/features/sendouq/core/summarizer.server.ts @@ -0,0 +1,150 @@ +import type { MapResult, PlayerResult } from "~/db/types"; +import type { MatchById } from "../queries/findMatchById.server"; +import { previousOrCurrentSeason } from "~/features/mmr/season"; +import invariant from "tiny-invariant"; +import { winnersArrayToWinner } from "../q-utils"; + +export function summarizeMaps({ + match, + winners, + members, +}: { + match: MatchById; + winners: ("ALPHA" | "BRAVO")[]; + members: { id: number; groupId: number }[]; +}) { + const season = previousOrCurrentSeason(new Date())?.nth; + invariant(typeof season === "number", "No ranked season for skills"); + + const result: Array = []; + + const playedMaps = match.mapList.slice(0, winners.length); + + for (const [i, map] of playedMaps.entries()) { + const winnerSide = winners[i]; + const winnerGroupId = + winnerSide === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; + + const winnerPlayers = members.filter((p) => p.groupId === winnerGroupId); + const loserPlayers = members.filter((p) => p.groupId !== winnerGroupId); + + for (const winner of winnerPlayers) { + result.push({ + userId: winner.id, + wins: 1, + losses: 0, + mode: map.mode, + stageId: map.stageId, + season, + }); + } + + for (const loser of loserPlayers) { + result.push({ + userId: loser.id, + wins: 0, + losses: 1, + mode: map.mode, + stageId: map.stageId, + season, + }); + } + } + + return result; +} + +export function summarizePlayerResults({ + match, + winners, + members, +}: { + match: MatchById; + winners: ("ALPHA" | "BRAVO")[]; + members: { id: number; groupId: number }[]; +}) { + const season = previousOrCurrentSeason(new Date())?.nth; + invariant(typeof season === "number", "No ranked season for skills"); + + const result: Array = []; + + const addMapResult = ({ + outcome, + type, + ownerUserId, + otherUserId, + }: { + outcome: "win" | "loss"; + type: "MATE" | "ENEMY"; + ownerUserId: number; + otherUserId: number; + }) => { + const existing = result.find( + (r) => r.ownerUserId === ownerUserId && r.otherUserId === otherUserId + ); + if (existing) { + if (outcome === "win") { + existing.mapWins++; + } else existing.mapLosses++; + } else { + result.push({ + ownerUserId, + otherUserId, + type, + mapWins: outcome === "win" ? 1 : 0, + mapLosses: outcome === "win" ? 0 : 1, + season, + setLosses: 0, + setWins: 0, + }); + } + }; + + for (const winner of winners) { + for (const member of members) { + for (const member2 of members) { + if (member.id === member2.id) continue; + + const type = member.groupId === member2.groupId ? "MATE" : "ENEMY"; + const won = + winner === "ALPHA" + ? member.groupId === match.alphaGroupId + : member.groupId === match.bravoGroupId; + + addMapResult({ + ownerUserId: member.id, + otherUserId: member2.id, + type, + outcome: won ? "win" : "loss", + }); + } + } + } + + const winner = winnersArrayToWinner(winners); + + for (const member of members) { + for (const member2 of members) { + if (member.id === member2.id) continue; + + const type = member.groupId === member2.groupId ? "MATE" : "ENEMY"; + const won = + winner === "ALPHA" + ? member.groupId === match.alphaGroupId + : member.groupId === match.bravoGroupId; + + result.push({ + ownerUserId: member.id, + otherUserId: member2.id, + type, + mapWins: 0, + mapLosses: 0, + season, + setWins: won ? 1 : 0, + setLosses: won ? 0 : 1, + }); + } + } + + return result; +} diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts new file mode 100644 index 000000000..79eb81b13 --- /dev/null +++ b/app/features/sendouq/q-constants.ts @@ -0,0 +1,32 @@ +import type { Group } from "~/db/types"; +import { assertType } from "~/utils/types"; + +export const MAP_LIST_PREFERENCE_OPTIONS = [ + "NO_PREFERENCE", + "PREFER_ALL_MODES", + "PREFER_SZ", + "ALL_MODES_ONLY", + "SZ_ONLY", +] as const; +assertType< + Group["mapListPreference"], + (typeof MAP_LIST_PREFERENCE_OPTIONS)[number] +>(); +assertType< + (typeof MAP_LIST_PREFERENCE_OPTIONS)[number], + Group["mapListPreference"] +>(); + +export const SENDOUQ = { + SZ_MAP_COUNT: 6, + OTHER_MODE_MAP_COUNT: 2, + MAX_STAGE_REPEAT_COUNT: 2, +} as const; + +export const FULL_GROUP_SIZE = 4; + +export const SENDOUQ_BEST_OF = 7; + +export const JOIN_CODE_SEARCH_PARAM_KEY = "join"; + +export const USER_SKILLS_CACHE_KEY = "user-skills"; diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts new file mode 100644 index 000000000..2a76ad917 --- /dev/null +++ b/app/features/sendouq/q-schemas.server.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; +import { + FULL_GROUP_SIZE, + MAP_LIST_PREFERENCE_OPTIONS, + SENDOUQ_BEST_OF, +} from "./q-constants"; +import { + checkboxValueToBoolean, + id, + safeJSONParse, + weaponSplId, +} from "~/utils/zod"; +import { matchEndedAtIndex } from "./core/match"; + +export const frontPageSchema = z.union([ + z.object({ + _action: z.literal("JOIN_QUEUE"), + mapListPreference: z.enum(MAP_LIST_PREFERENCE_OPTIONS), + mapPool: z.string(), + direct: z.literal("true").nullish(), + }), + z.object({ + _action: z.literal("JOIN_TEAM"), + }), + z.object({ + _action: z.literal("SET_INITIAL_SP"), + tier: z.enum(["higher", "default", "lower"]), + }), +]); + +export const preparingSchema = z.union([ + z.object({ + _action: z.literal("JOIN_QUEUE"), + }), + z.object({ + _action: z.literal("ADD_TRUSTED"), + id, + }), +]); + +export const lookingSchema = z.union([ + z.object({ + _action: z.literal("LIKE"), + targetGroupId: id, + }), + z.object({ + _action: z.literal("UNLIKE"), + targetGroupId: id, + }), + z.object({ + _action: z.literal("GROUP_UP"), + targetGroupId: id, + }), + z.object({ + _action: z.literal("MATCH_UP"), + targetGroupId: id, + }), + z.object({ + _action: z.literal("GIVE_MANAGER"), + userId: id, + }), + z.object({ + _action: z.literal("REMOVE_MANAGER"), + userId: id, + }), + z.object({ + _action: z.literal("LEAVE_GROUP"), + }), + z.object({ + _action: z.literal("REFRESH_GROUP"), + }), +]); + +const winners = z.preprocess( + safeJSONParse, + z + .array(z.enum(["ALPHA", "BRAVO"])) + .min(Math.ceil(SENDOUQ_BEST_OF / 2)) + .max(SENDOUQ_BEST_OF) + .refine((val) => { + const matchEndedAt = matchEndedAtIndex(val); + + // match did end + if (matchEndedAt === null) return true; + + // no extra scores after match ended + return val.length === matchEndedAt + 1; + }) +); +export const matchSchema = z.union([ + z.object({ + _action: z.literal("REPORT_SCORE"), + winners, + adminReport: z.preprocess( + checkboxValueToBoolean, + z.boolean().nullish().default(false) + ), + }), + z.object({ + _action: z.literal("LOOK_AGAIN"), + previousGroupId: id, + }), + z.object({ + _action: z.literal("REPORT_WEAPONS"), + weapons: z.preprocess( + safeJSONParse, + z.array(z.array(weaponSplId).length(FULL_GROUP_SIZE * 2)) + ), + }), +]); diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts new file mode 100644 index 000000000..e0e3a0e9f --- /dev/null +++ b/app/features/sendouq/q-types.ts @@ -0,0 +1,38 @@ +import type { Group, GroupMember, PlusTier } from "~/db/types"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import type { TieredSkill } from "../mmr/tiered.server"; + +export type LookingGroup = { + id: number; + mapListPreference?: Group["mapListPreference"]; + tier?: TieredSkill["tier"]; + members?: { + id: number; + discordId: string; + discordName: string; + discordAvatar: string; + plusTier?: PlusTier["tier"]; + role: GroupMember["role"]; + weapons?: MainWeaponId[]; + skill?: TieredSkill; + }[]; +}; + +export type LookingGroupWithInviteCode = LookingGroup & { + inviteCode: Group["inviteCode"]; + members: NonNullable; +}; + +export interface DividedGroups { + own: LookingGroup | LookingGroupWithInviteCode; + neutral: LookingGroup[]; + likesReceived: LookingGroup[]; + likesGiven: LookingGroup[]; +} + +export interface DividedGroupsUncensored { + own: LookingGroupWithInviteCode; + neutral: LookingGroupWithInviteCode[]; + likesReceived: LookingGroupWithInviteCode[]; + likesGiven: LookingGroupWithInviteCode[]; +} diff --git a/app/features/sendouq/q-utils.ts b/app/features/sendouq/q-utils.ts new file mode 100644 index 000000000..3ce31dbd0 --- /dev/null +++ b/app/features/sendouq/q-utils.ts @@ -0,0 +1,89 @@ +import type { Group } from "~/db/types"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { MapPool } from "~/modules/map-pool-serializer"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + SENDOUQ_PREPARING_PAGE, + sendouQMatchPage, +} from "~/utils/urls"; +import { SENDOUQ } from "./q-constants"; +import { stageIds } from "~/modules/in-game-lists"; +import type { Params } from "@remix-run/react"; +import invariant from "tiny-invariant"; + +function groupRedirectLocation( + group?: Pick & { matchId?: number } +) { + if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE; + if (group?.matchId) return sendouQMatchPage(group.matchId); + if (group) return SENDOUQ_LOOKING_PAGE; + + return SENDOUQ_PAGE; +} + +export function groupRedirectLocationByCurrentLocation({ + group, + currentLocation, +}: { + group?: Pick & { matchId?: number }; + currentLocation: "default" | "preparing" | "looking" | "match"; +}) { + const newLocation = groupRedirectLocation(group); + + // we are already in the correct location, don't redirect + if (currentLocation === "default" && newLocation === SENDOUQ_PAGE) return; + if (currentLocation === "preparing" && newLocation === SENDOUQ_PREPARING_PAGE) + return; + if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE) + return; + if (currentLocation === "match" && newLocation.includes("match")) return; + + return newLocation; +} + +export function mapPoolOk(mapPool: MapPool) { + for (const modeShort of rankedModesShort) { + if ( + modeShort === "SZ" && + mapPool.countMapsByMode(modeShort) !== SENDOUQ.SZ_MAP_COUNT + ) { + return false; + } + + if ( + modeShort !== "SZ" && + mapPool.countMapsByMode(modeShort) !== SENDOUQ.OTHER_MODE_MAP_COUNT + ) { + return false; + } + } + + for (const stageId of stageIds) { + if ( + mapPool.stageModePairs.filter((pair) => pair.stageId === stageId).length > + SENDOUQ.MAX_STAGE_REPEAT_COUNT + ) { + return false; + } + } + + return true; +} + +export function matchIdFromParams(params: Params) { + const result = Number(params["id"]); + invariant(!Number.isNaN(result), "match id is not a number"); + + return result; +} + +export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) { + const alphaCount = winners.filter((winner) => winner === "ALPHA").length; + const bravoCount = winners.filter((winner) => winner === "BRAVO").length; + + if (alphaCount > bravoCount) return "ALPHA"; + if (bravoCount > alphaCount) return "BRAVO"; + + throw new Error("no winner"); +} diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css new file mode 100644 index 000000000..7de7b95db --- /dev/null +++ b/app/features/sendouq/q.css @@ -0,0 +1,154 @@ +.twf { + width: 1.5rem; + height: 1.5rem; +} + +.q__clocks-container { + display: flex; + gap: var(--s-2); +} + +.q__clock { + font-size: var(--fonts-sm); + font-weight: var(--bold); + color: var(--text-lighter); + align-items: center; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + flex: 1 1 0; + line-height: 1.3; +} + +.q__clock-country { + color: var(--text) !important; + white-space: nowrap; + font-size: var(--fonts-xs); +} + +.q__header { + font-size: var(--fonts-lg); +} + +.q__map-preference-label { + margin-block-end: 0; + font-weight: var(--semi-bold); + display: flex; + align-items: center; + gap: var(--s-1); + color: var(--text-lighter); +} + +.q__map-pool-grid { + display: grid; + grid-template-columns: repeat(7, max-content); + gap: var(--s-2-5); + font-size: var(--fonts-xs); + align-items: center; +} + +.q__map-pool-grid__stage-image { + border-radius: 7px; +} + +.q__own-group-container { + margin: 0 auto; + min-width: 300px; +} + +.q__groups-container { + display: grid; + justify-content: center; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--s-6); +} + +.q__mobile-groups-container { + max-width: 300px; + margin: 0 auto; +} + +.q__group { + background-color: var(--bg-lighter-solid); + width: 100%; + border-radius: var(--rounded); + padding: var(--s-2-5); + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.q__group-member { + display: flex; + gap: var(--s-2); + align-items: center; + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + color: var(--text); +} + +.q__member-placeholder { + border-radius: 100%; + background-color: var(--bg-lightest); + height: 24px; + width: 24px; + display: grid; + place-items: center; + font-weight: var(--semi-bold); +} + +.q__group-member-weapons { + display: flex; + gap: var(--s-1); + margin-block-start: -2px; +} + +.q__group-member-weapon { + background-color: var(--bg); + border-radius: 100%; + padding: var(--s-1); + overflow: visible; +} + +.q-preparing__card-container { + min-width: 250px; + margin: 0 auto; +} + +.q-match__join-discord-section { + border-left: 4px solid var(--theme); + padding-inline-start: var(--s-4); + font-size: var(--fonts-sm); + color: var(--text-lighter); +} + +.q-match__members-container { + min-width: 180px; +} + +.q-match__container { + /** Push footer down to avoid it "flashing" when the score reporter animates */ + padding-bottom: 14rem; +} + +.q-match__header { + line-height: 1.2; +} + +.q-match__teams-container { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-4); +} + +.q-match__weapon-grid-item { + width: 32px; + height: 32px; +} + +@media screen and (min-width: 480px) { + .q-match__teams-container { + grid-template-columns: 1fr 1fr; + } +} diff --git a/app/features/sendouq/queries/addInitialSkill.server.ts b/app/features/sendouq/queries/addInitialSkill.server.ts new file mode 100644 index 000000000..d42dd2ee7 --- /dev/null +++ b/app/features/sendouq/queries/addInitialSkill.server.ts @@ -0,0 +1,34 @@ +import { ordinal } from "openskill"; +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + insert into "Skill" ("mu", "season", "sigma", "ordinal", "userId", "matchesCount") + values ( + @mu, + @season, + @sigma, + @ordinal, + @userId, + 0 + ) +`); + +export function addInitialSkill({ + mu, + sigma, + season, + userId, +}: { + mu: number; + sigma: number; + season: number; + userId: number; +}) { + stm.run({ + mu, + sigma, + season, + ordinal: ordinal({ mu, sigma }), + userId, + }); +} diff --git a/app/features/sendouq/queries/addLike.server.ts b/app/features/sendouq/queries/addLike.server.ts new file mode 100644 index 000000000..fcecf2147 --- /dev/null +++ b/app/features/sendouq/queries/addLike.server.ts @@ -0,0 +1,17 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + insert into "GroupLike" ("likerGroupId", "targetGroupId") + values (@likerGroupId, @targetGroupId) + on conflict ("likerGroupId", "targetGroupId") do nothing +`); + +export function addLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + stm.run({ likerGroupId, targetGroupId }); +} diff --git a/app/features/sendouq/queries/addManagerRole.server.ts b/app/features/sendouq/queries/addManagerRole.server.ts new file mode 100644 index 000000000..26a850ce1 --- /dev/null +++ b/app/features/sendouq/queries/addManagerRole.server.ts @@ -0,0 +1,18 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + update "GroupMember" + set "role" = 'MANAGER' + where "userId" = @userId + and "groupId" = @groupId +`); + +export function addManagerRole({ + userId, + groupId, +}: { + userId: number; + groupId: number; +}) { + stm.run({ userId, groupId }); +} diff --git a/app/features/sendouq/queries/addMapResults.server.ts b/app/features/sendouq/queries/addMapResults.server.ts new file mode 100644 index 000000000..46d34abd1 --- /dev/null +++ b/app/features/sendouq/queries/addMapResults.server.ts @@ -0,0 +1,34 @@ +import { sql } from "~/db/sql"; +import type { MapResult } from "~/db/types"; + +const addMapResultDeltaStm = sql.prepare(/* sql */ ` + insert into "MapResult" ( + "mode", + "stageId", + "userId", + "wins", + "losses", + "season" + ) values ( + @mode, + @stageId, + @userId, + @wins, + @losses, + @season + ) on conflict ("userId", "stageId", "mode", "season") do + update + set + "wins" = "wins" + @wins, + "losses" = "losses" + @losses +`); + +export function addMapResults( + results: Array< + Pick + > +) { + for (const result of results) { + addMapResultDeltaStm.run(result); + } +} diff --git a/app/features/sendouq/queries/addMember.server.ts b/app/features/sendouq/queries/addMember.server.ts new file mode 100644 index 000000000..2d6b554ce --- /dev/null +++ b/app/features/sendouq/queries/addMember.server.ts @@ -0,0 +1,23 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + insert into "GroupMember" ( + "groupId", + "userId", + "role" + ) values ( + @groupId, + @userId, + 'REGULAR' + ) +`); + +export function addMember({ + groupId, + userId, +}: { + groupId: number; + userId: number; +}) { + stm.run({ groupId, userId }); +} diff --git a/app/features/sendouq/queries/addPlayerResults.server.ts b/app/features/sendouq/queries/addPlayerResults.server.ts new file mode 100644 index 000000000..1265e2935 --- /dev/null +++ b/app/features/sendouq/queries/addPlayerResults.server.ts @@ -0,0 +1,36 @@ +import { sql } from "~/db/sql"; +import type { PlayerResult } from "~/db/types"; + +const addPlayerResultDeltaStm = sql.prepare(/* sql */ ` + insert into "PlayerResult" ( + "ownerUserId", + "otherUserId", + "mapWins", + "mapLosses", + "setWins", + "setLosses", + "type", + "season" + ) values ( + @ownerUserId, + @otherUserId, + @mapWins, + @mapLosses, + @setWins, + @setLosses, + @type, + @season + ) on conflict ("ownerUserId", "otherUserId", "type", "season") do + update + set + "mapWins" = "mapWins" + @mapWins, + "mapLosses" = "mapLosses" + @mapLosses, + "setWins" = "setWins" + @setWins, + "setLosses" = "setLosses" + @setLosses +`); + +export function addPlayerResults(results: Array) { + for (const result of results) { + addPlayerResultDeltaStm.run(result); + } +} diff --git a/app/features/sendouq/queries/addReportedWeapons.server.ts b/app/features/sendouq/queries/addReportedWeapons.server.ts new file mode 100644 index 000000000..2921e54cf --- /dev/null +++ b/app/features/sendouq/queries/addReportedWeapons.server.ts @@ -0,0 +1,20 @@ +import { sql } from "~/db/sql"; +import type { MainWeaponId } from "~/modules/in-game-lists"; + +const insertStm = sql.prepare(/* sql */ ` + insert into "ReportedWeapon" + ("groupMatchMapId", "weaponSplId", "userId") + values (@groupMatchMapId, @weaponSplId, @userId) +`); + +export const addReportedWeapons = ( + args: { + groupMatchMapId: number; + weaponSplId: MainWeaponId; + userId: number; + }[] +) => { + for (const { groupMatchMapId, userId, weaponSplId } of args) { + insertStm.run({ groupMatchMapId, userId, weaponSplId }); + } +}; diff --git a/app/features/sendouq/queries/addSkills.server.ts b/app/features/sendouq/queries/addSkills.server.ts new file mode 100644 index 000000000..4cafa8e0d --- /dev/null +++ b/app/features/sendouq/queries/addSkills.server.ts @@ -0,0 +1,65 @@ +import { ordinal } from "openskill"; +import { sql } from "~/db/sql"; +import type { Skill } from "~/db/types"; +import { identifierToUserIds } from "~/features/mmr/mmr-utils.server"; + +const getStm = (type: "user" | "team") => + sql.prepare(/* sql */ ` + insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "matchesCount") + values ( + @groupMatchId, + @identifier, + @mu, + @season, + @sigma, + @ordinal, + @userId, + 1 + coalesce(( + select max("matchesCount") from "Skill" + where + ${type === "user" ? /* sql */ `"userId" = @userId` : ""} + ${type === "team" ? /* sql */ `"identifier" = @identifier` : ""} + and "season" = @season + group by ${ + type === "user" ? /* sql */ `"userId"` : /* sql */ `"identifier"` + } + ), 0) + ) returning * +`); + +const addSkillTeamUserStm = sql.prepare(/* sql */ ` + insert into "SkillTeamUser" ( + "skillId", + "userId" + ) values ( + @skillId, + @userId + ) on conflict("skillId", "userId") do nothing +`); + +const userStm = getStm("user"); +const teamStm = getStm("team"); + +export function addSkills( + skills: Pick< + Skill, + "groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId" + >[] +) { + for (const skill of skills) { + const stm = skill.userId ? userStm : teamStm; + const insertedSkill = stm.get({ + ...skill, + ordinal: ordinal(skill), + }) as Skill; + + if (insertedSkill.identifier) { + for (const userId of identifierToUserIds(insertedSkill.identifier)) { + addSkillTeamUserStm.run({ + skillId: insertedSkill.id, + userId, + }); + } + } + } +} diff --git a/app/features/sendouq/queries/createGroup.server.ts b/app/features/sendouq/queries/createGroup.server.ts new file mode 100644 index 000000000..ad4efd0d6 --- /dev/null +++ b/app/features/sendouq/queries/createGroup.server.ts @@ -0,0 +1,111 @@ +import { nanoid } from "nanoid"; +import { INVITE_CODE_LENGTH } from "~/constants"; +import { sql } from "~/db/sql"; +import type { Group, GroupMember } from "~/db/types"; +import type { MapPool } from "~/modules/map-pool-serializer"; + +const createGroupStm = sql.prepare(/* sql */ ` + insert into "Group" + ("mapListPreference", "inviteCode", "status") + values + (@mapListPreference, @inviteCode, @status) + returning * +`); + +const createGroupMemberStm = sql.prepare(/* sql */ ` + insert into "GroupMember" + ("groupId", "userId", "role") + values + (@groupId, @userId, @role) +`); + +const createMapPoolMapStm = sql.prepare(/* sql */ ` + insert into "MapPoolMap" + ("stageId", "mode", "groupId") + values + (@stageId, @mode, @groupId) +`); + +type CreateGroupArgs = Pick & { + status: Exclude; + userId: number; + mapPool: MapPool; +}; + +const DEFAULT_ROLE: GroupMember["role"] = "OWNER"; + +export const createGroup = sql.transaction((args: CreateGroupArgs) => { + const group = createGroupStm.get({ + mapListPreference: args.mapListPreference, + inviteCode: nanoid(INVITE_CODE_LENGTH), + status: args.status, + }) as Group; + + createGroupMemberStm.run({ + groupId: group.id, + userId: args.userId, + role: DEFAULT_ROLE, + }); + + for (const { stageId, mode } of args.mapPool.stageModePairs) { + createMapPoolMapStm.run({ + stageId, + mode, + groupId: group.id, + }); + } + + return group; +}); + +type CreateGroupFromPreviousGroupArgs = { + previousGroupId: number; + members: { + id: number; + role: GroupMember["role"]; + }[]; +}; + +const createGroupFromPreviousGroupStm = sql.prepare(/* sql */ ` + insert into "Group" + ("mapListPreference", "teamId", "inviteCode", "status") + values + ( + (select "mapListPreference" from "Group" where "id" = @previousGroupId), + (select "teamId" from "Group" where "id" = @previousGroupId), + @inviteCode, + @status + ) + returning * +`); + +const stealMapPoolStm = sql.prepare(/* sql */ ` + update "MapPoolMap" + set "groupId" = @groupId + where "groupId" = @previousGroupId +`); + +export const createGroupFromPreviousGroup = sql.transaction( + (args: CreateGroupFromPreviousGroupArgs) => { + const group = createGroupFromPreviousGroupStm.get({ + previousGroupId: args.previousGroupId, + inviteCode: nanoid(INVITE_CODE_LENGTH), + status: "PREPARING", + }) as Group; + + for (const member of args.members) { + createGroupMemberStm.run({ + groupId: group.id, + userId: member.id, + role: member.role, + }); + } + + stealMapPoolStm.run({ + previousGroupId: args.previousGroupId, + groupId: group.id, + }); + + return group; + } +); diff --git a/app/features/sendouq/queries/createMatch.server.ts b/app/features/sendouq/queries/createMatch.server.ts new file mode 100644 index 000000000..2b8e57e84 --- /dev/null +++ b/app/features/sendouq/queries/createMatch.server.ts @@ -0,0 +1,59 @@ +import { sql } from "~/db/sql"; +import type { GroupMatch } from "~/db/types"; +import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; + +const createMatchStm = sql.prepare(/* sql */ ` + insert into "GroupMatch" ( + "alphaGroupId", + "bravoGroupId" + ) values ( + @alphaGroupId, + @bravoGroupId + ) + returning * +`); + +const createMatchMapStm = sql.prepare(/* sql */ ` + insert into "GroupMatchMap" ( + "matchId", + "index", + "mode", + "stageId", + "source" + ) values ( + @matchId, + @index, + @mode, + @stageId, + @source + ) +`); + +export const createMatch = sql.transaction( + ({ + alphaGroupId, + bravoGroupId, + mapList, + }: { + alphaGroupId: number; + bravoGroupId: number; + mapList: TournamentMapListMap[]; + }) => { + const match = createMatchStm.get({ + alphaGroupId, + bravoGroupId, + }) as GroupMatch; + + for (const [i, { mode, source, stageId }] of mapList.entries()) { + createMatchMapStm.run({ + matchId: match.id, + index: i, + mode, + stageId, + source: String(source), + }); + } + + return match; + } +); diff --git a/app/features/sendouq/queries/deleteLike.server.ts b/app/features/sendouq/queries/deleteLike.server.ts new file mode 100644 index 000000000..e3ff754b7 --- /dev/null +++ b/app/features/sendouq/queries/deleteLike.server.ts @@ -0,0 +1,17 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + delete from "GroupLike" + where "likerGroupId" = @likerGroupId + and "targetGroupId" = @targetGroupId +`); + +export function deleteLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + stm.run({ likerGroupId, targetGroupId }); +} diff --git a/app/features/sendouq/queries/deleteReportedWeaponsByMatchId.server.ts b/app/features/sendouq/queries/deleteReportedWeaponsByMatchId.server.ts new file mode 100644 index 000000000..0f5cd7304 --- /dev/null +++ b/app/features/sendouq/queries/deleteReportedWeaponsByMatchId.server.ts @@ -0,0 +1,21 @@ +import { sql } from "~/db/sql"; + +const deleteStm = sql.prepare(/* sql */ ` + delete from "ReportedWeapon" + where "groupMatchMapId" = @groupMatchMapId +`); + +const getGroupMatchMapsStm = sql.prepare(/* sql */ ` + select "id" from "GroupMatchMap" + where "matchId" = @matchId +`); + +export const deleteReporterWeaponsByMatchId = (matchId: number) => { + const groupMatchMaps = getGroupMatchMapsStm.all({ matchId }) as Array<{ + id: number; + }>; + + for (const { id } of groupMatchMaps) { + deleteStm.run({ groupMatchMapId: id }); + } +}; diff --git a/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts b/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts new file mode 100644 index 000000000..ec1ae32b5 --- /dev/null +++ b/app/features/sendouq/queries/findCurrentGroupByUserId.server.ts @@ -0,0 +1,35 @@ +import invariant from "tiny-invariant"; +import { sql } from "~/db/sql"; +import type { Group, GroupMember } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "Group"."id", + "Group"."status", + "Group"."latestActionAt", + "GroupMatch"."id" as "matchId", + "GroupMember"."role" + from + "Group" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" + or "GroupMatch"."bravoGroupId" = "Group"."id" + where + "Group"."status" != 'INACTIVE' + and "GroupMember"."userId" = @userId +`); + +type ActiveGroup = Pick & { + matchId?: number; + role: GroupMember["role"]; +}; + +export function findCurrentGroupByUserId( + userId: number +): ActiveGroup | undefined { + const groups = stm.all({ userId }) as any; + + invariant(groups.length <= 1, "User can't be in more than one group"); + + return groups[0]; +} diff --git a/app/features/sendouq/queries/findLikes.ts b/app/features/sendouq/queries/findLikes.ts new file mode 100644 index 000000000..f7a1229c7 --- /dev/null +++ b/app/features/sendouq/queries/findLikes.ts @@ -0,0 +1,21 @@ +import { sql } from "~/db/sql"; +import type { GroupLike } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "GroupLike"."likerGroupId", + "GroupLike"."targetGroupId" + from + "GroupLike" + where + "GroupLike"."likerGroupId" = @groupId + or "GroupLike"."targetGroupId" = @groupId + order by + "GroupLike"."createdAt" desc +`); + +export function findLikes( + groupId: number +): Pick[] { + return stm.all({ groupId }) as any; +} diff --git a/app/features/sendouq/queries/findMatchById.server.ts b/app/features/sendouq/queries/findMatchById.server.ts new file mode 100644 index 000000000..7d35c60f1 --- /dev/null +++ b/app/features/sendouq/queries/findMatchById.server.ts @@ -0,0 +1,51 @@ +import { sql } from "~/db/sql"; +import type { GroupMatch, GroupMatchMap } from "~/db/types"; +import { parseDBJsonArray } from "~/utils/sql"; + +const stm = sql.prepare(/* sql */ ` + select + "GroupMatch"."id", + "GroupMatch"."alphaGroupId", + "GroupMatch"."bravoGroupId", + "GroupMatch"."createdAt", + "GroupMatch"."reportedAt", + "GroupMatch"."reportedByUserId", + (select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked", + json_group_array( + json_object( + 'id', "GroupMatchMap"."id", + 'mode', "GroupMatchMap"."mode", + 'stageId', "GroupMatchMap"."stageId", + 'source', "GroupMatchMap"."source", + 'winnerGroupId', "GroupMatchMap"."winnerGroupId" + ) + ) as "mapList" + from "GroupMatch" + left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id" + where "GroupMatch"."id" = @id + group by "GroupMatch"."id" + order by "GroupMatchMap"."index" asc +`); + +export interface MatchById { + id: GroupMatch["id"]; + alphaGroupId: GroupMatch["alphaGroupId"]; + bravoGroupId: GroupMatch["bravoGroupId"]; + createdAt: GroupMatch["createdAt"]; + reportedAt: GroupMatch["reportedAt"]; + reportedByUserId: GroupMatch["reportedByUserId"]; + isLocked: number; + mapList: Array< + Pick + >; +} + +export function findMatchById(id: number) { + const row = stm.get({ id }) as any; + if (!row) return null; + + return { + ...row, + mapList: parseDBJsonArray(row.mapList), + } as MatchById; +} diff --git a/app/features/sendouq/queries/findPreparingGroup.server.ts b/app/features/sendouq/queries/findPreparingGroup.server.ts new file mode 100644 index 000000000..7b86056b0 --- /dev/null +++ b/app/features/sendouq/queries/findPreparingGroup.server.ts @@ -0,0 +1,69 @@ +import { sql } from "~/db/sql"; +import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; +import type { LookingGroupWithInviteCode } from "../q-types"; + +const stm = sql.prepare(/* sql */ ` + with "q1" as ( + select + "Group"."id", + "Group"."createdAt", + "Group"."mapListPreference", + "Group"."inviteCode", + "User"."id" as "userId", + "User"."discordId", + "User"."discordName", + "User"."discordAvatar", + "GroupMember"."role", + json_group_array("UserWeapon"."weaponSplId") as "weapons" + from + "Group" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + left join "User" on "User"."id" = "GroupMember"."userId" + left join "UserWeapon" on "UserWeapon"."userId" = "User"."id" + left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" + or "GroupMatch"."bravoGroupId" = "Group"."id" + where + "Group"."id" = @ownGroupId + and "Group"."status" = 'PREPARING' + and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3) + group by "User"."id" + order by "UserWeapon"."order" asc + ) + select + "q1"."id", + "q1"."mapListPreference", + "q1"."inviteCode", + json_group_array( + json_object( + 'id', "q1"."userId", + 'discordId', "q1"."discordId", + 'discordName', "q1"."discordName", + 'discordAvatar', "q1"."discordAvatar", + 'role', "q1"."role", + 'weapons', "q1"."weapons" + ) + ) as "members" + from "q1" + group by "q1"."id" + order by "q1"."createdAt" desc +`); + +export function findPreparingGroup( + ownGroupId: number +): LookingGroupWithInviteCode { + const row = stm.get({ ownGroupId }) as any; + + return { + id: row.id, + mapListPreference: row.mapListPreference, + inviteCode: row.inviteCode, + members: parseDBJsonArray(row.members).map((member: any) => { + const weapons = parseDBArray(member.weapons); + + return { + ...member, + weapons: weapons.length > 0 ? weapons : undefined, + }; + }), + }; +} diff --git a/app/features/sendouq/queries/findTeamByInviteCode.server.ts b/app/features/sendouq/queries/findTeamByInviteCode.server.ts new file mode 100644 index 000000000..87c137003 --- /dev/null +++ b/app/features/sendouq/queries/findTeamByInviteCode.server.ts @@ -0,0 +1,33 @@ +import { sql } from "~/db/sql"; +import type { Group } from "~/db/types"; +import { parseDBArray } from "~/utils/sql"; + +const stm = sql.prepare(/* sql */ ` + select + "Group"."id", + "Group"."status", + json_group_array( + "User"."discordName" + ) as "members" + from + "Group" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + left join "User" on "User"."id" = "GroupMember"."userId" + where + "Group"."inviteCode" = @inviteCode + and "Group"."status" != 'INACTIVE' + group by "Group"."id" +`); + +export function findTeamByInviteCode( + inviteCode: string +): { id: number; status: Group["status"]; members: string[] } | null { + const row = stm.get({ inviteCode }) as any; + if (!row) return null; + + return { + id: row.id, + status: row.status, + members: parseDBArray(row.members), + }; +} diff --git a/app/features/sendouq/queries/groupForMatch.server.ts b/app/features/sendouq/queries/groupForMatch.server.ts new file mode 100644 index 000000000..20da6c2d4 --- /dev/null +++ b/app/features/sendouq/queries/groupForMatch.server.ts @@ -0,0 +1,64 @@ +import { sql } from "~/db/sql"; +import type { Group, GroupMember, User } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "Group"."id", + "AllTeam"."name" as "teamName", + "AllTeam"."customUrl" as "teamCustomUrl", + "UserSubmittedImage"."url" as "teamAvatarUrl", + json_group_array( + json_object( + 'id', "GroupMember"."userId", + 'discordId', "User"."discordId", + 'discordName', "User"."discordName", + 'discordAvatar', "User"."discordAvatar", + 'role', "GroupMember"."role", + 'customUrl', "User"."customUrl" + ) + ) as "members" + from + "Group" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + left join "User" on "User"."id" = "GroupMember"."userId" + left join "AllTeam" on "AllTeam"."id" = "Group"."teamId" + left join "UserSubmittedImage" on "AllTeam"."avatarImgId" = "UserSubmittedImage"."id" + where + "Group"."id" = @id + group by "Group"."id" + order by "GroupMember"."userId" asc +`); + +export interface GroupForMatch { + id: Group["id"]; + team?: { + name: string; + avatarUrl: string | null; + customUrl: string; + }; + members: Array<{ + id: GroupMember["userId"]; + discordId: User["discordId"]; + discordName: User["discordName"]; + discordAvatar: User["discordAvatar"]; + role: GroupMember["role"]; + customUrl: User["customUrl"]; + }>; +} + +export function groupForMatch(id: number) { + const row = stm.get({ id }) as any; + if (!row) return null; + + return { + id: row.id, + team: row.teamName + ? { + name: row.teamName, + avatarUrl: row.teamAvatarUrl, + customUrl: row.teamCustomUrl, + } + : undefined, + members: JSON.parse(row.members), + } as GroupForMatch; +} diff --git a/app/features/sendouq/queries/groupSize.server.ts b/app/features/sendouq/queries/groupSize.server.ts new file mode 100644 index 000000000..6bcc43ec1 --- /dev/null +++ b/app/features/sendouq/queries/groupSize.server.ts @@ -0,0 +1,14 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + select + count(*) as "count" + from + "GroupMember" + where + "GroupMember"."groupId" = @groupId +`); + +export function groupSize(groupId: number) { + return stm.pluck().get({ groupId }) as number; +} diff --git a/app/features/sendouq/queries/groupSuccessorOwner.ts b/app/features/sendouq/queries/groupSuccessorOwner.ts new file mode 100644 index 000000000..356900ac9 --- /dev/null +++ b/app/features/sendouq/queries/groupSuccessorOwner.ts @@ -0,0 +1,27 @@ +import { sql } from "~/db/sql"; +import type { GroupMember } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "GroupMember"."userId", + "GroupMember"."role" + from "GroupMember" + where "GroupMember"."groupId" = @groupId + and "GroupMember"."role" != 'OWNER' + order by "GroupMember"."createdAt" asc +`); + +export const groupSuccessorOwner = (groupId: number) => { + const rows = stm.all({ groupId }) as Array< + Pick + >; + + if (rows.length === 0) { + return null; + } + + const manager = rows.find((r) => r.role === "MANAGER"); + if (manager) return manager.userId; + + return rows[0].userId; +}; diff --git a/app/features/sendouq/queries/leaveGroup.server.ts b/app/features/sendouq/queries/leaveGroup.server.ts new file mode 100644 index 000000000..367dac9a7 --- /dev/null +++ b/app/features/sendouq/queries/leaveGroup.server.ts @@ -0,0 +1,45 @@ +import { sql } from "~/db/sql"; + +const makeMemberOwnerStm = sql.prepare(/* sql */ ` + update "GroupMember" + set "role" = 'OWNER' + where "GroupMember"."groupId" = @groupId + and "GroupMember"."userId" = @userId +`); + +const deleteGroupMemberStm = sql.prepare(/* sql */ ` + delete from "GroupMember" + where "GroupMember"."groupId" = @groupId + and "GroupMember"."userId" = @userId +`); + +const deleteGroupStm = sql.prepare(/* sql */ ` + delete from "Group" + where "Group"."id" = @groupId +`); + +export const leaveGroup = sql.transaction( + ({ + groupId, + userId, + newOwnerId, + wasOwner, + }: { + groupId: number; + userId: number; + newOwnerId: number | null; + wasOwner: boolean; + }) => { + if (!wasOwner) { + deleteGroupMemberStm.run({ groupId, userId }); + return; + } + + if (newOwnerId) { + makeMemberOwnerStm.run({ groupId, userId: newOwnerId }); + deleteGroupMemberStm.run({ groupId, userId }); + } else { + deleteGroupStm.run({ groupId }); + } + } +); diff --git a/app/features/sendouq/queries/likeExists.server.ts b/app/features/sendouq/queries/likeExists.server.ts new file mode 100644 index 000000000..54d7ff8de --- /dev/null +++ b/app/features/sendouq/queries/likeExists.server.ts @@ -0,0 +1,18 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + select 1 from "GroupLike" + where + "GroupLike"."likerGroupId" = @likerGroupId + and "GroupLike"."targetGroupId" = @targetGroupId +`); + +export function likeExists({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + return Boolean(stm.get({ likerGroupId, targetGroupId })); +} diff --git a/app/features/sendouq/queries/lookingGroups.server.ts b/app/features/sendouq/queries/lookingGroups.server.ts new file mode 100644 index 000000000..9de226129 --- /dev/null +++ b/app/features/sendouq/queries/lookingGroups.server.ts @@ -0,0 +1,93 @@ +import { sql } from "~/db/sql"; +import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; +import type { LookingGroupWithInviteCode } from "../q-types"; + +// groups visible for longer to make development easier +const SECONDS_TILL_STALE = + process.env.NODE_ENV === "development" ? 1_000_000 : 1_800; + +const stm = sql.prepare(/* sql */ ` + with "q1" as ( + select + "Group"."id", + "Group"."createdAt", + "Group"."mapListPreference", + "Group"."inviteCode", + "User"."id" as "userId", + "User"."discordId", + "User"."discordName", + "User"."discordAvatar", + "PlusTier"."tier" as "plusTier", + "GroupMember"."role", + json_group_array("UserWeapon"."weaponSplId") as "weapons" + from + "Group" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + left join "User" on "User"."id" = "GroupMember"."userId" + left join "PlusTier" on "PlusTier"."userId" = "User"."id" + left join "UserWeapon" on "UserWeapon"."userId" = "User"."id" + left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" + or "GroupMatch"."bravoGroupId" = "Group"."id" + where + "Group"."status" = 'ACTIVE' + -- only groups that were active in the last half an hour as well as own group + and ("Group"."latestActionAt" > (unixepoch() - ${SECONDS_TILL_STALE}) or "Group"."id" = @ownGroupId) + and "GroupMatch"."id" is null + and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3) + group by "User"."id" + order by "UserWeapon"."order" asc + ) + select + "q1"."id", + "q1"."mapListPreference", + "q1"."inviteCode", + json_group_array( + json_object( + 'id', "q1"."userId", + 'discordId', "q1"."discordId", + 'discordName', "q1"."discordName", + 'discordAvatar', "q1"."discordAvatar", + 'plusTier', "q1"."plusTier", + 'role', "q1"."role", + 'weapons', "q1"."weapons" + ) + ) as "members" + from "q1" + group by "q1"."id" + order by "q1"."createdAt" desc +`); + +export function findLookingGroups({ + minGroupSize, + maxGroupSize, + ownGroupId, +}: { + minGroupSize?: number; + maxGroupSize?: number; + ownGroupId: number; +}): LookingGroupWithInviteCode[] { + return stm + .all({ ownGroupId }) + .map((row: any) => { + return { + id: row.id, + mapListPreference: row.mapListPreference, + inviteCode: row.inviteCode, + members: parseDBJsonArray(row.members).map((member: any) => { + const weapons = parseDBArray(member.weapons); + + return { + ...member, + weapons: weapons.length > 0 ? weapons : undefined, + }; + }), + }; + }) + .filter((group: any) => { + if (group.id === ownGroupId) return true; + if (maxGroupSize && group.members.length > maxGroupSize) return false; + if (minGroupSize && group.members.length < minGroupSize) return false; + + return true; + }); +} diff --git a/app/features/sendouq/queries/mapPoolByGroupId.server.ts b/app/features/sendouq/queries/mapPoolByGroupId.server.ts new file mode 100644 index 000000000..a107c5761 --- /dev/null +++ b/app/features/sendouq/queries/mapPoolByGroupId.server.ts @@ -0,0 +1,14 @@ +import { sql } from "~/db/sql"; +import type { MapPoolMap } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "MapPoolMap"."stageId", + "MapPoolMap"."mode" + from "MapPoolMap" + where "MapPoolMap"."groupId" = @groupId +`); + +export function mapPoolByGroupId(groupId: number) { + return stm.all({ groupId }) as Array>; +} diff --git a/app/features/sendouq/queries/morphGroups.server.ts b/app/features/sendouq/queries/morphGroups.server.ts new file mode 100644 index 000000000..5367d23ad --- /dev/null +++ b/app/features/sendouq/queries/morphGroups.server.ts @@ -0,0 +1,41 @@ +import { sql } from "~/db/sql"; + +const deleteGroupStm = sql.prepare(/* sql */ ` + delete from "Group" + where "Group"."id" = @groupId +`); + +const addGroupMemberStm = sql.prepare(/* sql */ ` + insert into "GroupMember" ("groupId", "userId", "role") + values (@groupId, @userId, @role) +`); + +const deleteLikesStm = sql.prepare(/* sql */ ` + delete from "GroupLike" + where "likerGroupId" = @groupId + or "targetGroupId" = @groupId +`); + +export const morphGroups = sql.transaction( + ({ + survivingGroupId, + otherGroupId, + newMembers, + }: { + survivingGroupId: number; + otherGroupId: number; + newMembers: number[]; + }) => { + deleteGroupStm.run({ groupId: otherGroupId }); + + deleteLikesStm.run({ groupId: survivingGroupId }); + + for (const userId of newMembers) { + addGroupMemberStm.run({ + groupId: survivingGroupId, + userId, + role: "REGULAR", + }); + } + } +); diff --git a/app/features/sendouq/queries/refreshGroup.server.ts b/app/features/sendouq/queries/refreshGroup.server.ts new file mode 100644 index 000000000..5fd6f4b26 --- /dev/null +++ b/app/features/sendouq/queries/refreshGroup.server.ts @@ -0,0 +1,12 @@ +import { sql } from "~/db/sql"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; + +const stm = sql.prepare(/* sql */ ` + update "Group" + set 'latestActionAt' = @latestActionAt + where "Group"."id" = @groupId +`); + +export function refreshGroup(groupId: number) { + stm.run({ latestActionAt: dateToDatabaseTimestamp(new Date()), groupId }); +} diff --git a/app/features/sendouq/queries/removeManagerRole.server.ts b/app/features/sendouq/queries/removeManagerRole.server.ts new file mode 100644 index 000000000..285d208bd --- /dev/null +++ b/app/features/sendouq/queries/removeManagerRole.server.ts @@ -0,0 +1,18 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + update "GroupMember" + set "role" = 'REGULAR' + where "userId" = @userId + and "groupId" = @groupId +`); + +export function removeManagerRole({ + userId, + groupId, +}: { + userId: number; + groupId: number; +}) { + stm.run({ userId, groupId }); +} diff --git a/app/features/sendouq/queries/reportScore.server.ts b/app/features/sendouq/queries/reportScore.server.ts new file mode 100644 index 000000000..81f6a4c41 --- /dev/null +++ b/app/features/sendouq/queries/reportScore.server.ts @@ -0,0 +1,52 @@ +import { sql } from "~/db/sql"; +import type { GroupMatch } from "~/db/types"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; + +const updateMatchStm = sql.prepare(/* sql */ ` + update "GroupMatch" + set "reportedAt" = @reportedAt, + "reportedByUserId" = @reportedByUserId + where "id" = @matchId + returning * +`); + +const clearMatchMapWinnersStm = sql.prepare(/* sql */ ` + update "GroupMatchMap" + set "winnerGroupId" = null + where "matchId" = @matchId +`); + +const updateMatchMapStm = sql.prepare(/* sql */ ` + update "GroupMatchMap" + set "winnerGroupId" = @winnerGroupId + where "matchId" = @matchId and "index" = @index +`); + +export const reportScore = ({ + reportedByUserId, + winners, + matchId, +}: { + reportedByUserId: number; + winners: ("ALPHA" | "BRAVO")[]; + matchId: number; +}) => { + const updatedMatch = updateMatchStm.get({ + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + matchId, + }) as GroupMatch; + + clearMatchMapWinnersStm.run({ matchId }); + + for (const [index, winner] of winners.entries()) { + updateMatchMapStm.run({ + winnerGroupId: + winner === "ALPHA" + ? updatedMatch.alphaGroupId + : updatedMatch.bravoGroupId, + matchId, + index, + }); + } +}; diff --git a/app/features/sendouq/queries/reportedWeaponsByMatchId.server.ts b/app/features/sendouq/queries/reportedWeaponsByMatchId.server.ts new file mode 100644 index 000000000..4688c3bb9 --- /dev/null +++ b/app/features/sendouq/queries/reportedWeaponsByMatchId.server.ts @@ -0,0 +1,21 @@ +import { sql } from "~/db/sql"; +import type { ReportedWeapon } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "ReportedWeapon"."groupMatchMapId", + "ReportedWeapon"."weaponSplId", + "ReportedWeapon"."userId" + from + "ReportedWeapon" + left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId" + where "GroupMatchMap"."matchId" = @matchId +`); + +export function reportedWeaponsByMatchId(matchId: number) { + const rows = stm.all({ matchId }) as Array; + + if (rows.length === 0) return null; + + return rows; +} diff --git a/app/features/sendouq/queries/seasonMatchesByUserId.server.ts b/app/features/sendouq/queries/seasonMatchesByUserId.server.ts new file mode 100644 index 000000000..188c223ba --- /dev/null +++ b/app/features/sendouq/queries/seasonMatchesByUserId.server.ts @@ -0,0 +1,195 @@ +import { sql } from "~/db/sql"; +import type { GroupMatch, GroupMatchMap, User } from "~/db/types"; +import { type RankingSeason, seasonObject } from "~/features/mmr/season"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; + +const MATCHES_PER_PAGE = 8; + +const stm = sql.prepare(/* sql */ ` + with "q1" as ( + select + "GroupMatch"."id", + "GroupMatch"."alphaGroupId", + "GroupMatch"."bravoGroupId", + "GroupMatch"."createdAt" + from "GroupMatch" + left join "Group" on + "GroupMatch"."alphaGroupId" = "Group"."id" or + "GroupMatch"."bravoGroupId" = "Group"."id" + left join "GroupMember" on "Group"."id" = "GroupMember"."groupId" + where "GroupMember"."userId" = @userId + and "GroupMatch"."createdAt" between @starts and @ends + order by "GroupMatch"."id" desc + limit ${MATCHES_PER_PAGE} + offset ${MATCHES_PER_PAGE} * (@page - 1) + ), + "q2" as ( + select + "q1".*, + json_group_array( + "GroupMatchMap"."winnerGroupId" + ) as "winnerGroupIds" + from + "q1" + left join "GroupMatchMap" on "q1"."id" = "GroupMatchMap"."matchId" + where "GroupMatchMap"."winnerGroupId" is not null + group by "q1"."id" + ), "q3" as ( + select + "q2".*, + json_group_array( + json_object( + 'id', "User"."id", + 'discordName', "User"."discordName", + 'discordId', "User"."discordId", + 'discordAvatar', "User"."discordAvatar" + ) + ) as "groupAlphaMembers" + from "q2" + left join "Group" on "q2"."alphaGroupId" = "Group"."id" + left join "GroupMember" on "Group"."id" = "GroupMember"."groupId" + left join "User" on "GroupMember"."userId" = "User"."id" + group by "q2"."id" + ) + select + "q3".*, + json_group_array( + json_object( + 'id', "User"."id", + 'discordName', "User"."discordName", + 'discordId', "User"."discordId", + 'discordAvatar', "User"."discordAvatar" + ) + ) as "groupBravoMembers" + from "q3" + left join "Group" on "q3"."bravoGroupId" = "Group"."id" + left join "GroupMember" on "Group"."id" = "GroupMember"."groupId" + left join "User" on "GroupMember"."userId" = "User"."id" + group by "q3"."id" + order by "q3"."id" desc +`); + +const weaponsStm = sql.prepare(/* sql */ ` + with "q1" as ( + select + "ReportedWeapon"."userId", + "ReportedWeapon"."weaponSplId", + count(*) as "count" + from + "GroupMatch" + left join "GroupMatchMap" on "GroupMatch"."id" = "GroupMatchMap"."matchId" + left join "ReportedWeapon" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId" + where "GroupMatch"."id" = @id + group by "ReportedWeapon"."userId", "ReportedWeapon"."weaponSplId" + order by "count" desc + ) + select + "q1"."userId", + "q1"."weaponSplId" + from "q1" + group by "q1"."userId" +`); + +interface SeasonMatchByUserId { + id: GroupMatch["id"]; + alphaGroupId: GroupMatch["alphaGroupId"]; + bravoGroupId: GroupMatch["bravoGroupId"]; + winnerGroupIds: Array; + createdAt: GroupMatch["createdAt"]; + groupAlphaMembers: Array<{ + id: User["id"]; + discordName: User["discordName"]; + discordId: User["discordId"]; + discordAvatar: User["discordAvatar"]; + weaponSplId?: MainWeaponId; + }>; + groupBravoMembers: Array<{ + id: User["id"]; + discordName: User["discordName"]; + discordId: User["discordId"]; + discordAvatar: User["discordAvatar"]; + weaponSplId?: MainWeaponId; + }>; +} + +export function seasonMatchesByUserId({ + userId, + season, + page, +}: { + userId: number; + season: RankingSeason["nth"]; + page: number; +}): SeasonMatchByUserId[] { + const { starts, ends } = seasonObject(season); + + const rows = stm.all({ + userId, + starts: dateToDatabaseTimestamp(starts), + ends: dateToDatabaseTimestamp(ends), + page, + }) as any; + + return rows.map((row: any) => { + const weapons = weaponsStm.all({ id: row.id }) as any; + + return { + ...row, + winnerGroupIds: parseDBArray(row.winnerGroupIds), + groupAlphaMembers: parseDBJsonArray(row.groupAlphaMembers).map( + (member: any) => ({ + ...member, + weaponSplId: weapons.find((w: any) => w.userId === member.id) + ?.weaponSplId, + }) + ), + groupBravoMembers: parseDBJsonArray(row.groupBravoMembers).map( + (member: any) => ({ + ...member, + weaponSplId: weapons.find((w: any) => w.userId === member.id) + ?.weaponSplId, + }) + ), + }; + }); +} + +const pagesStm = sql.prepare(/* sql */ ` + with "q1" as ( + select + "GroupMatch"."id" + from "GroupMatch" + left join "Group" on + "GroupMatch"."alphaGroupId" = "Group"."id" or + "GroupMatch"."bravoGroupId" = "Group"."id" + left join "GroupMember" on "Group"."id" = "GroupMember"."groupId" + left join "GroupMatchMap" on "GroupMatch"."id" = "GroupMatchMap"."matchId" + where "GroupMember"."userId" = @userId + and "GroupMatch"."createdAt" between @starts and @ends + and "GroupMatchMap"."winnerGroupId" is not null + group by "GroupMatch"."id" + ) + select + count(*) as "count" + from "q1" +`); + +export function seasonMatchesByUserIdPagesCount({ + userId, + season, +}: { + userId: number; + season: RankingSeason["nth"]; +}): number { + const { starts, ends } = seasonObject(season); + + const row = pagesStm.get({ + userId, + starts: dateToDatabaseTimestamp(starts), + ends: dateToDatabaseTimestamp(ends), + }) as any; + + return Math.ceil((row.count as number) / MATCHES_PER_PAGE); +} diff --git a/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts b/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts new file mode 100644 index 000000000..ac6a526c6 --- /dev/null +++ b/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts @@ -0,0 +1,35 @@ +import { sql } from "~/db/sql"; +import { type RankingSeason, seasonObject } from "~/features/mmr/season"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; + +const stm = sql.prepare(/* sql */ ` + select + "ReportedWeapon"."weaponSplId", + count(*) as "count" + from + "ReportedWeapon" + left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId" + left join "GroupMatch" on "GroupMatch"."id" = "GroupMatchMap"."matchId" + where + "ReportedWeapon"."userId" = @userId + and "GroupMatch"."createdAt" between @starts and @ends + group by "ReportedWeapon"."weaponSplId" + order by "count" desc +`); + +export function seasonReportedWeaponsByUserId({ + userId, + season, +}: { + userId: number; + season: RankingSeason["nth"]; +}) { + const { starts, ends } = seasonObject(season); + + return stm.all({ + userId, + starts: dateToDatabaseTimestamp(starts), + ends: dateToDatabaseTimestamp(ends), + }) as Array<{ weaponSplId: MainWeaponId; count: number }>; +} diff --git a/app/features/sendouq/queries/seasonStagesByUserId.server.ts b/app/features/sendouq/queries/seasonStagesByUserId.server.ts new file mode 100644 index 000000000..b81888b36 --- /dev/null +++ b/app/features/sendouq/queries/seasonStagesByUserId.server.ts @@ -0,0 +1,50 @@ +import { sql } from "~/db/sql"; +import type { MapResult } from "~/db/types"; +import type { ModeShort, StageId } from "~/modules/in-game-lists"; + +const stm = sql.prepare(/* sql */ ` + select + * + from + "MapResult" + where + "MapResult"."userId" = @userId + and "MapResult"."season" = @season +`); + +export function seasonStagesByUserId({ + userId, + season, +}: { + userId: number; + season: number; +}) { + const rows = stm.all({ userId, season }) as Array; + + return rows.reduce( + (acc, cur) => { + if (!acc[cur.stageId]) acc[cur.stageId] = {}; + + acc[cur.stageId]![cur.mode] = { + wins: cur.wins, + losses: cur.losses, + }; + + return acc; + }, + {} as Partial< + Record< + StageId, + Partial< + Record< + ModeShort, + { + wins: number; + losses: number; + } + > + > + > + > + ); +} diff --git a/app/features/sendouq/queries/seasonsMatesEnemiesByUserId.server.ts b/app/features/sendouq/queries/seasonsMatesEnemiesByUserId.server.ts new file mode 100644 index 000000000..4fa3a52b0 --- /dev/null +++ b/app/features/sendouq/queries/seasonsMatesEnemiesByUserId.server.ts @@ -0,0 +1,51 @@ +import { sql } from "~/db/sql"; +import type { PlayerResult, User } from "~/db/types"; +import { type RankingSeason } from "~/features/mmr/season"; + +const stm = sql.prepare(/* sql */ ` + select + "mapWins", + "mapLosses", + "setWins", + "setLosses", + json_object( + 'id', "User"."id", + 'discordName', "User"."discordName", + 'discordAvatar', "User"."discordAvatar", + 'discordId', "User"."discordId", + 'customUrl', "User"."customUrl" + ) as "user" + from "PlayerResult" + left join "User" on "User"."id" = "PlayerResult"."otherUserId" + where + "ownerUserId" = @userId + and "season" = @season + and "type" = @type + order by "mapWins" + "mapLosses" desc +`); + +export function seasonsMatesEnemiesByUserId({ + userId, + season, + type, +}: { + userId: number; + season: RankingSeason["nth"]; + type: PlayerResult["type"]; +}) { + const rows = stm.all({ userId, season, type }) as any[]; + + return rows.map((row) => ({ + ...row, + user: JSON.parse(row.user), + })) as Array<{ + mapWins: number; + mapLosses: number; + setWins: number; + setLosses: number; + user: Pick< + User, + "id" | "discordName" | "discordAvatar" | "discordId" | "customUrl" + >; + }>; +} diff --git a/app/features/sendouq/queries/setGroupAsActive.server.ts b/app/features/sendouq/queries/setGroupAsActive.server.ts new file mode 100644 index 000000000..b25d1868b --- /dev/null +++ b/app/features/sendouq/queries/setGroupAsActive.server.ts @@ -0,0 +1,12 @@ +import { sql } from "~/db/sql"; +import type { Group } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + update "Group" + set "status" = 'ACTIVE' + where "id" = @groupId +`); + +export function setGroupAsActive(groupId: Group["id"]) { + stm.run({ groupId }); +} diff --git a/app/features/sendouq/queries/setGroupAsInactive.server.ts b/app/features/sendouq/queries/setGroupAsInactive.server.ts new file mode 100644 index 000000000..e22e8ad33 --- /dev/null +++ b/app/features/sendouq/queries/setGroupAsInactive.server.ts @@ -0,0 +1,11 @@ +import { sql } from "~/db/sql"; + +const groupToInactiveStm = sql.prepare(/* sql */ ` + update "Group" + set "status" = 'INACTIVE' + where "id" = @groupId +`); + +export function setGroupAsInactive(groupId: number) { + groupToInactiveStm.run({ groupId }); +} diff --git a/app/features/sendouq/queries/syncGroupTeamId.server.ts b/app/features/sendouq/queries/syncGroupTeamId.server.ts new file mode 100644 index 000000000..99003ba4e --- /dev/null +++ b/app/features/sendouq/queries/syncGroupTeamId.server.ts @@ -0,0 +1,32 @@ +import invariant from "tiny-invariant"; +import { sql } from "~/db/sql"; +import { FULL_GROUP_SIZE } from "../q-constants"; + +const memberTeamIdsStm = sql.prepare(/* sql */ ` + select "TeamMember"."teamId" + from "GroupMember" + left join "TeamMember" on "TeamMember"."userId" = "GroupMember"."userId" + where "groupId" = @groupId +`); + +const updateStm = sql.prepare(/* sql */ ` + update "Group" + set "teamId" = @teamId + where "id" = @groupId +`); + +export function syncGroupTeamId(groupId: number) { + const teamIds = memberTeamIdsStm + .all({ groupId }) + .map((row: any) => row.teamId); + invariant(teamIds.length === FULL_GROUP_SIZE, "Group to sync is not full"); + + const set = new Set(teamIds); + + if (set.size === 1) { + const teamId = teamIds[0]; + updateStm.run({ groupId, teamId }); + } else { + updateStm.run({ groupId, teamId: null }); + } +} diff --git a/app/features/sendouq/queries/userHasSkill.server.ts b/app/features/sendouq/queries/userHasSkill.server.ts new file mode 100644 index 000000000..5350a7966 --- /dev/null +++ b/app/features/sendouq/queries/userHasSkill.server.ts @@ -0,0 +1,24 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + select + 1 + from + "Skill" + where + "Skill"."userId" = @userId + and "Skill"."season" = @season + limit 1 +`); + +export function userHasSkill({ + userId, + season, +}: { + userId: number; + season: number; +}) { + const rows = stm.all({ userId, season }); + + return rows.length > 0; +} diff --git a/app/features/sendouq/queries/usersInActiveGroup.server.ts b/app/features/sendouq/queries/usersInActiveGroup.server.ts new file mode 100644 index 000000000..9645c6b8e --- /dev/null +++ b/app/features/sendouq/queries/usersInActiveGroup.server.ts @@ -0,0 +1,29 @@ +import { sql } from "~/db/sql"; +import { findTrustedPlayers } from "~/features/tournament/queries/findTrustedPlayers.server"; + +const stm = sql.prepare(/* sql */ ` + select + "GroupMember"."userId" + from + "GroupMember" + left join "Group" on "Group"."id" = "GroupMember"."groupId" + where + "Group"."status" != 'INACTIVE' +`); + +export function trustedPlayersAvailableToPlay(user: { + id: number; + team?: { id: number }; +}) { + const trusted = findTrustedPlayers({ + userId: user.id, + teamId: user.team?.id, + }); + if (trusted.length === 0) return trusted; + + const activePlayers = (stm.all() as Array<{ userId: number }>).map( + (u) => u.userId + ); + + return trusted.filter((u) => !activePlayers.includes(u.id)); +} diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx new file mode 100644 index 000000000..41b9aca34 --- /dev/null +++ b/app/features/sendouq/routes/q.looking.tsx @@ -0,0 +1,597 @@ +import type { + ActionFunction, + LinksFunction, + LoaderArgs, + V2_MetaFunction, +} from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { useFetcher, useLoaderData, useRevalidator } from "@remix-run/react"; +import clsx from "clsx"; +import * as React from "react"; +import { Flipper } from "react-flip-toolkit"; +import invariant from "tiny-invariant"; +import { Main } from "~/components/Main"; +import { SubmitButton } from "~/components/SubmitButton"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { useTranslation } from "~/hooks/useTranslation"; +import { useVisibilityChange } from "~/hooks/useVisibilityChange"; +import { getUser, requireUserId } from "~/modules/auth/user.server"; +import { MapPool } from "~/modules/map-pool-serializer"; +import { + parseRequestFormData, + validate, + type SendouRouteHandle, +} from "~/utils/remix"; +import { assertUnreachable } from "~/utils/types"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + navIconUrl, + sendouQMatchPage, +} from "~/utils/urls"; +import { GroupCard } from "../components/GroupCard"; +import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups"; +import { + addSkillsToGroups, + censorGroups, + divideGroups, + filterOutGroupsWithIncompatibleMapListPreference, + groupExpiryStatus, + membersNeededForFull, +} from "../core/groups.server"; +import { matchMapList } from "../core/match.server"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { lookingSchema } from "../q-schemas.server"; +import { groupRedirectLocationByCurrentLocation } from "../q-utils"; +import styles from "../q.css"; +import { addLike } from "../queries/addLike.server"; +import { addManagerRole } from "../queries/addManagerRole.server"; +import { createMatch } from "../queries/createMatch.server"; +import { deleteLike } from "../queries/deleteLike.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findLikes } from "../queries/findLikes"; +import { groupSize } from "../queries/groupSize.server"; +import { groupSuccessorOwner } from "../queries/groupSuccessorOwner"; +import { leaveGroup } from "../queries/leaveGroup.server"; +import { likeExists } from "../queries/likeExists.server"; +import { findLookingGroups } from "../queries/lookingGroups.server"; +import { mapPoolByGroupId } from "../queries/mapPoolByGroupId.server"; +import { morphGroups } from "../queries/morphGroups.server"; +import { refreshGroup } from "../queries/refreshGroup.server"; +import { removeManagerRole } from "../queries/removeManagerRole.server"; +import { syncGroupTeamId } from "../queries/syncGroupTeamId.server"; +import { makeTitle } from "~/utils/strings"; +import { MemberAdder } from "../components/MemberAdder"; +import type { LookingGroupWithInviteCode } from "../q-types"; +import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server"; +import { userSkills } from "~/features/mmr/tiered.server"; +import { useWindowSize } from "~/hooks/useWindowSize"; +import { Tab, Tabs } from "~/components/Tabs"; + +export const handle: SendouRouteHandle = { + i18n: ["q"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_LOOKING_PAGE, + type: "IMAGE", + }), +}; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: styles }]; +}; + +export const meta: V2_MetaFunction = () => { + return [{ title: makeTitle("SendouQ") }]; +}; + +// this function doesn't throw normally because we are assuming +// if there is a validation error the user saw stale data +// and when we return null we just force a refresh +export const action: ActionFunction = async ({ request }) => { + const user = await requireUserId(request); + const data = await parseRequestFormData({ + request, + schema: lookingSchema, + }); + const currentGroup = findCurrentGroupByUserId(user.id); + if (!currentGroup) return null; + + // this throws because there should normally be no way user loses ownership by the action of some other user + const validateIsGroupOwner = () => + validate(currentGroup.role === "OWNER", "Not owner"); + const isGroupManager = () => + currentGroup.role === "MANAGER" || currentGroup.role === "OWNER"; + + switch (data._action) { + case "LIKE": { + if (!isGroupManager()) return null; + + addLike({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "UNLIKE": { + if (!isGroupManager()) return null; + + deleteLike({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "GROUP_UP": { + if (!isGroupManager()) return null; + if ( + !likeExists({ + targetGroupId: currentGroup.id, + likerGroupId: data.targetGroupId, + }) + ) { + return null; + } + + const lookingGroups = findLookingGroups({ + maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)), + ownGroupId: currentGroup.id, + }); + + const ourGroup = lookingGroups.find( + (group) => group.id === currentGroup.id + ); + if (!ourGroup) return null; + const theirGroup = lookingGroups.find( + (group) => group.id === data.targetGroupId + ); + if (!theirGroup) return null; + + const { id: survivingGroupId } = groupAfterMorph({ + liker: "THEM", + ourGroup, + theirGroup, + }); + + const otherGroup = + ourGroup.id === survivingGroupId ? theirGroup : ourGroup; + + invariant(ourGroup.members, "our group has no members"); + invariant(otherGroup.members, "other group has no members"); + + morphGroups({ + survivingGroupId, + otherGroupId: otherGroup.id, + newMembers: otherGroup.members.map((m) => m.id), + }); + refreshGroup(survivingGroupId); + + if ( + ourGroup.members.length + otherGroup.members.length === + FULL_GROUP_SIZE + ) { + syncGroupTeamId(survivingGroupId); + } + + break; + } + case "MATCH_UP": { + if (!isGroupManager()) return null; + if ( + !likeExists({ + targetGroupId: currentGroup.id, + likerGroupId: data.targetGroupId, + }) + ) { + return null; + } + + const lookingGroups = findLookingGroups({ + minGroupSize: FULL_GROUP_SIZE, + ownGroupId: currentGroup.id, + }); + + const ourGroup = lookingGroups.find( + (group) => group.id === currentGroup.id + ); + if (!ourGroup) return null; + const theirGroup = lookingGroups.find( + (group) => group.id === data.targetGroupId + ); + if (!theirGroup) return null; + + const createdMatch = createMatch({ + alphaGroupId: ourGroup.id, + bravoGroupId: theirGroup.id, + mapList: matchMapList({ + ourGroup, + theirGroup, + ourMapPool: new MapPool(mapPoolByGroupId(ourGroup.id)), + theirMapPool: new MapPool(mapPoolByGroupId(theirGroup.id)), + }), + }); + + throw redirect(sendouQMatchPage(createdMatch.id)); + } + case "GIVE_MANAGER": { + validateIsGroupOwner(); + + addManagerRole({ + groupId: currentGroup.id, + userId: data.userId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "REMOVE_MANAGER": { + validateIsGroupOwner(); + + removeManagerRole({ + groupId: currentGroup.id, + userId: data.userId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "LEAVE_GROUP": { + let newOwnerId: number | null = null; + if (currentGroup.role === "OWNER") { + newOwnerId = groupSuccessorOwner(currentGroup.id); + } + + leaveGroup({ + groupId: currentGroup.id, + userId: user.id, + newOwnerId, + wasOwner: currentGroup.role === "OWNER", + }); + + throw redirect(SENDOUQ_PAGE); + } + case "REFRESH_GROUP": { + refreshGroup(currentGroup.id); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; + +export const loader = async ({ request }: LoaderArgs) => { + const user = await getUser(request); + + const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined; + const redirectLocation = groupRedirectLocationByCurrentLocation({ + group: currentGroup, + currentLocation: "looking", + }); + + if (redirectLocation) { + throw redirect(redirectLocation); + } + + invariant(currentGroup, "currentGroup is undefined"); + + const currentGroupSize = groupSize(currentGroup.id); + const groupIsFull = currentGroupSize === FULL_GROUP_SIZE; + + const dividedGroups = divideGroups({ + groups: findLookingGroups({ + maxGroupSize: groupIsFull + ? undefined + : membersNeededForFull(currentGroupSize), + minGroupSize: groupIsFull ? FULL_GROUP_SIZE : undefined, + ownGroupId: currentGroup.id, + }), + ownGroupId: currentGroup.id, + likes: findLikes(currentGroup.id), + }); + + const groupsWithSkills = addSkillsToGroups({ + groups: dividedGroups, + ...(await userSkills()), + }); + + const compatibleGroups = groupIsFull + ? filterOutGroupsWithIncompatibleMapListPreference(groupsWithSkills) + : groupsWithSkills; + + const censoredGroups = censorGroups({ + groups: compatibleGroups, + showMembers: !groupIsFull, + showInviteCode: hasGroupManagerPerms(currentGroup.role) && !groupIsFull, + }); + + return { + groups: censoredGroups, + role: currentGroup.role, + lastUpdated: new Date().getTime(), + expiryStatus: groupExpiryStatus(currentGroup), + trustedPlayers: hasGroupManagerPerms(currentGroup.role) + ? trustedPlayersAvailableToPlay(user!) + : [], + }; +}; + +export default function QLookingPage() { + const data = useLoaderData(); + useAutoRefresh(); + + const ownGroup = data.groups.own as LookingGroupWithInviteCode; + + return ( +
+
+ +
+ +
+
+ {ownGroup.inviteCode ? ( + + ) : null} + +
+ ); +} + +// TODO: could be improved e.g. don't refresh when group has expired +// or we got new data in the last 20 seconds +function useAutoRefresh() { + const { revalidate } = useRevalidator(); + const visibility = useVisibilityChange(); + + React.useEffect(() => { + // when user comes back to this tab + if (visibility === "visible") { + revalidate(); + } + + // ...as well as every 20 seconds + const interval = setInterval(() => { + if (visibility === "hidden") return; + revalidate(); + }, 20 * 1000); + + return () => { + clearInterval(interval); + }; + }, [visibility, revalidate]); +} + +function InfoText() { + const { i18n } = useTranslation(); + const isMounted = useIsMounted(); + const data = useLoaderData(); + const fetcher = useFetcher(); + + if (data.expiryStatus === "EXPIRED") { + return ( + + Group hidden due to inactivity. Still looking?{" "} + + Click here + + + ); + } + + if (data.expiryStatus === "EXPIRING_SOON") { + return ( + + Group will be hidden soon due to inactivity. Still looking?{" "} + + Click here + + + ); + } + + return ( +
+ {isMounted + ? `Last updated at ${new Date(data.lastUpdated).toLocaleTimeString( + i18n.language + )}` + : "Placeholder"} +
+ ); +} + +function Groups() { + const data = useLoaderData(); + const isMounted = useIsMounted(); + const { width } = useWindowSize(); + + if (data.expiryStatus === "EXPIRED" || !isMounted) return null; + + if (width < 750) return ; + return ; +} + +function MobileGroupCards() { + const data = useLoaderData(); + const [tab, setTab] = React.useState<"received" | "neutral" | "given">( + "neutral" + ); + + const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE; + + const groups = + tab === "received" + ? data.groups.likesReceived + : tab === "given" + ? data.groups.likesGiven + : data.groups.neutral; + + return ( +
+ + setTab("received")}> + Received ({data.groups.likesReceived.length}) + + setTab("neutral")}> + Neutral ({data.groups.neutral.length}) + + setTab("given")}> + Given ({data.groups.likesGiven.length}) + + +
+ {groups.map((group) => { + const { mapListPreference } = groupAfterMorph({ + liker: tab === "received" ? "THEM" : "US", + ourGroup: data.groups.own, + theirGroup: group, + }); + + const action = + tab === "neutral" + ? "LIKE" + : tab === "given" + ? "UNLIKE" + : isFullGroup + ? "MATCH_UP" + : "GROUP_UP"; + + return ( + + ); + })} +
+
+ ); +} + +function GroupCardColumns() { + const data = useLoaderData(); + + const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE; + + return ( + g.id) + .join("")}-${data.groups.neutral + .map((g) => g.id) + .join("")}-${data.groups.likesGiven.map((g) => g.id).join("")}`} + > +
+
+

+ {isFullGroup ? "Challenges received" : "Groups that asked you"} +

+
+ {data.groups.likesReceived.map((group) => { + const { mapListPreference } = groupAfterMorph({ + liker: "THEM", + ourGroup: data.groups.own, + theirGroup: group, + }); + + return ( + + ); + })} +
+
+
+

Neutral

+
+ {data.groups.neutral.map((group) => { + const { mapListPreference } = groupAfterMorph({ + liker: "US", + ourGroup: data.groups.own, + theirGroup: group, + }); + + return ( + + ); + })} +
+
+
+

+ {isFullGroup ? "Challenges issued" : "Groups you asked"} +

+
+ {data.groups.likesGiven.map((group) => { + const { mapListPreference } = groupAfterMorph({ + liker: "US", + ourGroup: data.groups.own, + theirGroup: group, + }); + + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx new file mode 100644 index 000000000..20d19f9dd --- /dev/null +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -0,0 +1,982 @@ +import type { + ActionArgs, + LinksFunction, + LoaderArgs, + SerializeFrom, +} from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import type { FetcherWithComponents } from "@remix-run/react"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; +import clsx from "clsx"; +import * as React from "react"; +import { Flipped, Flipper } from "react-flip-toolkit"; +import invariant from "tiny-invariant"; +import { Avatar } from "~/components/Avatar"; +import { Button } from "~/components/Button"; +import { WeaponCombobox } from "~/components/Combobox"; +import { ModeImage, StageImage, WeaponImage } from "~/components/Image"; +import { Main } from "~/components/Main"; +import { SubmitButton } from "~/components/SubmitButton"; +import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox"; +import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; +import { sql } from "~/db/sql"; +import type { GroupMember, ReportedWeapon } from "~/db/types"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { useTranslation } from "~/hooks/useTranslation"; +import { useUser } from "~/modules/auth"; +import { requireUserId } from "~/modules/auth/user.server"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import { isAdmin } from "~/permissions"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { animate } from "~/utils/flip"; +import type { SendouRouteHandle } from "~/utils/remix"; +import { + badRequestIfFalsy, + notFoundIfFalsy, + parseRequestFormData, + validate, +} from "~/utils/remix"; +import type { Unpacked } from "~/utils/types"; +import { assertUnreachable } from "~/utils/types"; +import { + SENDOUQ_PAGE, + SENDOUQ_PREPARING_PAGE, + SENDOUQ_RULES_PAGE, + SENDOU_INK_DISCORD_URL, + navIconUrl, + teamPage, + userPage, + userSubmittedImage, +} from "~/utils/urls"; +import { matchEndedAtIndex } from "../core/match"; +import { compareMatchToReportedScores } from "../core/match.server"; +import { calculateMatchSkills } from "../core/skills.server"; +import { FULL_GROUP_SIZE, USER_SKILLS_CACHE_KEY } from "../q-constants"; +import { matchSchema } from "../q-schemas.server"; +import { matchIdFromParams, winnersArrayToWinner } from "../q-utils"; +import styles from "../q.css"; +import { addReportedWeapons } from "../queries/addReportedWeapons.server"; +import { addSkills } from "../queries/addSkills.server"; +import { createGroupFromPreviousGroup } from "../queries/createGroup.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findMatchById } from "../queries/findMatchById.server"; +import type { GroupForMatch } from "../queries/groupForMatch.server"; +import { groupForMatch } from "../queries/groupForMatch.server"; +import { reportScore } from "../queries/reportScore.server"; +import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; +import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; +import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server"; +import { Divider } from "~/components/Divider"; +import { cache } from "~/utils/cache.server"; +import { Toggle } from "~/components/Toggle"; +import { addMapResults } from "../queries/addMapResults.server"; +import { + summarizeMaps, + summarizePlayerResults, +} from "../core/summarizer.server"; +import { addPlayerResults } from "../queries/addPlayerResults.server"; +import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: styles }]; +}; + +export const handle: SendouRouteHandle = { + i18n: ["q", "tournament"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_PAGE, + type: "IMAGE", + }), +}; + +export const action = async ({ request, params }: ActionArgs) => { + const matchId = matchIdFromParams(params); + const user = await requireUserId(request); + const data = await parseRequestFormData({ + request, + schema: matchSchema, + }); + + switch (data._action) { + case "REPORT_SCORE": { + const match = notFoundIfFalsy(findMatchById(matchId)); + validate( + !match.isLocked, + "Match has already been reported by both teams" + ); + validate( + !data.adminReport || isAdmin(user), + "Only admins can report scores as admin" + ); + const members = [ + ...groupForMatch(match.alphaGroupId)!.members.map((m) => ({ + ...m, + groupId: match.alphaGroupId, + })), + ...groupForMatch(match.bravoGroupId)!.members.map((m) => ({ + ...m, + groupId: match.bravoGroupId, + })), + ]; + + const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId; + invariant( + groupMemberOfId || data.adminReport, + "User is not a manager of any group" + ); + + const winner = winnersArrayToWinner(data.winners); + const winnerTeamId = + winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; + const loserTeamId = + winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId; + + // when admin reports match gets locked right away + const compared = data.adminReport + ? "SAME" + : compareMatchToReportedScores({ + match, + winners: data.winners, + newReporterGroupId: groupMemberOfId!, + previousReporterGroupId: match.reportedByUserId + ? members.find((m) => m.id === match.reportedByUserId)!.groupId + : undefined, + }); + + // same group reporting same score, probably by mistake + if (compared === "DUPLICATE") { + return null; + } + if (compared === "DIFFERENT") { + return { error: "different" as const }; + } + + const newSkills = + compared === "SAME" + ? calculateMatchSkills({ + groupMatchId: match.id, + winner: groupForMatch(winnerTeamId)!.members.map((m) => m.id), + loser: groupForMatch(loserTeamId)!.members.map((m) => m.id), + }) + : null; + + sql.transaction(() => { + reportScore({ + matchId, + reportedByUserId: user.id, + winners: data.winners, + }); + // own group gets set inactive + if (groupMemberOfId) setGroupAsInactive(groupMemberOfId); + // skills & map/player results only update after both teams have reported + if (newSkills) { + addMapResults( + summarizeMaps({ match, members, winners: data.winners }) + ); + addPlayerResults( + summarizePlayerResults({ match, members, winners: data.winners }) + ); + addSkills(newSkills); + cache.delete(USER_SKILLS_CACHE_KEY); + } + // fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played + if (compared === "FIX_PREVIOUS") { + deleteReporterWeaponsByMatchId(matchId); + } + // admin reporting, just set both groups inactive + if (data.adminReport) { + setGroupAsInactive(match.alphaGroupId); + setGroupAsInactive(match.bravoGroupId); + } + })(); + + break; + } + case "LOOK_AGAIN": { + const previousGroup = groupForMatch(data.previousGroupId); + validate(previousGroup, "Previous group not found"); + + for (const member of previousGroup.members) { + const currentGroup = findCurrentGroupByUserId(member.id); + validate(!currentGroup, "Member is already in a group"); + if (member.id === user.id) { + validate( + member.role === "OWNER", + "You are not the owner of the group" + ); + } + } + + createGroupFromPreviousGroup({ + previousGroupId: data.previousGroupId, + members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })), + }); + + throw redirect(SENDOUQ_PREPARING_PAGE); + } + case "REPORT_WEAPONS": { + const match = notFoundIfFalsy(findMatchById(matchId)); + validate(match.reportedAt, "Match has not been reported yet"); + + if (reportedWeaponsByMatchId(matchId) && !isAdmin(user)) { + return null; + } + + const reportedMaps = match.mapList.reduce( + (acc, cur) => acc + (cur.winnerGroupId ? 1 : 0), + 0 + ); + validate( + reportedMaps === data.weapons.length, + "Not reporting weapons for all maps" + ); + + const groupAlpha = badRequestIfFalsy(groupForMatch(match.alphaGroupId)); + const groupBravo = badRequestIfFalsy(groupForMatch(match.bravoGroupId)); + const users = [ + ...groupAlpha.members.map((m) => m.id), + ...groupBravo.members.map((m) => m.id), + ]; + sql.transaction(() => { + deleteReporterWeaponsByMatchId(matchId); + addReportedWeapons( + match.mapList + .filter((m) => m.winnerGroupId) + .flatMap((matchMap, i) => + data.weapons[i].map((weaponSplId, j) => ({ + groupMatchMapId: matchMap.id, + weaponSplId: weaponSplId as MainWeaponId, + userId: users[j], + })) + ) + ); + })(); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; + +export const loader = ({ params }: LoaderArgs) => { + const matchId = matchIdFromParams(params); + const match = notFoundIfFalsy(findMatchById(matchId)); + + const groupAlpha = groupForMatch(match.alphaGroupId); + invariant(groupAlpha, "Group alpha not found"); + const groupBravo = groupForMatch(match.bravoGroupId); + invariant(groupBravo, "Group bravo not found"); + + return { + match, + groupAlpha, + groupBravo, + reportedWeapons: match.reportedAt + ? reportedWeaponsByMatchId(matchId) + : undefined, + }; +}; + +export default function QMatchPage() { + const user = useUser(); + const isMounted = useIsMounted(); + const { i18n } = useTranslation(); + const data = useLoaderData(); + const [showWeaponsForm, setShowWeaponsForm] = React.useState(false); + const submitScoreFetcher = useFetcher(); + + React.useEffect(() => { + setShowWeaponsForm(false); + }, [data.reportedWeapons]); + + const ownMember = + data.groupAlpha.members.find((m) => m.id === user?.id) ?? + data.groupBravo.members.find((m) => m.id === user?.id); + const canReportScore = Boolean( + !data.match.isLocked && (ownMember || isAdmin(user)) + ); + + const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id) + ? data.groupAlpha + : data.groupBravo.members.some((m) => m.id === user?.id) + ? data.groupBravo + : null; + + const ownTeamReported = Boolean( + data.match.reportedByUserId && + ownGroup?.members.some((m) => m.id === data.match.reportedByUserId) + ); + const showScore = data.match.isLocked || ownTeamReported; + + return ( +
+
+

Match #{data.match.id}

+
+ {isMounted + ? databaseTimestampToDate(data.match.createdAt).toLocaleString( + i18n.language, + { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + } + ) + : // reserve place + "0/0/0 0:00"} +
+
+ {showScore ? ( + <> + + {ownGroup && ownMember && data.match.reportedAt ? ( + w.weaponSplId).join("")} + /> + ) : null} + + ) : null} + {!showWeaponsForm ? ( + <> +
+ + +
+ {!data.match.isLocked ? ( +
+
+ + Read the rules + +
+
+ If needed, contact your opponent on the #match-meetup{" "} + channel of the sendou.ink Discord:{" "} + + {SENDOU_INK_DISCORD_URL} + + . Alpha team hosts. Password should be{" "} + {resolveRoomPass(data.match.id)}. Pool code is{" "} + SENDOUQ +
+
+ ) : null} + + {submitScoreFetcher.data?.error === "different" ? ( +
+ You reported different results than your opponent. Double check + the above is correct and otherwise contact the opponent to fix it + on their side. +
+ ) : null} + + ) : null} +
+ ); +} + +function Score({ reportedAt }: { reportedAt: number }) { + const isMounted = useIsMounted(); + const { i18n } = useTranslation(); + const data = useLoaderData(); + const reporter = + data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ?? + data.groupBravo.members.find((m) => m.id === data.match.reportedByUserId); + + const score = data.match.mapList.reduce( + (acc, cur) => { + if (!cur.winnerGroupId) return acc; + + if (cur.winnerGroupId === data.match.alphaGroupId) { + return [acc[0] + 1, acc[1]]; + } + + return [acc[0], acc[1] + 1]; + }, + [0, 0] + ); + + return ( +
+
{score.join(" - ")}
+ {data.match.isLocked ? ( +
+ Reported by {reporter?.discordName ?? admin} at{" "} + {isMounted + ? databaseTimestampToDate(reportedAt).toLocaleString( + i18n.language, + { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + } + ) + : ""} +
+ ) : ( +
+ SP will be adjusted after opponent confirms the score +
+ )} +
+ ); +} + +function AfterMatchActions({ + ownGroupId, + role, + reportedAt, + showWeaponsForm, + setShowWeaponsForm, +}: { + ownGroupId: number; + role: GroupMember["role"]; + reportedAt: number; + showWeaponsForm: boolean; + setShowWeaponsForm: (show: boolean) => void; +}) { + const user = useUser(); + const { t } = useTranslation(["game-misc"]); + const data = useLoaderData(); + const lookAgainFetcher = useFetcher(); + const weaponsFetcher = useFetcher(); + + const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId); + + const weaponsUsageInitialValue = () => { + if (!data.reportedWeapons) + return playedMaps.map(() => new Array(FULL_GROUP_SIZE * 2).fill(null)); + + const result: MainWeaponId[][] = []; + + const players = [...data.groupAlpha.members, ...data.groupBravo.members]; + for (const matchMap of data.match.mapList.filter((m) => m.winnerGroupId)) { + result.push( + players.map((u) => { + const weaponSplId = data.reportedWeapons?.find( + (rw) => rw.groupMatchMapId === matchMap.id && rw.userId === u.id + )?.weaponSplId; + + invariant(typeof weaponSplId === "number", "weaponSplId is null"); + return weaponSplId; + }) + ); + } + + return result; + }; + const [weaponsUsage, setWeaponsUsage] = React.useState< + (null | MainWeaponId)[][] + >(weaponsUsageInitialValue()); + + const wasReportedInTheLastHour = + databaseTimestampToDate(reportedAt).getTime() > Date.now() - 3600 * 1000; + const showLookAgain = role === "OWNER" && wasReportedInTheLastHour; + + const showWeaponsFormButton = isAdmin(user) || !data.reportedWeapons; + + const winners = playedMaps.map((m) => + m.winnerGroupId === data.match.alphaGroupId ? "ALPHA" : "BRAVO" + ); + + return ( +
+ + + {showLookAgain ? ( + } + state={lookAgainFetcher.state} + _action="LOOK_AGAIN" + > + Look again with same group + + ) : null} + {showWeaponsFormButton ? ( + + ) : null} + + {showWeaponsForm ? ( + + +
+ {playedMaps.map((map, i) => { + return ( +
+ + {i !== 0 ? ( + + ) : null} +
+ {[ + ...data.groupAlpha.members, + ...data.groupBravo.members, + ].map((m, j) => { + return ( + + {j === 0 ? ( + Alpha + ) : null} + {j === FULL_GROUP_SIZE ? ( + Bravo + ) : null} +
+
+ {m.discordName} +
+ { + if (!weapon) return; + + setWeaponsUsage((val) => { + const newVal = [...val]; + newVal[i] = [...newVal[i]]; + newVal[i][j] = Number( + weapon.value + ) as MainWeaponId; + return newVal; + }); + }} + /> +
+
+ ); + })} +
+
+ ); + })} +
+
+ {weaponsUsage.map((match, i) => { + return ( +
+
+ {t(`game-misc:MODE_SHORT_${data.match.mapList[i].mode}`)}{" "} + {t(`game-misc:STAGE_${data.match.mapList[i].stageId}`)} +
+
+ {match.map((weapon, j) => { + return ( + <> + {typeof weapon === "number" ? ( + + ) : ( + + ? + + )} + {j === 3 ?
: null} + + ); + })} +
+
+ ); + })} +
+ {weaponsUsage.flat().some((val) => val === null) ? ( +
+ Report all weapons to submit +
+ ) : ( +
+ + Report weapons + +
+ )} + + ) : null} +
+ ); +} + +function MatchGroup({ + group, + side, +}: { + group: GroupForMatch; + side: "ALPHA" | "BRAVO"; +}) { + return ( +
+

{side}

+
+ {group.team ? ( + + {group.team.avatarUrl ? ( + + ) : null} + {group.team.name} + + ) : null} + {group.members.map((member) => ( + + +
+ {member.discordName} +
+ + ))} +
+
+ ); +} + +function MapList({ + canReportScore, + isResubmission, + fetcher, +}: { + canReportScore: boolean; + isResubmission: boolean; + fetcher: FetcherWithComponents; +}) { + const user = useUser(); + const data = useLoaderData(); + const [adminToggleChecked, setAdminToggleChecked] = React.useState(false); + + const previouslyReportedWinners = isResubmission + ? data.match.mapList + .filter((m) => m.winnerGroupId) + .map((m) => + m.winnerGroupId === data.groupAlpha.id ? "ALPHA" : "BRAVO" + ) + : []; + const [winners, setWinners] = React.useState<("ALPHA" | "BRAVO")[]>( + previouslyReportedWinners + ); + + const newScoresAreDifferent = + !previouslyReportedWinners || + previouslyReportedWinners.length !== winners.length || + previouslyReportedWinners.some((w, i) => w !== winners[i]); + const scoreCanBeReported = + Boolean(matchEndedAtIndex(winners)) && + !data.match.isLocked && + newScoresAreDifferent; + + const allMembers = [ + ...data.groupAlpha.members, + ...data.groupBravo.members, + ].map((m) => m.id); + return ( + + + +
+ {data.match.mapList.map((map, i) => { + return ( + w.groupMatchMapId === map.id) + .sort( + (a, b) => + allMembers.indexOf(a.userId) - + allMembers.indexOf(b.userId) + )} + /> + ); + })} +
+
+ {scoreCanBeReported && isAdmin(user) ? ( +
+ + Report as admin +
+ ) : null} + {scoreCanBeReported ? ( +
+ + + {isResubmission ? "Submit adjusted scores" : "Submit scores"} + +
+ ) : null} +
+ ); +} + +function MapListMap({ + i, + map, + winners, + setWinners, + canReportScore, + weapons, +}: { + i: number; + map: Unpacked["match"]["mapList"]>; + winners: ("ALPHA" | "BRAVO")[]; + setWinners?: (winners: ("ALPHA" | "BRAVO")[]) => void; + canReportScore: boolean; + weapons?: ReportedWeapon[]; +}) { + const data = useLoaderData(); + const { t } = useTranslation(["game-misc", "tournament"]); + + const pickInfo = (source: string) => { + if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker"); + if (source === "BOTH") return t("tournament:pickInfo.both"); + if (source === "DEFAULT") return t("tournament:pickInfo.default"); + + if (source === String(data.match.alphaGroupId)) { + return t("tournament:pickInfo.team.specific", { + team: "Alpha", + }); + } + + return t("tournament:pickInfo.team.specific", { + team: "Bravo", + }); + }; + + const handleReportScore = (i: number, side: "ALPHA" | "BRAVO") => () => { + const newWinners = [...winners]; + newWinners[i] = side; + + // delete any scores that would have been after set ended (can happen when they go back to edit previously reported scores) + + const matchEndedAt = matchEndedAtIndex(newWinners); + + if (matchEndedAt) { + newWinners.splice(matchEndedAt + 1); + } + + setWinners?.(newWinners); + }; + + const scoreCanBeReported = + Boolean(matchEndedAtIndex(winners)) && !data.match.isLocked; + const showWinnerReportRow = (i: number) => { + if (!canReportScore) return false; + + if (i === 0) return true; + + if (scoreCanBeReported && !winners[i]) return false; + + const previous = winners[i - 1]; + return Boolean(previous); + }; + + const winningInfoText = (winnerId: number | null) => { + if (!data.match.isLocked) return null; + + if (!winnerId) + return ( + <> + • Unplayed + + ); + + const winner = winnerId === data.match.alphaGroupId ? "Alpha" : "Bravo"; + + return <>• {winner} won; + }; + + return ( +
+ +
+ +
+
+ {i + 1}) {" "} + {t(`game-misc:STAGE_${map.stageId}`)} +
+
+ {pickInfo(map.source)} {winningInfoText(map.winnerGroupId)} +
+
+
+
+ {weapons ? ( +
+ {weapons.map((w, i) => { + return ( + + + {i === 3 ?
: null} + + ); + })} +
+ ) : null} + {showWinnerReportRow(i) ? ( + { + await animate(el, [{ opacity: 0 }, { opacity: 1 }], { + duration: 300, + }); + el.style.opacity = "1"; + }} + > +
+ +
+ + +
+
+ + +
+
+
+ ) : null} +
+ ); +} + +function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) { + const user = useUser(); + const data = useLoaderData(); + + const ownSide = data.groupAlpha.members.some((m) => m.id === user?.id) + ? "ALPHA" + : "BRAVO"; + + const score = winners.reduce( + (acc, cur) => { + if (cur === "ALPHA") { + return [acc[0] + 1, acc[1]]; + } + + return [acc[0], acc[1] + 1]; + }, + [0, 0] + ); + + const userWon = + ownSide === "ALPHA" ? score[0] > score[1] : score[0] < score[1]; + + return ( +
+ Reporting {score.join("-")} {userWon ? "win" : "loss"} +
+ ); +} diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx new file mode 100644 index 000000000..4bd75ef93 --- /dev/null +++ b/app/features/sendouq/routes/q.preparing.tsx @@ -0,0 +1,158 @@ +import type { + ActionFunction, + LinksFunction, + LoaderArgs, + V2_MetaFunction, +} from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { useFetcher, useLoaderData } from "@remix-run/react"; +import invariant from "tiny-invariant"; +import { Main } from "~/components/Main"; +import { SubmitButton } from "~/components/SubmitButton"; +import { getUser, requireUser } from "~/modules/auth/user.server"; +import type { SendouRouteHandle } from "~/utils/remix"; +import { parseRequestFormData, validate } from "~/utils/remix"; +import { makeTitle } from "~/utils/strings"; +import { assertUnreachable } from "~/utils/types"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PREPARING_PAGE, + navIconUrl, +} from "~/utils/urls"; +import { GroupCard } from "../components/GroupCard"; +import { MemberAdder } from "../components/MemberAdder"; +import { hasGroupManagerPerms } from "../core/groups"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { preparingSchema } from "../q-schemas.server"; +import { groupRedirectLocationByCurrentLocation } from "../q-utils"; +import styles from "../q.css"; +import { addMember } from "../queries/addMember.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findPreparingGroup } from "../queries/findPreparingGroup.server"; +import { groupForMatch } from "../queries/groupForMatch.server"; +import { refreshGroup } from "../queries/refreshGroup.server"; +import { setGroupAsActive } from "../queries/setGroupAsActive.server"; +import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server"; + +export const handle: SendouRouteHandle = { + i18n: ["q"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_PREPARING_PAGE, + type: "IMAGE", + }), +}; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: styles }]; +}; + +export const meta: V2_MetaFunction = () => { + return [{ title: makeTitle("SendouQ") }]; +}; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestFormData({ + request, + schema: preparingSchema, + }); + + const currentGroup = findCurrentGroupByUserId(user.id); + validate(currentGroup, "No group found"); + validate(hasGroupManagerPerms(currentGroup.role), "Can't manage group"); + + switch (data._action) { + case "JOIN_QUEUE": { + validate(currentGroup.status === "PREPARING", "No group preparing"); + + setGroupAsActive(currentGroup.id); + refreshGroup(currentGroup.id); + + return redirect(SENDOUQ_LOOKING_PAGE); + } + case "ADD_TRUSTED": { + const available = trustedPlayersAvailableToPlay(user); + validate( + available.some((u) => u.id === data.id), + "Player not available to play" + ); + + const ownGroupWithMembers = groupForMatch(currentGroup.id); + invariant(ownGroupWithMembers, "No own group found"); + validate( + ownGroupWithMembers.members.length < FULL_GROUP_SIZE, + "Group is full" + ); + + addMember({ + groupId: currentGroup.id, + userId: data.id, + }); + + return null; + } + default: { + assertUnreachable(data); + } + } +}; + +export const loader = async ({ request }: LoaderArgs) => { + const user = await getUser(request); + + const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined; + const redirectLocation = groupRedirectLocationByCurrentLocation({ + group: currentGroup, + currentLocation: "preparing", + }); + + if (redirectLocation) { + throw redirect(redirectLocation); + } + + const ownGroup = findPreparingGroup(currentGroup!.id); + invariant(ownGroup, "No own group found"); + + return { + group: ownGroup, + role: currentGroup!.role, + trustedPlayers: hasGroupManagerPerms(currentGroup!.role) + ? trustedPlayersAvailableToPlay(user!) + : [], + }; +}; + +export default function QPreparingPage() { + const data = useLoaderData(); + const joinQFetcher = useFetcher(); + + return ( +
+
+ +
+ {data.group.members.length < FULL_GROUP_SIZE && + hasGroupManagerPerms(data.role) ? ( + + ) : null} + + + Join the queue + + +
+ ); +} diff --git a/app/features/sendouq/routes/q.rules.tsx b/app/features/sendouq/routes/q.rules.tsx new file mode 100644 index 000000000..5be229fb0 --- /dev/null +++ b/app/features/sendouq/routes/q.rules.tsx @@ -0,0 +1,89 @@ +import type { V2_MetaFunction } from "@remix-run/react"; +import { Main } from "~/components/Main"; +import { makeTitle } from "~/utils/strings"; + +export const meta: V2_MetaFunction = () => { + return [{ title: makeTitle("SendouQ Rules") }]; +}; + +export default function SendouqRules() { + return ( +
+

SendouQ Rules

+

Disconnections

+
+ Each team is allowed one replay per set due to a team member + disconnecting. Replay is only possible if ALL of the following are true: +
    +
  1. + More than half was left in the clock (the clock was 2:30 or higher + at the time of the DC) +
  2. +
  3. + The team without DC's objective counter was at 30 or higher at + the time of the disconnect +
  4. +
  5. Team with the disconnection stopped playing without delay
  6. +
  7. Disconnection was unintentional
  8. +
+ For the replay same weapons and gear must be used by both teams. The + team who fails to do so loses the map. If players disconnect from both + teams a replay can be played without using either team's one replay + for the set (or even if there were no replays left to use from either + team). Host disconnection can be replayed with the same conditions as + above. +
+ +

Subs

+
+ There are no subs. If a player is unavailable to play from either team + then the set must be played with 3 players or forfeited. +
+ +

Alting

+
You can only play with one account.
+ +

Player eligibility

+
+ Players banned by{" "} + + Splatoon Competitive Community Safety + {" "} + are not allowed to participate. Playing with banned players is not + allowed. +
+ +

Time limits

+
+ After a team has all their members in the lobby and has shared the + password with the other team then that team has 15 minutes to + join the lobby. Failing to do so, the match can be started with the + members currently in the room. If a player has problems connecting to + the room it is advised to try switching the host. +
+ +

Spectators

+
There can be spectators if both teams agree to it.
+ +

Intentional losing

+
+ Players are not allowed to intentionally lose a match. This includes + (but is not limited to) tanking your own rank on purpose or boosting + another player's/team's ranking. +
+ +

Discriminatory language

+
+ Any kind of discriminatory language such as using slurs is strictly not + allowed. This rule applies everywhere in SendouQ including (but not + limited to) text chats, voice chats & in-game names. +
+ +

Repercussions

+
+ Players found breaking the rules can lose access to SendouQ and other + sendou.ink features such as tournaments and the Plus Server. +
+
+ ); +} diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx new file mode 100644 index 000000000..a62b2fff7 --- /dev/null +++ b/app/features/sendouq/routes/q.tsx @@ -0,0 +1,688 @@ +import { Flag } from "~/components/Flag"; +import { Main } from "~/components/Main"; +import { useAutoRerender } from "~/hooks/useAutoRerender"; +import styles from "../q.css"; +import { redirect } from "@remix-run/node"; +import type { + LoaderArgs, + ActionFunction, + LinksFunction, + V2_MetaFunction, + SerializeFrom, +} from "@remix-run/node"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; +import { useTranslation } from "~/hooks/useTranslation"; +import { + FULL_GROUP_SIZE, + JOIN_CODE_SEARCH_PARAM_KEY, + MAP_LIST_PREFERENCE_OPTIONS, + SENDOUQ, +} from "../q-constants"; +import { + parseRequestFormData, + validate, + type SendouRouteHandle, +} from "~/utils/remix"; +import { Image, ModeImage } from "~/components/Image"; +import * as React from "react"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import clsx from "clsx"; +import { + LOG_IN_URL, + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + SENDOUQ_PREPARING_PAGE, + SENDOUQ_RULES_PAGE, + SENDOUQ_YOUTUBE_VIDEO, + navIconUrl, + stageImageUrl, +} from "~/utils/urls"; +import { stageIds } from "~/modules/in-game-lists"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { MapPool } from "~/modules/map-pool-serializer"; +import { SubmitButton } from "~/components/SubmitButton"; +import { getUserId, requireUserId } from "~/modules/auth/user.server"; +import { frontPageSchema } from "../q-schemas.server"; +import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; +import { createGroup } from "../queries/createGroup.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { groupRedirectLocationByCurrentLocation, mapPoolOk } from "../q-utils"; +import { ModePreferenceIcons } from "../components/ModePrefenceIcons"; +import { makeTitle } from "~/utils/strings"; +import { currentSeason } from "~/features/mmr"; +import type { RankingSeason } from "~/features/mmr/season"; +import { nextSeason } from "~/features/mmr/season"; +import { useUser } from "~/modules/auth"; +import { Button } from "~/components/Button"; +import { findTeamByInviteCode } from "../queries/findTeamByInviteCode.server"; +import { Alert } from "~/components/Alert"; +import { Dialog } from "~/components/Dialog"; +import { joinListToNaturalString } from "~/utils/arrays"; +import { assertUnreachable } from "~/utils/types"; +import { addMember } from "../queries/addMember.server"; +import { userHasSkill } from "../queries/userHasSkill.server"; +import { FormMessage } from "~/components/FormMessage"; +import { addInitialSkill } from "../queries/addInitialSkill.server"; +import { + DEFAULT_SKILL_HIGH, + DEFAULT_SKILL_LOW, + DEFAULT_SKILL_MID, +} from "~/features/mmr/mmr-constants"; + +export const handle: SendouRouteHandle = { + i18n: ["q"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_PAGE, + type: "IMAGE", + }), +}; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: styles }]; +}; + +export const meta: V2_MetaFunction = () => { + return [ + { title: makeTitle("SendouQ") }, + { + name: "description", + content: + "Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.", + }, + ]; +}; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUserId(request); + const data = await parseRequestFormData({ + request, + schema: frontPageSchema, + }); + + const season = currentSeason(new Date()); + validate(!findCurrentGroupByUserId(user.id), "Already in a group"); + validate(season, "Season is not active"); + + switch (data._action) { + case "JOIN_QUEUE": { + const mapPool = new MapPool(data.mapPool); + validate(mapPoolOk(mapPool), "Invalid map pool"); + + createGroup({ + mapListPreference: data.mapListPreference, + status: data.direct === "true" ? "ACTIVE" : "PREPARING", + userId: user.id, + mapPool, + }); + + return redirect( + data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE + ); + } + case "JOIN_TEAM": { + const code = new URL(request.url).searchParams.get( + JOIN_CODE_SEARCH_PARAM_KEY + ); + + const teamInvitedTo = + code && user ? findTeamByInviteCode(code) : undefined; + validate(teamInvitedTo, "Invite code doesn't match any active team"); + validate(teamInvitedTo.members.length < FULL_GROUP_SIZE, "Team is full"); + + addMember({ + groupId: teamInvitedTo.id, + userId: user.id, + }); + + return redirect( + teamInvitedTo.status === "PREPARING" + ? SENDOUQ_PREPARING_PAGE + : SENDOUQ_LOOKING_PAGE + ); + } + case "SET_INITIAL_SP": { + validate( + !userHasSkill({ userId: user.id, season: season.nth }), + "Already set initial SP" + ); + + const defaultSkill = + data.tier === "higher" + ? DEFAULT_SKILL_HIGH + : data.tier === "default" + ? DEFAULT_SKILL_MID + : DEFAULT_SKILL_LOW; + + addInitialSkill({ + mu: defaultSkill.mu, + season: season.nth, + sigma: defaultSkill.sigma, + userId: user.id, + }); + + return null; + } + default: { + assertUnreachable(data); + } + } +}; + +export const loader = async ({ request }: LoaderArgs) => { + const user = await getUserId(request); + + const redirectLocation = groupRedirectLocationByCurrentLocation({ + group: user ? findCurrentGroupByUserId(user.id) : undefined, + currentLocation: "default", + }); + + if (redirectLocation) { + throw redirect(redirectLocation); + } + + const code = new URL(request.url).searchParams.get( + JOIN_CODE_SEARCH_PARAM_KEY + ); + const teamInvitedTo = code && user ? findTeamByInviteCode(code) : undefined; + + const now = new Date(); + const season = currentSeason(now); + const upcomingSeason = nextSeason(now); + + return { + hasSkill: season + ? userHasSkill({ userId: user!.id, season: season.nth }) + : null, + season, + upcomingSeason, + teamInvitedTo, + }; +}; + +export default function QPage() { + const [dialogOpen, setDialogOpen] = React.useState(true); + const user = useUser(); + const data = useLoaderData(); + const fetcher = useFetcher(); + + return ( +
+ + {data.upcomingSeason ? ( + + ) : null} + {data.season ? ( + <> + {data.hasSkill && data.teamInvitedTo === null ? ( + + Invite code doesn't match any active team + + ) : null} + {data.teamInvitedTo && + data.teamInvitedTo.members.length < FULL_GROUP_SIZE && + data.hasSkill ? ( + setDialogOpen(false)} + members={data.teamInvitedTo.members} + /> + ) : null} + {!data.hasSkill && user ? : null} + {user && data.hasSkill ? ( + <> + + +
+
+

Join the queue!

+ + Rules + +
+ +
+ + +
+ Add team members +
+ No team members in mind yet?
+ + Join the queue directly. + +
+
+
+ + ) : null} + {!user ? ( +
+ +
+ ) : null} + + ) : null} +
+ ); +} + +const countries = [ + { + id: 1, + countryCode: "US", + timeZone: "America/Los_Angeles", + city: "Los Angeles", + }, + { id: 2, countryCode: "US", timeZone: "America/New_York", city: "New York" }, + { id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "Paris" }, + { id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "Tokyo" }, +] as const; +const weekdayFormatter = ({ + timeZone, + locale, +}: { + timeZone: string; + locale: string; +}) => + new Intl.DateTimeFormat([locale], { + timeZone, + weekday: "long", + }); +const clockFormatter = ({ + timeZone, + locale, +}: { + timeZone: string; + locale: string; +}) => + new Intl.DateTimeFormat([locale], { + timeZone, + hour: "numeric", + minute: "numeric", + }); +function Clocks() { + const isMounted = useIsMounted(); + const { i18n } = useTranslation(); + useAutoRerender(); + + return ( +
+ {countries.map((country) => { + return ( +
+
{country.city}
+ +
+ {isMounted + ? weekdayFormatter({ + timeZone: country.timeZone, + locale: i18n.language, + }).format(new Date()) + : // take space + "Monday"} +
+
+ {isMounted + ? clockFormatter({ + timeZone: country.timeZone, + locale: i18n.language, + }).format(new Date()) + : // take space + "0:00 PM"} +
+
+ ); + })} +
+ ); +} + +function JoinTeamDialog({ + open, + close, + members, +}: { + open: boolean; + close: () => void; + members: string[]; +}) { + const fetcher = useFetcher(); + + return ( + + Join group with {joinListToNaturalString(members)}? + + + Join + + + + + ); +} + +function ActiveSeasonInfo({ + season, +}: { + season: SerializeFrom; +}) { + const isMounted = useIsMounted(); + + const starts = new Date(season.starts); + const ends = new Date(season.ends); + + const dateToString = (date: Date) => + date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); + + return ( +
+ Season {season.nth} open{" "} + {isMounted ? ( + + {dateToString(starts)} - {dateToString(ends)} + + ) : null} +
+ ); +} + +function UpcomingSeasonInfo({ + season, +}: { + season: SerializeFrom; +}) { + const isMounted = useIsMounted(); + if (!isMounted) return null; + + const starts = new Date(season.starts); + + const dateToString = (date: Date) => + date.toLocaleString("en-US", { + month: "long", + day: "numeric", + hour: "numeric", + }); + + return ( +
+ It's off-season! +
+ Join Season {season.nth} starting {dateToString(starts)} +
+ ); +} + +function StartRank() { + const fetcher = useFetcher(); + + return ( + +
+ + {["higher", "default", "lower"].map((tier) => { + return ( +
+ + +
+ ); + })} + + Decides your starting SP (MMR). "Higher" is recommended for + Plus Server level players. "Lower" for Low Ink eligible + players. "Default" for everyone else. + + + Setting initial SP is mandatory before you can join SendouQ. + +
+ + Submit + +
+ ); +} + +const MAP_PREFERENCE_LOCAL_STORAGE_KEY = "q_mapPreference"; +function MapPreference() { + const [value, setValue] = React.useState(null); + const { t } = useTranslation(["q"]); + + React.useEffect(() => { + const storedValue = localStorage.getItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY); + if (storedValue) { + setValue(storedValue); + } else { + setValue("NO_PREFERENCE"); + } + }, []); + + return ( +
+ + {MAP_LIST_PREFERENCE_OPTIONS.map((option) => { + return ( +
+ { + setValue(option); + localStorage.setItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY, option); + }} + /> + +
+ ); + })} + {value === "SZ_ONLY" || value === "ALL_MODES_ONLY" ? ( +
+ {t("q:mapListPreference.note", { + optionOne: + value === "SZ_ONLY" + ? t("q:mapListPreference.ALL_MODES_ONLY") + : t("q:mapListPreference.SZ_ONLY"), + optionTwo: + value === "SZ_ONLY" + ? t("q:mapListPreference.PREFER_SZ") + : t("q:mapListPreference.PREFER_ALL_MODES"), + })} +
+ ) : null} +
+ ); +} + +const MAP_POOL_LOCAL_STORAGE_KEY = "q_mapPool"; +function MapPoolSelector() { + const { t } = useTranslation(["game-misc"]); + const [mapPool, setMapPool] = React.useState(new MapPool([])); + + React.useEffect(() => { + try { + const mapPool = localStorage.getItem(MAP_POOL_LOCAL_STORAGE_KEY); + if (mapPool) { + setMapPool(new MapPool(JSON.parse(mapPool))); + } + } catch (e) { + console.error(e); + } + }, []); + + return ( +
+ +
+
+ {rankedModesShort.map((modeShort) => { + return ; + })} +
+ {stageIds.map((stageId) => { + return ( + +
+ +
+
{t(`game-misc:STAGE_${stageId}`)}
+ {rankedModesShort.map((modeShort) => { + const id = `${stageId}-${modeShort}`; + return ( + { + const checked = e.target.checked; + setMapPool((prev) => { + let newMapPool: MapPool; + if (checked) { + newMapPool = new MapPool([ + ...prev.stageModePairs, + { stageId, mode: modeShort }, + ]); + } else { + newMapPool = new MapPool([ + ...prev.stageModePairs.filter( + (pair) => + pair.stageId !== stageId || + pair.mode !== modeShort + ), + ]); + } + + localStorage.setItem( + MAP_POOL_LOCAL_STORAGE_KEY, + JSON.stringify(newMapPool.stageModePairs) + ); + + return newMapPool; + }); + }} + /> + ); + })} +
p.stageId === stageId) + .length <= SENDOUQ.MAX_STAGE_REPEAT_COUNT, + })} + > + max {SENDOUQ.MAX_STAGE_REPEAT_COUNT} +
+
+ ); + })} +
+
+
SENDOUQ.SZ_MAP_COUNT, + "text-success": + mapPool.countMapsByMode("SZ") === SENDOUQ.SZ_MAP_COUNT, + })} + > + {mapPool.countMapsByMode("SZ")}/{SENDOUQ.SZ_MAP_COUNT} +
+
SENDOUQ.OTHER_MODE_MAP_COUNT, + "text-success": + mapPool.countMapsByMode("TC") === SENDOUQ.OTHER_MODE_MAP_COUNT, + })} + > + {mapPool.countMapsByMode("TC")}/{SENDOUQ.OTHER_MODE_MAP_COUNT} +
+
SENDOUQ.OTHER_MODE_MAP_COUNT, + "text-success": + mapPool.countMapsByMode("RM") === SENDOUQ.OTHER_MODE_MAP_COUNT, + })} + > + {mapPool.countMapsByMode("RM")}/{SENDOUQ.OTHER_MODE_MAP_COUNT} +
+
SENDOUQ.OTHER_MODE_MAP_COUNT, + "text-success": + mapPool.countMapsByMode("CB") === SENDOUQ.OTHER_MODE_MAP_COUNT, + })} + > + {mapPool.countMapsByMode("CB")}/{SENDOUQ.OTHER_MODE_MAP_COUNT} +
+
+
+ ); +} diff --git a/app/features/top-search/top-search.css b/app/features/top-search/top-search.css index 5de47240f..4573fa596 100644 --- a/app/features/top-search/top-search.css +++ b/app/features/top-search/top-search.css @@ -11,6 +11,21 @@ text-align: right; } +.placements__table__name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 160px; +} + +.placements__tier-header { + display: flex; + align-items: center; + gap: var(--s-2); + margin-block: var(--s-2); + color: var(--text-lighter); +} + .placements__table__row { background-color: var(--bg-lighter); display: flex; diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 39c53c80b..a712a9f5d 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -10,16 +10,17 @@ import invariant from "tiny-invariant"; import { removeDuplicates } from "~/utils/arrays"; import type { FinalStanding } from "./finalStandings.server"; import type { Rating } from "openskill/dist/types"; -// hacky workaround to stop db from being imported in tests -// eslint-disable-next-line @typescript-eslint/no-restricted-imports import { rate, userIdsToIdentifier } from "~/features/mmr/mmr-utils"; import shuffle from "just-shuffle"; import type { Unpacked } from "~/utils/types"; export interface TournamentSummary { - skills: Omit[]; - mapResultDeltas: MapResult[]; - playerResultDeltas: PlayerResult[]; + skills: Omit< + Skill, + "tournamentId" | "id" | "ordinal" | "season" | "groupMatchId" + >[]; + mapResultDeltas: Omit[]; + playerResultDeltas: Omit[]; tournamentResults: Omit[]; } @@ -44,12 +45,14 @@ export function tournamentSummary({ teams, finalStandings, queryCurrentTeamRating, + queryTeamPlayerRatingAverage, queryCurrentUserRating, }: { results: AllMatchResult[]; teams: TeamsArg; finalStandings: FinalStandingsArg; queryCurrentTeamRating: (identifier: string) => Rating; + queryTeamPlayerRatingAverage: (identifier: string) => Rating; queryCurrentUserRating: (userId: number) => Rating; }): TournamentSummary { const userIdsToTeamId = userIdsToTeamIdRecord(teams); @@ -60,6 +63,7 @@ export function tournamentSummary({ userIdsToTeamId, queryCurrentTeamRating, queryCurrentUserRating, + queryTeamPlayerRatingAverage, }), mapResultDeltas: mapResultDeltas({ results, userIdsToTeamId }), playerResultDeltas: playerResultDeltas({ results, userIdsToTeamId }), @@ -86,6 +90,7 @@ function skills(args: { results: AllMatchResult[]; userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; + queryTeamPlayerRatingAverage: (identifier: string) => Rating; queryCurrentUserRating: (userId: number) => Rating; }) { const result: TournamentSummary["skills"] = []; @@ -168,10 +173,12 @@ function calculateTeamSkills({ results, userIdsToTeamId, queryCurrentTeamRating, + queryTeamPlayerRatingAverage, }: { results: AllMatchResult[]; userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; + queryTeamPlayerRatingAverage: (identifier: string) => Rating; }) { const teamRatings = new Map(); const teamMatchesCount = new Map(); @@ -206,10 +213,16 @@ function calculateTeamSkills({ }); const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers); - const [[ratedWinner], [ratedLoser]] = rate([ - [getTeamRating(winnerTeamIdentifier)], - [getTeamRating(loserTeamIdentifier)], - ]); + const [[ratedWinner], [ratedLoser]] = rate( + [ + [getTeamRating(winnerTeamIdentifier)], + [getTeamRating(loserTeamIdentifier)], + ], + [ + [queryTeamPlayerRatingAverage(winnerTeamIdentifier)], + [queryTeamPlayerRatingAverage(loserTeamIdentifier)], + ] + ); teamRatings.set(winnerTeamIdentifier, ratedWinner); teamRatings.set(loserTeamIdentifier, ratedLoser); @@ -268,8 +281,8 @@ function mapResultDeltas({ }: { results: AllMatchResult[]; userIdsToTeamId: UserIdToTeamId; -}): MapResult[] { - const result: MapResult[] = []; +}): TournamentSummary["mapResultDeltas"] { + const result: TournamentSummary["mapResultDeltas"] = []; const addMapResult = ( mapResult: Pick & { @@ -324,10 +337,12 @@ function playerResultDeltas({ }: { results: AllMatchResult[]; userIdsToTeamId: UserIdToTeamId; -}): PlayerResult[] { - const result: PlayerResult[] = []; +}): TournamentSummary["playerResultDeltas"] { + const result: TournamentSummary["playerResultDeltas"] = []; - const addPlayerResult = (playerResult: PlayerResult) => { + const addPlayerResult = ( + playerResult: TournamentSummary["playerResultDeltas"][number] + ) => { const existingResult = result.find( (r) => r.type === playerResult.type && diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 85ff0686b..23dcf0e9e 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -102,6 +102,7 @@ function summarize({ results }: { results?: AllMatchResult[] } = {}) { ], queryCurrentTeamRating: () => rating(), queryCurrentUserRating: () => rating(), + queryTeamPlayerRatingAverage: () => rating(), }); } diff --git a/app/features/tournament-bracket/queries/addSummary.server.ts b/app/features/tournament-bracket/queries/addSummary.server.ts index 836021b90..6af26f938 100644 --- a/app/features/tournament-bracket/queries/addSummary.server.ts +++ b/app/features/tournament-bracket/queries/addSummary.server.ts @@ -2,6 +2,7 @@ import { sql } from "~/db/sql"; import type { TournamentSummary } from "../core/summarizer.server"; import { ordinal } from "openskill"; import type { Skill } from "~/db/types"; +import { identifierToUserIds } from "~/features/mmr/mmr-utils.server"; const addSkillStm = sql.prepare(/* sql */ ` insert into "Skill" ( @@ -11,7 +12,8 @@ const addSkillStm = sql.prepare(/* sql */ ` "ordinal", "userId", "identifier", - "matchesCount" + "matchesCount", + "season" ) values ( @tournamentId, @@ -20,7 +22,8 @@ const addSkillStm = sql.prepare(/* sql */ ` @ordinal, @userId, @identifier, - @matchesCount + coalesce((select max("matchesCount") from "Skill" where "userId" = @userId or "identifier" = @identifier group by "userId", "identifier"), 0) + @matchesCount + coalesce((select max("matchesCount") from "Skill" where "userId" = @userId or "identifier" = @identifier group by "userId", "identifier"), 0), + @season ) returning * `); @@ -40,14 +43,16 @@ const addMapResultDeltaStm = sql.prepare(/* sql */ ` "stageId", "userId", "wins", - "losses" + "losses", + "season" ) values ( @mode, @stageId, @userId, @wins, - @losses - ) on conflict ("userId", "stageId", "mode") do + @losses, + @season + ) on conflict ("userId", "stageId", "mode", "season") do update set "wins" = "wins" + @wins, @@ -62,7 +67,8 @@ const addPlayerResultDeltaStm = sql.prepare(/* sql */ ` "mapLosses", "setWins", "setLosses", - "type" + "type", + "season" ) values ( @ownerUserId, @otherUserId, @@ -70,8 +76,9 @@ const addPlayerResultDeltaStm = sql.prepare(/* sql */ ` @mapLosses, @setWins, @setLosses, - @type - ) on conflict ("ownerUserId", "otherUserId", "type") do + @type, + @season + ) on conflict ("ownerUserId", "otherUserId", "type", "season") do update set "mapWins" = "mapWins" + @mapWins, @@ -100,9 +107,11 @@ export const addSummary = sql.transaction( ({ tournamentId, summary, + season, }: { tournamentId: number; summary: TournamentSummary; + season: number; }) => { for (const skill of summary.skills) { const insertedSkill = addSkillStm.get({ @@ -113,13 +122,14 @@ export const addSummary = sql.transaction( userId: skill.userId, identifier: skill.identifier, matchesCount: skill.matchesCount, + season, }) as Skill; if (insertedSkill.identifier) { - for (const userIdString of insertedSkill.identifier.split("-")) { + for (const userId of identifierToUserIds(insertedSkill.identifier)) { addSkillTeamUserStm.run({ skillId: insertedSkill.id, - userId: Number(userIdString), + userId, }); } } @@ -132,6 +142,7 @@ export const addSummary = sql.transaction( userId: mapResultDelta.userId, wins: mapResultDelta.wins, losses: mapResultDelta.losses, + season, }); } @@ -144,6 +155,7 @@ export const addSummary = sql.transaction( setWins: playerResultDelta.setWins, setLosses: playerResultDelta.setLosses, type: playerResultDelta.type, + season, }); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 3bfe8759f..21c4d56fe 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -74,7 +74,12 @@ import { tournamentSummary } from "../core/summarizer.server"; import invariant from "tiny-invariant"; import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server"; import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { queryCurrentTeamRating, queryCurrentUserRating } from "~/features/mmr"; +import { + currentSeason, + queryCurrentTeamRating, + queryCurrentUserRating, +} from "~/features/mmr"; +import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server"; export const links: LinksFunction = () => { return [ @@ -165,15 +170,27 @@ export const action: ActionFunction = async ({ params, request }) => { const results = allMatchResultsByTournamentId(tournamentId); invariant(results.length > 0, "No results found"); + // TODO: support tournaments outside of seasons as well as unranked tournaments + const _currentSeason = currentSeason(new Date()); + validate(_currentSeason, "No current season found"); + addSummary({ tournamentId, summary: tournamentSummary({ teams, finalStandings: _finalStandings, results, - queryCurrentTeamRating, - queryCurrentUserRating, + queryCurrentTeamRating: (identifier) => + queryCurrentTeamRating({ identifier, season: _currentSeason.nth }), + queryCurrentUserRating: (userId) => + queryCurrentUserRating({ userId, season: _currentSeason.nth }), + queryTeamPlayerRatingAverage: (identifier) => + queryTeamPlayerRatingAverage({ + identifier, + season: _currentSeason.nth, + }), }), + season: _currentSeason.nth, }); return null; diff --git a/app/hooks/useEventListener.ts b/app/hooks/useEventListener.ts new file mode 100644 index 000000000..212a15548 --- /dev/null +++ b/app/hooks/useEventListener.ts @@ -0,0 +1,82 @@ +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; + +// MediaQueryList Event based useEventListener interface +function useEventListener( + eventName: K, + handler: (event: MediaQueryListEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +// Window Event based useEventListener interface +function useEventListener( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + element?: undefined, + options?: boolean | AddEventListenerOptions +): void; + +// Element Event based useEventListener interface +function useEventListener< + K extends keyof HTMLElementEventMap, + T extends HTMLElement = HTMLDivElement +>( + eventName: K, + handler: (event: HTMLElementEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +// Document Event based useEventListener interface +function useEventListener( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + KM extends keyof MediaQueryListEventMap, + T extends HTMLElement | MediaQueryList | void = void +>( + eventName: KW | KH | KM, + handler: ( + event: + | WindowEventMap[KW] + | HTMLElementEventMap[KH] + | MediaQueryListEventMap[KM] + | Event + ) => void, + element?: RefObject, + options?: boolean | AddEventListenerOptions +) { + // Create a ref that stores handler + const savedHandler = useRef(handler); + + useIsomorphicLayoutEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current ?? window; + + if (!(targetElement && targetElement.addEventListener)) return; + + // Create event listener that calls handler function stored in ref + const listener: typeof handler = (event) => savedHandler.current(event); + + targetElement.addEventListener(eventName, listener, options); + + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, listener, options); + }; + }, [eventName, element, options]); +} + +export { useEventListener }; diff --git a/app/hooks/useIsomorphicLayoutEffect.ts b/app/hooks/useIsomorphicLayoutEffect.ts new file mode 100644 index 000000000..ea6a0bc01 --- /dev/null +++ b/app/hooks/useIsomorphicLayoutEffect.ts @@ -0,0 +1,4 @@ +import { useEffect, useLayoutEffect } from "react"; + +export const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts new file mode 100644 index 000000000..a981d66be --- /dev/null +++ b/app/hooks/useWindowSize.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { useEventListener } from "./useEventListener"; +import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; + +interface WindowSize { + width: number; + height: number; +} + +export function useWindowSize(): WindowSize { + const [windowSize, setWindowSize] = useState({ + width: 0, + height: 0, + }); + + const handleSize = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + useEventListener("resize", handleSize); + + // Set size at the first client-side load + useIsomorphicLayoutEffect(() => { + handleSize(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return windowSize; +} diff --git a/app/modules/map-pool-serializer/map-pool.ts b/app/modules/map-pool-serializer/map-pool.ts index 4e1e0e5ca..742c02462 100644 --- a/app/modules/map-pool-serializer/map-pool.ts +++ b/app/modules/map-pool-serializer/map-pool.ts @@ -95,6 +95,14 @@ export class MapPool { return Object.values(this.parsed).every((stages) => stages.length === 0); } + countMapsByMode(mode: ModeShort): number { + return this.parsed[mode].length; + } + + get length() { + return this.stageModePairs.length; + } + getClonedObject(): MapPoolObject { return clone(this.parsed) as MapPoolObject; } diff --git a/app/modules/tournament-map-list-generator/constants.ts b/app/modules/tournament-map-list-generator/constants.ts index 41c9ec11d..6aad187d1 100644 --- a/app/modules/tournament-map-list-generator/constants.ts +++ b/app/modules/tournament-map-list-generator/constants.ts @@ -1,14 +1,29 @@ import { MapPool } from "../map-pool-serializer"; export const DEFAULT_MAP_POOL = new MapPool([ - { mode: "SZ", stageId: 10 }, - { mode: "SZ", stageId: 1 }, + { mode: "SZ", stageId: 6 }, + { mode: "SZ", stageId: 8 }, + { mode: "SZ", stageId: 9 }, + { mode: "SZ", stageId: 15 }, + { mode: "SZ", stageId: 17 }, + + { mode: "TC", stageId: 1 }, { mode: "TC", stageId: 2 }, - { mode: "TC", stageId: 6 }, + { mode: "TC", stageId: 10 }, + { mode: "TC", stageId: 14 }, + { mode: "TC", stageId: 16 }, + + { mode: "RM", stageId: 0 }, + { mode: "RM", stageId: 3 }, + { mode: "RM", stageId: 9 }, { mode: "RM", stageId: 10 }, - { mode: "RM", stageId: 2 }, + { mode: "RM", stageId: 17 }, + + { mode: "CB", stageId: 0 }, + { mode: "CB", stageId: 1 }, { mode: "CB", stageId: 8 }, - { mode: "CB", stageId: 3 }, + { mode: "CB", stageId: 14 }, + { mode: "CB", stageId: 16 }, ]); export const sourceTypes = ["DEFAULT", "TIEBREAKER", "BOTH"] as const; diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index 1db61da53..a65287dfd 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -5,6 +5,7 @@ import type { RankedModeShort } from "../in-game-lists"; import { rankedModesShort } from "../in-game-lists/modes"; import { MapPool } from "../map-pool-serializer"; import type { TournamentMaplistInput } from "./types"; +import { DEFAULT_MAP_POOL } from "./constants"; const TournamentMapListGenerator = suite("Tournament map list generator"); const TournamentMapListGeneratorOneMode = suite( @@ -31,6 +32,16 @@ const team2Picks = new MapPool([ { mode: "CB", stageId: 2 }, { mode: "CB", stageId: 3 }, ]); +const team2PicksNoOverlap = new MapPool([ + { mode: "SZ", stageId: 11 }, + { mode: "SZ", stageId: 9 }, + { mode: "TC", stageId: 2 }, + { mode: "TC", stageId: 8 }, + { mode: "RM", stageId: 17 }, + { mode: "RM", stageId: 1 }, + { mode: "CB", stageId: 2 }, + { mode: "CB", stageId: 3 }, +]); const tiebreakerPicks = new MapPool([ { mode: "SZ", stageId: 1 }, { mode: "TC", stageId: 11 }, @@ -351,6 +362,72 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => { } }); +TournamentMapListGenerator( + "Calculates all mode maps without tiebreaker", + () => { + const mapList = generateMaps({ + teams: [ + { + id: 1, + maps: team1Picks, + }, + { + id: 2, + maps: team2Picks, + }, + ], + bestOf: 7, + tiebreakerMaps: new MapPool([]), + }); + + // the one map both of them picked + assert.equal(mapList[6].stageId, 7); + assert.equal(mapList[6].mode, "RM"); + } +); + +TournamentMapListGenerator( + "Calculates all mode maps without tiebreaker (no overlap)", + () => { + const mapList = generateMaps({ + teams: [ + { + id: 1, + maps: team1Picks, + }, + { + id: 2, + maps: team2PicksNoOverlap, + }, + ], + bestOf: 7, + tiebreakerMaps: new MapPool([]), + }); + + // default map pool contains the tiebreaker + assert.ok( + DEFAULT_MAP_POOL.stageModePairs.some( + (pair) => + pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode + ) + ); + + // neither teams map pool contains the tiebreaker + assert.not.ok( + team1Picks.stageModePairs.some( + (pair) => + pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode + ) + ); + assert.not.ok( + team2PicksNoOverlap.stageModePairs.some( + (pair) => + pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode + ) + ); + } +); + // TODO: figure out how to handle this // checks for case were there is complete overlap in one mode but not others // which means with forced tiebreaker the map list would become unbalanced @@ -665,5 +742,26 @@ TournamentMapListGeneratorOneMode("Handles one team submitted no maps", () => { } }); +TournamentMapListGeneratorOneMode( + 'Throws if including modes not specified in "modesIncluded"', + () => { + assert.throws(() => + generateMaps({ + teams: [ + { + id: 1, + maps: team1Picks, + }, + { + id: 2, + maps: new MapPool([]), + }, + ], + modesIncluded: ["SZ"], + }) + ); + } +); + TournamentMapListGenerator.run(); TournamentMapListGeneratorOneMode.run(); diff --git a/app/modules/tournament-map-list-generator/tournament-map-list.ts b/app/modules/tournament-map-list-generator/tournament-map-list.ts index 76482ade4..f530a63fe 100644 --- a/app/modules/tournament-map-list-generator/tournament-map-list.ts +++ b/app/modules/tournament-map-list-generator/tournament-map-list.ts @@ -15,11 +15,7 @@ const OPTIMAL_MAPLIST_SCORE = 0; export function createTournamentMapList( input: TournamentMaplistInput ): Array { - invariant( - input.modesIncluded.length === 1 || - input.tiebreakerMaps.stageModePairs.length > 0, - "Must include tiebreaker maps if there are multiple modes" - ); + validateInput(input); const { shuffle } = seededRandom(input.seed); const stages = shuffle(resolveCommonStages()); @@ -43,10 +39,8 @@ export function createTournamentMapList( } const stageList = - mapList.length < input.bestOf - 1 || - // in 1 mode only the tiebreaker is not a thing - tournamentIsOneModeOnly() - ? resolveOneModeOnlyStages() + mapList.length < input.bestOf - 1 || input.tiebreakerMaps.length === 0 + ? resolveStages() : input.tiebreakerMaps.stageModePairs.map((p) => ({ ...p, score: 0, @@ -125,27 +119,53 @@ export function createTournamentMapList( ); } - function resolveOneModeOnlyStages() { - if (utilizeOtherStageIdsInOneModeOnlyTournament()) { + function resolveStages() { + if (utilizeOtherStageIdsWhenNoTiebreaker()) { // no overlap so we need to use a random map for tiebreaker - return shuffle([...stageIds]) - .filter( - (stageId) => - !input.teams[0].maps.hasStage(stageId) && - !input.teams[1].maps.hasStage(stageId) - ) - .map((stageId) => ({ - stageId, - mode: input.modesIncluded[0]!, - score: 0, - source: "TIEBREAKER" as const, - })); + + if (tournamentIsOneModeOnly()) { + return shuffle([...stageIds]) + .filter( + (stageId) => + !input.teams[0].maps.hasStage(stageId) && + !input.teams[1].maps.hasStage(stageId) + ) + .map((stageId) => ({ + stageId, + mode: input.modesIncluded[0]!, + score: 0, + source: "TIEBREAKER" as const, + })); + } else { + return DEFAULT_MAP_POOL.stageModePairs + .filter( + (pair) => + !input.teams[0].maps.has(pair) && !input.teams[1].maps.has(pair) + ) + .map((pair) => ({ + stageId: pair.stageId, + mode: pair.mode, + score: 0, + source: "TIEBREAKER" as const, + })); + } } return stages; } - function utilizeOtherStageIdsInOneModeOnlyTournament() { + function validateInput(input: TournamentMaplistInput) { + invariant( + input.teams.every((t) => + t.maps.stageModePairs.every((pair) => + input.modesIncluded.includes(pair.mode) + ) + ), + "Maps submitted for modes not included in the tournament" + ); + } + + function utilizeOtherStageIdsWhenNoTiebreaker() { if (mapList.length < input.bestOf - 1) return false; if ( @@ -271,8 +291,8 @@ export function createTournamentMapList( } function wouldPreventTiebreaker(stage: StageValidatorInput) { - // tiebreaker always guaranteed if not one mode - if (!tournamentIsOneModeOnly()) return false; + // tiebreaker always guaranteed if maps are explicitly set + if (input.tiebreakerMaps.length > 0) return false; const commonMaps = input.teams[0].maps.stageModePairs.filter( ({ stageId, mode }) => diff --git a/app/root.tsx b/app/root.tsx index c397dbd72..9cc368395 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -249,6 +249,7 @@ export const namespaceJsonsToPreloadObj: Record< team: true, vods: true, art: true, + q: true, }; const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj); diff --git a/app/routes/u.$identifier.tsx b/app/routes/u.$identifier.tsx index 8fa595c4e..df1493dc3 100644 --- a/app/routes/u.$identifier.tsx +++ b/app/routes/u.$identifier.tsx @@ -32,6 +32,7 @@ import { userVodsPage, USER_SEARCH_PAGE, userArtPage, + userSeasonsPage, } from "~/utils/urls"; export const links: LinksFunction = () => { @@ -131,6 +132,7 @@ export default function UserPageLayout() {
{t("header.profile")} + Seasons {isOwnPage && ( {t("actions.edit")} diff --git a/app/routes/u.$identifier/art.tsx b/app/routes/u.$identifier/art.tsx index 7d4a4578e..a4f8c1c7e 100644 --- a/app/routes/u.$identifier/art.tsx +++ b/app/routes/u.$identifier/art.tsx @@ -226,7 +226,7 @@ function AddArtButton({ isArtist }: { isArtist?: boolean }) { } return ( - + {t("art:addArt")} ); diff --git a/app/routes/u.$identifier/seasons.tsx b/app/routes/u.$identifier/seasons.tsx new file mode 100644 index 000000000..ae274d2c0 --- /dev/null +++ b/app/routes/u.$identifier/seasons.tsx @@ -0,0 +1,557 @@ +import type { LoaderArgs, SerializeFrom } from "@remix-run/node"; +import { + Link, + useLoaderData, + useMatches, + useSearchParams, +} from "@remix-run/react"; +import clsx from "clsx"; +import { + ModeImage, + StageImage, + TierImage, + WeaponImage, +} from "~/components/Image"; +import { db } from "~/db"; +import { ordinalToSp } from "~/features/mmr"; +import { seasonAllMMRByUserId } from "~/features/mmr/queries/seasonAllMMRByUserId.server"; +import { seasonObject } from "~/features/mmr/season"; +import { userSkills } from "~/features/mmr/tiered.server"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { notFoundIfFalsy } from "~/utils/remix"; +import { type UserPageLoaderData, userParamsSchema } from "../u.$identifier"; +import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server"; +import { useTranslation } from "~/hooks/useTranslation"; +import { cutToNDecimalPlaces } from "~/utils/number"; +import { + seasonMatchesByUserId, + seasonMatchesByUserIdPagesCount, +} from "~/features/sendouq/queries/seasonMatchesByUserId.server"; +import { sendouQMatchPage, userSeasonsPage } from "~/utils/urls"; +import { Avatar } from "~/components/Avatar"; +import invariant from "tiny-invariant"; +import { Pagination } from "~/components/Pagination"; +import * as React from "react"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { SubNav, SubNavLink } from "~/components/SubNav"; +import { z } from "zod"; +import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server"; +import { stageIds } from "~/modules/in-game-lists"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server"; +import Chart from "~/components/Chart"; + +export const seasonsSearchParamsSchema = z.object({ + page: z.coerce.number().default(1), + info: z.enum(["weapons", "stages", "mates", "enemies"]).default("weapons"), +}); + +export const loader = async ({ params, request }: LoaderArgs) => { + const { identifier } = userParamsSchema.parse(params); + const parsedSearchParams = seasonsSearchParamsSchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams) + ); + const { info, page } = parsedSearchParams.success + ? parsedSearchParams.data + : seasonsSearchParamsSchema.parse({}); + + const user = notFoundIfFalsy(db.users.findByIdentifier(identifier)); + + const { tier } = (await userSkills()).userSkills[user.id] ?? { + approximate: false, + ordinal: 0, + tier: { isPlus: false, name: "IRON" }, + }; + + return { + skills: seasonAllMMRByUserId({ season: 0, userId: user.id }), + tier, + matches: { + value: seasonMatchesByUserId({ season: 0, userId: user.id, page }), + currentPage: page, + pages: seasonMatchesByUserIdPagesCount({ season: 0, userId: user.id }), + }, + info: { + currentTab: info, + stages: + info === "stages" + ? seasonStagesByUserId({ season: 0, userId: user.id }) + : null, + weapons: + info === "weapons" + ? seasonReportedWeaponsByUserId({ season: 0, userId: user.id }) + : null, + players: + info === "enemies" || info === "mates" + ? seasonsMatesEnemiesByUserId({ + season: 0, + userId: user.id, + type: info === "enemies" ? "ENEMY" : "MATE", + }) + : null, + }, + }; +}; + +export default function UserSeasonsPage() { + const data = useLoaderData(); + + const tabLink = (tab: string) => + `?info=${tab}&page=${data.matches.currentPage}`; + + if (data.matches.value.length === 0) { + return ( +
+ This user has not played SendouQ or ranked tournaments yet. +
+ ); + } + + return ( +
+ + {data.skills.length > 0 ? ( +
+ + {data.skills.length >= 3 ? : null} +
+ ) : null} +
+ + + Weapons + + + Stages + + + Teammates + + + Enemies + + +
+ {data.info.weapons ? : null} + {data.info.stages ? : null} + {data.info.players ? : null} +
+
+ +
+ ); +} + +function SeasonHeader() { + const isMounted = useIsMounted(); + const { starts, ends } = seasonObject(0); + + const isDifferentYears = + new Date(starts).getFullYear() !== new Date(ends).getFullYear(); + + return ( +
+

Season 0

+
+ {isMounted ? ( + <> + {new Date(starts).toLocaleString("en", { + day: "numeric", + month: "long", + year: isDifferentYears ? "numeric" : undefined, + })}{" "} + -{" "} + {new Date(ends).toLocaleString("en", { + day: "numeric", + month: "long", + year: "numeric", + })} + + ) : ( + "0" + )} +
+
+ ); +} + +function Rank() { + const data = useLoaderData(); + + const maxOrdinal = Math.max(...data.skills.map((s) => s.ordinal)); + + const peakAndCurrentSame = data.skills[0].ordinal === maxOrdinal; + + return ( +
+ +
+
+ {data.tier.name} + {data.tier.isPlus ? "+" : ""} +
+
+ {ordinalToSp(data.skills[data.skills.length - 1].ordinal)}SP +
+ {!peakAndCurrentSame ? ( +
+ Peak {ordinalToSp(maxOrdinal)}SP +
+ ) : null} +
+
+ ); +} + +const now = new Date(); +function PowerChart() { + const data = useLoaderData(); + + const chartOptions = React.useMemo(() => { + return [ + { + label: "Power", + data: data.skills.map((s) => { + // hack to force shorter bottom axis text + const date = new Date(s.date); + date.setFullYear(now.getFullYear()); + return { + primary: date, + secondary: ordinalToSp(s.ordinal), + }; + }), + }, + ]; + }, [data]); + + return ; +} + +const MIN_DEGREE = 5; +const WEAPONS_TO_SHOW = 9; +function Weapons({ + weapons, +}: { + weapons: NonNullable["info"]["weapons"]>; +}) { + const { t } = useTranslation(["weapons"]); + + const slicedWeapons = weapons.slice(0, WEAPONS_TO_SHOW); + + const totalCount = weapons.reduce((acc, cur) => cur.count + acc, 0); + const percentage = (count: number) => + cutToNDecimalPlaces((count / totalCount) * 100); + const countToDegree = (count: number) => + Math.max((count / totalCount) * 360, MIN_DEGREE); + + const restCount = + totalCount - slicedWeapons.reduce((acc, cur) => cur.count + acc, 0); + const restWeaponsCount = weapons.length - WEAPONS_TO_SHOW; + + return ( +
+ {slicedWeapons.map(({ count, weaponSplId }) => ( + + + + ))} + {restWeaponsCount > 0 ? ( + + +{restWeaponsCount} + + ) : null} +
+ ); +} + +function Stages({ + stages, +}: { + stages: NonNullable["info"]["stages"]>; +}) { + const { t } = useTranslation(["game-misc"]); + return ( +
+ {stageIds.map((id) => { + return ( +
+ + {rankedModesShort.map((mode) => { + const stats = stages[id]?.[mode]; + const winPercentage = stats + ? cutToNDecimalPlaces( + (stats.wins / (stats.wins + stats.losses)) * 100 + ) + : ""; + const infoText = `${t(`game-misc:MODE_SHORT_${mode}`)} ${t( + `game-misc:STAGE_${id}` + )} ${winPercentage}${winPercentage ? "%" : ""}`; + + return ( +
+ + {stats ? ( +
+ {stats.wins}W {stats.losses}L +
+ ) : null} +
+ ); + })} +
+ ); + })} +
+ ); +} + +function Players({ + players, +}: { + players: NonNullable["info"]["players"]>; +}) { + return ( +
+ {players.map((player) => { + const setWinRate = Math.round( + (player.setWins / (player.setWins + player.setLosses)) * 100 + ); + const mapWinRate = Math.round( + (player.mapWins / (player.mapWins + player.mapLosses)) * 100 + ); + return ( +
+ + + {player.user.discordName} + +
= 50, + "text-warning": setWinRate < 50, + })} + > + {setWinRate}% ({mapWinRate}%) +
+
+ {player.setWins} ({player.mapWins}) W +
+
+ {player.setLosses} ({player.mapLosses}) L +
+
+ ); + })} +
+ ); +} + +function WeaponCircle({ + degrees, + children, + count, +}: { + degrees: number; + children: React.ReactNode; + count?: number; +}) { + return ( +
+
+
+
{children}
+
+ {count ?
{count}
: null} +
+ ); +} + +function Matches() { + const isMounted = useIsMounted(); + const data = useLoaderData(); + const [, setSearchParams] = useSearchParams(); + const ref = React.useRef(null); + + const setPage = (page: number) => { + setSearchParams({ page: String(page) }); + }; + + React.useEffect(() => { + ref.current?.scrollIntoView({ + block: "center", + }); + }, [data.matches.currentPage]); + + let lastDayRendered: number | null = null; + return ( +
+
+
+
+ {data.matches.value.map((match) => { + const day = databaseTimestampToDate(match.createdAt).getDate(); + const shouldRenderDateHeader = day !== lastDayRendered; + lastDayRendered = day; + + return ( + +
+ {isMounted + ? databaseTimestampToDate(match.createdAt).toLocaleString( + "en", + { + weekday: "long", + month: "long", + day: "numeric", + } + ) + : "t"} +
+ +
+ ); + })} +
+ setPage(data.matches.currentPage + 1)} + previousPage={() => setPage(data.matches.currentPage - 1)} + setPage={(page) => setPage(page)} + /> +
+
+ ); +} + +function Match({ + match, +}: { + match: SerializeFrom["matches"]["value"][0]; +}) { + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const userPageData = parentRoute.data as UserPageLoaderData; + const userId = userPageData.id; + + const score = match.winnerGroupIds.reduce( + (acc, cur) => [ + acc[0] + (cur === match.alphaGroupId ? 1 : 0), + acc[1] + (cur === match.bravoGroupId ? 1 : 0), + ], + [0, 0] + ); + + // make sure user's team is always on the top + const rows = match.groupAlphaMembers.some((m) => m.id === userId) + ? [ + , + , + ] + : [ + , + , + ]; + + return ( + + {rows} + + ); +} + +function MatchMembersRow({ + score, + members, +}: { + score: number; + members: SerializeFrom< + typeof loader + >["matches"]["value"][0]["groupAlphaMembers"]; +}) { + return ( +
+ {members.map((member) => { + return ( +
+ + + {member.discordName} + + {member.weaponSplId ? ( + + ) : null} +
+ ); + })} +
{score}
+
+ ); +} diff --git a/app/styles/common.css b/app/styles/common.css index 6fb804748..b75261ade 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -475,6 +475,10 @@ dialog::backdrop { margin-block-start: -12px; } +.sub-nav__container__secondary { + margin-block-end: var(--s-2); +} + .sub-nav__link__container { display: flex; max-width: 110px; @@ -500,6 +504,12 @@ dialog::backdrop { white-space: nowrap; } +.sub-nav__link__secondary { + font-size: var(--fonts-xxs); + padding: var(--s-0-5) var(--s-2); + background-color: var(--bg-lighter-solid); +} + .sub-nav__container.compact .sub-nav__link { padding: var(--s-1) var(--s-2); } @@ -512,6 +522,11 @@ dialog::backdrop { visibility: hidden; } +.sub-nav__border-guy__secondary { + height: 2.5px; + background-color: var(--bg-lighter-solid); +} + .sub-nav__link__container.active > .sub-nav__border-guy { visibility: initial; } @@ -1220,6 +1235,21 @@ dialog::backdrop { background-color: var(--theme); } +.tier__container { + display: grid; +} + +.tier__img { + grid-column: 1; + grid-row: 1; +} + +.chart__container { + height: 175px; + background-color: var(--bg-lighter); + border-radius: var(--rounded); +} + #nprogress .bar { margin-top: 3rem !important; background: var(--theme) !important; diff --git a/app/styles/u.css b/app/styles/u.css index 481cde537..3e3b83800 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -283,3 +283,106 @@ font-size: var(--fonts-sm); } } + +.u__season__weapon-container { + display: flex; + align-items: center; + font-size: var(--fonts-sm); + flex-direction: column; + font-weight: var(--bold); + position: relative; + width: 60px; +} + +.u__season__weapon-border__outer { + --degree: 80deg; + --smoothing: 0.5deg; + --color: var(--theme); + + display: block; + content: ""; + height: 60px; + width: 60px; + border-radius: 50%; + background: conic-gradient( + var(--color) var(--degree), + transparent calc(var(--degree) + var(--smoothing)) 100% + ); + position: absolute; +} + +.u__season__weapon-border__outer-static { + height: 60px; + width: 60px; + background-color: var(--bg-lighter); + position: absolute; + border-radius: 50%; +} + +.u__season__weapon-border__inner { + height: 84%; + width: 84%; + top: 8%; + left: 8%; + position: relative; + border-radius: 50%; + background: var(--bg); + display: grid; + place-items: center; +} + +.u__season__weapon-count { + margin-top: 62px; +} + +.u__season__player-name { + width: 64px; + font-size: var(--fonts-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + font-weight: var(--semi-bold); +} + +.u__season__match { + background-color: var(--bg-lighter); + border-radius: var(--rounded); + padding: var(--s-2) var(--s-2-5); + color: var(--text); + transition: ease-in 0.2s background-color; + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.u__season__match:hover { + background-color: var(--theme-transparent); +} + +.u__season__match__user { + display: flex; + flex-direction: column; + font-size: var(--fonts-xs); + width: 65px; + align-items: center; +} + +.u__season__match__user__name { + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.u__season__match__score { + font-size: var(--fonts-xl); + font-weight: var(--bold); + margin-inline: auto; +} + +.u__season__info-container { + background-color: var(--bg-lighter); + padding: var(--s-2-5) var(--s-2); + border-radius: var(--rounded); +} diff --git a/app/styles/utils.css b/app/styles/utils.css index eb76f195f..b6d98553c 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -1,3 +1,7 @@ +.text-xl { + font-size: var(--fonts-xl); +} + .text-lg { font-size: var(--fonts-lg); } @@ -54,6 +58,10 @@ text-transform: uppercase; } +.text-capitalize { + text-transform: capitalize; +} + .fill-success { fill: var(--theme-success); } @@ -86,6 +94,10 @@ border-radius: 100%; } +.font-body { + font-weight: var(--body); +} + .font-semi-bold { font-weight: var(--semi-bold); } @@ -194,6 +206,10 @@ margin-inline-start: var(--s-2); } +.ml-4 { + margin-inline-start: var(--s-4); +} + .mr-auto { margin-inline-end: auto; } @@ -234,7 +250,7 @@ white-space: pre-wrap; } -.whitespace-no-wrap { +.whitespace-nowrap { white-space: nowrap; } @@ -314,6 +330,10 @@ cursor: pointer; } +.line-height-tight { + line-height: 1.3; +} + @media screen and (min-width: 480px) { .mobile-hidden { display: inherit; diff --git a/app/styles/vars.css b/app/styles/vars.css index 86986dc64..b42b0004e 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -2,6 +2,7 @@ html { --bg: #ebebf0; --bg-darker: #f8f8f8; --bg-lighter: rgb(250 250 250); + --bg-lighter-solid: rgb(250 250 250); --bg-lightest: #fff; --bg-light-variation: #fff; --bg-lighter-transparent: hsla(225deg 100% 88% / 50%); @@ -91,6 +92,7 @@ html.dark { --bg: #02011e; --bg-darker: #0a092d; --bg-lighter: rgb(169 138 255 / 10%); + --bg-lighter-solid: #140f34; --bg-lighter-transparent: rgb(64 67 108 / 50%); --bg-light-variation: #a98aff30; --bg-lightest: rgb(169 138 255 / 30%); diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index 397db63c8..6db1e6d03 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -33,6 +33,8 @@ function at(arr: T[], n: number) { } export function joinListToNaturalString(arg: string[], lastSeparator = "and") { + if (arg.length === 1) return arg[0]; + const list = [...arg]; const last = list.pop(); const commaJoined = list.join(", "); diff --git a/app/utils/flip.ts b/app/utils/flip.ts new file mode 100644 index 000000000..41a73e40e --- /dev/null +++ b/app/utils/flip.ts @@ -0,0 +1,19 @@ +// https://github.com/aholachek/react-flip-toolkit/issues/95#issuecomment-546101332 +/** + * Thin wrapper around Element.animate() that returns a Promise + * @param el Element to animate + * @param keyframes The keyframes to use when animating + * @param options Either the duration of the animation or an options argument detailing how the animation should be performed + * @returns A promise that will resolve after the animation completes or is cancelled + */ +export function animate( + el: HTMLElement, + keyframes: Keyframe[] | PropertyIndexedKeyframes, + options?: number | KeyframeAnimationOptions +): Promise { + return new Promise((resolve) => { + const anim = el.animate(keyframes, options); + anim.addEventListener("finish", () => resolve()); + anim.addEventListener("cancel", () => resolve()); + }); +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index b9658259b..9a4d6fede 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -7,6 +7,7 @@ import type { XRankPlacement, User, Art, + GroupMatch, } from "~/db/types"; import type { ModeShort, weaponCategories } from "~/modules/in-game-lists"; import type { @@ -24,6 +25,8 @@ import type { StageBackgroundStyle } from "~/features/map-planner"; import type { ImageUploadType } from "~/features/img-upload"; import { serializeBuild } from "~/features/build-analyzer"; import type { ArtSouce } from "~/features/art"; +import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants"; +import type { TierName } from "~/features/mmr/mmr-constants"; const staticAssetsUrl = ({ folder, @@ -90,6 +93,12 @@ export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator"; export const VODS_PAGE = "/vods"; export const LEADERBOARDS_PAGE = "/leaderboards"; export const LINKS_PAGE = "/links"; +export const SENDOUQ_YOUTUBE_VIDEO = + "https://www.youtube.com/watch?v=XIRNcTFDYzA"; +export const SENDOUQ_PAGE = "/q"; +export const SENDOUQ_RULES_PAGE = "/q/rules"; +export const SENDOUQ_PREPARING_PAGE = "/q/preparing"; +export const SENDOUQ_LOOKING_PAGE = "/q/looking"; export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif"; export const COMMON_PREVIEW_IMAGE = @@ -118,6 +127,16 @@ interface UserLinkArgs { export const userPage = (user: UserLinkArgs) => `/u/${user.customUrl ?? user.discordId}`; +export const userSeasonsPage = ({ + user, + season, +}: { + user: UserLinkArgs; + season?: number; +}) => + `${userPage(user)}/seasons${ + typeof season === "number" ? `?season=${season}` : "" + }`; export const userEditProfilePage = (user: UserLinkArgs) => `${userPage(user)}/edit`; export const userBuildsPage = (user: UserLinkArgs) => @@ -230,6 +249,13 @@ export const tournamentSubsPage = (tournamentId: number) => { return `/to/${tournamentId}/subs`; }; +export const sendouQInviteLink = (inviteCode: string) => + `${SENDOUQ_PAGE}?${JOIN_CODE_SEARCH_PARAM_KEY}=${inviteCode}`; + +export const sendouQMatchPage = (id: GroupMatch["id"]) => { + return `${SENDOUQ_PAGE}/match/${id}`; +}; + export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) => `/maps${eventId ? `?eventId=${eventId}` : ""}`; export const readonlyMapsPage = (eventId: CalendarEvent["id"]) => @@ -292,6 +318,9 @@ export const stageImageUrl = (stageId: StageId) => `/static-assets/img/stages/${stageId}`; export const brandImageUrl = (brand: "tentatek" | "takoroka") => `/static-assets/img/layout/${brand}`; +export const tierImageUrl = (tier: TierName) => + `/static-assets/img/tiers/${tier}`; +export const TIER_PLUS_URL = `/static-assets/img/tiers/plus`; export const stageMinimapImageUrlWithEnding = ({ stageId, mode, diff --git a/migrations/032-q.js b/migrations/032-q.js new file mode 100644 index 000000000..fac039f8c --- /dev/null +++ b/migrations/032-q.js @@ -0,0 +1,200 @@ +module.exports.up = function (db) { + db.transaction(() => { + db.prepare(`delete from "Skill"`).run(); + db.prepare(`create index skill_identifier on "Skill"("identifier")`).run(); + + db.prepare( + /* sql */ `alter table "MapPoolMap" add "groupId" integer` + ).run(); + db.prepare( + `create index map_pool_map_group_id on "MapPoolMap"("groupId")` + ).run(); + + db.prepare( + /* sql */ `alter table "Skill" add "groupMatchId" integer` + ).run(); + db.prepare( + `create index skill_group_match_id on "Skill"("groupMatchId")` + ).run(); + db.prepare( + /* sql */ `alter table "Skill" add "season" integer not null` + ).run(); + + db.prepare(/*sql*/ `drop table "MapResult"`).run(); + db.prepare(/*sql*/ `drop table "PlayerResult"`).run(); + + db.prepare( + /*sql*/ ` + create table "MapResult" ( + "mode" text not null, + "stageId" integer not null, + "userId" integer not null, + "wins" integer not null, + "losses" integer not null, + "season" integer not null, + foreign key ("userId") references "User"("id") on delete cascade, + unique("userId", "stageId", "mode", "season") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index map_result_user_id on "MapResult"("userId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "PlayerResult" ( + "ownerUserId" integer not null, + "otherUserId" integer not null, + "mapWins" integer not null, + "mapLosses" integer not null, + "setWins" integer not null, + "setLosses" integer not null, + "type" text not null, + "season" integer not null, + foreign key ("ownerUserId") references "User"("id") on delete cascade, + foreign key ("otherUserId") references "User"("id") on delete cascade, + unique("ownerUserId", "otherUserId", "type", "season") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index player_result_owner_user_id on "PlayerResult"("ownerUserId")` + ).run(); + db.prepare( + `create index player_result_other_user_id on "PlayerResult"("otherUserId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "Group" ( + "id" integer primary key, + "teamId" integer, + "createdAt" integer default (strftime('%s', 'now')) not null, + "latestActionAt" integer default (strftime('%s', 'now')) not null, + "mapListPreference" text not null, + "inviteCode" text not null, + "status" text not null, + foreign key ("teamId") references "AllTeam"("id") on delete restrict + ) strict + ` + ).run(); + + db.prepare(`create index group_team_id on "Group"("teamId")`).run(); + + db.prepare( + /*sql*/ ` + create table "GroupMember" ( + "groupId" integer not null, + "userId" integer not null, + "role" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("userId") references "User"("id") on delete restrict, + foreign key ("groupId") references "Group"("id") on delete cascade, + unique("userId", "groupId") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index group_member_group_id on "GroupMember"("groupId")` + ).run(); + db.prepare( + `create index group_member_user_id on "GroupMember"("userId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "GroupLike" ( + "likerGroupId" integer not null, + "targetGroupId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("likerGroupId") references "Group"("id") on delete cascade, + foreign key ("targetGroupId") references "Group"("id") on delete cascade, + unique("likerGroupId", "targetGroupId") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index group_like_liker_group_id on "GroupLike"("likerGroupId")` + ).run(); + db.prepare( + `create index group_like_target_group_id on "GroupLike"("targetGroupId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "GroupMatch" ( + "id" integer primary key, + "alphaGroupId" integer not null, + "bravoGroupId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + "reportedAt" integer, + "reportedByUserId" integer, + foreign key ("alphaGroupId") references "Group"("id") on delete restrict, + foreign key ("bravoGroupId") references "Group"("id") on delete restrict, + foreign key ("reportedByUserId") references "User"("id") on delete restrict, + unique("alphaGroupId") on conflict rollback, + unique("bravoGroupId") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index group_match_alpha_group_id on "GroupMatch"("alphaGroupId")` + ).run(); + db.prepare( + `create index group_match_bravo_group_id on "GroupMatch"("bravoGroupId")` + ).run(); + db.prepare( + `create index group_match_reported_by_user_id on "GroupMatch"("reportedByUserId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "GroupMatchMap" ( + "id" integer primary key, + "matchId" integer not null, + "index" integer not null, + "mode" text not null, + "stageId" integer not null, + "source" text not null, + "winnerGroupId" integer, + foreign key ("matchId") references "GroupMatch"("id") on delete cascade, + foreign key ("winnerGroupId") references "Group"("id") on delete restrict, + unique("matchId", "index") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index group_match_map_match_id on "GroupMatchMap"("matchId")` + ).run(); + db.prepare( + `create index group_match_map_winner_group_id on "GroupMatchMap"("winnerGroupId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "ReportedWeapon" ( + "groupMatchMapId" integer, + "weaponSplId" integer not null, + "userId" integer not null, + foreign key ("groupMatchMapId") references "GroupMatchMap"("id") on delete restrict, + foreign key ("userId") references "User"("id") on delete restrict, + unique("groupMatchMapId", "userId") on conflict rollback + ) strict + ` + ).run(); + + db.prepare( + `create index reported_weapon_group_match_map_id on "ReportedWeapon"("groupMatchMapId")` + ).run(); + db.prepare( + `create index reported_weapon_user_id on "ReportedWeapon"("userId")` + ).run(); + })(); +}; diff --git a/package-lock.json b/package-lock.json index 9fe4b462e..badd6bdd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "nprogress": "^0.2.0", "openskill": "^3.1.0", "react": "^18.2.0", + "react-charts": "^3.0.0-beta.55", "react-dom": "^18.2.0", "react-flip-toolkit": "^7.1.0", "react-i18next": "^11.18.6", @@ -4373,6 +4374,37 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" }, + "node_modules/@types/d3-array": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.5.tgz", + "integrity": "sha512-Qk7fpJ6qFp+26VeQ47WY0mkwXaiq8+76RJcncDEfMc2ocRzXLO67bLFRNI4OX1aGBoPzsM5Y2T+/m1pldOgD+A==" + }, + "node_modules/@types/d3-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", + "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz", + "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz", + "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "node_modules/@types/debug": { "version": "4.1.7", "dev": true, @@ -4528,6 +4560,11 @@ "version": "15.7.5", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.0.tgz", + "integrity": "sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==" + }, "node_modules/@types/ramda": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.0.tgz", @@ -6347,6 +6384,92 @@ "version": "3.0.11", "license": "MIT" }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "node_modules/d3-delaunay": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-5.3.0.tgz", + "integrity": "sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==", + "dependencies": { + "delaunator": "4" + } + }, + "node_modules/d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz", + "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==" + }, + "node_modules/d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "dependencies": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "node_modules/d3-scale/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-shape": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz", + "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==", + "dependencies": { + "d3-path": "1 - 2" + } + }, + "node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6761,6 +6884,11 @@ "node": ">=4" } }, + "node_modules/delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==" + }, "node_modules/delegates": { "version": "1.0.0", "license": "MIT" @@ -9678,6 +9806,11 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/intl-messageformat": { "version": "10.3.3", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.3.3.tgz", @@ -12940,6 +13073,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-charts": { + "version": "3.0.0-beta.55", + "resolved": "https://registry.npmjs.org/react-charts/-/react-charts-3.0.0-beta.55.tgz", + "integrity": "sha512-PuPGoK/3B4SgmnANqh1+biuJAN/iYAvLO/juLKJ2dtF0+Wzfa0KarGbgAij2o8P83bAn9bZnu46RCevNvYwOjQ==", + "dependencies": { + "@babel/runtime": "^7.14.6", + "@types/d3-array": "^3.0.1", + "@types/d3-scale": "^4.0.1", + "@types/d3-shape": "^3.0.1", + "@types/raf": "^3.4.0", + "@types/react": "^17.0.14", + "@types/react-dom": "^17.0.9", + "d3-array": "^2.12.1", + "d3-delaunay": "5.3.0", + "d3-scale": "^3.3.0", + "d3-shape": "^2.1.0", + "d3-time": "^2.1.1", + "d3-time-format": "^4.1.0", + "ts-toolbelt": "^9.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-charts/node_modules/@types/react": { + "version": "17.0.62", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.62.tgz", + "integrity": "sha512-eANCyz9DG8p/Vdhr0ZKST8JV12PhH2ACCDYlFw6DIO+D+ca+uP4jtEDEpVqXZrh/uZdXQGwk7whJa3ah5DtyLw==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/react-charts/node_modules/@types/react-dom": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", + "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "dependencies": { + "@types/react": "^17" + } + }, "node_modules/react-dom": { "version": "18.2.0", "license": "MIT", @@ -19393,6 +19573,37 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" }, + "@types/d3-array": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.5.tgz", + "integrity": "sha512-Qk7fpJ6qFp+26VeQ47WY0mkwXaiq8+76RJcncDEfMc2ocRzXLO67bLFRNI4OX1aGBoPzsM5Y2T+/m1pldOgD+A==" + }, + "@types/d3-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", + "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==" + }, + "@types/d3-scale": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz", + "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz", + "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "@types/debug": { "version": "4.1.7", "dev": true, @@ -19531,6 +19742,11 @@ "@types/prop-types": { "version": "15.7.5" }, + "@types/raf": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.0.tgz", + "integrity": "sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==" + }, "@types/ramda": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.0.tgz", @@ -20760,6 +20976,91 @@ "csstype": { "version": "3.0.11" }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "d3-delaunay": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-5.3.0.tgz", + "integrity": "sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==", + "requires": { + "delaunator": "4" + } + }, + "d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "1 - 2" + } + }, + "d3-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz", + "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==" + }, + "d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "requires": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + }, + "dependencies": { + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + } + } + }, + "d3-shape": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz", + "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==", + "requires": { + "d3-path": "1 - 2" + } + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -21030,6 +21331,11 @@ } } }, + "delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==" + }, "delegates": { "version": "1.0.0" }, @@ -22943,6 +23249,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "intl-messageformat": { "version": "10.3.3", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.3.3.tgz", @@ -24938,6 +25249,47 @@ "loose-envify": "^1.1.0" } }, + "react-charts": { + "version": "3.0.0-beta.55", + "resolved": "https://registry.npmjs.org/react-charts/-/react-charts-3.0.0-beta.55.tgz", + "integrity": "sha512-PuPGoK/3B4SgmnANqh1+biuJAN/iYAvLO/juLKJ2dtF0+Wzfa0KarGbgAij2o8P83bAn9bZnu46RCevNvYwOjQ==", + "requires": { + "@babel/runtime": "^7.14.6", + "@types/d3-array": "^3.0.1", + "@types/d3-scale": "^4.0.1", + "@types/d3-shape": "^3.0.1", + "@types/raf": "^3.4.0", + "@types/react": "^17.0.14", + "@types/react-dom": "^17.0.9", + "d3-array": "^2.12.1", + "d3-delaunay": "5.3.0", + "d3-scale": "^3.3.0", + "d3-shape": "^2.1.0", + "d3-time": "^2.1.1", + "d3-time-format": "^4.1.0", + "ts-toolbelt": "^9.6.0" + }, + "dependencies": { + "@types/react": { + "version": "17.0.62", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.62.tgz", + "integrity": "sha512-eANCyz9DG8p/Vdhr0ZKST8JV12PhH2ACCDYlFw6DIO+D+ca+uP4jtEDEpVqXZrh/uZdXQGwk7whJa3ah5DtyLw==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", + "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "requires": { + "@types/react": "^17" + } + } + } + }, "react-dom": { "version": "18.2.0", "requires": { diff --git a/package.json b/package.json index dffd2240c..7b83091c6 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "nprogress": "^0.2.0", "openskill": "^3.1.0", "react": "^18.2.0", + "react-charts": "^3.0.0-beta.55", "react-dom": "^18.2.0", "react-flip-toolkit": "^7.1.0", "react-i18next": "^11.18.6", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 96d1bbfca..c38e87239 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -21,6 +21,7 @@ "pages.leaderboards": "Leaderboards", "pages.links": "Links", "pages.art": "Art", + "pages.sendouq": "SendouQ", "header.profile": "Profile", "header.logout": "Log out", diff --git a/public/locales/en/q.json b/public/locales/en/q.json new file mode 100644 index 000000000..214d0a69b --- /dev/null +++ b/public/locales/en/q.json @@ -0,0 +1,8 @@ +{ + "mapListPreference.NO_PREFERENCE": "No preference", + "mapListPreference.PREFER_ALL_MODES": "Prefer all modes", + "mapListPreference.PREFER_SZ": "Prefer SZ only", + "mapListPreference.ALL_MODES_ONLY": "All modes only", + "mapListPreference.SZ_ONLY": "SZ only", + "mapListPreference.note": "Note: you will not be able to match with teams who chose \"{{optionOne}}\". Consider selecting \"{{optionTwo}}\"." +} diff --git a/public/static-assets/img/layout/sendouq.avif b/public/static-assets/img/layout/sendouq.avif new file mode 100644 index 000000000..ee3407139 Binary files /dev/null and b/public/static-assets/img/layout/sendouq.avif differ diff --git a/public/static-assets/img/layout/sendouq.png b/public/static-assets/img/layout/sendouq.png new file mode 100644 index 000000000..7c84bf313 Binary files /dev/null and b/public/static-assets/img/layout/sendouq.png differ diff --git a/public/static-assets/img/tiers/bronze.avif b/public/static-assets/img/tiers/bronze.avif new file mode 100644 index 000000000..95a478835 Binary files /dev/null and b/public/static-assets/img/tiers/bronze.avif differ diff --git a/public/static-assets/img/tiers/bronze.png b/public/static-assets/img/tiers/bronze.png new file mode 100644 index 000000000..7e2347627 Binary files /dev/null and b/public/static-assets/img/tiers/bronze.png differ diff --git a/public/static-assets/img/tiers/diamond.avif b/public/static-assets/img/tiers/diamond.avif new file mode 100644 index 000000000..88928c20a Binary files /dev/null and b/public/static-assets/img/tiers/diamond.avif differ diff --git a/public/static-assets/img/tiers/diamond.png b/public/static-assets/img/tiers/diamond.png new file mode 100644 index 000000000..c0e5de079 Binary files /dev/null and b/public/static-assets/img/tiers/diamond.png differ diff --git a/public/static-assets/img/tiers/gold.avif b/public/static-assets/img/tiers/gold.avif new file mode 100644 index 000000000..5b1ca8523 Binary files /dev/null and b/public/static-assets/img/tiers/gold.avif differ diff --git a/public/static-assets/img/tiers/gold.png b/public/static-assets/img/tiers/gold.png new file mode 100644 index 000000000..f803b997d Binary files /dev/null and b/public/static-assets/img/tiers/gold.png differ diff --git a/public/static-assets/img/tiers/iron.avif b/public/static-assets/img/tiers/iron.avif new file mode 100644 index 000000000..5ff884c05 Binary files /dev/null and b/public/static-assets/img/tiers/iron.avif differ diff --git a/public/static-assets/img/tiers/iron.png b/public/static-assets/img/tiers/iron.png new file mode 100644 index 000000000..165c3dd4a Binary files /dev/null and b/public/static-assets/img/tiers/iron.png differ diff --git a/public/static-assets/img/tiers/leviathan.avif b/public/static-assets/img/tiers/leviathan.avif new file mode 100644 index 000000000..8572e16c0 Binary files /dev/null and b/public/static-assets/img/tiers/leviathan.avif differ diff --git a/public/static-assets/img/tiers/leviathan.png b/public/static-assets/img/tiers/leviathan.png new file mode 100644 index 000000000..ad81cea14 Binary files /dev/null and b/public/static-assets/img/tiers/leviathan.png differ diff --git a/public/static-assets/img/tiers/platinum.avif b/public/static-assets/img/tiers/platinum.avif new file mode 100644 index 000000000..ab8b6d15e Binary files /dev/null and b/public/static-assets/img/tiers/platinum.avif differ diff --git a/public/static-assets/img/tiers/platinum.png b/public/static-assets/img/tiers/platinum.png new file mode 100644 index 000000000..8b8199ee2 Binary files /dev/null and b/public/static-assets/img/tiers/platinum.png differ diff --git a/public/static-assets/img/tiers/plus.avif b/public/static-assets/img/tiers/plus.avif new file mode 100644 index 000000000..927256b52 Binary files /dev/null and b/public/static-assets/img/tiers/plus.avif differ diff --git a/public/static-assets/img/tiers/plus.png b/public/static-assets/img/tiers/plus.png new file mode 100644 index 000000000..c19501811 Binary files /dev/null and b/public/static-assets/img/tiers/plus.png differ diff --git a/public/static-assets/img/tiers/silver.avif b/public/static-assets/img/tiers/silver.avif new file mode 100644 index 000000000..f5673ebce Binary files /dev/null and b/public/static-assets/img/tiers/silver.avif differ diff --git a/public/static-assets/img/tiers/silver.png b/public/static-assets/img/tiers/silver.png new file mode 100644 index 000000000..26da33036 Binary files /dev/null and b/public/static-assets/img/tiers/silver.png differ diff --git a/remix.config.js b/remix.config.js index 8bd29305c..a55859597 100644 --- a/remix.config.js +++ b/remix.config.js @@ -100,9 +100,16 @@ module.exports = { route("/art", "features/art/routes/art.tsx"); route("/art/new", "features/art/routes/art.new.tsx"); + + route("/q", "features/sendouq/routes/q.tsx"); + route("/q/rules", "features/sendouq/routes/q.rules.tsx"); + route("/q/looking", "features/sendouq/routes/q.looking.tsx"); + route("/q/preparing", "features/sendouq/routes/q.preparing.tsx"); + route("/q/match/:id", "features/sendouq/routes/q.match.$id.tsx"); }); }, serverModuleFormat: "cjs", + serverDependenciesToBundle: ["react-charts", "d3-time-format"], future: { v2_meta: true, v2_normalizeFormMethod: true, diff --git a/types/react-i18next.d.ts b/types/react-i18next.d.ts index 082809b32..c1250d8ee 100644 --- a/types/react-i18next.d.ts +++ b/types/react-i18next.d.ts @@ -15,6 +15,7 @@ import type tournament from "../public/locales/en/tournament.json"; import type team from "../public/locales/en/team.json"; import type vods from "../public/locales/en/vods.json"; import type art from "../public/locales/en/art.json"; +import type q from "../public/locales/en/q.json"; declare module "react-i18next" { interface CustomTypeOptions { @@ -35,6 +36,7 @@ declare module "react-i18next" { team: typeof team; vods: typeof vods; art: typeof art; + q: typeof q; }; } }