diff --git a/app/constants.ts b/app/constants.ts index d93ba77b6..371bf4037 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -1,3 +1,5 @@ +import { Ability } from "@prisma/client"; + export const DISCORD_URL = "https://discord.gg/sendou"; export const ADMIN_TEST_UUID = "846e12eb-d373-4002-a0c3-e23077e1c88c"; @@ -208,3 +210,33 @@ export const weapons = [ "Hero Brella Replica", "Octo Shot Replica", ] as const; + +export const abilities: Ability[] = [ + "ISM", + "ISS", + "REC", + "RSU", + "SSU", + "SCU", + "SS", + "SPU", + "QR", + "QSJ", + "BRU", + "RES", + "BDU", + "MPU", + "OG", + "LDE", + "T", + "CB", + "NS", + "H", + "TI", + "RP", + "AD", + "SJ", + "OS", + "DR", + "EMPTY", +]; diff --git a/app/models/GameDetail.server.ts b/app/models/GameDetail.server.ts new file mode 100644 index 000000000..09f3fe12f --- /dev/null +++ b/app/models/GameDetail.server.ts @@ -0,0 +1,52 @@ +import { Ability } from "@prisma/client"; +import { db } from "~/utils/db.server"; + +export interface CreateGameDetailsInput { + id: string; + duration: number; + startedAt: Date; + lfgStageId: string; + teams: { + id: string; + isWinner: boolean; + score: number; + players: { + principalId: string; + name: string; + weapon: string; + mainAbilities: Ability[]; + subAbilities: Ability[]; + kills: number; + assists: number; + deaths: number; + specials: number; + paint: number; + gear: string[]; + }[]; + }[]; +} +export function create(details: CreateGameDetailsInput[]) { + return db.$transaction([ + db.gameDetail.createMany({ + data: details.map(({ teams: _teams, ...detail }) => detail), + }), + db.gameDetailTeam.createMany({ + data: details.flatMap((detail) => + detail.teams.map(({ players: _players, ...team }) => ({ + gameDetailId: detail.id, + ...team, + })) + ), + }), + db.gameDetailPlayer.createMany({ + data: details + .flatMap((detail) => detail.teams) + .flatMap((team) => + team.players.map((player) => ({ + gameDetailTeamId: team.id, + ...player, + })) + ), + }), + ]); +} diff --git a/app/models/LFGMatch.server.ts b/app/models/LFGMatch.server.ts index e11b3eed6..71d71b6da 100644 --- a/app/models/LFGMatch.server.ts +++ b/app/models/LFGMatch.server.ts @@ -10,8 +10,10 @@ export function findById(id: string) { createdAt: true, stages: { select: { + id: true, stage: { select: { + id: true, name: true, mode: true, }, diff --git a/app/routes/match-details.ts b/app/routes/match-details.ts index 4b5bbdf38..333a66b0b 100644 --- a/app/routes/match-details.ts +++ b/app/routes/match-details.ts @@ -1,44 +1,22 @@ import type { ActionFunction } from "remix"; import { z } from "zod"; -import { weapons } from "~/constants"; -import { modesShort, stages } from "~/core/stages/stages"; +import { abilities, weapons } from "~/constants"; +import { idToStage, modesShort, stages } from "~/core/stages/stages"; import { parseRequestFormData } from "~/utils"; +import { v4 as uuidv4 } from "uuid"; +import * as LFGMatch from "~/models/LFGMatch.server"; +import * as GameDetail from "~/models/GameDetail.server"; +import invariant from "tiny-invariant"; +import { Ability } from "@prisma/client"; -export const abilityEnum = z.enum([ - "ISM", - "ISS", - "REC", - "RSU", - "SSU", - "SCU", - "SS", - "SPU", - "QR", - "QSJ", - "BRU", - "RES", - "BDU", - "MPU", - "OG", - "LDE", - "T", - "CB", - "NS", - "H", - "TI", - "RP", - "AD", - "SJ", - "OS", - "DR", -]); +const abilityEnum = z.enum(abilities as [Ability, ...Ability[]]); const playerSchema = z.object({ principal_id: z.string(), name: z.string().min(1).max(10), weapon: z.enum(weapons), - main_abilities: z.array(abilityEnum), - sub_abilities: z.array(z.array(abilityEnum)), + main_abilities: z.array(abilityEnum).length(3), + sub_abilities: z.array(abilityEnum).length(9), kills: z.number().int().min(0).max(50), assists: z.number().int().min(0).max(50), deaths: z.number().int().min(0).max(50), @@ -52,30 +30,28 @@ const teamInfoSchema = z.object({ players: z.array(playerSchema), }); -export const detailedMapSchema = z.array( - z.object({ - stage: z.enum(stages), - mode: z.enum(modesShort as [string, ...string[]]), - duration: z.number().int().min(15).max(500), - winners: teamInfoSchema, - losers: teamInfoSchema, - date: z.string().refine((val) => { - const d = new Date(Number(val)); - if (Number.isNaN(d.getTime())) { - return false; - } +export const detailedMapSchema = z.object({ + stage: z.enum(stages), + mode: z.enum(modesShort as [string, ...string[]]), + duration: z.number().int().min(15).max(500), + winners: teamInfoSchema, + losers: teamInfoSchema, + date: z.string().refine((val) => { + const d = new Date(Number(val)); + if (Number.isNaN(d.getTime())) { + return false; + } - const nd = new Date(); - nd.setMonth(-6); + const nd = new Date(); + nd.setMonth(-6); - if (d.getTime() < nd.getTime()) { - return false; - } + if (d.getTime() < nd.getTime()) { + return false; + } - return true; - }), - }) -); + return true; + }), +}); const matchDetailsSchema = z.object({ token: z.string(), @@ -86,15 +62,81 @@ const matchDetailsSchema = z.object({ }); export const action: ActionFunction = async ({ request }) => { - const data = await parseRequestFormData({ + const input = await parseRequestFormData({ request, schema: matchDetailsSchema, useBody: true, }); - if (data.token !== process.env.LANISTA_TOKEN) { + if (input.token !== process.env.LANISTA_TOKEN) { return new Response(null, { status: 401 }); } + const match = await LFGMatch.findById(input.data.matchId); + if (!match) { + return new Response("Invalid match id", { status: 400 }); + } + + const expectedMapsCount = match.stages.reduce( + (acc, cur) => Number(Boolean(cur.winnerGroupId)) + acc, + 0 + ); + if (expectedMapsCount !== input.data.maps.length) { + return new Response( + `Incorrect amount of maps provided. Expected ${expectedMapsCount} got ${input.data.maps.length}`, + { status: 400 } + ); + } + + for (const [i, map] of input.data.maps.entries()) { + const stageObj = idToStage(match.stages[i].stage.id); + if (stageObj.name === map.stage && stageObj.mode === map.mode) { + continue; + } + + return new Response( + `In position ${i + 1} expected ${stageObj.mode} ${ + stageObj.name + } but got ${map.mode} ${map.stage}`, + { status: 400 } + ); + } + + const createGameDetailsInput: GameDetail.CreateGameDetailsInput[] = []; + + for (const [i, map] of input.data.maps.entries()) { + const lfgStage = match.stages[i]; + invariant(lfgStage, "Unexpected lfgStage undefined"); + + createGameDetailsInput.push({ + id: uuidv4(), + duration: map.duration, + lfgStageId: lfgStage.id, + startedAt: new Date(map.date), + teams: [map.winners, map.losers].map((team, i) => { + return { + id: uuidv4(), + isWinner: i === 0, + score: team.score, + players: team.players.map((player) => ({ + principalId: player.principal_id, + name: player.name, + weapon: player.weapon, + mainAbilities: player.main_abilities, + subAbilities: player.sub_abilities, + kills: player.kills, + assists: player.assists, + deaths: player.deaths, + specials: player.specials, + paint: player.paint, + gear: player.gear, + })), + }; + }), + }); + } + + await GameDetail.create(createGameDetailsInput); + return new Response(null, { status: 204 }); }; diff --git a/prisma/migrations/20220307165333_add_details/migration.sql b/prisma/migrations/20220307165333_add_details/migration.sql new file mode 100644 index 000000000..d75cc2079 --- /dev/null +++ b/prisma/migrations/20220307165333_add_details/migration.sql @@ -0,0 +1,65 @@ +/* + Warnings: + + - The required column `id` was added to the `LfgGroupMatchStage` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- CreateEnum +CREATE TYPE "Ability" AS ENUM ('CB', 'LDE', 'OG', 'T', 'H', 'NS', 'TI', 'RP', 'AD', 'DR', 'SJ', 'OS', 'BDU', 'REC', 'RES', 'ISM', 'ISS', 'MPU', 'QR', 'QSJ', 'RSU', 'SSU', 'SCU', 'SPU', 'SS', 'BRU', 'EMPTY'); + +-- AlterTable +ALTER TABLE "LfgGroupMatchStage" ADD COLUMN "id" TEXT NOT NULL, +ADD CONSTRAINT "LfgGroupMatchStage_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "bannedUntil" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "GameDetail" ( + "id" TEXT NOT NULL, + "duration" INTEGER NOT NULL, + "startedAt" TIMESTAMP(3) NOT NULL, + "lfgStageId" TEXT, + + CONSTRAINT "GameDetail_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GameDetailTeam" ( + "id" TEXT NOT NULL, + "gameDetailId" TEXT NOT NULL, + "isWinner" BOOLEAN NOT NULL, + "score" INTEGER NOT NULL, + + CONSTRAINT "GameDetailTeam_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GameDetailPlayer" ( + "gameDetailTeamId" TEXT NOT NULL, + "principalId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "weapon" TEXT NOT NULL, + "mainAbilities" "Ability"[], + "subAbilities" "Ability"[], + "kills" INTEGER NOT NULL, + "assists" INTEGER NOT NULL, + "deaths" INTEGER NOT NULL, + "specials" INTEGER NOT NULL, + "paint" INTEGER NOT NULL, + "gear" TEXT[], + + CONSTRAINT "GameDetailPlayer_pkey" PRIMARY KEY ("gameDetailTeamId","principalId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GameDetail_lfgStageId_key" ON "GameDetail"("lfgStageId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GameDetailTeam_gameDetailId_isWinner_key" ON "GameDetailTeam"("gameDetailId", "isWinner"); + +-- AddForeignKey +ALTER TABLE "GameDetail" ADD CONSTRAINT "GameDetail_lfgStageId_fkey" FOREIGN KEY ("lfgStageId") REFERENCES "LfgGroupMatchStage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GameDetailTeam" ADD CONSTRAINT "GameDetailTeam_gameDetailId_fkey" FOREIGN KEY ("gameDetailId") REFERENCES "GameDetail"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 764b56ce6..31019c71f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { weapons String[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + bannedUntil DateTime? trustedUsers TrustRelationships[] @relation("trustGiver") trustingUsers TrustRelationships[] @relation("trustReceiver") ownedOrganization Organization? @@ -272,6 +273,7 @@ model LfgGroupMatch { } model LfgGroupMatchStage { + id String @id @default(uuid()) lfgGroupMatchId String stageId Int order Int @@ -279,6 +281,7 @@ model LfgGroupMatchStage { lfgGroupMatch LfgGroupMatch @relation(fields: [lfgGroupMatchId], references: [id]) stage Stage @relation(fields: [stageId], references: [id]) winnerGroup LfgGroup? @relation(fields: [winnerGroupId], references: [id]) + details GameDetail[] @@unique([lfgGroupMatchId, order]) } @@ -299,3 +302,71 @@ model Skill { @@unique([userId, matchId]) @@unique([userId, tournamentId]) } + +model GameDetail { + id String @id @default(uuid()) + duration Int + startedAt DateTime + lfgStageId String? + teams GameDetailTeam[] + lfgStage LfgGroupMatchStage? @relation(fields: [lfgStageId], references: [id]) + + @@unique([lfgStageId]) +} + +model GameDetailTeam { + id String @id @default(uuid()) + gameDetailId String + isWinner Boolean + score Int + gameDetails GameDetail @relation(fields: [gameDetailId], references: [id]) + + @@unique([gameDetailId, isWinner]) +} + +enum Ability { + CB + LDE + OG + T + H + NS + TI + RP + AD + DR + SJ + OS + BDU + REC + RES + ISM + ISS + MPU + QR + QSJ + RSU + SSU + SCU + SPU + SS + BRU + EMPTY +} + +model GameDetailPlayer { + gameDetailTeamId String + principalId String + name String + weapon String + mainAbilities Ability[] + subAbilities Ability[] + kills Int + assists Int + deaths Int + specials Int + paint Int + gear String[] + + @@id([gameDetailTeamId, principalId]) +}