-
- {entry.placementRank}
+
+ {entry.tier && showTiers ? (
+
+
+ {entry.tier.name}
+ {entry.tier.isPlus ? "+" : ""}
-
-
+ ) : null}
+ {/* TODO: dynamic season */}
+
+
+
+ {entry.placementRank}
+
+
+ {entry.weaponSplId ? (
+
+ ) : null}
+
+ {entry.discordName}
+
+
{entry.power}
-
{discordFullName(entry)}
-
{entry.power}
-
-
+
+
);
})}
diff --git a/app/features/mmr/index.ts b/app/features/mmr/index.ts
index bd8379168..82548fdd0 100644
--- a/app/features/mmr/index.ts
+++ b/app/features/mmr/index.ts
@@ -4,3 +4,5 @@ export {
} from "./mmr-utils.server";
export { rate, userIdsToIdentifier, ordinalToSp } from "./mmr-utils";
+
+export { currentSeason } from "./season";
diff --git a/app/features/mmr/mmr-constants.ts b/app/features/mmr/mmr-constants.ts
new file mode 100644
index 000000000..34212cc33
--- /dev/null
+++ b/app/features/mmr/mmr-constants.ts
@@ -0,0 +1,80 @@
+export const TIERS = [
+ {
+ name: "LEVIATHAN",
+ percentile: 5,
+ },
+ {
+ name: "DIAMOND",
+ percentile: 10,
+ },
+ {
+ name: "PLATINUM",
+ percentile: 15,
+ },
+ {
+ name: "GOLD",
+ percentile: 17.5,
+ },
+ {
+ name: "SILVER",
+ percentile: 20,
+ },
+ {
+ name: "BRONZE",
+ percentile: 17.5,
+ },
+ {
+ name: "IRON",
+ percentile: 15,
+ },
+] as const;
+
+export const TIERS_BEFORE_LEVIATHAN = [
+ {
+ name: "DIAMOND",
+ percentile: 15,
+ },
+ {
+ name: "PLATINUM",
+ percentile: 15,
+ },
+ {
+ name: "GOLD",
+ percentile: 17.5,
+ },
+ {
+ name: "SILVER",
+ percentile: 20,
+ },
+ {
+ name: "BRONZE",
+ percentile: 17.5,
+ },
+ {
+ name: "IRON",
+ percentile: 15,
+ },
+] as const;
+
+export type TierName = (typeof TIERS)[number]["name"];
+
+// won 4 in row vs. equally skilled opponents, about 1200SP
+export const DEFAULT_SKILL_HIGH = {
+ mu: 34.970668845350744,
+ sigma: 7.362186212527989,
+} as const;
+
+// lost 4 in row vs. equally skilled opponents, about 900SP
+export const DEFAULT_SKILL_LOW = {
+ mu: 15.02933115464926,
+ sigma: 7.362186212527989,
+} as const;
+
+// won 2, lost 2 vs. equally skilled opponents, about 1050SP
+export const DEFAULT_SKILL_MID = {
+ mu: 25.189621801205735,
+ sigma: 7.362186212527989,
+} as const;
+
+export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200;
+export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100;
diff --git a/app/features/mmr/mmr-utils.server.ts b/app/features/mmr/mmr-utils.server.ts
index 1c8f97692..aae3d0115 100644
--- a/app/features/mmr/mmr-utils.server.ts
+++ b/app/features/mmr/mmr-utils.server.ts
@@ -2,8 +2,14 @@ import { rating } from "openskill";
import { findCurrentSkillByUserId } from "./queries/findCurrentSkillByUserId.server";
import { findCurrentTeamSkillByIdentifier } from "./queries/findCurrentTeamSkillByIdentifier.server";
-export function queryCurrentUserRating(userId: number) {
- const skill = findCurrentSkillByUserId(userId);
+export function queryCurrentUserRating({
+ userId,
+ season,
+}: {
+ userId: number;
+ season: number;
+}) {
+ const skill = findCurrentSkillByUserId({ userId, season: season ?? null });
if (!skill) {
return rating();
@@ -12,12 +18,46 @@ export function queryCurrentUserRating(userId: number) {
return rating(skill);
}
-export function queryCurrentTeamRating(identifier: string) {
- const skill = findCurrentTeamSkillByIdentifier(identifier);
+export function queryCurrentTeamRating({
+ identifier,
+ season,
+}: {
+ identifier: string;
+ season: number;
+}) {
+ const skill = findCurrentTeamSkillByIdentifier({
+ identifier,
+ season,
+ });
- if (!skill) {
- return rating();
- }
+ if (!skill) return rating();
return rating(skill);
}
+
+export function queryTeamPlayerRatingAverage({
+ identifier,
+ season,
+}: {
+ identifier: string;
+ season: number;
+}) {
+ const playerRatings = identifierToUserIds(identifier).map((userId) =>
+ queryCurrentUserRating({ userId, season })
+ );
+
+ if (playerRatings.length === 0) return rating();
+
+ return {
+ mu:
+ playerRatings.reduce((acc, cur) => acc + cur.mu, 0) /
+ playerRatings.length,
+ sigma:
+ playerRatings.reduce((acc, cur) => acc + cur.sigma, 0) /
+ playerRatings.length,
+ };
+}
+
+export function identifierToUserIds(identifier: string) {
+ return identifier.split("-").map(Number);
+}
diff --git a/app/features/mmr/mmr-utils.ts b/app/features/mmr/mmr-utils.ts
index 4579ffdb4..fa0c91687 100644
--- a/app/features/mmr/mmr-utils.ts
+++ b/app/features/mmr/mmr-utils.ts
@@ -1,21 +1,73 @@
-import { rate as openskillRate } from "openskill";
-import type { Team } from "openskill/dist/types";
+import { rate as openskillRate, ordinal } from "openskill";
+import type { Rating, Team } from "openskill/dist/types";
import invariant from "tiny-invariant";
const TAU = 0.3;
export function ordinalToSp(ordinal: number) {
- return toTwoDecimals(ordinal * 10 + 1000);
+ return toTwoDecimals(ordinal * 15 + 1000);
+}
+
+export function ordinalToRoundedSp(ordinal: number) {
+ return Math.floor(ordinalToSp(ordinal));
}
function toTwoDecimals(value: number) {
return Number(value.toFixed(2));
}
-export function rate(teams: Team[]) {
+export function rate(teams: Team[], secondaryTeams?: [[Rating], [Rating]]) {
+ if (secondaryTeams) return rateConservative(teams, secondaryTeams);
+
return openskillRate(teams, { tau: TAU, preventSigmaIncrease: true });
}
+// when ranking teams we rate the team against the actual team rating that it played against
+// as well as against the average ratings of the players on the team
+// then they get the bigger boost of the two (if won) or the smaller penalty of the two (if lost)
+// this is to avoid situations where teams might unexpectedly lose a huge amount of points
+// due to other team score not being accurate (not enough games played) to their perceived skill level
+function rateConservative(
+ teams: Team[],
+ secondaryTeams: [[Rating], [Rating]]
+): [[Rating], [Rating]] {
+ const [[ordinaryRatingForWinner], [ordinaryRatingForLoser]] = openskillRate(
+ teams,
+ {
+ tau: TAU,
+ preventSigmaIncrease: true,
+ }
+ );
+
+ const [, [conservativeRatingForLoser]] = openskillRate(
+ [secondaryTeams[0], teams[1]],
+ {
+ tau: TAU,
+ preventSigmaIncrease: true,
+ }
+ );
+
+ const [[conservativeRatingForWinner]] = openskillRate(
+ [teams[0], secondaryTeams[1]],
+ {
+ tau: TAU,
+ preventSigmaIncrease: true,
+ }
+ );
+
+ const winnerRating =
+ ordinal(ordinaryRatingForWinner) > ordinal(conservativeRatingForWinner)
+ ? ordinaryRatingForWinner
+ : conservativeRatingForWinner;
+
+ const loserRating =
+ ordinal(ordinaryRatingForLoser) > ordinal(conservativeRatingForLoser)
+ ? ordinaryRatingForLoser
+ : conservativeRatingForLoser;
+
+ return [[winnerRating], [loserRating]];
+}
+
export function userIdsToIdentifier(userIds: number[]) {
invariant(userIds.length === 4, "userIds for identifier must be length 4");
return [...userIds].sort((a, b) => a - b).join("-");
diff --git a/app/features/mmr/queries/findCurrentSkillByUserId.server.ts b/app/features/mmr/queries/findCurrentSkillByUserId.server.ts
index 61a9a7934..3b2425030 100644
--- a/app/features/mmr/queries/findCurrentSkillByUserId.server.ts
+++ b/app/features/mmr/queries/findCurrentSkillByUserId.server.ts
@@ -8,15 +8,21 @@ const stm = sql.prepare(/* sql */ `
from
"Skill"
where
- "userId" = @userId
- and "id" = (
+ "id" = (
select max("id")
from "Skill"
where "userId" = @userId
+ and "season" = @season
group by "userId"
)
`);
-export function findCurrentSkillByUserId(userId: number) {
- return stm.get({ userId }) as Pick
| null;
+export function findCurrentSkillByUserId({
+ userId,
+ season,
+}: {
+ userId: number;
+ season: number;
+}) {
+ return stm.get({ userId, season }) as Pick | null;
}
diff --git a/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts b/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts
index 16ce26582..ef9ca8753 100644
--- a/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts
+++ b/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts
@@ -8,15 +8,21 @@ const stm = sql.prepare(/* sql */ `
from
"Skill"
where
- "identifier" = @identifier
- and "id" = (
+ "id" = (
select max("id")
from "Skill"
where "identifier" = @identifier
+ and "season" = @season
group by "identifier"
)
`);
-export function findCurrentTeamSkillByIdentifier(identifier: string) {
- return stm.get({ identifier }) as Pick | null;
+export function findCurrentTeamSkillByIdentifier({
+ identifier,
+ season,
+}: {
+ identifier: string;
+ season: number;
+}) {
+ return stm.get({ identifier, season }) as Pick | null;
}
diff --git a/app/features/mmr/queries/orderedMMRBySeason.server.ts b/app/features/mmr/queries/orderedMMRBySeason.server.ts
new file mode 100644
index 000000000..0d91fe641
--- /dev/null
+++ b/app/features/mmr/queries/orderedMMRBySeason.server.ts
@@ -0,0 +1,56 @@
+import { sql } from "~/db/sql";
+import type { Skill } from "~/db/types";
+
+const userStm = sql.prepare(/* sql */ `
+ select
+ "Skill"."ordinal",
+ "Skill"."matchesCount",
+ "Skill"."userId"
+ from
+ "Skill"
+ inner join (
+ select "userId", max("id") as "maxId"
+ from "Skill"
+ where "Skill"."season" = @season
+ group by "userId"
+ ) "Latest" on "Skill"."userId" = "Latest"."userId" and "Skill"."id" = "Latest"."maxId"
+ where
+ "Skill"."season" = @season
+ and "Skill"."userId" is not null
+ order by
+ "Skill"."ordinal" desc
+`);
+
+const teamStm = sql.prepare(/* sql */ `
+ select
+ "Skill"."ordinal",
+ "Skill"."matchesCount",
+ "Skill"."identifier"
+ from
+ "Skill"
+ inner join (
+ select "identifier", max("id") as "maxId"
+ from "Skill"
+ where "Skill"."season" = @season
+ group by "identifier"
+ ) "Latest" on "Skill"."identifier" = "Latest"."identifier" and "Skill"."id" = "Latest"."maxId"
+ where
+ "Skill"."season" = @season
+ and "Skill"."identifier" is not null
+ order by
+ "Skill"."ordinal" desc
+`);
+
+export function orderedMMRBySeason({
+ season,
+ type,
+}: {
+ season: number;
+ type: "team" | "user";
+}) {
+ const stm = type === "team" ? teamStm : userStm;
+
+ return stm.all({ season }) as Array<
+ Pick
+ >;
+}
diff --git a/app/features/mmr/queries/seasonAllMMRByUserId.server.ts b/app/features/mmr/queries/seasonAllMMRByUserId.server.ts
new file mode 100644
index 000000000..03c5b29e8
--- /dev/null
+++ b/app/features/mmr/queries/seasonAllMMRByUserId.server.ts
@@ -0,0 +1,38 @@
+import { sql } from "~/db/sql";
+import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
+
+const stm = sql.prepare(/* sql */ `
+ select
+ max("Skill"."ordinal") as "ordinal",
+ date(
+ coalesce("GroupMatch"."createdAt", "CalendarEventDate"."startTime"), 'unixepoch'
+ ) as "date"
+ from
+ "Skill"
+ left join "GroupMatch" on "GroupMatch"."id" = "Skill"."groupMatchId"
+ left join "Tournament" on "Tournament"."id" = "Skill"."tournamentId"
+ -- TODO: support tournament having many start dates
+ left join "CalendarEvent" on "Tournament"."id" = "CalendarEvent"."tournamentId"
+ left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
+ where
+ "Skill"."userId" = @userId
+ and "Skill"."season" = @season
+ and "Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD}
+ and ("GroupMatch"."id" is not null or "Tournament"."id" is not null)
+ group by "date"
+ order by "date" asc
+`);
+
+export function seasonAllMMRByUserId({
+ userId,
+ season,
+}: {
+ userId: number;
+ season: number;
+}) {
+ return stm.all({ userId, season }) as Array<{
+ ordinal: number;
+ date: string;
+ isMostRecent: number;
+ }>;
+}
diff --git a/app/features/mmr/season.ts b/app/features/mmr/season.ts
new file mode 100644
index 000000000..8de88321f
--- /dev/null
+++ b/app/features/mmr/season.ts
@@ -0,0 +1,59 @@
+const SEASONS =
+ process.env.NODE_ENV === "development"
+ ? ([
+ {
+ nth: 0,
+ starts: new Date("2020-08-14T15:00:00.000Z"),
+ ends: new Date("2029-08-27T20:59:59.999Z"),
+ },
+ ] as const)
+ : ([
+ {
+ nth: 0,
+ starts: new Date("2023-08-14T17:00:00.000Z"),
+ ends: new Date("2023-08-27T20:59:59.999Z"),
+ },
+ ] as const);
+
+export type RankingSeason = (typeof SEASONS)[number];
+
+export function previousOrCurrentSeason(date: Date) {
+ const _currentSeason = currentSeason(date);
+ if (_currentSeason) return _currentSeason;
+
+ let latestPreviousSeason;
+ for (const season of SEASONS) {
+ if (date >= season.ends) latestPreviousSeason = season;
+ }
+
+ return latestPreviousSeason;
+}
+
+export function currentSeason(date: Date) {
+ for (const season of SEASONS) {
+ if (date >= season.starts && date <= season.ends) return season;
+ }
+
+ return null;
+}
+
+export function nextSeason(date: Date) {
+ for (const season of SEASONS) {
+ if (date < season.starts) return season;
+ }
+
+ return null;
+}
+
+export function seasonObject(nth: number) {
+ return SEASONS[nth];
+}
+
+export function allSeasons(date: Date) {
+ const startedSeasons = SEASONS.filter((s) => date >= s.starts);
+ if (startedSeasons.length > 0) {
+ return startedSeasons.map((s) => s.nth).reverse();
+ }
+
+ return [0];
+}
diff --git a/app/features/mmr/tiered.server.ts b/app/features/mmr/tiered.server.ts
new file mode 100644
index 000000000..a43f04dc4
--- /dev/null
+++ b/app/features/mmr/tiered.server.ts
@@ -0,0 +1,147 @@
+import {
+ TIERS,
+ USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
+ type TierName,
+ TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
+ TIERS_BEFORE_LEVIATHAN,
+} from "./mmr-constants";
+import type { Skill } from "~/db/types";
+import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
+import { orderedMMRBySeason } from "./queries/orderedMMRBySeason.server";
+import { currentSeason } from "./season";
+import { cachified } from "cachified";
+import { cache, ttl } from "~/utils/cache.server";
+import { HALF_HOUR_IN_MS, ONE_HOUR_IN_MS } from "~/constants";
+import { USER_SKILLS_CACHE_KEY } from "../sendouq/q-constants";
+
+export interface TieredSkill {
+ ordinal: number;
+ tier: {
+ name: TierName;
+ isPlus: boolean;
+ };
+ approximate: boolean;
+}
+
+export function freshUserSkills(): {
+ userSkills: Record;
+ intervals: SkillTierInterval[];
+} {
+ const points = orderedMMRBySeason({
+ season: currentSeason(new Date())!.nth,
+ type: "user",
+ });
+
+ const tierIntervals = skillTierIntervals(points, "user");
+
+ return {
+ intervals: tierIntervals,
+ userSkills: Object.fromEntries(
+ points.map((p) => {
+ const { name, isPlus } = tierIntervals.find(
+ (t) => t.neededOrdinal! <= p.ordinal
+ ) ?? { name: "IRON", isPlus: false };
+ return [
+ p.userId as number,
+ {
+ ordinal: p.ordinal,
+ tier: { name, isPlus },
+ approximate: p.matchesCount < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
+ },
+ ];
+ })
+ ),
+ };
+}
+
+export async function userSkills() {
+ const cachedSkills = await cachified({
+ key: USER_SKILLS_CACHE_KEY,
+ cache,
+ ttl: ttl(HALF_HOUR_IN_MS),
+ staleWhileRevalidate: ttl(ONE_HOUR_IN_MS),
+ getFreshValue() {
+ return freshUserSkills();
+ },
+ });
+
+ // TODO: this can be removed after Season 0 has been kicked off
+ if (Object.keys(cachedSkills.userSkills).length < 10) {
+ return freshUserSkills();
+ }
+
+ return cachedSkills;
+}
+
+export type SkillTierInterval = ReturnType[number];
+
+function skillTierIntervals(
+ orderedPoints: Array>,
+ type: "user" | "team"
+) {
+ const LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN =
+ type === "user"
+ ? USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN
+ : TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
+ let points = orderedPoints.filter(
+ (p) => p.matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD
+ );
+ const hasLeviathan = points.length >= LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
+ if (!hasLeviathan) {
+ // using all entries, no matter if they have enough to be on the leaderboard
+ // to create the tiers
+ points = orderedPoints;
+ }
+
+ const totalPlayers = points.length;
+
+ const tiersToUse = hasLeviathan ? TIERS : TIERS_BEFORE_LEVIATHAN;
+
+ const allTiers = tiersToUse.flatMap((tier) =>
+ [true, false].map((isPlus) => ({
+ ...tier,
+ isPlus,
+ percentile: tier.percentile / 2,
+ }))
+ );
+ const result: Array<{
+ name: TierName;
+ isPlus: boolean;
+ /** inclusive */
+ neededOrdinal?: number;
+ }> = [
+ {
+ name: tiersToUse[0].name,
+ isPlus: true,
+ },
+ ];
+
+ if (points.length === 1) {
+ result[0].neededOrdinal = points[0].ordinal;
+ return result;
+ }
+
+ let previousPercentiles = 0;
+ for (let i = 0; i < points.length; i++) {
+ const currentTier = allTiers[result.length - 1];
+ const currentPercentile = ((i + 1) / totalPlayers) * 100;
+
+ // "isPlus" is top 50% of that tier
+ const accPercentile = previousPercentiles + currentTier.percentile;
+
+ if (currentPercentile > accPercentile) {
+ const previousPoints = points[i - 1];
+ const thisTier = result[result.length - 1];
+ thisTier.neededOrdinal = previousPoints.ordinal;
+
+ const newTier = allTiers[result.length];
+ result.push({
+ name: newTier.name,
+ isPlus: newTier.isPlus,
+ });
+ previousPercentiles = accPercentile;
+ }
+ }
+
+ return result;
+}
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
new file mode 100644
index 000000000..46d4ee8c3
--- /dev/null
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -0,0 +1,195 @@
+import { Link, useFetcher } from "@remix-run/react";
+import clsx from "clsx";
+import * as React from "react";
+import { Flipped } from "react-flip-toolkit";
+import { Avatar } from "~/components/Avatar";
+import { Button } from "~/components/Button";
+import { FormWithConfirm } from "~/components/FormWithConfirm";
+import { TierImage, WeaponImage } from "~/components/Image";
+import { SubmitButton } from "~/components/SubmitButton";
+import { ArrowsPointingInIcon } from "~/components/icons/ArrowsPointingIn";
+import { StarFilledIcon } from "~/components/icons/StarFilled";
+import UndoIcon from "~/components/icons/Undo";
+import { UsersIcon } from "~/components/icons/Users";
+import type { Group, GroupMember as GroupMemberType } from "~/db/types";
+import { SENDOUQ_LOOKING_PAGE, userPage } from "~/utils/urls";
+import { FULL_GROUP_SIZE } from "../q-constants";
+import type { LookingGroup } from "../q-types";
+import { ModePreferenceIcons } from "./ModePrefenceIcons";
+import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
+
+export function GroupCard({
+ group,
+ action,
+ mapListPreference,
+ ownRole,
+ ownGroup = false,
+}: {
+ group: LookingGroup;
+ action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
+ mapListPreference?: Group["mapListPreference"];
+ ownRole?: GroupMemberType["role"];
+ ownGroup?: boolean;
+}) {
+ const fetcher = useFetcher();
+
+ return (
+
+
+ {mapListPreference ? (
+
+ ) : null}
+
+ {group.members?.map((member) => {
+ return (
+
+
+
+
+ {member.weapons?.map((weapon) => {
+ return (
+
+ );
+ })}
+
+ {member.skill ? (
+
+
+ {!member.skill.approximate ? (
+ <>{ordinalToRoundedSp(member.skill.ordinal)}SP>
+ ) : null}
+
+ ) : null}
+
+
+ );
+ })}
+ {!group.members
+ ? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
+ return (
+
+ ?
+
+ );
+ })
+ : null}
+
+ {group.tier ? (
+
+
+ {group.tier.name}
+ {group.tier.isPlus ? "+" : ""}
+
+ ) : null}
+ {action && (ownRole === "OWNER" || ownRole === "MANAGER") ? (
+
+
+
+ ) : action === "LIKE" ? (
+
+ ) : action === "GROUP_UP" ? (
+
+ ) : (
+
+ )
+ }
+ >
+ {action === "MATCH_UP"
+ ? "Start match"
+ : action === "LIKE" && !group.members
+ ? "Challenge"
+ : action === "LIKE"
+ ? "Ask to play"
+ : action === "GROUP_UP"
+ ? "Group up"
+ : "Undo"}
+
+
+ ) : null}
+ {ownGroup ? (
+
+
+
+ ) : 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 ? (
+ }
+ onClick={() => setShowWeaponsForm(!showWeaponsForm)}
+ >
+ Report used weapons
+
+ ) : 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}
+
+
+
{
+ 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:
+
+ -
+ More than half was left in the clock (the clock was 2:30 or higher
+ at the time of the DC)
+
+ -
+ The team without DC's objective counter was at 30 or higher at
+ the time of the disconnect
+
+ - Team with the disconnection stopped playing without delay
+ - Disconnection was unintentional
+
+ 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
+
+
+ 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 (
+
+ );
+}
+
+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 (
+
+
+
+ {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;
};
}
}