MapList with score validation logic

This commit is contained in:
Kalle
2022-02-07 09:49:41 +02:00
parent 6686edfe6f
commit 004501fa0a
6 changed files with 207 additions and 39 deletions

View File

@@ -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<HTMLImageElement> {
mode: Mode;
}
export function ModeImage({ mode, ...props }: ModeImageProps) {
return (
<img
src={modeToImageUrl(mode)}
alt={modesShortToLong[mode]}
title={modesShortToLong[mode]}
{...props}
/>
);
}

View File

@@ -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<string[]>([]);
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 (
<ol className="play-match__stages">
<h2 className="play-match__map-list-header">Map list</h2>
<div className="play-match__best-of">Best of {mapList.length}</div>
{canSubmitScore && (
<li className="play-match__select-column-header">
<span>Winner</span>
</li>
)}
{mapList.map((stage, i) => {
return (
<li key={`${stage.name}-${stage.mode}`} className="play-match__stage">
{canSubmitScore && (
<select
className={clsx("play-match__select", {
invisible: i > winners.length,
})}
onChange={(e) => updateWinners(e.target.value, i)}
value={winners[i] ?? NO_RESULT}
>
<option value={NO_RESULT}></option>
<option value={groupIds.our}>Us</option>
<option value={groupIds.their}>Them</option>
</select>
)}
<ModeImage className="play-match__mode" mode={stage.mode} />
{i + 1}){" "}
<span className="play-match__stage-name">{stage.name}</span>
</li>
);
})}
<div className="play-match__error-text">{warningText}</div>
</ol>
);
}

View File

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

View File

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

View File

@@ -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) => (
<div key={user.id} className="play-match__player">
<Avatar user={user} />
<span className="play-match__player-name">
@@ -195,26 +198,14 @@ export default function LFGMatchPage() {
</Form>
</div>
)}
<ol className="play-match__stages">
<h2 className="play-match__map-list-header">Map list</h2>
<div className="play-match__best-of">Best of {data.mapList.length}</div>
{data.mapList.map((stage, i) => {
return (
<li
key={`${stage.name}-${stage.mode}`}
className="play-match__stage"
>
<img
className="play-match__mode"
src={modeToImageUrl(stage.mode)}
/>
{i + 1}){" "}
<span className="play-match__stage-name">{stage.name}</span> (
{stage.mode})
</li>
);
})}
</ol>
<MapList
mapList={data.mapList}
canSubmitScore={data.isCaptain}
groupIds={{
our: data.groups[0].id,
their: data.groups[1].id,
}}
/>
</div>
);
}

View File

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