diff --git a/app/components/ModeImage.tsx b/app/components/ModeImage.tsx new file mode 100644 index 000000000..4a12277d3 --- /dev/null +++ b/app/components/ModeImage.tsx @@ -0,0 +1,19 @@ +import { Mode } from "@prisma/client"; +import { modesShortToLong } from "~/core/stages/stages"; +import { modeToImageUrl } from "~/utils"; + +export interface ModeImageProps + extends React.ButtonHTMLAttributes { + mode: Mode; +} + +export function ModeImage({ mode, ...props }: ModeImageProps) { + return ( + {modesShortToLong[mode]} + ); +} diff --git a/app/components/play/MapList.tsx b/app/components/play/MapList.tsx new file mode 100644 index 000000000..6ace143ac --- /dev/null +++ b/app/components/play/MapList.tsx @@ -0,0 +1,81 @@ +import { Mode } from "@prisma/client"; +import clsx from "clsx"; +import clone from "just-clone"; +import { useState } from "react"; +import { scoreValid } from "~/core/play/validators"; +import { ModeImage } from "../ModeImage"; + +const NO_RESULT = "NO_RESULT"; + +export function MapList({ + mapList, + canSubmitScore, + groupIds, +}: { + mapList: { + name: string; + mode: Mode; + }[]; + canSubmitScore: boolean; + groupIds: { + our: string; + their: string; + }; +}) { + const [winners, setWinners] = useState([]); + + const updateWinners = (winnerId: string, index: number) => { + const newWinners = clone(winners); + + // we make sure this option is only available for the last score + if (winnerId === NO_RESULT) { + newWinners.pop(); + } else if (index === newWinners.length) { + newWinners.push(winnerId); + } else { + newWinners[index] = winnerId; + } + + setWinners(newWinners); + }; + + // TODO: not properly handling scores getting reported after conclusion + const warningText = scoreValid(winners, mapList.length) + ? undefined + : "Report more maps to submit the score"; + + return ( +
    +

    Map list

    +
    Best of {mapList.length}
    + {canSubmitScore && ( +
  1. + Winner +
  2. + )} + {mapList.map((stage, i) => { + return ( +
  3. + {canSubmitScore && ( + + )} + + {i + 1}){" "} + {stage.name} +
  4. + ); + })} +
    {warningText}
    +
+ ); +} diff --git a/app/core/play/validators.test.ts b/app/core/play/validators.test.ts new file mode 100644 index 000000000..3e7575d83 --- /dev/null +++ b/app/core/play/validators.test.ts @@ -0,0 +1,31 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { scoreValid } from "./validators"; + +const ScoreValidator = suite("scoreValid()"); + +ScoreValidator("Accepts valid scores", () => { + const winners = ["a", "b", "a", "a", "a", "a"]; + const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a"]; + const winners3 = ["a", "a", "a", "a", "a"]; + const winners4 = ["a", "a"]; + + assert.ok(scoreValid(winners, 9)); + assert.ok(scoreValid(winners2, 9)); + assert.ok(scoreValid(winners3, 9)); + assert.ok(scoreValid(winners4, 3)); +}); + +ScoreValidator("Rejects invalid scores", () => { + const winners = ["a", "b", "a", "a", "a", "a", "a"]; + const winners2 = ["a", "b", "b", "b", "b", "a", "a", "a", "a", "b"]; + const winners3 = ["a", "a", "a", "a", "a", "b"]; + const winners4 = ["a", "a", "a"]; + + assert.not.ok(scoreValid(winners, 9)); + assert.not.ok(scoreValid(winners2, 9)); + assert.not.ok(scoreValid(winners3, 9)); + assert.not.ok(scoreValid(winners4, 3)); +}); + +ScoreValidator.run(); diff --git a/app/core/play/validators.ts b/app/core/play/validators.ts index db7775f21..91fd78ff2 100644 --- a/app/core/play/validators.ts +++ b/app/core/play/validators.ts @@ -34,3 +34,34 @@ export function canUniteWithGroup({ return maxGroupSizeToConsider >= otherGroupSize; } + +/** + * Is score valid? In a best of 9 examples of valid scores: + * 5-0, 5-1, 5-4; + * invalid scores: + * 6-0, 5-5, 4-3 + * */ +export function scoreValid(winners: string[], bestOf: number) { + const requiredWinsToTakeTheSet = Math.ceil(bestOf / 2); + const ids = Array.from(new Set(winners)); + if (ids.length > 2) return false; + + const scores = [0, 0]; + for (const [i, winnerId] of winners.entries()) { + if (winnerId === ids[0]) scores[0]++; + else scores[1]++; + + // it's not possible to report more maps once set has concluded + if ( + scores.some((score) => score === requiredWinsToTakeTheSet) && + i !== winners.length - 1 + ) { + return false; + } + } + + return ( + scores.some((score) => score === requiredWinsToTakeTheSet) && + scores.some((score) => score < requiredWinsToTakeTheSet) + ); +} diff --git a/app/routes/play/match.$id.tsx b/app/routes/play/match.$id.tsx index 9329468e2..2d6dfd46f 100644 --- a/app/routes/play/match.$id.tsx +++ b/app/routes/play/match.$id.tsx @@ -14,6 +14,7 @@ import invariant from "tiny-invariant"; import { z } from "zod"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; +import { MapList } from "~/components/play/MapList"; import { DISCORD_URL } from "~/constants"; import * as LFGGroup from "~/models/LFGGroup.server"; import * as LFGMatch from "~/models/LFGMatch.server"; @@ -22,7 +23,6 @@ import { getUser, listToUserReadableString, makeTitle, - modeToImageUrl, parseRequestFormData, requireUser, UserLean, @@ -38,7 +38,7 @@ export const meta: MetaFunction = ({ data }: { data: LFGMatchLoaderData }) => { title: data.isOwnMatch ? makeTitle( `vs. ${listToUserReadableString( - data.groups[1].map( + data.groups[1].members.map( (u) => `${u.discordName}#${u.discordDiscriminator}` ) )}` @@ -91,7 +91,7 @@ interface LFGMatchLoaderData { isCaptain: boolean; isOwnMatch: boolean; isRanked: boolean; - groups: UserLean[][]; + groups: { id: string; members: UserLean[] }[]; mapList: { name: string; mode: Mode; @@ -103,7 +103,7 @@ export const loader: LoaderFunction = async ({ params, context }) => { const user = getUser(context); const match = await LFGMatch.findById(params.id); - if (!match || match.groups.length === 0) { + if (!match || match.groups.length !== 2) { throw new Response(null, { status: 404 }); } @@ -131,13 +131,16 @@ export const loader: LoaderFunction = async ({ params, context }) => { return Number(bIsOwnGroup) - Number(aIsOwnGroup); }) .map((g) => { - return g.members.map((g) => ({ - id: g.user.id, - discordId: g.user.discordId, - discordAvatar: g.user.discordAvatar, - discordName: g.user.discordName, - discordDiscriminator: g.user.discordDiscriminator, - })); + return { + id: g.id, + members: g.members.map((g) => ({ + id: g.user.id, + discordId: g.user.discordId, + discordAvatar: g.user.discordAvatar, + discordName: g.user.discordName, + discordDiscriminator: g.user.discordDiscriminator, + })), + }; }), mapList: match.stages.map(({ stage }) => stage), }); @@ -157,7 +160,7 @@ export default function LFGMatchPage() { key={i} className="play-match__waves-section play-match__players" > - {g.map((user) => ( + {g.members.map((user) => (
@@ -195,26 +198,14 @@ export default function LFGMatchPage() {
)} -
    -

    Map list

    -
    Best of {data.mapList.length}
    - {data.mapList.map((stage, i) => { - return ( -
  1. - - {i + 1}){" "} - {stage.name} ( - {stage.mode}) -
  2. - ); - })} -
+ ); } diff --git a/app/styles/play-match.css b/app/styles/play-match.css index 4fd58d525..422a0c1dd 100644 --- a/app/styles/play-match.css +++ b/app/styles/play-match.css @@ -52,13 +52,20 @@ margin-block-start: var(--s-4); } -/* .play-match__stage { - background-image: var(--_tournament-bg-url); - background-position: center; - border-radius: var(--rounded); - max-width: 24rem; - margin: 0 auto; -} */ +.play-match__select { + width: 4rem; + margin-inline-end: var(--s-4); +} + +.play-match__select-column-header { + width: 6rem; + padding: var(--s-1-5); + color: var(--theme); + font-size: var(--fonts-xs); + font-weight: var(--bold); + list-style: none; + text-align: center; +} .play-match__stages { max-width: max-content; @@ -99,4 +106,12 @@ .play-match__stage-name { font-weight: var(--bold); margin-inline: 4px; + text-overflow: clip; + white-space: nowrap; +} + +.play-match__error-text { + color: var(--theme-warning); + margin-block-start: var(--s-4); + text-align: center; }