Show warning if reports different score

This commit is contained in:
Kalle 2022-02-08 22:19:54 +02:00
parent 4f4473b355
commit fb1b3deba1
10 changed files with 211 additions and 28 deletions

View File

@ -1,8 +1,9 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { uniteGroupInfo, UniteGroupInfoArg } from "./utils";
import { scoresAreIdentical, uniteGroupInfo, UniteGroupInfoArg } from "./utils";
const UniteGroupInfo = suite("uniteGroupInfo()");
const ScoresAreIdentical = suite("scoresAreIdentical()");
const SMALL_GROUP: UniteGroupInfoArg = { id: "small", memberCount: 1 };
const BIG_GROUP: UniteGroupInfoArg = { id: "big", memberCount: 3 };
@ -29,4 +30,45 @@ UniteGroupInfo("Bigger group survives", () => {
assert.equal(otherGroupId, "small");
});
ScoresAreIdentical("Detects identical score", () => {
const result = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
],
winnerIds: ["a", "a", "a"],
});
assert.ok(result);
});
ScoresAreIdentical("Detects not identical score", () => {
const result = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "b" },
],
winnerIds: ["a", "b", "a"],
});
const result2 = scoresAreIdentical({
stages: [
{ winnerGroupId: "a" },
{ winnerGroupId: "a" },
{ winnerGroupId: "b" },
],
winnerIds: ["b", "b", "a"],
});
const result3 = scoresAreIdentical({
stages: [{ winnerGroupId: "a" }, { winnerGroupId: "a" }],
winnerIds: ["a", "a", "a"],
});
assert.not.ok(result);
assert.not.ok(result2);
assert.not.ok(result3);
});
UniteGroupInfo.run();
ScoresAreIdentical.run();

View File

@ -1,4 +1,5 @@
import type { UniteGroupsArgs } from "~/models/LFGGroup.server";
import * as LFGMatch from "~/models/LFGMatch.server";
export interface UniteGroupInfoArg {
id: string;
@ -18,3 +19,23 @@ export function uniteGroupInfo(
removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount,
};
}
/** Checks if the reported score is the same as score from the database */
export function scoresAreIdentical({
stages,
winnerIds,
}: {
stages: { winnerGroupId: string | null }[];
winnerIds: string[];
}): boolean {
const stagesWithWinner = stages.filter((stage) => stage.winnerGroupId);
if (stagesWithWinner.length !== winnerIds.length) return false;
for (const [i, stage] of stagesWithWinner.entries()) {
if (!stage.winnerGroupId) break;
if (stage.winnerGroupId !== winnerIds[i]) return false;
}
return true;
}

View File

@ -1,5 +1,7 @@
import type { Prisma } from "@prisma/client";
import { db } from "~/utils/db.server";
export type FindById = Prisma.PromiseReturnType<typeof findById>;
export function findById(id: string) {
return db.lfgGroupMatch.findUnique({
where: { id },

View File

@ -5,6 +5,7 @@ import {
json,
LinksFunction,
LoaderFunction,
MetaFunction,
redirect,
useLoaderData,
} from "remix";
@ -17,7 +18,13 @@ import { uniteGroupInfo } from "~/core/play/utils";
import { canUniteWithGroup, isGroupAdmin } from "~/core/play/validators";
import * as LFGGroup from "~/models/LFGGroup.server";
import styles from "~/styles/play-looking.css";
import { parseRequestFormData, requireUser, UserLean, validate } from "~/utils";
import {
makeTitle,
parseRequestFormData,
requireUser,
UserLean,
validate,
} from "~/utils";
import {
skillToMMR,
teamSkillToApproximateMMR,
@ -28,6 +35,15 @@ export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: MetaFunction = ({ data }: { data: LookingLoaderData }) => {
return {
title: makeTitle([
`(${data.likedGroups.length}/${data.neutralGroups.length}/${data.likerGroups.length})`,
"Looking",
]),
};
};
export type LookingActionSchema = z.infer<typeof lookingActionSchema>;
const lookingActionSchema = z.union([
z.object({
@ -160,6 +176,7 @@ interface LookingLoaderData {
isCaptain: boolean;
}
// TODO: show unranked when looking for match as ranked
export const loader: LoaderFunction = async ({ context }) => {
const user = requireUser(context);
const ownGroup = await LFGGroup.findActiveByMember(user);

View File

@ -8,6 +8,7 @@ import {
LoaderFunction,
MetaFunction,
redirect,
useActionData,
useLoaderData,
useTransition,
} from "remix";
@ -19,6 +20,8 @@ import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ModeImage } from "~/components/ModeImage";
import { MapList } from "~/components/play/MapList";
import { DISCORD_URL } from "~/constants";
import { scoresAreIdentical } from "~/core/play/utils";
import { isGroupAdmin } from "~/core/play/validators";
import * as LFGGroup from "~/models/LFGGroup.server";
import * as LFGMatch from "~/models/LFGMatch.server";
import styles from "~/styles/play-match.css";
@ -64,7 +67,15 @@ const matchActionSchema = z.union([
}),
]);
export const action: ActionFunction = async ({ request, context }) => {
type ActionData = {
error?: "DIFFERENT_SCORE";
ok?: z.infer<typeof matchActionSchema>["_action"];
};
export const action: ActionFunction = async ({
request,
context,
}): Promise<ActionData | Response> => {
const data = await parseRequestFormData({
request,
schema: matchActionSchema,
@ -76,7 +87,24 @@ export const action: ActionFunction = async ({ request, context }) => {
switch (data._action) {
case "REPORT_SCORE": {
validate(ownGroup.matchId, "No match for the group");
validate(ownGroup.matchId, "Group doesn't have a match");
validate(isGroupAdmin({ group: ownGroup, user }), "Not group admin");
const match = await LFGMatch.findById(ownGroup.matchId);
if (match?.stages.some((stage) => stage.winnerGroupId)) {
// just don't do anything if they report same as someone else before them
// to user it looks identical to if they were the first to submit
if (
scoresAreIdentical({
stages: match.stages,
winnerIds: data.winnerIds,
})
) {
break;
}
return { error: "DIFFERENT_SCORE" };
}
await LFGMatch.reportScore({
UNSAFE_matchId: ownGroup.matchId,
UNSAFE_winnerIds: data.winnerIds,
@ -190,12 +218,21 @@ export const loader: LoaderFunction = async ({ params, context }) => {
});
};
// TODO: match time
export default function LFGMatchPage() {
const data = useLoaderData<LFGMatchLoaderData>();
const transition = useTransition();
const actionData = useActionData<ActionData>();
return (
<div className="container">
{actionData?.error === "DIFFERENT_SCORE" && (
<div className="play-match__error">
The score you reported is different from what your opponent reported.
If you think the information below is wrong notify us on the #helpdesk
channel of our <a href={DISCORD_URL}>Discord</a> channel
</div>
)}
<div className="play-match__waves">
<div className="play-match__teams">
{data.groups.map((g, i) => {
@ -239,7 +276,7 @@ export default function LFGMatchPage() {
<>
<div
className={clsx("play-match__checkmark", "left", {
invisible: stage.winner === 0,
invisible: stage.winner !== 0,
})}
>
<CheckmarkIcon />
@ -253,7 +290,7 @@ export default function LFGMatchPage() {
</div>
<div
className={clsx("play-match__checkmark", {
invisible: stage.winner === 1,
invisible: stage.winner !== 1,
})}
>
<CheckmarkIcon />

View File

@ -1,8 +1,16 @@
.play-match__error {
padding: var(--s-4);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
color: var(--theme-warning);
}
.play-match__waves {
padding: var(--s-8);
background: url("/svg/background-pattern.svg");
background-color: var(--bg-lighter);
border-radius: var(--rounded);
margin-block-start: var(--s-4);
}
.play-match__teams {
@ -53,6 +61,7 @@
padding: var(--s-1);
background-color: var(--bg-darker);
border-radius: var(--rounded);
padding-inline: var(--s-3);
}
.play-match__played-mode {

View File

@ -5,8 +5,10 @@ import type { EventTargetRecorder } from "server/events";
import { z } from "zod";
import { LoggedInUserSchema } from "~/utils/schemas";
export function makeTitle(endOfTitle?: string) {
return endOfTitle ? `sendou.ink | ${endOfTitle}` : "sendou.ink";
export function makeTitle(title?: string | string[]) {
if (!title) return "sendou.ink";
if (typeof title === "string") return `${title} | sendou.ink`;
return `${title.join(" | ")} | sendou.ink`;
}
/** Get logged in user from context. Throws with 401 error if no user found. */

View File

@ -184,6 +184,7 @@ CREATE TABLE "LfgGroupMember" (
-- CreateTable
CREATE TABLE "LfgGroupMatch" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LfgGroupMatch_pkey" PRIMARY KEY ("id")
);
@ -209,6 +210,18 @@ CREATE TABLE "Skill" (
CONSTRAINT "Skill_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GroupSkill" (
"id" TEXT NOT NULL,
"mu" DOUBLE PRECISION NOT NULL,
"sigma" DOUBLE PRECISION NOT NULL,
"matchId" TEXT,
"tournamentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupSkill_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_StageToTournament" (
"A" INTEGER NOT NULL,
@ -221,6 +234,12 @@ CREATE TABLE "_TournamentMatchGameResultToUser" (
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_GroupSkillToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_discordId_key" ON "User"("discordId");
@ -281,6 +300,12 @@ CREATE UNIQUE INDEX "_TournamentMatchGameResultToUser_AB_unique" ON "_Tournament
-- CreateIndex
CREATE INDEX "_TournamentMatchGameResultToUser_B_index" ON "_TournamentMatchGameResultToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_GroupSkillToUser_AB_unique" ON "_GroupSkillToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_GroupSkillToUser_B_index" ON "_GroupSkillToUser"("B");
-- AddForeignKey
ALTER TABLE "Organization" ADD CONSTRAINT "Organization_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@ -368,6 +393,12 @@ ALTER TABLE "Skill" ADD CONSTRAINT "Skill_tournamentId_fkey" FOREIGN KEY ("tourn
-- AddForeignKey
ALTER TABLE "Skill" ADD CONSTRAINT "Skill_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "LfgGroupMatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupSkill" ADD CONSTRAINT "GroupSkill_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "Tournament"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupSkill" ADD CONSTRAINT "GroupSkill_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "LfgGroupMatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_StageToTournament" ADD FOREIGN KEY ("A") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@ -379,3 +410,9 @@ ALTER TABLE "_TournamentMatchGameResultToUser" ADD FOREIGN KEY ("A") REFERENCES
-- AddForeignKey
ALTER TABLE "_TournamentMatchGameResultToUser" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_GroupSkillToUser" ADD FOREIGN KEY ("A") REFERENCES "GroupSkill"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_GroupSkillToUser" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -27,6 +27,7 @@ model User {
tournamentMatches TournamentMatchGameResult[]
lfgGroups LfgGroupMember[]
skill Skill[]
groupSkills GroupSkill[]
}
model Organization {
@ -42,27 +43,28 @@ model Organization {
}
model Tournament {
id String @id @default(uuid())
name String
id String @id @default(uuid())
name String
// Name in lower case to show in URL
nameForUrl String
description String
startTime DateTime
checkInStartTime DateTime
nameForUrl String
description String
startTime DateTime
checkInStartTime DateTime
// CSS for tournament banner's background value
bannerBackground String
bannerBackground String
// CSS for tournament banner's color value
bannerTextHSLArgs String
bannerTextHSLArgs String
// Team ID's in an array in the order of the seed. 0 index is 1st seed, 1 index is 2nd seed etc.
// Any team not in the list will be at the bottom in the order of their createdAt time stamp
// (older teams first)
seeds String[]
mapPool Stage[]
organizerId String
organizer Organization @relation(fields: [organizerId], references: [id])
teams TournamentTeam[]
brackets TournamentBracket[]
changedSkills Skill[]
seeds String[]
mapPool Stage[]
organizerId String
organizer Organization @relation(fields: [organizerId], references: [id])
teams TournamentTeam[]
brackets TournamentBracket[]
changedSkills Skill[]
changedGroupSkills GroupSkill[]
// There might be duplicate nameForUrl's but inside an organization they're unique
@@unique([nameForUrl, organizerId])
@ -255,10 +257,12 @@ model LfgGroupMember {
}
model LfgGroupMatch {
id String @id @default(uuid())
groups LfgGroup[]
changedSkills Skill[]
stages LfgGroupMatchStage[]
id String @id @default(uuid())
createdAt DateTime @default(now())
groups LfgGroup[]
changedSkills Skill[]
stages LfgGroupMatchStage[]
changedGroupSkills GroupSkill[]
}
model LfgGroupMatchStage {
@ -285,3 +289,15 @@ model Skill {
match LfgGroupMatch? @relation(fields: [matchId], references: [id])
tournament Tournament? @relation(fields: [tournamentId], references: [id])
}
model GroupSkill {
id String @id @default(uuid())
mu Float
sigma Float
matchId String?
tournamentId String?
createdAt DateTime @default(now())
users User[]
match LfgGroupMatch? @relation(fields: [matchId], references: [id])
tournament Tournament? @relation(fields: [tournamentId], references: [id])
}

View File

@ -79,7 +79,7 @@ app.all(
}
);
const port = process.env.PORT || 3000;
const port = process.env.PORT ?? 5800;
app.listen(port, () => {
console.log(`Express server listening on port ${port}`);
});