mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 19:46:28 -05:00
Allow canceling SendouQ match
This commit is contained in:
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user