From d3df5bdda13cd0153f7015ec423d866f97e2ac09 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 13 Apr 2022 18:45:13 +0300 Subject: [PATCH] Allow canceling SendouQ match --- app/components/play/MapList.tsx | 97 ++++++++++++++++++++++++++++-- app/components/play/MatchTeams.tsx | 2 + app/core/mmr/utils.ts | 17 ++++++ app/models/LFGMatch.server.ts | 77 ++++++++++++++++++++---- app/routes/play/match.$id.tsx | 58 +++++++++++++++--- app/styles/global.css | 12 ++++ app/styles/play-match.css | 36 ++++++++++- app/utils/index.ts | 8 ++- scripts/recalculateSkills.ts | 2 +- 9 files changed, 283 insertions(+), 26 deletions(-) diff --git a/app/components/play/MapList.tsx b/app/components/play/MapList.tsx index e73c1a04b..88faa6c4d 100644 --- a/app/components/play/MapList.tsx +++ b/app/components/play/MapList.tsx @@ -1,9 +1,12 @@ import { Mode } from "@prisma/client"; import clsx from "clsx"; import clone from "just-clone"; -import { useState } from "react"; -import { Form } from "remix"; +import * as React from "react"; +import { Form, useLoaderData } from "remix"; import { scoreValid } from "~/core/play/validators"; +import { LFGMatchLoaderData } from "~/routes/play/match.$id"; +import { userFullDiscordName } from "~/utils"; +import { Button } from "../Button"; import { ModeImage } from "../ModeImage"; import { SubmitButton } from "../SubmitButton"; @@ -27,7 +30,8 @@ export function MapList({ canSubmitScore, groupIds, }: MapListProps) { - const [winners, setWinners] = useState(reportedWinnerIds); + const [winners, setWinners] = React.useState(reportedWinnerIds); + const [cancelModeEnabled, setCancelModeEnabled] = React.useState(false); const updateWinners = (winnerId: string, index: number) => { const newWinners = clone(winners); @@ -53,6 +57,11 @@ export function MapList({ return false; }; + if (cancelModeEnabled) + return ( + setCancelModeEnabled(false)} /> + ); + return (

    Map list

    @@ -90,6 +99,7 @@ export function MapList({ winners={winners} groupIds={groupIds} isFirstTimeReporting={reportedWinnerIds.length === 0} + enableCancelMode={() => setCancelModeEnabled(true)} /> )}
@@ -101,6 +111,7 @@ function Submitter({ winners, groupIds, isFirstTimeReporting, + enableCancelMode, }: { mapList: MapListProps["mapList"]; winners: string[]; @@ -109,13 +120,30 @@ function Submitter({ their: string; }; isFirstTimeReporting: boolean; + enableCancelMode: () => void; }) { const warningText = scoreValid(winners, mapList.length) ? undefined : "Report more maps to submit the score"; if (warningText) { - return
{warningText}
; + return ( +
+ {warningText} +
+
+ or{" "} + +
+
+
+ ); } const score = winners.reduce( @@ -147,3 +175,64 @@ function Submitter({ ); } + +function CancelMatch({ disableCancelMode }: { disableCancelMode: () => void }) { + const data = useLoaderData(); + + return ( +
+

Cancel match

+

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

+

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

+
+

Choose missing player

+
+ {data.groups + .flatMap((g) => g.members) + .map((m) => ( + + + + + ))} +
+
+ + +
+
+
+ ); +} diff --git a/app/components/play/MatchTeams.tsx b/app/components/play/MatchTeams.tsx index 9b015142b..3d2c22571 100644 --- a/app/components/play/MatchTeams.tsx +++ b/app/components/play/MatchTeams.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import { useLoaderData } from "remix"; import { LFGMatchLoaderData } from "~/routes/play/match.$id"; +import { userFullDiscordName } from "~/utils"; import { weaponsInGameOrder } from "~/utils/sorters"; import { oldSendouInkPlayerProfile, @@ -115,6 +116,7 @@ export function MatchTeams() { })} target="_blank" rel="noopener noreferrer" + title={userFullDiscordName(user)} >
diff --git a/app/core/mmr/utils.ts b/app/core/mmr/utils.ts index 8873eac2d..3a262f2f0 100644 --- a/app/core/mmr/utils.ts +++ b/app/core/mmr/utils.ts @@ -116,6 +116,23 @@ export function adjustSkills({ ]; } +export function adjustSkillsWithCancel({ + skills, + playerIds, + noUpdateUserIds, +}: { + skills: AdjustSkill[]; + playerIds: { + winning: string[]; + losing: string[]; + }; + noUpdateUserIds: string[]; +}) { + const allAdjusted = adjustSkills({ skills, playerIds }); + + return allAdjusted.filter((skill) => !noUpdateUserIds.includes(skill.userId)); +} + export function resolveOwnMMR({ skills, user, diff --git a/app/models/LFGMatch.server.ts b/app/models/LFGMatch.server.ts index a1b0bff82..60c8b1853 100644 --- a/app/models/LFGMatch.server.ts +++ b/app/models/LFGMatch.server.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; -import { adjustSkills } from "~/core/mmr/utils"; +import { adjustSkills, adjustSkillsWithCancel } from "~/core/mmr/utils"; import { db } from "~/utils/db.server"; +import * as Skill from "~/models/Skill.server"; export type FindById = Prisma.PromiseReturnType; export function findById(id: string) { @@ -8,6 +9,7 @@ export function findById(id: string) { where: { id }, select: { createdAt: true, + cancelCausingUserId: true, stages: { select: { id: true, @@ -144,20 +146,19 @@ export async function reportScore({ groupIds, }: ReportScoreArgs) { const allPlayerIds = [...playerIds.winning, ...playerIds.losing]; - const skills = await db.skill.findMany({ - where: { userId: { in: allPlayerIds } }, - orderBy: { - createdAt: "desc", - }, - distinct: "userId", - }); + const skills = await Skill.findMostRecentByUserIds(allPlayerIds); const adjustedSkills = adjustSkills({ skills, playerIds }); return db.$transaction([ - db.skill.createMany({ - data: adjustedSkills.map((s) => ({ ...s, matchId: UNSAFE_matchId })), - }), + Skill.createMany( + adjustedSkills.map((s) => ({ + ...s, + matchId: UNSAFE_matchId, + tournamentId: null, + amountOfSets: null, + })) + ), db.lfgGroup.updateMany({ where: { id: { @@ -211,3 +212,57 @@ function insertScores({ where lfg2.lfg_group_match_id = lfg."lfgGroupMatchId" and lfg2.order = lfg.order; `); } + +export async function cancel({ + matchId, + cancelCausingUserId, + groupIds, + playerIds, +}: { + matchId: string; + cancelCausingUserId: string; + groupIds: string[]; + playerIds: { + winning: string[]; + losing: string[]; + }; +}) { + const allPlayerIds = [...playerIds.winning, ...playerIds.losing]; + const skills = await Skill.findMostRecentByUserIds(allPlayerIds); + + const adjustedSkills = adjustSkillsWithCancel({ + skills, + playerIds, + // if someone quits / is a no show we don't punish their + // teammates for that but they get no change to skill. + // Winners still get their raised points + noUpdateUserIds: playerIds.losing.filter( + (id) => id !== cancelCausingUserId + ), + }); + + return db.$transaction([ + Skill.createMany( + adjustedSkills.map((s) => ({ + ...s, + matchId, + tournamentId: null, + amountOfSets: null, + })) + ), + db.lfgGroup.updateMany({ + where: { + id: { + in: groupIds, + }, + }, + data: { + status: "INACTIVE", + }, + }), + db.lfgGroupMatch.update({ + where: { id: matchId }, + data: { cancelCausingUserId }, + }), + ]); +} diff --git a/app/routes/play/match.$id.tsx b/app/routes/play/match.$id.tsx index ebbf999fe..19416ea85 100644 --- a/app/routes/play/match.$id.tsx +++ b/app/routes/play/match.$id.tsx @@ -95,6 +95,10 @@ const matchActionSchema = z.union([ .max(LFG_AMOUNT_OF_STAGES_TO_GENERATE) ), }), + z.object({ + _action: z.literal("CANCEL_MATCH"), + cancelCausingUserId: z.string().uuid(), + }), ]); export type MatchActionData = { @@ -117,6 +121,10 @@ export const action: ActionFunction = async ({ const match = await LFGMatch.findById(params.id); invariant(match, "Match is undefined"); + const matchWasAlreadyReported = + Boolean(match.cancelCausingUserId) || + match.stages.some((stage) => stage.winnerGroupId); + let ownGroup = match.groups.find((g) => g.members.some((m) => m.memberId === user.id) ); @@ -131,9 +139,6 @@ export const action: ActionFunction = async ({ switch (data._action) { case "REPORT_SCORE": { validateIsGroupAdmin(); - const matchWasAlreadyReported = match.stages.some( - (stage) => stage.winnerGroupId - ); if (matchWasAlreadyReported) { // just don't do anything if they report same as someone else before them // to user it looks identical to if they were the first to submit @@ -230,6 +235,33 @@ export const action: ActionFunction = async ({ }); return redirect(sendouQAddPlayersPage()); } + case "CANCEL_MATCH": { + validateIsGroupAdmin(); + if (matchWasAlreadyReported) { + // most likely user won't know their request didn't do anything + // since someone else did the same already + return { ok: "CANCEL_MATCH" }; + } + + const losingGroup = match.groups.find((g) => + g.members.some((m) => m.memberId === data.cancelCausingUserId) + ); + validate(losingGroup, "Invalid cancelCausingUserId"); + const winningGroup = match.groups.find((g) => g.id !== losingGroup.id); + invariant(winningGroup, "!winnerGroup"); + + await LFGMatch.cancel({ + matchId: params.id, + cancelCausingUserId: data.cancelCausingUserId, + groupIds: match.groups.map((g) => g.id), + playerIds: { + losing: losingGroup.members.map((m) => m.memberId), + winning: winningGroup.members.map((m) => m.memberId), + }, + }); + + return { ok: "CANCEL_MATCH" }; + } default: { const exhaustive: never = data; throw new Response(`Unknown action: ${JSON.stringify(exhaustive)}`, { @@ -250,6 +282,7 @@ export interface LFGMatchLoaderData { isCaptain: boolean; isOwnMatch: boolean; isRanked: boolean; + wasCanceled: boolean; createdAtTimestamp: number; groups: { id: string; members: (UserLean & { friendCode?: string })[] }[]; mapList: { @@ -321,6 +354,7 @@ export const loader: LoaderFunction = async ({ params, context }) => { isCaptain, isRanked, isOwnMatch, + wasCanceled: Boolean(match.cancelCausingUserId), groups, scores, createdAtTimestamp: new Date(match.createdAt).getTime(), @@ -360,12 +394,19 @@ export default function LFGMatchPage() { const showPlayAgainSection = () => { if (!data.isCaptain) return false; - if (!data.scores) return false; + if (!data.scores && !data.wasCanceled) return false; if (!matchStartedInTheLastHour()) return false; return true; }; + const showMapListSection = () => { + if (data.wasCanceled) return false; + if (!data.isRanked) return false; + + return !data.scores || adminEditActive; + }; + React.useEffect(() => { if (actionData?.ok === "EDIT_REPORTED_SCORE") { setAdminEditActive(false); @@ -397,7 +438,7 @@ export default function LFGMatchPage() { )}
-
+
{new Date(data.createdAtTimestamp).toLocaleString("en-us", { year: "numeric", month: "long", @@ -406,6 +447,9 @@ export default function LFGMatchPage() { minute: "numeric", })}
+ {data.wasCanceled ? ( +
Canceled
+ ) : null} {showPlayAgainSection() && (
@@ -578,7 +622,7 @@ export default function LFGMatchPage() {
)} - {(!data.scores || adminEditActive) && data.isRanked && ( + {showMapListSection() ? ( - )} + ) : null}
); diff --git a/app/styles/global.css b/app/styles/global.css index 6adc56ca9..1f9bbf626 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -732,10 +732,18 @@ hr { margin-inline-start: var(--s-2); } +.mr-1 { + margin-inline-end: var(--s-1); +} + .mr-2 { margin-inline-end: var(--s-2); } +.mr-3 { + margin-inline-end: var(--s-3); +} + .my-1-5 { margin-block: var(--s-1-5); } @@ -784,6 +792,10 @@ hr { align-items: center; } +.flex-col { + flex-direction: column; +} + .gap-2 { gap: var(--s-2); } diff --git a/app/styles/play-match.css b/app/styles/play-match.css index 5c5bb36e9..bbf498113 100644 --- a/app/styles/play-match.css +++ b/app/styles/play-match.css @@ -20,15 +20,22 @@ gap: var(--s-4); } -.play-match__time { +.play-match__small-info { display: flex; justify-content: center; - color: var(--text-lighter); font-size: var(--fonts-xs); font-weight: var(--semi-bold); margin-block-start: var(--s-2); } +.play-match__small-info.time { + color: var(--text-lighter); +} + +.play-match__small-info.canceled { + color: var(--theme-error); +} + .play-match__waves-section { padding: var(--s-4); background-color: var(--bg-darker); @@ -255,6 +262,31 @@ margin-block-start: var(--s-4); } +.play-match__cancel-match { + display: flex; + max-width: 24rem; + flex-direction: column; + padding: var(--s-4); + margin: 0 auto; + background-color: var(--bg-lighter); + border-radius: var(--rounded); + font-size: var(--fonts-sm); + gap: var(--s-4); + margin-block-start: var(--s-4); +} + +.play-match__cancel-match__form { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.play-match__cancel-match__radios { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); +} + .play-match__map-list-header { background-color: var(--bg-darker); border-radius: var(--rounded); diff --git a/app/utils/index.ts b/app/utils/index.ts index da45e3103..2ad347842 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -1,4 +1,4 @@ -import { Mode } from "@prisma/client"; +import { Mode, User } from "@prisma/client"; import type { CSSProperties } from "react"; import { json, useLocation } from "remix"; import type { Socket } from "socket.io-client"; @@ -18,6 +18,12 @@ export function flipObject< return result; } +export function userFullDiscordName( + user: Pick +) { + return `${user.discordName}#${user.discordDiscriminator}`; +} + export function makeTitle(title?: string | string[]) { if (!title) return "sendou.ink"; if (typeof title === "string") return `${title} | sendou.ink`; diff --git a/scripts/recalculateSkills.ts b/scripts/recalculateSkills.ts index a12b3e200..55927cca3 100644 --- a/scripts/recalculateSkills.ts +++ b/scripts/recalculateSkills.ts @@ -5,7 +5,7 @@ import { groupsToWinningAndLosingPlayerIds } from "~/core/play/utils"; import { Unpacked } from "~/utils"; import * as TournamentMatch from "~/models/TournamentMatch.server"; -// TODO: tournament skills +// TODO: canceled skills const prisma = new PrismaClient();