mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 13:45:27 -05:00
MapList with score validation logic
This commit is contained in:
19
app/components/ModeImage.tsx
Normal file
19
app/components/ModeImage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
81
app/components/play/MapList.tsx
Normal file
81
app/components/play/MapList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/core/play/validators.test.ts
Normal file
31
app/core/play/validators.test.ts
Normal 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();
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user