mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 15:56:19 -05:00
Edit match if winner unchanged
This commit is contained in:
parent
fc2a6323fa
commit
b84917133b
|
|
@ -77,7 +77,7 @@ export function Chat({ id, users }: ChatProps) {
|
|||
</actionFetcher.Form>
|
||||
</div>
|
||||
)}
|
||||
<button className="chat__fab" onClick={toggleOpen}>
|
||||
<button type="button" className="chat__fab" onClick={toggleOpen}>
|
||||
{isOpen ? (
|
||||
<CrossIcon className="chat__fab__icon" />
|
||||
) : (
|
||||
|
|
|
|||
17
app/components/icons/Edit.tsx
Normal file
17
app/components/icons/Edit.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function EditIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ export function BracketActions({ data }: { data: BracketModified }) {
|
|||
return (
|
||||
hasBothParticipants &&
|
||||
isOwnMatch &&
|
||||
!matchIsOver(match.round.stages.length, match.score)
|
||||
!matchIsOver({ bestOf: match.round.stages.length, score: match.score })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ export function EliminationBracket({
|
|||
key={match.id}
|
||||
match={match}
|
||||
ownTeamName={ownTeamName}
|
||||
isOver={matchIsOver(round.stages.length, match.score)}
|
||||
isOver={matchIsOver({
|
||||
bestOf: round.stages.length,
|
||||
score: match.score,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import clone from "just-clone";
|
|||
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { Label } from "../Label";
|
||||
import { TeamRosterInputsCheckboxes } from "./TeamRosterInputsCheckboxes";
|
||||
import * as React from "react";
|
||||
|
||||
/** Fields of a tournament team required to render `<TeamRosterInputs />` */
|
||||
export interface TeamRosterInputTeam {
|
||||
|
|
@ -12,7 +13,6 @@ export interface TeamRosterInputTeam {
|
|||
member: {
|
||||
id: string;
|
||||
discordName: string;
|
||||
/** Only used when rendering <TeamRosterInputs /> of a match that was already reported. */
|
||||
played?: boolean;
|
||||
};
|
||||
}[];
|
||||
|
|
@ -32,10 +32,10 @@ export function TeamRosterInputs({
|
|||
}: {
|
||||
teamUpper: TeamRosterInputTeam;
|
||||
teamLower: TeamRosterInputTeam;
|
||||
winnerId?: string;
|
||||
setWinnerId: (newId: string) => void;
|
||||
winnerId?: string | null;
|
||||
setWinnerId?: (newId: string) => void;
|
||||
checkedPlayers: [string[], string[]];
|
||||
setCheckedPlayers: React.Dispatch<React.SetStateAction<[string[], string[]]>>;
|
||||
setCheckedPlayers?: (newPlayerIds: [string[], string[]]) => void;
|
||||
presentational?: boolean;
|
||||
}) {
|
||||
const inputMode = (team: TeamRosterInputTeam): TeamRosterInputsType => {
|
||||
|
|
@ -60,15 +60,15 @@ export function TeamRosterInputs({
|
|||
presentational={presentational}
|
||||
checked={winnerId === team.id}
|
||||
teamId={team.id}
|
||||
onChange={() => setWinnerId(team.id)}
|
||||
onChange={() => setWinnerId?.(team.id)}
|
||||
/>
|
||||
<TeamRosterInputsCheckboxes
|
||||
team={team}
|
||||
checkedPlayers={checkedPlayers[teamI]}
|
||||
mode={inputMode(team)}
|
||||
handlePlayerClick={(playerId: string) =>
|
||||
setCheckedPlayers((players) => {
|
||||
const newPlayers = clone(players);
|
||||
handlePlayerClick={(playerId: string) => {
|
||||
const newCheckedPlayers = () => {
|
||||
const newPlayers = clone(checkedPlayers);
|
||||
if (checkedPlayers.flat().includes(playerId)) {
|
||||
newPlayers[teamI] = newPlayers[teamI].filter(
|
||||
(id) => id !== playerId
|
||||
|
|
@ -78,8 +78,9 @@ export function TeamRosterInputs({
|
|||
}
|
||||
|
||||
return newPlayers;
|
||||
})
|
||||
}
|
||||
};
|
||||
setCheckedPlayers?.(newCheckedPlayers());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -99,6 +100,8 @@ function WinnerRadio({
|
|||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const id = React.useId();
|
||||
|
||||
if (presentational) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -114,8 +117,13 @@ function WinnerRadio({
|
|||
|
||||
return (
|
||||
<div className="tournament-bracket__during-match-actions__radio-container">
|
||||
<input type="radio" id={teamId} onChange={onChange} checked={checked} />
|
||||
<Label className="mb-0 ml-2" htmlFor={teamId}>
|
||||
<input
|
||||
type="radio"
|
||||
id={`${teamId}-${id}`}
|
||||
onChange={onChange}
|
||||
checked={checked}
|
||||
/>
|
||||
<Label className="mb-0 ml-2" htmlFor={`${teamId}-${id}`}>
|
||||
Winner
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import clsx from "clsx";
|
||||
import { Label } from "../Label";
|
||||
import { TeamRosterInputsType, TeamRosterInputTeam } from "./TeamRosterInputs";
|
||||
import * as React from "react";
|
||||
|
||||
export function TeamRosterInputsCheckboxes({
|
||||
team,
|
||||
|
|
@ -14,6 +15,8 @@ export function TeamRosterInputsCheckboxes({
|
|||
/** DEFAULT = inputs work, DISABLED = inputs disabled and look disabled, PRESENTATION = inputs disabled but look like in DEFAULT (without hover styles) */
|
||||
mode: TeamRosterInputsType;
|
||||
}) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="tournament-bracket__during-match-actions__team-players">
|
||||
{team.members.map(({ member }) => (
|
||||
|
|
@ -28,7 +31,7 @@ export function TeamRosterInputsCheckboxes({
|
|||
<input
|
||||
className="plain tournament-bracket__during-match-actions__checkbox"
|
||||
type="checkbox"
|
||||
id={member.id}
|
||||
id={`${member.id}-${id}`}
|
||||
name="playerName"
|
||||
disabled={mode === "DISABLED" || mode === "PRESENTATIONAL"}
|
||||
value={member.id}
|
||||
|
|
@ -37,7 +40,7 @@ export function TeamRosterInputsCheckboxes({
|
|||
/>{" "}
|
||||
<Label
|
||||
className="tournament-bracket__during-match-actions__player-name"
|
||||
htmlFor={member.id}
|
||||
htmlFor={`${member.id}-${id}`}
|
||||
>
|
||||
{member.discordName}
|
||||
</Label>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import clone from "just-clone";
|
|||
import invariant from "tiny-invariant";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
import { FindInfoForModal } from "~/models/TournamentMatch.server";
|
||||
import { BracketMatchAction } from "~/routes/to/$organization.$tournament/bracket.$bid/match.$num";
|
||||
import { Unpacked } from "~/utils";
|
||||
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "../../constants";
|
||||
import { FindTournamentByNameForUrlI } from "../../services/tournament";
|
||||
import { Bracket, eliminationBracket, Match } from "./algorithms";
|
||||
|
|
@ -468,6 +471,47 @@ function getWinnerDestinationMatchIdToMatchPositions(
|
|||
}, new Map<string, number[]>());
|
||||
}
|
||||
|
||||
export function newResultChangesWinner({
|
||||
oldResults,
|
||||
newResults,
|
||||
}: {
|
||||
oldResults: Unpacked<NonNullable<FindInfoForModal>>["matchInfos"];
|
||||
newResults: BracketMatchAction["results"];
|
||||
}): boolean {
|
||||
const oldWinnerIdCounts = oldResults.reduce(
|
||||
(acc: Record<string, number>, stage) => {
|
||||
if (!stage.winnerId) return acc;
|
||||
|
||||
if (!acc[stage.winnerId]) acc[stage.winnerId] = 1;
|
||||
else acc[stage.winnerId]++;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const countsToWinner = (counts: Record<string, number>) =>
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1])?.[0][0];
|
||||
|
||||
const oldWinnerId = countsToWinner(oldWinnerIdCounts);
|
||||
invariant(oldWinnerId, "!oldWinnerId");
|
||||
|
||||
const newWinnerIdCounts = newResults.reduce(
|
||||
(acc: Record<string, number>, stage) => {
|
||||
if (!stage.winnerTeamId) return acc;
|
||||
|
||||
if (!acc[stage.winnerTeamId]) acc[stage.winnerTeamId] = 1;
|
||||
else acc[stage.winnerTeamId]++;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const newWinnerId = countsToWinner(newWinnerIdCounts);
|
||||
invariant(newWinnerId, "!newWinnerId");
|
||||
|
||||
return oldWinnerId !== newWinnerId;
|
||||
}
|
||||
|
||||
export type EliminationBracket<T> = {
|
||||
winners: T;
|
||||
losers: T;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// TODO: move to validators
|
||||
export function canReportMatchScore({
|
||||
userId,
|
||||
members,
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ export function tournamentHasStarted(
|
|||
return brackets[0].rounds.length > 0;
|
||||
}
|
||||
|
||||
export function matchIsOver(
|
||||
bestOf: number,
|
||||
score?: [upperTeamScore: number, lowerTeamScore: number]
|
||||
) {
|
||||
export interface MatchIsOverArgs {
|
||||
bestOf: number;
|
||||
score?: [upperTeamScore: number, lowerTeamScore: number];
|
||||
}
|
||||
// TODO: move to validators
|
||||
export function matchIsOver({ bestOf, score }: MatchIsOverArgs) {
|
||||
if (!score) return false;
|
||||
|
||||
const [upperTeamScore, lowerTeamScore] = score;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,36 @@
|
|||
import { TOURNAMENT_TEAM_ROSTER_MAX_SIZE } from "~/constants";
|
||||
import { matchIsOver, MatchIsOverArgs } from "./utils";
|
||||
|
||||
interface IsTournamentAdminArgs {
|
||||
userId?: string;
|
||||
organization: { ownerId: string };
|
||||
}
|
||||
|
||||
/** Checks that a user is considered an admin of the tournament. An admin can perform all sorts of actions that normal users can't. */
|
||||
export function isTournamentAdmin({
|
||||
// TODO: refactor to user
|
||||
userId,
|
||||
organization,
|
||||
}: {
|
||||
userId?: string;
|
||||
organization: { ownerId: string };
|
||||
}) {
|
||||
}: IsTournamentAdminArgs) {
|
||||
return organization.ownerId === userId;
|
||||
}
|
||||
|
||||
export function canEditMatchResults({
|
||||
userId,
|
||||
organization,
|
||||
match,
|
||||
tournamentConcluded,
|
||||
}: IsTournamentAdminArgs & {
|
||||
match: MatchIsOverArgs;
|
||||
tournamentConcluded: boolean;
|
||||
}) {
|
||||
if (!isTournamentAdmin({ userId, organization })) return false;
|
||||
if (!matchIsOver(match)) return false;
|
||||
if (tournamentConcluded) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks if tournament has not started meaning there is no bracket with rounds generated. */
|
||||
export function tournamentHasNotStarted(tournament: {
|
||||
brackets: { rounds: unknown[] }[];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useLoaderData } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import type { BracketModified } from "~/services/bracket";
|
||||
import { Unpacked } from "~/utils";
|
||||
import * as React from "react";
|
||||
import { useSocketEvent } from "./useSocketEvent";
|
||||
|
||||
export type BracketData = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export function findById(bracketId: string) {
|
|||
position: true,
|
||||
stages: {
|
||||
select: {
|
||||
id: true,
|
||||
position: true,
|
||||
stage: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import invariant from "tiny-invariant";
|
|||
import { TeamRosterInputTeam } from "~/components/tournament/TeamRosterInputs";
|
||||
import { getRoundNameByPositions } from "~/core/tournament/bracket";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { MatchIsOverArgs } from "~/core/tournament/utils";
|
||||
|
||||
export type FindById = Prisma.PromiseReturnType<typeof findById>;
|
||||
export function findById(id: string) {
|
||||
|
|
@ -73,6 +74,49 @@ export function deleteResult(id: string) {
|
|||
return db.tournamentMatchGameResult.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export function updateResults({
|
||||
matchId,
|
||||
newResults,
|
||||
reporterId,
|
||||
}: {
|
||||
matchId: string;
|
||||
newResults: {
|
||||
UNSAFE_playerIds: string[];
|
||||
roundStageId: string;
|
||||
winnerOrder: TeamOrder;
|
||||
}[];
|
||||
reporterId: string;
|
||||
}) {
|
||||
const newResultsWithIds = newResults.map((r) => ({ ...r, id: uuidv4() }));
|
||||
|
||||
return db.$transaction([
|
||||
db.tournamentMatchGameResult.deleteMany({
|
||||
where: {
|
||||
matchId,
|
||||
},
|
||||
}),
|
||||
db.tournamentMatchGameResult.createMany({
|
||||
data: newResultsWithIds.map((result) => ({
|
||||
id: result.id,
|
||||
matchId,
|
||||
reporterId,
|
||||
roundStageId: result.roundStageId,
|
||||
winner: result.winnerOrder,
|
||||
})),
|
||||
}),
|
||||
db.$executeRawUnsafe(`
|
||||
insert into "_TournamentMatchGameResultToUser" ("A", "B") values
|
||||
${newResultsWithIds
|
||||
.flatMap((result) =>
|
||||
result.UNSAFE_playerIds.map(
|
||||
(playerId) => `('${result.id}', '${playerId}')`
|
||||
)
|
||||
)
|
||||
.join(", ")};
|
||||
`),
|
||||
]);
|
||||
}
|
||||
|
||||
export type CreateParticipantsData = {
|
||||
matchId: string;
|
||||
order: TeamOrder;
|
||||
|
|
@ -86,9 +130,12 @@ export function createParticipants(data: CreateParticipantsData) {
|
|||
|
||||
export type FindInfoForModal =
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
scoreTitle: string;
|
||||
roundName: string;
|
||||
bestOf: MatchIsOverArgs["bestOf"];
|
||||
score: MatchIsOverArgs["score"];
|
||||
matchInfos: {
|
||||
idForFrontend: string;
|
||||
teamUpper: TeamRosterInputTeam;
|
||||
|
|
@ -177,18 +224,18 @@ export async function findInfoForModal({
|
|||
};
|
||||
});
|
||||
|
||||
const scoreTitle = match.results
|
||||
.reduce(
|
||||
(scores, result) => {
|
||||
if (result.winner === "UPPER") scores[0]++;
|
||||
else scores[1]++;
|
||||
return scores;
|
||||
},
|
||||
[0, 0]
|
||||
)
|
||||
.join("-");
|
||||
const score = match.results.reduce(
|
||||
(scores: [number, number], result) => {
|
||||
if (result.winner === "UPPER") scores[0]++;
|
||||
else scores[1]++;
|
||||
return scores;
|
||||
},
|
||||
[0, 0]
|
||||
);
|
||||
const scoreTitle = score.join("-");
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
title: `${teamsOrdered[0].team.name} vs. ${teamsOrdered[1].team.name}`,
|
||||
scoreTitle,
|
||||
roundName: getRoundNameByPositions(
|
||||
|
|
@ -196,6 +243,8 @@ export async function findInfoForModal({
|
|||
tournamentRounds.map((round) => round.position)
|
||||
),
|
||||
matchInfos,
|
||||
score,
|
||||
bestOf: tournamentRound.stages.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +264,7 @@ function playersOfMatch({
|
|||
member: User;
|
||||
})[];
|
||||
}) {
|
||||
if (!stageResult) return { upperTeamMembers: [], lowerTeamMembers: [] };
|
||||
if (!stageResult) return { upperTeamMembers, lowerTeamMembers };
|
||||
|
||||
const stageResultPlayerIds = stageResult.players.reduce(
|
||||
(acc, cur) => acc.add(cur.id),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import invariant from "tiny-invariant";
|
|||
import { z } from "zod";
|
||||
import { BracketActions } from "~/components/tournament/BracketActions";
|
||||
import { EliminationBracket } from "~/components/tournament/EliminationBracket";
|
||||
import { BEST_OF_OPTIONS, TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { BEST_OF_OPTIONS } from "~/constants";
|
||||
import { bracketToChangedMMRs } from "~/core/mmr/utils";
|
||||
import { isTournamentAdmin } from "~/core/tournament/validators";
|
||||
import { useUser } from "~/hooks/common";
|
||||
|
|
@ -28,10 +28,13 @@ import {
|
|||
getSocket,
|
||||
parseRequestFormData,
|
||||
requireUser,
|
||||
safeJSONParse,
|
||||
validate,
|
||||
} from "~/utils";
|
||||
import { db } from "~/utils/db.server";
|
||||
import {
|
||||
reportedMatchPlayerIds,
|
||||
reportedMatchPositions,
|
||||
} from "~/utils/schemas";
|
||||
import { chatRoute } from "~/utils/urls";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
|
|
@ -43,17 +46,8 @@ const bracketActionSchema = z.union([
|
|||
_action: z.literal("REPORT_SCORE"),
|
||||
matchId: z.string().uuid(),
|
||||
winnerTeamId: z.string().uuid(),
|
||||
position: z.preprocess(
|
||||
Number,
|
||||
z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(Math.max(...BEST_OF_OPTIONS))
|
||||
),
|
||||
playerIds: z.preprocess(
|
||||
safeJSONParse,
|
||||
z.array(z.string().uuid()).length(TOURNAMENT_TEAM_ROSTER_MIN_SIZE * 2)
|
||||
),
|
||||
position: reportedMatchPositions,
|
||||
playerIds: reportedMatchPlayerIds,
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("UNDO_REPORT_SCORE"),
|
||||
|
|
|
|||
|
|
@ -1,23 +1,182 @@
|
|||
import { json, LinksFunction, LoaderFunction } from "@remix-run/node";
|
||||
import { useLoaderData, useLocation } from "@remix-run/react";
|
||||
import {
|
||||
ActionFunction,
|
||||
json,
|
||||
LinksFunction,
|
||||
LoaderFunction,
|
||||
} from "@remix-run/node";
|
||||
import {
|
||||
Form,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
useMatches,
|
||||
useTransition,
|
||||
} from "@remix-run/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import Modal from "~/components/Modal";
|
||||
import { TeamRosterInputs } from "~/components/tournament/TeamRosterInputs";
|
||||
import * as TournamentMatch from "~/models/TournamentMatch.server";
|
||||
import { Unpacked } from "~/utils";
|
||||
import styles from "~/styles/tournament-match.css";
|
||||
import { FancyStageBanner } from "~/components/tournament/FancyStageBanner";
|
||||
import { TeamRosterInputs } from "~/components/tournament/TeamRosterInputs";
|
||||
import { newResultChangesWinner } from "~/core/tournament/bracket";
|
||||
import { canEditMatchResults } from "~/core/tournament/validators";
|
||||
import { useUser } from "~/hooks/common";
|
||||
import * as TournamentMatch from "~/models/TournamentMatch.server";
|
||||
import { bracketById } from "~/services/bracket";
|
||||
import {
|
||||
findTournamentByNameForUrl,
|
||||
FindTournamentByNameForUrlI,
|
||||
} from "~/services/tournament";
|
||||
import styles from "~/styles/tournament-match.css";
|
||||
import {
|
||||
getSocket,
|
||||
parseRequestFormData,
|
||||
requireUser,
|
||||
safeJSONParse,
|
||||
Unpacked,
|
||||
validate,
|
||||
} from "~/utils";
|
||||
import {
|
||||
reportedMatchPlayerIds,
|
||||
reportedMatchPositions,
|
||||
} from "~/utils/schemas";
|
||||
import * as React from "react";
|
||||
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { BracketData } from "~/hooks/useBracketDataWithEvents";
|
||||
import { TeamOrder } from "@prisma/client";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
type MatchLoaderData = NonNullable<TournamentMatch.FindInfoForModal>;
|
||||
export type BracketMatchAction = z.infer<typeof bracketMatchActionSchema>;
|
||||
const bracketMatchActionSchema = z.object({
|
||||
results: z.preprocess(
|
||||
safeJSONParse,
|
||||
z.array(
|
||||
z.object({
|
||||
winnerTeamId: z.string().uuid().nullish(),
|
||||
position: reportedMatchPositions,
|
||||
playerIds: reportedMatchPlayerIds,
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const matchParamsSchema = z.object({
|
||||
organization: z.string(),
|
||||
tournament: z.string(),
|
||||
bid: z.string(),
|
||||
num: z.preprocess(Number, z.number()),
|
||||
});
|
||||
|
||||
// - if match has concluded admin can edit score
|
||||
// - can't edit to change winner if would cause more than 2 match resets
|
||||
export const action: ActionFunction = async ({ params, request, context }) => {
|
||||
const user = requireUser(context);
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: bracketMatchActionSchema,
|
||||
});
|
||||
const socket = getSocket(context);
|
||||
const {
|
||||
organization: organizationNameForUrl,
|
||||
tournament: tournamentNameForUrl,
|
||||
bid: bracketId,
|
||||
num: matchNumber,
|
||||
} = matchParamsSchema.parse(params);
|
||||
const [match, tournament, bracket] = await Promise.all([
|
||||
TournamentMatch.findInfoForModal({
|
||||
bracketId,
|
||||
matchNumber,
|
||||
}),
|
||||
findTournamentByNameForUrl({
|
||||
organizationNameForUrl,
|
||||
tournamentNameForUrl,
|
||||
}),
|
||||
bracketById(bracketId),
|
||||
]);
|
||||
invariant(match, "!match");
|
||||
|
||||
validate(
|
||||
canEditMatchResults({
|
||||
userId: user?.id,
|
||||
tournamentConcluded: tournament.concluded,
|
||||
organization: tournament.organizer,
|
||||
match: { score: match.score, bestOf: match.bestOf },
|
||||
}),
|
||||
"Can't edit match"
|
||||
);
|
||||
|
||||
// in future we could change the scores and adjust bracket directly but now
|
||||
// for simplicity we just reset the related matches and let people to rereport those
|
||||
if (
|
||||
newResultChangesWinner({
|
||||
oldResults: match.matchInfos,
|
||||
newResults: data.results,
|
||||
})
|
||||
) {
|
||||
// check if affected would only be this match + 2 others or fail
|
||||
// on related matches delete these teams, reset score
|
||||
// on this match reset score
|
||||
throw new Error("Not implemented");
|
||||
} else {
|
||||
const newResults = data.results
|
||||
.filter((s) => s.winnerTeamId)
|
||||
.map((stage) => {
|
||||
const stageInBracket = bracket.rounds
|
||||
.flatMap((round) =>
|
||||
round.matches.flatMap((match) => ({
|
||||
matchId: match.id,
|
||||
stages: round.stages,
|
||||
}))
|
||||
)
|
||||
.find((matchWithStages) => matchWithStages.matchId === match.id)
|
||||
?.stages.find((stageInArr) => stageInArr.position === stage.position);
|
||||
invariant(stageInBracket, "!stageInBracket");
|
||||
|
||||
return {
|
||||
UNSAFE_playerIds: stage.playerIds,
|
||||
roundStageId: stageInBracket.id,
|
||||
winnerOrder: (match.matchInfos[0].teamUpper.id === stage.winnerTeamId
|
||||
? "UPPER"
|
||||
: "LOWER") as TeamOrder,
|
||||
};
|
||||
});
|
||||
|
||||
await TournamentMatch.updateResults({
|
||||
matchId: match.id,
|
||||
reporterId: user.id,
|
||||
newResults,
|
||||
});
|
||||
|
||||
const bracketData: BracketData = [
|
||||
{
|
||||
number: matchNumber,
|
||||
score: newResults.reduce(
|
||||
(acc: [number, number], result) => {
|
||||
acc[result.winnerOrder === "UPPER" ? 0 : 1]++;
|
||||
|
||||
return acc;
|
||||
},
|
||||
[0, 0]
|
||||
),
|
||||
participants: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
socket.emit(`bracket-${bracketId}`, bracketData);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type MatchLoaderData = {
|
||||
match: NonNullable<TournamentMatch.FindInfoForModal>;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }) => {
|
||||
const { bid: bracketId, num: matchNumber } = z
|
||||
.object({ bid: z.string(), num: z.preprocess(Number, z.number()) })
|
||||
.parse(params);
|
||||
const { bid: bracketId, num: matchNumber } = matchParamsSchema.parse(params);
|
||||
|
||||
const match = await TournamentMatch.findInfoForModal({
|
||||
bracketId,
|
||||
|
|
@ -25,53 +184,244 @@ export const loader: LoaderFunction = async ({ params }) => {
|
|||
});
|
||||
if (!match) throw new Response("No match found", { status: 404 });
|
||||
|
||||
return json<MatchLoaderData>(match);
|
||||
return json<MatchLoaderData>({ match });
|
||||
};
|
||||
|
||||
export default function MatchModal() {
|
||||
const data = useLoaderData<MatchLoaderData>();
|
||||
const { match } = useLoaderData<MatchLoaderData>();
|
||||
const location = useLocation();
|
||||
const [editEnabled, setEditEnabled] = React.useState(false);
|
||||
const [results, setResults] = React.useState<BracketMatchAction["results"]>(
|
||||
match.matchInfos.map((matchInfo, i) => ({
|
||||
playerIds: [
|
||||
...matchInfo.teamLower.members.map(preCheckPlayerIfNoSubs),
|
||||
...matchInfo.teamUpper.members.map(preCheckPlayerIfNoSubs),
|
||||
]
|
||||
.filter((m) => m.member.played)
|
||||
.map((m) => m.member.id),
|
||||
position: i + 1,
|
||||
winnerTeamId: matchInfo.winnerId,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<span className="tournament-match-modal__vs-title">{data.title}</span>{" "}
|
||||
<span className="tournament-match-modal__vs-title">
|
||||
{match.title}
|
||||
</span>{" "}
|
||||
<span className="tournament-match-modal__score-title">
|
||||
{data.scoreTitle}
|
||||
{match.scoreTitle}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
closeUrl={location.pathname.split("/match")[0]}
|
||||
>
|
||||
<h4 className="tournament-match-modal__round-name">{data.roundName}</h4>
|
||||
<div className="flex items-center gap-4">
|
||||
<h4 className="tournament-match-modal__round-name">
|
||||
{match.roundName}
|
||||
</h4>
|
||||
<EditResults
|
||||
results={results}
|
||||
editEnabled={editEnabled}
|
||||
setEditEnabled={setEditEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="tournament-match-modal__rounds">
|
||||
{data.matchInfos
|
||||
.filter((matchInfo) => matchInfo.winnerId)
|
||||
.map((matchInfo, i) => {
|
||||
return (
|
||||
<div
|
||||
className="tournament-match-modal__round"
|
||||
key={matchInfo.idForFrontend}
|
||||
>
|
||||
<FancyStageBanner stage={matchInfo.stage} roundNumber={i + 1} />
|
||||
<TeamRosterInputs
|
||||
teamUpper={matchInfo.teamUpper}
|
||||
teamLower={matchInfo.teamLower}
|
||||
checkedPlayers={matchInfoToCheckedPlayers(matchInfo)}
|
||||
setCheckedPlayers={() => null}
|
||||
setWinnerId={() => null}
|
||||
winnerId={matchInfo.winnerId}
|
||||
presentational
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!editEnabled
|
||||
? match.matchInfos
|
||||
.filter((matchInfo) => matchInfo.winnerId)
|
||||
.map((matchInfo, i) => {
|
||||
return (
|
||||
<div
|
||||
className="tournament-match-modal__round"
|
||||
key={matchInfo.idForFrontend}
|
||||
>
|
||||
<FancyStageBanner
|
||||
stage={matchInfo.stage}
|
||||
roundNumber={i + 1}
|
||||
/>
|
||||
<TeamRosterInputs
|
||||
teamUpper={matchInfo.teamUpper}
|
||||
teamLower={matchInfo.teamLower}
|
||||
checkedPlayers={matchInfoToCheckedPlayers(matchInfo)}
|
||||
winnerId={matchInfo.winnerId}
|
||||
presentational
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{editEnabled
|
||||
? results.map((result, i) => {
|
||||
const matchInfo = match.matchInfos[i];
|
||||
invariant(matchInfo, "!matchInfo");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tournament-match-modal__round"
|
||||
key={matchInfo.idForFrontend}
|
||||
>
|
||||
<FancyStageBanner
|
||||
stage={matchInfo.stage}
|
||||
roundNumber={i + 1}
|
||||
/>
|
||||
<TeamRosterInputs
|
||||
teamUpper={matchInfo.teamUpper}
|
||||
teamLower={matchInfo.teamLower}
|
||||
checkedPlayers={resultToCheckedPlayers(
|
||||
matchInfo,
|
||||
result.playerIds
|
||||
)}
|
||||
setCheckedPlayers={(newPlayers) => {
|
||||
setResults(
|
||||
results.map((result, resultI) =>
|
||||
resultI !== i
|
||||
? result
|
||||
: { ...result, playerIds: newPlayers.flat() }
|
||||
)
|
||||
);
|
||||
}}
|
||||
setWinnerId={(newWinnerId) => {
|
||||
setResults(
|
||||
results.map((result, resultI) =>
|
||||
resultI !== i
|
||||
? result
|
||||
: { ...result, winnerTeamId: newWinnerId }
|
||||
)
|
||||
);
|
||||
}}
|
||||
winnerId={result.winnerTeamId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditResults({
|
||||
results,
|
||||
editEnabled,
|
||||
setEditEnabled,
|
||||
}: {
|
||||
results: BracketMatchAction["results"];
|
||||
editEnabled: boolean;
|
||||
setEditEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { match } = useLoaderData<MatchLoaderData>();
|
||||
const user = useUser();
|
||||
const [, parentRoute] = useMatches();
|
||||
const transition = useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transition.type !== "actionReload") return;
|
||||
setEditEnabled(false);
|
||||
}, [setEditEnabled, transition.type]);
|
||||
|
||||
const tournament = parentRoute.data as FindTournamentByNameForUrlI;
|
||||
|
||||
if (
|
||||
!canEditMatchResults({
|
||||
userId: user?.id,
|
||||
tournamentConcluded: tournament.concluded,
|
||||
organization: tournament.organizer,
|
||||
match: { score: match.score, bestOf: match.bestOf },
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!editEnabled) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setEditEnabled(true)}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
icon={<EditIcon />}
|
||||
tiny
|
||||
>
|
||||
Edit result
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const newResultsErrorMsg = () => {
|
||||
const parsed = bracketMatchActionSchema.safeParse({
|
||||
results: results.filter((res) => res.winnerTeamId),
|
||||
});
|
||||
|
||||
// should be the only thing that can go wrong here
|
||||
if (!parsed.success) return "Each team in each map needs to have 4 players";
|
||||
|
||||
const scores: Record<string, number> = {};
|
||||
let matchConcluded = false;
|
||||
for (const result of results) {
|
||||
if (!result.winnerTeamId) continue;
|
||||
|
||||
if (matchConcluded) return "Too many maps reported";
|
||||
|
||||
if (!scores[result.winnerTeamId]) scores[result.winnerTeamId] = 0;
|
||||
scores[result.winnerTeamId]++;
|
||||
|
||||
const winsRequiredToTakeTheSet = Math.ceil(match.bestOf / 2);
|
||||
if (scores[result.winnerTeamId] === winsRequiredToTakeTheSet) {
|
||||
matchConcluded = true;
|
||||
}
|
||||
}
|
||||
if (!matchConcluded) return "Not enough maps reported";
|
||||
};
|
||||
|
||||
if (newResultsErrorMsg()) {
|
||||
return (
|
||||
<div className="tournament-match-modal__error-msg">
|
||||
{newResultsErrorMsg()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
newResultChangesWinner({
|
||||
oldResults: match.matchInfos,
|
||||
newResults: results,
|
||||
})
|
||||
) {
|
||||
return (
|
||||
<p className="tournament-match-modal__reset-match-msg button-text-paragraph">
|
||||
Changing the winner of the set. Reset the related matches so they can be
|
||||
re-reported by the teams?{" "}
|
||||
<Button variant="minimal-success" tiny>
|
||||
Reset matches
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form method="post" className="flex items-center gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="results"
|
||||
value={JSON.stringify(results.filter((res) => res.winnerTeamId))}
|
||||
/>
|
||||
<Button type="submit" variant="outlined-success" tiny>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
tiny
|
||||
onClick={() => setEditEnabled(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function matchInfoToCheckedPlayers(
|
||||
matchInfo: Unpacked<
|
||||
NonNullable<TournamentMatch.FindInfoForModal>["matchInfos"]
|
||||
|
|
@ -86,3 +436,33 @@ function matchInfoToCheckedPlayers(
|
|||
.map((m) => m.member.id),
|
||||
];
|
||||
}
|
||||
|
||||
function resultToCheckedPlayers(
|
||||
matchInfo: Unpacked<
|
||||
NonNullable<TournamentMatch.FindInfoForModal>["matchInfos"]
|
||||
>,
|
||||
playerIds: string[]
|
||||
): [string[], string[]] {
|
||||
return [
|
||||
matchInfo.teamUpper.members
|
||||
.filter((m) => playerIds.includes(m.member.id))
|
||||
.map((m) => m.member.id),
|
||||
matchInfo.teamLower.members
|
||||
.filter((m) => playerIds.includes(m.member.id))
|
||||
.map((m) => m.member.id),
|
||||
];
|
||||
}
|
||||
|
||||
function preCheckPlayerIfNoSubs(
|
||||
member: Unpacked<
|
||||
Unpacked<MatchLoaderData["match"]["matchInfos"]>["teamUpper"]["members"]
|
||||
>,
|
||||
_: number,
|
||||
roster: unknown[]
|
||||
): Unpacked<
|
||||
Unpacked<MatchLoaderData["match"]["matchInfos"]>["teamUpper"]["members"]
|
||||
> {
|
||||
return roster.length === TOURNAMENT_TEAM_ROSTER_MIN_SIZE
|
||||
? { member: { ...member.member, played: true } }
|
||||
: member;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export type BracketModified = {
|
|||
type BracketModifiedSide = {
|
||||
id: string;
|
||||
name: string;
|
||||
stages: { position: number; stage: Stage }[];
|
||||
stages: { id: string; position: number; stage: Stage }[];
|
||||
side?: EliminationBracketSide;
|
||||
matches: {
|
||||
id: string;
|
||||
|
|
@ -213,7 +213,10 @@ export async function reportScore({
|
|||
return;
|
||||
}
|
||||
if (
|
||||
matchIsOver(match.round.stages.length, matchResultsToTuple(match.results))
|
||||
matchIsOver({
|
||||
bestOf: match.round.stages.length,
|
||||
score: matchResultsToTuple(match.results),
|
||||
})
|
||||
) {
|
||||
throw new Response("Match is already over", { status: 400 });
|
||||
}
|
||||
|
|
@ -233,7 +236,7 @@ export async function reportScore({
|
|||
.map((r) => ({ winner: r.winner }))
|
||||
.concat([{ winner: winnerTeam.order }])
|
||||
);
|
||||
if (matchIsOver(match.round.stages.length, newScore)) {
|
||||
if (matchIsOver({ bestOf: match.round.stages.length, score: newScore })) {
|
||||
const loserTeam = match.participants.find((p) => p.teamId !== winnerTeamId);
|
||||
invariant(loserTeam, "loserTeamId is undefined");
|
||||
|
||||
|
|
@ -490,7 +493,10 @@ export async function undoLastScore({
|
|||
}
|
||||
|
||||
if (
|
||||
matchIsOver(match.round.stages.length, matchResultsToTuple(match.results))
|
||||
matchIsOver({
|
||||
bestOf: match.round.stages.length,
|
||||
score: matchResultsToTuple(match.results),
|
||||
})
|
||||
) {
|
||||
throw new Response("Match is already over", { status: 400 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,6 +189,11 @@ button > .button-icon {
|
|||
margin-inline-end: var(--s-1-5);
|
||||
}
|
||||
|
||||
button.tiny > .button-icon {
|
||||
width: 1rem;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
textarea:not(.plain) {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--border);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@
|
|||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.tournament-match-modal__error-msg {
|
||||
color: var(--theme-error);
|
||||
font-size: var(--fonts-sm);
|
||||
|
||||
/* Prevent layout shift */
|
||||
padding-block: 0.16rem;
|
||||
}
|
||||
|
||||
.tournament-match-modal__reset-match-msg {
|
||||
max-width: 24rem;
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.tournament-match-modal__rounds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
|
|||
|
||||
export function safeJSONParse(value: unknown): unknown {
|
||||
try {
|
||||
if (typeof value !== "string") return value;
|
||||
const parsedValue = z.string().parse(value);
|
||||
return JSON.parse(parsedValue);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Mode } from ".prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { Unpacked } from "~/utils";
|
||||
import { BEST_OF_OPTIONS, TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { safeJSONParse, Unpacked } from "~/utils";
|
||||
import { assertType } from "./assertType";
|
||||
|
||||
type MapList = z.infer<typeof ModeSchema>;
|
||||
|
|
@ -31,3 +32,16 @@ export const LoggedInUserSchema = z
|
|||
.nullish(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
export const reportedMatchPlayerIds = z.preprocess(
|
||||
safeJSONParse,
|
||||
z.array(z.string().uuid()).length(TOURNAMENT_TEAM_ROSTER_MIN_SIZE * 2)
|
||||
);
|
||||
|
||||
export const reportedMatchPositions = z.preprocess(
|
||||
Number,
|
||||
z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(Math.max(...BEST_OF_OPTIONS))
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user