diff --git a/.env.template b/.env.template new file mode 100644 index 000000000..ede356aab --- /dev/null +++ b/.env.template @@ -0,0 +1,9 @@ +# Generate one of your own for free at +# https://start.gg/admin/profile/developer +# Read more about start.gg auth here: +# https://developer.start.gg/docs/authentication +STARTGG_TOKEN=YOUR_TOKEN_HERE + +# For persistence of the partykit state to somewhere more reliable +SUPABASE_URL=SOMETHING +SUPABASE_KEY=SOMETHING diff --git a/codegen.ts b/codegen.ts new file mode 100644 index 000000000..3f5005a14 --- /dev/null +++ b/codegen.ts @@ -0,0 +1,17 @@ +import { CodegenConfig } from "@graphql-codegen/cli"; +import baseConfig from "./graphql.config"; + +const config: CodegenConfig = { + ...baseConfig, + ignoreNoDocuments: false, // for better experience with the watcher + generates: { + "./src/startgg-gql/generated/": { + preset: "client", + presetConfig: { + fragmentMasking: false, + }, + }, + }, +}; + +export default config; diff --git a/graphql.config.ts b/graphql.config.ts new file mode 100644 index 000000000..ff650c77b --- /dev/null +++ b/graphql.config.ts @@ -0,0 +1,14 @@ +import "dotenv/config"; + +export default { + schema: [ + { + "https://api.start.gg/gql/alpha": { + headers: { + Authorization: `Bearer ${process.env.STARTGG_TOKEN}`, + }, + }, + }, + ], + documents: ["./src/startgg-gql/*.ts"], +}; diff --git a/package.json b/package.json index 7badddf60..0db0f4680 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,14 @@ "@blueprintjs/datetime2": "2.3.15", "@blueprintjs/icons": "5.14.0", "@blueprintjs/select": "5.3.3", + "@blueprintjs/table": "^5.2.2", "@eslint/js": "^9.14.0", + "@graphql-codegen/cli": "5.0.3", + "@graphql-codegen/client-preset": "4.4.0", "@lcdp/offline-plugin": "5.1.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", + "@reduxjs/toolkit": "2.3.0", + "@supabase/supabase-js": "^2.45.4", "@types/better-sqlite3": "7.6.11", "@types/eslint__js": "^8.42.3", "@types/fuzzy-search": "2.1.5", @@ -58,6 +63,8 @@ "@types/papaparse": "patch:@types/papaparse@npm%3A5.3.14#~/.yarn/patches/@types-papaparse-npm-5.3.14-864eddd3a0.patch", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@urql/core": "^5.0.4", + "@urql/exchange-graphcache": "^7.1.2", "autoprefixer": "10.4.20", "axios": "1.7.7", "babel-loader": "9.2.1", @@ -71,6 +78,7 @@ "css-loader": "7.1.2", "css-minimizer-webpack-plugin": "7.0.0", "date-fns": "2.30.0", + "dotenv": "^16.4.5", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react-hooks": "^5.0.0", @@ -78,6 +86,7 @@ "favicons-webpack-plugin": "6.0.1", "fork-ts-checker-webpack-plugin": "9.0.2", "fuzzy-search": "3.2.1", + "graphql": "^16.9.0", "he": "1.2.0", "html-entities": "2.5.2", "html-loader": "5.1.0", @@ -95,6 +104,8 @@ "normalize.css": "8.0.1", "p-queue": "8.0.1", "papaparse": "5.4.1", + "partykit": "0.0.111", + "partysocket": "1.0.1", "peerjs": "2.0.0-beta.3", "postcss": "8.4.49", "postcss-loader": "8.1.1", @@ -103,13 +114,16 @@ "react-dom": "18.3.1", "react-error-boundary": "4.1.2", "react-intl": "6.8.7", + "react-redux": "^9.1.2", "react-refresh": "0.14.2", + "react-router-dom": "^6.24.1", "sanitize-filename": "1.6.3", "simfile-parser": "0.7.2", "style-loader": "4.0.0", "typescript": "5.6.3", "typescript-eslint": "^8.14.0", "undici": "6.21.0", + "urql": "^4.1.0", "victory": "37.3.2", "webpack": "5.96.1", "webpack-cli": "5.1.4", diff --git a/partykit.json b/partykit.json new file mode 100644 index 000000000..ff146517e --- /dev/null +++ b/partykit.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://www.partykit.io/schema.json", + "name": "ddr-card-draw-party", + "main": "src/party/server.ts", + "compatibilityDate": "2024-06-21" +} diff --git a/src/app.tsx b/src/app.tsx index 09bf92c43..b3a1e3297 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,5 +1,6 @@ import "normalize.css"; import "@blueprintjs/core/lib/css/blueprint.css"; +import "@blueprintjs/table/lib/css/table.css"; import "@blueprintjs/icons/lib/css/blueprint-icons.css"; import "@blueprintjs/select/lib/css/blueprint-select.css"; import "@blueprintjs/datetime2/lib/css/blueprint-datetime2.css"; @@ -8,21 +9,145 @@ import { FocusStyleManager } from "@blueprintjs/core"; FocusStyleManager.onlyShowFocusOnTabs(); -import { DrawingList } from "./drawing-list"; import { UpdateManager } from "./update-manager"; -import { DrawStateManager } from "./draw-state"; +import { IntlProvider } from "./intl-provider"; import { Header } from "./header"; import { ThemeSyncWidget } from "./theme-toggle"; -import { DropHandler } from "./drop-handler"; +import { Provider } from "react-redux"; +import { store } from "./state/store"; +import { PartySocketManager } from "./party/client"; +import { Provider as UrqlProvider } from "urql"; + +import { + createBrowserRouter, + Outlet, + RouterProvider, + useParams, + Link, +} from "react-router-dom"; +import { CabManagement } from "./cab-management"; +import { MainView } from "./main-view"; +import { nanoid } from "nanoid"; +import { urqlClient } from "./startgg-gql"; + +const router = createBrowserRouter([ + { + path: "/", + Component: () => { + return ( +
+

DDR Tools Event Mode

+

Alpha Preview

+

+ You need to pick an event first. Would you like to:{" "} + Create New Event? +

+

+ No idea what this is?{" "} + Here's a video trying to + explain how to use it! +

+
+ ); + }, + }, + { + path: "e/:roomName", + element: , + }, + { + path: "e/:roomName/cab/:cabId/source", + element: , + children: [ + { + path: "cards", + lazy: async () => { + const { CabCards } = await import("./obs-sources/cards"); + return { Component: CabCards }; + }, + }, + { + path: "title", + lazy: async () => { + const { CabTitle } = await import("./obs-sources/text"); + return { Component: CabTitle }; + }, + }, + { + path: "players", + lazy: async () => { + const { CabPlayers } = await import("./obs-sources/text"); + return { Component: CabPlayers }; + }, + }, + { + path: "p1", + lazy: async () => { + const { CabPlayer } = await import("./obs-sources/text"); + return { element: }; + }, + }, + { + path: "p2", + lazy: async () => { + const { CabPlayer } = await import("./obs-sources/text"); + return { element: }; + }, + }, + ], + }, +]); + +function ObsSource() { + const params = useParams<"roomName" | "cabId">(); + if (!params.roomName) { + return null; + } + return ( + + + + + + + + ); +} + +function AppForRoom() { + const params = useParams<"roomName">(); + if (!params.roomName) { + return null; + } + return ( + + + +
+
+ + +
+ + + + ); +} export function App() { return ( - + -
- - - + + ); } diff --git a/src/apply-default-config.tsx b/src/apply-default-config.tsx deleted file mode 100644 index 3dfdf07ef..000000000 --- a/src/apply-default-config.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { GameData } from "./models/SongData"; -import { useEffect } from "react"; -import { ConfigState, useConfigState } from "./config-state"; - -interface Props { - defaults?: GameData["defaults"]; - granularResolution: number | undefined; -} - -export function ApplyDefaultConfig({ defaults, granularResolution }: Props) { - useEffect(() => { - if (!defaults) { - return; - } - - useConfigState.setState(() => { - const { - lowerLvlBound, - upperLvlBound, - flags, - difficulties, - folders, - style, - } = defaults; - const ret: Partial = { - lowerBound: lowerLvlBound, - upperBound: upperLvlBound, - flags: new Set(flags), - difficulties: new Set(difficulties), - folders: new Set(folders), - style, - cutoffDate: "", - }; - if (!granularResolution) { - ret.useGranularLevels = false; - } - return ret; - }); - }, [defaults, granularResolution]); - return null; -} diff --git a/src/cab-management.tsx b/src/cab-management.tsx new file mode 100644 index 000000000..cb9dd22a2 --- /dev/null +++ b/src/cab-management.tsx @@ -0,0 +1,238 @@ +import { + Button, + Card, + ControlGroup, + InputGroup, + Menu, + MenuItem, + Popover, + Tooltip, +} from "@blueprintjs/core"; +import { useAppDispatch, useAppState } from "./state/store"; +import React, { ReactNode, useCallback, useState } from "react"; +import { CabInfo, eventSlice } from "./state/event.slice"; +import { + Add, + CaretLeft, + CaretRight, + Cross, + Font, + Layers, + MobileVideo, + More, + People, + Person, + Remove, +} from "@blueprintjs/icons"; +import { detectedLanguage } from "./utils"; +import { copyPlainTextToClipboard } from "./utils/share"; +import { useSetAtom } from "jotai"; +import { mainTabAtom } from "./main-view"; +import { playerNameByIndex } from "./models/Drawing"; + +export function CabManagement() { + const [isCollapsed, setCollapsed] = useState(true); + const cabs = useAppState(eventSlice.selectors.allCabs); + + if (isCollapsed) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+ + + + + +
+
+ {cabs.map((cab) => ( + + ))} +
+
+ ); +} + +function AddCabControl(props: { children?: ReactNode }) { + const [name, setName] = useState(""); + const dispatch = useAppDispatch(); + const addCab = useCallback(() => { + dispatch(eventSlice.actions.addCab(name)); + setName(""); + }, [dispatch, name]); + return ( +
{ + e.preventDefault(); + addCab(); + }} + > + + setName(e.currentTarget.value)} + placeholder="Cab name" + /> + @@ -256,7 +209,7 @@ function FolderSettings() { key={`${dataSetName}:${idx}`} label={folder} value={folder} - checked={selectedFolders.has(folder)} + checked={selectedFolders.includes(folder)} onChange={() => updateState((s) => { const newFolders = new Set(s.folders); @@ -265,7 +218,7 @@ function FolderSettings() { } else { newFolders.add(folder); } - return { folders: newFolders }; + return { folders: Array.from(newFolders) }; }) } /> @@ -276,8 +229,9 @@ function FolderSettings() { function GeneralSettings() { const { t } = useIntl(); - const gameData = useDrawState((s) => s.gameData); + const updateState = useUpdateConfig(); const configState = useConfigState(); + const gameData = useStockGameData(configState.gameKey); const { useWeights, constrainPocketPicks, @@ -285,7 +239,6 @@ function GeneralSettings() { hideVetos, lowerBound, upperBound, - update: updateState, difficulties: selectedDifficulties, style: selectedStyle, chartCount, @@ -299,12 +252,12 @@ function GeneralSettings() { } return getAvailableDifficulties(gameData, selectedStyle); }, [gameData, selectedStyle]); - const isNarrow = useIsNarrow(); const [expandFilters, setExpandFilters] = useState(false); const availableLevels = useMemo( () => getAvailableLevels(gameData, useGranularLevels), [gameData, useGranularLevels], ); + const getMetaString = useGetMetaString(); if (!gameData) { return null; @@ -361,21 +314,6 @@ function GeneralSettings() { return ( <> - {isNarrow && ( - <> - - - - - - - - - - - )}
{ if (!isNaN(chartCount)) { - updateState(() => { - return { chartCount }; - }); + updateState({ chartCount }); } }} /> @@ -413,9 +349,7 @@ function GeneralSettings() { clampValueOnBlur onValueChange={(playerPicks) => { if (!isNaN(playerPicks)) { - updateState(() => { - return { playerPicks }; - }); + updateState({ playerPicks }); } }} /> @@ -490,7 +424,7 @@ function GeneralSettings() { next.style, ); if (diffs.length === 1) { - next.difficulties = new Set(diffs.map((d) => d.key)); + next.difficulties = diffs.map((d) => d.key); } if (lvlRange.low > next.upperBound) { next.upperBound = lvlRange.low; @@ -504,7 +438,7 @@ function GeneralSettings() { > {gameStyles.map((style) => ( ))} @@ -516,7 +450,7 @@ function GeneralSettings() { key={`${dif.key}`} name="difficulties" value={dif.key} - checked={selectedDifficulties.has(dif.key)} + checked={selectedDifficulties.includes(dif.key)} onChange={(e) => { const { checked, value } = e.currentTarget; updateState((s) => { @@ -526,10 +460,10 @@ function GeneralSettings() { } else { difficulties.delete(value); } - return { difficulties }; + return { difficulties: Array.from(difficulties) }; }); }} - label={t("meta." + dif.key)} + label={getMetaString(dif.key)} /> ))} diff --git a/src/controls/controls-weights.tsx b/src/controls/controls-weights.tsx index c9eb63922..fd3e61a4f 100644 --- a/src/controls/controls-weights.tsx +++ b/src/controls/controls-weights.tsx @@ -2,10 +2,9 @@ import { shallow } from "zustand/shallow"; import styles from "./controls-weights.css"; import { zeroPad } from "../utils"; import { useMemo } from "react"; -import { useConfigState } from "../config-state"; +import { useConfigState, useGameData, useUpdateConfig } from "../state/hooks"; import { useIntl } from "../hooks/useIntl"; import { NumericInput, Checkbox, Classes } from "@blueprintjs/core"; -import { useDrawState } from "../draw-state"; import { getAvailableLevels } from "../game-data-utils"; import { LevelRangeBucket, getBuckets } from "../card-draw"; @@ -33,12 +32,12 @@ function printGroup( export function WeightsControls({ usesTiers, high, low }: Props) { const { t } = useIntl(); + const updateConfig = useUpdateConfig(); const { weights, useWeights, forceDistribution, bucketCount, - updateConfig, totalToDraw, useGranularLevels, } = useConfigState( @@ -47,13 +46,12 @@ export function WeightsControls({ usesTiers, high, low }: Props) { weights: cfg.weights, forceDistribution: cfg.forceDistribution, bucketCount: cfg.probabilityBucketCount, - updateConfig: cfg.update, totalToDraw: cfg.chartCount, useGranularLevels: cfg.useGranularLevels, }), shallow, ); - const gameData = useDrawState((s) => s.gameData); + const gameData = useGameData(); const groups = useMemo(() => { const availableLevels = getAvailableLevels(gameData, useGranularLevels); return Array.from( diff --git a/src/controls/degrs-tester.tsx b/src/controls/degrs-tester.tsx index 3f6691ad8..e88f647e3 100644 --- a/src/controls/degrs-tester.tsx +++ b/src/controls/degrs-tester.tsx @@ -3,12 +3,12 @@ import { Callout, Dialog, DialogBody, + FormGroup, ProgressBar, } from "@blueprintjs/core"; import { draw } from "../card-draw"; -import { useDrawState } from "../draw-state"; import { useAtom } from "jotai"; -import { useConfigState } from "../config-state"; +import { configSlice } from "../state/config.slice"; import { requestIdleCallback } from "../utils/idle-callback"; import { TEST_SIZE, @@ -21,17 +21,28 @@ import { SongCard, SongCardProps } from "../song-card/song-card"; import { useState } from "react"; import { Rain, Repeat, WarningSign } from "@blueprintjs/icons"; import { EligibleChart, PlayerPickPlaceholder } from "../models/Drawing"; +import { store } from "../state/store"; +import { GameData } from "../models/SongData"; +import { useGameData } from "../state/hooks"; +import { ConfigSelect } from "."; export function isDegrs(thing: EligibleChart | PlayerPickPlaceholder) { return "name" in thing && thing.name.startsWith('DEAD END("GROOVE'); } -function* oneMillionDraws() { - const gameData = useDrawState.getState().gameData!; - const configState = useConfigState.getState(); +function* oneMillionDraws(gameData: GameData, configId: string) { + const configState = configSlice.selectors.selectById( + store.getState(), + configId, + ); for (let idx = 0; idx < TEST_SIZE; idx++) { - yield [draw(gameData, configState), idx] as const; + yield [ + draw(gameData, configState!, { + meta: { players: [], title: "", type: "simple" }, + }), + idx, + ] as const; } } @@ -40,9 +51,9 @@ function* oneMillionDraws() { * yields current progress every 1000 loops to allow passing back * the event loop **/ -export function* degrsTester() { +export function* degrsTester(gameData: GameData, configId: string) { let totalDegrs = 0; - for (const [set, idx] of oneMillionDraws()) { + for (const [set, idx] of oneMillionDraws(gameData, configId)) { if (set.charts.some(isDegrs)) { totalDegrs++; } @@ -59,17 +70,18 @@ function nextIdleCycle() { }); } -export function DegrsTestButton() { +export function DegrsTestButton(props: { configId: string | null }) { const [isTesting, setIsTesting] = useAtom(degrsIsTesting); const [progress, setProgress] = useAtom(degrsTestProgress); const [results, setResults] = useAtom(degrsTestResults); + const gameData = useGameData(); async function startTest() { setIsTesting(true); setProgress(0); setResults(undefined); await nextIdleCycle(); - const tester = degrsTester(); + const tester = degrsTester(gameData!, props.configId!); let report = tester.next(); while (!report.done) { setProgress(report.value); @@ -88,6 +100,7 @@ export function DegrsTestButton() { {!isTesting && ( + + ); +} + export function HeaderControls() { + const [configId, setConfigId] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [lastDrawFailed, setLastDrawFailed] = useState(false); - const [drawSongs, hasGameData] = useDrawState((s) => [ - s.drawSongs, - !!s.gameData, - ]); + const [matchPickerOpen, setMatchPickerOpen] = useState(false); + const hasAnyConfig = useAppState((s) => !!s.config.ids.length); const isNarrow = useIsNarrow(); + const dispatch = useAppDispatch(); - function handleDraw() { - useConfigState.setState({ showEligibleCharts: false }); - drawSongs(useConfigState.getState()); + function handleDraw(match: PickedMatch) { + if (!configId) { + return; + } + setMatchPickerOpen(false); + const result = dispatch( + createDraw( + { + meta: { + type: "startgg", + subtype: match.subtype, + entrants: match.players, + title: match.title, + id: match.id, + }, + }, + configId, + ), + ); + if (typeof result === "boolean") { + setLastDrawFailed(result); + } else { + setLastDrawFailed(false); + } } function openSettings() { @@ -43,48 +194,60 @@ export function HeaderControls() { return ( <> - setSettingsOpen(false)} - title={ - <> - - - - - - - } + /> + setMatchPickerOpen(false)} + title="New Draw" > - }> - }> - - - - - {!isNarrow && ( - <> - - - - )} + + + + + + dispatch(createDraw({ meta }, configId!))} + /> + } + > + custom draw + + + + + } + > + start.gg (h2h) + + + + + } + > + start.gg (gauntlet) + + + + - - - } @@ -92,13 +255,215 @@ export function HeaderControls() { usePortal={false} position={Position.BOTTOM_RIGHT} > - + + + } + > + + + + } + onClick={() => setAddOpen(true)} + /> + } + onClick={() => { + createBasisRef.current = selected || undefined; + setAddOpen(true); + }} + /> + } + onClick={() => dispatch(configSlice.actions.removeOne(selected!))} + /> + + } + onClick={async () => { + createBasisRef.current = await loadConfig(); + setAddOpen(true); + }} + /> + } + onClick={() => + saveConfig(dispatch((d, gs) => gs().config.entities[selected!])) + } + /> + + } + > + } + /> + + + {!!apiKey && ( + { + if (slugRef.current) { + slugRef.current.value = slug; + } + }} + /> + )} + + ); +} + +function EventPicker(props: { onSelected(slug: string): void }) { + const [result] = useCurrentUserEvents(); + const setEventSlug = useSetAtom(startggEventSlug); + const tournaments = result.data?.currentUser?.tournaments?.nodes; + + function handleSelect(e: React.MouseEvent) { + e.preventDefault(); + const slug = e.currentTarget.dataset.slug; + if (slug) { + setEventSlug(slug); + props.onSelected(slug); + } + } + + if (!tournaments) { + return null; + } + return ( + <> +

Try the easy way and pick from your tournaments:

+
    + {tournaments.map((t) => { + if (!t) return null; + const events = t.events; + return ( +
  • + {t.name} + {events?.length ? ( +
      + {events.map((evt) => { + if (!evt) return null; + return ( +
    • + + {evt.name} + +
    • + ); + })} +
    + ) : ( + " (no events)" + )} +
  • + ); + })} +
+ + ); +} diff --git a/src/startgg-gql/generated/gql.ts b/src/startgg-gql/generated/gql.ts new file mode 100644 index 000000000..87f296b48 --- /dev/null +++ b/src/startgg-gql/generated/gql.ts @@ -0,0 +1,68 @@ +/* eslint-disable */ +import * as types from './graphql'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +const documents = { + "\n query PlayerName($pid: ID!) {\n entrant(id: $pid) {\n __typename\n id\n name\n }\n }\n": types.PlayerNameDocument, + "\n query SetName($sid: ID!) {\n set(id: $sid) {\n __typename\n id\n fullRoundText\n }\n }\n": types.SetNameDocument, + "\n query GauntletDivisions($eventSlug: String!) {\n event(slug: $eventSlug) {\n id\n phases {\n id\n name\n state\n bracketType\n seeds(query: { page: 0, perPage: 32 }) {\n nodes {\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n": types.GauntletDivisionsDocument, + "\n query EventSets($eventSlug: String!, $pageNo: Int!) {\n event(slug: $eventSlug) {\n id\n sets(filters: { hideEmpty: true }, perPage: 100, page: $pageNo) {\n pageInfo {\n totalPages\n total\n }\n nodes {\n id\n fullRoundText\n identifier\n slots {\n id\n prereqType\n prereqId\n prereqPlacement\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n": types.EventSetsDocument, + "\n mutation ReportSet(\n $setId: ID!\n $winnerId: ID\n $gameData: [BracketSetGameDataInput]\n ) {\n reportBracketSet(setId: $setId, winnerId: $winnerId, gameData: $gameData) {\n id\n completedAt\n }\n }\n": types.ReportSetDocument, + "\n query EventList($page: Int!, $perPage: Int!) {\n currentUser {\n tournaments(\n query: {\n page: $page\n perPage: $perPage\n filter: { tournamentView: \"admin\" }\n }\n ) {\n nodes {\n id\n name\n slug\n events {\n id\n name\n slug\n }\n }\n pageInfo {\n total\n totalPages\n page\n perPage\n }\n }\n }\n }\n": types.EventListDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query PlayerName($pid: ID!) {\n entrant(id: $pid) {\n __typename\n id\n name\n }\n }\n"): (typeof documents)["\n query PlayerName($pid: ID!) {\n entrant(id: $pid) {\n __typename\n id\n name\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query SetName($sid: ID!) {\n set(id: $sid) {\n __typename\n id\n fullRoundText\n }\n }\n"): (typeof documents)["\n query SetName($sid: ID!) {\n set(id: $sid) {\n __typename\n id\n fullRoundText\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query GauntletDivisions($eventSlug: String!) {\n event(slug: $eventSlug) {\n id\n phases {\n id\n name\n state\n bracketType\n seeds(query: { page: 0, perPage: 32 }) {\n nodes {\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query GauntletDivisions($eventSlug: String!) {\n event(slug: $eventSlug) {\n id\n phases {\n id\n name\n state\n bracketType\n seeds(query: { page: 0, perPage: 32 }) {\n nodes {\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query EventSets($eventSlug: String!, $pageNo: Int!) {\n event(slug: $eventSlug) {\n id\n sets(filters: { hideEmpty: true }, perPage: 100, page: $pageNo) {\n pageInfo {\n totalPages\n total\n }\n nodes {\n id\n fullRoundText\n identifier\n slots {\n id\n prereqType\n prereqId\n prereqPlacement\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query EventSets($eventSlug: String!, $pageNo: Int!) {\n event(slug: $eventSlug) {\n id\n sets(filters: { hideEmpty: true }, perPage: 100, page: $pageNo) {\n pageInfo {\n totalPages\n total\n }\n nodes {\n id\n fullRoundText\n identifier\n slots {\n id\n prereqType\n prereqId\n prereqPlacement\n entrant {\n id\n name\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation ReportSet(\n $setId: ID!\n $winnerId: ID\n $gameData: [BracketSetGameDataInput]\n ) {\n reportBracketSet(setId: $setId, winnerId: $winnerId, gameData: $gameData) {\n id\n completedAt\n }\n }\n"): (typeof documents)["\n mutation ReportSet(\n $setId: ID!\n $winnerId: ID\n $gameData: [BracketSetGameDataInput]\n ) {\n reportBracketSet(setId: $setId, winnerId: $winnerId, gameData: $gameData) {\n id\n completedAt\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query EventList($page: Int!, $perPage: Int!) {\n currentUser {\n tournaments(\n query: {\n page: $page\n perPage: $perPage\n filter: { tournamentView: \"admin\" }\n }\n ) {\n nodes {\n id\n name\n slug\n events {\n id\n name\n slug\n }\n }\n pageInfo {\n total\n totalPages\n page\n perPage\n }\n }\n }\n }\n"): (typeof documents)["\n query EventList($page: Int!, $perPage: Int!) {\n currentUser {\n tournaments(\n query: {\n page: $page\n perPage: $perPage\n filter: { tournamentView: \"admin\" }\n }\n ) {\n nodes {\n id\n name\n slug\n events {\n id\n name\n slug\n }\n }\n pageInfo {\n total\n totalPages\n page\n perPage\n }\n }\n }\n }\n"]; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; \ No newline at end of file diff --git a/src/startgg-gql/generated/graphql.ts b/src/startgg-gql/generated/graphql.ts new file mode 100644 index 000000000..879348d83 --- /dev/null +++ b/src/startgg-gql/generated/graphql.ts @@ -0,0 +1,685 @@ +/* eslint-disable */ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + /** + * The `JSON` scalar type represents JSON values as specified by + * [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). + */ + JSON: { input: any; output: any; } + /** + * Represents a Unix Timestamp. Supports up to 53 bit int values, + * as that is JavaScript's internal memory allocation for integer values. + */ + Timestamp: { input: any; output: any; } +}; + +/** Represents the state of an activity */ +export enum ActivityState { + /** Activity is active or in progress */ + Active = 'ACTIVE', + /** Activity, like a set, has been called to start */ + Called = 'CALLED', + /** Activity is done */ + Completed = 'COMPLETED', + /** Activity is created */ + Created = 'CREATED', + /** Activity is invalid */ + Invalid = 'INVALID', + /** Activity is queued to run */ + Queued = 'QUEUED', + /** Activity is ready to be started */ + Ready = 'READY' +} + +/** Represents the name of the third-party service (e.g Twitter) for OAuth */ +export enum AuthorizationType { + Battlenet = 'BATTLENET', + Discord = 'DISCORD', + Epic = 'EPIC', + Mixer = 'MIXER', + Steam = 'STEAM', + Twitch = 'TWITCH', + Twitter = 'TWITTER', + Xbox = 'XBOX' +} + +/** Game specific H2H set data such as character, stage, and stock info */ +export type BracketSetGameDataInput = { + /** Score for entrant 1 (if applicable). For smash, this is stocks remaining. */ + entrant1Score?: InputMaybe; + /** Score for entrant 2 (if applicable). For smash, this is stocks remaining. */ + entrant2Score?: InputMaybe; + /** Game number */ + gameNum: Scalars['Int']['input']; + /** List of selections for the game, typically character selections. */ + selections?: InputMaybe>>; + /** ID of the stage that was played for this game (if applicable) */ + stageId?: InputMaybe; + /** Entrant ID of game winner */ + winnerId?: InputMaybe; +}; + +/** Game specific H2H selections made by the entrants, such as character info */ +export type BracketSetGameSelectionInput = { + /** Character selected by this entrant for this game. */ + characterId?: InputMaybe; + /** Entrant ID that made selection */ + entrantId: Scalars['ID']['input']; +}; + +/** The type of Bracket format that a Phase is configured with. */ +export enum BracketType { + Circuit = 'CIRCUIT', + CustomSchedule = 'CUSTOM_SCHEDULE', + DoubleElimination = 'DOUBLE_ELIMINATION', + EliminationRounds = 'ELIMINATION_ROUNDS', + Exhibition = 'EXHIBITION', + Matchmaking = 'MATCHMAKING', + Race = 'RACE', + RoundRobin = 'ROUND_ROBIN', + SingleElimination = 'SINGLE_ELIMINATION', + Swiss = 'SWISS' +} + +/** Comparison operator */ +export enum Comparator { + Equal = 'EQUAL', + GreaterThan = 'GREATER_THAN', + GreaterThanOrEqual = 'GREATER_THAN_OR_EQUAL', + LessThan = 'LESS_THAN', + LessThanOrEqual = 'LESS_THAN_OR_EQUAL' +} + +export type EventEntrantPageQuery = { + filter?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type EventEntrantPageQueryFilter = { + name?: InputMaybe; +}; + +export type EventFilter = { + fantasyEventId?: InputMaybe; + fantasyRosterHash?: InputMaybe; + id?: InputMaybe; + ids?: InputMaybe>>; + published?: InputMaybe; + slug?: InputMaybe; + type?: InputMaybe>>; + videogameId?: InputMaybe>>; +}; + +export type EventOwnersQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** The type of selection i.e. is it for a character or something else */ +export enum GameSelectionType { + /** Character selection */ + Character = 'CHARACTER' +} + +export type LeagueEventsFilter = { + leagueEntrantId?: InputMaybe; + pointMappingGroupIds?: InputMaybe>>; + search?: InputMaybe; + tierIds?: InputMaybe>>; + upcoming?: InputMaybe; + userId?: InputMaybe; +}; + +export type LeagueEventsQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type LeaguePageFilter = { + activeShops?: InputMaybe; + afterDate?: InputMaybe; + beforeDate?: InputMaybe; + computedUpdatedAt?: InputMaybe; + hasBannerImages?: InputMaybe; + id?: InputMaybe; + ids?: InputMaybe>>; + isFeatured?: InputMaybe; + name?: InputMaybe; + /** ID of the user that owns this league. */ + ownerId?: InputMaybe; + past?: InputMaybe; + publiclySearchable?: InputMaybe; + published?: InputMaybe; + upcoming?: InputMaybe; + videogameIds?: InputMaybe>>; +}; + +export type LeagueQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sort?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type LocationFilterType = { + city?: InputMaybe; + countryCode?: InputMaybe; + state?: InputMaybe; +}; + +/** Different options available for verifying player-reported match results */ +export enum MatchConfigVerificationMethod { + Any = 'ANY', + Mixer = 'MIXER', + StreamMe = 'STREAM_ME', + Twitch = 'TWITCH', + Youtube = 'YOUTUBE' +} + +export type PaginationSearchType = { + fieldsToSearch?: InputMaybe>>; + searchString?: InputMaybe; +}; + +export type ParticipantPageFilter = { + checkedIn?: InputMaybe; + eventIds?: InputMaybe>>; + gamerTag?: InputMaybe; + id?: InputMaybe; + ids?: InputMaybe>>; + incompleteTeam?: InputMaybe; + missingDeck?: InputMaybe; + notCheckedIn?: InputMaybe; + search?: InputMaybe; + unpaid?: InputMaybe; +}; + +export type ParticipantPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type PhaseGroupPageQuery = { + entrantIds?: InputMaybe>>; + filter?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type PhaseGroupPageQueryFilter = { + id?: InputMaybe>>; + waveId?: InputMaybe; +}; + +export type PhaseGroupUpdateInput = { + phaseGroupId: Scalars['ID']['input']; + stationId?: InputMaybe; + waveId?: InputMaybe; +}; + +export type PhaseUpsertInput = { + bracketType?: InputMaybe; + /** The number of pools to configure for the Phase. Only applies to brackets that support pools */ + groupCount?: InputMaybe; + /** The name of the Phase. For example, "Top 8" or "Pools" */ + name?: InputMaybe; +}; + +/** Enforces limits on the amount of allowable Race submissions */ +export enum RaceLimitMode { + BestAll = 'BEST_ALL', + FirstAll = 'FIRST_ALL', + Playtime = 'PLAYTIME' +} + +/** Race type */ +export enum RaceType { + Goals = 'GOALS', + Timed = 'TIMED' +} + +export type ResolveConflictsLockedSeedConfig = { + eventId: Scalars['ID']['input']; + numSeeds: Scalars['Int']['input']; +}; + +export type ResolveConflictsOptions = { + lockedSeeds?: InputMaybe>>; +}; + +export type SeedPageFilter = { + checkInState?: InputMaybe>>; + entrantName?: InputMaybe; + eventCheckInGroupId?: InputMaybe; + eventId?: InputMaybe; + id?: InputMaybe; + phaseGroupId?: InputMaybe>>; + phaseId?: InputMaybe>>; + search?: InputMaybe; +}; + +export type SeedPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** Filter Sets by geographical constraints. */ +export type SetFilterLocation = { + /** Only return Sets in this country. Expects a valid two-letter country code */ + country?: InputMaybe; + distanceFrom?: InputMaybe; + /** Only return Sets in this state. Only applicable to US states */ + state?: InputMaybe; +}; + +/** Only return Sets that are a certain distance away from a specified point */ +export type SetFilterLocationDistanceFrom = { + /** Point at which to perform distance calculation */ + point?: InputMaybe; + /** Distance from the point to include results in */ + radius?: InputMaybe; +}; + +export type SetFilterLocationDistanceFromPoint = { + lat?: InputMaybe; + lon?: InputMaybe; +}; + +export type SetFilters = { + /** Only return Sets for these Entrants */ + entrantIds?: InputMaybe>>; + /** Only return Sets for this Entrant size. For example, to fetch 1v1 Sets only, filter by an entrantSize of 1 */ + entrantSize?: InputMaybe>>; + /** Only return Sets in these Events */ + eventIds?: InputMaybe>>; + /** Only return Sets that have an attached VOD */ + hasVod?: InputMaybe; + /** Do not return empty Sets. For example, set this to true to filter out sets that are waiting for progressions. */ + hideEmpty?: InputMaybe; + /** Only return Sets that are in an Online event. If omitted, Sets for both online and offline Events are returned */ + isEventOnline?: InputMaybe; + /** Only return Sets in certain geographical areas. */ + location?: InputMaybe; + /** Only return Sets for these Participants */ + participantIds?: InputMaybe>>; + /** Only return Sets in these PhaseGroups */ + phaseGroupIds?: InputMaybe>>; + /** Only return Sets in these Phases */ + phaseIds?: InputMaybe>>; + /** Only return Sets for these Players */ + playerIds?: InputMaybe>>; + /** Only return Sets for these Rounds */ + roundNumber?: InputMaybe; + /** Return sets that contain a bye */ + showByes?: InputMaybe; + /** Only returns Sets that are in these states */ + state?: InputMaybe>>; + /** Only return Sets that are assigned to these Station IDs */ + stationIds?: InputMaybe>>; + /** Only return Sets that are assigned to these Station numbers */ + stationNumbers?: InputMaybe>>; + /** Only return Sets in these Tournaments */ + tournamentIds?: InputMaybe>>; + /** Only return sets created or updated since this timestamp */ + updatedAfter?: InputMaybe; +}; + +/** Different sort type configurations used when displaying multiple sets */ +export enum SetSortType { + /** Sets are sorted in the suggested order that they be called to be played. The order of completed sets is reversed. */ + CallOrder = 'CALL_ORDER', + /** Sets are sorted by relevancy dependent on the state and progress of the event. */ + Magic = 'MAGIC', + /** Sets will not be sorted. */ + None = 'NONE', + /** Sets are sorted in the order that they were started. */ + Recent = 'RECENT', + /** Sets sorted by round and identifier */ + Round = 'ROUND', + /** Deprecated. This is equivalent to CALL_ORDER */ + Standard = 'STANDARD' +} + +export type ShopLevelsQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type ShopOrderMessagesQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** Represents the name of the third-party social service (e.g Twitter) for OAuth */ +export enum SocialConnectionType { + Discord = 'DISCORD', + Mixer = 'MIXER', + Twitch = 'TWITCH', + Twitter = 'TWITTER', + Xbox = 'XBOX' +} + +export type StandingGroupStandingPageFilter = { + page?: InputMaybe; + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type StandingPageFilter = { + id?: InputMaybe; + ids?: InputMaybe>>; + search?: InputMaybe; +}; + +export type StandingPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type StationFilter = { + page?: InputMaybe; + perPage?: InputMaybe; +}; + +export type StationUpsertInput = { + clusterId?: InputMaybe; + number: Scalars['Int']['input']; +}; + +/** Represents the source of a stream */ +export enum StreamSource { + /** Stream is on smashcast.tv channel */ + Hitbox = 'HITBOX', + /** Stream is on a mixer.com channel */ + Mixer = 'MIXER', + /** Stream is on a stream.me channel */ + Streamme = 'STREAMME', + /** Stream is on twitch.tv channel */ + Twitch = 'TWITCH', + /** Stream is on a youtube.com channel */ + Youtube = 'YOUTUBE' +} + +/** Represents the type of stream service */ +export enum StreamType { + Mixer = 'MIXER', + Twitch = 'TWITCH', + Youtube = 'YOUTUBE' +} + +/** Membership status of a team member */ +export enum TeamMemberStatus { + Accepted = 'ACCEPTED', + Alum = 'ALUM', + Hiatus = 'HIATUS', + Invited = 'INVITED', + OpenSpot = 'OPEN_SPOT', + Request = 'REQUEST', + Unknown = 'UNKNOWN' +} + +/** Membership type of a team member */ +export enum TeamMemberType { + Player = 'PLAYER', + Staff = 'STAFF' +} + +export type TeamPaginationFilter = { + eventId?: InputMaybe; + eventIds?: InputMaybe>>; + eventState?: InputMaybe; + globalTeamId?: InputMaybe; + isLeague?: InputMaybe; + maxEntrantCount?: InputMaybe; + memberStatus?: InputMaybe>>; + minEntrantCount?: InputMaybe; + past?: InputMaybe; + rosterComplete?: InputMaybe; + rosterIncomplete?: InputMaybe; + search?: InputMaybe; + tournamentId?: InputMaybe; + type?: InputMaybe; + upcoming?: InputMaybe; + videogameId?: InputMaybe>>; +}; + +export type TeamPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type TopGameFilter = { + /** Array of which # top game you want to filter on.e.g. [2, 3] will filter on the 2nd and 3rd top games */ + gameNums?: InputMaybe>>; +}; + +export type TournamentLocationFilter = { + /** e.g. 50mi */ + distance?: InputMaybe; + /** Latitude, Longitude */ + distanceFrom?: InputMaybe; +}; + +export type TournamentPageFilter = { + activeShops?: InputMaybe; + addrState?: InputMaybe; + afterDate?: InputMaybe; + beforeDate?: InputMaybe; + computedUpdatedAt?: InputMaybe; + countryCode?: InputMaybe; + hasBannerImages?: InputMaybe; + hasOnlineEvents?: InputMaybe; + id?: InputMaybe; + ids?: InputMaybe>>; + /** If true, filter to only tournaments the currently authed user is an admin of */ + isCurrentUserAdmin?: InputMaybe; + isFeatured?: InputMaybe; + isLeague?: InputMaybe; + location?: InputMaybe; + name?: InputMaybe; + /** ID of the user that owns this tournament. */ + ownerId?: InputMaybe; + past?: InputMaybe; + publiclySearchable?: InputMaybe; + published?: InputMaybe; + regOpen?: InputMaybe; + sortByScore?: InputMaybe; + staffPicks?: InputMaybe; + topGames?: InputMaybe; + upcoming?: InputMaybe; + venueName?: InputMaybe; + videogameIds?: InputMaybe>>; +}; + +export enum TournamentPaginationSort { + ComputedUpdatedAt = 'computedUpdatedAt', + EndAt = 'endAt', + EventRegistrationClosesAt = 'eventRegistrationClosesAt', + StartAt = 'startAt' +} + +export type TournamentQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sort?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type TournamentRegistrationInput = { + eventIds?: InputMaybe>>; +}; + +export type UpdatePhaseSeedInfo = { + phaseGroupId?: InputMaybe; + seedId: Scalars['ID']['input']; + seedNum: Scalars['ID']['input']; +}; + +export type UpdatePhaseSeedingOptions = { + /** Validate that seedMapping exactly accounts for all entrants in the phase */ + strictMode?: InputMaybe; +}; + +export type UserEventsPaginationFilter = { + eventType?: InputMaybe; + location?: InputMaybe; + maxEntrantCount?: InputMaybe; + minEntrantCount?: InputMaybe; + search?: InputMaybe; + videogameId?: InputMaybe>>; +}; + +export type UserEventsPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type UserLeaguesPaginationFilter = { + past?: InputMaybe; + search?: InputMaybe; + upcoming?: InputMaybe; + videogameId?: InputMaybe>>; +}; + +export type UserLeaguesPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type UserTournamentsPaginationFilter = { + excludeId?: InputMaybe>>; + past?: InputMaybe; + search?: InputMaybe; + tournamentView?: InputMaybe; + upcoming?: InputMaybe; + videogameId?: InputMaybe>>; +}; + +export type UserTournamentsPaginationQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type VideogamePageFilter = { + forUser?: InputMaybe; + id?: InputMaybe>>; + name?: InputMaybe; +}; + +export type VideogameQuery = { + filter?: InputMaybe; + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 500 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type WaveUpsertInput = { + endAt: Scalars['Timestamp']['input']; + identifier: Scalars['String']['input']; + startAt: Scalars['Timestamp']['input']; +}; + +export type PlayerNameQueryVariables = Exact<{ + pid: Scalars['ID']['input']; +}>; + + +export type PlayerNameQuery = { __typename?: 'Query', entrant?: { __typename: 'Entrant', id?: string | null, name?: string | null } | null }; + +export type SetNameQueryVariables = Exact<{ + sid: Scalars['ID']['input']; +}>; + + +export type SetNameQuery = { __typename?: 'Query', set?: { __typename: 'Set', id?: string | null, fullRoundText?: string | null } | null }; + +export type GauntletDivisionsQueryVariables = Exact<{ + eventSlug: Scalars['String']['input']; +}>; + + +export type GauntletDivisionsQuery = { __typename?: 'Query', event?: { __typename?: 'Event', id?: string | null, phases?: Array<{ __typename?: 'Phase', id?: string | null, name?: string | null, state?: ActivityState | null, bracketType?: BracketType | null, seeds?: { __typename?: 'SeedConnection', nodes?: Array<{ __typename?: 'Seed', entrant?: { __typename?: 'Entrant', id?: string | null, name?: string | null } | null } | null> | null } | null } | null> | null } | null }; + +export type EventSetsQueryVariables = Exact<{ + eventSlug: Scalars['String']['input']; + pageNo: Scalars['Int']['input']; +}>; + + +export type EventSetsQuery = { __typename?: 'Query', event?: { __typename?: 'Event', id?: string | null, sets?: { __typename?: 'SetConnection', pageInfo?: { __typename?: 'PageInfo', totalPages?: number | null, total?: number | null } | null, nodes?: Array<{ __typename?: 'Set', id?: string | null, fullRoundText?: string | null, identifier?: string | null, slots?: Array<{ __typename?: 'SetSlot', id?: string | null, prereqType?: string | null, prereqId?: string | null, prereqPlacement?: number | null, entrant?: { __typename?: 'Entrant', id?: string | null, name?: string | null } | null } | null> | null } | null> | null } | null } | null }; + +export type ReportSetMutationVariables = Exact<{ + setId: Scalars['ID']['input']; + winnerId?: InputMaybe; + gameData?: InputMaybe> | InputMaybe>; +}>; + + +export type ReportSetMutation = { __typename?: 'Mutation', reportBracketSet?: Array<{ __typename?: 'Set', id?: string | null, completedAt?: any | null } | null> | null }; + +export type EventListQueryVariables = Exact<{ + page: Scalars['Int']['input']; + perPage: Scalars['Int']['input']; +}>; + + +export type EventListQuery = { __typename?: 'Query', currentUser?: { __typename?: 'User', tournaments?: { __typename?: 'TournamentConnection', nodes?: Array<{ __typename?: 'Tournament', id?: string | null, name?: string | null, slug?: string | null, events?: Array<{ __typename?: 'Event', id?: string | null, name?: string | null, slug?: string | null } | null> | null } | null> | null, pageInfo?: { __typename?: 'PageInfo', total?: number | null, totalPages?: number | null, page?: number | null, perPage?: number | null } | null } | null } | null }; + + +export const PlayerNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PlayerName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"entrant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; +export const SetNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SetName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"set"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullRoundText"}}]}}]}}]} as unknown as DocumentNode; +export const GauntletDivisionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GauntletDivisions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"bracketType"}},{"kind":"Field","name":{"kind":"Name","value":"seeds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"page"},"value":{"kind":"IntValue","value":"0"}},{"kind":"ObjectField","name":{"kind":"Name","value":"perPage"},"value":{"kind":"IntValue","value":"32"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"entrant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const EventSetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventSets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pageNo"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"sets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"hideEmpty"},"value":{"kind":"BooleanValue","value":true}}]}},{"kind":"Argument","name":{"kind":"Name","value":"perPage"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pageNo"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullRoundText"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"slots"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"prereqType"}},{"kind":"Field","name":{"kind":"Name","value":"prereqId"}},{"kind":"Field","name":{"kind":"Name","value":"prereqPlacement"}},{"kind":"Field","name":{"kind":"Name","value":"entrant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ReportSetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReportSet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"setId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"winnerId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"gameData"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BracketSetGameDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reportBracketSet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"setId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"setId"}}},{"kind":"Argument","name":{"kind":"Name","value":"winnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"winnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"gameData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"gameData"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}}]}}]}}]} as unknown as DocumentNode; +export const EventListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"perPage"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tournaments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"perPage"},"value":{"kind":"Variable","name":{"kind":"Name","value":"perPage"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tournamentView"},"value":{"kind":"StringValue","value":"admin","block":false}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"page"}},{"kind":"Field","name":{"kind":"Name","value":"perPage"}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/src/startgg-gql/generated/index.ts b/src/startgg-gql/generated/index.ts new file mode 100644 index 000000000..af7839936 --- /dev/null +++ b/src/startgg-gql/generated/index.ts @@ -0,0 +1 @@ +export * from "./gql"; \ No newline at end of file diff --git a/src/startgg-gql/index.ts b/src/startgg-gql/index.ts new file mode 100644 index 000000000..f671d7067 --- /dev/null +++ b/src/startgg-gql/index.ts @@ -0,0 +1,215 @@ +import { useMutation, useQuery } from "urql"; +import type { + EventSetsDocument, + PlayerNameDocument, + ReportSetDocument, + SetNameDocument, + EventListDocument, + GauntletDivisionsDocument, +} from "./generated/graphql"; +import { Client, fetchExchange, gql } from "@urql/core"; +import { cacheExchange } from "@urql/exchange-graphcache"; +import { getDefaultStore, useAtomValue } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +export const startggKeyAtom = atomWithStorage( + "ddrtools.event.startggtoken", + process.env.STARTGG_TOKEN as string, + undefined, + { getOnInit: true }, +); +export const startggEventSlug = atomWithStorage( + "ddrtools.event.startggslug", + "tournament/red-october-2024/event/stepmaniax-full-mode", + undefined, + { getOnInit: true }, +); + +export const urqlClient = new Client({ + url: "https://api.start.gg/gql/alpha", + fetchOptions: () => ({ + headers: { + Authorization: `Bearer ${getDefaultStore().get(startggKeyAtom)}`, + }, + }), + exchanges: [cacheExchange(), fetchExchange], +}); + +const PlayerNameDoc: typeof PlayerNameDocument = gql` + query PlayerName($pid: ID!) { + entrant(id: $pid) { + __typename + id + name + } + } +`; + +export function useStartggPlayerName(playerId: string) { + const [result] = useQuery({ + query: PlayerNameDoc, + variables: { + pid: playerId, + }, + }); + return result.data?.entrant?.name; +} + +const SetNameDoc: typeof SetNameDocument = gql` + query SetName($sid: ID!) { + set(id: $sid) { + __typename + id + fullRoundText + } + } +`; + +export function useStartggSetName(setId: string) { + const [result] = useQuery({ + query: SetNameDoc, + variables: { + sid: setId, + }, + }); + return result.data?.set?.fullRoundText; +} + +export function useStartggMatches() { + const eventSlug = useAtomValue(startggEventSlug)!; + return useQuery({ + query: EventSetsDoc, + variables: { + eventSlug, + pageNo: 0, + }, + }); +} + +export function useStartggPhases() { + const eventSlug = useAtomValue(startggEventSlug)!; + return useQuery({ + query: GauntletDivisions, + variables: { + eventSlug, + }, + }); +} + +const GauntletDivisions: typeof GauntletDivisionsDocument = gql` + query GauntletDivisions($eventSlug: String!) { + event(slug: $eventSlug) { + id + phases { + id + name + state + bracketType + seeds(query: { page: 0, perPage: 32 }) { + nodes { + entrant { + id + name + } + } + } + } + } + } +`; + +const EventSetsDoc: typeof EventSetsDocument = gql` + query EventSets($eventSlug: String!, $pageNo: Int!) { + event(slug: $eventSlug) { + id + sets(filters: { hideEmpty: true }, perPage: 100, page: $pageNo) { + pageInfo { + totalPages + total + } + nodes { + id + fullRoundText + identifier + slots { + id + prereqType + prereqId + prereqPlacement + entrant { + id + name + } + } + } + } + } + } +`; + +const ReportSetMutation: typeof ReportSetDocument = gql` + mutation ReportSet( + $setId: ID! + $winnerId: ID + $gameData: [BracketSetGameDataInput] + ) { + reportBracketSet(setId: $setId, winnerId: $winnerId, gameData: $gameData) { + id + completedAt + } + } +`; + +export type { + BracketSetGameDataInput, + ReportSetMutationVariables, +} from "./generated/graphql"; + +/** + * Passing a winnerId will mark the set as completed. + * Passing game data will overwrite any existing game data. + */ +export function useReportSetMutation() { + return useMutation(ReportSetMutation); +} + +const EventListQuery: typeof EventListDocument = gql` + query EventList($page: Int!, $perPage: Int!) { + currentUser { + tournaments( + query: { + page: $page + perPage: $perPage + filter: { tournamentView: "admin" } + } + ) { + nodes { + id + name + slug + events { + id + name + slug + } + } + pageInfo { + total + totalPages + page + perPage + } + } + } + } +`; + +export function useCurrentUserEvents() { + return useQuery({ + query: EventListQuery, + variables: { + page: 1, + perPage: 25, + }, + }); +} diff --git a/src/state/central.ts b/src/state/central.ts new file mode 100644 index 000000000..60cf57a88 --- /dev/null +++ b/src/state/central.ts @@ -0,0 +1,8 @@ +import { createAction } from "@reduxjs/toolkit"; +import { withPayload } from "./util"; +import type { AppState } from "./store"; + +export const receivePartyState = createAction( + "party/supplyState", + withPayload(), +); diff --git a/src/state/config.slice.ts b/src/state/config.slice.ts new file mode 100644 index 000000000..810d31afb --- /dev/null +++ b/src/state/config.slice.ts @@ -0,0 +1,63 @@ +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +export interface ConfigState { + id: string; + name: string; + gameKey: string; + chartCount: number; + playerPicks: number; + upperBound: number; + lowerBound: number; + useWeights: boolean; + orderByAction: boolean; + hideVetos: boolean; + weights: Array; + probabilityBucketCount: number | null; + forceDistribution: boolean; + constrainPocketPicks: boolean; + style: string; + folders: Array; + difficulties: Array; + flags: Array; + cutoffDate: string; + defaultPlayersPerDraw: number; + sortByLevel: boolean; + useGranularLevels: boolean; +} + +export const defaultConfig: Omit = { + chartCount: 5, + playerPicks: 0, + upperBound: 0, + lowerBound: 0, + useWeights: false, + hideVetos: false, + orderByAction: true, + weights: [], + probabilityBucketCount: null, + forceDistribution: true, + constrainPocketPicks: true, + style: "", + cutoffDate: "", + folders: [], + difficulties: [], + flags: [], + sortByLevel: false, + defaultPlayersPerDraw: 2, + useGranularLevels: false, +}; + +const adapter = createEntityAdapter({}); + +export const configSlice = createSlice({ + name: "config", + initialState: adapter.getInitialState(), + reducers: { + addOne: adapter.addOne, + updateOne: adapter.updateOne, + removeOne: adapter.removeOne, + }, + selectors: { + ...adapter.getSelectors(), + }, +}); diff --git a/src/state/drawings.slice.ts b/src/state/drawings.slice.ts new file mode 100644 index 000000000..6624e46b9 --- /dev/null +++ b/src/state/drawings.slice.ts @@ -0,0 +1,196 @@ +import { + PayloadAction, + createEntityAdapter, + createSlice, +} from "@reduxjs/toolkit"; +import { + Drawing, + DrawnChart, + EligibleChart, + PlayerActionOnChart, +} from "../models/Drawing"; + +export const drawingsAdapter = createEntityAdapter({}); + +/** payload is the drawing id */ +type ActionOnSingleDrawing = PayloadAction; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type ActionOnSingleChart = PayloadAction< + { drawingId: string; chartId: string } & extra +>; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type PlayerActionOnChartPayload = PayloadAction< + { + drawingId: string; + chartId: string; + player: number; + reorder: boolean; + } & extra +>; + +export const drawingsSlice = createSlice({ + name: "drawings", + initialState: drawingsAdapter.getInitialState(), + reducers: { + addDrawing: drawingsAdapter.addOne, + updateOne: drawingsAdapter.updateOne, + removeOne: drawingsAdapter.removeOne, + clearDrawings: drawingsAdapter.removeAll, + addOneChart( + state, + action: PayloadAction<{ + drawingId: string; + chart: DrawnChart; + }>, + ) { + const drawing = state.entities[action.payload.drawingId]; + drawing.charts.push(action.payload.chart); + }, + updateOneChart( + state, + action: PayloadAction<{ + drawingId: string; + chartId: string; + changes: Partial; + }>, + ) { + const chart = state.entities[action.payload.drawingId].charts.find( + (c) => c.id === action.payload.chartId, + ); + if (!chart) { + return; + } + Object.assign(chart, action.payload.changes); + }, + swapPlayerPositions(state, action: ActionOnSingleDrawing) { + const drawing = state.entities[action.payload]; + if (!drawing) { + return; + } + drawing.playerDisplayOrder = drawing.playerDisplayOrder.toReversed(); + }, + incrementPriorityPlayer(state, action: ActionOnSingleDrawing) { + const drawing = state.entities[action.payload]; + if (!drawing) { + return; + } + let priorityPlayer = drawing.priorityPlayer; + if (!priorityPlayer) { + priorityPlayer = 1; + } else { + priorityPlayer += 1; + if (priorityPlayer >= drawing.playerDisplayOrder.length + 1) { + priorityPlayer = undefined; + } + } + drawing.priorityPlayer = priorityPlayer; + }, + resetChart(state, action: ActionOnSingleChart) { + const { chartId, drawingId } = action.payload; + const drawing = state.entities[drawingId]; + if (!drawing) { + return; + } + delete drawing.bans[chartId]; + delete drawing.protects[chartId]; + delete drawing.pocketPicks[chartId]; + delete drawing.winners[chartId]; + }, + banProtectReplace( + state, + action: PlayerActionOnChartPayload< + { type: "ban" | "protect" } | { type: "pocket"; pick: EligibleChart } + >, + ) { + const { chartId, drawingId, player, reorder } = action.payload; + const drawing = state.entities[drawingId]; + if (!drawing) { + return; + } + const playerAction: PlayerActionOnChart = { chartId, player }; + if (action.payload.type === "ban") { + if (reorder) { + moveChartInArray(drawing, chartId, "end"); + } + drawing.bans[chartId] = playerAction; + } else if (action.payload.type === "protect") { + if (reorder) { + moveChartInArray(drawing, chartId, "start"); + } + drawing.protects[chartId] = playerAction; + } else if (action.payload.type === "pocket") { + if (reorder) { + moveChartInArray(drawing, chartId, "start"); + } + drawing.pocketPicks[chartId] = { + chartId, + player, + pick: action.payload.pick, + }; + } + }, + setWinner(state, action: ActionOnSingleChart<{ player: number | null }>) { + const winners = state.entities[action.payload.drawingId].winners; + if (action.payload.player === null) { + delete winners[action.payload.chartId]; + } else { + winners[action.payload.chartId] = action.payload.player; + } + }, + addPlayerScore( + state, + action: PayloadAction<{ + drawingId: string; + chartId: string; + playerId: string; + score: number; + }>, + ) { + const { drawingId, playerId, chartId, score } = action.payload; + const drawing = state.entities[drawingId]; + if (!drawing) { + return; + } + if (drawing.meta.type !== "startgg") { + return; + } + if (!drawing.meta.scoresByEntrant) { + drawing.meta.scoresByEntrant = {}; + for (const entrant of drawing.meta.entrants) { + drawing.meta.scoresByEntrant[entrant.id] = {}; + } + } + drawing.meta.scoresByEntrant[playerId][chartId] = score; + }, + }, + selectors: { + haveDrawings(state) { + return !!state.ids.length; + }, + }, +}); + +export const drawingSelectors = drawingsAdapter.getSelectors( + drawingsSlice.selectSlice, +); + +function moveChartInArray( + drawing: Drawing, + chartId: string, + pos: "start" | "end", +) { + const targetChart = drawing.charts.find((c) => c.id === chartId); + if (!targetChart) { + return; + } + const chartsWithoutTarget = drawing.charts.filter((c) => c.id !== chartId); + if (pos === "start") { + const insertIdx = + Object.keys(drawing.protects).length + + Object.keys(drawing.pocketPicks).length; + chartsWithoutTarget.splice(insertIdx, 0, targetChart); + } else { + chartsWithoutTarget.push(targetChart); + } + drawing.charts = chartsWithoutTarget; +} diff --git a/src/state/event.slice.ts b/src/state/event.slice.ts new file mode 100644 index 000000000..9408a21c4 --- /dev/null +++ b/src/state/event.slice.ts @@ -0,0 +1,57 @@ +import { PayloadAction, createSelector, createSlice } from "@reduxjs/toolkit"; +import { nanoid } from "nanoid"; + +export interface CabInfo { + /** drawing id if active */ + activeMatch: string | null; + name: string; + id: string; +} + +interface EventState { + eventName: string; + cabs: Record; +} + +const initialState: EventState = { + eventName: "", + cabs: { + default: { + id: "default", + name: "Primary Cab", + activeMatch: null, + }, + }, +}; + +export const eventSlice = createSlice({ + name: "event", + initialState, + reducers: { + /** add a cab with its name */ + addCab(state, action: PayloadAction) { + const newCab: CabInfo = { + id: nanoid(5), + name: action.payload, + activeMatch: null, + }; + state.cabs[newCab.id] = newCab; + }, + removeCab(state, action: PayloadAction) { + delete state.cabs[action.payload]; + }, + assignMatchToCab( + state, + action: PayloadAction<{ cabId: string; matchId: string | null }>, + ) { + const cab = state.cabs[action.payload.cabId]; + if (!cab) return; + cab.activeMatch = action.payload.matchId; + }, + }, + selectors: { + allCabs: createSelector([(state: EventState) => state.cabs], (cabs) => { + return Object.values(cabs); + }), + }, +}); diff --git a/src/state/game-data.atoms.ts b/src/state/game-data.atoms.ts new file mode 100644 index 000000000..540826caa --- /dev/null +++ b/src/state/game-data.atoms.ts @@ -0,0 +1,44 @@ +import { atom, getDefaultStore, useAtomValue } from "jotai"; +import { GameData } from "../models/SongData"; +import { useEffect } from "react"; +import { atomFamily } from "jotai/utils"; + +export const stockDataCache = atom>({}); +export const customDataCache = atom>({}); + +export const stockDataByName = atomFamily((name: string) => + atom((get) => get(stockDataCache)[name]), +); + +export async function loadStockGamedataByName(name: string) { + const jotaiStore = getDefaultStore(); + const cache = jotaiStore.get(stockDataCache); + if (cache[name]) { + return cache[name]; + } + + try { + const data = ( + await import(/* webpackChunkName: "songData" */ `../songs/${name}.json`) + ).default as GameData; + jotaiStore.set(stockDataCache, (prev) => { + return { + ...prev, + [name]: data, + }; + }); + return data; + } catch (e) { + console.warn(`failed to load song data with key '${name}'`); + } +} + +export function useStockGameData(name: string): GameData | null { + const data = useAtomValue(stockDataByName(name)); + useEffect(() => { + if (!data && name) { + loadStockGamedataByName(name); + } + }, [data, name]); + return data || null; +} diff --git a/src/state/hooks.tsx b/src/state/hooks.tsx new file mode 100644 index 000000000..be3693975 --- /dev/null +++ b/src/state/hooks.tsx @@ -0,0 +1,57 @@ +import { useAppDispatch, useAppState } from "./store"; +import { EqualityFn } from "react-redux"; +import { createContext, useCallback, useContext } from "react"; +import { configSlice, type ConfigState } from "./config.slice"; +import { useStockGameData } from "./game-data.atoms"; + +const configContext = createContext(null); + +export const ConfigContextProvider = configContext.Provider; + +function useConfigId() { + const id = useContext(configContext); + if (!id) { + throw new Error("config id used without provider parent"); + } + return id; +} + +export function useConfigState( + selector?: (state: ConfigState) => T, + equalityFn?: EqualityFn, +) { + const configId = useConfigId(); + return useAppState((state) => { + const configObj = configSlice.selectors.selectById(state, configId); + if (!selector) return configObj as T; + return selector(configObj); + }, equalityFn); +} + +export function useGameData() { + const gameKey = useConfigState((c) => c.gameKey); + return useStockGameData(gameKey); +} + +export function useUpdateConfig() { + const configId = useConfigId(); + const dispatch = useAppDispatch(); + return useCallback( + ( + patch: + | Partial + | ((state: ConfigState) => Partial), + ) => { + dispatch((dispatch, getState) => { + if (typeof patch === "function") { + const state = configSlice.selectors.selectById(getState(), configId); + patch = patch(state); + } + dispatch( + configSlice.actions.updateOne({ id: configId, changes: patch }), + ); + }); + }, + [dispatch, configId], + ); +} diff --git a/src/state/listener-middleware.ts b/src/state/listener-middleware.ts new file mode 100644 index 000000000..c1612ae41 --- /dev/null +++ b/src/state/listener-middleware.ts @@ -0,0 +1,11 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit"; +import type { AppState, AppDispatch } from "./store"; + +const listener = createListenerMiddleware(); + +export const middleware = listener.middleware; + +export const startAppListening = listener.startListening.withTypes< + AppState, + AppDispatch +>(); diff --git a/src/state/root-reducer.ts b/src/state/root-reducer.ts new file mode 100644 index 000000000..ac7505292 --- /dev/null +++ b/src/state/root-reducer.ts @@ -0,0 +1,18 @@ +import { combineSlices } from "@reduxjs/toolkit"; +import { configSlice } from "./config.slice"; +import { drawingsSlice } from "./drawings.slice"; +import { receivePartyState } from "./central"; +import { eventSlice } from "./event.slice"; + +const combinedReducer = combineSlices(drawingsSlice, configSlice, eventSlice); + +export const reducer: typeof combinedReducer = (state, action) => { + if (receivePartyState.match(action)) { + return Object.assign({}, state, action.payload); + } + return combinedReducer(state, action); +}; + +reducer.inject = combinedReducer.inject; +reducer.withLazyLoadedSlices = combinedReducer.withLazyLoadedSlices; +reducer.selector = combinedReducer.selector; diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 000000000..37ab869d8 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,27 @@ +import { + configureStore, + ThunkAction, + ActionFromReducer, + createSelector, +} from "@reduxjs/toolkit"; +import { useDispatch, useSelector, useStore } from "react-redux"; +import { reducer } from "./root-reducer"; +import { middleware as listener } from "./listener-middleware"; + +export const store = configureStore({ + reducer, + middleware: (getDefaults) => getDefaults().concat(listener), +}); + +export type AppState = ReturnType; +export const useAppState = useSelector.withTypes(); +export const createAppSelector = createSelector.withTypes(); +export type AppDispatch = typeof store.dispatch; +export const useAppDispatch = useDispatch.withTypes(); +export const useAppStore = useStore.withTypes(); +export type AppThunk = ThunkAction< + ReturnType, + AppState, + unknown, + ActionFromReducer +>; diff --git a/src/state/thunks.ts b/src/state/thunks.ts new file mode 100644 index 000000000..f3044dc2e --- /dev/null +++ b/src/state/thunks.ts @@ -0,0 +1,289 @@ +import { AppThunk } from "./store"; +import { draw, StartggInfo } from "../card-draw"; +import { loadStockGamedataByName } from "./game-data.atoms"; +import { drawingSelectors, drawingsSlice } from "./drawings.slice"; +import { EligibleChart } from "../models/Drawing"; +import { configSlice, ConfigState, defaultConfig } from "./config.slice"; + +declare const umami: { + track( + eventName?: string, + eventProperties?: Record, + ): void; +}; + +function trackDraw(count: number | null, game?: string) { + if (typeof umami === "undefined") { + return; + } + const results = + count === null ? { result: "failed" } : { result: "success", count, game }; + umami.track("cards-drawn", results); +} + +/** + * Thunk creator for performing a new draw + * @returns false if draw was unsuccessful + */ +export function createDraw( + startggTargetSet: StartggInfo, + configId: string, +): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const config = configSlice.selectors.selectById(state, configId); + if (!config) { + console.error("couldnt draw, no config"); + return false; + } + const gameData = await loadStockGamedataByName(config.gameKey); + if (!gameData) { + console.error("couldnt draw, no game data"); + trackDraw(null); + return false; // no draw was possible + } + + const drawing = draw(gameData, config, startggTargetSet); + trackDraw(drawing.charts.length, gameData.i18n.en.name as string); + if (!drawing.charts.length) { + return false; // could not draw the requested number of charts + } + + dispatch(drawingsSlice.actions.addDrawing(drawing)); + }; +} + +/** + * thunk creator for redrawing all charts in a target drawing + */ +export function createRedrawAll(drawingId: string): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const drawing = state.drawings.entities[drawingId]; + const originalConfig = state.config.entities[drawing.configId]; + const drawConfig = { + ...originalConfig, + chartCount: drawing.charts.length, + }; + const gameData = await loadStockGamedataByName(originalConfig.gameKey); + + // preserve pocket picks and protects in the redraw by keeping them in the starting point info + // and filtering out all other charts + const protectedChartIds = new Set( + Object.keys(drawing.pocketPicks).concat(Object.keys(drawing.protects)), + ); + const chartsToKeep = drawing.charts.filter( + (chart) => + protectedChartIds.has(chart.id) || chart.type === "PLACEHOLDER", + ); + const startingPoint = { + ...drawing, + charts: chartsToKeep, + }; + + const drawResult = draw(gameData!, drawConfig, startingPoint); + dispatch( + drawingsSlice.actions.updateOne({ + id: drawingId, + changes: { + charts: chartsToKeep.concat(drawResult.charts), + pocketPicks: drawing.pocketPicks, + bans: {}, + protects: drawing.protects, + winners: {}, + }, + }), + ); + }; +} + +/** + * thunk creator for redrawing a single chart within a drawing + */ +export function createRedrawChart( + drawingId: string, + chartId: string, +): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const drawing = state.drawings.entities[drawingId]; + const originalConfig = state.config.entities[drawing.configId]; + const gameData = await loadStockGamedataByName(originalConfig.gameKey); + if (!gameData) return; + const startingPoint = { + ...drawing, + charts: drawing.charts.filter((chart) => chart.id !== chartId), + }; + + const drawResult = draw(gameData, originalConfig, startingPoint); + const chart = drawResult.charts.pop(); + if ( + !chart || + chart.type !== "DRAWN" || + drawing.charts.some((c) => c.id === chart.id) + ) { + return; // result didn't include a new chart + } + dispatch( + drawingsSlice.actions.updateOneChart({ + drawingId, + chartId, + changes: chart, + }), + ); + }; +} + +/** + * thunk creator for redrawing a single chart within a drawing + */ +export function createPlusOneChart(drawingId: string): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const drawing = state.drawings.entities[drawingId]; + const originalConfig = state.config.entities[drawing.configId]; + const gameData = await loadStockGamedataByName(originalConfig.gameKey); + if (!gameData) return; + + const customConfig: ConfigState = { + ...originalConfig, + // force drawing one more chart than already exists + chartCount: + 1 + + drawing.charts.reduce( + (acc, curr) => (curr.type === "DRAWN" ? acc + 1 : acc), + 0, + ), + }; + + const drawResult = draw(gameData, customConfig, drawing); + const chart = drawResult.charts.pop(); + if ( + !chart || + chart.type !== "DRAWN" || + drawing.charts.some((c) => c.id === chart.id) + ) { + return; // result didn't include a new chart + } + dispatch( + drawingsSlice.actions.addOneChart({ + drawingId, + chart, + }), + ); + }; +} + +/** thunk creator for pick/ban/pocket pick that can include orderByAction setting */ +export function createPickBanPocket( + drawingId: string, + chartId: string, + type: "ban" | "protect" | "pocket", + player: number, + pick?: EligibleChart, +): AppThunk { + return (dispatch, getState) => { + const state = getState(); + const drawing = drawingSelectors.selectById(state, drawingId); + const reorder = !!configSlice.selectors.selectById(state, drawing.configId) + ?.orderByAction; + let action; + if (type === "pocket") { + if (pick) { + action = drawingsSlice.actions.banProtectReplace({ + drawingId, + chartId, + type, + player, + pick, + reorder, + }); + } + } else { + action = drawingsSlice.actions.banProtectReplace({ + drawingId, + chartId, + type, + player, + reorder, + }); + } + if (action) { + dispatch(action); + } + }; +} + +import { GameData } from "../models/SongData"; +import { nanoid } from "nanoid"; + +function getOverridesFromGameData(gameData?: GameData): Partial { + if (!gameData) return {}; + const { + flags, + difficulties, + folders, + style, + lowerLvlBound: lowerBound, + upperLvlBound: upperBound, + } = gameData.defaults; + const gameSpecificOverrides: Partial = { + lowerBound, + upperBound, + flags, + difficulties, + style, + cutoffDate: "", + }; + if (folders) { + gameSpecificOverrides.folders = folders; + } + if (!gameData.meta.granularTierResolution) { + gameSpecificOverrides.useGranularLevels = false; + } + return gameSpecificOverrides; +} + +export function createConfigFromInputs( + name: string, + gameKey: string, + basisConfigId?: string, +): AppThunk> { + return async (dispatch, getState) => { + const gameData = await loadStockGamedataByName(gameKey); + const basisConfig = basisConfigId + ? getState().config.entities[basisConfigId] + : {}; + const newConfig: ConfigState = { + ...defaultConfig, + ...getOverridesFromGameData(gameData), + ...basisConfig, + id: nanoid(10), + name, + gameKey, + }; + dispatch(configSlice.actions.addOne(newConfig)); + return newConfig; + }; +} + +export function createConfigFromImport( + name: string, + gameKey: string, + imported: ConfigState, +): AppThunk> { + return async (dispatch) => { + const gameData = await loadStockGamedataByName(gameKey); + const basisConfig = imported; + const newConfig: ConfigState = { + ...defaultConfig, + ...getOverridesFromGameData(gameData), + ...basisConfig, + id: nanoid(10), + name, + gameKey, + }; + dispatch(configSlice.actions.addOne(newConfig)); + return newConfig; + }; +} diff --git a/src/state/util.ts b/src/state/util.ts new file mode 100644 index 000000000..daaaec6b6 --- /dev/null +++ b/src/state/util.ts @@ -0,0 +1,3 @@ +export function withPayload() { + return (t: T) => ({ payload: t }); +} diff --git a/src/tournament-mode/drawing-actions.tsx b/src/tournament-mode/drawing-actions.tsx index 7086238d7..634dc5bf8 100644 --- a/src/tournament-mode/drawing-actions.tsx +++ b/src/tournament-mode/drawing-actions.tsx @@ -1,105 +1,190 @@ import { Button, - Icon, + Dialog, + DialogBody, Menu, MenuItem, Popover, Tooltip, } from "@blueprintjs/core"; import { - SendMessage, - Changes, - Share, Camera, - Refresh, - NewPerson, - BlockedPerson, + CubeAdd, Error, + Exchange, + FloppyDisk, + NewLayer, + Refresh, + Th, + Trash, } from "@blueprintjs/icons"; -import { useDrawing, useDrawingStore } from "../drawing-context"; -import styles from "./drawing-actions.css"; -import { CurrentPeersMenu } from "./remote-peer-menu"; -import { displayFromPeerId, useRemotePeers } from "./remote-peers"; +import { useAtomValue } from "jotai"; import { domToPng } from "modern-screenshot"; -import { shareImage } from "../utils/share"; -import { firstOf } from "../utils"; -import { useConfigState } from "../config-state"; +import { useState, lazy } from "react"; import { useErrorBoundary } from "react-error-boundary"; +import { showPlayerAndRoundLabels } from "../config-state"; +import { useDrawing } from "../drawing-context"; +import { playerCount, StartggGauntletMeta } from "../models/Drawing"; +import { + BracketSetGameDataInput as GDI, + ReportSetMutationVariables as MutationVariables, + useReportSetMutation, +} from "../startgg-gql"; +import { drawingsSlice } from "../state/drawings.slice"; +import { eventSlice } from "../state/event.slice"; +import { AppThunk, useAppDispatch, useAppState } from "../state/store"; +import { createPlusOneChart, createRedrawAll } from "../state/thunks"; +import { CountingSet } from "../utils/counting-set"; +import { shareImage } from "../utils/share"; +import styles from "./drawing-actions.css"; + +const GauntletEditor = lazy(() => import("./gauntlet-scores")); + +/** thunk that dispatches nothing, but calculates the result to be sent to startgg */ +function getMatchResult( + drawingId: string, +): AppThunk { + return (_, getState): MutationVariables | undefined => { + const s = getState(); + const gameData: Array = []; + const drawing = s.drawings.entities[drawingId]; + if (drawing.meta.type !== "startgg") { + return; + } + const winsPerPlayer = new CountingSet(); + for (const [songId, pIdx] of Object.entries(drawing.winners)) { + if (pIdx === null) { + continue; + } + try { + const entrant = drawing.meta.entrants[pIdx]; + gameData.push({ + gameNum: gameData.length + 1, + winnerId: entrant.id, + }); + winsPerPlayer.add(entrant.id); + } catch (e) { + console.warn(`failed to add game data for song ${songId}`, e); + } + } + let winnerId: string | undefined; + const orderedByWins = Array.from(winsPerPlayer.valuesWithCount()).sort( + (a, b) => b[1] - a[1], + ); + if ( + orderedByWins.length == 1 || + orderedByWins[0][1] > orderedByWins[1][1] + ) { + // confirmed no tie for first place + winnerId = orderedByWins[0][0]; + } + const ret: MutationVariables = { + setId: drawing.meta.id, + winnerId, + }; + if (gameData.length) { + ret.gameData = gameData; + } + return ret; + }; +} const DEFAULT_FILENAME = "card-draw.png"; +function SaveToStartggButton() { + const dispatch = useAppDispatch(); + const drawingId = useDrawing((s) => s.id); + const drawingMeta = useDrawing((s) => s.meta); + const [mutationData, reportSet] = useReportSetMutation(); + if (drawingMeta.type !== "startgg" || drawingMeta.subtype !== "versus") { + return null; + } + + let tooltipContent = "Save Winner to Start.gg"; + if (mutationData.error) { + tooltipContent = `Error saving: ${mutationData.error.message}`; + } + if (mutationData.fetching) { + tooltipContent = "Saving..."; + } + + return ( + +
); } diff --git a/src/tournament-mode/drawing-labels.css b/src/tournament-mode/drawing-labels.css index 782449468..ab6818fea 100644 --- a/src/tournament-mode/drawing-labels.css +++ b/src/tournament-mode/drawing-labels.css @@ -35,6 +35,7 @@ .players > * { flex: 1 1 250px; + text-align: center; } .versus + .players { diff --git a/src/tournament-mode/drawing-labels.tsx b/src/tournament-mode/drawing-labels.tsx index e19f6d5c8..e0680c86a 100644 --- a/src/tournament-mode/drawing-labels.tsx +++ b/src/tournament-mode/drawing-labels.tsx @@ -1,43 +1,75 @@ import { useCallback } from "react"; -import { useConfigState } from "../config-state"; import { useDrawing } from "../drawing-context"; import styles from "./drawing-labels.css"; -import { AutoCompleteSelect, RoundSelect } from "./round-select"; import { Icon } from "@blueprintjs/core"; import { CaretLeft, CaretRight } from "@blueprintjs/icons"; +import { useAtomValue } from "jotai"; +import { showPlayerAndRoundLabels } from "../config-state"; +import { useAppDispatch } from "../state/store"; +import { drawingsSlice } from "../state/drawings.slice"; +import { getAllPlayers } from "../models/Drawing"; +import { CountingSet } from "../utils/counting-set"; export function SetLabels() { - const showLabels = useConfigState((s) => s.showPlayerAndRoundLabels); - const players = useDrawing((s) => s.players); + const showLabels = useAtomValue(showPlayerAndRoundLabels); + const playerDisplayOrder = useDrawing((d) => d.playerDisplayOrder); + const meta = useDrawing((d) => d.meta); + const winners = useDrawing((d) => d.winners); if (!showLabels) { return null; } + const hideWins = meta.type === "startgg" && meta.subtype === "gauntlet"; + let winsPerPlayer: CountingSet | undefined; + if (!hideWins) { + winsPerPlayer = new CountingSet(); + for (const pIdx of Object.values(winners)) { + if (pIdx === null) { + continue; + } + winsPerPlayer.add(pIdx); + } + } + + const allPlayers = getAllPlayers({ meta, playerDisplayOrder }); + return (
-
- -
+
{meta.title}
- {players.map((p, idx) => ( - - ))} + {allPlayers.map((name, idx) => { + const winCount = winsPerPlayer ? ( + <> ({winsPerPlayer.get(playerDisplayOrder[idx])}) + ) : null; + const ret = ( + + {name} + {winCount} + + ); + if (idx === 0 && allPlayers.length === 2) { + return ( + <> + {ret} + + + ); + } + return ret; + })}
); } function Versus() { - const players = useDrawing((s) => s.players); - const ipp = useDrawing((s) => s.incrementPriorityPlayer); + const dispatch = useAppDispatch(); + const drawingId = useDrawing((s) => s.id); + const ipp = useCallback( + () => dispatch(drawingsSlice.actions.incrementPriorityPlayer(drawingId)), + [dispatch, drawingId], + ); const priorityPlayer = useDrawing((s) => s.priorityPlayer); - if (players.length !== 2) { - return null; - } return (
); } - -function PlayerLabel({ - playerIndex, - placeholder, -}: { - playerIndex: number; - placeholder: string; -}) { - const updateDrawing = useDrawing((s) => s.updateDrawing); - const value = useDrawing((s) => s.players[playerIndex - 1] || null); - const playerNames = useConfigState((s) => s.playerNames); - const updateConfig = useConfigState((s) => s.update); - const handleChange = useCallback( - (value: string) => { - updateDrawing((drawing) => { - const prev = drawing.players.slice(); - prev[playerIndex - 1] = value; - return { players: prev }; - }); - if (!playerNames.includes(value)) { - updateConfig((prev) => { - const nextNames = prev.playerNames.slice(); - nextNames.push(value); - return { playerNames: nextNames }; - }); - } - }, - [updateDrawing, playerIndex, playerNames, updateConfig], - ); - const ret = ( - - ); - if (playerIndex === 1) { - return ( - <> - {ret} - - - ); - } - return ret; -} diff --git a/src/tournament-mode/gauntlet-scores.tsx b/src/tournament-mode/gauntlet-scores.tsx new file mode 100644 index 000000000..85c29210f --- /dev/null +++ b/src/tournament-mode/gauntlet-scores.tsx @@ -0,0 +1,124 @@ +import { HotkeysProvider } from "@blueprintjs/core"; +import { + Column, + Table2, + EditableCell2, + ColumnProps, + Cell, +} from "@blueprintjs/table"; +import { useDrawing } from "../drawing-context"; +import { type DrawnChart, StartggGauntletMeta } from "../models/Drawing"; +import { ReactElement, useState } from "react"; +import { inferShortname } from "../controls/player-names"; +import { useDispatch } from "react-redux"; +import { drawingsSlice } from "../state/drawings.slice"; +import { ScoreSortableColumn } from "./sortable-columns"; + +export default function GauntletScoreEditor({ + meta, +}: { + meta: StartggGauntletMeta; +}) { + const drawingId = useDrawing((d) => d.id); + const bans = useDrawing((d) => d.bans); + const pocketPicks = useDrawing((d) => d.pocketPicks); + const charts = useDrawing((d) => d.charts).filter( + (c): c is DrawnChart => c.type === "DRAWN" && !bans[c.id], + ); + const dispatch = useDispatch(); + const [playerOrderMap, setPlayerOrderMap] = useState( + meta.entrants.map((_, idx) => idx), + ); + + const players = meta.entrants; + + function updateScore(playerIdx: number, chartId: string, rawInput: string) { + const playerId = players[playerIdx].id; + const score = Number.parseInt(rawInput, 10); + if (!Number.isSafeInteger(score)) { + return; + } + dispatch( + drawingsSlice.actions.addPlayerScore({ + drawingId, + chartId, + playerId, + score, + }), + ); + } + + function playerCellRenderer(displayIdx: number) { + const playerIdx = playerOrderMap[displayIdx]; + return {inferShortname(players[playerIdx].name)}; + } + function getPlayerScore(displayIdx: number, chartId: string) { + const playerIdx = playerOrderMap[displayIdx]; + if (meta.scoresByEntrant) { + const playerId = players[playerIdx].id; + const scoreNum = meta.scoresByEntrant[playerId][chartId]; + if (typeof scoreNum === "number") { + return scoreNum; + } + } + } + + function playerScoreRenderer(displayIdx: number, chartId: string) { + const playerIdx = playerOrderMap[displayIdx]; + const score = getPlayerScore(displayIdx, chartId)?.toLocaleString(); + return ( + updateScore(playerIdx, chartId, value)} + /> + ); + } + + const chartCols = charts.map>((c) => { + const maybeReplacedBy = pocketPicks[c.id]?.pick; + let songName = c.nameTranslation || c.name; + if (maybeReplacedBy) { + songName = maybeReplacedBy.nameTranslation || maybeReplacedBy.name; + } + const sortableColumn = new ScoreSortableColumn(songName, c.id); + return sortableColumn.getColumn( + (rowIdx) => playerScoreRenderer(rowIdx, c.id), + (chartId, comparator) => { + setPlayerOrderMap((prev) => { + const next = prev.slice(); + next.sort((aIdx, bIdx) => { + const aScore = getPlayerScore(aIdx, chartId); + const bScore = getPlayerScore(bIdx, chartId); + return comparator(aScore, bScore); + }); + return next; + }); + }, + ); + }); + + chartCols.unshift( + , + ); + + return ( + + + {chartCols} + + + ); +} diff --git a/src/tournament-mode/remote-peer-menu.tsx b/src/tournament-mode/remote-peer-menu.tsx deleted file mode 100644 index 3a06de27b..000000000 --- a/src/tournament-mode/remote-peer-menu.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { - Tag, - FormGroup, - MenuItem, - Collapse, - Button, - Intent, -} from "@blueprintjs/core"; -import { Clipboard, Duplicate, HeartBroken } from "@blueprintjs/icons"; -import { InputButtonPair } from "../controls/input-button-pair"; -import { toaster } from "../toaster"; -import { displayFromPeerId, useRemotePeers } from "./remote-peers"; - -export function RemotePeerControls() { - const peers = useRemotePeers(); - - let displayName = "(no name set)"; - if (peers.thisPeer) { - displayName = displayFromPeerId(peers.thisPeer.id); - } - - let coreControl: JSX.Element; - // Copied to keyboard success toaster - function copyToaster() { - navigator.clipboard.writeText(displayName); - toaster.show({ - message: "Hostname copied to clipboard", - intent: Intent.SUCCESS, - icon: , - }); - } - // Copy to keyboard button - const copyButton = ( -