Allow canceling SendouQ match

This commit is contained in:
Kalle
2022-04-13 18:45:13 +03:00
parent 2a72ae58d7
commit d3df5bdda1
9 changed files with 283 additions and 26 deletions

View File

@@ -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<string[]>(reportedWinnerIds);
const [winners, setWinners] = React.useState<string[]>(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 (
<CancelMatch disableCancelMode={() => setCancelModeEnabled(false)} />
);
return (
<ol className="play-match__stages">
<h2 className="play-match__map-list-header">Map list</h2>
@@ -90,6 +99,7 @@ export function MapList({
winners={winners}
groupIds={groupIds}
isFirstTimeReporting={reportedWinnerIds.length === 0}
enableCancelMode={() => setCancelModeEnabled(true)}
/>
)}
</ol>
@@ -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 <div className="play-match__error-text">{warningText}</div>;
return (
<div className="play-match__error-text">
{warningText}
<div>
<div className="flex flex-col">
or{" "}
<Button
variant="minimal-destructive"
tiny
onClick={enableCancelMode}
>
Cancel Match
</Button>
</div>
</div>
</div>
);
}
const score = winners.reduce(
@@ -147,3 +175,64 @@ function Submitter({
</div>
);
}
function CancelMatch({ disableCancelMode }: { disableCancelMode: () => void }) {
const data = useLoaderData<LFGMatchLoaderData>();
return (
<div className="play-match__cancel-match">
<h2 className="play-match__map-list-header">Cancel match</h2>
<p>
You should only cancel the match if one player can&apos;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).
</p>
<p>
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&apos;s.
</p>
<Form className="play-match__cancel-match__form" method="post">
<h4>Choose missing player</h4>
<div className="play-match__cancel-match__radios">
{data.groups
.flatMap((g) => g.members)
.map((m) => (
<span
key={m.id}
title={userFullDiscordName(m)}
className="flex items-center"
>
<input
id={m.id}
type="radio"
name="cancelCausingUserId"
value={m.id}
required
className="mr-1"
/>
<label htmlFor={m.id}>{m.discordName}</label>
</span>
))}
</div>
<div className="flex items-center mt-2">
<Button
type="submit"
variant="destructive"
name="_action"
value="CANCEL_MATCH"
tiny
className="mr-3"
>
Cancel match
</Button>
<Button tiny type="button" onClick={disableCancelMode}>
Nevermind
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -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)}
>
<div className="play-match__player">
<Avatar user={user} />

View File

@@ -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,

View File

@@ -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<typeof findById>;
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 },
}),
]);
}

View File

@@ -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() {
)}
<div className="play-match__waves">
<MatchTeams />
<div className="play-match__time">
<div className="play-match__small-info time">
{new Date(data.createdAtTimestamp).toLocaleString("en-us", {
year: "numeric",
month: "long",
@@ -406,6 +447,9 @@ export default function LFGMatchPage() {
minute: "numeric",
})}
</div>
{data.wasCanceled ? (
<div className="play-match__small-info canceled">Canceled</div>
) : null}
{showPlayAgainSection() && (
<Form method="post">
<div className="play-match__waves-section play-match__play-again-container">
@@ -578,7 +622,7 @@ export default function LFGMatchPage() {
</Form>
</div>
)}
{(!data.scores || adminEditActive) && data.isRanked && (
{showMapListSection() ? (
<MapList
mapList={data.mapList}
reportedWinnerIds={data.mapList
@@ -594,7 +638,7 @@ export default function LFGMatchPage() {
their: data.groups[1].id,
}}
/>
)}
) : null}
</div>
</>
);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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<User, "discordName" | "discordDiscriminator">
) {
return `${user.discordName}#${user.discordDiscriminator}`;
}
export function makeTitle(title?: string | string[]) {
if (!title) return "sendou.ink";
if (typeof title === "string") return `${title} | sendou.ink`;

View File

@@ -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();