Edit match if winner unchanged

This commit is contained in:
Kalle 2022-04-21 17:34:39 +03:00
parent fc2a6323fa
commit b84917133b
20 changed files with 651 additions and 91 deletions

View File

@ -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" />
) : (

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// TODO: move to validators
export function canReportMatchScore({
userId,
members,

View File

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

View File

@ -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[] }[];

View File

@ -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 = {

View File

@ -12,6 +12,7 @@ export function findById(bracketId: string) {
position: true,
stages: {
select: {
id: true,
position: true,
stage: true,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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