diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..4de20922d --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,47 @@ +--- +name: verify +description: Build, launch, and drive DDRCardDraw locally to verify changes at the browser surface. +--- + +# Verifying DDRCardDraw changes + +## Build & launch + +Two dev servers, both from the repo root (partykit needs `partykit.json` in cwd): + +```bash +yarn install +npx partykit dev --port 1999 # websocket/state backend (event mode) +yarn start:frontend # webpack dev server on :8080 +``` + +Wait until `curl http://localhost:1999/parties/main/anyroom` returns 200 (partykit) +and `curl http://localhost:8080/` returns 200 (webpack; first build takes ~1-2 min). + +## Drive + +Playwright (chromium at `/opt/pw-browsers/chromium-*/chrome-linux/chrome` in remote +envs) against: + +- `http://localhost:8080/classic` — classic mode, no backend needed. +- `http://localhost:8080/e/` — event/tournament mode, backed by the + partykit socket. Rooms are created on first visit. + +Useful flows in event mode: + +- Expand the cab panel: click the lone caret button in the 40px-wide left rail. +- Add a cab: fill `input[placeholder="Cab name"]`, press Enter — dispatches a + redux action that round-trips through the partykit socket. +- Kill/restart `partykit dev` to exercise disconnect/reconnect handling + (toasts appear via the app-root OverlayToaster). + +## Gotchas + +- `pkill -f 'partykit dev'` matches its own wrapping shell — use `pkill -f '[p]artykit dev'`. +- `partykit dev` spawns a `workerd` child that actually owns port 1999 and the + websockets. To simulate a stalled/dead server, signal `'[w]orkerd serve'` + (SIGSTOP freezes it without closing sockets — great for ack-timeout tests); + killing only the node wrapper leaves the server running. Playwright's + `page.on("websocket")` frame events give protocol-level evidence. +- Dev `partykit dev` persists room state to disk, so room contents survive a backend restart. +- In dev, the frontend targets `localhost:1999` automatically (`src/party/host.ts`). 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/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index f741a3433..8f582522f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -10,9 +10,9 @@ jobs: strategy: matrix: include: - - check: "validate:json" + - check: "validate:types:data" name: Validate Song Data - - check: "validate:ts" + - check: "validate:types:ts" name: Typecheck - check: "validate:format" name: Formatting diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 14a40cf69..e608be2c2 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,5 +1,9 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "printWidth": 80, - "ignorePatterns": ["src/assets/jackets/*", "tsconfig.json"] + "ignorePatterns": [ + "src/assets/jackets/*", + "tsconfig.json", + "src/startgg-gql/generated/*" + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 27511076e..2bf2ab639 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,7 +17,12 @@ "env": { "builtin": true }, - "ignorePatterns": ["scripts/", "surge-redirect/", "webpack.config.js"], + "ignorePatterns": [ + "scripts/", + "surge-redirect/", + "webpack.config.js", + "src/party/database.types.ts" + ], "rules": { // #region typescript-eslint recommended (excepts correctness rules) "no-array-constructor": "error", diff --git a/.yarnrc.yml b/.yarnrc.yml index d91cf2aa7..ba22fa169 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,7 +1,5 @@ compressionLevel: mixed -enableGlobalCache: false - nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.12.0.cjs 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/docs/images/obs/custom-font.png b/docs/images/obs/custom-font.png new file mode 100644 index 000000000..e18c9be8e Binary files /dev/null and b/docs/images/obs/custom-font.png differ diff --git a/docs/images/obs/obs-source-add.png b/docs/images/obs/obs-source-add.png new file mode 100644 index 000000000..cd7b6fcec Binary files /dev/null and b/docs/images/obs/obs-source-add.png differ diff --git a/docs/images/obs/obs-source-properties.png b/docs/images/obs/obs-source-properties.png new file mode 100644 index 000000000..d236cc852 Binary files /dev/null and b/docs/images/obs/obs-source-properties.png differ diff --git a/docs/images/obs/simple-css.png b/docs/images/obs/simple-css.png new file mode 100644 index 000000000..e938bbdff Binary files /dev/null and b/docs/images/obs/simple-css.png differ diff --git a/docs/images/obs/source-menu.png b/docs/images/obs/source-menu.png new file mode 100644 index 000000000..6ae23b3eb Binary files /dev/null and b/docs/images/obs/source-menu.png differ diff --git a/docs/obs.md b/docs/obs.md new file mode 100644 index 000000000..f258d1ef6 --- /dev/null +++ b/docs/obs.md @@ -0,0 +1,86 @@ +# OBS Sources + +The app provides a number of single-purpose URLs for use within OBS as browser type source. Each individual cab has its own set of sources, which will update as the match assigned to that cab changes. + +![alt text](images/obs/source-menu.png) + +After clicking on one, the URL will be put on your clipboard. In OBS you can add that info to your stream layout by adding a new browser source. + +![alt text](images/obs/obs-source-add.png) + +Paste the copied URL into the properties and click OK. + +![alt text](images/obs/obs-source-properties.png) + +If you have a match assigned to the chosen cab, then you should see the text appear immediately. If nothing is assigned to the cab, then the source will display nothing at all. + +## Custom styles + +Adding custom CSS to text-based sources is a great way to help the info fit the graphic design of the rest of your stream graphics. + +All text-based sources from DDR Tools render inside a single `h1` HTML tag. Keep the existing default styles for `body` and add extra styles below. As a simple example: + +```css +body { + background-color: rgba(0, 0, 0, 0); + margin: 0px auto; + overflow: hidden; +} +h1 { + color: black; + font-size: 600%; + text-shadow: 2px 2px 2px white; + -webkit-text-stroke: 2px red; +} +``` + +![alt text](images/obs/simple-css.png) + +For more info on styling text with CSS, refer to [MDN documentation](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Text_styling). + +Some common CSS style rules that will likely be useful for a stream layout: + +- [text-align](https://developer.mozilla.org/en-US/docs/Web/CSS/text-align) ([more on this below](#text-alignment)) +- [font-family](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) ([more on this below](#custom-fonts)) +- [color](https://developer.mozilla.org/en-US/docs/Web/CSS/color) +- [font-size](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size) +- [font-weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight) +- [font-style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style) +- [text-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow) +- [-webkit-text-stroke](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-text-stroke) + +### Text alignment + +In order to have text position correctly within the layout as it updates with values of varying lengths, it's important that you set the correct alignment direction! Text will align left by default, but if it should be visually centered in the layout (e.g. a title) or aligned against the right side (e.g. name of a player on the right) then the alignment should be set accordingly. + +If you use no other custom styles, at least set the alignment of anything not left-aligned correctly with one of: + +- `text-align: center;` +- `text-align: right;` + +### Custom Fonts + +The font of choice can be chosen with a `font-family` rule. By default it will have access to any font you have installed locally on your system. Additional fonts can be used without any installation steps directly from google fonts: + +1. Visit [fonts.google.com](https://fonts.google.com) and find something you would like to use. (for example, [Tektur](https://fonts.google.com/specimen/Tektur?categoryFilters=Feeling:%2FExpressive%2FFuturistic)) +2. Click the "Get Font" button on the font page, and then the "Get embed code" button. +3. On the web tab, switch to `@import` style. +4. Copy the contents of the `; +} + 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/assets/i18n.json b/src/assets/i18n.json index 75feeaf84..19141ba7e 100644 --- a/src/assets/i18n.json +++ b/src/assets/i18n.json @@ -1,8 +1,9 @@ { "en": { "hero": { - "description": "Click 'Draw' above to draw some songs at random. Choose from other games in the top left menu.", - "callout": "Instant local pack imports are now available! Drag and drop a pack folder onto this page to get started." + "description": "Click 'Draw' above to draw some songs at random after setting up a draw configuration.", + "previewDesc": "You are currently previewing pre-made draw configurations for an event. Pick from the config presets in the top right corner, and then click 'Draw'.", + "callout": "This is a new, fully redesigned version specifically for use in tournaments. It has many new features, but is under active development and not quite stable yet." }, "clearDrawings": "Clear Draws", "clearDrawingsConfirm": "This will clear all songs drawn so far. Confirm?", @@ -106,6 +107,12 @@ "updateLoading": "Installing an update...", "updateReady": "Update ready, refresh for the freshest code around!", "applyUpdate": "Apply", + "party": { + "disconnected": "Lost connection to the event server. Changes are disabled until reconnected.", + "reconnected": "Reconnected to the event server!", + "actionBlocked": "Still reconnecting to the event server. Your change was not applied.", + "sendFailed": "A change could not be delivered to the event server after several attempts." + }, "error": { "title": "Error Caught!", "description": "A deeply embarassing error just happened somewhere near here. If you're lucky, a reset will fix things up.", @@ -113,10 +120,6 @@ } }, "ja": { - "hero": { - "description": "右上の「ドロー」をクリックすると、ランダムに曲が選ばれます。左上のメニューから他のゲームを選択できます。", - "callout": "ローカルパックの取り込み機能が利用可能になりました!開始するには、パックフォルダをこのページにドラッグ&ドロップしてください。" - }, "clearDrawings": "全消去", "clearDrawingsConfirm": "これまでに引いた譜面をすべて消去します。よろしいですか?", "credits": "クレジット", @@ -219,6 +222,12 @@ "updateLoading": "更新中...", "updateReady": "更新の準備ができました、このページをリロードしてください!", "applyUpdate": "更新を適用", + "party": { + "disconnected": "イベントサーバーとの接続が切断されました。再接続するまで変更できません。", + "reconnected": "イベントサーバーに再接続しました!", + "actionBlocked": "イベントサーバーに再接続中のため、変更は適用されませんでした。", + "sendFailed": "複数回試みましたが、変更をイベントサーバーへ送信できませんでした。" + }, "error": { "title": "エラーが発生しました!", "description": "恥ずかしながら、どこかでエラーが発生したようです。リセットすることで解決するかもしれません。", diff --git a/src/assets/jackets/itg/syr5/[10] 34+35.jpg b/src/assets/jackets/itg/syr5/[10] 34+35.jpg new file mode 100644 index 000000000..d0ca76210 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] 34+35.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] A Drop.jpg b/src/assets/jackets/itg/syr5/[10] A Drop.jpg new file mode 100644 index 000000000..d05cf0482 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] A Drop.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Baila Conmigo.jpg b/src/assets/jackets/itg/syr5/[10] Baila Conmigo.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Baila Conmigo.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Blade.jpg b/src/assets/jackets/itg/syr5/[10] Blade.jpg new file mode 100644 index 000000000..0cbdc2bae Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Blade.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Doodles.jpg b/src/assets/jackets/itg/syr5/[10] Doodles.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Doodles.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Freak Out.jpg b/src/assets/jackets/itg/syr5/[10] Freak Out.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Freak Out.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Fun Tonight.jpg b/src/assets/jackets/itg/syr5/[10] Fun Tonight.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Fun Tonight.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Happy Dance.jpg b/src/assets/jackets/itg/syr5/[10] Happy Dance.jpg new file mode 100644 index 000000000..f65d4f060 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Happy Dance.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Houdini.jpg b/src/assets/jackets/itg/syr5/[10] Houdini.jpg new file mode 100644 index 000000000..da0ff24e7 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Houdini.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] NASTY (ELIMINATE REMIX).jpg b/src/assets/jackets/itg/syr5/[10] NASTY (ELIMINATE REMIX).jpg new file mode 100644 index 000000000..58701bd0d Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] NASTY (ELIMINATE REMIX).jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] Never Gonna Give You Up.jpg b/src/assets/jackets/itg/syr5/[10] Never Gonna Give You Up.jpg new file mode 100644 index 000000000..1c16eb679 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] Never Gonna Give You Up.jpg differ diff --git a/src/assets/jackets/itg/syr5/[10] the CIA.jpg b/src/assets/jackets/itg/syr5/[10] the CIA.jpg new file mode 100644 index 000000000..5a33ee167 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[10] the CIA.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] AWAKEN.jpg b/src/assets/jackets/itg/syr5/[11] AWAKEN.jpg new file mode 100644 index 000000000..a6d677c04 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] AWAKEN.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Bullet Waiting for Me.jpg b/src/assets/jackets/itg/syr5/[11] Bullet Waiting for Me.jpg new file mode 100644 index 000000000..a3ee1f34d Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Bullet Waiting for Me.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Dirty Vibe.jpg b/src/assets/jackets/itg/syr5/[11] Dirty Vibe.jpg new file mode 100644 index 000000000..beec3aa04 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Dirty Vibe.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] EXPERIENCE.jpg b/src/assets/jackets/itg/syr5/[11] EXPERIENCE.jpg new file mode 100644 index 000000000..b64c2f503 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] EXPERIENCE.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Fly Me To The Moon.jpg b/src/assets/jackets/itg/syr5/[11] Fly Me To The Moon.jpg new file mode 100644 index 000000000..49f5025db Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Fly Me To The Moon.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Gang Baby.jpg b/src/assets/jackets/itg/syr5/[11] Gang Baby.jpg new file mode 100644 index 000000000..8a2b8d5d0 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Gang Baby.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Ghost Ship.jpg b/src/assets/jackets/itg/syr5/[11] Ghost Ship.jpg new file mode 100644 index 000000000..199d5a763 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Ghost Ship.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Hidamaream.jpg b/src/assets/jackets/itg/syr5/[11] Hidamaream.jpg new file mode 100644 index 000000000..02bb913a1 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Hidamaream.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] MORJA KAIJU VIP.jpg b/src/assets/jackets/itg/syr5/[11] MORJA KAIJU VIP.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] MORJA KAIJU VIP.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Rain.jpg b/src/assets/jackets/itg/syr5/[11] Rain.jpg new file mode 100644 index 000000000..b353eafa8 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Rain.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] SAY.jpg b/src/assets/jackets/itg/syr5/[11] SAY.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] SAY.jpg differ diff --git a/src/assets/jackets/itg/syr5/[11] Scissor.jpg b/src/assets/jackets/itg/syr5/[11] Scissor.jpg new file mode 100644 index 000000000..b49f540db Binary files /dev/null and b/src/assets/jackets/itg/syr5/[11] Scissor.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Amanojaku.jpg b/src/assets/jackets/itg/syr5/[12] Amanojaku.jpg new file mode 100644 index 000000000..2a4a2d536 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Amanojaku.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Anomaly Remix.jpg b/src/assets/jackets/itg/syr5/[12] Anomaly Remix.jpg new file mode 100644 index 000000000..f3e58a223 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Anomaly Remix.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Boiled Yatsuhashi (VIP).jpg b/src/assets/jackets/itg/syr5/[12] Boiled Yatsuhashi (VIP).jpg new file mode 100644 index 000000000..0ea77e17e Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Boiled Yatsuhashi (VIP).jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Distress.jpg b/src/assets/jackets/itg/syr5/[12] Distress.jpg new file mode 100644 index 000000000..f65d4f060 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Distress.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] GIGA OMEGA VIP.jpg b/src/assets/jackets/itg/syr5/[12] GIGA OMEGA VIP.jpg new file mode 100644 index 000000000..700414de3 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] GIGA OMEGA VIP.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] I GOT THE FEELIN.jpg b/src/assets/jackets/itg/syr5/[12] I GOT THE FEELIN.jpg new file mode 100644 index 000000000..1361cff41 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] I GOT THE FEELIN.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] I Still Drop The Bass.jpg b/src/assets/jackets/itg/syr5/[12] I Still Drop The Bass.jpg new file mode 100644 index 000000000..34b9a4f06 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] I Still Drop The Bass.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Infiltration.jpg b/src/assets/jackets/itg/syr5/[12] Infiltration.jpg new file mode 100644 index 000000000..416c40495 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Infiltration.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Kachi Kachi Yama.jpg b/src/assets/jackets/itg/syr5/[12] Kachi Kachi Yama.jpg new file mode 100644 index 000000000..a351cc15c Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Kachi Kachi Yama.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] MURDER PLOT.jpg b/src/assets/jackets/itg/syr5/[12] MURDER PLOT.jpg new file mode 100644 index 000000000..7370eb06c Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] MURDER PLOT.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Satellite.jpg b/src/assets/jackets/itg/syr5/[12] Satellite.jpg new file mode 100644 index 000000000..142e70889 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Satellite.jpg differ diff --git a/src/assets/jackets/itg/syr5/[12] Take Me Back.jpg b/src/assets/jackets/itg/syr5/[12] Take Me Back.jpg new file mode 100644 index 000000000..905cdbe3f Binary files /dev/null and b/src/assets/jackets/itg/syr5/[12] Take Me Back.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] BEAST BASS BOMB.jpg b/src/assets/jackets/itg/syr5/[13] BEAST BASS BOMB.jpg new file mode 100644 index 000000000..c8150a78c Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] BEAST BASS BOMB.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Banjo Kazooie Main Theme.jpg b/src/assets/jackets/itg/syr5/[13] Banjo Kazooie Main Theme.jpg new file mode 100644 index 000000000..ba67ea325 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Banjo Kazooie Main Theme.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Came To Rage.jpg b/src/assets/jackets/itg/syr5/[13] Came To Rage.jpg new file mode 100644 index 000000000..7640318ea Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Came To Rage.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Dance Through the Danger.jpg b/src/assets/jackets/itg/syr5/[13] Dance Through the Danger.jpg new file mode 100644 index 000000000..856d268c8 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Dance Through the Danger.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Freedom.jpg b/src/assets/jackets/itg/syr5/[13] Freedom.jpg new file mode 100644 index 000000000..cbfebefc6 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Freedom.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Hi-tech Tokio.jpg b/src/assets/jackets/itg/syr5/[13] Hi-tech Tokio.jpg new file mode 100644 index 000000000..2e92d0549 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Hi-tech Tokio.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Naatu Naatu.jpg b/src/assets/jackets/itg/syr5/[13] Naatu Naatu.jpg new file mode 100644 index 000000000..f65d4f060 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Naatu Naatu.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] STEEL NOODLE.jpg b/src/assets/jackets/itg/syr5/[13] STEEL NOODLE.jpg new file mode 100644 index 000000000..66d5e9537 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] STEEL NOODLE.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] Worst Plan.jpg b/src/assets/jackets/itg/syr5/[13] Worst Plan.jpg new file mode 100644 index 000000000..f371dae16 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] Worst Plan.jpg differ diff --git a/src/assets/jackets/itg/syr5/[13] monolith.jpg b/src/assets/jackets/itg/syr5/[13] monolith.jpg new file mode 100644 index 000000000..79585aac7 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[13] monolith.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] BMF.jpg b/src/assets/jackets/itg/syr5/[8] BMF.jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] BMF.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Bonbiskip!!.jpg b/src/assets/jackets/itg/syr5/[8] Bonbiskip!!.jpg new file mode 100644 index 000000000..7480bb376 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Bonbiskip!!.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] FOREIGN.jpg b/src/assets/jackets/itg/syr5/[8] FOREIGN.jpg new file mode 100644 index 000000000..3eecd7245 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] FOREIGN.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Mellow.jpg b/src/assets/jackets/itg/syr5/[8] Mellow.jpg new file mode 100644 index 000000000..6ddbf1557 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Mellow.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Memories of You (ATLUS Meguro Remix).jpg b/src/assets/jackets/itg/syr5/[8] Memories of You (ATLUS Meguro Remix).jpg new file mode 100644 index 000000000..3a81c0fff Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Memories of You (ATLUS Meguro Remix).jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Nine One One (feat. dimo).jpg b/src/assets/jackets/itg/syr5/[8] Nine One One (feat. dimo).jpg new file mode 100644 index 000000000..217bcc447 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Nine One One (feat. dimo).jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] OKLOSER.jpg b/src/assets/jackets/itg/syr5/[8] OKLOSER.jpg new file mode 100644 index 000000000..dd167b3c4 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] OKLOSER.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Riddle.jpg b/src/assets/jackets/itg/syr5/[8] Riddle.jpg new file mode 100644 index 000000000..78826798b Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Riddle.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Spin out (aran Remix).jpg b/src/assets/jackets/itg/syr5/[8] Spin out (aran Remix).jpg new file mode 100644 index 000000000..2c5bd3854 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Spin out (aran Remix).jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Tanpopo.jpg b/src/assets/jackets/itg/syr5/[8] Tanpopo.jpg new file mode 100644 index 000000000..cc484c2b5 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Tanpopo.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Trap Queen.jpg b/src/assets/jackets/itg/syr5/[8] Trap Queen.jpg new file mode 100644 index 000000000..8c2648481 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Trap Queen.jpg differ diff --git a/src/assets/jackets/itg/syr5/[8] Zendaimimon.jpg b/src/assets/jackets/itg/syr5/[8] Zendaimimon.jpg new file mode 100644 index 000000000..e216c2b1c Binary files /dev/null and b/src/assets/jackets/itg/syr5/[8] Zendaimimon.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] All Night - nikko Remix.jpg b/src/assets/jackets/itg/syr5/[9] All Night - nikko Remix.jpg new file mode 100644 index 000000000..58701bd0d Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] All Night - nikko Remix.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Bichael Byers.jpg b/src/assets/jackets/itg/syr5/[9] Bichael Byers.jpg new file mode 100644 index 000000000..58701bd0d Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Bichael Byers.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Bumble Bee.jpg b/src/assets/jackets/itg/syr5/[9] Bumble Bee.jpg new file mode 100644 index 000000000..99a6c8ca2 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Bumble Bee.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] My Mutant Ride.jpg b/src/assets/jackets/itg/syr5/[9] My Mutant Ride.jpg new file mode 100644 index 000000000..93a582808 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] My Mutant Ride.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Padam Padam.jpg b/src/assets/jackets/itg/syr5/[9] Padam Padam.jpg new file mode 100644 index 000000000..aaeff75cd Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Padam Padam.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Rough Percussionythm.jpg b/src/assets/jackets/itg/syr5/[9] Rough Percussionythm.jpg new file mode 100644 index 000000000..30fd17b76 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Rough Percussionythm.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Sick Beat.jpg b/src/assets/jackets/itg/syr5/[9] Sick Beat.jpg new file mode 100644 index 000000000..42bbcc24b Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Sick Beat.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] So What.jpg b/src/assets/jackets/itg/syr5/[9] So What.jpg new file mode 100644 index 000000000..a3626b488 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] So What.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] Sunset.jpg b/src/assets/jackets/itg/syr5/[9] Sunset.jpg new file mode 100644 index 000000000..3ff007d78 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] Sunset.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] The Way To Infinity.jpg b/src/assets/jackets/itg/syr5/[9] The Way To Infinity.jpg new file mode 100644 index 000000000..e72c5033e Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] The Way To Infinity.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] U-turn.jpg b/src/assets/jackets/itg/syr5/[9] U-turn.jpg new file mode 100644 index 000000000..f65d4f060 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] U-turn.jpg differ diff --git a/src/assets/jackets/itg/syr5/[9] We Got the Moves.jpg b/src/assets/jackets/itg/syr5/[9] We Got the Moves.jpg new file mode 100644 index 000000000..f65d4f060 Binary files /dev/null and b/src/assets/jackets/itg/syr5/[9] We Got the Moves.jpg differ diff --git a/src/card-draw.ts b/src/card-draw.ts index 4fdf9e933..42c39536f 100644 --- a/src/card-draw.ts +++ b/src/card-draw.ts @@ -35,6 +35,7 @@ export function getDrawnChart( chart: Chart, ): EligibleChart { return { + cardVariant: gameData.meta.cardVariant, name: currentSong.name, jacket: chart.jacket || currentSong.jacket, nameTranslation: currentSong.name_translation, @@ -73,8 +74,10 @@ export function songIsValid( return false; } return ( - (!song.folder || !config.folders.size || config.folders.has(song.folder)) && - (!song.flags || song.flags.every((f) => config.flags.has(f))) + (!song.folder || + !config.folders.length || + config.folders.includes(song.folder)) && + (!song.flags || song.flags.every((f) => config.flags.includes(f))) ); } @@ -93,10 +96,10 @@ export function chartIsValid( const levelMetric = chartLevelOrTier(chart, config.useGranularLevels); return ( chart.style === config.style && - config.difficulties.has(chart.diffClass) && + config.difficulties.includes(chart.diffClass) && levelMetric >= config.lowerBound && levelMetric <= config.upperBound && - (!chart.flags || chart.flags.every((f) => config.flags.has(f))) + (!chart.flags || chart.flags.every((f) => config.flags.includes(f))) ); } @@ -226,19 +229,27 @@ function bucketIndexForLvl(lvl: number, buckets: LvlRanges): number | null { return null; } +export type DrawingMeta = Pick; +export type StartingPoint = DrawingMeta & { charts?: Drawing["charts"] }; + +const artistDrawBlocklist = new Set(); + /** * Produces a drawn set of charts given the song data and the user * input of the html form elements. * @param songs The song data (see `src/songs/`) * @param configData the data gathered by all form elements on the page, indexed by `name` attribute */ -export function draw(gameData: GameData, configData: ConfigState): Drawing { +export function draw( + gameData: GameData, + configData: ConfigState, + startPoint: StartingPoint, +) { const { chartCount: numChartsToRandom, useWeights, forceDistribution, weights, - defaultPlayersPerDraw, useGranularLevels, } = configData; @@ -250,10 +261,15 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { getBuckets(configData, availableLvls, gameData.meta.granularTierResolution), ); - for (const chart of eligibleCharts(configData, gameData)) { - const bucketIdx = useWeights + function bucketIndexForChart(chart: EligibleChart) { + return useWeights ? bucketIndexForLvl(chartLevelOrTier(chart, useGranularLevels), buckets) : 0; // outside of weights mode we just put all songs into one shared bucket + } + + for (const chart of eligibleCharts(configData, gameData)) { + if (artistDrawBlocklist.has(chart.artist)) continue; + const bucketIdx = bucketIndexForChart(chart); if (bucketIdx === null) continue; validCharts.get(bucketIdx).push(chart); } @@ -295,14 +311,15 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { // so a level with a weight of 15% can only show up on at most 1 card, a level with // a weight of 30% can only show up on at most 2 cards, etc. if (forceDistribution) { + const evenRatios = numChartsToRandom % totalWeightUsed === 0; for (const bucketIdx of validCharts.keys()) { const normalizedWeight = (weights[bucketIdx] || 0) / totalWeightUsed; const maxForThisBucket = Math.ceil( normalizedWeight * numChartsToRandom, ); maxDrawPerBucket.set(bucketIdx, maxForThisBucket); - // setup minimum draws - for (let i = 1; i < maxForThisBucket; i++) { + // setup minimum draws (even ratios means we use max, not min, so +1) + for (let i = evenRatios ? 0 : 1; i < maxForThisBucket; i++) { requiredDrawIndexes.push(bucketIdx); } } @@ -312,13 +329,39 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { // OK, setup work is done, here's whre we actually draw the cards! let redraw = false; - const drawnCharts: DrawnChart[] = []; + let drawnCharts: DrawnChart[] = []; + + const preSeededDrawnCharts = + startPoint.charts?.filter((c) => c.type === "DRAWN") || []; + const preSeededDifficulties: number[] = []; + // account for the chart levels already in the draw starting point + for (const chart of preSeededDrawnCharts) { + const bucketIdx = bucketIndexForChart(chart); + if (bucketIdx === null) continue; + // count this chart within quota + preSeededDifficulties.push(bucketIdx); + + // remove from base requirements + const removeIdx = requiredDrawIndexes.indexOf(bucketIdx); + if (removeIdx >= 0) { + requiredDrawIndexes.splice(removeIdx, 1); + } + // remove this existing chart from eligible pool to prevent dupes + const bucket = validCharts.get(bucketIdx); + const idxInBucket = bucket.findIndex( + (eligibleChart) => + eligibleChart.name === chart.name && + chart.diffAbbr === eligibleChart.diffAbbr && + chart.level === eligibleChart.level, + ); + bucket.splice(idxInBucket, 1); + } do { /** * Record of how many songs of each bucket index have been drawn so far */ - const difficultyCounts = new CountingSet(); + const difficultyCounts = new CountingSet(preSeededDifficulties); // make a copy of valid charts here in the loop so we // can mutate it later during the draw process, but @@ -331,7 +374,10 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { localValidCharts.set(bucketIdx, charts.slice()); } - while (drawnCharts.length < numChartsToRandom) { + while ( + drawnCharts.length + preSeededDrawnCharts.length < + numChartsToRandom + ) { if (bucketDistribution.length === 0) { // no more songs available to pick in the requested range // will be returning fewer than requested number of charts @@ -389,41 +435,31 @@ export function draw(gameData: GameData, configData: ConfigState): Drawing { redraw = false; if (underDrawn || overDrawn) { redraw = true; - drawnCharts.splice(0, drawnCharts.length); + drawnCharts = []; break; } } } } while (redraw); - const charts: Drawing["charts"] = configData.sortByLevel - ? drawnCharts.sort( - (a, b) => - chartLevelOrTier(a, useGranularLevels, false) - - chartLevelOrTier(b, useGranularLevels, false), - ) - : shuffle(drawnCharts); - - if (configData.playerPicks) { - charts.unshift( - ...times( - configData.playerPicks, - (): PlayerPickPlaceholder => ({ - id: `pick_placeholder-` + nanoid(5), - type: CHART_PLACEHOLDER, - }), - ), + let charts: Drawing["charts"]; + if (configData.sortByLevel) { + charts = drawnCharts.sort( + (a, b) => + chartLevelOrTier(a, useGranularLevels, false) - + chartLevelOrTier(b, useGranularLevels, false), ); + } else { + charts = shuffle(drawnCharts); } - return { - id: `draw-${nanoid(10)}`, - cardVariant: gameData.meta.cardVariant, - charts, - players: times(defaultPlayersPerDraw, () => ""), - bans: [], - protects: [], - pocketPicks: [], - winners: [], - }; + if (!startPoint.charts && configData.playerPicks) { + charts.unshift(...times(configData.playerPicks, newPlaceholder)); + } + + return charts; +} + +export function newPlaceholder(): PlayerPickPlaceholder { + return { id: `pick_placeholder-` + nanoid(5), type: CHART_PLACEHOLDER }; } diff --git a/src/classic-mode/index.tsx b/src/classic-mode/index.tsx new file mode 100644 index 000000000..3e7dd31fd --- /dev/null +++ b/src/classic-mode/index.tsx @@ -0,0 +1,21 @@ +import { Provider as ReduxProvider } from "react-redux"; +import { Header } from "../header"; +import { createClientStore } from "../state/store"; +import { Outlet } from "react-router-dom"; +import { ClassicModeContext } from "../common-components/app-mode"; +import { LocalStorageManager } from "./localstorage-manager"; +import { useMemo } from "react"; +import { classicModeState } from "../state/localstorage"; + +export function ClassicModeShell() { + const store = useMemo(() => createClientStore(classicModeState), []); + return ( + + + +
+ + + + ); +} diff --git a/src/classic-mode/localstorage-manager.tsx b/src/classic-mode/localstorage-manager.tsx new file mode 100644 index 000000000..a1e5ccd9a --- /dev/null +++ b/src/classic-mode/localstorage-manager.tsx @@ -0,0 +1,20 @@ +import { LOCAL_STATE_STORAGE_KEY } from "../state/localstorage"; +import { useAppStore } from "../state/store"; +import { useEffect } from "react"; + +/** + * component that persists the redux state to localstorage with every change + */ +export function LocalStorageManager() { + const store = useAppStore(); + useEffect(() => { + return store.subscribe(() => { + const state = store.getState(); + setTimeout(() => { + const encoded = JSON.stringify(state); + localStorage.setItem(LOCAL_STATE_STORAGE_KEY, encoded); + }); + }); + }, [store]); + return null; +} diff --git a/src/common-components/app-mode.tsx b/src/common-components/app-mode.tsx new file mode 100644 index 000000000..ffba0ea65 --- /dev/null +++ b/src/common-components/app-mode.tsx @@ -0,0 +1,26 @@ +import { createContext, ReactNode, useContext } from "react"; + +const appModeContext = createContext<"classic" | "event">("event"); + +export function ClassicModeContext(props: { children: ReactNode }) { + return ( + + {props.children} + + ); +} + +export function useAppMode() { + return useContext(appModeContext); +} + +export function EventModeGated(props: { + children: ReactNode; + fallback?: ReactNode; +}) { + const mode = useAppMode(); + if (mode === "classic") { + return props.fallback || null; + } + return props.children; +} diff --git a/src/common-components/delayed-spinner.tsx b/src/common-components/delayed-spinner.tsx new file mode 100644 index 000000000..5ee7451d0 --- /dev/null +++ b/src/common-components/delayed-spinner.tsx @@ -0,0 +1,18 @@ +import { Spinner } from "@blueprintjs/core"; +import { useState, useEffect } from "react"; + +export function DelayedSpinner(props: { timeout?: number }) { + const [show, updateShow] = useState(false); + useEffect(() => { + if (show) return; + + const timeout = setTimeout(() => { + updateShow(true); + }, props.timeout || 250); + return () => clearTimeout(timeout); + }, [props.timeout, show]); + if (show) { + return ; + } + return null; +} diff --git a/src/config-persistence.ts b/src/config-persistence.ts index 3488bd685..cdd213745 100644 --- a/src/config-persistence.ts +++ b/src/config-persistence.ts @@ -1,36 +1,52 @@ -import { ConfigState, useConfigState } from "./config-state"; -import { useDrawState } from "./draw-state"; +import { ConfigState } from "./config-state"; import { toaster } from "./toaster"; import { buildDataUri, dateForFilename, shareData } from "./utils/share"; -interface PersistedConfigV1 { - version: 1; - dataSetName: string; - configState: Serialized & OldSettings; +/** Mark specific fields in T optional, keeping others unchanged */ +// type Optional = Partial> & +// Omit; + +interface PersistedConfigV2 { + version: 2; + configState: ConfigState; } -/** - * Returns a union of all property names in T which do not contain a function value. - * Allows us to filter out mutations from a zustand store state. - */ -type NonFunctionKeys = keyof { - // oxlint-disable-next-line typescript/no-unsafe-function-type - [K in keyof T as T[K] extends Function ? never : K]: T[K]; -}; +/** Holds one or more configs in a single file, for batch import/export */ +interface PersistedConfigsV2 { + version: 2; + configStates: ConfigState[]; +} -/** - * Strips mutations from an object, and converts sets to arrays, maps to arrays of entry pairs - */ -type Serialized = { - [K in NonFunctionKeys]: T[K] extends ReadonlyMap - ? Array<[K, V]> - : T[K] extends ReadonlySet - ? Array - : T[K]; -}; +export function saveConfig(config: ConfigState) { + const persistedObj: PersistedConfigV2 = { + version: 2, + configState: config, + }; + const dataUri = buildDataUri( + JSON.stringify(persistedObj, undefined, 2), + "application/json", + "url", + ); + + return shareData(dataUri, { + filename: `ddr-tools-config-${config.name.replaceAll(" ", "-")}-${dateForFilename()}.json`, + methods: [ + { type: "nativeShare", allowDesktop: true }, + { type: "download" }, + ], + }); +} -export function saveConfig() { - const persistedObj = buildPersistedConfig(); +/** Export several configs into a single file. Falls back to the single-config + * format when only one config is given, so individual exports stay tidy. */ +export function saveConfigs(configs: ConfigState[]) { + if (configs.length === 1) { + return saveConfig(configs[0]); + } + const persistedObj: PersistedConfigsV2 = { + version: 2, + configStates: configs, + }; const dataUri = buildDataUri( JSON.stringify(persistedObj, undefined, 2), "application/json", @@ -38,7 +54,7 @@ export function saveConfig() { ); return shareData(dataUri, { - filename: `ddr-tools-config-${persistedObj.dataSetName}-${dateForFilename()}.json`, + filename: `ddr-tools-configs-${configs.length}-${dateForFilename()}.json`, methods: [ { type: "nativeShare", allowDesktop: true }, { type: "download" }, @@ -46,38 +62,46 @@ export function saveConfig() { }); } -export function loadConfig() { +/** Load one or more configs from a file. Accepts both the single-config and + * multi-config file formats, always resolving to an array. */ +export function loadConfigs(): Promise { const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = ".json,application/json"; fileInput.style.visibility = "hidden"; document.body.appendChild(fileInput); - const resolution = new Promise((resolve, reject) => { + const resolution = new Promise((resolve, reject) => { async function changeHandler() { try { const files = fileInput.files; if (!files) { - reject(); throw new Error("no file selected"); } const f = files.item(0); if (!f) { - reject(); throw new Error("no file selected"); } if (f.type !== "application/json") { - reject(); throw new Error("file type is " + f.type); } - const contents: PersistedConfigV1 = JSON.parse(await f.text()); - await loadPersistedConfig(contents); - resolve(); - toaster.show({ - message: "Successfully loaded draw settings", - icon: "import", - intent: "success", - }); + const contents: PersistedConfigV2 | PersistedConfigsV2 = JSON.parse( + await f.text(), + ); + if (contents.version !== 2) { + throw new Error("config version was not expected value"); + } + if ( + "configStates" in contents && + Array.isArray(contents.configStates) + ) { + resolve(contents.configStates); + } else if ("configState" in contents && contents.configState) { + resolve([contents.configState]); + } else { + throw new Error("no config data found in file"); + } } catch (e) { + reject(); toaster.show({ message: "Failed to load settings file", icon: "error", @@ -94,78 +118,3 @@ export function loadConfig() { fileInput.click(); return resolution; } - -function buildPersistedConfig(): PersistedConfigV1 { - const { ...configState } = useConfigState.getState(); - const serializedState: PersistedConfigV1["configState"] = { - ...configState, - difficulties: Array.from(configState.difficulties), - flags: Array.from(configState.flags), - folders: Array.from(configState.folders), - }; - const ret: PersistedConfigV1 = { - version: 1, - dataSetName: useDrawState.getState().dataSetName, - configState: serializedState, - }; - return ret; -} - -async function loadPersistedConfig(saved: PersistedConfigV1) { - if (saved.version !== 1) { - return false; - } - const drawState = useDrawState.getState(); - if (drawState.dataSetName !== saved.dataSetName) { - const nextConfigChange = new Promise((resolve) => { - const unsub = useConfigState.subscribe(() => { - unsub(); - resolve(); - }); - }); - await drawState.loadGameData(saved.dataSetName); - // the ApplyDefaultConfig component will kick in - // to overwrite config in response to this change - // so we have to wait for that to happen before continuing - await nextConfigChange; - } - - useConfigState.setState({ - ...migrateOldNames(saved.configState), - difficulties: new Set(saved.configState.difficulties), - flags: new Set(saved.configState.flags), - folders: new Set(saved.configState.folders), - }); -} - -interface OldSettings { - /** renamed to `showEligibleCharts` */ - showPool?: boolean; - /** renamed to `showPlayerAndRoundLabels` */ - showLabels?: boolean; -} - -function migrateOldNames( - config: PersistedConfigV1["configState"], -): Serialized { - const { showPool, showLabels, ...modernConfig } = config; - - if (showPool) { - modernConfig.showEligibleCharts = showPool; - } - - if (showLabels) { - modernConfig.showPlayerAndRoundLabels = showLabels; - } - - const maybeOldWeights = modernConfig.weights as unknown as - | Array<[number, number]> - | Array; - if (Array.isArray(maybeOldWeights[0])) { - modernConfig.weights = maybeOldWeights.map((pair) => - Array.isArray(pair) ? pair[1] : pair, - ); - } - - return modernConfig; -} diff --git a/src/config-state.ts b/src/config-state.ts index 0e9847e85..ca75fc398 100644 --- a/src/config-state.ts +++ b/src/config-state.ts @@ -1,69 +1,5 @@ -import type { StoreApi } from "zustand"; -import { createWithEqualityFn } from "zustand/traditional"; +import { atom } from "jotai"; -export interface ConfigState { - 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: ReadonlySet; - difficulties: ReadonlySet; - flags: ReadonlySet; - cutoffDate: string; - showEligibleCharts: boolean; - playerNames: string[]; - tournamentRounds: string[]; - showPlayerAndRoundLabels: boolean; - defaultPlayersPerDraw: number; - sortByLevel: boolean; - useGranularLevels: boolean; - showMaxScore: boolean; - update: StoreApi["setState"]; -} +export const showPlayerAndRoundLabels = atom(true); -export const useConfigState = createWithEqualityFn( - (set) => ({ - chartCount: 5, - playerPicks: 0, - upperBound: 0, - lowerBound: 0, - useWeights: false, - hideVetos: false, - orderByAction: true, - weights: [], - probabilityBucketCount: null, - forceDistribution: true, - constrainPocketPicks: true, - style: "", - cutoffDate: "", - folders: new Set(), - difficulties: new Set(), - flags: new Set(), - showEligibleCharts: false, - playerNames: [], - tournamentRounds: [ - "Pools", - "Winner's Bracket", - "Winner's Finals", - "Loser's Bracket", - "Loser's Finals", - "Grand Finals", - "Tiebreaker", - ], - showPlayerAndRoundLabels: false, - sortByLevel: false, - defaultPlayersPerDraw: 2, - useGranularLevels: false, - showMaxScore: false, - update: set, - }), - Object.is, -); +export type { ConfigState } from "./state/config.slice"; diff --git a/src/controls/config-page.tsx b/src/controls/config-page.tsx new file mode 100644 index 000000000..0b29750f1 --- /dev/null +++ b/src/controls/config-page.tsx @@ -0,0 +1,98 @@ +import { ErrorBoundary } from "react-error-boundary"; +import { ErrorFallback } from "../utils/error-fallback"; +import ControlsDrawer from "./controls-drawer"; +import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { Link } from "react-router-dom"; +import { CircleArrowLeft } from "@blueprintjs/icons"; +import { FormGroup, InputGroup } from "@blueprintjs/core"; +import { useAppDispatch, useAppState } from "../state/store"; +import { configSlice, ConfigState } from "../state/config.slice"; +import { GameDataSelect } from "../version-select"; +import { useLastConfigSelected } from "../state/config.atoms"; +import { changeGameKeyForConfig } from "../state/thunks"; +import { ConfigList } from "./config-select"; + +export function ConfigPage() { + const initialState = useLastConfigSelected() || null; + const [configId, setConfigId] = useState(initialState); + function setNextConfig(id: string | null) { + setConfigId(id); + } + + return ( +
+

+ + + {" "} + +

+
+ + +
+ + }> + + +
+
+
+
+ ); +} + +function ConfigIdGate({ + configId, + children, +}: { + configId: string | null; + children: React.ReactNode; +}) { + const configExists = useAppState((s) => + configId ? !!configSlice.selectors.selectById(s, configId) : false, + ); + if (configExists) { + return children; + } + return null; +} + +function ConfigCoreFields({ configId }: { configId: string | null }) { + const name = useAppState( + (s) => configId && s.config.entities[configId]?.name, + ); + const gameKey = useAppState( + (s) => configId && s.config.entities[configId]?.gameKey, + ); + const dispatch = useAppDispatch(); + if (!configId) return null; + const updateConfig = (changes: Partial) => { + dispatch( + configSlice.actions.updateOne({ + id: configId, + changes, + }), + ); + }; + return ( +
+ + updateConfig({ name: e.currentTarget.value })} + /> + + + + dispatch(changeGameKeyForConfig(configId, newGame)) + } + /> + +
+ ); +} diff --git a/src/controls/config-select.css b/src/controls/config-select.css new file mode 100644 index 000000000..0daeb5968 --- /dev/null +++ b/src/controls/config-select.css @@ -0,0 +1,18 @@ +.listContainer { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.listContainer h2 { + font-size: 1.1rem; + margin-block: 0.3em; +} + +.actionButtons { + float: right; +} + +.listContainer p { + margin-bottom: 0; +} diff --git a/src/controls/config-select.tsx b/src/controls/config-select.tsx new file mode 100644 index 000000000..3b25db54d --- /dev/null +++ b/src/controls/config-select.tsx @@ -0,0 +1,381 @@ +import { + Button, + ButtonGroup, + Card, + Checkbox, + Dialog, + DialogBody, + DialogFooter, + HTMLSelect, + Menu, + MenuItem, + Popover, +} from "@blueprintjs/core"; +import { + ThAdd, + Duplicate, + FloppyDisk, + Import, + Trash, + More, + DocumentShare, +} from "@blueprintjs/icons"; +import { useState } from "react"; +import { useAppDispatch, useAppState, useAppStore } from "../state/store"; +import styles from "./config-select.css"; +import { createNewConfig } from "../state/thunks"; +import { useRoomName } from "../hooks/useRoomName"; +import { useSetLastConfigSelected } from "../state/config.atoms"; +import { configSlice } from "../state/config.slice"; +import { loadConfigs, saveConfig, saveConfigs } from "../config-persistence"; +import { copyTextToClipboard } from "../utils/share"; +import { useStockGameData } from "../state/game-data.atoms"; +import { toaster } from "../toaster"; + +function getEmptyItemLabel(empty: boolean) { + if (!empty) return "select a config"; + return "no configs created"; +} + +function pluralizeConfigs(count: number) { + return `${count} config${count === 1 ? "" : "s"}`; +} + +/** + * @param total configs in the imported file + * @param updated how many of them replaced an existing config + **/ +function importToastMessage(total: number, updated: number) { + if (updated === total) { + return `Updated ${pluralizeConfigs(updated)}`; + } + if (updated > 0) { + return `Imported ${pluralizeConfigs(total)} (updated ${updated} existing)`; + } + return `Imported ${pluralizeConfigs(total)}`; +} + +export function ConfigSelect(props: { + selectedId: string | null; + onChange(nextId: string): void; +}) { + const configIds = useAppState((s) => s.config.ids); + const isEmpty = !configIds.length; + + return ( + props.onChange(e.currentTarget.value)} + > + + {configIds.map((configId) => ( + + ))} + + ); +} + +function ConfigSelectEntry(props: { configId: string }) { + const config = useAppState((s) => + configSlice.selectors.selectById(s, props.configId), + ); + return ( + + ); +} + +export function ConfigList(props: { + selectedId: string | null; + onChange(nextId: string | null): void; +}) { + const configIds = useAppState((s) => s.config.ids); + const dispatch = useAppDispatch(); + const store = useAppStore(); + const roomName = useRoomName(); + const setLastConfigSelected = useSetLastConfigSelected(); + const [exportOpen, setExportOpen] = useState(false); + + const isEmpty = !configIds.length; + + function changeConfig(key: string | null) { + props.onChange(key); + setLastConfigSelected(key || undefined); + } + function nextConfigId(idx: number) { + const nextIdx = idx < configIds.length - 1 ? idx + 1 : idx - 1; + return nextIdx > -1 ? configIds[nextIdx] : null; + } + function importConfigs() { + void loadConfigs().then((configs) => { + const existingIds = configSlice.selectors.selectIds(store.getState()); + const updated = configs.filter((c) => existingIds.includes(c.id)).length; + dispatch(configSlice.actions.setMany(configs)); + const last = configs[configs.length - 1]; + if (last) { + changeConfig(last.id); + } + if (configs.length > 1 || updated) { + toaster.show({ + message: importToastMessage(configs.length, updated), + icon: "import", + intent: "success", + }); + } + }); + } + return ( +
+ {!isEmpty && ( +
+ ); +} + +function BatchExportDialog(props: { isOpen: boolean; onClose: () => void }) { + return ( + + {props.isOpen && } + + ); +} + +function BatchExportForm(props: { onClose: () => void }) { + const configIds = useAppState((s) => s.config.ids); + const store = useAppStore(); + const [selectedIds, setSelectedIds] = useState>( + () => new Set(configIds as string[]), + ); + + function toggle(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + const allSelected = selectedIds.size === configIds.length; + function toggleAll() { + setSelectedIds(allSelected ? new Set() : new Set(configIds as string[])); + } + + function handleExport() { + const configs = configSlice.selectors + .selectAll(store.getState()) + .filter((c) => selectedIds.has(c.id)); + void saveConfigs(configs); + props.onClose(); + } + + return ( + <> + + 0} + onChange={toggleAll} + label="Select all" + /> + {(configIds as string[]).map((cid) => ( + + ))} + + } + disabled={!selectedIds.size} + onClick={handleExport} + > + Export {selectedIds.size} + + } + /> + + ); +} + +function BatchExportRow(props: { + configId: string; + checked: boolean; + onToggle(id: string): void; +}) { + const config = useAppState((s) => + configSlice.selectors.selectById(s, props.configId), + ); + return ( + props.onToggle(props.configId)} + label={`${config.name} (${config.gameKey}, ${config.lowerBound}-${config.upperBound})`} + /> + ); +} + +function ConfigListEntry(props: { + configId: string; + selected: boolean; + /** config to select when deleting this one */ + nextConfigId: string | null; + selectConfig(configId: string | null): void; +}) { + const roomName = useRoomName(); + const dispatch = useAppDispatch(); + const config = useAppState((s) => + configSlice.selectors.selectById(s, props.configId), + ); + const gameData = useStockGameData(config.gameKey); + const multiDraws = config.multiDraws?.configs.length + ? `${config.multiDraws.configs.length} ${config.multiDraws.merge ? "draws" : "sets"}` + : null; + return ( + e.defaultPrevented || props.selectConfig(props.configId)} + selected={props.selected} + compact + > + {props.selected && ( + { + const url = new URL(document.location.href); + url.pathname = `/preview/${roomName}`; + url.search = "?configId=" + props.configId; + void copyTextToClipboard( + url.href, + "Preview URL copied to clipboard", + ); + }} + onExport={() => { + void saveConfig( + dispatch((_, gs) => + configSlice.selectors.selectById(gs(), props.configId), + ), + ); + }} + onDuplicate={() => { + void dispatch(createNewConfig(roomName, props.configId)).then((c) => + props.selectConfig(c.id), + ); + }} + onDelete={() => { + if (!confirm(`Are you sure you want to delete "${config.name}"?`)) + return; + props.selectConfig(props.nextConfigId); + dispatch(configSlice.actions.removeOne(config.id)); + }} + /> + )} +

{config.name}

+

+ {config.gameKey}, draw {config.chartCount},{" "} + {gameData?.meta.usesDrawGroups ? "tier" : "lvl"} {config.lowerBound} + –{config.upperBound} +
+ {multiDraws && ` (+${multiDraws})`} +

+
+ ); +} + +function ConfigActionsMenu(props: { + onExport(this: void): void; + onShareLink(this: void): void; + onDuplicate(): void; + onDelete(): void; +}) { + const menu = ( + + } + text="Export to JSON" + onClick={props.onExport} + /> + } + text="Share preview link" + onClick={props.onShareLink} + /> + } + text="Duplicate" + onClick={(e) => { + e.preventDefault(); + props.onDuplicate(); + }} + /> + } + text="Delete" + intent="danger" + onClick={(e) => { + e.preventDefault(); + props.onDelete(); + }} + /> + + ); + return ( + + @@ -221,7 +178,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); @@ -230,7 +187,7 @@ function FolderSettings() { } else { newFolders.add(folder); } - return { folders: newFolders }; + return { folders: Array.from(newFolders) }; }) } /> @@ -241,8 +198,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, @@ -250,7 +208,6 @@ function GeneralSettings() { hideVetos, lowerBound, upperBound, - update: updateState, difficulties: selectedDifficulties, style: selectedStyle, chartCount, @@ -265,8 +222,8 @@ function GeneralSettings() { } return getAvailableDifficulties(gameData, selectedStyle); }, [gameData, selectedStyle]); - const isNarrow = useIsNarrow(); const [expandFilters, setExpandFilters] = useState(false); + const getMetaString = useGetMetaString(); if (!gameData) { return null; @@ -281,21 +238,6 @@ function GeneralSettings() { return ( <> - {isNarrow && ( - <> - - - - - - - - - - - )}
{ if (!isNaN(chartCount)) { - updateState(() => { - return { chartCount }; - }); + updateState({ chartCount }); } }} /> @@ -333,20 +273,19 @@ function GeneralSettings() { clampValueOnBlur onValueChange={(playerPicks) => { if (!isNaN(playerPicks)) { - updateState(() => { - return { playerPicks }; - }); + updateState({ playerPicks }); } }} />
+
+ + ); +} diff --git a/src/controls/index.tsx b/src/controls/index.tsx index fad6937c3..49a33c15a 100644 --- a/src/controls/index.tsx +++ b/src/controls/index.tsx @@ -1,90 +1,39 @@ import { Button, ButtonGroup, - Drawer, - DrawerSize, + Dialog, Intent, - NavbarDivider, Position, - Spinner, Tooltip, } from "@blueprintjs/core"; -import { NewLayers, Cog, FloppyDisk, Import } from "@blueprintjs/icons"; -import { useState, lazy, Suspense } from "react"; +import { NewLayers, Cog } from "@blueprintjs/icons"; +import { useState } from "react"; import { FormattedMessage } from "react-intl"; -import { useConfigState } from "../config-state"; -import { useDrawState } from "../draw-state"; -import { useIsNarrow } from "../hooks/useMediaQuery"; -import { loadConfig, saveConfig } from "../config-persistence"; -import { ErrorBoundary } from "react-error-boundary"; -import { ErrorFallback } from "../utils/error-fallback"; -import { ShowChartsToggle } from "./show-charts-toggle"; +import { useAppState } from "../state/store"; +import { DrawDialog } from "./draw-dialog"; -const ControlsDrawer = lazy(() => import("./controls-drawer")); +import { ConfigSelect } from "./config-select"; +import { Link } from "react-router-dom"; +export { ConfigSelect }; export function HeaderControls() { - const [settingsOpen, setSettingsOpen] = useState(false); const [lastDrawFailed, setLastDrawFailed] = useState(false); - const [drawSongs, hasGameData] = useDrawState((s) => [ - s.drawSongs, - !!s.gameData, - ]); - const isNarrow = useIsNarrow(); - - function handleDraw() { - useConfigState.setState({ showEligibleCharts: false }); - drawSongs(useConfigState.getState()); - } - - function openSettings() { - setSettingsOpen((open) => !open); - setLastDrawFailed(false); - } + const [matchPickerOpen, setMatchPickerOpen] = useState(false); + const hasAnyConfig = useAppState((s) => !!s.config.ids.length); return ( <> - setSettingsOpen(false)} - title={ - <> - - - - - - - } + setMatchPickerOpen(false)} > - }> - }> - - - - - {!isNarrow && ( - <> - - - - )} + setMatchPickerOpen(false)} + onDrawAttempt={(success) => setLastDrawFailed(!success)} + /> + - - - } @@ -92,12 +41,23 @@ export function HeaderControls() { usePortal={false} position={Position.BOTTOM_RIGHT} > - + + + + + + + + {configs} + + + + + + ); +} diff --git a/src/controls/player-list-input.tsx b/src/controls/player-list-input.tsx new file mode 100644 index 000000000..895ae6099 --- /dev/null +++ b/src/controls/player-list-input.tsx @@ -0,0 +1,128 @@ +import { Button, InputGroup } from "@blueprintjs/core"; +import { Cross, DragHandleVertical, Plus } from "@blueprintjs/icons"; +import { useRef } from "react"; +import { List, arrayMove } from "react-movable"; +import { Player, newPlayer } from "../models/Drawing"; + +/** + * Cap on the height of the scrollable list region. Once enough players are + * added to reach it, the list scrolls internally instead of growing the + * enclosing (vertically-centered) dialog unboundedly and shoving the whole + * modal around on screen. + */ +const LIST_HEIGHT = "10.5em"; + +/** + * A vertical, drag-to-reorder list of players. Each row has a dedicated drag + * handle (so text can still be selected/edited in the name field), an editable + * name input, and a remove button, with an "add player" button below. + * + * The list is simply the players in display order. Since each player carries a + * stable id that drawn-card actions reference, reordering/renaming/removing here + * never invalidates those actions — removals are reconciled by id downstream. + */ +export function PlayerListInput(props: { + value: Player[]; + onChange: (next: Player[]) => void; +}) { + const { value: players, onChange } = props; + const scrollRef = useRef(null); + // Index of a freshly-added row that should grab focus once it renders. + const focusIndexRef = useRef(null); + + function renameAt(index: number, name: string) { + onChange(players.map((p, i) => (i === index ? { ...p, name } : p))); + } + + function removeAt(index: number) { + onChange(players.filter((_, i) => i !== index)); + } + + function addPlayer() { + focusIndexRef.current = players.length; + onChange([...players, newPlayer(`P${players.length + 1}`)]); + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }); + } + + return ( + <> + + onChange(arrayMove(players, oldIndex, newIndex)) + } + renderList={({ children, props: listProps }) => ( +
{ + listProps.ref.current = el; + scrollRef.current = el; + }} + style={{ maxHeight: LIST_HEIGHT, overflowY: "auto" }} + > + {children} +
+ )} + renderItem={({ value, props: itemProps, index, isDragged }) => { + const { key, ...rest } = itemProps; + return ( +
+ + + + { + if (el) { + el.focus(); + focusIndexRef.current = null; + } + } + : undefined + } + onFocus={(e) => e.currentTarget.select()} + onChange={(e) => renameAt(index!, e.currentTarget.value)} + /> +
+ ); + }} + /> + + + ); +} diff --git a/src/controls/player-names.tsx b/src/controls/player-names.tsx index 9ac707f68..3d9068097 100644 --- a/src/controls/player-names.tsx +++ b/src/controls/player-names.tsx @@ -1,134 +1,83 @@ -import { - Checkbox, - Classes, - FormGroup, - NumericInput, - TagInput, -} from "@blueprintjs/core"; -import { ReactNode } from "react"; -import { useConfigState } from "../config-state"; -import { useIntl } from "../hooks/useIntl"; -import { DiagramTree, Person } from "@blueprintjs/icons"; +import { Section, SectionCard } from "@blueprintjs/core"; +// import { useConfigState, useUpdateConfig } from "../state/hooks"; +// import { useIntl } from "../hooks/useIntl"; +import { useAtomValue } from "jotai"; +// import { showPlayerAndRoundLabels } from "../config-state"; +// import { useAppState } from "../state/store"; +import { startggEventSlug, startggKeyAtom } from "../startgg-gql"; +import { StartggCredsManager } from "../startgg-gql/components"; export function PlayerNamesControls() { - const { t } = useIntl(); - const playerNames = useConfigState((s) => s.playerNames); - const updateConfig = useConfigState((s) => s.update); - - function addPlayers(names: string[]) { - updateConfig((prev) => { - const next = prev.playerNames.slice(); - for (const name of names) { - if (!next.includes(name)) { - next.push(name); - } - } - if (next.length !== prev.playerNames.length) { - return { playerNames: next }; - } - return {}; - }); - } - function removePlayer(name: ReactNode, index: number) { - updateConfig((prev) => { - const next = prev.playerNames.slice(); - next.splice(index, 1); - return { playerNames: next }; - }); - } - + const apiKey = useAtomValue(startggKeyAtom); + const eventSlug = useAtomValue(startggEventSlug); return ( <> - - - - } - onAdd={addPlayers} - onRemove={removePlayer} - addOnBlur={true} - /> - - +
+ + + +
); } -function ShowLabelsToggle() { - const update = useConfigState((s) => s.update); - const enabled = useConfigState((s) => s.showPlayerAndRoundLabels); - const { t } = useIntl(); - - return ( - - update({ showPlayerAndRoundLabels: e.currentTarget.checked }) - } - label={t("controls.playerLabels")} - /> - ); +export function inferShortname(name: string): string; +export function inferShortname( + name: string | null | undefined, +): string | undefined; +export function inferShortname(name: string | null | undefined) { + if (!name) return; + const namePieces = name.split(" | "); + return namePieces.length >= 1 ? namePieces[namePieces.length - 1] : undefined; } -function PlayersPerDraw() { - const update = useConfigState((s) => s.update); - const ppd = useConfigState((s) => s.defaultPlayersPerDraw); - const { t } = useIntl(); +// function EntrantNameForm(props: { entrant: Entrant }) { +// return ( +// +// ); +// } - return ( - - update({ defaultPlayersPerDraw: next })} - /> - - ); -} +// function ShowLabelsToggle() { +// const [enabled, updateShowLabels] = useAtom(showPlayerAndRoundLabels); +// const { t } = useIntl(); -function TournamentLabelEditor() { - const { t } = useIntl(); - const tournamentRounds = useConfigState((s) => s.tournamentRounds); - const updateConfig = useConfigState((s) => s.update); +// return ( +// updateShowLabels(e.currentTarget.checked)} +// label={t("controls.playerLabels")} +// /> +// ); +// } - function addLabels(names: string[]) { - updateConfig((prev) => { - const next = prev.tournamentRounds.slice(); - for (const name of names) { - if (!next.includes(name)) { - next.push(name); - } - } - if (next.length !== prev.tournamentRounds.length) { - return { tournamentRounds: next }; - } - return {}; - }); - } - function removeLabel(name: ReactNode, index: number) { - updateConfig((prev) => { - const next = prev.tournamentRounds.slice(); - next.splice(index, 1); - return { tournamentRounds: next }; - }); - } - return ( - - } - onAdd={addLabels} - onRemove={removeLabel} - /> - - ); -} +// function PlayersPerDraw() { +// const update = useUpdateConfig(); +// const ppd = useConfigState((s) => s.defaultPlayersPerDraw); +// const { t } = useIntl(); + +// return ( +// +// update({ defaultPlayersPerDraw: next })} +// /> +// +// ); +// } diff --git a/src/controls/release-date-filter.tsx b/src/controls/release-date-filter.tsx index 20258fa30..c7c245895 100644 --- a/src/controls/release-date-filter.tsx +++ b/src/controls/release-date-filter.tsx @@ -4,15 +4,15 @@ import parse from "date-fns/parse"; import format from "date-fns/format"; import { detectedLanguage } from "../utils"; import { useIntl } from "../hooks/useIntl"; -import { useConfigState } from "../config-state"; +import { useConfigState, useUpdateConfig } from "../state/hooks"; const dateFormat = "yyyy-MM-dd"; export default function ReleaseDateFilterControl(props: { mostRecentRelease: string; }) { const { t } = useIntl(); - const updateState = useConfigState((s) => s.update); const cutoffDate = useConfigState((s) => s.cutoffDate); + const updateState = useUpdateConfig(); const reference = new Date(); const maxDate = parse(props.mostRecentRelease, dateFormat, reference); diff --git a/src/controls/show-charts-toggle.css b/src/controls/show-charts-toggle.css deleted file mode 100644 index b44e92a91..000000000 --- a/src/controls/show-charts-toggle.css +++ /dev/null @@ -1,3 +0,0 @@ -.showAllToggle { - margin-bottom: 0; -} diff --git a/src/controls/show-charts-toggle.tsx b/src/controls/show-charts-toggle.tsx deleted file mode 100644 index 00c82c0b4..000000000 --- a/src/controls/show-charts-toggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Switch } from "@blueprintjs/core"; -import { useConfigState } from "../config-state"; -import { useIntl } from "../hooks/useIntl"; -import { shallow } from "zustand/shallow"; -import styles from "./show-charts-toggle.css"; - -export function ShowChartsToggle({ inDrawer }: { inDrawer: boolean }) { - const { t } = useIntl(); - const { showEligible, update } = useConfigState( - (state) => ({ - showEligible: state.showEligibleCharts, - update: state.update, - }), - shallow, - ); - return ( - { - update({ - showEligibleCharts: !!e.currentTarget.checked, - }); - }} - /> - ); -} diff --git a/src/draw-state/index.ts b/src/draw-state/index.ts deleted file mode 100644 index 47950df21..000000000 --- a/src/draw-state/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useDrawState } from "./store"; -export { DrawStateManager } from "./manager"; diff --git a/src/draw-state/manager.tsx b/src/draw-state/manager.tsx deleted file mode 100644 index ee9d4781d..000000000 --- a/src/draw-state/manager.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ReactNode, useEffect } from "react"; -import { UnloadHandler } from "../unload-handler"; -import { - requestIdleCallback, - cancelIdleCallback, -} from "../utils/idle-callback"; -import i18nData from "../assets/i18n.json"; -import { availableGameData, detectedLanguage } from "../utils"; -import { I18NDict } from "../models/SongData"; -import { ApplyDefaultConfig } from "../apply-default-config"; -import { IntlProvider } from "../intl-provider"; -import { shallow } from "zustand/shallow"; -import { useDrawState } from "./store"; - -interface Props { - defaultDataSet: string; - children: ReactNode; -} - -function getInitialDataSet(defaultDataName: string) { - const hash = window.location.hash.slice(1); - if (hash.startsWith("game-")) { - const targetData = hash.slice(5); - if (availableGameData.some((d) => d.name === targetData)) { - return targetData; - } - } - if ( - defaultDataName && - availableGameData.some((d) => d.name === defaultDataName) - ) { - return defaultDataName; - } - return availableGameData[0].name; -} - -export function DrawStateManager(props: Props) { - const [gameData, hasDrawings, loadGameData] = useDrawState( - (state) => [state.gameData, !!state.drawings.length, state.loadGameData], - shallow, - ); - useEffect(() => { - const idleHandle = requestIdleCallback(() => - loadGameData(getInitialDataSet(props.defaultDataSet)), - ); - return () => cancelIdleCallback(idleHandle); - }, [loadGameData, props.defaultDataSet]); - - return ( - } - mergeTranslations={gameData?.i18n} - > - - - {props.children} - - ); -} diff --git a/src/draw-state/store.ts b/src/draw-state/store.ts deleted file mode 100644 index c434a8cbe..000000000 --- a/src/draw-state/store.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { draw } from "../card-draw"; -import { Drawing } from "../models/Drawing"; -import FuzzySearch from "fuzzy-search"; -import { GameData, Song } from "../models/SongData"; -import { ConfigState } from "../config-state"; -import type { StoreApi } from "zustand"; -import { createWithEqualityFn } from "zustand/traditional"; -import { DataConnection } from "peerjs"; -import { showDrawErrorToast } from "./error-toast"; - -interface DrawState { - importedData: Map; - gameData: GameData | null; - fuzzySearch: FuzzySearch | null; - drawings: Drawing[]; - dataSetName: string; - confirmMessage: string; - addImportedData(this: void, dataSetName: string, gameData: GameData): void; - loadGameData( - this: void, - dataSetName: string, - gameData?: GameData, - ): Promise; - /** returns false if no songs could be drawn */ - drawSongs(this: void, config: ConfigState): boolean; - clearDrawings(this: void): void; - injectRemoteDrawing( - this: void, - d: Drawing, - syncWithPeer?: DataConnection, - ): void; -} - -function applyNewData(data: GameData, set: StoreApi["setState"]) { - set({ - gameData: data, - drawings: [], - fuzzySearch: new FuzzySearch( - data.songs, - [ - "name", - "name_translation", - "search_hint", - "artist", - "artist_translation", - ], - { - sort: true, - }, - ), - }); -} - -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); -} - -export const useDrawState = createWithEqualityFn( - (set, get) => ({ - importedData: new Map(), - gameData: null, - fuzzySearch: null, - drawings: [], - dataSetName: "", - confirmMessage: "This will clear all songs drawn so far. Confirm?", - clearDrawings() { - if (get().drawings.length && !window.confirm(get().confirmMessage)) { - return; - } - set({ drawings: [] }); - }, - addImportedData(dataSetName, gameData) { - const { importedData } = get(); - const nextData = new Map(importedData); - nextData.set(dataSetName, gameData); - set({ - importedData: nextData, - dataSetName, - }); - writeDataSetToUrl(dataSetName); - applyNewData(gameData, set); - }, - async loadGameData(dataSetName: string, gameData?: GameData) { - const state = get(); - if (state.dataSetName === dataSetName && state.gameData) { - return state.gameData; - } - if (state.drawings.length && !window.confirm(get().confirmMessage)) { - return state.gameData; - } - set({ - gameData: null, - dataSetName, - drawings: [], - }); - writeDataSetToUrl(dataSetName); - - // Attempt to look up a local data file first - gameData = state.importedData.get(dataSetName); - - const data = - gameData || - ( - await import( - /* webpackChunkName: "songData" */ `../songs/${dataSetName}.json` - ) - ).default; - applyNewData(data, set); - return data; - }, - drawSongs(config: ConfigState) { - const state = get(); - if (!state.gameData) { - trackDraw(null); - return false; - } - - const drawing = draw(state.gameData, config); - trackDraw(drawing.charts.length, state.dataSetName); - if (drawing.charts.length < config.chartCount + config.playerPicks) { - showDrawErrorToast(); - return false; - } - - set((prevState) => { - return { - drawings: [drawing, ...prevState.drawings].filter(Boolean), - }; - }); - return true; - }, - injectRemoteDrawing(drawing, syncWithPeer) { - set((prevState) => { - const currentDrawing = prevState.drawings.find( - (d) => d.id === drawing.id, - ); - const newDrawings = prevState.drawings.filter( - (d) => d.id !== drawing.id, - ); - newDrawings.unshift(drawing); - if (currentDrawing) { - drawing.__syncPeer = currentDrawing.__syncPeer; - } - if (syncWithPeer) { - drawing.__syncPeer = syncWithPeer; - } - return { - drawings: newDrawings, - }; - }); - }, - }), - Object.is, -); - -function writeDataSetToUrl(game: string) { - const nextHash = `game-${game}`; - if ("#" + nextHash !== window.location.hash) { - const nextUrl = new URL(window.location.href); - nextUrl.hash = encodeURIComponent(nextHash); - window.history.replaceState(undefined, "", nextUrl); - } -} diff --git a/src/drawing-context.tsx b/src/drawing-context.tsx index 43eec6125..f4af4c9a5 100644 --- a/src/drawing-context.tsx +++ b/src/drawing-context.tsx @@ -1,211 +1,46 @@ -import { ReactNode } from "react"; -import { StoreApi } from "zustand"; -import { draw } from "./card-draw"; -import { useConfigState } from "./config-state"; -import { createContextualStore } from "./zustand/contextual-zustand"; -import { useDrawState } from "./draw-state"; -import { - CHART_PLACEHOLDER, - Drawing, - EligibleChart, - PlayerActionOnChart, - PocketPick, -} from "./models/Drawing"; -import { SerializibleStore } from "./zustand/shared-zustand"; - -export const stubDrawing: Drawing = { - id: "stub", - cardVariant: undefined, - players: [], - charts: [], - bans: [], - pocketPicks: [], - protects: [], - winners: [], -}; - -interface DrawingProviderProps { - initialDrawing: Drawing; - children?: ReactNode; +import { createContext, ReactNode, useContext, useDebugValue } from "react"; +import { CompoundSetId, MergedDrawing } from "./models/Drawing"; +import { useAppState } from "./state/store"; +import { EqualityFn } from "react-redux"; +import { ConfigContextProvider } from "./state/hooks"; +import { drawingsSlice } from "./state/drawings.slice"; + +const context = createContext(["", ""]); +const RawDrawingProvider = context.Provider; + +export function useDrawing( + selector: (d: MergedDrawing) => T, + equalityFn?: EqualityFn, +) { + const drawingId = useContext(context); + const ret = useAppState((state) => { + const drawing = drawingsSlice.selectors.selectMergedByCompoundId( + state, + drawingId, + ); + return selector(drawing); + }, equalityFn); + useDebugValue(ret); + return ret; } -export interface DrawingContext extends Drawing, SerializibleStore { - updateDrawing: StoreApi["setState"]; - incrementPriorityPlayer(this: void): void; - redrawAllCharts(this: void): void; - redrawChart(this: void, chartId: string): void; - resetChart(this: void, chartId: string): void; - /** - * handles any of the protect/pocket-pick/ban actions a user may take on a drawn chart - * @param action type of action being performed - * @param chartId id of the chart being acted upon - * @param player the player acting on the chart, 1 or 2 - * @param chart new chart being pocket picked, if this is a pocket pick action - */ - handleBanProtectReplace( - this: void, - action: "ban" | "protect" | "pocket", - chartId: string, - player: number, - chart?: EligibleChart, - ): void; - setWinner(this: void, chartId: string, p: number | null): void; +export function DrawingProvider({ + drawingId, + children, +}: { + drawingId: CompoundSetId; + children: ReactNode; +}) { + return ( + + {children} + + ); } -function keyFromAction(action: "ban" | "protect" | "pocket") { - switch (action) { - case "ban": - return "bans"; - case "protect": - return "protects"; - case "pocket": - return "pocketPicks"; - } +function ContextualConfigProvider({ children }: { children: ReactNode }) { + const configId = useDrawing((d) => d.configId); + return ( + {children} + ); } - -const { - Provider: DrawingProvider, - useContextValue: useDrawing, - StoreIndex: allDrawingStores, - useStore: useDrawingStore, -} = createContextualStore( - (props, set, get) => ({ - ...props.initialDrawing, - updateDrawing: set, - incrementPriorityPlayer() { - set((d) => { - let priorityPlayer = d.priorityPlayer; - if (!priorityPlayer) { - priorityPlayer = 1; - } else { - priorityPlayer += 1; - if (priorityPlayer >= d.players.length + 1) { - priorityPlayer = undefined; - } - } - return { priorityPlayer }; - }); - }, - resetChart(chartId) { - // Note: winners are intentionally left untouched here. A chart can have - // both an action (protect/pocket/ban) and a marked winner at the same - // time, and removing the action should not clear the winner. Winners are - // removed via their own control (setWinner(chartId, null)). - set((d) => ({ - bans: d.bans.filter((p) => p.chartId !== chartId), - protects: d.protects.filter((p) => p.chartId !== chartId), - pocketPicks: d.pocketPicks.filter((p) => p.chartId !== chartId), - })); - }, - redrawChart(chartId) { - const newChart = draw(useDrawState.getState().gameData!, { - ...useConfigState.getState(), - chartCount: 1, - playerPicks: 0, - }).charts[0]; - set((d) => ({ - charts: d.charts.map((chart) => { - if (chart.id === chartId) { - newChart.id = chartId; - return newChart; - } - return chart; - }), - })); - }, - redrawAllCharts() { - const self = get(); - const keepChartIds = new Set([ - ...self.pocketPicks.map((pick) => pick.chartId), - ...self.protects.map((pick) => pick.chartId), - ]); - const keepCharts = self.charts.filter( - (c) => keepChartIds.has(c.id) || c.type === CHART_PLACEHOLDER, - ); - const newCharts = draw(useDrawState.getState().gameData!, { - ...useConfigState.getState(), - chartCount: get().charts.length - keepCharts.length, - playerPicks: 0, - }); - set(() => ({ charts: [...keepCharts, ...newCharts.charts], bans: [] })); - }, - handleBanProtectReplace(action, chartId, player, newChart) { - const drawing = get(); - const charts = drawing.charts.slice(); - const key = keyFromAction(action); - const arr = drawing[key].slice() as PlayerActionOnChart[] | PocketPick[]; - const targetChartIdx = charts.findIndex((chart) => chart.id === chartId); - const targetChart = charts[targetChartIdx]; - - if ( - useConfigState.getState().orderByAction && - targetChart?.type !== CHART_PLACEHOLDER - ) { - charts.splice(targetChartIdx, 1); - if (action === "ban") { - // insert at tail of list - const insertPoint = charts.length; - charts.splice(insertPoint, 0, targetChart); - } else { - const frontLockedCardCount = - // number of placeholder cards total (picked and unpicked) - charts.reduce( - (total, curr) => - total + (curr.type === CHART_PLACEHOLDER ? 1 : 0), - 0, - ) + - // number of protects - drawing.protects.length + - // number of picks NOT targeting placeholder cards - drawing.pocketPicks.filter( - (p) => p.targetType !== CHART_PLACEHOLDER, - ).length; - - // insert at head of list, behind other picks/placeholders - charts.splice(frontLockedCardCount, 0, targetChart); - } - set({ charts }); - } - - const existingIndex = arr.findIndex((b) => b.chartId === chartId); - if (existingIndex >= 0) { - arr.splice(existingIndex, 1); - } else { - arr.push({ - player, - pick: newChart!, - chartId, - targetType: targetChart.type, - }); - } - set({ [key]: arr }); - }, - serializeSyncFields() { - return Object.entries(get()).reduce((ret: Partial, [k, v]) => { - if (typeof v === "function") { - return ret; - } - if (k.startsWith("__")) { - return ret; - } - ret[k as keyof Drawing] = v; - return ret; - }, {}) as Drawing; - }, - setWinner(chartId, player) { - const arr = get().winners.slice(); - const existingIndex = arr.findIndex((b) => b.chartId === chartId); - if (existingIndex >= 0) { - arr.splice(existingIndex, 1); - } - if (player) { - arr.push({ player, chartId }); - } - set({ winners: arr }); - }, - }), - (p) => p.initialDrawing.id, - { initialDrawing: stubDrawing }, -); - -export { useDrawing, DrawingProvider, allDrawingStores, useDrawingStore }; diff --git a/src/drawing-list.tsx b/src/drawing-list.tsx index 2540cf45e..3a54b2af1 100644 --- a/src/drawing-list.tsx +++ b/src/drawing-list.tsx @@ -1,62 +1,43 @@ -import { - Suspense, - lazy, - memo, - useDeferredValue, - useEffect, - useState, -} from "react"; -import { FormattedMessage } from "react-intl"; +import React, { Suspense, lazy, memo, useDeferredValue } from "react"; import styles from "./drawing-list.css"; -import { useDrawState } from "./draw-state"; -import { useConfigState } from "./config-state"; -import { useIntl } from "./hooks/useIntl"; -import { Callout, NonIdealState, Spinner } from "@blueprintjs/core"; -import { Import } from "@blueprintjs/icons"; +import { Callout, NonIdealState } from "@blueprintjs/core"; +import { WarningSign } from "@blueprintjs/icons"; import logo from "./assets/ddr-tools-256.png"; -import { ErrorBoundary } from "react-error-boundary"; -import { ErrorFallback } from "./utils/error-fallback"; +import { useAppState } from "./state/store"; +import { drawingsSlice } from "./state/drawings.slice"; +import { DelayedSpinner } from "./common-components/delayed-spinner"; +import { useIntl } from "./hooks/useIntl"; +import { FormattedMessage } from "react-intl"; -const EligibleChartsList = lazy(() => import("./eligible-charts")); -const DrawnSet = lazy(() => import("./drawn-set")); +const DrawnSetGroup = lazy(() => import("./drawn-set-group")); const ScrollableDrawings = memo(() => { - const drawings = useDeferredValue(useDrawState((s) => s.drawings)); + const drawingIds = useDeferredValue(useAppState((s) => s.drawings.ids)); return ( -
- {drawings.map((d) => ( - - ))} +
+ {drawingIds + .map((drawingId) => ( + + )) + .reverse()}
); }); -export function DrawingList() { +export function DrawingList(props: { introString?: React.ReactNode }) { const { t } = useIntl(); const hasDrawings = useDeferredValue( - useDrawState((s) => !!s.drawings.length), - ); - const showEligible = useDeferredValue( - useConfigState((cfg) => cfg.showEligibleCharts), + useAppState(drawingsSlice.selectors.haveDrawings), ); - if (showEligible) { - return ( - }> - }> - - - - ); - } if (!hasDrawings) { return (
} title="DDR Tools" - description={t("hero.description")} + description={props.introString || t("hero.description")} action={ - }> + }> } @@ -70,19 +51,3 @@ export function DrawingList() { ); } - -function DelayedSpinner(props: { timeout?: number }) { - const [show, updateShow] = useState(false); - useEffect(() => { - if (show) return; - - const timeout = setTimeout(() => { - updateShow(true); - }, props.timeout || 250); - return () => clearTimeout(timeout); - }, [props.timeout, show]); - if (show) { - return ; - } - return null; -} diff --git a/src/drawn-set-group.css b/src/drawn-set-group.css new file mode 100644 index 000000000..8caa67ce4 --- /dev/null +++ b/src/drawn-set-group.css @@ -0,0 +1,7 @@ +.drawnSetGroup { + position: relative; +} + +.drawnSetGroup:not(:first-child) { + border-top: 2px solid #777; +} diff --git a/src/drawn-set-group.tsx b/src/drawn-set-group.tsx new file mode 100644 index 000000000..5538809e2 --- /dev/null +++ b/src/drawn-set-group.tsx @@ -0,0 +1,46 @@ +import { DrawingProvider } from "./drawing-context"; +import DrawnSet, { ChartList } from "./drawn-set"; +import { useRotatingGradientStyles } from "./hooks/useRotatingGradient"; +import { useAppState } from "./state/store"; +import { MatchLabels } from "./tournament-mode/drawing-labels"; +import styles from "./drawn-set-group.css"; +import { MatchActions } from "./tournament-mode/drawing-actions"; + +export default function DrawnSetGroup({ drawingId }: { drawingId: string }) { + const gradient = useRotatingGradientStyles(); + const drawing = useAppState((s) => s.drawings.entities[drawingId]); + if (!drawing) return null; + return ( +
+ {drawing.subDrawings && + Object.values(drawing.subDrawings).map((subDraw, idx) => ( + + {idx === 0 && } + + + ))} + +
+ ); +} + +export function PlainDrawnSetGroup({ drawingId }: { drawingId: string }) { + const drawing = useAppState((s) => s.drawings.entities[drawingId]); + if (!drawing) return null; + return ( +
+ {drawing.subDrawings && + Object.values(drawing.subDrawings).map((subDraw) => ( + + + + ))} +
+ ); +} diff --git a/src/drawn-set.css b/src/drawn-set.css index 1af9ad835..d234c9440 100644 --- a/src/drawn-set.css +++ b/src/drawn-set.css @@ -1,13 +1,3 @@ -:root { - --drawing-grad-saturation: 40%; - --drawing-grad-lightness: 85%; -} - -body:global(.bp6-dark) { - --drawing-grad-saturation: 25%; - --drawing-grad-lightness: 15%; -} - .chartList { display: flex; padding: 10px; @@ -19,6 +9,11 @@ body:global(.bp6-dark) { position: relative; } +.drawing[data-focused] { + outline: 2px inset red; + outline-offset: -2px; +} + body:global(.obs-layer) .drawing { background-image: none !important; } diff --git a/src/drawn-set.tsx b/src/drawn-set.tsx index 36b553056..356e1bca4 100644 --- a/src/drawn-set.tsx +++ b/src/drawn-set.tsx @@ -1,29 +1,17 @@ -import { memo, useState } from "react"; +import { memo } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { SongCard } from "./song-card"; import styles from "./drawn-set.css"; -import { Drawing } from "./models/Drawing"; -import { SetLabels } from "./tournament-mode/drawing-labels"; -import { DrawingProvider, useDrawing } from "./drawing-context"; +import { useDrawing } from "./drawing-context"; import { DrawingActions } from "./tournament-mode/drawing-actions"; -import { SyncWithPeers } from "./tournament-mode/sync-with-peers"; -import { useConfigState } from "./config-state"; import { ErrorFallback } from "./utils/error-fallback"; -const HUE_STEP = (255 / 8) * 3; -let hue = Math.floor(Math.random() * 255); - -function getRandomGradiant() { - hue += HUE_STEP; - return `linear-gradient(hsl(${hue}, var(--drawing-grad-saturation), var(--drawing-grad-lightness)), transparent, transparent)`; -} - -interface Props { - drawing: Drawing; -} - -function ChartList() { +/** + * expects a drawing context wrapper + **/ +export function ChartList() { const charts = useDrawing((d) => d.charts); + if (!charts) return null; return (
{charts.map((c) => ( @@ -35,16 +23,12 @@ function ChartList() { function ChartFromContext({ chartId }: { chartId: string }) { const chart = useDrawing((d) => d.charts.find((c) => c.id === chartId)); - const veto = useDrawing((d) => d.bans.find((b) => b.chartId === chartId)); - const protect = useDrawing((d) => - d.protects.find((b) => b.chartId === chartId), - ); - const pocketPick = useDrawing((d) => - d.pocketPicks.find((b) => b.chartId === chartId), - ); - const winner = useDrawing((d) => - d.winners.find((b) => b.chartId === chartId), - ); + const veto = useDrawing((d) => { + return d.bans[chartId]; + }); + const protect = useDrawing((d) => d.protects[chartId]); + const pocketPick = useDrawing((d) => d.pocketPicks[chartId]); + const winner = useDrawing((d) => d.winners[chartId]); if (!chart) { return null; } @@ -54,7 +38,7 @@ function ChartFromContext({ chartId }: { chartId: string }) { protectedBy={protect?.player} replacedBy={pocketPick?.player} replacedWith={pocketPick?.pick} - winner={winner?.player} + winner={winner} chart={chart} actionsEnabled /> @@ -62,49 +46,40 @@ function ChartFromContext({ chartId }: { chartId: string }) { } function TournamentModeSpacer() { - const showLabels = useConfigState((s) => s.showPlayerAndRoundLabels); - if (showLabels) { - return null; - } return
; } -const DrawnSet = memo(function DrawnSet({ drawing }) { - const [backgroundImage] = useState(getRandomGradiant()); +const DrawnSet = memo(function DrawnSet() { + const [, drawingId] = useDrawing((d) => d.compoundId); return ( - - - -
- } - > - + - -
- - -
- + +
+ } + > +
+ +
+
- - + +
+ ); }); diff --git a/src/drop-handler.tsx b/src/drop-handler.tsx index 645468543..bdc57b848 100644 --- a/src/drop-handler.tsx +++ b/src/drop-handler.tsx @@ -8,11 +8,12 @@ import { } from "@blueprintjs/core"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { PackWithSongs } from "simfile-parser/browser"; -import { useDrawState } from "./draw-state"; import { getDataFileFromPack } from "./utils/itg-import"; import { pause } from "./utils/pause"; import { convertErrorToString } from "./utils/error-to-string"; import { Import } from "@blueprintjs/icons"; +import { useSetAtom } from "jotai"; +import { customDataCache } from "./state/game-data.atoms"; function loadParserModule() { return import("simfile-parser/browser"); @@ -117,7 +118,7 @@ function useDataParsing( function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) { const [tiered, setTiered] = useState(false); const [saving, setSaving] = useState(false); - const loadGameData = useDrawState((s) => s.addImportedData); + const setCustomData = useSetAtom(customDataCache); const { parsedPack, parseError } = useDataParsing(droppedFolder, setTiered); const derivedData = useMemo(() => { @@ -132,11 +133,16 @@ function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) { return; } setSaving(true); - loadGameData(parsedPack.name, derivedData); + setCustomData((prev) => { + return { + ...prev, + [parsedPack.name]: derivedData, + }; + }); await pause(500); setSaving(false); onSave(); - }, [parsedPack, derivedData, loadGameData, onSave]); + }, [parsedPack, derivedData, setCustomData, onSave]); const maybeSkeleton = derivedData ? "" : Classes.SKELETON; diff --git a/src/eligible-charts/filter.tsx b/src/eligible-charts/filter.tsx index ca1a2ad5e..77a9d1723 100644 --- a/src/eligible-charts/filter.tsx +++ b/src/eligible-charts/filter.tsx @@ -1,12 +1,12 @@ import { HTMLSelect } from "@blueprintjs/core"; import { atom, useAtom } from "jotai"; -import { useConfigState } from "../config-state"; -import { useIntl } from "../hooks/useIntl"; +import { useConfigState } from "../state/hooks"; +import { useGetMetaString } from "../game-data-utils"; export const currentTabAtom = atom("all"); export function EligibleChartsListFilter() { - const { t } = useIntl(); + const getMetaString = useGetMetaString(); const [currentTab, setCurrentTab] = useAtom(currentTabAtom); const selectedFlags = Array.from(useConfigState((cfg) => cfg.flags)); @@ -22,7 +22,7 @@ export function EligibleChartsListFilter() { onChange={(e) => setCurrentTab(e.currentTarget.value)} options={Array.from(selectedFlags).map((flag) => ({ value: flag, - label: flag === "all" ? "All charts" : t(`meta.${flag}`), + label: flag === "all" ? "All charts" : getMetaString(flag), }))} /> ); diff --git a/src/eligible-charts/histogram.tsx b/src/eligible-charts/histogram.tsx index 9eb4dc436..21a6f5f10 100644 --- a/src/eligible-charts/histogram.tsx +++ b/src/eligible-charts/histogram.tsx @@ -9,30 +9,29 @@ import { } from "victory"; import { useMemo } from "react"; import { CountingSet } from "../utils/counting-set"; -import { useDrawState } from "../draw-state"; -import { useIntl } from "../hooks/useIntl"; import { chartLevelOrTier, getAvailableLevels, - getDiffClass, - getMetaString, + useGetDiffClass, + useGetMetaString, } from "../game-data-utils"; -import { Theme, useTheme } from "../theme-toggle"; +import { useTheme } from "../theme-toggle"; import { useIsNarrow } from "../hooks/useMediaQuery"; -import { useConfigState } from "../config-state"; +import { useConfigState, useGameData } from "../state/hooks"; interface Props { charts: EligibleChart[]; } export function DiffHistogram({ charts }: Props) { - const { t } = useIntl(); - const fgColor = useTheme() === Theme.Dark ? "white" : undefined; + const fgColor = useTheme() === "dark" ? "white" : undefined; const isNarrow = useIsNarrow(); - const allDiffs = useDrawState((s) => s.gameData?.meta.difficulties); - const gameData = useDrawState((s) => s.gameData); + const gameData = useGameData(); + const allDiffs = gameData?.meta.difficulties; const useGranularLevels = useConfigState((s) => s.useGranularLevels); const availableLevels = getAvailableLevels(gameData, useGranularLevels, true); + const getDiffClass = useGetDiffClass(); + const getMetaString = useGetMetaString(); function formatLabel(idx: number) { const n = availableLevels[idx]; if (!n) return ""; @@ -56,16 +55,16 @@ export function DiffHistogram({ charts }: Props) { } const orderedLevels = Array.from(allLevels.values()).sort((a, b) => a - b); const difficulties = (allDiffs || []) - .filter((d) => !!countByClassAndLvl[getDiffClass(t, d.key)]) + .filter((d) => !!countByClassAndLvl[getDiffClass(d.key)]) .reverse(); const dataPerDiff = difficulties.map((diff) => ({ color: diff.color, key: diff.key, - label: getMetaString(t, diff.key), + label: getMetaString(diff.key), data: orderedLevels.map((lvl) => ({ xPlacement: availableLevels.indexOf(lvl), level: lvl, - count: countByClassAndLvl[getDiffClass(t, diff.key)].get(lvl) || 0, + count: countByClassAndLvl[getDiffClass(diff.key)].get(lvl) || 0, })), })); return [ @@ -76,7 +75,14 @@ export function DiffHistogram({ charts }: Props) { .sort((a, b) => a[0] - b[0]) .map(([, count]) => count), ]; - }, [allDiffs, charts, useGranularLevels, t, availableLevels]); + }, [ + allDiffs, + charts, + useGranularLevels, + getDiffClass, + getMetaString, + availableLevels, + ]); return ( s.gameData); +export default function EligibleChartsView() { + const [configId, setConfigId] = useState(null); + const selector = ( + + ); + + if (!configId) { + return selector; + } + + return ( + <> + {selector} + + + + + ); +} + +function EligibleChartsList() { + const gameData = useGameData(); const [currentTab] = useDeferredValue(useAtom(currentTabAtom)); const configState = useDeferredValue(useConfigState()); const isNarrow = useIsNarrow(); @@ -52,11 +75,6 @@ export default function EligibleChartsList() { await shareCharts(filteredCharts, "eligible"); }, [filteredCharts]); - const customStubDrawing = useMemo( - () => ({ ...stubDrawing, cardVariant: gameData?.meta.cardVariant }), - [gameData?.meta.cardVariant], - ); - if (!gameData) { return ; } @@ -73,7 +91,7 @@ export default function EligibleChartsList() { {charts.length} eligible charts from {songs.size} songs (of{" "} {gameData.songs.length} total) - {configState.flags.size > 0 && !isNarrow && ( + {configState.flags.length > 0 && !isNarrow && ( @@ -92,18 +110,13 @@ export default function EligibleChartsList() {
- - {filteredCharts.map((chart, idx) => - isDegrs(chart) ? ( - - ) : ( - - ), - )} - + {filteredCharts.map((chart, idx) => + isDegrs(chart) ? ( + + ) : ( + + ), + )}
); diff --git a/src/game-data-utils.tsx b/src/game-data-utils.tsx index 7917bd6ff..e9d23178c 100644 --- a/src/game-data-utils.tsx +++ b/src/game-data-utils.tsx @@ -1,18 +1,30 @@ +import { useCallback } from "react"; import { useIntl } from "./hooks/useIntl"; import { EligibleChart } from "./models/Drawing"; import { Chart, GameData, I18NDict } from "./models/SongData"; +import { useConfigState } from "./state/hooks"; -export function getMetaString(t: (key: string) => string, key: string) { - return t("meta." + key); +export function useGetMetaString() { + const { t } = useIntl(); + const gameKey = useConfigState((c) => c.gameKey); + return useCallback( + (key: string) => t(`game.${gameKey}.${key}`), + [gameKey, t], + ); } export function MetaString({ key }: { key: string }) { - const { t } = useIntl(); - return <>{getMetaString(t, key)}; + const getMetaString = useGetMetaString(); + return <>{getMetaString(key)}; } -export function getDiffClass(t: (key: string) => string, diffClassKey: string) { - return t("meta.$abbr." + diffClassKey); +export function useGetDiffClass() { + const { t } = useIntl(); + const gameKey = useConfigState((c) => c.gameKey); + return useCallback( + (diffClassKey: string) => t(`game.${gameKey}.$abbr.${diffClassKey}`), + [gameKey, t], + ); } interface AbbrProps { @@ -20,8 +32,8 @@ interface AbbrProps { } export function AbbrDifficulty({ diffClass }: AbbrProps) { - const { t } = useIntl(); - return <>{getDiffClass(t, diffClass)}; + const getDiffClass = useGetDiffClass(); + return <>{getDiffClass(diffClass)}; } /** diff --git a/src/header.tsx b/src/header.tsx index 290758428..c71ef3fa7 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -7,24 +7,88 @@ import { Navbar, Popover, } from "@blueprintjs/core"; -import { Trash, InfoSign, Menu as MenuIcon, Help } from "@blueprintjs/icons"; -import { useState } from "react"; +import { + Trash, + InfoSign, + Menu as MenuIcon, + Help, + Control, +} from "@blueprintjs/icons"; +import { JSX, useCallback, useState } from "react"; import { About } from "./about"; import { HeaderControls } from "./controls"; import { useIntl } from "./hooks/useIntl"; import { LastUpdate } from "./last-update"; -import { ThemeToggle } from "./theme-toggle"; -import { DataLoadingSpinner, VersionSelect } from "./version-select"; -import { useDrawState } from "./draw-state"; +import { ThemeToggle, useInObs } from "./theme-toggle"; +import { useAppDispatch, useAppState } from "./state/store"; +import { drawingsSlice } from "./state/drawings.slice"; +import { EventModeGated } from "./common-components/app-mode"; +import { useNavigate, useHref } from "react-router-dom"; + +export function Header({ + heading, + controls, +}: { + heading?: JSX.Element; + controls?: JSX.Element; +}) { + const inObs = useInObs(); + + if (inObs) return null; -export function Header() { + return ( + + + + + {heading || ( + + Event Mode{" "} + + + + Alpha Preview + + + + + )} + + + {controls || } + + + ); +} + +export function HamburgerMenu() { const [aboutOpen, setAboutOpen] = useState(false); - const clearDrawings = useDrawState((d) => d.clearDrawings); - const haveDrawings = useDrawState((d) => !!d.drawings.length); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const dashHref = useHref("dash", { relative: "route" }); + const clearDrawings = useCallback( + () => dispatch(drawingsSlice.actions.clearDrawings()), + [dispatch], + ); + const haveDrawings = useAppState(drawingsSlice.selectors.haveDrawings); const { t } = useIntl(); const menu = ( + } + text="Stream Dashboard" + href={dashHref} + onClick={(e) => { + e.preventDefault(); + navigate("dash"); + }} + /> } onClick={clearDrawings} @@ -47,28 +111,14 @@ export function Header() { ); - return ( - + <> setAboutOpen(false)}> - - - + + ); +} diff --git a/src/preview-mode/shell.tsx b/src/preview-mode/shell.tsx new file mode 100644 index 000000000..602fd32f6 --- /dev/null +++ b/src/preview-mode/shell.tsx @@ -0,0 +1,41 @@ +import { Outlet, useLoaderData } from "react-router-dom"; +import { Provider as ReduxProvider } from "react-redux"; +import { getPartykitState } from "../party/host"; +import { useMemo } from "react"; +import { createClientStore } from "../state/store"; +import { PreviewModeHeader } from "./header"; +import { DrawingList } from "../drawing-list"; +import { ClassicModeContext } from "../common-components/app-mode"; +import { useIntl } from "../hooks/useIntl"; + +async function loader(roomName: string | undefined) { + if (!roomName) { + return; + } + const state = await getPartykitState(roomName); + state.drawings.entities = {}; + state.drawings.ids = []; + return state; +} + +export function PreviewShell() { + const initialState = useLoaderData() as Awaited>; + const store = useMemo(() => createClientStore(initialState), [initialState]); + return ( + + + + ); +} + +PreviewShell.loader = loader; + +export function PreviewView() { + const { t } = useIntl(); + return ( + + + + + ); +} diff --git a/src/song-card/icon-menu.tsx b/src/song-card/acton-menu.tsx similarity index 68% rename from src/song-card/icon-menu.tsx rename to src/song-card/acton-menu.tsx index 5594a531e..a6ae124c6 100644 --- a/src/song-card/icon-menu.tsx +++ b/src/song-card/acton-menu.tsx @@ -7,21 +7,22 @@ import { Person, Refresh, Clipboard, + Draw, } from "@blueprintjs/icons"; import { Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; import { useDrawing } from "../drawing-context"; import { JSX } from "react"; interface Props { - onStartPocketPick?: (p: number) => void; - onVeto?: (p: number) => void; - onProtect?: (p: number) => void; + onStartPocketPick?: (p: string) => void; + onVeto?: (p: string) => void; + onProtect?: (p: string) => void; onRedraw?: () => void; - onSetWinner?: (p: number | null) => void; + onSetWinner?: (p: string | null) => void; onCopy?: () => void; } -export function IconMenu(props: Props) { +export function ActionMenu(props: Props) { const { onStartPocketPick, onVeto, @@ -84,21 +85,39 @@ export function IconMenu(props: Props) { ); } +export function FillPlaceholderList(props: { + onFillPlaceholder(p: string): void; +}) { + const players = useDrawing((d) => d.meta.players); + return ( + + {players.map((player) => ( + props.onFillPlaceholder(player.id)} + icon={} + /> + ))} + + ); +} + interface IconRowProps { icon: JSX.Element; text: string; - onClick: (p: number) => void; + onClick: (p: string) => void; } function PlayerList({ icon, text, onClick }: IconRowProps) { - const players = useDrawing((d) => d.players); + const players = useDrawing((d) => d.meta.players); return ( - {players.map((p, idx) => ( + {players.map((player) => ( onClick(idx + 1)} + key={player.id} + text={player.name} + onClick={() => onClick(player.id)} icon={} /> ))} diff --git a/src/song-card/card-label.tsx b/src/song-card/card-label.tsx index ff3d2016a..1cb799795 100644 --- a/src/song-card/card-label.tsx +++ b/src/song-card/card-label.tsx @@ -10,7 +10,7 @@ import { Draw, SVGIconProps, } from "@blueprintjs/icons"; -import { usePlayerLabel } from "./use-player-label"; +import { usePlayerLabelForId } from "./use-player-label"; export enum LabelType { Protect = 1, @@ -21,7 +21,7 @@ export enum LabelType { } interface Props { - player: number; + playerId: string; type: LabelType; onRemove?: () => void; } @@ -56,8 +56,8 @@ function LabelIcon({ type, ...props }: SVGIconProps & { type: LabelType }) { } } -export function CardLabel({ player, type, onRemove }: Props) { - const label = usePlayerLabel(player); +export function CardLabel({ playerId, type, onRemove }: Props) { + const label = usePlayerLabelForId(playerId); const rootClassname = classNames(styles.cardLabel, { [styles.winner]: type === LabelType.Winner, diff --git a/src/song-card/chart-level.tsx b/src/song-card/chart-level.tsx index 89b556857..ea52f3ada 100644 --- a/src/song-card/chart-level.tsx +++ b/src/song-card/chart-level.tsx @@ -1,4 +1,4 @@ -import { useConfigState } from "../config-state"; +import { useConfigState } from "../state/hooks"; import { formatLevel } from "../game-data-utils"; import { CHART_PLACEHOLDER, diff --git a/src/song-card/index.tsx b/src/song-card/index.tsx index f5b007735..90ec4e065 100644 --- a/src/song-card/index.tsx +++ b/src/song-card/index.tsx @@ -1,11 +1,11 @@ -import { useDrawing } from "../drawing-context"; import { SongCardBase, type SongCardProps } from "./song-card"; import { getContentVariants } from "./variants"; export { SongCardProps }; export function SongCard(p: SongCardProps) { - const cardType = useDrawing((d) => d.cardVariant); - const cardImpl = getContentVariants(cardType); + const cardImpl = getContentVariants( + "cardVariant" in p.chart ? p.chart.cardVariant : undefined, + ); return ; } diff --git a/src/song-card/song-card.css b/src/song-card/song-card.css index 2302baab7..789258634 100644 --- a/src/song-card/song-card.css +++ b/src/song-card/song-card.css @@ -32,6 +32,11 @@ U+FFFD; } +.popoverWrapper { + display: flex !important; + flex: 1 0 0; +} + .chart { display: flex !important; flex: 1 0 0; @@ -63,6 +68,15 @@ cursor: pointer; } +.chart.randomSelected { + box-shadow: 0px 0px 4px black; + transform: scale(1.05); +} + +:global(.bp6-dark) .chart.randomSelected { + box-shadow: 0px 0px 4px white; +} + .clickTarget { flex: 1 1 auto; display: flex; diff --git a/src/song-card/song-card.tsx b/src/song-card/song-card.tsx index 374bb8a28..b7c479ab6 100644 --- a/src/song-card/song-card.tsx +++ b/src/song-card/song-card.tsx @@ -1,8 +1,14 @@ import { Popover } from "@blueprintjs/core"; import classNames from "classnames"; -import React, { JSX, useCallback, useMemo, useState } from "react"; -import { shallow } from "zustand/shallow"; -import { useConfigState } from "../config-state"; +import { + type JSX, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useConfigState } from "../state/hooks"; import { useDrawing } from "../drawing-context"; import { CHART_PLACEHOLDER, @@ -12,29 +18,35 @@ import { } from "../models/Drawing"; import { SongSearch } from "../song-search"; import { CardLabel, LabelType } from "./card-label"; -import { IconMenu } from "./icon-menu"; +import { FillPlaceholderList, ActionMenu } from "./acton-menu"; import styles from "./song-card.css"; +import { useAppDispatch } from "../state/store"; +import { createPickBanPocket, createRedrawChart } from "../state/thunks"; +import { getJacketUrl } from "../utils/jackets"; +import { drawingsSlice } from "../state/drawings.slice"; import { copyTextToClipboard } from "../utils/share"; +import { useChartRandomSelected } from "../tournament-mode/highlight-random"; + import { baseChartValues, CardContentsProps } from "./variants"; -type Player = number; +type PlayerId = string; interface IconCallbacks { - onVeto: (p: Player) => void; - onProtect: (p: Player) => void; - onReplace: (p: Player, chart: EligibleChart) => void; + onVeto: (p: PlayerId) => void; + onProtect: (p: PlayerId) => void; + onReplace: (p: PlayerId, chart: EligibleChart) => void; onRedraw: () => void; onReset: () => void; - onSetWinner: (p: Player | null) => void; + onSetWinner: (p: PlayerId | null) => void; } export interface SongCardProps { onClick?: () => void; chart: DrawnChart | EligibleChart | PlayerPickPlaceholder; - vetoedBy?: Player; - protectedBy?: Player; - replacedBy?: Player; - winner?: Player; + vetoedBy?: PlayerId; + protectedBy?: PlayerId; + replacedBy?: PlayerId; + winner?: PlayerId | null; replacedWith?: EligibleChart; actionsEnabled?: boolean; } @@ -44,26 +56,34 @@ type Props = SongCardProps & CardContentsProps; export { Props as SongCardBaseProps }; function useIconCallbacksForChart(chartId: string): IconCallbacks { - const [handleBanPickPocket, redrawChart, resetChart, setWinner] = useDrawing( - (d) => [ - d.handleBanProtectReplace, - d.redrawChart, - d.resetChart, - d.setWinner, - ], - shallow, + const dispatch = useAppDispatch(); + const drawingId = useDrawing((s) => s.compoundId); + + const handleBanPickPocket = useCallback( + ( + type: "ban" | "protect" | "pocket", + player: string, + pick?: EligibleChart, + ) => dispatch(createPickBanPocket(drawingId, chartId, type, player, pick)), + [drawingId, chartId, dispatch], ); return useMemo( () => ({ - onVeto: handleBanPickPocket.bind(undefined, "ban", chartId), - onProtect: handleBanPickPocket.bind(undefined, "protect", chartId), - onReplace: handleBanPickPocket.bind(undefined, "pocket", chartId), - onRedraw: redrawChart.bind(undefined, chartId), - onReset: resetChart.bind(undefined, chartId), - onSetWinner: setWinner.bind(undefined, chartId), + onVeto: handleBanPickPocket.bind(undefined, "ban"), + onProtect: handleBanPickPocket.bind(undefined, "protect"), + onReplace: handleBanPickPocket.bind(undefined, "pocket"), + onRedraw: () => { + dispatch(createRedrawChart(drawingId, chartId)); + }, + onReset: () => + dispatch(drawingsSlice.actions.resetChart({ drawingId, chartId })), + onSetWinner: (player) => + dispatch( + drawingsSlice.actions.setWinner({ drawingId, chartId, player }), + ), }), - [handleBanPickPocket, chartId, redrawChart, resetChart, setWinner], + [handleBanPickPocket, drawingId, chartId, dispatch], ); } @@ -81,26 +101,40 @@ export function SongCardBase(props: Props) { } = props; const hideVetos = useConfigState((s) => s.hideVetos); + const [wasRandomlySelected, clearRandomSelection] = + useChartRandomSelected(chart); + const rootRef = useRef(null); + useEffect(() => { + if (wasRandomlySelected && rootRef.current) { + rootRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, [wasRandomlySelected]); + const [showingContextMenu, setContextMenuOpen] = useState(false); const showMenu = () => setContextMenuOpen(true); const hideMenu = () => setContextMenuOpen(false); const [pocketPickPendingForPlayer, setPocketPickPendingForPlayer] = - useState(0); + useState(null); const baseChartIsPlaceholder = "type" in chart && chart.type === CHART_PLACEHOLDER; const { name, diffAbbr, jacket } = replacedWith || baseChartValues(chart); - const hasLabel = !!(vetoedBy || protectedBy || replacedBy); + const hasLabel = !!( + vetoedBy !== undefined || + protectedBy !== undefined || + replacedBy !== undefined + ); + const hasWinner = typeof winner === "number"; let jacketBg = {}; if (jacket) { - const prefix = jacket.startsWith("blob:") ? "" : "jackets/"; - jacketBg = { - backgroundImage: `url("${prefix}${jacket}")`, - }; + jacketBg = { backgroundImage: `url("${getJacketUrl(jacket)}")` }; } const iconCallbacks = useIconCallbacksForChart((chart as DrawnChart).id); @@ -116,14 +150,16 @@ export function SongCardBase(props: Props) { const canCopy = !!name && !!diffAbbr; let menuContent: undefined | JSX.Element; - if (actionsEnabled && !winner) { - if (!replacedWith && baseChartIsPlaceholder) { + if (actionsEnabled && !hasWinner) { + if (replacedWith === undefined && baseChartIsPlaceholder) { menuContent = ( - + ); } else if (!hasLabel) { menuContent = ( - ); - } else if (!vetoedBy) { + } else if (vetoedBy === undefined) { menuContent = ( - + ); } } const rootClassname = classNames(styles.chart, { - [styles.vetoed]: vetoedBy, - [styles.protected]: protectedBy, - [styles.replaced]: replacedBy && !baseChartIsPlaceholder, - [styles.picked]: replacedBy && baseChartIsPlaceholder, + [styles.vetoed]: vetoedBy !== undefined, + [styles.protected]: protectedBy !== undefined, + [styles.replaced]: replacedBy !== undefined && !baseChartIsPlaceholder, + [styles.picked]: replacedBy !== undefined && baseChartIsPlaceholder, [styles.clickable]: !!menuContent || !!props.onClick || canCopy, [styles.hideVeto]: hideVetos, + [styles.randomSelected]: wasRandomlySelected, }); const handleCardClick = menuContent ? showMenu : props.onClick || handleCopy; const actionLabels = ( <> - {vetoedBy && ( + {vetoedBy !== undefined && ( )} - {protectedBy && ( + {protectedBy !== undefined && ( )} - {replacedBy && ( + {replacedBy !== undefined && ( )} - {winner && ( + {winner !== undefined && winner !== null && ( iconCallbacks?.onSetWinner(null)} /> @@ -184,41 +224,50 @@ export function SongCardBase(props: Props) { ); return ( -
This one!
} + targetTagName="div" + className={styles.popoverWrapper} > - { - if (actionsEnabled && chart) { - iconCallbacks.onReplace(pocketPickPendingForPlayer as 1 | 2, chart); - } - setPocketPickPendingForPlayer(0); - }} - onCancel={() => setPocketPickPendingForPlayer(0)} - /> -
- {actionLabels} - -
- - - - -
+ { + if (actionsEnabled && chart) { + iconCallbacks.onReplace(pocketPickPendingForPlayer!, chart); + } + setPocketPickPendingForPlayer(null); + }} + onCancel={() => setPocketPickPendingForPlayer(null)} + /> +
+ {actionLabels} + +
+ + + + +
+
); } diff --git a/src/song-card/use-player-label.ts b/src/song-card/use-player-label.ts index 6dc193d03..dfd80b652 100644 --- a/src/song-card/use-player-label.ts +++ b/src/song-card/use-player-label.ts @@ -1,5 +1,6 @@ import { useDrawing } from "../drawing-context"; +import { playerNameById } from "../models/Drawing"; -export function usePlayerLabel(n: number) { - return useDrawing((d) => d.players[n - 1] || `P${n}`); +export function usePlayerLabelForId(playerId: string) { + return useDrawing((d) => playerNameById(d.meta, playerId)); } diff --git a/src/song-card/variants/base.tsx b/src/song-card/variants/base.tsx index 08d42aa31..39ec2346d 100644 --- a/src/song-card/variants/base.tsx +++ b/src/song-card/variants/base.tsx @@ -9,7 +9,7 @@ import { useIntl } from "../../hooks/useIntl"; import { detectedLanguage } from "../../utils"; import { ChartLevel } from "../chart-level"; import styles from "../song-card.css"; -import { useConfigState } from "../../config-state"; +import { useConfigState } from "../../state/hooks"; const isJapanese = detectedLanguage === "ja"; diff --git a/src/song-jacket.tsx b/src/song-jacket.tsx index 0df854f5d..8cd171d59 100644 --- a/src/song-jacket.tsx +++ b/src/song-jacket.tsx @@ -1,6 +1,7 @@ import { Song } from "./models/SongData"; import { Icon } from "@blueprintjs/core"; import { Music } from "@blueprintjs/icons"; +import { getJacketUrl } from "./utils/jackets"; interface Props { song: Song; @@ -12,7 +13,7 @@ export function SongJacket(props: Props) { if (props.song.jacket) { return ( diff --git a/src/song-search/index.tsx b/src/song-search/index.tsx index cc92b9e9a..e54a939b0 100644 --- a/src/song-search/index.tsx +++ b/src/song-search/index.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { chartIsValid, getDrawnChart, songIsValid } from "../card-draw"; -import { useConfigState } from "../config-state"; -import { useDrawState } from "../draw-state"; +import { useConfigState, useGameData } from "../state/hooks"; import { EligibleChart } from "../models/Drawing"; import { Song } from "../models/SongData"; import { SearchResult, SearchResultData } from "./search-result"; import { Omnibar } from "@blueprintjs/select"; import styles from "./song-search.css"; +import { useFuzzySearch } from "../hooks/useFuzzySearch"; interface Props { isOpen: boolean; @@ -18,7 +18,8 @@ export function SongSearch(props: Props) { const { isOpen, onSongSelect, onCancel } = props; const [searchTerm, updateSearchTerm] = useState(""); const config = useConfigState(); - const fuzzySearch = useDrawState((s) => s.fuzzySearch); + const gameData = useGameData(); + const fuzzySearch = useFuzzySearch(); let items: SearchResultData[] = []; if (fuzzySearch) { @@ -51,11 +52,7 @@ export function SongSearch(props: Props) { item.song, item.chart === "none" || !item.chart ? undefined - : getDrawnChart( - useDrawState.getState().gameData!, - item.song, - item.chart, - ), + : getDrawnChart(gameData!, item.song, item.chart), ) } items={items} diff --git a/src/songs/dashdance25.json b/src/songs/dashdance25.json index 67bd566a9..18d62cebb 100644 --- a/src/songs/dashdance25.json +++ b/src/songs/dashdance25.json @@ -14,10 +14,10 @@ }, "defaults": { "flags": [], - "lowerLvlBound": 7, + "lowerLvlBound": 1, "difficulties": ["challenge"], "style": "single", - "upperLvlBound": 15 + "upperLvlBound": 5 }, "i18n": { "en": { diff --git a/src/songs/syr5.json b/src/songs/syr5.json new file mode 100644 index 000000000..a665cd3aa --- /dev/null +++ b/src/songs/syr5.json @@ -0,0 +1,1111 @@ +{ + "meta": { + "menuParent": "events", + "flags": [], + "lastUpdated": 1751082979926, + "usesDrawGroups": true, + "styles": ["single"], + "difficulties": [ + { + "key": "challenge", + "color": "#0191F2" + }, + { + "key": "expert", + "color": "#F64D8B" + }, + { + "key": "beginner", + "color": "#98aafd" + }, + { + "key": "difficult", + "color": "#F2F52C" + }, + { + "key": "basic", + "color": "#2BC856" + } + ] + }, + "defaults": { + "flags": [], + "lowerLvlBound": 8, + "difficulties": ["challenge", "expert", "beginner", "difficult", "basic"], + "upperLvlBound": 13, + "style": "single" + }, + "i18n": { + "en": { + "name": "Show Your Rhythm 5", + "single": "Single", + "double": "Double", + "beginner": "Beginner", + "basic": "Basic", + "difficult": "Difficult", + "expert": "Expert", + "challenge": "Challenge", + "edit": "Edit", + "$abbr": { + "beginner": "Beg", + "basic": "Bas", + "difficult": "Dif", + "expert": "Exp", + "challenge": "Cha", + "edit": "Edit" + } + } + }, + "songs": [ + { + "name": "[10] 34+35", + "name_translation": "", + "jacket": "itg/syr5/[10] 34+35.jpg", + "bpm": "110", + "artist": "Ariana Grande", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] A Drop", + "name_translation": "", + "jacket": "itg/syr5/[10] A Drop.jpg", + "bpm": "150", + "artist": "arthur x medic", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Baila Conmigo", + "name_translation": "", + "jacket": "itg/syr5/[10] Baila Conmigo.jpg", + "bpm": "128", + "artist": "Dayvi, Víctor Cárdenas, Kelly Ruiz", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Blade", + "name_translation": "", + "jacket": "itg/syr5/[10] Blade.jpg", + "bpm": "136", + "artist": "DJ Kuba, Neitan, Bounce Inc.", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Doodles", + "name_translation": "", + "jacket": "itg/syr5/[10] Doodles.jpg", + "bpm": "104", + "artist": "Rose Betts", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Freak Out", + "name_translation": "", + "jacket": "itg/syr5/[10] Freak Out.jpg", + "bpm": "169", + "artist": "Miya Folick", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "expert", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Fun Tonight", + "name_translation": "", + "jacket": "itg/syr5/[10] Fun Tonight.jpg", + "bpm": "130", + "artist": "MACROSS 82-99", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Happy Dance", + "name_translation": "", + "jacket": "itg/syr5/[10] Happy Dance.jpg", + "bpm": "125", + "artist": "MercyMe", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Houdini", + "name_translation": "", + "jacket": "itg/syr5/[10] Houdini.jpg", + "bpm": "117", + "artist": "Dua Lipa", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] NASTY (ELIMINATE REMIX)", + "name_translation": "", + "jacket": "itg/syr5/[10] NASTY (ELIMINATE REMIX).jpg", + "bpm": "95", + "artist": "TINASHE, ELIMINATE", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "challenge", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] Never Gonna Give You Up", + "name_translation": "", + "jacket": "itg/syr5/[10] Never Gonna Give You Up.jpg", + "bpm": "113", + "artist": "Rick Astley", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "expert", + "drawGroup": 10 + } + ] + }, + { + "name": "[10] the CIA", + "name_translation": "", + "jacket": "itg/syr5/[10] the CIA.jpg", + "bpm": "135-190", + "artist": "glass beach", + "charts": [ + { + "lvl": 10, + "style": "single", + "diffClass": "expert", + "drawGroup": 10 + } + ] + }, + { + "name": "[11] AWAKEN", + "name_translation": "", + "jacket": "itg/syr5/[11] AWAKEN.jpg", + "bpm": "140", + "artist": "Korva", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "beginner", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Bullet Waiting for Me", + "name_translation": "", + "jacket": "itg/syr5/[11] Bullet Waiting for Me.jpg", + "bpm": "107", + "artist": "Nikki Simmons", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Dirty Vibe", + "name_translation": "", + "jacket": "itg/syr5/[11] Dirty Vibe.jpg", + "bpm": "126", + "artist": "RudeLies", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] EXPERIENCE", + "name_translation": "", + "jacket": "itg/syr5/[11] EXPERIENCE.jpg", + "bpm": "154", + "artist": "k?d", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "expert", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Fly Me To The Moon", + "name_translation": "", + "jacket": "itg/syr5/[11] Fly Me To The Moon.jpg", + "bpm": "130", + "artist": "Quartzone", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "expert", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Gang Baby", + "name_translation": "", + "jacket": "itg/syr5/[11] Gang Baby.jpg", + "bpm": "129", + "artist": "NLE Choppa", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Ghost Ship", + "name_translation": "", + "jacket": "itg/syr5/[11] Ghost Ship.jpg", + "bpm": "110", + "artist": "Teminite", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Hidamaream", + "name_translation": "", + "jacket": "itg/syr5/[11] Hidamaream.jpg", + "bpm": "140", + "artist": "Droplamp", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] MORJA KAIJU VIP", + "name_translation": "", + "jacket": "itg/syr5/[11] MORJA KAIJU VIP.jpg", + "bpm": "150", + "artist": "Skrillex", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Rain", + "name_translation": "", + "jacket": "itg/syr5/[11] Rain.jpg", + "bpm": "139", + "artist": "E-Type", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] SAY", + "name_translation": "", + "jacket": "itg/syr5/[11] SAY.jpg", + "bpm": "145", + "artist": "Knock2", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[11] Scissor", + "name_translation": "", + "jacket": "itg/syr5/[11] Scissor.jpg", + "bpm": "140", + "artist": "Shandy Kubota", + "charts": [ + { + "lvl": 11, + "style": "single", + "diffClass": "challenge", + "drawGroup": 11 + } + ] + }, + { + "name": "[12] 天邪鬼", + "name_translation": "[12] Amanojaku", + "jacket": "itg/syr5/[12] Amanojaku.jpg", + "bpm": "155", + "artist": "Qlarabelle", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Anomaly", + "name_translation": "", + "jacket": "itg/syr5/[12] Anomaly Remix.jpg", + "bpm": "172", + "artist": "Noisia", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Boiled Yatsuhashi", + "name_translation": "", + "jacket": "itg/syr5/[12] Boiled Yatsuhashi (VIP).jpg", + "bpm": "150", + "artist": "Hylen", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Distress", + "name_translation": "", + "jacket": "itg/syr5/[12] Distress.jpg", + "bpm": "125", + "artist": "dj TAKA Remixed by TaQ", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] GIGA OMEGA VIP", + "name_translation": "", + "jacket": "itg/syr5/[12] GIGA OMEGA VIP.jpg", + "bpm": "140", + "artist": "Blacklolita", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] I GOT THE FEELIN", + "name_translation": "", + "jacket": "itg/syr5/[12] I GOT THE FEELIN.jpg", + "bpm": "130", + "artist": "Houseplan", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] I Still Drop The Bass", + "name_translation": "", + "jacket": "itg/syr5/[12] I Still Drop The Bass.jpg", + "bpm": "140", + "artist": "かめりあ", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Infiltration", + "name_translation": "", + "jacket": "itg/syr5/[12] Infiltration.jpg", + "bpm": "150", + "artist": "ParagonX9", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] カチカチ山", + "name_translation": "[12] Kachi Kachi Yama", + "jacket": "itg/syr5/[12] Kachi Kachi Yama.jpg", + "bpm": "143-150", + "artist": "Gacharic Spin", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] MURDER PLOT", + "name_translation": "", + "jacket": "itg/syr5/[12] MURDER PLOT.jpg", + "bpm": "140", + "artist": "KORDHELL", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Satellite", + "name_translation": "", + "jacket": "itg/syr5/[12] Satellite.jpg", + "bpm": "145", + "artist": "Valentino Khan", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[12] Take Me Back", + "name_translation": "", + "jacket": "itg/syr5/[12] Take Me Back.jpg", + "bpm": "175", + "artist": "KaW", + "charts": [ + { + "lvl": 12, + "style": "single", + "diffClass": "challenge", + "drawGroup": 12 + } + ] + }, + { + "name": "[13] BEAST BASS BOMB", + "name_translation": "", + "jacket": "itg/syr5/[13] BEAST BASS BOMB.jpg", + "bpm": "170", + "artist": "Hommarju", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Banjo Kazooie Main Theme (Metal Remix)", + "name_translation": "", + "jacket": "itg/syr5/[13] Banjo Kazooie Main Theme.jpg", + "bpm": "148", + "artist": "FamilyJules", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Came To Rage", + "name_translation": "", + "jacket": "itg/syr5/[13] Came To Rage.jpg", + "bpm": "130", + "artist": "Riot Ten", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Dance Through the Danger", + "name_translation": "", + "jacket": "itg/syr5/[13] Dance Through the Danger.jpg", + "bpm": "180", + "artist": "covered by RichaadEB feat. Cristina Vee", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Freedom", + "name_translation": "", + "jacket": "itg/syr5/[13] Freedom.jpg", + "bpm": "150", + "artist": "USAO", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] ハイテックトキオ", + "name_translation": "[13] Hi-tech Tokio", + "jacket": "itg/syr5/[13] Hi-tech Tokio.jpg", + "bpm": "165", + "artist": "lapix ∞ BEMANI Sound Team \"Sota Fujimori\"", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Naatu Naatu", + "name_translation": "", + "jacket": "itg/syr5/[13] Naatu Naatu.jpg", + "bpm": "160-170", + "artist": "Rahul Sipligunj, Kaala Bhairava, M.M. Keeravaani", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] STEEL NOODLE", + "name_translation": "", + "jacket": "itg/syr5/[13] STEEL NOODLE.jpg", + "bpm": "188", + "artist": "Mootz", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] Worst Plan", + "name_translation": "", + "jacket": "itg/syr5/[13] Worst Plan.jpg", + "bpm": "150", + "artist": "BEMANI Sound Team \"SYUNN\"", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[13] monolith", + "name_translation": "", + "jacket": "itg/syr5/[13] monolith.jpg", + "bpm": "203", + "artist": "Takenoko boy feat. metyao", + "charts": [ + { + "lvl": 13, + "style": "single", + "diffClass": "challenge", + "drawGroup": 13 + } + ] + }, + { + "name": "[08] BMF", + "name_translation": "", + "jacket": "itg/syr5/[8] BMF.jpg", + "bpm": "128", + "artist": "SZA", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] ボンビースキップ!!", + "name_translation": "[08] Bonbiskip!!", + "jacket": "itg/syr5/[8] Bonbiskip!!.jpg", + "bpm": "80-164", + "artist": "good-cool feat. すわひでお", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "difficult", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] FOREIGN", + "name_translation": "", + "jacket": "itg/syr5/[8] FOREIGN.jpg", + "bpm": "92", + "artist": "HEDEGAARD, Moses, CANCUN?", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "difficult", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Mellow", + "name_translation": "", + "jacket": "itg/syr5/[8] Mellow.jpg", + "bpm": "160", + "artist": "Spacekats", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Memories of You (ATLUS Meguro Remix)", + "name_translation": "", + "jacket": "itg/syr5/[8] Memories of You (ATLUS Meguro Remix).jpg", + "bpm": "125", + "artist": "Yumi Kawamura", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Nine One One", + "name_translation": "", + "jacket": "itg/syr5/[8] Nine One One (feat. dimo).jpg", + "bpm": "100", + "artist": "rogerclark", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "challenge", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] OKLOSER", + "name_translation": "", + "jacket": "itg/syr5/[8] OKLOSER.jpg", + "bpm": "152", + "artist": "Doja Cat", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "difficult", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Riddle", + "name_translation": "", + "jacket": "itg/syr5/[8] Riddle.jpg", + "bpm": "128", + "artist": "Bad Computer", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Spin out (aran Remix)", + "name_translation": "", + "jacket": "itg/syr5/[8] Spin out (aran Remix).jpg", + "bpm": "128", + "artist": "Shandy Kubota", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] たんぽぽ", + "name_translation": "[08] Tanpopo", + "jacket": "itg/syr5/[8] Tanpopo.jpg", + "bpm": "115", + "artist": "Kanae Asaba", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] Trap Queen", + "name_translation": "", + "jacket": "itg/syr5/[8] Trap Queen.jpg", + "bpm": "74-148", + "artist": "Fetty Wap", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "expert", + "drawGroup": 8 + } + ] + }, + { + "name": "[08] 前代未聞", + "name_translation": "[08] Zendaimimon", + "jacket": "itg/syr5/[8] Zendaimimon.jpg", + "bpm": "130", + "artist": "M2U", + "charts": [ + { + "lvl": 8, + "style": "single", + "diffClass": "difficult", + "drawGroup": 8 + } + ] + }, + { + "name": "[09] All Night - nikko Remix", + "name_translation": "", + "jacket": "itg/syr5/[9] All Night - nikko Remix.jpg", + "bpm": "145", + "artist": "NGHTMRE, nikko", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "expert", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Bichael Byers", + "name_translation": "", + "jacket": "itg/syr5/[9] Bichael Byers.jpg", + "bpm": "162", + "artist": "Not Nate (Eliminate)", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Bumble Bee", + "name_translation": "", + "jacket": "itg/syr5/[9] Bumble Bee.jpg", + "bpm": "138", + "artist": "Bambee", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] My Mutant Ride", + "name_translation": "", + "jacket": "itg/syr5/[9] My Mutant Ride.jpg", + "bpm": "142", + "artist": "PAS TASTA ft. 柴田聡子 & TAKU INOUE", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Padam Padam", + "name_translation": "", + "jacket": "itg/syr5/[9] Padam Padam.jpg", + "bpm": "128", + "artist": "Kylie Minogue", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Rough Percussionythm", + "name_translation": "", + "jacket": "itg/syr5/[9] Rough Percussionythm.jpg", + "bpm": "175", + "artist": "BEMANI Sound Team \"setup VS 謎の勢力\"", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "difficult", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Sick Beat", + "name_translation": "", + "jacket": "itg/syr5/[9] Sick Beat.jpg", + "bpm": "99-198", + "artist": "Kero Kero Bonito", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "expert", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] So What", + "name_translation": "", + "jacket": "itg/syr5/[9] So What.jpg", + "bpm": "126", + "artist": "P!nk", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] Sunset", + "name_translation": "", + "jacket": "itg/syr5/[9] Sunset.jpg", + "bpm": "115", + "artist": "Macross 82-99", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "expert", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] The Way To Infinity", + "name_translation": "", + "jacket": "itg/syr5/[9] The Way To Infinity.jpg", + "bpm": "75", + "artist": "Au5", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] U-turn", + "name_translation": "", + "jacket": "itg/syr5/[9] U-turn.jpg", + "bpm": "92", + "artist": "Tegan and Sara", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "challenge", + "drawGroup": 9 + } + ] + }, + { + "name": "[09] We Got the Moves", + "name_translation": "", + "jacket": "itg/syr5/[9] We Got the Moves.jpg", + "bpm": "150", + "artist": "Electric Callboy", + "charts": [ + { + "lvl": 9, + "style": "single", + "diffClass": "expert", + "drawGroup": 9 + } + ] + } + ] +} diff --git a/src/startgg-gql/components.tsx b/src/startgg-gql/components.tsx new file mode 100644 index 000000000..7e95530ff --- /dev/null +++ b/src/startgg-gql/components.tsx @@ -0,0 +1,124 @@ +import { Button, Classes, InputGroup, Label, Text } from "@blueprintjs/core"; +import { useAtomValue, useAtom, useSetAtom } from "jotai"; +import React, { ReactNode, useRef, useCallback } from "react"; +import { startggKeyAtom, startggEventSlug, useCurrentUserEvents } from "."; + +export function StartggApiKeyGated(props: { children: ReactNode }) { + const apiKey = useAtomValue(startggKeyAtom); + const eventSlug = useAtomValue(startggEventSlug); + if (apiKey && eventSlug) { + return props.children; + } else { + return ; + } +} + +export function StartggCredsManager() { + const [apiKey, setApiKey] = useAtom(startggKeyAtom); + const [eventSlug, setEventSlug] = useAtom(startggEventSlug); + const apikeyRef = useRef(null); + const slugRef = useRef(null); + const saveKey = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!apikeyRef.current) return; + setApiKey(apikeyRef.current.value); + if (!slugRef.current) return; + setEventSlug(slugRef.current.value); + }, + [setApiKey, setEventSlug], + ); + return ( +
+ + Start.gg credentials are saved locally on this device and never synced + with other devices + + + + {!!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..dc3004828 --- /dev/null +++ b/src/startgg-gql/generated/gql.ts @@ -0,0 +1,76 @@ +/* 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 + */ +type Documents = { + "\n query PlayerName($pid: ID!) {\n entrant(id: $pid) {\n __typename\n id\n name\n }\n }\n": typeof types.PlayerNameDocument, + "\n query SetName($sid: ID!) {\n set(id: $sid) {\n __typename\n id\n fullRoundText\n }\n }\n": typeof 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": typeof 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 phaseGroup {\n displayIdentifier\n phase {\n name\n groupCount\n }\n }\n }\n }\n }\n }\n": typeof 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": typeof 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": typeof types.EventListDocument, +}; +const documents: 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 phaseGroup {\n displayIdentifier\n phase {\n name\n groupCount\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 phaseGroup {\n displayIdentifier\n phase {\n name\n groupCount\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 phaseGroup {\n displayIdentifier\n phase {\n name\n groupCount\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..825a0e40b --- /dev/null +++ b/src/startgg-gql/generated/graphql.ts @@ -0,0 +1,2527 @@ +/* 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; } +}; + +/** A set of actions available for an entity to take */ +export type ActionSet = { + id?: Maybe; +}; + +/** 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' +} + +/** A user's address */ +export type Address = { + __typename?: 'Address'; + city?: Maybe; + country?: Maybe; + countryId?: Maybe; + id?: Maybe; + state?: Maybe; + stateId?: Maybe; +}; + +/** Represents the name of the third-party service (e.g Twitter) for OAuth */ +export enum AuthorizationType { + Battlenet = 'BATTLENET', + Discord = 'DISCORD', + Epic = 'EPIC', + Mixer = 'MIXER', + Riot = 'RIOT', + Steam = 'STEAM', + Twitch = 'TWITCH', + Twitter = 'TWITTER', + Xbox = 'XBOX' +} + +/** Bracket-specific configuration */ +export type BracketConfig = { + bracketType?: Maybe; + id?: Maybe; +}; + +/** 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' +} + +/** A character in a videogame */ +export type Character = { + __typename?: 'Character'; + id?: Maybe; + images?: Maybe>>; + /** Name of Character */ + name?: Maybe; +}; + + +/** A character in a videogame */ +export type CharacterImagesArgs = { + type?: InputMaybe; +}; + +/** Comparison operator */ +export enum Comparator { + Equal = 'EQUAL', + GreaterThan = 'GREATER_THAN', + GreaterThanOrEqual = 'GREATER_THAN_OR_EQUAL', + LessThan = 'LESS_THAN', + LessThanOrEqual = 'LESS_THAN_OR_EQUAL' +} + +/** Name, address, etc */ +export type ContactInfo = { + __typename?: 'ContactInfo'; + /** Participant City Name */ + city?: Maybe; + /** Participant Country Name */ + country?: Maybe; + /** Participant Country (region) id */ + countryId?: Maybe; + id?: Maybe; + name?: Maybe; + /** First Name */ + nameFirst?: Maybe; + /** Last Name */ + nameLast?: Maybe; + /** Participant State Name */ + state?: Maybe; + /** Participant State (region) id */ + stateId?: Maybe; + /** Zip or Postal Code */ + zipcode?: Maybe; +}; + +/** An entrant in an event */ +export type Entrant = { + __typename?: 'Entrant'; + event?: Maybe; + id?: Maybe; + /** Entrant's seed number in the first phase of the event. */ + initialSeedNum?: Maybe; + isDisqualified?: Maybe; + /** The entrant name as it appears in bracket: gamerTag of the participant or team name */ + name?: Maybe; + /** Paginated sets for this entrant */ + paginatedSets?: Maybe; + participants?: Maybe>>; + seeds?: Maybe>>; + skill?: Maybe; + /** Standing for this entrant given an event. All entrants queried must be in the same event (for now). */ + standing?: Maybe; + /** @deprecated DEPRECATED. Use streams instead, which supports multiple stream types and teams. */ + stream?: Maybe; + streams?: Maybe>>; + /** Team linked to this entrant, if one exists */ + team?: Maybe; +}; + + +/** An entrant in an event */ +export type EntrantPaginatedSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortType?: InputMaybe; +}; + +export type EntrantConnection = { + __typename?: 'EntrantConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** An event in a tournament */ +export type Event = { + __typename?: 'Event'; + /** How long before the event start will the check-in end (in seconds) */ + checkInBuffer?: Maybe; + /** How long the event check-in will last (in seconds) */ + checkInDuration?: Maybe; + /** Whether check-in is enabled for this event */ + checkInEnabled?: Maybe; + /** Rough categorization of event tier, denoting relative importance in the competitive scene */ + competitionTier?: Maybe; + /** When the event was created (unix timestamp) */ + createdAt?: Maybe; + /** Last date attendees are able to create teams for team events */ + deckSubmissionDeadline?: Maybe; + /** + * Maximum number of participants each Entrant can have + * @deprecated Migrate to teamRosterSize + */ + entrantSizeMax?: Maybe; + /** + * Minimum number of participants each Entrant can have + * @deprecated Migrate to teamRosterSize + */ + entrantSizeMin?: Maybe; + /** The entrants that belong to an event, paginated by filter criteria */ + entrants?: Maybe; + /** How much it costs to enter this event */ + entryFee?: Maybe; + /** Whether the event has decks */ + hasDecks?: Maybe; + /** Are player tasks enabled for this event */ + hasTasks?: Maybe; + id?: Maybe; + images?: Maybe>>; + /** Whether the event is an online event or not */ + isOnline?: Maybe; + league?: Maybe; + /** Markdown field for match rules/instructions */ + matchRulesMarkdown?: Maybe; + /** Title of event set by organizer */ + name?: Maybe; + /** Gets the number of entrants in this event */ + numEntrants?: Maybe; + /** The phase groups that belong to an event. */ + phaseGroups?: Maybe>>; + /** The phases that belong to an event. */ + phases?: Maybe>>; + /** TO settings for prizing */ + prizingInfo?: Maybe; + publishing?: Maybe; + /** Markdown field for event rules/instructions */ + rulesMarkdown?: Maybe; + /** Id of the event ruleset */ + rulesetId?: Maybe; + /** + * Settings pulled from the event ruleset, if one exists + * @deprecated Use ruleset + */ + rulesetSettings?: Maybe; + /** Paginated sets for this Event */ + sets?: Maybe; + slug?: Maybe; + /** Paginated list of standings */ + standings?: Maybe; + /** When does this event start? */ + startAt?: Maybe; + /** The state of the Event. */ + state?: Maybe; + /** Paginated stations on this event */ + stations?: Maybe; + /** Last date attendees are able to create teams for team events */ + teamManagementDeadline?: Maybe; + /** If this is a teams event, returns whether or not teams can set custom names */ + teamNameAllowed?: Maybe; + /** Team roster size requirements */ + teamRosterSize?: Maybe; + tournament?: Maybe; + /** The type of the event, whether an entrant will have one participant or multiple */ + type?: Maybe; + /** When the event was last modified (unix timestamp) */ + updatedAt?: Maybe; + /** Whether the event uses the new EventSeeds for seeding */ + useEventSeeds?: Maybe; + /** The entrant (if applicable) for a given user in this event */ + userEntrant?: Maybe; + videogame?: Maybe; + /** The waves being used by the event */ + waves?: Maybe>>; +}; + + +/** An event in a tournament */ +export type EventEntrantsArgs = { + query?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventImagesArgs = { + type?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventPhasesArgs = { + phaseId?: InputMaybe; + state?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortType?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventStandingsArgs = { + query: StandingPaginationQuery; +}; + + +/** An event in a tournament */ +export type EventStationsArgs = { + query?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventUserEntrantArgs = { + userId?: InputMaybe; +}; + + +/** An event in a tournament */ +export type EventWavesArgs = { + phaseId?: InputMaybe; +}; + +export type EventConnection = { + __typename?: 'EventConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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>>; +}; + +/** Name and Gamertag of the owner of an event in a league */ +export type EventOwner = { + __typename?: 'EventOwner'; + email?: Maybe; + eventId?: Maybe; + fullName?: Maybe; + gamerTag?: Maybe; +}; + +export type EventOwnerConnection = { + __typename?: 'EventOwnerConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +export type EventOwnersQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** An event-level Team, in the context of some competition */ +export type EventTeam = Team & { + __typename?: 'EventTeam'; + /** Uniquely identifying token for team. Same as the hashed part of the slug */ + discriminator?: Maybe; + /** @deprecated Use the entrant field off the EventTeam type */ + entrant?: Maybe; + /** @deprecated Use the event field off the EventTeam type */ + event?: Maybe; + globalTeam?: Maybe; + id?: Maybe; + images?: Maybe>>; + members?: Maybe>>; + name?: Maybe; +}; + + +/** An event-level Team, in the context of some competition */ +export type EventTeamImagesArgs = { + type?: InputMaybe; +}; + + +/** An event-level Team, in the context of some competition */ +export type EventTeamMembersArgs = { + status?: InputMaybe>>; +}; + +export type EventTeamConnection = { + __typename?: 'EventTeamConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** Used for league application tiers */ +export type EventTier = { + __typename?: 'EventTier'; + id?: Maybe; + /** Name of this tier */ + name?: Maybe; +}; + +/** A game represents a single game within a set. */ +export type Game = { + __typename?: 'Game'; + /** Score of entrant 1. For smash, this is equivalent to stocks remaining. */ + entrant1Score?: Maybe; + /** Score of entrant 2. For smash, this is equivalent to stocks remaining. */ + entrant2Score?: Maybe; + id?: Maybe; + images?: Maybe>>; + orderNum?: Maybe; + /** Selections for this game such as character, etc. */ + selections?: Maybe>>; + /** The stage that this game was played on (if applicable) */ + stage?: Maybe; + state?: Maybe; + winnerId?: Maybe; +}; + + +/** A game represents a single game within a set. */ +export type GameImagesArgs = { + type?: InputMaybe; +}; + +/** A selection for this game. i.e. character/stage selection, etc */ +export type GameSelection = { + __typename?: 'GameSelection'; + /** If this is a character selection, returns the selected character. */ + character?: Maybe; + /** The entrant who this selection is for */ + entrant?: Maybe; + id?: Maybe; + orderNum?: Maybe; + /** + * The participant who this selection is for. This is only populated if there are + * selections for multiple participants of a single entrant + */ + participant?: Maybe; + selectionType?: Maybe; + selectionValue?: Maybe; +}; + +/** The type of selection i.e. is it for a character or something else */ +export enum GameSelectionType { + /** Character selection */ + Character = 'CHARACTER' +} + +/** Global Team */ +export type GlobalTeam = Team & { + __typename?: 'GlobalTeam'; + /** Uniquely identifying token for team. Same as the hashed part of the slug */ + discriminator?: Maybe; + /** @deprecated Use the entrant field off the EventTeam type */ + entrant?: Maybe; + /** @deprecated Use the event field off the EventTeam type */ + event?: Maybe; + eventTeams?: Maybe; + id?: Maybe; + images?: Maybe>>; + /** Leagues-level teams for leagues this team is competing in */ + leagueTeams?: Maybe; + members?: Maybe>>; + name?: Maybe; +}; + + +/** Global Team */ +export type GlobalTeamEventTeamsArgs = { + query?: InputMaybe; +}; + + +/** Global Team */ +export type GlobalTeamImagesArgs = { + type?: InputMaybe; +}; + + +/** Global Team */ +export type GlobalTeamLeagueTeamsArgs = { + query?: InputMaybe; +}; + + +/** Global Team */ +export type GlobalTeamMembersArgs = { + status?: InputMaybe>>; +}; + +/** An image */ +export type Image = { + __typename?: 'Image'; + height?: Maybe; + id?: Maybe; + ratio?: Maybe; + type?: Maybe; + url?: Maybe; + width?: Maybe; +}; + +/** A league */ +export type League = { + __typename?: 'League'; + addrState?: Maybe; + city?: Maybe; + countryCode?: Maybe; + /** When the tournament was created (unix timestamp) */ + createdAt?: Maybe; + currency?: Maybe; + /** When the tournament ends */ + endAt?: Maybe; + entrantCount?: Maybe; + eventOwners?: Maybe; + /** When does event registration close */ + eventRegistrationClosesAt?: Maybe; + /** Paginated list of events in a league */ + events?: Maybe; + /** + * Hacked "progression" into this final event + * @deprecated No longer used + */ + finalEventId?: Maybe; + /** True if tournament has at least one offline event */ + hasOfflineEvents?: Maybe; + hasOnlineEvents?: Maybe; + hashtag?: Maybe; + id?: Maybe; + images?: Maybe>>; + /** True if tournament has at least one online event */ + isOnline?: Maybe; + lat?: Maybe; + links?: Maybe; + lng?: Maybe; + mapsPlaceId?: Maybe; + /** The tournament name */ + name?: Maybe; + /** + * Top X number of people in the standings who progress to final event + * @deprecated No longer used + */ + numProgressingToFinalEvent?: Maybe; + numUniquePlayers?: Maybe; + postalCode?: Maybe; + primaryContact?: Maybe; + primaryContactType?: Maybe; + /** Publishing settings for this tournament */ + publishing?: Maybe; + /** When does registration for the tournament end */ + registrationClosesAt?: Maybe; + rules?: Maybe; + /** The short slug used to form the url */ + shortSlug?: Maybe; + /** Whether standings for this league should be visible */ + showStandings?: Maybe; + slug?: Maybe; + /** Paginated list of standings */ + standings?: Maybe; + /** When the tournament Starts */ + startAt?: Maybe; + /** State of the tournament, can be ActivityState::CREATED, ActivityState::ACTIVE, or ActivityState::COMPLETED */ + state?: Maybe; + /** When is the team creation deadline */ + teamCreationClosesAt?: Maybe; + tiers?: Maybe>>; + /** The timezone of the tournament */ + timezone?: Maybe; + /** The type of tournament from TournamentType */ + tournamentType?: Maybe; + /** When the tournament was last modified (unix timestamp) */ + updatedAt?: Maybe; + /** Build Tournament URL */ + url?: Maybe; + venueAddress?: Maybe; + venueName?: Maybe; + videogames?: Maybe>>; +}; + + +/** A league */ +export type LeagueEventOwnersArgs = { + query?: InputMaybe; +}; + + +/** A league */ +export type LeagueEventsArgs = { + query?: InputMaybe; +}; + + +/** A league */ +export type LeagueImagesArgs = { + type?: InputMaybe; +}; + + +/** A league */ +export type LeagueStandingsArgs = { + query?: InputMaybe; +}; + + +/** A league */ +export type LeagueUrlArgs = { + relative?: InputMaybe; + tab?: InputMaybe; +}; + +export type LeagueConnection = { + __typename?: 'LeagueConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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 512 */ + 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 512 */ + perPage?: InputMaybe; + sort?: InputMaybe; + sortBy?: InputMaybe; +}; + +export type LocationFilterType = { + city?: InputMaybe; + countryCode?: InputMaybe; + state?: InputMaybe; +}; + +/** Match-level configuration */ +export type MatchConfig = { + bracketType?: Maybe; + id?: Maybe; +}; + +/** 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 Mutation = { + __typename?: 'Mutation'; + /** Assign a station to a set. If there is a stream attached to the station, the set will be assigned to the stream as well. */ + assignStation?: Maybe; + /** Assign a stream to a set */ + assignStream?: Maybe; + /** Delete a phase by id */ + deletePhase?: Maybe; + /** Delete a station by id */ + deleteStation?: Maybe; + /** Delete a wave by id */ + deleteWave?: Maybe; + /** Generate tournament registration Token on behalf of user */ + generateRegistrationToken?: Maybe; + /** Update a set to called state */ + markSetCalled?: Maybe; + /** Update a set to called state */ + markSetInProgress?: Maybe; + /** Register for tournament */ + registerForTournament?: Maybe; + /** + * Report set winner or game stats for a H2H bracket set. If winnerId is + * supplied, mark set as complete. gameData parameter will overwrite any existing + * reported game data. + */ + reportBracketSet?: Maybe>>; + /** Resets set to initial state, can affect other sets and phase groups */ + resetSet?: Maybe; + /** Automatically attempt to resolve all schedule conflicts. Returns a list of changed seeds */ + resolveScheduleConflicts?: Maybe>>; + /** Swap two seed ids in a phase */ + swapSeeds?: Maybe>>; + /** + * Update game stats for a H2H bracket set. Set winner cannot be changed with + * this function, use the resetSet mutation instead. + */ + updateBracketSet?: Maybe; + /** Update set of phase groups in a phase */ + updatePhaseGroups?: Maybe>>; + /** Update the seeding for a phase */ + updatePhaseSeeding?: Maybe; + /** Update a set's vodUrl */ + updateVodUrl?: Maybe; + /** Create or update a Phase */ + upsertPhase?: Maybe; + /** Add or update a station by id */ + upsertStation?: Maybe; + /** Add or update a wave by id */ + upsertWave?: Maybe; +}; + + +export type MutationAssignStationArgs = { + setId: Scalars['ID']['input']; + stationId: Scalars['ID']['input']; +}; + + +export type MutationAssignStreamArgs = { + setId: Scalars['ID']['input']; + streamId: Scalars['ID']['input']; +}; + + +export type MutationDeletePhaseArgs = { + phaseId: Scalars['ID']['input']; +}; + + +export type MutationDeleteStationArgs = { + stationId: Scalars['ID']['input']; +}; + + +export type MutationDeleteWaveArgs = { + waveId: Scalars['ID']['input']; +}; + + +export type MutationGenerateRegistrationTokenArgs = { + registration: TournamentRegistrationInput; + userId: Scalars['ID']['input']; +}; + + +export type MutationMarkSetCalledArgs = { + setId: Scalars['ID']['input']; +}; + + +export type MutationMarkSetInProgressArgs = { + setId: Scalars['ID']['input']; +}; + + +export type MutationRegisterForTournamentArgs = { + registration?: InputMaybe; + registrationToken?: InputMaybe; +}; + + +export type MutationReportBracketSetArgs = { + gameData?: InputMaybe>>; + isDQ?: InputMaybe; + setId: Scalars['ID']['input']; + winnerId?: InputMaybe; +}; + + +export type MutationResetSetArgs = { + resetDependentSets?: InputMaybe; + setId: Scalars['ID']['input']; +}; + + +export type MutationResolveScheduleConflictsArgs = { + options?: InputMaybe; + tournamentId: Scalars['ID']['input']; +}; + + +export type MutationSwapSeedsArgs = { + phaseId: Scalars['ID']['input']; + seed1Id: Scalars['ID']['input']; + seed2Id: Scalars['ID']['input']; +}; + + +export type MutationUpdateBracketSetArgs = { + gameData?: InputMaybe>>; + isDQ?: InputMaybe; + setId: Scalars['ID']['input']; + winnerId?: InputMaybe; +}; + + +export type MutationUpdatePhaseGroupsArgs = { + groupConfigs: Array>; +}; + + +export type MutationUpdatePhaseSeedingArgs = { + options?: InputMaybe; + phaseId: Scalars['ID']['input']; + seedMapping: Array>; +}; + + +export type MutationUpdateVodUrlArgs = { + setId: Scalars['ID']['input']; + vodUrl?: InputMaybe; +}; + + +export type MutationUpsertPhaseArgs = { + eventId?: InputMaybe; + payload: PhaseUpsertInput; + phaseId?: InputMaybe; +}; + + +export type MutationUpsertStationArgs = { + fields: StationUpsertInput; + stationId?: InputMaybe; + tournamentId?: InputMaybe; +}; + + +export type MutationUpsertWaveArgs = { + fields: WaveUpsertInput; + tournamentId?: InputMaybe; + waveId?: InputMaybe; +}; + +export type PageInfo = { + __typename?: 'PageInfo'; + filter?: Maybe; + page?: Maybe; + perPage?: Maybe; + sortBy?: Maybe; + total?: Maybe; + totalPages?: Maybe; +}; + +export type PaginationSearchType = { + fieldsToSearch?: InputMaybe>>; + searchString?: InputMaybe; +}; + +/** A participant of a tournament; either a spectator or competitor */ +export type Participant = { + __typename?: 'Participant'; + /** If this participant was checked-in by admin */ + checkedIn?: Maybe; + /** The time this participant was checked-in by admin */ + checkedInAt?: Maybe; + /** Info for connected accounts to external services. */ + connectedAccounts?: Maybe; + /** + * Contact Info selected during registration. Falls back to User.location and/or + * User.name if necessary. These fields are for admin use only. If you are not a + * tournament admin or the participant being queried, these fields will be null. + * Do not display this information publicly. + */ + contactInfo?: Maybe; + /** Email of the user, only available to admins within 18 months of tournament completion for tournament administrators. */ + email?: Maybe; + /** Entrants associated with this Participant, if applicable */ + entrants?: Maybe>>; + /** The events this participant registered for within a Tournament. */ + events?: Maybe>>; + /** The tag that was used when the participant registered, e.g. Mang0 */ + gamerTag?: Maybe; + id?: Maybe; + images?: Maybe>>; + player?: Maybe; + /** The prefix that the user set for this Tournament, e.g. C9 */ + prefix?: Maybe; + /** Tournament Admin viewable field. Shows details for required social connections */ + requiredConnections?: Maybe>>; + /** The user this participant is associated to. */ + user?: Maybe; + /** If this participant is verified as actually being in the tournament */ + verified?: Maybe; +}; + + +/** A participant of a tournament; either a spectator or competitor */ +export type ParticipantImagesArgs = { + type?: InputMaybe; +}; + +export type ParticipantConnection = { + __typename?: 'ParticipantConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** A phase in an event */ +export type Phase = { + __typename?: 'Phase'; + /** The bracket type of this phase. */ + bracketType?: Maybe; + /** The Event that this phase belongs to */ + event?: Maybe; + /** Number of phase groups in this phase */ + groupCount?: Maybe; + id?: Maybe; + /** Is the phase an exhibition or not. */ + isExhibition?: Maybe; + /** Name of phase e.g. Round 1 Pools */ + name?: Maybe; + /** The number of seeds this phase contains. */ + numSeeds?: Maybe; + /** @deprecated Please use 'seeds' instead */ + paginatedSeeds?: Maybe; + /** Phase groups under this phase, paginated */ + phaseGroups?: Maybe; + /** The relative order of this phase within an event */ + phaseOrder?: Maybe; + /** Information about the progressions into this phase */ + progressingInData?: Maybe>>; + /** Information about the progressions out of this phase */ + progressions?: Maybe>>; + /** Paginated seeds for this phase */ + seeds?: Maybe; + /** Paginated sets for this Phase */ + sets?: Maybe; + /** State of the phase */ + state?: Maybe; + waves?: Maybe>>; +}; + + +/** A phase in an event */ +export type PhasePaginatedSeedsArgs = { + eventId?: InputMaybe; + query: SeedPaginationQuery; +}; + + +/** A phase in an event */ +export type PhasePhaseGroupsArgs = { + query?: InputMaybe; +}; + + +/** A phase in an event */ +export type PhaseSeedsArgs = { + eventId?: InputMaybe; + query: SeedPaginationQuery; +}; + + +/** A phase in an event */ +export type PhaseSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortType?: InputMaybe; +}; + +/** A group within a phase */ +export type PhaseGroup = { + __typename?: 'PhaseGroup'; + /** The bracket type of this group's phase. */ + bracketType?: Maybe; + /** URL for this phase groups's bracket. */ + bracketUrl?: Maybe; + /** Unique identifier for this group within the context of its phase */ + displayIdentifier?: Maybe; + /** For the given phase group, this is the start time of the first round that occurs in the group. */ + firstRoundTime?: Maybe; + id?: Maybe; + numRounds?: Maybe; + /** @deprecated Please use 'seeds', which is now paginated */ + paginatedSeeds?: Maybe; + /** + * Paginated sets on this phaseGroup + * @deprecated Please use 'sets', which is now paginated + */ + paginatedSets?: Maybe; + /** The phase associated with this phase group */ + phase?: Maybe; + /** The progressions out of this phase group */ + progressionsOut?: Maybe>>; + rounds?: Maybe>>; + seedMap?: Maybe; + /** Paginated seeds for this phase group */ + seeds?: Maybe; + /** Paginated sets on this phaseGroup */ + sets?: Maybe; + /** Paginated list of standings */ + standings?: Maybe; + /** Unix time the group is scheduled to start. This info could also be on the wave instead. */ + startAt?: Maybe; + state?: Maybe; + tiebreakOrder?: Maybe; + wave?: Maybe; +}; + + +/** A group within a phase */ +export type PhaseGroupPaginatedSeedsArgs = { + eventId?: InputMaybe; + query: SeedPaginationQuery; +}; + + +/** A group within a phase */ +export type PhaseGroupPaginatedSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortType?: InputMaybe; +}; + + +/** A group within a phase */ +export type PhaseGroupSeedsArgs = { + eventId?: InputMaybe; + query: SeedPaginationQuery; +}; + + +/** A group within a phase */ +export type PhaseGroupSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; + sortType?: InputMaybe; +}; + + +/** A group within a phase */ +export type PhaseGroupStandingsArgs = { + query?: InputMaybe; +}; + +export type PhaseGroupConnection = { + __typename?: 'PhaseGroupConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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; +}; + +/** A player */ +export type Player = { + __typename?: 'Player'; + gamerTag?: Maybe; + id?: Maybe; + prefix?: Maybe; + /** Most recent active & published rankings */ + rankings?: Maybe>>; + /** + * Recent sets for this player. + * @deprecated Use the sets field instead. + */ + recentSets?: Maybe>>; + /** Recent standings */ + recentStandings?: Maybe>>; + /** Set history for this player. */ + sets?: Maybe; + user?: Maybe; +}; + + +/** A player */ +export type PlayerRankingsArgs = { + limit?: InputMaybe; + videogameId?: InputMaybe; +}; + + +/** A player */ +export type PlayerRecentSetsArgs = { + opponentId?: InputMaybe; +}; + + +/** A player */ +export type PlayerRecentStandingsArgs = { + limit?: InputMaybe; + videogameId?: InputMaybe; +}; + + +/** A player */ +export type PlayerSetsArgs = { + filters?: InputMaybe; + page?: InputMaybe; + perPage?: InputMaybe; +}; + +/** A player's ranks */ +export type PlayerRank = { + __typename?: 'PlayerRank'; + id?: Maybe; + /** The player's placement on the ranking */ + rank?: Maybe; + title?: Maybe; +}; + +/** An OAuth ProfileAuthorization object */ +export type ProfileAuthorization = { + __typename?: 'ProfileAuthorization'; + /** The id given by the external service */ + externalId?: Maybe; + /** The username given by the external service (including discriminator if discord) */ + externalUsername?: Maybe; + id?: Maybe; + stream?: Maybe; + /** The name of the external service providing this auth i.e. "twitch" */ + type?: Maybe; + url?: Maybe; +}; + +/** A connection between a placement in an origin phase group to a destination seed. */ +export type Progression = { + __typename?: 'Progression'; + id?: Maybe; + originOrder?: Maybe; + originPhase?: Maybe; + originPhaseGroup?: Maybe; + originPlacement?: Maybe; + placeholderName?: Maybe; +}; + +/** Data on phase progression, keyed on destination PhaseId */ +export type ProgressionData = { + __typename?: 'ProgressionData'; + /** Amount of seeds per phase group that are progressing. */ + numProgressing?: Maybe; + /** Origin phase ID that is the source of this progression. */ + origin?: Maybe; +}; + +export type Query = { + __typename?: 'Query'; + /** Returns the authenticated user */ + currentUser?: Maybe; + /** Returns an entrant given its id */ + entrant?: Maybe; + /** Returns an event given its id or slug */ + event?: Maybe; + /** Returns a league given its id or slug */ + league?: Maybe; + /** Paginated, filterable list of leagues */ + leagues?: Maybe; + /** Returns a participant given its id */ + participant?: Maybe; + /** Returns a phase given its id */ + phase?: Maybe; + /** Returns a phase group given its id */ + phaseGroup?: Maybe; + /** Returns a player given an id */ + player?: Maybe; + /** Returns a phase seed given its id */ + seed?: Maybe; + /** Returns a set given its id */ + set?: Maybe; + /** A shop entity */ + shop?: Maybe; + /** Returns an stream given its id */ + stream?: Maybe; + /** Returns all the stream queues for a given tournament */ + streamQueue?: Maybe>>; + /** Returns a team given its id */ + team?: Maybe; + /** Returns a tournament given its id or slug */ + tournament?: Maybe; + /** Paginated, filterable list of tournaments */ + tournaments?: Maybe; + /** Returns a user given a user slug of the form user/abc123, or id */ + user?: Maybe; + /** Returns a videogame given its id */ + videogame?: Maybe; + /** Returns paginated list of videogames matching the search criteria. */ + videogames?: Maybe; +}; + + +export type QueryEntrantArgs = { + id: Scalars['ID']['input']; +}; + + +export type QueryEventArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryLeagueArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryLeaguesArgs = { + query: LeagueQuery; +}; + + +export type QueryParticipantArgs = { + id: Scalars['ID']['input']; + isAdmin?: InputMaybe; +}; + + +export type QueryPhaseArgs = { + id?: InputMaybe; +}; + + +export type QueryPhaseGroupArgs = { + id?: InputMaybe; +}; + + +export type QueryPlayerArgs = { + id: Scalars['ID']['input']; +}; + + +export type QuerySeedArgs = { + id?: InputMaybe; +}; + + +export type QuerySetArgs = { + id: Scalars['ID']['input']; +}; + + +export type QueryShopArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryStreamArgs = { + id: Scalars['ID']['input']; +}; + + +export type QueryStreamQueueArgs = { + includePlayerStreams?: InputMaybe; + tournamentId: Scalars['ID']['input']; +}; + + +export type QueryTeamArgs = { + id?: InputMaybe; + inviteCode?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryTournamentArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryTournamentsArgs = { + query: TournamentQuery; +}; + + +export type QueryUserArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryVideogameArgs = { + id?: InputMaybe; + slug?: InputMaybe; +}; + + +export type QueryVideogamesArgs = { + query: VideogameQuery; +}; + +/** Race specific bracket configuration */ +export type RaceBracketConfig = BracketConfig & { + __typename?: 'RaceBracketConfig'; + automaticEndTime?: Maybe; + automaticStartTime?: Maybe; + bracketType?: Maybe; + goalTargetComparator?: Maybe; + goalTargetValue?: Maybe; + id?: Maybe; + limitMode?: Maybe; + limitValue?: Maybe; + raceType?: Maybe; +}; + +/** Enforces limits on the amount of allowable Race submissions */ +export enum RaceLimitMode { + BestAll = 'BEST_ALL', + FirstAll = 'FIRST_ALL', + Playtime = 'PLAYTIME' +} + +/** Race specific match configuration */ +export type RaceMatchConfig = MatchConfig & { + __typename?: 'RaceMatchConfig'; + bracketType?: Maybe; + id?: Maybe; + /** Can players report results? */ + playerReportingEnabled?: Maybe; + /** Accepted methods of verification that players can use */ + verificationMethods?: Maybe>>; + /** Are players required to submit verification of their reported results? */ + verificationRequired?: Maybe; +}; + +/** Race type */ +export enum RaceType { + Goals = 'GOALS', + Timed = 'TIMED' +} + +export type ResetAffectedData = { + __typename?: 'ResetAffectedData'; + affectedPhaseGroupCount?: Maybe; + affectedSetCount?: Maybe; + affectedSets?: Maybe>>; +}; + +export type ResolveConflictsLockedSeedConfig = { + eventId: Scalars['ID']['input']; + numSeeds: Scalars['Int']['input']; +}; + +export type ResolveConflictsOptions = { + lockedSeeds?: InputMaybe>>; +}; + +/** A round within a phase group */ +export type Round = { + __typename?: 'Round'; + /** + * If applicable, bestOf is the number of games + * one must win a majority out of to win a set in this round + */ + bestOf?: Maybe; + id?: Maybe; + /** Indicates this round's order in the phase group */ + number?: Maybe; + /** The time that this round is scheduled to start at */ + startAt?: Maybe; +}; + +/** + * The score that led to this standing being awarded. The meaning of this field can + * vary by standing type and is not used for some standing types. + */ +export type Score = { + __typename?: 'Score'; + /** Like value, but formatted for race format events. Formatted according to the race config for the front end to use. */ + displayValue?: Maybe; + /** The name of this score. e.g. "Kills" or "Stocks" */ + label?: Maybe; + /** The raw score value */ + value?: Maybe; +}; + +/** A seed for an entrant */ +export type Seed = { + __typename?: 'Seed'; + /** Map of Participant ID to checked in boolean */ + checkedInParticipants?: Maybe; + entrant?: Maybe; + groupSeedNum?: Maybe; + id?: Maybe; + isBye?: Maybe; + phase?: Maybe; + phaseGroup?: Maybe; + placeholderName?: Maybe; + placement?: Maybe; + /** The player(s) associated with this seed's entrant */ + players?: Maybe>>; + progressionSeedId?: Maybe; + /** Source progression information */ + progressionSource?: Maybe; + seedNum?: Maybe; + /** Entrant's win/loss record for this standing. Scores do not include byes. */ + setRecordWithoutByes?: Maybe; + standings?: Maybe>>; +}; + + +/** A seed for an entrant */ +export type SeedSetRecordWithoutByesArgs = { + phaseGroupId: Scalars['ID']['input']; +}; + + +/** A seed for an entrant */ +export type SeedStandingsArgs = { + containerType?: InputMaybe; +}; + +export type SeedConnection = { + __typename?: 'SeedConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** A set */ +export type Set = { + __typename?: 'Set'; + /** The time this set was marked as completed */ + completedAt?: Maybe; + /** The time this set was created */ + createdAt?: Maybe; + displayScore?: Maybe; + /** The source of the first entrant in this set */ + entrant1Source?: Maybe; + /** The source of the second entrant in this set */ + entrant2Source?: Maybe; + /** Event that this set belongs to. */ + event?: Maybe; + /** Full round text of this set. */ + fullRoundText?: Maybe; + game?: Maybe; + games?: Maybe>>; + /** Whether this set contains a placeholder entrant */ + hasPlaceholder?: Maybe; + id?: Maybe; + /** The letters that describe a unique identifier within the pool. Eg. F, AT */ + identifier?: Maybe; + images?: Maybe>>; + lPlacement?: Maybe; + /** The progression seed that the loser of this set will be placed into (if applicable) */ + loserProgressionSeed?: Maybe; + /** Phase group that this Set belongs to. */ + phaseGroup?: Maybe; + /** The sets that are affected from resetting this set */ + resetAffectedData?: Maybe; + /** The round number of the set. Negative numbers are losers bracket */ + round?: Maybe; + /** + * Indicates whether the set is in best of or total games mode. This instructs + * which field is used to figure out how many games are in this set. + */ + setGamesType?: Maybe; + /** A possible spot in a set. Use this to get all entrants in a set. Use this for all bracket types (FFA, elimination, etc) */ + slots?: Maybe>>; + /** The start time of the Set. If there is no startAt time on the Set, will pull it from phaseGroup rounds configuration. */ + startAt?: Maybe; + startedAt?: Maybe; + state?: Maybe; + /** Tournament event station for a set */ + station?: Maybe; + /** Tournament event stream for a set */ + stream?: Maybe; + /** If setGamesType is in total games mode, this defined the number of games in the set. */ + totalGames?: Maybe; + /** The time this set was last updated */ + updatedAt?: Maybe; + /** Url of a VOD for this set */ + vodUrl?: Maybe; + wPlacement?: Maybe; + winnerId?: Maybe; + /** The progression seed that the winner of this set will be placed into (if applicable) */ + winnerProgressionSeed?: Maybe; +}; + + +/** A set */ +export type SetDisplayScoreArgs = { + mainEntrantId?: InputMaybe; +}; + + +/** A set */ +export type SetGameArgs = { + orderNum: Scalars['Int']['input']; +}; + + +/** A set */ +export type SetImagesArgs = { + type?: InputMaybe; +}; + + +/** A set */ +export type SetSlotsArgs = { + includeByes?: InputMaybe; +}; + +export type SetConnection = { + __typename?: 'SetConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** + * Describes a source for an entrant in a set. Generally the source is either from + * a winner/loser of a set in this phase group, or a seed if this is a progression + */ +export type SetEntrantSource = { + __typename?: 'SetEntrantSource'; + /** The condition of this source. e.g. winner, loser, etc. */ + condition?: Maybe; + /** Human readable string for the condition. e.g. "Winner of Set #1234" or "Seed #1" */ + conditionString?: Maybe; + /** The type of this source. e.g. set, seed */ + type: Scalars['String']['output']; + /** The ID of the type of this source. e.g. set ID, seed ID, etc. */ + typeId?: Maybe; +}; + +/** 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; +}; + +/** A slot in a set where a seed currently or will eventually exist in order to participate in the set. */ +export type SetSlot = { + __typename?: 'SetSlot'; + entrant?: Maybe; + id?: Maybe; + /** Pairs with prereqType, is the ID of the prereq. */ + prereqId?: Maybe; + /** Given a set prereq type, defines the placement required in the origin set to end up in this slot. */ + prereqPlacement?: Maybe; + /** Describes where the entity in this slot comes from. */ + prereqType?: Maybe; + seed?: Maybe; + /** The index of the slot. Unique per set. */ + slotIndex?: Maybe; + /** The standing within this set for the seed currently assigned to this slot. */ + standing?: Maybe; +}; + +/** 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' +} + +/** A shop */ +export type Shop = { + __typename?: 'Shop'; + id?: Maybe; + levels?: Maybe; + messages?: Maybe; + name?: Maybe; + slug?: Maybe; + url?: Maybe; +}; + + +/** A shop */ +export type ShopLevelsArgs = { + query?: InputMaybe; +}; + + +/** A shop */ +export type ShopMessagesArgs = { + query?: InputMaybe; +}; + +/** A shop level */ +export type ShopLevel = { + __typename?: 'ShopLevel'; + currAmount?: Maybe; + description?: Maybe; + goalAmount?: Maybe; + id?: Maybe; + images?: Maybe>>; + name?: Maybe; +}; + + +/** A shop level */ +export type ShopLevelImagesArgs = { + type?: InputMaybe; +}; + +export type ShopLevelConnection = { + __typename?: 'ShopLevelConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +export type ShopLevelsQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** The message and player info for a shop order */ +export type ShopOrderMessage = { + __typename?: 'ShopOrderMessage'; + /** The player's gamertag. Returns null if anonymous message type */ + gamertag?: Maybe; + id?: Maybe; + /** The order message */ + message?: Maybe; + /** The player's name. Returns null unless name & tag display is selected */ + name?: Maybe; + /** The player who left the comment */ + player?: Maybe; + /** The total order amount */ + total?: Maybe; +}; + +export type ShopOrderMessageConnection = { + __typename?: 'ShopOrderMessageConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +export type ShopOrderMessagesQuery = { + page?: InputMaybe; + /** How many nodes to return for the page. Maximum value of 512 */ + 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' +} + +/** Video Stage */ +export type Stage = { + __typename?: 'Stage'; + id?: Maybe; + /** Stage name */ + name?: Maybe; +}; + +/** A standing indicates the placement of something within a container. */ +export type Standing = { + __typename?: 'Standing'; + /** + * The containing entity that contextualizes this standing. Event standings, for + * example, represent an entrant's standing in the entire event vs. Set standings + * which is an entrant's standing in only a single set within an event. + */ + container?: Maybe; + /** If the entity this standing is assigned to can be resolved into an entrant, this will provide the entrant. */ + entrant?: Maybe; + id?: Maybe; + isFinal?: Maybe; + /** Metadata that goes along with this standing. Can take on different forms based on standing group type and settings. */ + metadata?: Maybe; + placement?: Maybe; + /** The player(s) tied to this standing's entity */ + player?: Maybe; + /** @deprecated The "placement" field is identical and will eventually replace "standing" */ + standing?: Maybe; + stats?: Maybe; + totalPoints?: Maybe; +}; + +export type StandingConnection = { + __typename?: 'StandingConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** The containing entity that this standing is for */ +export type StandingContainer = Event | PhaseGroup | Set | Tournament; + +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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** Any stats related to this standing. This type is experimental and very likely to change in the future. */ +export type StandingStats = { + __typename?: 'StandingStats'; + score?: Maybe; +}; + +export type StationFilter = { + page?: InputMaybe; + perPage?: InputMaybe; +}; + +export type StationUpsertInput = { + clusterId?: InputMaybe; + number: Scalars['Int']['input']; +}; + +/** Stations, such as a stream setup, at an event */ +export type Stations = { + __typename?: 'Stations'; + canAutoAssign?: Maybe; + clusterNumber?: Maybe; + clusterPrefix?: Maybe; + enabled?: Maybe; + id?: Maybe; + identifier?: Maybe; + numSetups?: Maybe; + number?: Maybe; + prefix?: Maybe; + queue?: Maybe; + queueDepth?: Maybe; + state?: Maybe; + /** Tournament event stream for a station */ + stream?: Maybe; + updatedAt?: Maybe; +}; + +export type StationsConnection = { + __typename?: 'StationsConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** A Stream object */ +export type Stream = { + __typename?: 'Stream'; + id?: Maybe; + /** Whether the stream is currently live. May be slightly delayed. */ + isOnline?: Maybe; + /** The name of the stream */ + name?: Maybe; + /** The name of the external service providing this auth i.e. "twitch" */ + type?: Maybe; +}; + +/** A Stream queue object */ +export type StreamQueue = { + __typename?: 'StreamQueue'; + id?: Maybe; + /** The sets on the stream */ + sets?: Maybe>>; + /** The stream on the queue */ + stream?: Maybe; +}; + +/** 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' +} + +/** Tournament Stream */ +export type Streams = { + __typename?: 'Streams'; + enabled?: Maybe; + followerCount?: Maybe; + id?: Maybe; + isOnline?: Maybe; + numSetups?: Maybe; + parentStreamId?: Maybe; + shortName?: Maybe; + streamGame?: Maybe; + streamId?: Maybe; + streamLogo?: Maybe; + streamName?: Maybe; + streamSource?: Maybe; + streamStatus?: Maybe; + streamType?: Maybe; + streamTypeId?: Maybe; +}; + +/** A team, either at the global level or within the context of an event */ +export type Team = { + /** Uniquely identifying token for team. Same as the hashed part of the slug */ + discriminator?: Maybe; + /** @deprecated Use the entrant field off the EventTeam type */ + entrant?: Maybe; + /** @deprecated Use the event field off the EventTeam type */ + event?: Maybe; + id?: Maybe; + images?: Maybe>>; + members?: Maybe>>; + name?: Maybe; +}; + + +/** A team, either at the global level or within the context of an event */ +export type TeamImagesArgs = { + type?: InputMaybe; +}; + + +/** A team, either at the global level or within the context of an event */ +export type TeamMembersArgs = { + status?: InputMaybe>>; +}; + +/** A set of actions available for a team to take */ +export type TeamActionSet = ActionSet & { + __typename?: 'TeamActionSet'; + id?: Maybe; +}; + +export type TeamConnection = { + __typename?: 'TeamConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +/** A member of a team */ +export type TeamMember = { + __typename?: 'TeamMember'; + id?: Maybe; + isAlternate?: Maybe; + isCaptain?: Maybe; + /** The type of the team member */ + memberType?: Maybe; + participant?: Maybe; + player?: Maybe; + /** The status of the team member */ + status?: Maybe; +}; + +/** 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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** Team roster size requirements */ +export type TeamRosterSize = { + __typename?: 'TeamRosterSize'; + maxAlternates?: Maybe; + maxPlayers?: Maybe; + minAlternates?: Maybe; + minPlayers?: Maybe; +}; + +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>>; +}; + +/** A tournament */ +export type Tournament = { + __typename?: 'Tournament'; + addrState?: Maybe; + /** Admin-only view of admins for this tournament */ + admins?: Maybe>>; + city?: Maybe; + countryCode?: Maybe; + /** When the tournament was created (unix timestamp) */ + createdAt?: Maybe; + currency?: Maybe; + /** When the tournament ends */ + endAt?: Maybe; + /** When does event registration close */ + eventRegistrationClosesAt?: Maybe; + events?: Maybe>>; + /** True if tournament has at least one offline event */ + hasOfflineEvents?: Maybe; + hasOnlineEvents?: Maybe; + hashtag?: Maybe; + id?: Maybe; + images?: Maybe>>; + /** True if tournament has at least one online event */ + isOnline?: Maybe; + /** Is tournament registration open */ + isRegistrationOpen?: Maybe; + lat?: Maybe; + links?: Maybe; + lng?: Maybe; + mapsPlaceId?: Maybe; + /** The tournament name */ + name?: Maybe; + /** Number of attendees including spectators, if public */ + numAttendees?: Maybe; + /** The user who created the tournament */ + owner?: Maybe; + /** Paginated, queryable list of participants */ + participants?: Maybe; + postalCode?: Maybe; + primaryContact?: Maybe; + primaryContactType?: Maybe; + /** Publishing settings for this tournament */ + publishing?: Maybe; + /** When does registration for the tournament end */ + registrationClosesAt?: Maybe; + rules?: Maybe; + /** The short slug used to form the url */ + shortSlug?: Maybe; + /** The slug used to form the url */ + slug?: Maybe; + /** When the tournament Starts */ + startAt?: Maybe; + /** State of the tournament, can be ActivityState::CREATED, ActivityState::ACTIVE, or ActivityState::COMPLETED */ + state?: Maybe; + stations?: Maybe; + streamQueue?: Maybe>>; + streams?: Maybe>>; + /** When is the team creation deadline */ + teamCreationClosesAt?: Maybe; + /** Paginated, queryable list of teams */ + teams?: Maybe; + /** The timezone of the tournament */ + timezone?: Maybe; + /** The type of tournament from TournamentType */ + tournamentType?: Maybe; + /** When the tournament was last modified (unix timestamp) */ + updatedAt?: Maybe; + /** Build Tournament URL */ + url?: Maybe; + venueAddress?: Maybe; + venueName?: Maybe; + /** List of all waves in this tournament */ + waves?: Maybe>>; +}; + + +/** A tournament */ +export type TournamentAdminsArgs = { + roles?: InputMaybe>>; +}; + + +/** A tournament */ +export type TournamentEventsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; +}; + + +/** A tournament */ +export type TournamentImagesArgs = { + type?: InputMaybe; +}; + + +/** A tournament */ +export type TournamentParticipantsArgs = { + isAdmin?: InputMaybe; + query: ParticipantPaginationQuery; +}; + + +/** A tournament */ +export type TournamentStationsArgs = { + page?: InputMaybe; + perPage?: InputMaybe; +}; + + +/** A tournament */ +export type TournamentTeamsArgs = { + query: TeamPaginationQuery; +}; + + +/** A tournament */ +export type TournamentUrlArgs = { + relative?: InputMaybe; + tab?: InputMaybe; +}; + +export type TournamentConnection = { + __typename?: 'TournamentConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +export type TournamentLinks = { + __typename?: 'TournamentLinks'; + discord?: Maybe; + facebook?: Maybe; +}; + +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 512 */ + 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; +}; + +/** A user */ +export type User = { + __typename?: 'User'; + /** Authorizations to external services (i.e. Twitch, Twitter) */ + authorizations?: Maybe>>; + bio?: Maybe; + /** Public facing user birthday that respects user publishing settings */ + birthday?: Maybe; + /** Uniquely identifying token for user. Same as the hashed part of the slug */ + discriminator?: Maybe; + email?: Maybe; + /** Events this user has competed in */ + events?: Maybe; + genderPronoun?: Maybe; + id?: Maybe; + images?: Maybe>>; + /** Leagues this user has competed in */ + leagues?: Maybe; + /** Public location info for this user */ + location?: Maybe
; + /** Public facing user name that respects user publishing settings */ + name?: Maybe; + /** player for user */ + player?: Maybe; + slug?: Maybe; + /** Tournaments this user is organizing or competing in */ + tournaments?: Maybe; +}; + + +/** A user */ +export type UserAuthorizationsArgs = { + types?: InputMaybe>>; +}; + + +/** A user */ +export type UserEventsArgs = { + query?: InputMaybe; +}; + + +/** A user */ +export type UserImagesArgs = { + type?: InputMaybe; +}; + + +/** A user */ +export type UserLeaguesArgs = { + query?: InputMaybe; +}; + + +/** A user */ +export type UserTournamentsArgs = { + query?: 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 512 */ + 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 512 */ + 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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** A videogame */ +export type Videogame = { + __typename?: 'Videogame'; + /** All characters for this videogame */ + characters?: Maybe>>; + displayName?: Maybe; + id?: Maybe; + images?: Maybe>>; + name?: Maybe; + slug?: Maybe; + /** All stages for this videogame */ + stages?: Maybe>>; +}; + + +/** A videogame */ +export type VideogameImagesArgs = { + type?: InputMaybe; +}; + +export type VideogameConnection = { + __typename?: 'VideogameConnection'; + nodes?: Maybe>>; + pageInfo?: Maybe; +}; + +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 512 */ + perPage?: InputMaybe; + sortBy?: InputMaybe; +}; + +/** A wave in a tournament */ +export type Wave = { + __typename?: 'Wave'; + id?: Maybe; + /** The Wave Identifier */ + identifier?: Maybe; + /** Unix time the wave is scheduled to start. */ + startAt?: Maybe; +}; + +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, phaseGroup?: { __typename?: 'PhaseGroup', displayIdentifier?: string | null, phase?: { __typename?: 'Phase', name?: string | null, groupCount?: number | 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"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"phaseGroup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"displayIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"phase"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"groupCount"}}]}}]}}]}}]}}]}}]}}]} 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..0394bde99 --- /dev/null +++ b/src/startgg-gql/index.ts @@ -0,0 +1,222 @@ +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 + } + } + phaseGroup { + displayIdentifier + phase { + name + groupCount + } + } + } + } + } + } +`; + +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..3e0352c05 --- /dev/null +++ b/src/state/central.ts @@ -0,0 +1,21 @@ +import { createAction } from "@reduxjs/toolkit"; +import { withPayload } from "./util"; +import type { AppState } from "./store"; +import { nanoid } from "nanoid"; + +export const receivePartyState = createAction( + "party/supplyState", + withPayload(), +); + +export const mergeDraws = createAction( + "drawings/mergeSubdraws", + (input: { drawingId: string }) => { + return { + payload: { + drawingId: input.drawingId, + newSubdrawId: `set-${nanoid(12)}`, + }, + }; + }, +); diff --git a/src/state/config.atoms.ts b/src/state/config.atoms.ts new file mode 100644 index 000000000..45f0f7a84 --- /dev/null +++ b/src/state/config.atoms.ts @@ -0,0 +1,22 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { useRoomName } from "../hooks/useRoomName"; + +const lastConfigSelectedByEvent = atomFamily((roomName: string) => + atomWithStorage( + `ddrtools.lastConfigSelected:${roomName}`, + undefined, + undefined, + { getOnInit: true }, + ), +); + +export function useLastConfigSelected() { + const roomName = useRoomName(); + return useAtomValue(lastConfigSelectedByEvent(roomName)); +} + +export function useSetLastConfigSelected() { + const roomName = useRoomName(); + return useSetAtom(lastConfigSelectedByEvent(roomName)); +} diff --git a/src/state/config.slice.ts b/src/state/config.slice.ts new file mode 100644 index 000000000..73096ee93 --- /dev/null +++ b/src/state/config.slice.ts @@ -0,0 +1,76 @@ +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; + showMaxScore: boolean; + /** if present, will draw an additional set of cards for each string id in `configs` */ + multiDraws?: { + /** if true, auto-merge the resulting draws */ + merge: boolean; + /** ids of other configs to use for the subsequent draws */ + configs: Array; + }; +} + +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, + showMaxScore: false, +}; + +const adapter = createEntityAdapter({}); + +export const configSlice = createSlice({ + name: "config", + initialState: adapter.getInitialState(), + reducers: { + // oxlint-disable typescript/unbound-method + addOne: adapter.addOne, + updateOne: adapter.updateOne, + removeOne: adapter.removeOne, + /** insert or fully replace configs, keyed by id */ + setMany: adapter.setMany, + // oxlint-enable typescript/unbound-method + }, + selectors: { + ...adapter.getSelectors(), + }, +}); diff --git a/src/state/drawings.slice.ts b/src/state/drawings.slice.ts new file mode 100644 index 000000000..ff8eacd06 --- /dev/null +++ b/src/state/drawings.slice.ts @@ -0,0 +1,517 @@ +// oxlint-disable typescript/unbound-method +import { + PayloadAction, + Slice, + createEntityAdapter, + createSelector, + createSlice, +} from "@reduxjs/toolkit"; +import { + CompoundSetId, + Drawing, + DrawnChart, + EligibleChart, + MergedDrawing, + newPlayer, + Player, + PlayerActionOnChart, + SubDrawing, + PlayerPickPlaceholder, +} from "../models/Drawing"; +import { mergeDraws } from "./central"; + +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: CompoundSetId; chartId: string } & extra +>; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type PlayerActionOnChartPayload = PayloadAction< + { + drawingId: CompoundSetId; + chartId: string; + player: string; + reorder: boolean; + } & extra +>; + +export const drawingsSlice = createSlice({ + name: "drawings", + initialState: drawingsAdapter.getInitialState(), + reducers: { + addDrawing: drawingsAdapter.addOne, + updateOne: drawingsAdapter.updateOne, + removeOne(state, action: PayloadAction) { + const [mainId, subId] = action.payload; + if (!subId) { + return drawingsAdapter.removeOne(state, mainId); + } + const drawing = state.entities[mainId]; + if (drawing.subDrawings) { + const target = drawing.subDrawings[subId]; + delete drawing.subDrawings[subId]; + for (const chart of target.charts) { + delete drawing.winners[chart.id]; + delete drawing.pocketPicks[chart.id]; + delete drawing.bans[chart.id]; + delete drawing.protects[chart.id]; + } + } + }, + clearDrawings: drawingsAdapter.removeAll, + addOneChart( + state, + action: PayloadAction<{ + drawingId: CompoundSetId; + chart: DrawnChart | PlayerPickPlaceholder; + }>, + ) { + const [, target] = getDrawingFromCompoundId( + state, + action.payload.drawingId, + ); + target.charts.push(action.payload.chart); + }, + updateOneChart( + state, + action: PayloadAction<{ + drawingId: CompoundSetId; + chartId: string; + changes: Partial; + }>, + ) { + const [, target] = getDrawingFromCompoundId( + state, + action.payload.drawingId, + ); + const chart = target.charts.find((c) => c.id === action.payload.chartId); + if (!chart) { + return; + } + Object.assign(chart, action.payload.changes); + }, + /** + * Persist edits from the "edit title & players" dialog (rename/reorder/ + * add/remove). Because players carry stable ids, reordering and renaming + * need no fixup; removing a player just means dropping the card actions + * (winners, bans, protects, pocket picks) that still reference its + * now-absent id. + */ + updatePlayers( + state, + action: PayloadAction<{ + id: string; + title: string; + players: Player[]; + }>, + ) { + const { id, title, players } = action.payload; + const drawing = state.entities[id]; + if (!drawing) { + return; + } + + const remainingIds = new Set(players.map((p) => p.id)); + + for (const [chartId, winner] of Object.entries(drawing.winners)) { + if (winner !== null && !remainingIds.has(winner)) { + delete drawing.winners[chartId]; + } + } + + for (const record of [ + drawing.bans, + drawing.protects, + drawing.pocketPicks, + ]) { + for (const [chartId, entry] of Object.entries(record)) { + if (entry && !remainingIds.has(entry.player)) { + delete record[chartId]; + } + } + } + + // drop the priority player if they were removed from the roster + if (drawing.priorityPlayer && !remainingIds.has(drawing.priorityPlayer)) { + drawing.priorityPlayer = undefined; + } + + drawing.meta.title = title; + drawing.meta.players = players; + }, + swapPlayerPositions(state, action: ActionOnSingleDrawing) { + const mainId = action.payload; + const drawing = state.entities[mainId]; + if (!drawing) { + return; + } + drawing.meta.players.reverse(); + }, + incrementPriorityPlayer(state, action: ActionOnSingleDrawing) { + const mainId = action.payload; + const drawing = state.entities[mainId]; + if (!drawing) { + return; + } + // cycle: nobody -> first player -> ... -> last player -> nobody + const players = drawing.meta.players; + const currentIndex = drawing.priorityPlayer + ? players.findIndex((p) => p.id === drawing.priorityPlayer) + : -1; + const next = players[currentIndex + 1]; + drawing.priorityPlayer = next?.id; + }, + resetChart(state, action: ActionOnSingleChart) { + const { chartId, drawingId } = action.payload; + const [drawing] = getDrawingFromCompoundId(state, drawingId); + if (!drawing) { + return; + } + // Note: the winner is intentionally left untouched here. A chart can + // have both an action (protect/pocket/ban) and a marked winner at the + // same time, and removing the action should not clear the winner. The + // winner is removed via its own control (setWinner with player: null). + delete drawing.bans[chartId]; + delete drawing.protects[chartId]; + delete drawing.pocketPicks[chartId]; + }, + banProtectReplace( + state, + action: PlayerActionOnChartPayload< + { type: "ban" | "protect" } | { type: "pocket"; pick: EligibleChart } + >, + ) { + const { chartId, drawingId, player, reorder } = action.payload; + const [drawing, target] = getDrawingFromCompoundId(state, drawingId); + if (!drawing) { + return; + } + const playerAction: PlayerActionOnChart = { chartId, player }; + if (action.payload.type === "ban") { + if (reorder) { + target.charts = moveChartInArray( + drawing, + target.charts, + chartId, + "end", + ); + } + drawing.bans[chartId] = playerAction; + } else if (action.payload.type === "protect") { + if (reorder) { + target.charts = moveChartInArray( + drawing, + target.charts, + chartId, + "start", + ); + } + drawing.protects[chartId] = playerAction; + } else if (action.payload.type === "pocket") { + if (reorder) { + target.charts = moveChartInArray( + drawing, + target.charts, + chartId, + "start", + ); + } + drawing.pocketPicks[chartId] = { + chartId, + player, + pick: action.payload.pick, + }; + } + }, + setWinner(state, action: ActionOnSingleChart<{ player: string | null }>) { + const [drawing] = getDrawingFromCompoundId( + state, + action.payload.drawingId, + ); + const winners = drawing.winners; + if (action.payload.player === null) { + delete winners[action.payload.chartId]; + } else { + winners[action.payload.chartId] = action.payload.player; + } + }, + addPlayerScore( + state, + action: PayloadAction<{ + drawingId: CompoundSetId; + chartId: string; + playerId: string; + score: number; + }>, + ) { + const { drawingId, playerId, chartId, score } = action.payload; + const [mainId] = drawingId; + const drawing = state.entities[mainId]; + if (!drawing) { + return; + } + if ( + drawing.meta.type !== "startgg" || + drawing.meta.subtype !== "gauntlet" + ) { + return; + } + if (!drawing.meta.scoresByEntrant) { + drawing.meta.scoresByEntrant = {}; + for (const entrant of drawing.meta.players) { + drawing.meta.scoresByEntrant[entrant.id] = {}; + } + } + drawing.meta.scoresByEntrant[playerId][chartId] = score; + }, + addSubdraw( + state, + action: PayloadAction<{ newSubdraw: SubDrawing; existingDrawId: string }>, + ) { + const { existingDrawId, newSubdraw } = action.payload; + const existingDraw = state.entities[existingDrawId]; + if (!existingDraw.subDrawings) { + existingDraw.subDrawings = {}; + } + existingDraw.subDrawings[newSubdraw.compoundId[1]] = newSubdraw; + }, + updateCharts( + state, + action: PayloadAction<{ + drawId: CompoundSetId; + newCharts: SubDrawing["charts"]; + }>, + ) { + const { newCharts, drawId } = action.payload; + const [parent, target] = getDrawingFromCompoundId(state, drawId); + // cleanup charts being removed + for (const chart of target.charts) { + if (!newCharts.some((c) => c.id === chart.id)) { + // `chart` is not in the new set, so we should remove + delete parent.winners[chart.id]; + delete parent.bans[chart.id]; + delete parent.pocketPicks[chart.id]; + delete parent.protects[chart.id]; + } + } + target.charts = newCharts; + }, + }, + extraReducers(builder) { + builder.addCase( + mergeDraws, + (state, { payload: { drawingId, newSubdrawId } }) => { + const draw = state.entities[drawingId]; + if (!draw) return; + const oldDraws = draw.subDrawings; + draw.subDrawings = { + [newSubdrawId]: { + compoundId: [drawingId, newSubdrawId], + configId: draw.configId, + charts: Object.values(oldDraws).flatMap( + (subDraw) => subDraw.charts, + ), + }, + }; + }, + ); + }, + selectors: { + haveDrawings(state) { + return !!state.ids.length; + }, + byCompoundOrPlainId(state, id: CompoundSetId | string) { + if (typeof id === "string") return [state.entities[id]]; + return getDrawingFromCompoundId(state, id); + }, + selectMergedByCompoundId(state, compoundId: CompoundSetId) { + return selectMergedByCompoundId(state, compoundId); + }, + }, +}); + +export const drawingSelectors = drawingsAdapter.getSelectors( + drawingsSlice.selectSlice, +); + +type StateOfSlice = S extends Slice ? State : never; + +/** one-time migration for old data. mutates state */ +export function migrateToSubdraws(state: StateOfSlice) { + for (const id of state.ids) { + const parent = state.entities[id]; + if (parent.subDrawings) { + for (const [subId, subDraw] of Object.entries(parent.subDrawings)) { + // @ts-expect-error this field no longer exists + delete subDraw.id; + if (!subDraw.compoundId) { + subDraw.compoundId = [parent.id, subId]; + } + } + } else { + parent.subDrawings = {}; + } + if (parent.charts) { + parent.subDrawings[parent.id] = { + compoundId: [parent.id, parent.id], + configId: parent.configId, + charts: parent.charts, + }; + delete parent.charts; + } + } +} + +/** + * one-time migration from the old player model to the current one. The old + * model gave players plain string names (simple) or a `meta.entrants` array + * (startgg), kept display order in a separate `playerDisplayOrder` field, and + * referenced players by numeric index in `playerDisplayOrder`/`winners`/action + * `player`. The new model gives every player a stable id under `meta.players`, + * stores that array directly in display order, and references players by id. + * + * The presence of a `playerDisplayOrder` field marks a drawing still in an old + * shape, so this is idempotent (already-migrated drawings are skipped). mutates + * state. + */ +export function migratePlayersToIds(state: StateOfSlice) { + for (const id of state.ids) { + const drawing = state.entities[id]; + if (!drawing) { + continue; + } + // `playerDisplayOrder` no longer exists on the model; its presence means + // the drawing predates this migration. + const legacyDrawing = drawing as unknown as { + playerDisplayOrder?: Array; + priorityPlayer?: number | string; + }; + const legacyOrder = legacyDrawing.playerDisplayOrder; + if (!legacyOrder) { + continue; + } + + // startgg used to store players under `entrants` + const legacyMeta = drawing.meta as unknown as { + players?: Array; + entrants?: Player[]; + }; + const rawPlayers = legacyMeta.players ?? legacyMeta.entrants ?? []; + + // ensure every player is an object carrying a stable id (simple drawings + // used to store plain name strings) + const players: Player[] = rawPlayers.map((p) => + typeof p === "string" ? newPlayer(p) : p, + ); + + // legacy references were numeric indices into this (original-order) players + // array; normalize both those and any already-id references to a player id. + // A dangling index yields undefined, so its reference is dropped. + const idFor = (ref: number | string): string | undefined => + typeof ref === "number" ? players[ref]?.id : ref; + + // fold the separate display order into the players array order + const byId = new Map(players.map((p) => [p.id, p])); + const ordered: Player[] = []; + for (const ref of legacyOrder) { + const player = byId.get(idFor(ref)!); + if (player) { + ordered.push(player); + byId.delete(player.id); + } + } + // append any players not referenced by the display order (defensive) + ordered.push(...byId.values()); + + drawing.meta.players = ordered; + delete legacyMeta.entrants; + delete legacyDrawing.playerDisplayOrder; + + const legacyWinners = drawing.winners as Record< + string, + number | string | null + >; + for (const [chartId, val] of Object.entries(legacyWinners)) { + if (val === null) { + continue; + } + const winnerId = idFor(val); + if (winnerId === undefined) { + delete drawing.winners[chartId]; + } else { + drawing.winners[chartId] = winnerId; + } + } + + for (const record of [ + drawing.bans, + drawing.protects, + drawing.pocketPicks, + ]) { + for (const [chartId, entry] of Object.entries(record)) { + if (!entry) { + continue; + } + const action = entry as { player: number | string }; + const playerId = idFor(action.player); + if (playerId === undefined) { + delete record[chartId]; + } else { + action.player = playerId; + } + } + } + + // priorityPlayer was a 1-based display position; resolve it to a player id + const priority = legacyDrawing.priorityPlayer; + if (typeof priority === "number") { + drawing.priorityPlayer = ordered[priority - 1]?.id; + } + } +} + +export function getDrawingFromCompoundId( + state: StateOfSlice, + id: CompoundSetId, +): [parent: Drawing, target: SubDrawing] { + const [mainId, subId] = id; + const drawing = state.entities[mainId]; + return [drawing, drawing.subDrawings[subId]]; +} + +const selectMergedByCompoundId = createSelector( + [ + (s: StateOfSlice, drawingId: CompoundSetId) => + s.entities[drawingId[0]], + (s: StateOfSlice, drawingId: CompoundSetId) => + s.entities[drawingId[0]]?.subDrawings?.[drawingId[1]], + ], + (drawing, subDrawing): MergedDrawing => { + return { ...drawing, ...subDrawing }; + }, +); + +function moveChartInArray( + drawing: Drawing, + charts: SubDrawing["charts"], + chartId: string, + pos: "start" | "end", +) { + const targetChart = charts.find((c) => c.id === chartId); + if (!targetChart) { + return charts; + } + const chartsWithoutTarget = 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); + } + return chartsWithoutTarget; +} diff --git a/src/state/event.slice.ts b/src/state/event.slice.ts new file mode 100644 index 000000000..21560a070 --- /dev/null +++ b/src/state/event.slice.ts @@ -0,0 +1,118 @@ +import { PayloadAction, createSelector, createSlice } from "@reduxjs/toolkit"; +import { nanoid } from "nanoid"; +import { CompoundSetId } from "../models/Drawing"; +import { mergeDraws } from "./central"; + +export interface CabInfo { + /** drawing id if active */ + activeMatch: CompoundSetId | string | null; + name: string; + id: string; +} + +interface EventState { + eventName: string; + cabs: Record; + obsLabels: Record; + obsCss: string; +} + +const initialState: EventState = { + eventName: "", + cabs: { + default: { + id: "default", + name: "Primary Cab", + activeMatch: null, + }, + }, + obsLabels: {}, + obsCss: `h1 { + /* add text styles here */ +}`, +}; + +export const eventSlice = createSlice({ + name: "event", + initialState, + reducers: { + /** add a cab with its name */ + addCab: { + // the id must be minted here rather than in the reducer: actions + // replay on the party server and other clients, and every replica + // has to produce an identical cab + prepare(name: string) { + return { payload: { name, id: nanoid(5) } }; + }, + reducer(state, action: PayloadAction<{ name: string; id: string }>) { + state.cabs[action.payload.id] = { + id: action.payload.id, + name: action.payload.name, + activeMatch: null, + }; + }, + }, + removeCab(state, action: PayloadAction) { + delete state.cabs[action.payload]; + }, + clearCabAssignment(state, action: PayloadAction) { + const cab = state.cabs[action.payload]; + if (!cab) return; + cab.activeMatch = null; + }, + assignMatchToCab( + state, + action: PayloadAction<{ cabId: string; matchId: string }>, + ) { + const cab = state.cabs[action.payload.cabId]; + if (!cab) return; + cab.activeMatch = action.payload.matchId; + }, + assignSetToCab( + state, + action: PayloadAction<{ cabId: string; matchId: CompoundSetId }>, + ) { + const cab = state.cabs[action.payload.cabId]; + if (!cab) return; + cab.activeMatch = action.payload.matchId; + }, + updateLabel( + state, + action: PayloadAction<{ id: string; value: string; label: string }>, + ) { + state.obsLabels[action.payload.id] = { + label: action.payload.label, + value: action.payload.value, + }; + }, + removeLabel(state, action: PayloadAction<{ id: string }>) { + delete state.obsLabels[action.payload.id]; + }, + updateObsCss(state, action: PayloadAction) { + state.obsCss = action.payload; + }, + }, + extraReducers(builder) { + builder.addCase(mergeDraws, (state, { payload }) => { + for (const cab of Object.values(state.cabs)) { + if ( + Array.isArray(cab.activeMatch) && + cab.activeMatch[0] === payload.drawingId + ) { + cab.activeMatch[1] = payload.newSubdrawId; + } + } + }); + }, + selectors: { + allCabs: createSelector([(state: EventState) => state.cabs], (cabs) => { + return Object.values(cabs); + }), + }, +}); + +export function addObsLabels(state: EventState) { + if (!state.obsLabels) { + state.obsLabels = {}; + } +} diff --git a/src/state/game-data.atoms.ts b/src/state/game-data.atoms.ts new file mode 100644 index 000000000..e7b21e95c --- /dev/null +++ b/src/state/game-data.atoms.ts @@ -0,0 +1,70 @@ +import { atom, getDefaultStore, useAtomValue, useSetAtom } from "jotai"; +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { GameData } from "../models/SongData"; +import { useEffect } from "react"; +import { useRoomName } from "../hooks/useRoomName"; + +const lastGameSelectedByEvent = atomFamily((roomName: string) => + atomWithStorage( + `ddrtools.lastGameSelected:${roomName}`, + undefined, + undefined, + { getOnInit: true }, + ), +); + +export function useLastGameSelected() { + const roomName = useRoomName(); + return useAtomValue(lastGameSelectedByEvent(roomName)); +} + +export function useSetLastGameSelected() { + const roomName = useRoomName(); + return useSetAtom(lastGameSelectedByEvent(roomName)); +} + +/** should be the return value from `useRoomName` */ +export function getLastGameSelected(roomName: string) { + const jotaiStore = getDefaultStore(); + return jotaiStore.get(lastGameSelectedByEvent(roomName)); +} + +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 { + 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) { + void 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..3219bae30 --- /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; + +export 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/localstorage.ts b/src/state/localstorage.ts new file mode 100644 index 000000000..a222f1cb4 --- /dev/null +++ b/src/state/localstorage.ts @@ -0,0 +1,16 @@ +import { applyMigrations } from "./migrations"; +import type { AppState } from "./root-reducer"; + +export const LOCAL_STATE_STORAGE_KEY = "ddrtools.classic.state"; + +export let classicModeState: AppState | undefined; + +try { + const persisted = localStorage.getItem(LOCAL_STATE_STORAGE_KEY); + if (persisted) { + classicModeState = JSON.parse(persisted); + if (classicModeState) applyMigrations(classicModeState); + } +} catch { + // YOLO +} diff --git a/src/state/migrations.ts b/src/state/migrations.ts new file mode 100644 index 000000000..157307412 --- /dev/null +++ b/src/state/migrations.ts @@ -0,0 +1,12 @@ +import { migratePlayersToIds, migrateToSubdraws } from "./drawings.slice"; +import { addObsLabels } from "./event.slice"; +import type { AppState } from "./root-reducer"; + +/** mutates `state` to apply any necessary migrations */ +export function applyMigrations(state: AppState) { + if (state.drawings) { + migrateToSubdraws(state.drawings); + migratePlayersToIds(state.drawings); + } + if (state.event) addObsLabels(state.event); +} diff --git a/src/state/party-gate-middleware.ts b/src/state/party-gate-middleware.ts new file mode 100644 index 000000000..c247fa45c --- /dev/null +++ b/src/state/party-gate-middleware.ts @@ -0,0 +1,25 @@ +import type { Middleware } from "@reduxjs/toolkit"; +import { + isPartyConnectionHealthy, + reportBlockedAction, +} from "../party/connection-status"; +import { receivePartyState } from "./central"; + +/** + * Drops locally-dispatched actions while the partykit websocket is down, + * so local state can't silently drift from the shared room state. + */ +export const partyGateMiddleware: Middleware = () => (next) => (action) => { + if (isPartyConnectionHealthy()) { + return next(action); + } + // always let server-originated messages through, in case any arrive + // before the reconnect handler flips the flag back + if ( + receivePartyState.match(action) || + (action as { meta?: { source?: string } }).meta?.source === "partykit" + ) { + return next(action); + } + reportBlockedAction(); +}; diff --git a/src/state/root-reducer.ts b/src/state/root-reducer.ts new file mode 100644 index 000000000..026fbd983 --- /dev/null +++ b/src/state/root-reducer.ts @@ -0,0 +1,21 @@ +// oxlint-disable typescript/unbound-method +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 type AppState = ReturnType; + +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..c9998d688 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,34 @@ +import { + configureStore, + ThunkAction, + ActionFromReducer, + createSelector, +} from "@reduxjs/toolkit"; +import { useDispatch, useSelector, useStore } from "react-redux"; +import { reducer, type AppState } from "./root-reducer"; +import { middleware as listener } from "./listener-middleware"; +import { partyGateMiddleware } from "./party-gate-middleware"; + +export function createClientStore(preloadedState?: AppState) { + return configureStore({ + reducer, + middleware: (getDefaults) => + getDefaults().concat(partyGateMiddleware, listener), + preloadedState, + }); +} + +export type StoreType = ReturnType; + +export type { AppState }; +export const useAppState = useSelector.withTypes(); +export const createAppSelector = createSelector.withTypes(); +export type AppDispatch = StoreType["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..c916f880c --- /dev/null +++ b/src/state/thunks.ts @@ -0,0 +1,473 @@ +import { AppThunk } from "./store"; +import { draw, DrawingMeta, newPlaceholder } from "../card-draw"; +import { + getLastGameSelected, + loadStockGamedataByName, +} from "./game-data.atoms"; +import { drawingsSlice, getDrawingFromCompoundId } from "./drawings.slice"; +import { + CHART_PLACEHOLDER, + CompoundSetId, + Drawing, + EligibleChart, + SubDrawing, +} 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( + drawMeta: DrawingMeta, + 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 "nok"; + } + const gameData = await loadStockGamedataByName(config.gameKey); + if (!gameData) { + console.error("couldnt draw, no game data"); + trackDraw(null); + return "nok"; // no draw was possible + } + + const charts = draw(gameData, config, drawMeta); + if (!charts.length) { + showDrawErrorToast(); + trackDraw(null); + return "nok"; // could not draw the requested number of charts + } + + const matchId = `draw-${nanoid(10)}`; + const setId = `set-${nanoid(12)}`; + const mainDraw: SubDrawing = { + compoundId: [matchId, setId], + configId, + charts, + }; + const drawing: Drawing = { + id: matchId, + winners: {}, + bans: {}, + protects: {}, + pocketPicks: {}, + meta: drawMeta.meta, + configId, + subDrawings: { [setId]: mainDraw }, + }; + trackDraw(charts.length, gameData.i18n.en.name as string); + + if (config.multiDraws) { + for (const otherConfigId of config.multiDraws.configs) { + const otherConfig = configSlice.selectors.selectById( + state, + otherConfigId, + ); + if (!otherConfig) { + console.error("couldnt perform extra draw, no config"); + continue; + } + const otherGameData = await loadStockGamedataByName( + otherConfig.gameKey, + ); + if (!otherGameData) { + console.error("couldnt perform extra draw, no game data"); + continue; + } + const otherCharts = draw(otherGameData, otherConfig, drawMeta); + if (!otherCharts.length) { + continue; // could not draw the requested number of charts + } + + trackDraw(otherCharts.length, otherGameData.i18n.en.name as string); + if (config.multiDraws.merge) { + mainDraw.charts = mainDraw.charts.concat(otherCharts); + } else { + const otherSetId = `set-${nanoid(12)}`; + drawing.subDrawings[otherSetId] = { + compoundId: [drawing.id, otherSetId], + configId: otherConfigId, + charts: otherCharts, + }; + } + } + } + + dispatch(drawingsSlice.actions.addDrawing(drawing)); + return "ok"; + }; +} + +/** + * Thunk creator for performing a new draw, and adding it + * as a sub-draw of an existing draw + * @returns false if draw was unsuccessful + */ +export function createSubdraw( + parentDrawId: string, + 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 "nok"; + } + const gameData = await loadStockGamedataByName(config.gameKey); + if (!gameData) { + console.error("couldnt draw, no game data"); + trackDraw(null); + return "nok"; // no draw was possible + } + const existingDraw = state.drawings.entities[parentDrawId]; + + const charts = draw(gameData, config, { meta: existingDraw.meta }); + trackDraw(charts.length, gameData.i18n.en.name as string); + if (!charts.length) { + showDrawErrorToast(); + return "nok"; // could not draw the requested number of charts + } + + const setId = `set-${nanoid(12)}`; + dispatch( + drawingsSlice.actions.addSubdraw({ + existingDrawId: parentDrawId, + newSubdraw: { compoundId: [parentDrawId, setId], configId, charts }, + }), + ); + return "ok"; + }; +} + +/** + * thunk creator for redrawing all charts in a target drawing + */ +export function createRedrawAll(drawingId: CompoundSetId): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const [parent, target] = getDrawingFromCompoundId( + state.drawings, + drawingId, + ); + + // 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(parent.pocketPicks).concat(Object.keys(parent.protects)), + ); + const chartsToKeep = target.charts.filter( + (chart) => + protectedChartIds.has(chart.id) || chart.type === "PLACEHOLDER", + ); + + const originalConfig = state.config.entities[target.configId]; + const drawConfig: ConfigState = { + ...originalConfig, + chartCount: target.charts.length - chartsToKeep.length, + }; + const gameData = await loadStockGamedataByName(originalConfig.gameKey); + + const charts = draw(gameData!, drawConfig, { + meta: parent.meta, + charts: chartsToKeep, + }); + dispatch( + drawingsSlice.actions.updateCharts({ + drawId: drawingId, + newCharts: chartsToKeep.concat(charts), + }), + ); + }; +} + +/** + * thunk creator for redrawing a single chart within a drawing + */ +export function createRedrawChart( + drawingId: CompoundSetId, + chartId: string, +): AppThunk { + return async (dispatch, getState) => { + const state = getState(); + const [parent, target] = getDrawingFromCompoundId( + state.drawings, + drawingId, + ); + const customConfig: ConfigState = { + ...state.config.entities[target.configId], + }; + const gameData = await loadStockGamedataByName(customConfig.gameKey); + if (!gameData) return; + + const charts = draw(gameData, customConfig, { + meta: parent.meta, + charts: target.charts.filter((chart) => chart.id !== chartId), + }); + const chart = charts.pop(); + if ( + !chart || + chart.type !== "DRAWN" || + target.charts.some((c) => c.id === chart.id) + ) { + showDrawErrorToast(); + return; // result didn't include a new chart + } + dispatch( + drawingsSlice.actions.updateOneChart({ + drawingId, + chartId, + changes: chart, + }), + ); + }; +} + +/** + * thunk creator for adding one more chart to an existing drawing + */ +export function createPlusOneChart( + drawingId: CompoundSetId, + type: "DRAWN" | "PLACEHOLDER", +): AppThunk { + return async (dispatch, getState) => { + if (type === CHART_PLACEHOLDER) { + return dispatch( + drawingsSlice.actions.addOneChart({ + drawingId, + chart: newPlaceholder(), + }), + ); + } + const state = getState(); + const [parent, target] = getDrawingFromCompoundId( + state.drawings, + drawingId, + ); + const originalConfig = state.config.entities[target.configId]; + const gameData = await loadStockGamedataByName(originalConfig.gameKey); + if (!gameData) return; + + const customConfig: ConfigState = { + ...originalConfig, + // force drawing one more chart than already exists + chartCount: + 1 + + target.charts.reduce( + (acc, curr) => (curr.type === "DRAWN" ? acc + 1 : acc), + 0, + ), + }; + + const charts = draw(gameData, customConfig, { + meta: parent.meta, + charts: target.charts, + }); + const chart = charts.pop(); + if ( + !chart || + chart.type !== "DRAWN" || + target.charts.some((c) => c.id === chart.id) + ) { + showDrawErrorToast(); + return; // result didn't include a new chart + } + return dispatch(drawingsSlice.actions.addOneChart({ drawingId, chart })); + }; +} + +/** thunk creator for pick/ban/pocket pick that can include orderByAction setting */ +export function createPickBanPocket( + drawingId: CompoundSetId, + chartId: string, + type: "ban" | "protect" | "pocket", + player: string, + pick?: EligibleChart, +): AppThunk { + return (dispatch, getState) => { + const state = getState(); + const [, target] = getDrawingFromCompoundId(state.drawings, drawingId); + const reorder = !!configSlice.selectors.selectById(state, target.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"; +import { availableGameData } from "../utils"; +import { showDrawErrorToast } from "../draw-state/error-toast"; + +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 createNewConfig( + roomName: string, + basisConfigId?: string, +): AppThunk> { + return async (dispatch, getState) => { + const basisConfig: Partial = basisConfigId + ? getState().config.entities[basisConfigId] + : {}; + const gameKey = + basisConfig.gameKey || + getLastGameSelected(roomName) || + availableGameData[0].name; + const gameData = await loadStockGamedataByName(gameKey); + const newConfig: ConfigState = { + ...defaultConfig, + ...getOverridesFromGameData(gameData), + ...basisConfig, + id: nanoid(10), + name: basisConfig.name ? `copy of ${basisConfig.name}` : "new config", + gameKey, + }; + dispatch(configSlice.actions.addOne(newConfig)); + return newConfig; + }; +} + +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; + }; +} + +export function changeGameKeyForConfig( + configId: string, + gameKey: string, +): AppThunk> { + return async (dispatch, getState) => { + const startingConfig = getState().config.entities[configId]; + const gameData = await loadStockGamedataByName(gameKey); + if (!gameData) return; + const changes: Partial = { gameKey }; + if (!gameData.meta.styles.includes(startingConfig.style)) { + changes.style = gameData.defaults.style; + } + if ( + startingConfig.difficulties.some( + (d) => + !gameData.meta.difficulties.some((metaDiff) => metaDiff.key === d), + ) + ) { + changes.difficulties = gameData.defaults.difficulties; + } + if ( + startingConfig.flags.some( + (f) => !gameData.meta.flags.some((metaFlag) => metaFlag === f), + ) + ) { + changes.flags = gameData.defaults.flags; + } + changes.upperBound = gameData.defaults.upperLvlBound; + changes.lowerBound = gameData.defaults.lowerLvlBound; + dispatch(configSlice.actions.updateOne({ id: configId, changes })); + }; +} 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/theme-toggle.tsx b/src/theme-toggle.tsx index a00217c51..77882e522 100644 --- a/src/theme-toggle.tsx +++ b/src/theme-toggle.tsx @@ -7,29 +7,26 @@ import { useMediaQuery } from "./hooks/useMediaQuery"; export const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); -export enum Theme { - Light = "light", - Dark = "dark", -} +export type Theme = "light" | "dark"; /** * Returns true if user prefers dark theme */ export function useThemePref() { - return useMediaQuery("(prefers-color-scheme: dark)") - ? Theme.Dark - : Theme.Light; + return useMediaQuery("(prefers-color-scheme: dark)") ? "dark" : "light"; } -function applyThemeBodyClass(theme: Theme, isObsLayer: boolean) { - document.body.classList.toggle(Classes.DARK, theme === Theme.Dark); - document.body.classList.toggle("obs-layer", isObsLayer); +function applyThemeBodyClass(theme: Theme, isOBSSource: boolean) { + document.body.classList.toggle(Classes.DARK, theme === "dark"); + document.body.classList.toggle("obs-layer", isOBSSource); } interface ThemeContext { - /** is this instance operating as a layer inside OBS */ - obsLayer: boolean; - setObsLayer(next: boolean): void; + /** is this instance loaded in OBS (either browser source or dock) */ + inObs: boolean; + /** is this instance operating as a browser source inside OBS */ + obsBrowserSource: boolean; + setIsObsSource(next: boolean): void; userPref: Theme | undefined; resolved: Theme; updateBrowserPref(this: void, t: Theme): void; @@ -44,16 +41,18 @@ declare global { // oxlint-disable-next-line typescript/no-namespace namespace obsstudio { const pluginVersion: string; + const getControlLevel: (cb: (level: number) => void) => void; } } const useThemeStore = create((set, get) => ({ - obsLayer: typeof window.obsstudio !== "undefined", - setObsLayer(next) { - set({ obsLayer: next }); + inObs: !!window.obsstudio, + obsBrowserSource: false, + setIsObsSource(next) { + set({ obsBrowserSource: next }); }, userPref: undefined, - resolved: darkQuery.matches ? Theme.Dark : Theme.Light, + resolved: darkQuery.matches ? "dark" : "light", updateBrowserPref(t) { const state = get(); if (!state.userPref && state.resolved !== t) { @@ -65,19 +64,33 @@ const useThemeStore = create((set, get) => ({ }, })); +// based on https://github.com/obsproject/obs-browser/issues/455#issuecomment-2351761820 +// there's no built-in way to distinguish between a browser source and a dock, +// but in a dock some basic APIs are just stubbed, so this `getControlLevel` function +// never calls the provided callback. +// this results in detection of OBS delayed until we get the CB, but that will be adequate. +if (window.obsstudio) { + window.obsstudio.getControlLevel(() => { + useThemeStore.getState().setIsObsSource(true); + }); +} + +export const useInObs = () => useThemeStore((s) => s.inObs); +export const useInObsSource = () => useThemeStore((s) => s.obsBrowserSource); + /** hook to get current app theme */ export const useTheme = () => useThemeStore((s) => s.resolved); export function ThemeSyncWidget() { const { resolved: resolvedTheme, - obsLayer: isOBS, + obsBrowserSource: isOBSSource, updateBrowserPref, } = useThemeStore(); const browserPref = useThemePref(); useEffect(() => { - applyThemeBodyClass(resolvedTheme, isOBS); - }, [resolvedTheme, isOBS]); + applyThemeBodyClass(resolvedTheme, isOBSSource); + }, [resolvedTheme, isOBSSource]); useEffect(() => { updateBrowserPref(browserPref); }, [updateBrowserPref, browserPref]); @@ -88,15 +101,13 @@ export function ThemeToggle() { const resolvedTheme = useThemeStore((t) => t.resolved); const setTheme = useThemeStore((t) => t.setTheme); - const ThemeIcon = resolvedTheme === Theme.Dark ? Flash : Moon; + const ThemeIcon = resolvedTheme === "dark" ? Flash : Moon; return ( } text={} - onClick={() => - setTheme(resolvedTheme === Theme.Dark ? Theme.Light : Theme.Dark) - } + onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} /> ); } diff --git a/src/tournament-mode/cab-management.tsx b/src/tournament-mode/cab-management.tsx new file mode 100644 index 000000000..700fc2424 --- /dev/null +++ b/src/tournament-mode/cab-management.tsx @@ -0,0 +1,263 @@ +import { + Button, + Card, + ControlGroup, + InputGroup, + Menu, + MenuItem, + MenuItemProps, + 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, + DiagramTree, + Font, + Layers, + MobileVideo, + More, + People, + Person, + Remove, + Tag, + Numerical, +} from "@blueprintjs/icons"; +import { detectedLanguage } from "../utils"; +import { useSetAtom } from "jotai"; +import { mainTabAtom } from "./main-view"; +import { drawingsSlice } from "../state/drawings.slice"; +import { playerDisplayName } from "../models/Drawing"; +import { copyObsSource, routableCabSourcePath } from "./copy-obs-source"; +import { useHref } from "react-router-dom"; + +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" + /> + + + + {Object.entries(labels).map(([id, { label, value }]) => ( + setCurrentEdit(id)} + /> + ))} + + + + + + ); +} + +function LabelCard(props: { + id: string; + label: string; + value: string; + onEdit(this: void): void; +}) { + const href = useHref(routableGlobalSourcePath(props.id)); + return ( + +
+

{props.label}

+

{props.value}

+
+ + + + + } + /> + + ); +} + +import { css } from "@codemirror/lang-css"; + +function CssEditor() { + const cleanDoc = useAppState((s) => s.event.obsCss); + const [isDirty, setIsDirty] = useState(false); + const [localDoc, setLocalDoc] = useState(cleanDoc); + const dispatch = useAppDispatch(); + const theme = useTheme(); + + return ( +
+

+ Global OBS Source Styles{" "} +

+ { + if (newDoc === cleanDoc) { + setIsDirty(false); + } else { + setIsDirty(true); + } + setLocalDoc(newDoc); + }} + /> +
+ ); +} diff --git a/src/tournament-mode/drawing-actions.css b/src/tournament-mode/drawing-actions.css index 9d2844d74..4de2366b3 100644 --- a/src/tournament-mode/drawing-actions.css +++ b/src/tournament-mode/drawing-actions.css @@ -4,6 +4,12 @@ right: 5px; } +.matchActions { + position: absolute; + top: 0; + right: 0; +} + :global(.obs-layer) .networkButtons { display: none; } diff --git a/src/tournament-mode/drawing-actions.tsx b/src/tournament-mode/drawing-actions.tsx index d269ef8bc..c8286e8f8 100644 --- a/src/tournament-mode/drawing-actions.tsx +++ b/src/tournament-mode/drawing-actions.tsx @@ -1,129 +1,288 @@ import { Button, - Icon, + Dialog, + DialogBody, Menu, MenuItem, Popover, Tooltip, } from "@blueprintjs/core"; import { - SendMessage, - Changes, - Share, Camera, - Refresh, - FloppyDisk, - NewPerson, - BlockedPerson, + DataLineage, + DocumentShare, + Random, + Edit, Error as ErrorIcon, + Exchange, + Label, + NewLayer, + NewLayers, + Refresh, + SendTo, + TableSync, + Th, + ThVirtual, + 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, shareCharts } from "../utils/share"; -import { firstOf } from "../utils"; -import { useConfigState } from "../config-state"; +import { useState, lazy, JSX } from "react"; import { useErrorBoundary } from "react-error-boundary"; +import { showPlayerAndRoundLabels } from "../config-state"; +import { useDrawing } from "../drawing-context"; +import { + CHART_DRAWN, + CHART_PLACEHOLDER, + playerById, + StartggGauntletMeta, +} from "../models/Drawing"; +import { + BracketSetGameDataInput as GDI, + ReportSetMutationVariables as MutationVariables, + useReportSetMutation, +} from "../startgg-gql"; +import { + drawingsSlice, + getDrawingFromCompoundId, +} from "../state/drawings.slice"; +import { eventSlice } from "../state/event.slice"; +import { + AppThunk, + useAppDispatch, + useAppState, + useAppStore, +} from "../state/store"; +import { + createPlusOneChart, + createRedrawAll, + createSubdraw, +} from "../state/thunks"; +import { CountingSet } from "../utils/counting-set"; +import { shareCharts, shareImage } from "../utils/share"; +import styles from "./drawing-actions.css"; +import { EventModeGated } from "../common-components/app-mode"; import { useIntl } from "../hooks/useIntl"; -import { JSX } from "react"; +import { ConfigContextProvider, useConfigId } from "../state/hooks"; +import { CustomDrawForm } from "../controls/draw-dialog"; +import { mergeDraws } from "../state/central"; +import { useHighlightRandom } from "./highlight-random"; + +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 parent = s.drawings.entities[drawingId]; + if (parent.meta.type !== "startgg") { + return; + } + const winsPerPlayer = new CountingSet(); + for (const [songId, playerId] of Object.entries(parent.winners)) { + if (playerId === null) { + continue; + } + const entrant = playerById(parent.meta, playerId); + if (!entrant) { + console.warn(`failed to add game data for song ${songId}`); + continue; + } + gameData.push({ gameNum: gameData.length + 1, winnerId: entrant.id }); + winsPerPlayer.add(entrant.id); + } + let winnerId: string | undefined; + const orderedByWins = Array.from(winsPerPlayer.valuesWithCount()).sort( + (a, b) => b[1] - a[1], + ); + if ( + orderedByWins.length === 1 || + (orderedByWins.length > 1 && orderedByWins[0][1] > orderedByWins[1][1]) + ) { + // confirmed no tie for first place + winnerId = orderedByWins[0][0]; + } + const ret: MutationVariables = { setId: parent.meta.id, winnerId }; + if (gameData.length) { + ret.gameData = gameData; + } + return ret; + }; +} const DEFAULT_FILENAME = "card-draw.png"; +function SaveToStartggButton({ drawingId }: { drawingId: string }) { + const dispatch = useAppDispatch(); + const drawingMeta = useAppState((s) => s.drawings.entities[drawingId].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 ( + +