├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── README.md
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── images
│ ├── cards
│ │ ├── Enhancers.png
│ │ ├── Jokers.png
│ │ ├── Tarots.png
│ │ ├── Vouchers.png
│ │ └── stickers.png
│ ├── check.png
│ ├── chip.png
│ └── chips.png
└── sounds
│ ├── button.mp3
│ ├── chips1.mp3
│ ├── paper1.mp3
│ └── tarot2.mp3
├── src
├── app
│ ├── error.tsx
│ ├── favicon.ico
│ ├── icon.png
│ ├── layout.tsx
│ ├── not-found.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ └── styles
│ │ ├── colors.css
│ │ ├── globals.css
│ │ └── m6x11plus.ttf
├── components
│ ├── inputs
│ │ ├── Button.tsx
│ │ ├── Checkbox.tsx
│ │ ├── Combobox.tsx
│ │ ├── Filters.tsx
│ │ ├── InputContainer.tsx
│ │ ├── Search.tsx
│ │ ├── Select.tsx
│ │ └── inputs.module.css
│ ├── pieces
│ │ ├── Card.tsx
│ │ ├── Chips.tsx
│ │ ├── Hand.tsx
│ │ ├── Tooltip.tsx
│ │ └── pieces.module.css
│ ├── sections
│ │ ├── collection
│ │ │ ├── Collection.tsx
│ │ │ ├── Graph.tsx
│ │ │ ├── Grid.tsx
│ │ │ ├── List.tsx
│ │ │ └── collection.module.css
│ │ ├── footer
│ │ │ └── Footer.tsx
│ │ ├── hero
│ │ │ ├── Hero.tsx
│ │ │ └── hero.module.css
│ │ ├── navbar
│ │ │ ├── NavBar.tsx
│ │ │ └── navbar.module.css
│ │ ├── popups
│ │ │ ├── Help.tsx
│ │ │ ├── LogIn.tsx
│ │ │ ├── Options.tsx
│ │ │ ├── Popup.tsx
│ │ │ └── popup.module.css
│ │ └── records
│ │ │ ├── CareerStats.tsx
│ │ │ ├── HandStats.tsx
│ │ │ ├── HighScores.tsx
│ │ │ ├── Records.tsx
│ │ │ └── records.module.css
│ └── stats
│ │ ├── HandStat.tsx
│ │ ├── Progress.tsx
│ │ ├── Stat.tsx
│ │ └── stat.module.css
└── lib
│ ├── cards
│ ├── cardKeys.ts
│ ├── cardMappings.ts
│ └── cards.ts
│ ├── context.ts
│ ├── profile.ts
│ ├── settings.ts
│ ├── types.ts
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Balatro Stats
2 |
3 | [balatrostats.com](https://www.balatrostats.com/)
4 |
5 | View your stats in Balatro!
6 |
7 | - View all the stats the game tracks, including the hidden ones.
8 | - Track, sort, and graph your collection with various filters.
9 | - Share your stats through a saved image or [PLANNED] a URL.
10 |
11 | ## What's New
12 |
13 | - 07/31/2024 - Launch
14 | - 03/15/2025 - Added support for custom cards and error handling
15 |
16 | Oops, it's been a while. I've been busy with real life and I lost interest in Balatro after trying to gold stake black deck, but slow progress is coming for V2. I am also working on another Balatro-related project that will probably come out first, so stay tuned.
17 |
18 | ## Running the app locally
19 |
20 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
21 |
22 | First, run the development server:
23 |
24 | ```bash
25 | npm run dev
26 | # or
27 | yarn dev
28 | # or
29 | pnpm dev
30 | # or
31 | bun dev
32 | ```
33 |
34 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | export default nextConfig
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "balatro-stats",
3 | "version": "1.0.2",
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "next lint",
9 | "prettier": "prettier --write ."
10 | },
11 | "dependencies": {
12 | "classnames": "^2.5.1",
13 | "file-saver": "^2.0.5",
14 | "html-to-image": "^1.11.11",
15 | "immer": "^10.1.1",
16 | "next": "^14.2.5",
17 | "next-auth": "^5.0.0-beta.20",
18 | "pako": "^2.1.0",
19 | "react": "^18",
20 | "react-dom": "^18",
21 | "use-immer": "^0.10.0",
22 | "use-sound": "^4.0.3"
23 | },
24 | "devDependencies": {
25 | "@types/file-saver": "^2.0.7",
26 | "@types/howler": "^2.2.11",
27 | "@types/node": "^20",
28 | "@types/pako": "^2.0.3",
29 | "@types/react": "^18",
30 | "@types/react-dom": "^18",
31 | "eslint": "^8",
32 | "eslint-config-next": "14.2.4",
33 | "postcss": "^8",
34 | "prettier": "^3.3.3",
35 | "tailwindcss": "^3.4.1",
36 | "typescript": "^5"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/public/images/cards/Enhancers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Enhancers.png
--------------------------------------------------------------------------------
/public/images/cards/Jokers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Jokers.png
--------------------------------------------------------------------------------
/public/images/cards/Tarots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Tarots.png
--------------------------------------------------------------------------------
/public/images/cards/Vouchers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Vouchers.png
--------------------------------------------------------------------------------
/public/images/cards/stickers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/stickers.png
--------------------------------------------------------------------------------
/public/images/check.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/check.png
--------------------------------------------------------------------------------
/public/images/chip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/chip.png
--------------------------------------------------------------------------------
/public/images/chips.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/chips.png
--------------------------------------------------------------------------------
/public/sounds/button.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/button.mp3
--------------------------------------------------------------------------------
/public/sounds/chips1.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/chips1.mp3
--------------------------------------------------------------------------------
/public/sounds/paper1.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/paper1.mp3
--------------------------------------------------------------------------------
/public/sounds/tarot2.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/tarot2.mp3
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Link from 'next/link'
4 |
5 | export default function Error({
6 | error,
7 | reset,
8 | }: {
9 | error: Error & { digest?: string }
10 | reset: () => void
11 | }) {
12 | return (
13 |
21 |
Oops! Something went wrong:
22 |
28 | {error.message}
29 |
30 |
31 |
32 | Crash Reports are set to Off. If you would like to send crash reports,
33 | please{' '}
34 |
35 | create a new issue in the GitHub
36 |
37 | .
38 |
39 |
40 |
{
48 | localStorage.removeItem('profile')
49 | reset()
50 | }
51 | }
52 | >
53 | Reset Profile and Try Again
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/icon.png
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import localFont from 'next/font/local'
3 | import './styles/globals.css'
4 | import './styles/colors.css'
5 |
6 | const balatroFont = localFont({
7 | src: './styles/m6x11plus.ttf',
8 | })
9 |
10 | export const metadata: Metadata = {
11 | title: 'Balatro Stats',
12 | description: 'View your stats in Balatro!',
13 | }
14 |
15 | export default function RootLayout({
16 | children,
17 | }: Readonly<{
18 | children: React.ReactNode
19 | }>) {
20 | return (
21 |
22 | {children}
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { soundContext } from '@/lib/context'
4 | import { jokerExtraSprites, jokerSprites } from '@/lib/cards/cardMappings'
5 | import Card from '@/components/pieces/Card'
6 | import Link from 'next/link'
7 |
8 | export default function NotFound() {
9 | const joker = {
10 | name: '404',
11 | image: `url(/images/cards/Jokers.png) ${jokerSprites.undiscovered} / 1000%`,
12 | topImage: `url(/images/cards/Enhancers.png) ${jokerExtraSprites.undiscovered} / 700% 500%`,
13 | }
14 |
15 | const sounds = {
16 | cardSound: () => {},
17 | }
18 |
19 | return (
20 |
21 |
31 |
37 | Go Back
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { initializeSettings } from '@/lib/settings'
4 | import { useEffect, useState } from 'react'
5 | import { useImmer } from 'use-immer'
6 | import useSound from 'use-sound'
7 | import { initialProfile } from '@/lib/profile'
8 | import Hero from '@/components/sections/hero/Hero'
9 | import NavBar from '@/components/sections/navbar/NavBar'
10 | import Records from '@/components/sections/records/Records'
11 | import Collection from '@/components/sections/collection/Collection'
12 | import Footer from '@/components/sections/footer/Footer'
13 | import { settingsContext, soundContext } from '@/lib/context'
14 |
15 | export default function Page() {
16 | // initialize and update profile
17 | const [profile, setProfile] = useState(initialProfile)
18 | const [saved, setSaved] = useState(false)
19 | useEffect(() => {
20 | const savedProfile = localStorage.getItem('profile')
21 | if (savedProfile) {
22 | setProfile(JSON.parse(savedProfile))
23 | setSaved(true)
24 | }
25 | }, [setProfile])
26 |
27 | // initialize and update settings
28 | const [settings, setSettings] = useImmer(initializeSettings())
29 | useEffect(() => {
30 | const savedSettings = localStorage.getItem('settings')
31 | if (savedSettings) {
32 | const parsedSettings: Record = JSON.parse(savedSettings)
33 | setSettings((draft) => {
34 | Object.entries(parsedSettings).forEach(([setting, enabled]) => {
35 | if (draft[setting] !== undefined) {
36 | draft[setting].enabled = enabled
37 | }
38 | })
39 | })
40 | }
41 | }, [settings, setSettings])
42 |
43 | // initialize sounds
44 | // This prevents us creating a new sound function every time we create a component using a sound
45 | const [buttonSound] = useSound('/sounds/button.mp3', {
46 | volume: 0.3,
47 | playbackRate: 0.95,
48 | soundEnabled: settings.soundEnabled.enabled,
49 | })
50 | const [cardSound] = useSound('/sounds/paper1.mp3', {
51 | volume: 0.5,
52 | soundEnabled: settings.soundEnabled.enabled,
53 | })
54 | const [chipSound] = useSound('/sounds/chips1.mp3', {
55 | volume: 0.3,
56 | soundEnabled: settings.soundEnabled.enabled,
57 | })
58 | const [tarotSound] = useSound('/sounds/tarot2.mp3', {
59 | volume: 0.66,
60 | soundEnabled: settings.soundEnabled.enabled,
61 | })
62 | const sounds = {
63 | buttonSound: buttonSound,
64 | cardSound: cardSound,
65 | chipSound: chipSound,
66 | tarotSound: tarotSound,
67 | }
68 |
69 | return (
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/src/app/styles/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --shadow: rgba(0, 0, 20, 0.5);
3 | --black: #4f6367;
4 | --white: white;
5 | --red: #ff4c40;
6 | --blue: #0093ff;
7 | --yellow: #f5b244;
8 | --green: #429f79;
9 | --orange: #ff8f00;
10 |
11 | --Common: #0093ff;
12 | --Uncommon: #35bd86;
13 | --Rare: #ff4c40;
14 | --Legendary: #ab5bb5;
15 |
16 | --Joker: #5f7e85;
17 | --Deck: #525db0;
18 | --Tarot: #9e74ce;
19 | --Planet: #00a7ca;
20 | --Spectral: #2e76fd;
21 | --Voucher: #ff5611;
22 |
23 | --Unknown: #5f7e85;
24 | --Consumable: #5f7e85;
25 | }
26 |
27 | .red {
28 | background-color: var(--red);
29 | }
30 |
31 | .red:hover,
32 | .red:active,
33 | .red:focus {
34 | background-color: #a02721;
35 | }
36 |
37 | .blue {
38 | background-color: var(--blue);
39 | }
40 |
41 | .blue:hover,
42 | .blue:active,
43 | .blue:focus {
44 | background-color: #0057a1;
45 | }
46 |
47 | .orange {
48 | background-color: var(--orange);
49 | }
50 |
51 | .orange:hover,
52 | .orange:active,
53 | .orange:focus {
54 | background-color: #a05b00;
55 | }
56 |
57 | .green {
58 | background-color: var(--green);
59 | }
60 |
61 | .green:hover,
62 | .green:active,
63 | .green:focus {
64 | background-color: #215f46;
65 | }
66 |
--------------------------------------------------------------------------------
/src/app/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | font-size: calc(18px);
7 | line-height: 1.15;
8 |
9 | --card-width: 71px;
10 | --card-height: calc(var(--card-width) * 1.338);
11 | --chip-size: 29px;
12 |
13 | --shadow-length: 0.125rem;
14 | --text-shadow: 0.0625rem 0.0625rem var(--shadow);
15 |
16 | --button-width: 6rem;
17 |
18 | text-shadow: var(--text-shadow);
19 | }
20 |
21 | body {
22 | color: white;
23 | background: #438368;
24 | }
25 |
26 | main {
27 | margin: 0 auto;
28 | max-width: calc(var(--card-width) * 5.75);
29 | }
30 |
31 | a:hover {
32 | text-decoration-line: underline;
33 | text-underline-offset: 0.25rem;
34 | }
35 |
36 | @media (min-width: 768px) {
37 | :root {
38 | font-size: calc(18px + 1.25vmin);
39 | --card-width: calc(142px + 1vmin);
40 | --chip-size: calc(58px + 0.5vmin);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/styles/m6x11plus.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/styles/m6x11plus.ttf
--------------------------------------------------------------------------------
/src/components/inputs/Button.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import { CSSProperties, useContext } from 'react'
3 | import { soundContext } from '@/lib/context'
4 | import classNames from 'classnames/bind'
5 |
6 | export default function Button({
7 | name,
8 | active,
9 | color,
10 | style,
11 | callback,
12 | disabled,
13 | underline,
14 | full,
15 | }: {
16 | name: string
17 | active?: boolean
18 | color?: 'red' | 'blue' | 'orange'
19 | style?: CSSProperties
20 | callback: () => void
21 | disabled?: boolean
22 | underline?: boolean
23 | full?: boolean
24 | }) {
25 | const sounds = useContext(soundContext)
26 | const play = sounds['buttonSound']
27 |
28 | const cx = classNames.bind(styles)
29 | const buttonClasses = cx('button', {
30 | full: full,
31 | })
32 | const insideClasses = cx('buttonInsides', color, {
33 | underline: underline,
34 | })
35 |
36 | return (
37 |
38 |
45 |
46 |
{
49 | callback()
50 | play()
51 | }}
52 | disabled={disabled == undefined ? false : disabled}
53 | >
54 |
55 | {name}
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/inputs/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import { useContext } from 'react'
3 | import { soundContext } from '@/lib/context'
4 |
5 | export default function Checkbox({
6 | label,
7 | checked,
8 | callback,
9 | }: {
10 | label: string
11 | checked: boolean
12 | callback: () => void
13 | }) {
14 | const sounds = useContext(soundContext)
15 | const play = sounds['buttonSound']
16 |
17 | return (
18 |
19 |
{
25 | play()
26 | callback()
27 | }}
28 | />
29 |
{label}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/inputs/Combobox.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import {
3 | Dispatch,
4 | RefObject,
5 | SetStateAction,
6 | useContext,
7 | useEffect,
8 | useRef,
9 | } from 'react'
10 | import { soundContext } from '@/lib/context'
11 |
12 | export default function Combobox({
13 | children,
14 | options,
15 | current,
16 | popupOpen,
17 | setPopupOpen,
18 | inputRef,
19 | optionCallback,
20 | }: {
21 | children: React.ReactNode
22 | options: string[]
23 | current: string
24 | popupOpen: boolean
25 | setPopupOpen: Dispatch>
26 | inputRef: RefObject
27 | optionCallback?: (option: string) => void
28 | }) {
29 | const sounds = useContext(soundContext)
30 | const playButtonSound = sounds['buttonSound']
31 | const playCardSound = sounds['cardSound']
32 |
33 | // Make sure the options scroll
34 | const optionsRef = useRef(null)
35 | useEffect(() => {
36 | if (optionsRef.current) {
37 | const currentIndex = options.indexOf(current)
38 | if (current && currentIndex != -1 && popupOpen) {
39 | const optionsNode =
40 | optionsRef.current.querySelectorAll('li')[currentIndex]
41 | optionsNode.scrollIntoView({ block: 'nearest' })
42 | }
43 | }
44 | }, [options, current, popupOpen])
45 |
46 | return (
47 | {
50 | playButtonSound()
51 | setPopupOpen(!popupOpen)
52 | }}
53 | onBlur={(e) => {
54 | if (!e.currentTarget.contains(e.relatedTarget)) {
55 | setPopupOpen(false)
56 | }
57 | }}
58 | >
59 | {children}
60 |
61 |
67 |
74 | {options.map((option, i) => {
75 | return (
76 | {
81 | if (optionCallback !== undefined) {
82 | optionCallback(options[i])
83 | }
84 | setPopupOpen(false)
85 | if (inputRef.current) {
86 | inputRef.current.focus()
87 | }
88 | }}
89 | onPointerEnter={() =>
90 | playCardSound({ playBackRate: Math.random() * 0.2 + 0.9 })
91 | }
92 | >
93 | {option}
94 |
95 | )
96 | })}
97 |
98 |
99 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/inputs/Filters.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import { FilterType } from '@/lib/types'
3 | import Button from './Button'
4 | import { Updater } from 'use-immer'
5 | import Checkbox from './Checkbox'
6 | import { useState } from 'react'
7 |
8 | export default function Filters({
9 | filters,
10 | setFilters,
11 | }: {
12 | filters: FilterType[]
13 | setFilters: Updater>
14 | }) {
15 | const [open, setOpen] = useState(false)
16 |
17 | const activeFilterCount = filters
18 | .map((group) => {
19 | return Object.values(group.filters)
20 | .map(({ enabled }) => enabled)
21 | .filter(Boolean).length
22 | })
23 | .reduce((sum, x) => sum + x, 0)
24 |
25 | return (
26 |
32 |
setOpen(!open)}
37 | disabled={!filters.length}
38 | />
39 |
40 |
47 | {filters.map((group) => {
48 | return (
49 |
50 |
{group.name}
51 |
52 |
53 | {Object.entries(group.filters).map(([name, { enabled }]) => {
54 | return (
55 | {
60 | setFilters((draft) => {
61 | draft[group.name].filters[name].enabled = !enabled
62 | })
63 | }}
64 | />
65 | )
66 | })}
67 |
68 |
69 | )
70 | })}
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/inputs/InputContainer.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import React from 'react'
3 |
4 | export default function InputContainer({
5 | label,
6 | children,
7 | }: {
8 | label: string
9 | children: React.ReactNode
10 | }) {
11 | return (
12 |
13 |
{label}
14 | {children}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/inputs/Search.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import {
3 | Dispatch,
4 | KeyboardEvent,
5 | SetStateAction,
6 | useContext,
7 | useRef,
8 | useState,
9 | } from 'react'
10 | import { soundContext } from '@/lib/context'
11 | import Combobox from './Combobox'
12 |
13 | function getAction(e: KeyboardEvent, popupOpen: boolean) {
14 | const { key, altKey, ctrlKey } = e
15 | if (altKey) {
16 | if (key == 'ArrowUp') {
17 | if (popupOpen) {
18 | return 'close'
19 | }
20 | return null
21 | }
22 | if (key == 'ArrowDown') {
23 | return 'open'
24 | }
25 | }
26 |
27 | if (ctrlKey) {
28 | if (key == 'Home') {
29 | return 'first'
30 | }
31 | if (key == 'End') return 'last'
32 | }
33 |
34 | switch (key) {
35 | case 'Escape':
36 | return 'clear'
37 | case 'Enter':
38 | return 'submit'
39 | case 'ArrowUp':
40 | return 'up'
41 | case 'ArrowDown':
42 | return 'down'
43 | case 'PageUp':
44 | return 'pageUp'
45 | case 'PageDown':
46 | return 'pageDown'
47 | }
48 |
49 | return null
50 | }
51 |
52 | export default function Search({
53 | options,
54 | current,
55 | setCurrent,
56 | placeholder,
57 | }: {
58 | options: string[]
59 | current: string
60 | setCurrent: Dispatch>
61 | placeholder: string
62 | }) {
63 | const [popupOpen, setPopupOpen] = useState(false)
64 | const [selected, setSelected] = useState('')
65 | const pageSize = 15
66 | const filteredOptions = options.filter((option) =>
67 | option.toLowerCase().includes(current.toLowerCase()),
68 | )
69 | const inputRef = useRef(null)
70 |
71 | const sounds = useContext(soundContext)
72 | const playButtonSound = sounds['buttonSound']
73 | const playCardSound = sounds['cardSound']
74 |
75 | return (
76 | {
83 | setCurrent(option)
84 | setSelected(option)
85 | }}
86 | >
87 | {
95 | setCurrent(e.target.value)
96 | setSelected(e.target.value)
97 | }}
98 | onKeyDown={(e) => {
99 | const action = getAction(e, popupOpen)
100 | let newIndex = 0
101 |
102 | switch (action) {
103 | case 'open':
104 | case 'submit':
105 | if (!popupOpen) {
106 | playButtonSound()
107 | }
108 | break
109 | case 'close':
110 | if (popupOpen) {
111 | playButtonSound()
112 | }
113 | break
114 | case 'up':
115 | case 'down':
116 | case 'pageUp':
117 | case 'pageDown':
118 | case 'first':
119 | case 'last':
120 | e.preventDefault()
121 | if (!popupOpen) {
122 | setPopupOpen(true)
123 | }
124 | playCardSound({ playbackRate: Math.random() * 0.2 + 0.9 })
125 | break
126 | }
127 |
128 | switch (action) {
129 | case 'open':
130 | setPopupOpen(true)
131 | break
132 | case 'close':
133 | setPopupOpen(false)
134 | break
135 | case 'submit':
136 | setPopupOpen(false)
137 | setCurrent(selected)
138 | break
139 |
140 | case 'clear':
141 | if (popupOpen) {
142 | setPopupOpen(false)
143 | } else {
144 | setCurrent('')
145 | }
146 | setSelected(current)
147 | break
148 |
149 | case 'up':
150 | newIndex = Math.max(filteredOptions.indexOf(selected) - 1, 0)
151 | setSelected(filteredOptions[newIndex])
152 | break
153 | case 'down':
154 | newIndex = Math.min(
155 | filteredOptions.indexOf(selected) + 1,
156 | filteredOptions.length - 1,
157 | )
158 | setSelected(filteredOptions[newIndex])
159 | break
160 | case 'pageUp':
161 | newIndex = Math.max(
162 | filteredOptions.indexOf(selected) - pageSize,
163 | 0,
164 | )
165 | setSelected(filteredOptions[newIndex])
166 | break
167 | case 'pageDown':
168 | newIndex = Math.min(
169 | filteredOptions.indexOf(selected) + pageSize,
170 | filteredOptions.length - 1,
171 | )
172 | setSelected(filteredOptions[newIndex])
173 | break
174 | case 'first':
175 | newIndex = 0
176 | setSelected(filteredOptions[newIndex])
177 | break
178 | case 'last':
179 | newIndex = filteredOptions.length - 1
180 | setSelected(filteredOptions[newIndex])
181 | break
182 | }
183 | }}
184 | />
185 |
186 | )
187 | }
188 |
--------------------------------------------------------------------------------
/src/components/inputs/Select.tsx:
--------------------------------------------------------------------------------
1 | import styles from './inputs.module.css'
2 | import {
3 | useState,
4 | useContext,
5 | useEffect,
6 | useRef,
7 | KeyboardEvent,
8 | Dispatch,
9 | SetStateAction,
10 | } from 'react'
11 | import { soundContext } from '@/lib/context'
12 | import Combobox from './Combobox'
13 |
14 | function getAction(e: KeyboardEvent, popupOpen: boolean) {
15 | const { key, altKey, ctrlKey, metaKey } = e
16 | if (altKey) {
17 | if (key == 'ArrowUp') {
18 | if (popupOpen) {
19 | return 'close'
20 | }
21 | return null
22 | }
23 | if (key == 'ArrowDown') {
24 | return 'open'
25 | }
26 | }
27 |
28 | switch (key) {
29 | case 'Escape':
30 | return 'close'
31 | case ' ':
32 | case 'Enter':
33 | if (popupOpen) {
34 | return 'close'
35 | } else {
36 | return 'open'
37 | }
38 | case 'ArrowUp':
39 | return 'up'
40 | case 'ArrowDown':
41 | return 'down'
42 | case 'PageUp':
43 | case 'Home':
44 | return 'first'
45 | case 'PageDown':
46 | case 'End':
47 | return 'last'
48 | }
49 |
50 | if (key.length == 1 && key != 'Tab' && !altKey && !ctrlKey && !metaKey) {
51 | return 'type'
52 | }
53 |
54 | return null
55 | }
56 |
57 | export default function Select({
58 | options,
59 | current,
60 | setCurrent,
61 | color,
62 | }: {
63 | options: string[]
64 | current: string
65 | setCurrent: Dispatch>
66 | color: 'orange' | 'green'
67 | }) {
68 | const [popupOpen, setPopupOpen] = useState(false)
69 | const [searchString, setSearchString] = useState('')
70 |
71 | useEffect(() => {
72 | const timeout = setTimeout(() => {
73 | setSearchString('')
74 | }, 1000)
75 |
76 | return () => clearTimeout(timeout)
77 | }, [searchString])
78 |
79 | const inputRef = useRef(null)
80 |
81 | const sounds = useContext(soundContext)
82 | const playButtonSound = sounds['buttonSound']
83 | const playCardSound = sounds['cardSound']
84 |
85 | return (
86 | setCurrent(option)}
93 | >
94 | {
99 | const action = getAction(e, popupOpen)
100 | let newIndex = 0
101 |
102 | switch (action) {
103 | case 'open':
104 | e.preventDefault()
105 | if (!popupOpen) {
106 | playButtonSound()
107 | }
108 | break
109 | case 'close':
110 | e.preventDefault()
111 | if (popupOpen) {
112 | playButtonSound()
113 | }
114 | break
115 | case 'up':
116 | case 'down':
117 | case 'first':
118 | case 'last':
119 | e.preventDefault()
120 | playCardSound({ playbackRate: Math.random() * 0.2 + 0.9 })
121 | break
122 | }
123 |
124 | switch (action) {
125 | case 'type':
126 | setPopupOpen(true)
127 |
128 | const newSearchString = searchString + e.key
129 | setSearchString(newSearchString)
130 |
131 | let matches = options.filter((option) =>
132 | option.toLowerCase().startsWith(newSearchString[0]),
133 | )
134 |
135 | if (matches.length == 0) {
136 | return
137 | } else if (
138 | newSearchString
139 | .split('')
140 | .every((letter) => letter == newSearchString[0])
141 | ) {
142 | setCurrent(
143 | matches[(newSearchString.length - 1) % matches.length],
144 | )
145 | } else {
146 | matches = options.filter((option) =>
147 | option.toLowerCase().startsWith(newSearchString),
148 | )
149 | if (matches[0]) {
150 | setCurrent(matches[0])
151 | }
152 | }
153 | break
154 |
155 | case 'open':
156 | setPopupOpen(true)
157 | break
158 | case 'close':
159 | setPopupOpen(false)
160 | break
161 | case 'up':
162 | newIndex = Math.max(options.indexOf(current) - 1, 0)
163 | setCurrent(options[newIndex])
164 | break
165 | case 'down':
166 | newIndex = Math.min(
167 | options.indexOf(current) + 1,
168 | options.length - 1,
169 | )
170 | setCurrent(options[newIndex])
171 | break
172 | case 'first':
173 | newIndex = 0
174 | setCurrent(options[newIndex])
175 | break
176 | case 'last':
177 | newIndex = options.length - 1
178 | setCurrent(options[newIndex])
179 | break
180 | }
181 | }}
182 | >
183 | {current}
184 |
185 |
186 | )
187 | }
188 |
--------------------------------------------------------------------------------
/src/components/inputs/inputs.module.css:
--------------------------------------------------------------------------------
1 | /* Button */
2 | .buttonContainer {
3 | display: flex;
4 | position: relative;
5 | flex-direction: column;
6 | align-items: center;
7 | user-select: none;
8 | }
9 |
10 | .buttonContainer:active .buttonTriangle {
11 | transform: translateY(var(--shadow-length));
12 | filter: none;
13 | }
14 |
15 | .button:active .buttonInsides {
16 | transform: translateY(var(--shadow-length));
17 | box-shadow: none;
18 | }
19 |
20 | .buttonTriangle {
21 | position: absolute;
22 | bottom: 100%;
23 | width: 0;
24 | height: 0;
25 | border-style: solid;
26 | border-width: 0.65rem 0.375rem 0 0.375rem;
27 | padding-bottom: 0.5rem;
28 | filter: drop-shadow(0 var(--shadow-length) var(--shadow));
29 | pointer-events: none;
30 | }
31 |
32 | .buttonInsides {
33 | box-shadow: 0 var(--shadow-length) var(--shadow);
34 | padding: 0.25rem 0.5rem;
35 | text-shadow: var(--text-shadow);
36 | border-radius: 0.5rem;
37 | }
38 |
39 | .buttonInsides:active {
40 | box-shadow: none;
41 | }
42 |
43 | @media (min-width: 1024px) {
44 | .buttonInsides {
45 | min-width: var(--button-width);
46 | }
47 | }
48 |
49 | .underline:hover {
50 | text-decoration-line: underline;
51 | text-underline-offset: 0.25rem;
52 | }
53 |
54 | .full {
55 | width: 100%;
56 | }
57 |
58 | /* Chicot reference */
59 | .button:disabled .buttonInsides {
60 | color: #6b6b6b;
61 | background-color: #545454;
62 | }
63 |
64 | .button:disabled:active .buttonInsides {
65 | transform: translateY(0);
66 | box-shadow: 0 var(--shadow-length) var(--shadow);
67 | }
68 |
69 | .button:disabled .buttonInsides:hover {
70 | background-color: #2d2d2d;
71 | }
72 |
73 | /* Combobox */
74 | .dropdown {
75 | position: relative;
76 | user-select: none;
77 | cursor: pointer;
78 | }
79 |
80 | .select {
81 | border-radius: 0.5rem;
82 | box-shadow: 0 var(--shadow-length) var(--shadow);
83 | color: white;
84 | text-shadow: var(--text-shadow);
85 | padding: 0.25rem;
86 | width: 100%;
87 | text-align: center;
88 | }
89 |
90 | .select::placeholder {
91 | color: #54bfff;
92 | }
93 |
94 | .select:active {
95 | box-shadow: none;
96 | transform: translateY(var(--shadow-length));
97 | }
98 |
99 | .optionsContainer {
100 | position: absolute;
101 | display: grid;
102 | /* transition: grid-template-rows 100ms ease-in-out; */
103 | width: 100%;
104 | }
105 |
106 | .options {
107 | background-color: white;
108 | color: var(--black);
109 | text-align: center;
110 | text-shadow: none;
111 | margin-top: 0.5rem;
112 | border-radius: 0.5rem;
113 | box-shadow: 0 var(--shadow-length) var(--shadow);
114 | z-index: 5;
115 |
116 | max-height: 15rem;
117 | overflow-y: scroll;
118 |
119 | /* transition: padding 100ms ease-in-out; */
120 | }
121 |
122 | .current {
123 | background-color: #e0e0e6;
124 | }
125 |
126 | .option {
127 | border-radius: 0.5rem;
128 | padding: 0.125rem;
129 | cursor: pointer;
130 | }
131 |
132 | .option:hover {
133 | background-color: var(--blue);
134 | color: white;
135 | }
136 |
137 | /* Input Container */
138 | .inputContainer {
139 | border-width: 0.125rem;
140 | border-radius: 0.5rem;
141 | box-shadow:
142 | 0 var(--shadow-length) #9c9da0,
143 | inset 0 var(--shadow-length) #9c9da0;
144 | padding: 0.5rem;
145 | text-align: center;
146 | margin-bottom: 0.5rem;
147 | }
148 |
149 | .inputContainerLabel {
150 | padding-bottom: 0.25rem;
151 | }
152 |
153 | /* Checkbox */
154 | .checkbox {
155 | display: flex;
156 | align-items: center;
157 | gap: 0.5rem;
158 | }
159 |
160 | .checkboxInput {
161 | appearance: none;
162 | height: 1rem;
163 | width: 1rem;
164 | background-color: #1e2b2d;
165 | border-radius: 0.375rem;
166 | border-width: 0.125rem;
167 | border-color: white;
168 | box-shadow: 0 0.0625rem 0 #ababab;
169 | flex-shrink: 0;
170 | }
171 |
172 | .checkboxInput:checked {
173 | background-image: url('/images/check.png');
174 | background-size: 100%;
175 | background-color: var(--red);
176 | }
177 |
178 | .checkboxInput:checked:hover {
179 | background-color: #a02721;
180 | }
181 |
182 | .checkboxInput:hover {
183 | background-color: #0a1213;
184 | box-shadow: 0 0.0625rem 0 #707070;
185 | }
186 |
187 | .checkboxInput:active {
188 | transform: translateY(0.0625rem);
189 | }
190 |
191 | /* Filters */
192 | .filtersContainer {
193 | display: grid;
194 | /* transition: grid-template-rows 100ms ease-in-out; */
195 | }
196 |
197 | .filters {
198 | display: grid;
199 | grid-auto-columns: 1fr;
200 | grid-auto-flow: column;
201 | justify-items: center;
202 | overflow: hidden;
203 | /* transition: padding 100ms ease-in-out; */
204 | }
205 |
206 | .filterName {
207 | padding-bottom: 0.125rem;
208 | }
209 |
--------------------------------------------------------------------------------
/src/components/pieces/Card.tsx:
--------------------------------------------------------------------------------
1 | import styles from './pieces.module.css'
2 | import { useContext, useRef, useState } from 'react'
3 | import { settingsContext, soundContext } from '@/lib/context'
4 | import Tooltip from './Tooltip'
5 | import { getHighestStake, sumStakes } from '@/lib/utils'
6 | import classNames from 'classnames/bind'
7 | import { jokerNames } from '@/lib/cards/cardMappings'
8 |
9 | function getStickerImage(sticker: number) {
10 | const stickerOffsets: Record = {
11 | 1: '-100% 0',
12 | 2: '-200% 0',
13 | 3: '-300% 0',
14 | 4: '0 -100%',
15 | 5: '-400% 0',
16 | 6: '-100% -100%',
17 | 7: '-200% -100%',
18 | 8: '-300% -100%',
19 | }
20 | return `url(/images/cards/stickers.png) ${stickerOffsets[sticker]} / 500%`
21 | }
22 |
23 | export default function Card({
24 | name,
25 | wins,
26 | losses,
27 | count,
28 | status,
29 | image,
30 | topImage,
31 | style,
32 | desc,
33 | }: {
34 | name: string
35 | wins?: Record
36 | losses?: Record
37 | count?: number
38 | status?: string
39 | image: string
40 | topImage?: string
41 | style?: { translateY?: number; degrees?: number }
42 | desc?: string
43 | }) {
44 | const cardRef = useRef(null)
45 | const settings = useContext(settingsContext)
46 | const [[x, y], setDegrees] = useState([0, 0])
47 |
48 | let opacity = 1
49 | if (settings.fadeCardsWithNoRounds.enabled) {
50 | opacity = count === undefined || count != 0 ? opacity : 0.5
51 | }
52 | if (settings.fadeCardsWithNoWins.enabled) {
53 | opacity = wins === undefined || (wins && sumStakes(wins)) ? opacity : 0.5
54 | }
55 |
56 | let stickerImage
57 | if (wins && Object.keys(wins).length != 0) {
58 | const sticker = getHighestStake(wins)
59 | stickerImage = getStickerImage(sticker)
60 | }
61 |
62 | const cx = classNames.bind(styles)
63 | const classes = cx({
64 | topImage: topImage,
65 | legendary:
66 | !['Hologram', 'The Soul'].includes(name) &&
67 | Object.values(jokerNames).includes(name),
68 | hologram: name == 'Hologram',
69 | soul: name == 'The Soul',
70 | undiscovered: !Object.values(jokerNames).includes(name),
71 | })
72 |
73 | let transform = []
74 | if (settings.cardPerspective.enabled) {
75 | transform.push(`rotateY(${x}deg) rotateX(${-y}deg)`)
76 | }
77 | if (name == 'Wee Joker') {
78 | transform.push('scale(0.75)')
79 | }
80 | if (style?.degrees) {
81 | transform.push(`rotate(${style.degrees}deg)`)
82 | }
83 | const transformStyle = transform.join(' ')
84 |
85 | const sounds = useContext(soundContext)
86 | const play = sounds['cardSound']
87 |
88 | return (
89 | sum + x, 0)}
92 | losses={losses && Object.values(losses).reduce((sum, x) => sum + x, 0)}
93 | rounds={count}
94 | status={status}
95 | translateY={style?.translateY}
96 | desc={desc}
97 | >
98 | {
107 | play({ playbackRate: Math.random() * 0.2 + 0.9 })
108 | }}
109 | onPointerMove={(e) => {
110 | if (settings.cardPerspective.enabled && cardRef.current) {
111 | const { top, right, bottom, left, x, y } =
112 | cardRef.current.getBoundingClientRect()
113 | const width = right - left
114 | const height = bottom - top
115 |
116 | const { clientX, clientY } = e
117 |
118 | const xPercentage = (clientX - x) / width
119 | const yPercentage = (clientY - y) / height
120 |
121 | const degrees = 15
122 | setDegrees([
123 | degrees * xPercentage - degrees / 2,
124 | degrees * yPercentage - degrees / 2,
125 | ])
126 | }
127 | }}
128 | onPointerLeave={() => {
129 | setDegrees([0, 0])
130 | }}
131 | >
132 | {topImage && (
133 |
139 | )}
140 |
141 |
142 | )
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/pieces/Chips.tsx:
--------------------------------------------------------------------------------
1 | import styles from './pieces.module.css'
2 | import Tooltip from './Tooltip'
3 | import { useContext } from 'react'
4 | import { soundContext } from '@/lib/context'
5 | import { settingsContext } from '@/lib/context'
6 | import { getHighestStake } from '@/lib/utils'
7 |
8 | function getChipImage(chip: string) {
9 | const chipOffsets: Record = {
10 | 'White Stake': '0 0',
11 | 'Red Stake': '-100% 0%',
12 | 'Green Stake': '-200% 0%',
13 | 'Black Stake': '-400% 0%',
14 | 'Blue Stake': '-300% 0%',
15 | 'Purple Stake': '0 -100%',
16 | 'Orange Stake': '-100% -100%',
17 | 'Gold Stake': '-200% -100%',
18 | }
19 | return `url(/images/chips.png) ${chipOffsets[chip]} / 500%`
20 | }
21 |
22 | function Chip({
23 | name,
24 | wins,
25 | losses,
26 | beaten,
27 | }: {
28 | name: string
29 | wins: number
30 | losses: number
31 | beaten: boolean
32 | }) {
33 | const sounds = useContext(soundContext)
34 | const play = sounds['chipSound']
35 |
36 | return (
37 |
38 |
45 | play({ playbackRate: Math.random() * 0.1 + 0.55 })
46 | }
47 | />
48 |
49 | )
50 | }
51 |
52 | const stakes = {
53 | 1: 'White Stake',
54 | 2: 'Red Stake',
55 | 3: 'Green Stake',
56 | 4: 'Black Stake',
57 | 5: 'Blue Stake',
58 | 6: 'Purple Stake',
59 | 7: 'Orange Stake',
60 | 8: 'Gold Stake',
61 | allStakes: [1, 2, 3, 4, 5, 6, 7, 8] as const,
62 | }
63 |
64 | export default function Chips({
65 | wins,
66 | losses,
67 | }: {
68 | wins: Record
69 | losses: Record
70 | }) {
71 | const settings = useContext(settingsContext)
72 | const highestStake = getHighestStake(wins)
73 |
74 | return (
75 |
76 | {stakes.allStakes.map((stake, i) => {
77 | const condition = settings.highlightChipsOnlyIfWin.enabled
78 | ? wins[stake]
79 | : i < highestStake
80 |
81 | return (
82 |
89 | )
90 | })}
91 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/pieces/Hand.tsx:
--------------------------------------------------------------------------------
1 | import { CardType } from '@/lib/types'
2 | import styles from './pieces.module.css'
3 | import Card from './Card'
4 |
5 | export default function Hand({
6 | cards,
7 | gap,
8 | }: {
9 | cards: CardType[]
10 | gap: string
11 | }) {
12 | const maximumDegrees = 5
13 |
14 | return (
15 |
21 | {cards.map((card, i) => {
22 | const translateY =
23 | -(1 / 2) * Math.sin((Math.PI / (cards.length - 1)) * i) - 0.5
24 | const degrees =
25 | cards.length > 1
26 | ? (maximumDegrees * 2 * i) / (cards.length - 1) - maximumDegrees
27 | : 0
28 |
29 | return (
30 |
44 | )
45 | })}
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/pieces/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import styles from './pieces.module.css'
2 |
3 | export default function Tooltip({
4 | children,
5 | name,
6 | wins,
7 | losses,
8 | rounds,
9 | status,
10 | translateY,
11 | desc,
12 | }: {
13 | children: React.ReactNode
14 | name?: string
15 | wins?: number
16 | losses?: number
17 | rounds?: number
18 | status?: string
19 | translateY?: number
20 | desc?: string
21 | }) {
22 | const roundNoun = (() => {
23 | const plural = rounds != 1
24 | switch (status) {
25 | case 'Tarot':
26 | case 'Planet':
27 | case 'Spectral':
28 | case 'Consumable':
29 | return plural ? 'uses' : 'use'
30 | case 'Voucher':
31 | return plural ? 'redeems' : 'redeem'
32 | default:
33 | return plural ? 'rounds' : 'round'
34 | }
35 | })()
36 | let statusText = status
37 | if (name) {
38 | if (['Pluto', 'Ceres', 'Eris'].includes(name)) {
39 | statusText = 'Dwarf Planet'
40 | } else if (name == 'Planet X') {
41 | statusText = 'Planet?'
42 | }
43 | }
44 |
45 | return (
46 |
52 |
53 | {name &&
{name}
}
54 |
55 |
56 | {wins != undefined && (
57 |
58 | {wins} {' '}
59 | {wins != 1 ? 'wins' : 'win'}
60 |
61 | )}
62 |
63 | {losses != undefined && (
64 |
65 | {losses} {' '}
66 | {losses != 1 ? 'losses' : 'loss'}
67 |
68 | )}
69 |
70 | {rounds != undefined && (
71 |
72 | {rounds} {roundNoun}
73 |
74 | )}
75 |
76 | {desc &&
{desc}
}
77 |
78 |
79 | {status && (
80 |
87 | {statusText}
88 |
89 | )}
90 |
91 |
92 |
{children}
93 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/pieces/pieces.module.css:
--------------------------------------------------------------------------------
1 | /* Tooltip */
2 | .tooltipWrapper {
3 | position: relative;
4 | user-select: none;
5 | width: fit-content;
6 | }
7 |
8 | .tooltipWrapper:hover {
9 | z-index: 1;
10 | }
11 |
12 | .tooltip {
13 | display: none;
14 | position: absolute;
15 | left: 50%;
16 | transform: translate(-50%);
17 | bottom: calc(100% + 0.5rem);
18 | text-align: center;
19 |
20 | background-color: #3f4a4d;
21 | border-color: #dee2ea;
22 | border-width: 0.125rem;
23 | border-radius: 0.5rem;
24 | box-shadow: 0 var(--shadow-length) 0 #919499;
25 |
26 | white-space: nowrap;
27 |
28 | padding: 0.1875rem;
29 | padding-bottom: calc(0.1875rem + var(--shadow-length));
30 | z-index: 2;
31 | }
32 |
33 | .tooltipTitle {
34 | font-size: 125%;
35 | line-height: 1.3;
36 | padding: 0 0.125rem;
37 | }
38 |
39 | .tooltipDesc {
40 | background-color: white;
41 | color: var(--black);
42 | text-shadow: none;
43 |
44 | border-color: transparent;
45 | box-shadow: 0 var(--shadow-length) 0 #ababab;
46 |
47 | padding: 0 0.125rem;
48 |
49 | border-width: 0.125rem;
50 | border-radius: 0.5rem;
51 | }
52 |
53 | .tooltipStat {
54 | color: #ff8f00;
55 | }
56 |
57 | .tooltipStatus {
58 | font-size: 110%;
59 | padding-bottom: 0.0625rem;
60 | border-radius: 0.5rem;
61 | margin: 0 auto;
62 | margin-top: calc(0.1875rem + var(--shadow-length));
63 | min-width: 5rem;
64 | padding: 0 0.75rem;
65 | padding-bottom: 0.0625rem;
66 | max-width: fit-content;
67 | }
68 |
69 | .tooltipItem {
70 | width: fit-content;
71 | perspective-origin: center bottom;
72 | perspective: var(--card-height);
73 | }
74 |
75 | .tooltipWrapper:has(.tooltipItem:hover) .tooltip {
76 | display: block;
77 | }
78 |
79 | /* Hand and Card */
80 | .hand {
81 | display: grid;
82 | justify-content: center;
83 | align-items: center;
84 | justify-items: center;
85 | }
86 |
87 | .card {
88 | aspect-ratio: 71/95;
89 | width: var(--card-width);
90 |
91 | filter: drop-shadow(0 calc(1.5 * var(--shadow-length)) var(--shadow));
92 | image-rendering: pixelated;
93 | backface-visibility: hidden;
94 |
95 | position: relative;
96 | --degrees: 0;
97 |
98 | background-color: white;
99 | }
100 |
101 | .card:hover {
102 | z-index: 1;
103 | animation: bounce 100ms ease forwards;
104 | will-change: transform;
105 | }
106 |
107 | .topImage {
108 | height: 100%;
109 | image-rendering: pixelated;
110 | }
111 |
112 | .hologram {
113 | filter: drop-shadow(0 0 0.25rem rgba(0, 255, 255, 0.8));
114 | transform: scale(1.2);
115 | }
116 |
117 | .hologram:hover,
118 | .undiscovered:hover {
119 | animation:
120 | hologramScale 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate,
121 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s;
122 | }
123 |
124 | .legendary {
125 | filter: drop-shadow(0 var(--shadow-length) var(--shadow));
126 | transform: scale(1.05);
127 | }
128 |
129 | .legendary:hover {
130 | animation:
131 | popoutScale 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate,
132 | popoutShadow 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate,
133 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s;
134 | }
135 |
136 | .soul {
137 | filter: drop-shadow(0 calc(2 * var(--shadow-length)) var(--shadow));
138 | }
139 |
140 | .soul:hover {
141 | animation:
142 | soulScale 1s cubic-bezier(0.37, 0, 0.63, 1) infinite,
143 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s;
144 | }
145 |
146 | /* Chip and Chips */
147 | .chip {
148 | aspect-ratio: 29/29;
149 | width: var(--chip-size);
150 |
151 | image-rendering: pixelated;
152 | margin: 0.0625rem;
153 | }
154 |
155 | .chip:hover {
156 | transform: scale(1.15);
157 | }
158 |
159 | .chips {
160 | display: grid;
161 | grid-template-columns: repeat(8, minmax(0, 1fr));
162 | justify-items: center;
163 | filter: drop-shadow(0 calc(0.75 * var(--shadow-length)) var(--shadow));
164 | }
165 |
166 | /* Animations */
167 | @keyframes bounce {
168 | /* Damped Sinusoidal Wave */
169 | 8% {
170 | scale: 1.13;
171 | }
172 | 28% {
173 | scale: 1.08;
174 | }
175 | 48% {
176 | scale: 1.12;
177 | }
178 | 68% {
179 | scale: 1.09;
180 | }
181 | 88% {
182 | scale: 1.11;
183 | }
184 | 100% {
185 | scale: 1.1;
186 | }
187 | }
188 |
189 | @keyframes popoutScale {
190 | 0% {
191 | scale: 1.05;
192 | }
193 | 100% {
194 | scale: 1.08;
195 | }
196 | }
197 |
198 | @keyframes popoutRotate {
199 | 0% {
200 | rotate: 4deg;
201 | }
202 | 100% {
203 | rotate: -4deg;
204 | }
205 | }
206 |
207 | @keyframes popoutShadow {
208 | 0% {
209 | filter: drop-shadow(0 var(--shadow-length) var(--shadow));
210 | }
211 | 100% {
212 | filter: drop-shadow(0 calc(2.5 * var(--shadow-length)) var(--shadow));
213 | }
214 | }
215 |
216 | @keyframes hologramScale {
217 | 0% {
218 | scale: 1;
219 | }
220 | 100% {
221 | scale: 1.1;
222 | }
223 | }
224 |
225 | @keyframes soulScale {
226 | /* Modified version of bounce */
227 | 0% {
228 | scale: 1;
229 | }
230 | 4% {
231 | scale: 1.13;
232 | }
233 | 14% {
234 | scale: 1.08;
235 | }
236 | 24% {
237 | scale: 1.12;
238 | }
239 | 34% {
240 | scale: 1.09;
241 | }
242 | 44% {
243 | scale: 1.11;
244 | }
245 | 68% {
246 | scale: 1.09;
247 | }
248 | 88% {
249 | scale: 1.1;
250 | }
251 | 100% {
252 | scale: 1;
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/src/components/sections/collection/Collection.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from 'react'
2 | import styles from './collection.module.css'
3 | import { CardType, FilterType, Profile } from '@/lib/types'
4 | import Button from '@/components/inputs/Button'
5 | import {
6 | filterCards,
7 | getHighestStake,
8 | sumStakes,
9 | useToImage,
10 | } from '@/lib/utils'
11 | import InputContainer from '../../inputs/InputContainer'
12 | import Select from '../../inputs/Select'
13 | import Search from '../../inputs/Search'
14 | import {
15 | initialJokers,
16 | initialDecks,
17 | initialConsumables,
18 | initialVouchers,
19 | initializeJoker,
20 | initializeDeck,
21 | initializeConsumable,
22 | initializeVouchers,
23 | } from '@/lib/cards/cards'
24 | import Grid from './Grid'
25 | import List from './List'
26 | import Graph from './Graph'
27 | import Filters from '../../inputs/Filters'
28 | import { useImmer } from 'use-immer'
29 | import {
30 | baseVoucherNames,
31 | upgradedVoucherNames,
32 | } from '../../../lib/cards/cardKeys'
33 | import { settingsContext } from '@/lib/context'
34 |
35 | interface TabType {
36 | name: string
37 | sorts: string[]
38 | graphs: string[]
39 | filters: string[]
40 | views: string[]
41 | }
42 |
43 | const tabs: Record = {
44 | Jokers: {
45 | name: 'Jokers',
46 | sorts: [
47 | 'Game Order',
48 | 'Name',
49 | 'Wins',
50 | 'Win %',
51 | 'Wins per Loss',
52 | 'Losses',
53 | 'Rounds',
54 | 'Rarity',
55 | 'Stake',
56 | ],
57 | graphs: ['Rounds', 'Wins', 'Win %', 'Wins per Loss', 'Losses'],
58 | filters: ['Stake', 'Rarity'],
59 | views: ['Grid', 'List', 'Graph'],
60 | },
61 | Decks: {
62 | name: 'Decks',
63 | sorts: [
64 | 'Game Order',
65 | 'Name',
66 | 'Wins',
67 | 'Win %',
68 | 'Wins per Loss',
69 | 'Losses',
70 | 'Stake',
71 | ],
72 | graphs: ['Wins', 'Win %', 'Wins per Loss', 'Losses'],
73 | filters: ['Stake'],
74 | views: ['Grid', 'List', 'Graph'],
75 | },
76 | Consumables: {
77 | name: 'Consumables',
78 | sorts: ['Game Order', 'Name', 'Uses'],
79 | graphs: ['Uses'],
80 | filters: ['Type'],
81 | views: ['Grid', 'Graph'],
82 | },
83 | Vouchers: {
84 | name: 'Vouchers',
85 | sorts: ['Game Order', 'Name', 'Redeems'],
86 | graphs: ['Redeems'],
87 | filters: ['Tier'],
88 | views: ['Grid', 'Graph'],
89 | },
90 | }
91 |
92 | const cardFilters: Record = {
93 | Stake: {
94 | name: 'Stake',
95 | filters: {
96 | None: {
97 | filter: (card: CardType) =>
98 | card.wins ? sumStakes(card.wins) == 0 : false,
99 | enabled: false,
100 | },
101 | 'White Stake': {
102 | filter: (card: CardType) =>
103 | card.wins ? getHighestStake(card.wins) == 1 : false,
104 | enabled: false,
105 | },
106 | 'Red Stake': {
107 | filter: (card: CardType) =>
108 | card.wins ? getHighestStake(card.wins) == 2 : false,
109 | enabled: false,
110 | },
111 | 'Green Stake': {
112 | filter: (card: CardType) =>
113 | card.wins ? getHighestStake(card.wins) == 3 : false,
114 | enabled: false,
115 | },
116 | 'Black Stake': {
117 | filter: (card: CardType) =>
118 | card.wins ? getHighestStake(card.wins) == 4 : false,
119 | enabled: false,
120 | },
121 | 'Blue Stake': {
122 | filter: (card: CardType) =>
123 | card.wins ? getHighestStake(card.wins) == 5 : false,
124 | enabled: false,
125 | },
126 | 'Purple Stake': {
127 | filter: (card: CardType) =>
128 | card.wins ? getHighestStake(card.wins) == 6 : false,
129 | enabled: false,
130 | },
131 | 'Orange Stake': {
132 | filter: (card: CardType) =>
133 | card.wins ? getHighestStake(card.wins) == 7 : false,
134 | enabled: false,
135 | },
136 | 'Gold Stake': {
137 | filter: (card: CardType) =>
138 | card.wins ? getHighestStake(card.wins) == 8 : false,
139 | enabled: false,
140 | },
141 | },
142 | },
143 | Rarity: {
144 | name: 'Rarity',
145 | filters: {
146 | Common: {
147 | filter: (card: CardType) => card.status == 'Common',
148 | enabled: false,
149 | },
150 | Uncommon: {
151 | filter: (card: CardType) => card.status == 'Uncommon',
152 | enabled: false,
153 | },
154 | Rare: {
155 | filter: (card: CardType) => card.status == 'Rare',
156 | enabled: false,
157 | },
158 | Legendary: {
159 | filter: (card: CardType) => card.status == 'Legendary',
160 | enabled: false,
161 | },
162 | },
163 | },
164 | Type: {
165 | name: 'Type',
166 | filters: {
167 | Tarot: {
168 | filter: (card: CardType) => card.status == 'Tarot',
169 | enabled: false,
170 | },
171 | Planet: {
172 | filter: (card: CardType) => card.status == 'Planet',
173 | enabled: false,
174 | },
175 | Spectral: {
176 | filter: (card: CardType) => card.status == 'Spectral',
177 | enabled: false,
178 | },
179 | },
180 | },
181 | Tier: {
182 | name: 'Tier',
183 | filters: {
184 | Base: {
185 | filter: (card: CardType) => {
186 | return baseVoucherNames.includes(card.name)
187 | },
188 | enabled: false,
189 | },
190 | Upgraded: {
191 | filter: (card: CardType) => {
192 | return upgradedVoucherNames.includes(card.name)
193 | },
194 | enabled: false,
195 | },
196 | },
197 | },
198 | }
199 |
200 | export default function Collection({ profile }: { profile: Profile }) {
201 | const [tab, setTab] = useState(tabs['Jokers'])
202 | const [search, setSearch] = useState('')
203 | const [view, setView] = useState(tab.views[0])
204 | const sorts = view == 'Graph' ? tab.graphs : tab.sorts
205 | const [sort, setSort] = useState(sorts[0])
206 | const [filters, setFilters] = useImmer(cardFilters)
207 | const activeFilters = tab.filters.map((group) =>
208 | Object.values(filters[group].filters)
209 | .filter(({ enabled }) => enabled)
210 | .map(({ filter }) => filter),
211 | )
212 | const settings = useContext(settingsContext)
213 | const [ref, savePNG] = useToImage(
214 | tab.name.toLowerCase(),
215 | settings.saveImageinNewTab.enabled,
216 | )
217 |
218 | if (!tab.views.includes(view)) {
219 | setView(tab.views[1])
220 | }
221 | if (!sorts.includes(sort)) {
222 | setSort(sorts[0])
223 | }
224 |
225 | const jokers = initialJokers()
226 | Object.entries(profile.joker_usage).forEach(([joker, stats]) => {
227 | if (!jokers[joker]) {
228 | jokers[joker] = initializeJoker(joker)
229 | }
230 | jokers[joker].wins = stats.wins
231 | jokers[joker].losses = stats.losses
232 | jokers[joker].count = stats.count
233 | })
234 |
235 | const decks = initialDecks()
236 | Object.entries(profile.deck_usage).forEach(([deck, stats]) => {
237 | if (!decks[deck]) {
238 | decks[deck] = initializeDeck(deck)
239 | }
240 | decks[deck].wins = stats.wins
241 | decks[deck].losses = stats.losses
242 | })
243 |
244 | const consumables = initialConsumables()
245 | Object.entries(profile.consumeable_usage).forEach(([card, stats]) => {
246 | if (!consumables[card]) {
247 | consumables[card] = initializeConsumable(card)
248 | }
249 | consumables[card].count = stats.count
250 | })
251 |
252 | const vouchers = initialVouchers()
253 | Object.entries(profile.voucher_usage).forEach(([voucher, stats]) => {
254 | if (!vouchers[voucher]) {
255 | vouchers[voucher] = initializeVouchers(voucher)
256 | }
257 | vouchers[voucher].count = stats.count
258 | })
259 |
260 | const cards = (() => {
261 | switch (tab.name) {
262 | default:
263 | case 'Jokers':
264 | return Object.values(jokers)
265 | case 'Decks':
266 | return Object.values(decks)
267 | case 'Consumables':
268 | return Object.values(consumables)
269 | case 'Vouchers':
270 | return Object.values(vouchers)
271 | }
272 | })()
273 |
274 | const filteredCards = filterCards(cards, search, sort, activeFilters)
275 |
276 | const insides = (() => {
277 | switch (view) {
278 | case 'Grid':
279 | return
280 | case 'List':
281 | return
282 | case 'Graph':
283 | return
284 | }
285 | })()
286 |
287 | return (
288 |
289 |
290 |
291 | {Object.keys(tabs).map((tabName) => {
292 | return (
293 | setTab(tabs[tabName])}
299 | />
300 | )
301 | })}
302 |
303 |
304 | <>
305 |
306 | card.name)}
308 | current={search}
309 | setCurrent={setSearch}
310 | placeholder={tab.name}
311 | />
312 |
313 |
314 |
315 |
321 |
322 |
323 |
324 | filters[group])}
326 | setFilters={setFilters}
327 | />
328 |
329 |
330 |
331 |
337 |
338 | >
339 |
340 | {insides}
341 |
342 |
343 |
348 |
356 |
357 |
358 | )
359 | }
360 |
--------------------------------------------------------------------------------
/src/components/sections/collection/Graph.tsx:
--------------------------------------------------------------------------------
1 | import styles from './collection.module.css'
2 | import { CardType } from '@/lib/types'
3 | import Card from '../../pieces/Card'
4 | import { sumStakes } from '@/lib/utils'
5 |
6 | function getStat(card: CardType, sort: string) {
7 | switch (sort) {
8 | default:
9 | case 'Rounds':
10 | case 'Uses':
11 | case 'Redeems':
12 | return card.count ?? 0
13 | case 'Wins':
14 | return card.wins ? sumStakes(card.wins) : 0
15 | case 'Win %':
16 | var wins = card.wins ? sumStakes(card.wins) : 0
17 | var losses = card.losses ? sumStakes(card.losses) : 0
18 | var rounds = wins + losses
19 | var winPercentage = wins / (rounds || 1)
20 | return Math.round(winPercentage * 100)
21 | case 'Wins per Loss':
22 | var wins = card.wins ? sumStakes(card.wins) : 0
23 | var losses = card.losses ? sumStakes(card.losses) : 0
24 | return Math.round((wins / (losses || 1)) * 100) / 100
25 | case 'Losses':
26 | return card.losses ? sumStakes(card.losses) : 0
27 | }
28 | }
29 |
30 | function getTotal(cards: CardType[], sort: string) {
31 | let total
32 | const card = cards[0]
33 | switch (sort) {
34 | default:
35 | case 'Rounds':
36 | case 'Uses':
37 | case 'Redeems':
38 | total = card.count
39 | break
40 | case 'Wins':
41 | total = card.wins ? sumStakes(card.wins) : 0
42 | break
43 | case 'Win %':
44 | return 100
45 | case 'Wins per Loss':
46 | var wins = card.wins ? sumStakes(card.wins) : 0
47 | var losses = card.losses ? sumStakes(card.losses) : 0
48 | total = wins / (losses || 1)
49 | break
50 | case 'Losses':
51 | total = card.losses ? sumStakes(card.losses) : 0
52 | break
53 | }
54 | return total || 1
55 | }
56 |
57 | function GraphRow({
58 | card,
59 | stat,
60 | total,
61 | asPercentage,
62 | }: {
63 | card: CardType
64 | stat: number
65 | total: number
66 | asPercentage: boolean
67 | }) {
68 | const isJoker =
69 | card.status == 'Common' ||
70 | card.status == 'Uncommon' ||
71 | card.status == 'Rare' ||
72 | card.status == 'Legendary'
73 | return (
74 |
75 |
87 |
88 |
89 | {asPercentage ? `${stat}%` : stat || '-'}
90 |
91 |
92 |
101 |
102 | )
103 | }
104 |
105 | export default function Graph({
106 | cards,
107 | tab,
108 | sort,
109 | }: {
110 | cards: CardType[]
111 | tab: string
112 | sort: string
113 | }) {
114 | const legendText: Record = {
115 | Rounds: 'Total completed rounds with this card',
116 | Uses: 'Number of times this card has been used',
117 | Redeems: 'Number of times this Voucher has been redeemed',
118 |
119 | 'Round %': 'Percentage of completed rounds with this card',
120 | 'Use %': 'Percentage of times this card has been used',
121 | 'Redeem %': 'Percentage of times this Voucher has been redeemed',
122 |
123 | Wins: `Number of wins with this ${tab == 'Decks' ? 'deck' : 'card'}`,
124 | 'Win %': `Percentage of games won with this ${tab == 'Decks' ? 'deck' : 'card'}`,
125 | 'Wins per Loss': `Number of wins per loss with this ${tab == 'Decks' ? 'deck' : 'card'}`,
126 | Losses: `Number of losses with this ${tab == 'Decks' ? 'deck' : 'card'}`,
127 | }
128 |
129 | return (
130 |
131 |
132 |
133 | <>{legendText[sort]}>
134 |
135 | {cards.map((card, i) => {
136 | const stat = getStat(card, sort)
137 | const total = getTotal(cards, sort)
138 | return (
139 |
146 | )
147 | })}
148 |
149 | )
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/sections/collection/Grid.tsx:
--------------------------------------------------------------------------------
1 | import styles from './collection.module.css'
2 | import Hand from '../../pieces/Hand'
3 | import { CardType } from '@/lib/types'
4 |
5 | export default function Grid({
6 | cards,
7 | tab,
8 | }: {
9 | cards: CardType[]
10 | tab: string
11 | }) {
12 | const cardsPerHand = tab != 'Vouchers' ? 5 : 4
13 | const gap = '0.25rem'
14 | const hands = []
15 | for (let i = 0; i < cards.length; i += cardsPerHand) {
16 | hands.push(
17 | ,
18 | )
19 | }
20 |
21 | return {hands}
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/sections/collection/List.tsx:
--------------------------------------------------------------------------------
1 | import styles from './collection.module.css'
2 | import { CardType } from '@/lib/types'
3 | import Card from '../../pieces/Card'
4 | import Chips from '../../pieces/Chips'
5 |
6 | function ListRow({ card }: { card: CardType }) {
7 | return (
8 |
9 |
18 | {card.wins && card.losses && (
19 |
20 | )}
21 |
22 | )
23 | }
24 |
25 | export default function List({ cards }: { cards: CardType[] }) {
26 | return (
27 |
28 | {cards.map((card) => {
29 | return
30 | })}
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/sections/collection/collection.module.css:
--------------------------------------------------------------------------------
1 | .collection {
2 | background-color: #3a5055;
3 | box-shadow: 0 var(--shadow-length) 0 #777e89;
4 |
5 | border-width: var(--shadow-length);
6 | border-radius: 0.5rem;
7 | border-color: #b9c2d2;
8 | padding: 0 0.5rem;
9 | padding-bottom: calc(0.5rem + var(--shadow-length));
10 | }
11 |
12 | .tabs {
13 | display: grid;
14 | grid-template-columns: repeat(4, minmax(0, auto));
15 | justify-content: center;
16 | gap: 0.5rem;
17 | padding: 1.5rem 0;
18 | }
19 |
20 | .grid {
21 | display: grid;
22 | gap: 0.25rem;
23 |
24 | background-color: #1e2b2d;
25 | box-shadow: 0 var(--shadow-length) 0 #0b1415;
26 | border-radius: 0.5rem;
27 |
28 | padding-top: 0.25rem;
29 | margin-top: 1rem;
30 | min-height: var(--card-height);
31 | }
32 |
33 | .list {
34 | background-color: #33464b;
35 | padding: 0.5rem;
36 | border-radius: 0.5rem;
37 | margin-top: 1rem;
38 | min-height: var(--card-height);
39 | }
40 |
41 | .list .listRow:first-child,
42 | .list .listRow:last-child,
43 | .list .graphRow:first-child,
44 | .list .graphRow:last-child {
45 | margin: 0;
46 | }
47 |
48 | .listRow {
49 | display: flex;
50 | align-items: center;
51 | justify-content: center;
52 | gap: 1rem;
53 |
54 | border-radius: 0.5rem;
55 | background-color: #2d3e42;
56 | padding: 0.25rem;
57 | margin: 0.35rem 0;
58 | }
59 |
60 | .graphRow {
61 | display: flex;
62 | align-items: center;
63 |
64 | border-radius: 0.5rem;
65 | background-color: #2d3e42;
66 | padding: 0.25rem 0;
67 | margin: 0.35rem 0;
68 | }
69 |
70 | .graphBar {
71 | border-radius: 0.125rem;
72 | height: var(--card-width);
73 | transition: width 900ms ease;
74 | }
75 |
76 | .graphNumber {
77 | width: 3rem;
78 | text-align: center;
79 | color: var(--orange);
80 | }
81 |
82 | .graphLegend {
83 | display: flex;
84 | align-items: center;
85 | justify-content: center;
86 | margin-bottom: 0.5rem;
87 | }
88 |
89 | .graphSquare {
90 | background-color: #ff8f00;
91 | height: 0.5rem;
92 | width: 0.5rem;
93 | border-radius: 0.125rem;
94 | margin: 0 0.25rem;
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/sections/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/sections/hero/Hero.tsx:
--------------------------------------------------------------------------------
1 | import styles from './hero.module.css'
2 | import { initialProfile, readProfile } from '@/lib/profile'
3 | import { Profile } from '@/lib/types'
4 | import {
5 | Dispatch,
6 | SetStateAction,
7 | useRef,
8 | useState,
9 | useContext,
10 | useEffect,
11 | } from 'react'
12 | import Button from '../../inputs/Button'
13 | import { soundContext } from '@/lib/context'
14 | import Help from '../popups/Help'
15 |
16 | export default function Hero({
17 | saved,
18 | profileSetter,
19 | }: {
20 | saved: boolean
21 | profileSetter: Dispatch>
22 | }) {
23 | const inputRef = useRef(null)
24 | const [confirm, setConfirm] = useState(false)
25 | const [disabled, setDisabled] = useState(false)
26 | const [help, setHelp] = useState(false)
27 |
28 | useEffect(() => {
29 | const timeout = setTimeout(() => {
30 | setConfirm(false)
31 | }, 3000)
32 |
33 | return () => clearTimeout(timeout)
34 | }, [confirm])
35 |
36 | const sounds = useContext(soundContext)
37 | const play = sounds['tarotSound']
38 |
39 | return (
40 | <>
41 |
42 |
49 | Balatro Stats
50 |
51 |
52 |
57 |
View your stats in Balatro!
58 |
65 | Upload your profile.jkr to begin.
66 | {
74 | setHelp(true)
75 | }}
76 | underline={true}
77 | />
78 |
79 |
80 |
81 |
82 |
83 | {
89 | const file = e.target.files?.[0]
90 | if (file) {
91 | readProfile(file, (profile: Profile) => {
92 | const date = new Date().toLocaleDateString()
93 | profile.lastUpdated = date
94 | profileSetter(profile)
95 | localStorage.setItem('profile', JSON.stringify(profile))
96 | })
97 | }
98 | }}
99 | />
100 | {
108 | if (inputRef.current) {
109 | inputRef.current.click()
110 | }
111 | }}
112 | />
113 |
114 |
115 |
116 | {
123 | if (confirm) {
124 | // Reset Profile
125 | if (inputRef.current) {
126 | inputRef.current.value = ''
127 | profileSetter(initialProfile)
128 | }
129 | setConfirm(false)
130 | localStorage.removeItem('profile')
131 | } else {
132 | // Ask the user to confirm to reset
133 | play({ playbackRate: 1 })
134 | setTimeout(() => {
135 | play({ playbackRate: 0.76 })
136 | }, 60)
137 |
138 | setConfirm(true)
139 | setDisabled(true)
140 | setTimeout(() => {
141 | setDisabled(false)
142 | }, 500)
143 | }
144 | }}
145 | disabled={disabled}
146 | />
147 |
148 |
149 |
150 |
156 | Click again to confirm
157 |
158 |
159 |
160 | {help && }
161 | >
162 | )
163 | }
164 |
--------------------------------------------------------------------------------
/src/components/sections/hero/hero.module.css:
--------------------------------------------------------------------------------
1 | .hero {
2 | padding: 0.5rem 0 1rem 0;
3 | }
4 |
5 | .input {
6 | display: none;
7 | }
8 |
9 | .buttons {
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | gap: 0.5rem;
14 | padding: 0.5rem;
15 | }
16 |
17 | .warning {
18 | padding-top: 0.5rem;
19 | text-align: center;
20 | transform-origin: center 75%;
21 | user-select: none;
22 | }
23 |
24 | .animation {
25 | animation:
26 | bounce 400ms ease-in-out,
27 | spin 300ms ease-in-out;
28 | }
29 |
30 | @keyframes bounce {
31 | /* Damped Sinusoidal Wave */
32 | 8% {
33 | scale: 1.4;
34 | }
35 | 28% {
36 | scale: 0.8;
37 | }
38 | 48% {
39 | scale: 1.3;
40 | }
41 | 68% {
42 | scale: 0.9;
43 | }
44 | 88% {
45 | scale: 1.1;
46 | }
47 | 100% {
48 | scale: 1;
49 | }
50 | }
51 |
52 | @keyframes spin {
53 | /* Damped Sinusoidal Wave */
54 | 8% {
55 | rotate: 8deg;
56 | }
57 | 28% {
58 | rotate: -7deg;
59 | }
60 | 48% {
61 | rotate: 7deg;
62 | }
63 | 68% {
64 | rotate: -6deg;
65 | }
66 | 88% {
67 | rotate: 4deg;
68 | }
69 | 100% {
70 | rotate: 0deg;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/sections/navbar/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import styles from './navbar.module.css'
2 | import { Profile } from '@/lib/types'
3 | import { Options } from '../popups/Options'
4 | import { useState } from 'react'
5 | import { Updater } from 'use-immer'
6 | import Button from '../../inputs/Button'
7 | import Login from '../popups/LogIn'
8 |
9 | export default function NavBar({
10 | profile,
11 | setter,
12 | }: {
13 | profile: Profile
14 | setter: Updater>
15 | }) {
16 | const [shareOpen, setShareOpen] = useState(false)
17 | const [optionsOpen, setOptionsOpen] = useState(false)
18 |
19 | return (
20 | <>
21 |
22 | <>
23 | {`${profile.name}'s stats`}
24 | {profile.lastUpdated && ` [${profile.lastUpdated}]`}
25 | >
26 |
27 |
28 | {/* {
36 | setShareOpen(true)
37 | }}
38 | underline={true}
39 | /> */}
40 | {
48 | setOptionsOpen(true)
49 | }}
50 | underline={true}
51 | />
52 |
53 |
54 | {shareOpen && }
55 | {optionsOpen && (
56 |
57 | )}
58 | >
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/sections/navbar/navbar.module.css:
--------------------------------------------------------------------------------
1 | .navbar {
2 | position: sticky;
3 | top: 0.125rem;
4 | background-color: #2e3a3c;
5 |
6 | box-shadow: 0 var(--shadow-length) #091113;
7 |
8 | border-radius: 0.5rem;
9 | border-width: 0.125rem;
10 | border-color: #1e2b2d;
11 |
12 | margin-bottom: 0.5rem;
13 | padding: 0.25rem 0.5rem;
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 |
18 | z-index: 10;
19 | }
20 |
21 | .buttons {
22 | display: flex;
23 | gap: 1rem;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/sections/popups/Help.tsx:
--------------------------------------------------------------------------------
1 | import styles from './popup.module.css'
2 | import { Dispatch, ReactNode, SetStateAction } from 'react'
3 | import Popup from './Popup'
4 |
5 | function Highlight({
6 | children,
7 | color,
8 | }: {
9 | children: ReactNode
10 | color?: string
11 | }) {
12 | if (color === undefined) {
13 | color = 'orange'
14 | }
15 |
16 | return (
17 |
22 | {children}
23 |
24 | )
25 | }
26 |
27 | export default function Help({
28 | setter,
29 | }: {
30 | setter: Dispatch>
31 | }) {
32 | return (
33 |
34 | <>
35 |
36 | Where to find your profile.jkr
37 |
38 |
39 |
40 |
41 |
42 | Windows:
43 |
44 | Open File Explorer and click the address bar
45 |
46 | Type: %AppData%/Balatro
47 |
48 |
49 | Press [Enter]
50 |
51 | Choose from folder 1, 2, or 3
52 |
53 |
54 |
55 |
56 | MacOS:
57 |
58 | Open Finder
59 |
60 | Press [Shift] +{' '}
61 | [Command] + [G]
62 |
63 |
64 | Type:{' '}
65 |
66 | ~/Library/Application Support/Balatro
67 |
68 |
69 |
70 | Press [Enter]
71 |
72 | Choose from folder 1, 2, or 3
73 |
74 |
75 |
76 |
77 | Steam Deck:
78 |
79 |
80 | Navigate to:{' '}
81 |
82 | ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro
83 |
84 |
85 | Choose from folder 1, 2, or 3
86 |
87 |
88 |
89 |
90 | >
91 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/sections/popups/LogIn.tsx:
--------------------------------------------------------------------------------
1 | import styles from './popup.module.css'
2 | import { Dispatch, SetStateAction } from 'react'
3 | import Popup from './Popup'
4 | import { signIn } from 'next-auth/react'
5 |
6 | export default function Login({
7 | setter,
8 | }: {
9 | setter: Dispatch>
10 | }) {
11 | return Log in with Discord to share your stats.
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/sections/popups/Options.tsx:
--------------------------------------------------------------------------------
1 | import styles from './popup.module.css'
2 | import { Dispatch, SetStateAction, useContext, useEffect } from 'react'
3 | import { settingsContext } from '@/lib/context'
4 | import Checkbox from '../../inputs/Checkbox'
5 | import { Updater } from 'use-immer'
6 | import Popup from './Popup'
7 |
8 | function minifySettings(
9 | settings: Record,
10 | ) {
11 | // Compress the settings to only keep the boolean value
12 | const miniSettings: Record = {}
13 | Object.entries(settings).forEach(([key, { enabled }]) => {
14 | miniSettings[key] = enabled
15 | })
16 | return JSON.stringify(miniSettings)
17 | }
18 |
19 | export function Options({
20 | activeSetter,
21 | settingsSetter,
22 | }: {
23 | activeSetter: Dispatch>
24 | settingsSetter: Updater>
25 | }) {
26 | const settings = useContext(settingsContext)
27 |
28 | useEffect(() => {
29 | localStorage.setItem('settings', minifySettings(settings))
30 | }, [settings])
31 |
32 | return (
33 |
34 | <>
35 |
36 |
Settings
37 |
38 | {Object.entries(settings).map(([key, { label, enabled }]) => {
39 | return (
40 | {
45 | settingsSetter((draft) => {
46 | draft[key].enabled = !enabled
47 | })
48 | }}
49 | />
50 | )
51 | })}
52 |
53 |
54 |
55 |
77 | >
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/sections/popups/Popup.tsx:
--------------------------------------------------------------------------------
1 | import styles from './popup.module.css'
2 | import { Dispatch, SetStateAction } from 'react'
3 | import Button from '../../inputs/Button'
4 |
5 | export default function Popup({
6 | children,
7 | setter,
8 | }: {
9 | children: React.ReactNode
10 | setter: Dispatch>
11 | }) {
12 | const exit = () => {
13 | setter(false)
14 | }
15 |
16 | return (
17 | <>
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 | >
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/sections/popups/popup.module.css:
--------------------------------------------------------------------------------
1 | .background {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | height: 100vh;
6 | width: 100vw;
7 | background-color: rgba(0, 0, 0, 0.5);
8 | z-index: 14;
9 |
10 | animation: fadeIn 250ms linear forwards;
11 | }
12 |
13 | .popup {
14 | position: fixed;
15 | left: 50%;
16 | transform: translate(-50%, -50%);
17 |
18 | width: calc(var(--card-width) * 5);
19 |
20 | background-color: #3a5055;
21 | box-shadow: 0 var(--shadow-length) 0 #777e89;
22 | border-width: var(--shadow-length);
23 | border-radius: 0.5rem;
24 | border-color: #b9c2d2;
25 | padding: 0.5rem;
26 |
27 | z-index: 15;
28 |
29 | animation: slide 500ms ease-in-out forwards;
30 | }
31 |
32 | .section {
33 | display: flex;
34 | flex-direction: column;
35 | align-items: center;
36 | padding: 0.5rem;
37 | padding-bottom: 1rem;
38 | }
39 |
40 | .subSections {
41 | display: flex;
42 | flex-direction: column;
43 | gap: 0.5rem;
44 | }
45 |
46 | .sectionHeading {
47 | font-size: 125%;
48 | padding-bottom: 0.25rem;
49 | text-align: center;
50 | }
51 |
52 | .list {
53 | list-style: auto;
54 | padding-left: 1.5rem;
55 | }
56 |
57 | .link {
58 | color: var(--blue);
59 | }
60 |
61 | @keyframes fadeIn {
62 | 0% {
63 | opacity: 0;
64 | }
65 | 100% {
66 | opacity: 1;
67 | }
68 | }
69 |
70 | @keyframes slide {
71 | 0% {
72 | top: 250%;
73 | }
74 | 66% {
75 | top: 48%;
76 | }
77 | 100% {
78 | top: 50%;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/sections/records/CareerStats.tsx:
--------------------------------------------------------------------------------
1 | import styles from './records.module.css'
2 | import { Profile } from '@/lib/types'
3 | import Stat from '@/components/stats/Stat'
4 | import { numberWithCommas } from '@/lib/utils'
5 | import { useContext } from 'react'
6 | import { settingsContext } from '@/lib/context'
7 | import {
8 | planetKeys,
9 | spectralKeys,
10 | tarotKeys,
11 | } from '../../../lib/cards/cardKeys'
12 |
13 | export default function CareerStats({ profile }: { profile: Profile }) {
14 | const settings = useContext(settingsContext)
15 |
16 | interface StatType {
17 | name: string
18 | value: number
19 | perGame: boolean
20 | color: string
21 | type: 'number' | 'money' | 'percentage'
22 | }
23 |
24 | const stats: Record = {
25 | c_cards_discarded: {
26 | name: 'Cards Discarded',
27 | value: 0,
28 | perGame: true,
29 | color: 'red',
30 | type: 'number',
31 | },
32 | c_cards_played: {
33 | name: 'Cards Played',
34 | value: 0,
35 | perGame: true,
36 | color: 'blue',
37 | type: 'number',
38 | },
39 | c_cards_sold: {
40 | name: 'Cards Sold',
41 | value: 0,
42 | perGame: true,
43 | color: 'white',
44 | type: 'number',
45 | },
46 | c_dollars_earned: {
47 | name: 'Total Money Earned',
48 | value: 0,
49 | perGame: true,
50 | color: 'yellow',
51 | type: 'money',
52 | },
53 | c_face_cards_played: {
54 | name: 'Face Cards Played',
55 | value: 0,
56 | perGame: true,
57 | color: 'white',
58 | type: 'number',
59 | },
60 | c_hands_played: {
61 | name: 'Hands Played',
62 | value: 0,
63 | perGame: true,
64 | color: 'white',
65 | type: 'number',
66 | },
67 | c_jokers_sold: {
68 | name: 'Jokers Sold',
69 | value: 0,
70 | perGame: true,
71 | color: 'white',
72 | type: 'number',
73 | },
74 | c_losses: {
75 | name: 'Losses',
76 | value: 0,
77 | perGame: false,
78 | color: 'red',
79 | type: 'number',
80 | },
81 | c_planetarium_used: {
82 | name: 'Celestial Packs Used',
83 | value: 0,
84 | perGame: true,
85 | color: 'Planet',
86 | type: 'number',
87 | },
88 | c_planets_bought: {
89 | name: 'Planets Purchased',
90 | value: 0,
91 | perGame: true,
92 | color: 'Planet',
93 | type: 'number',
94 | },
95 | c_playing_cards_bought: {
96 | name: 'Playing Cards Purchased',
97 | value: 0,
98 | perGame: true,
99 | color: 'white',
100 | type: 'number',
101 | },
102 | c_round_interest_cap_streak: {
103 | name: 'Current Max Interest Streak',
104 | value: 0,
105 | perGame: false,
106 | color: 'white',
107 | type: 'number',
108 | },
109 | c_rounds: {
110 | name: 'Rounds Played',
111 | value: 0,
112 | perGame: true,
113 | color: 'orange',
114 | type: 'number',
115 | },
116 | c_shop_dollars_spent: {
117 | name: 'Total Money Spent',
118 | value: 0,
119 | perGame: true,
120 | color: 'yellow',
121 | type: 'money',
122 | },
123 | c_shop_rerolls: {
124 | name: 'Times Rerolled',
125 | value: 0,
126 | perGame: true,
127 | color: 'green',
128 | type: 'number',
129 | },
130 | c_single_hand_round_streak: {
131 | name: 'Current Consecutive Single Hand Round Wins',
132 | value: 0,
133 | perGame: false,
134 | color: 'white',
135 | type: 'number',
136 | },
137 | c_tarot_reading_used: {
138 | name: 'Tarot Packs Used',
139 | value: 0,
140 | perGame: true,
141 | color: 'Tarot',
142 | type: 'number',
143 | },
144 | c_tarots_bought: {
145 | name: 'Tarot Cards Purchased',
146 | value: 0,
147 | perGame: true,
148 | color: 'Tarot',
149 | type: 'number',
150 | },
151 | c_vouchers_bought: {
152 | name: 'Vouchers Redeemed',
153 | value: 0,
154 | perGame: true,
155 | color: 'Voucher',
156 | type: 'number',
157 | },
158 | c_wins: {
159 | name: 'Wins',
160 | value: 0,
161 | perGame: false,
162 | color: 'blue',
163 | type: 'number',
164 | },
165 | win_percentage: {
166 | name: 'Win %',
167 | value: 0,
168 | perGame: false,
169 | color: 'orange',
170 | type: 'percentage',
171 | },
172 | gold_stake_wins: {
173 | name: 'Gold Stake Wins',
174 | value: 0,
175 | perGame: false,
176 | color: 'blue',
177 | type: 'number',
178 | },
179 | gold_stake_losses: {
180 | name: 'Gold Stake Losses',
181 | value: 0,
182 | perGame: false,
183 | color: 'red',
184 | type: 'number',
185 | },
186 | gold_stake_win_percentage: {
187 | name: 'Gold Stake Win %',
188 | value: 0,
189 | perGame: false,
190 | color: 'orange',
191 | type: 'percentage',
192 | },
193 | total_tarots_used: {
194 | name: 'Tarot Cards Used',
195 | value: 0,
196 | perGame: true,
197 | color: 'Tarot',
198 | type: 'number',
199 | },
200 | total_planets_used: {
201 | name: 'Planet Cards Used',
202 | value: 0,
203 | perGame: true,
204 | color: 'Planet',
205 | type: 'number',
206 | },
207 | total_spectrals_used: {
208 | name: 'Spectral Cards Used',
209 | value: 0,
210 | perGame: true,
211 | color: 'Spectral',
212 | type: 'number',
213 | },
214 | total_consumables_used: {
215 | name: 'Consumables Used',
216 | value: 0,
217 | perGame: true,
218 | color: 'White',
219 | type: 'number',
220 | },
221 | }
222 |
223 | const sections = {
224 | Record: ['c_wins', 'c_losses', 'win_percentage', 'c_rounds'],
225 | 'Gold Stake': [
226 | 'gold_stake_wins',
227 | 'gold_stake_losses',
228 | 'gold_stake_win_percentage',
229 | ],
230 | Cards: [
231 | 'c_cards_played',
232 | 'c_cards_discarded',
233 | 'c_face_cards_played',
234 | 'c_hands_played',
235 | ],
236 | Economy: [
237 | 'c_dollars_earned',
238 | 'c_shop_dollars_spent',
239 | 'c_playing_cards_bought',
240 | 'c_cards_sold',
241 | 'c_jokers_sold',
242 | 'c_shop_rerolls',
243 | ],
244 | Consumables: [
245 | 'c_tarot_reading_used',
246 | 'c_tarots_bought',
247 | 'total_tarots_used',
248 | 'c_planetarium_used',
249 | 'c_planets_bought',
250 | 'total_planets_used',
251 | 'total_spectrals_used',
252 | 'total_consumables_used',
253 | 'c_vouchers_bought',
254 | ],
255 | Streaks: ['c_round_interest_cap_streak', 'c_single_hand_round_streak'],
256 | }
257 |
258 | Object.keys(stats)
259 | .slice(0, 20)
260 | .forEach((key) => {
261 | stats[key].value = profile.career_stats[key]
262 | })
263 |
264 | const goldStakeWins = Object.values(profile.deck_usage)
265 | .map((deck) => deck.wins?.['8'] ?? 0)
266 | .reduce((sum, x) => sum + x, 0)
267 | const goldStakeLosses = Object.values(profile.deck_usage)
268 | .map((deck) => deck.losses?.['8'] ?? 0)
269 | .reduce((sum, x) => sum + x, 0)
270 | const goldStakeWinPercentage =
271 | Math.round(
272 | (goldStakeWins / (goldStakeWins + goldStakeLosses || 1)) * 10000,
273 | ) / 100
274 |
275 | stats['gold_stake_wins'].value = goldStakeWins
276 | stats['gold_stake_losses'].value = goldStakeLosses
277 | stats['gold_stake_win_percentage'].value = goldStakeWinPercentage
278 |
279 | stats['total_tarots_used'].value = Object.entries(
280 | profile.consumeable_usage,
281 | ).reduce((sum, [key, { count }]) => {
282 | if (tarotKeys.includes(key)) {
283 | return sum + count
284 | }
285 | return sum
286 | }, 0)
287 |
288 | stats['total_planets_used'].value = Object.entries(
289 | profile.consumeable_usage,
290 | ).reduce((sum, [key, { count }]) => {
291 | if (planetKeys.includes(key)) {
292 | return sum + count
293 | }
294 | return sum
295 | }, 0)
296 |
297 | stats['total_spectrals_used'].value = Object.entries(
298 | profile.consumeable_usage,
299 | ).reduce((sum, [key, { count }]) => {
300 | if (spectralKeys.includes(key)) {
301 | return sum + count
302 | }
303 | return sum
304 | }, 0)
305 |
306 | stats['total_consumables_used'].value = Object.values(
307 | profile.consumeable_usage,
308 | ).reduce((sum, { count }) => {
309 | return sum + count
310 | }, 0)
311 |
312 | const gamesPlayed =
313 | profile.career_stats['c_wins'] + profile.career_stats['c_losses'] || 1
314 | const winPercentage =
315 | Math.round((profile.career_stats['c_wins'] / gamesPlayed) * 10000) / 100
316 | stats['win_percentage'].value = winPercentage
317 |
318 | return (
319 |
320 | {Object.entries(sections).map(([section, keys]) => {
321 | return (
322 |
323 |
{section}
324 |
325 | {keys.map((key) => {
326 | const value = (() => {
327 | switch (stats[key].type) {
328 | default:
329 | case 'number':
330 | return numberWithCommas(stats[key].value)
331 | case 'money':
332 | return `$${numberWithCommas(stats[key].value)}`
333 | case 'percentage':
334 | return `${stats[key].value}%`
335 | }
336 | })()
337 | return (
338 |
349 | )
350 | })}
351 |
352 | )
353 | })}
354 |
355 | )
356 | }
357 |
--------------------------------------------------------------------------------
/src/components/sections/records/HandStats.tsx:
--------------------------------------------------------------------------------
1 | import styles from './records.module.css'
2 | import { Profile } from '@/lib/types'
3 | import { useState } from 'react'
4 | import InputContainer from '@/components/inputs/InputContainer'
5 | import HandStat from '@/components/stats/HandStat'
6 | import Select from '@/components/inputs/Select'
7 |
8 | export default function HandStats({ profile }: { profile: Profile }) {
9 | interface HandType {
10 | name: string
11 | level: number
12 | usage: number
13 | chips: number
14 | mult: number
15 | pchips: number
16 | pmult: number
17 | }
18 |
19 | const hands: Record = {
20 | HighCard: {
21 | name: 'High Card',
22 | level: 1,
23 | usage: 0,
24 |
25 | chips: 5,
26 | mult: 1,
27 | pchips: 10,
28 | pmult: 1,
29 | },
30 | Pair: {
31 | name: 'Pair',
32 | level: 1,
33 | usage: 0,
34 |
35 | chips: 10,
36 | mult: 2,
37 | pchips: 15,
38 | pmult: 1,
39 | },
40 | TwoPair: {
41 | name: 'Two Pair',
42 | level: 1,
43 | usage: 0,
44 |
45 | chips: 20,
46 | mult: 2,
47 | pchips: 20,
48 | pmult: 1,
49 | },
50 | ThreeofaKind: {
51 | name: 'Three of a Kind',
52 | level: 1,
53 | usage: 0,
54 |
55 | chips: 30,
56 | mult: 3,
57 | pchips: 20,
58 | pmult: 2,
59 | },
60 | Straight: {
61 | name: 'Straight',
62 | level: 1,
63 | usage: 0,
64 |
65 | chips: 30,
66 | mult: 4,
67 | pchips: 30,
68 | pmult: 3,
69 | },
70 | Flush: {
71 | name: 'Flush',
72 | level: 1,
73 | usage: 0,
74 |
75 | chips: 35,
76 | mult: 4,
77 | pchips: 15,
78 | pmult: 2,
79 | },
80 | FullHouse: {
81 | name: 'Full House',
82 | level: 1,
83 | usage: 0,
84 |
85 | chips: 40,
86 | mult: 4,
87 | pchips: 25,
88 | pmult: 2,
89 | },
90 | FourofaKind: {
91 | name: 'Four of a Kind',
92 | level: 1,
93 | usage: 0,
94 |
95 | chips: 60,
96 | mult: 7,
97 | pchips: 30,
98 | pmult: 3,
99 | },
100 | StraightFlush: {
101 | name: 'Straight Flush',
102 | level: 1,
103 | usage: 0,
104 |
105 | chips: 100,
106 | mult: 8,
107 | pchips: 40,
108 | pmult: 4,
109 | },
110 | FiveofaKind: {
111 | name: 'Five of a Kind',
112 | level: 1,
113 | usage: 0,
114 |
115 | chips: 120,
116 | mult: 12,
117 | pchips: 35,
118 | pmult: 3,
119 | },
120 | FlushHouse: {
121 | name: 'Flush House',
122 | level: 1,
123 | usage: 0,
124 |
125 | chips: 140,
126 | mult: 14,
127 | pchips: 40,
128 | pmult: 4,
129 | },
130 | FlushFive: {
131 | name: 'Flush Five',
132 | level: 1,
133 | usage: 0,
134 |
135 | chips: 160,
136 | mult: 16,
137 | pchips: 50,
138 | pmult: 3,
139 | },
140 | }
141 |
142 | const planetsToHands = {
143 | c_pluto: 'HighCard',
144 | c_mercury: 'Pair',
145 | c_uranus: 'TwoPair',
146 | c_venus: 'ThreeofaKind',
147 | c_saturn: 'Straight',
148 | c_jupiter: 'Flush',
149 | c_earth: 'FullHouse',
150 | c_mars: 'FourofaKind',
151 | c_neptune: 'StraightFlush',
152 | c_planet_x: 'FiveofaKind',
153 | c_ceres: 'FlushHouse',
154 | c_eris: 'FlushFive',
155 | } as const
156 |
157 | // Update use count
158 | Object.entries(profile.hand_usage).forEach(([key, { count }]) => {
159 | hands[key].usage = count
160 | })
161 |
162 | // Update levels
163 | if (profile.consumeable_usage?.c_black_hole) {
164 | Object.keys(hands).forEach((hand) => {
165 | hands[hand].level += profile.consumeable_usage.c_black_hole.count
166 | })
167 | }
168 |
169 | for (const [planet, hand] of Object.entries(planetsToHands)) {
170 | if (profile.consumeable_usage?.[planet]) {
171 | hands[hand].level += profile.consumeable_usage[planet].count
172 | }
173 | }
174 |
175 | const calculateTotal = (base: number, plus: number, level: number) => {
176 | return base + plus * (level - 1)
177 | }
178 |
179 | const sortingOptions: Record number> = {
180 | Rank: () => 0,
181 | Level: (a: HandType, b: HandType) => a.level - b.level,
182 | Chips: (a: HandType, b: HandType) =>
183 | calculateTotal(a.chips, a.pchips, a.level) -
184 | calculateTotal(b.chips, b.pchips, b.level),
185 | Mult: (a: HandType, b: HandType) =>
186 | calculateTotal(a.mult, a.pmult, a.level) -
187 | calculateTotal(b.mult, b.pmult, b.level),
188 | Score: (a: HandType, b: HandType) =>
189 | calculateTotal(a.chips, a.pchips, a.level) +
190 | calculateTotal(a.mult, a.pmult, a.level) -
191 | calculateTotal(b.chips, b.pchips, b.level) -
192 | calculateTotal(b.mult, b.pmult, b.level),
193 | Usage: (a: HandType, b: HandType) => a.usage - b.usage,
194 | }
195 |
196 | const [sort, setSort] = useState(Object.keys(sortingOptions)[0])
197 | const sortedHands = Object.values(hands).toSorted(sortingOptions[sort])
198 |
199 | return (
200 |
201 |
202 |
208 |
209 |
210 | {sortedHands.toReversed().map((hand) => {
211 | return (
212 |
220 | )
221 | })}
222 |
223 | )
224 | }
225 |
--------------------------------------------------------------------------------
/src/components/sections/records/HighScores.tsx:
--------------------------------------------------------------------------------
1 | import styles from './records.module.css'
2 | import { Profile } from '@/lib/types'
3 | import Stat from '@/components/stats/Stat'
4 | import Progress from '@/components/stats/Progress'
5 | import { numberWithCommas } from '@/lib/utils'
6 |
7 | function getMostPlayedHand(
8 | hands: Record,
9 | ) {
10 | let highestHand = 'None'
11 | let highestCount = 0
12 | Object.values(hands).map((hand) => {
13 | if (hand.count > highestCount) {
14 | highestHand = hand.order
15 | highestCount = hand.count
16 | }
17 | })
18 | return [highestHand, highestCount]
19 | }
20 |
21 | function formatScore(score: number) {
22 | const scoreString = score.toString()
23 | if (scoreString.includes('e')) {
24 | return score.toPrecision(4).toString().replace('e+', 'e')
25 | } else {
26 | return numberWithCommas(score)
27 | }
28 | }
29 |
30 | export default function HighScores({ profile }: { profile: Profile }) {
31 | const [hand, count] = getMostPlayedHand(profile.hand_usage)
32 | const keys: Record<
33 | string,
34 | {
35 | name: string
36 | value: number | string
37 | extra?: number | string
38 | color?: string
39 | }
40 | > = {
41 | hand: {
42 | name: profile.high_scores['hand'].label,
43 | value: formatScore(profile.high_scores['hand'].amt),
44 | color: 'red',
45 | },
46 | furthest_round: {
47 | name: profile.high_scores['furthest_round'].label,
48 | value: profile.high_scores['furthest_round'].amt,
49 | color: 'orange',
50 | },
51 | furthest_ante: {
52 | name: profile.high_scores['furthest_ante'].label,
53 | value: profile.high_scores['furthest_ante'].amt,
54 | color: 'orange',
55 | },
56 | poker_hand: {
57 | name: profile.high_scores['poker_hand'].label,
58 | value: hand,
59 | extra: count,
60 | },
61 | most_money: {
62 | name: profile.high_scores['most_money'].label,
63 | value: `$${numberWithCommas(profile.high_scores['most_money'].amt)}`,
64 | color: 'yellow',
65 | },
66 | win_streak: {
67 | name: profile.high_scores['win_streak'].label,
68 | value: numberWithCommas(profile.high_scores['win_streak'].amt),
69 | extra: profile.high_scores['current_streak'].amt,
70 | },
71 | }
72 |
73 | return (
74 |
75 |
79 | {Object.keys(keys).map((key) => {
80 | return (
81 |
88 | )
89 | })}
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/sections/records/Records.tsx:
--------------------------------------------------------------------------------
1 | import styles from './records.module.css'
2 | import { Profile } from '@/lib/types'
3 | import { useContext, useState } from 'react'
4 | import Button from '@/components/inputs/Button'
5 | import HighScores from '@/components/sections/records/HighScores'
6 | import CareerStats from '@/components/sections/records/CareerStats'
7 | import HandStats from '@/components/sections/records/HandStats'
8 | import { useToImage } from '@/lib/utils'
9 | import { settingsContext } from '@/lib/context'
10 |
11 | export default function Records({ profile }: { profile: Profile }) {
12 | const settings = useContext(settingsContext)
13 | const tabs = ['High Scores', 'Career Stats', 'Hand Stats']
14 | const [tab, setTab] = useState(tabs[0])
15 | const [ref, savePNG] = useToImage(
16 | tab.toLowerCase().replace(' ', '_'),
17 | settings.saveImageinNewTab.enabled,
18 | )
19 |
20 | const insides = (() => {
21 | switch (tab) {
22 | case 'High Scores':
23 | return
24 | case 'Career Stats':
25 | return
26 | case 'Hand Stats':
27 | return
28 | }
29 | })()
30 |
31 | return (
32 |
33 |
34 |
35 | {tabs.map((tabName, i) => {
36 | return (
37 | {
43 | setTab(tabs[i])
44 | }}
45 | />
46 | )
47 | })}
48 |
49 | {insides}
50 |
51 |
52 |
57 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/sections/records/records.module.css:
--------------------------------------------------------------------------------
1 | .records {
2 | background-color: #3a5055;
3 | box-shadow: 0 var(--shadow-length) 0 #777e89;
4 | border-width: var(--shadow-length);
5 | border-radius: 0.5rem;
6 | border-color: #b9c2d2;
7 | padding: 0 0.5rem;
8 | }
9 |
10 | .tabs {
11 | display: grid;
12 | grid-template-columns: repeat(3, minmax(0, auto));
13 | justify-content: center;
14 | gap: 0.5rem;
15 | padding: 1.5rem 0;
16 | }
17 |
18 | .highScores {
19 | padding-bottom: 0.5rem;
20 | }
21 |
22 | .careerStats {
23 | display: grid;
24 | gap: 0.5rem;
25 | padding-bottom: 0.5rem;
26 | }
27 |
28 | .careerStatsLabel {
29 | font-size: 125%;
30 | line-height: 1.5;
31 | text-align: center;
32 | }
33 |
34 | .handStats {
35 | padding-bottom: 0.5rem;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/stats/HandStat.tsx:
--------------------------------------------------------------------------------
1 | import styles from './stat.module.css'
2 | import { soundContext } from '@/lib/context'
3 | import { useContext } from 'react'
4 |
5 | export default function HandStat({
6 | hand,
7 | level,
8 | chips,
9 | mult,
10 | usage,
11 | }: {
12 | hand: string
13 | level: number
14 | chips: number
15 | mult: number
16 | usage: number
17 | }) {
18 | const sounds = useContext(soundContext)
19 | const play = sounds['cardSound']
20 | const levelColor = (() => {
21 | switch (level) {
22 | case 1:
23 | return '#f0f0f0'
24 | case 2:
25 | return '#89a4ff'
26 | case 3:
27 | return '#53f0a7'
28 | case 4:
29 | return '#fde26f'
30 | case 5:
31 | return '#ffba3d'
32 | case 6:
33 | return '#fa6e65'
34 | default:
35 | return '#c696f0'
36 | }
37 | })()
38 |
39 | return (
40 | play()}>
41 |
47 | lvl.{level}
48 |
49 |
50 |
{hand}
51 |
52 |
53 |
54 |
{chips}
X
55 |
{mult}
56 |
57 |
58 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/stats/Progress.tsx:
--------------------------------------------------------------------------------
1 | import styles from './stat.module.css'
2 | import Stat from './Stat'
3 |
4 | export default function Progress({
5 | progress,
6 | allUnlocked,
7 | }: {
8 | progress: any
9 | allUnlocked: boolean
10 | }) {
11 | const progressValue = Math.floor(
12 | (progress.overall_tally / progress.overall_of) * 100,
13 | )
14 | const keys = ['discovered', 'challenges', 'joker_stickers', 'deck_stakes']
15 | const keysToEnglish: Record = {
16 | discovered: 'Collection',
17 | challenges: 'Challenges',
18 | joker_stickers: 'Joker Stickers',
19 | deck_stakes: 'Deck Stake Wins',
20 | }
21 | return (
22 |
23 |
31 | {keys.map((key) => {
32 | const percentage = Math.floor(
33 | (progress[key].tally / progress[key].of) * 100,
34 | )
35 | return (
36 |
46 | )
47 | })}
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/stats/Stat.tsx:
--------------------------------------------------------------------------------
1 | import styles from './stat.module.css'
2 |
3 | export default function Stat({
4 | name,
5 | value,
6 | extra,
7 | color,
8 | bar,
9 | }: {
10 | name: string
11 | value: string | number
12 | color?: string
13 | extra?: string | number
14 | bar?: { percentage: number; allUnlocked: boolean }
15 | }) {
16 | return (
17 |
24 |
{name}
25 |
26 |
32 |
33 |
42 |
43 |
49 | {name == 'Best Hand' && (
50 |
58 | )}
59 | {value}{' '}
60 |
61 | {extra !== undefined ? `(${extra})` : ''}
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/stats/stat.module.css:
--------------------------------------------------------------------------------
1 | /* Progress */
2 | .progress {
3 | background-color: #1e2b2d;
4 | box-shadow: 0 var(--shadow-length) #1b1415;
5 |
6 | margin-bottom: 0.5rem;
7 | border-radius: 0.5rem;
8 | padding: 0.25rem;
9 |
10 | height: fit-content;
11 | }
12 |
13 | .progress .stat:first-child,
14 | .progress .stat:last-child {
15 | margin: 0;
16 | }
17 |
18 | /* Stat */
19 | .stat {
20 | display: grid;
21 | grid-template-columns: repeat(2, minmax(0, 1fr));
22 | text-align: center;
23 | align-items: center;
24 | background-color: #a3acb9;
25 | padding: 0.125rem;
26 |
27 | border-radius: 0.375rem;
28 |
29 | margin-bottom: 0.25rem;
30 | box-shadow: 0 var(--shadow-length) #686e78;
31 | }
32 |
33 | .statBox {
34 | background-color: #1b1415;
35 | display: grid;
36 | align-items: center;
37 |
38 | border-radius: 0.3125rem;
39 | overflow: clip;
40 | overflow-clip-margin: content-box;
41 | padding-bottom: var(--shadow-length);
42 | }
43 |
44 | .statBackground {
45 | background-color: #1e2b2d;
46 | grid-row-start: 1;
47 | grid-column-start: 1;
48 | }
49 |
50 | .statBar {
51 | grid-row-start: 1;
52 | grid-column-start: 1;
53 | }
54 |
55 | .statValue {
56 | display: flex;
57 | justify-content: center;
58 | align-items: center;
59 | gap: 0.25rem;
60 | grid-row-start: 1;
61 | grid-column-start: 1;
62 | font-size: 110%;
63 | line-height: 1.25;
64 | }
65 |
66 | .statExtra {
67 | color: #b9c2d2;
68 | font-size: 75%;
69 | }
70 |
71 | /* Hand Stats */
72 | .handStat {
73 | background-color: #a3acb9;
74 | box-shadow: 0 var(--shadow-length) #686e78;
75 | margin: 0.25rem 0;
76 |
77 | display: flex;
78 | justify-content: space-between;
79 | align-items: center;
80 |
81 | padding: 0.125rem;
82 | border-radius: 0.625rem;
83 | }
84 |
85 | .handStat:hover {
86 | background-color: #626770;
87 | box-shadow: 0 var(--shadow-length) #40454c;
88 | }
89 |
90 | .handLevel {
91 | color: #3a5055;
92 |
93 | text-shadow: none;
94 |
95 | border-width: 0.125rem;
96 | border-color: white;
97 | border-radius: 0.625rem;
98 |
99 | text-align: center;
100 | min-width: 3rem;
101 | padding: 0 0.5rem;
102 | width: fit-content;
103 | }
104 |
105 | .handName {
106 | text-align: center;
107 | }
108 |
109 | .handRight {
110 | display: flex;
111 | }
112 |
113 | .handScoring {
114 | color: var(--red);
115 | background-color: #1e2b2d;
116 | text-shadow: none;
117 |
118 | border-width: 0.125rem;
119 | border-color: #1e2b2d;
120 | border-radius: 0.625rem;
121 |
122 | display: flex;
123 | }
124 |
125 | .handChips {
126 | color: white;
127 | background-color: var(--blue);
128 | border-radius: 0.5rem;
129 |
130 | text-align: right;
131 | padding-right: 0.25rem;
132 | margin-right: 0.0625rem;
133 | min-width: 3rem;
134 | width: fit-content;
135 | }
136 |
137 | .handMult {
138 | color: white;
139 | background-color: var(--red);
140 | border-radius: 0.5rem;
141 |
142 | padding-left: 0.25rem;
143 | min-width: 3rem;
144 | width: fit-content;
145 | }
146 |
147 | .handUsageContainer {
148 | padding-left: 1rem;
149 | display: flex;
150 | align-items: center;
151 | }
152 |
153 | .handUsage {
154 | color: var(--orange);
155 | background-color: #3a5055;
156 |
157 | text-align: center;
158 |
159 | border-width: 0.125rem;
160 | border-color: #3a5055;
161 | border-radius: 0.625rem;
162 |
163 | margin-left: 0.125rem;
164 | min-width: 3rem;
165 | padding: 0 0.5rem;
166 | width: fit-content;
167 | }
168 |
--------------------------------------------------------------------------------
/src/lib/cards/cardKeys.ts:
--------------------------------------------------------------------------------
1 | export const jokerKeys = [
2 | 'j_joker',
3 | 'j_greedy_joker',
4 | 'j_lusty_joker',
5 | 'j_wrathful_joker',
6 | 'j_gluttenous_joker',
7 | 'j_jolly',
8 | 'j_zany',
9 | 'j_mad',
10 | 'j_crazy',
11 | 'j_droll',
12 | 'j_sly',
13 | 'j_wily',
14 | 'j_clever',
15 | 'j_devious',
16 | 'j_crafty',
17 | 'j_half',
18 | 'j_stencil',
19 | 'j_four_fingers',
20 | 'j_mime',
21 | 'j_credit_card',
22 | 'j_ceremonial',
23 | 'j_banner',
24 | 'j_mystic_summit',
25 | 'j_marble',
26 | 'j_loyalty_card',
27 | 'j_8_ball',
28 | 'j_misprint',
29 | 'j_dusk',
30 | 'j_raised_fist',
31 | 'j_chaos',
32 | 'j_fibonacci',
33 | 'j_steel_joker',
34 | 'j_scary_face',
35 | 'j_abstract',
36 | 'j_delayed_grat',
37 | 'j_hack',
38 | 'j_pareidolia',
39 | 'j_gros_michel',
40 | 'j_even_steven',
41 | 'j_odd_todd',
42 | 'j_scholar',
43 | 'j_business',
44 | 'j_supernova',
45 | 'j_ride_the_bus',
46 | 'j_space',
47 | 'j_egg',
48 | 'j_burglar',
49 | 'j_blackboard',
50 | 'j_runner',
51 | 'j_ice_cream',
52 | 'j_dna',
53 | 'j_splash',
54 | 'j_blue_joker',
55 | 'j_sixth_sense',
56 | 'j_constellation',
57 | 'j_hiker',
58 | 'j_faceless',
59 | 'j_green_joker',
60 | 'j_superposition',
61 | 'j_todo_list',
62 | 'j_cavendish',
63 | 'j_card_sharp',
64 | 'j_red_card',
65 | 'j_madness',
66 | 'j_square',
67 | 'j_seance',
68 | 'j_riff_raff',
69 | 'j_vampire',
70 | 'j_shortcut',
71 | 'j_hologram',
72 | 'j_vagabond',
73 | 'j_baron',
74 | 'j_cloud_9',
75 | 'j_rocket',
76 | 'j_obelisk',
77 | 'j_midas_mask',
78 | 'j_luchador',
79 | 'j_photograph',
80 | 'j_gift',
81 | 'j_turtle_bean',
82 | 'j_erosion',
83 | 'j_reserved_parking',
84 | 'j_mail',
85 | 'j_to_the_moon',
86 | 'j_hallucination',
87 | 'j_fortune_teller',
88 | 'j_juggler',
89 | 'j_drunkard',
90 | 'j_stone',
91 | 'j_golden',
92 | 'j_lucky_cat',
93 | 'j_baseball',
94 | 'j_bull',
95 | 'j_diet_cola',
96 | 'j_trading',
97 | 'j_flash',
98 | 'j_popcorn',
99 | 'j_trousers',
100 | 'j_ancient',
101 | 'j_ramen',
102 | 'j_walkie_talkie',
103 | 'j_selzer',
104 | 'j_castle',
105 | 'j_smiley',
106 | 'j_campfire',
107 | 'j_ticket',
108 | 'j_mr_bones',
109 | 'j_acrobat',
110 | 'j_sock_and_buskin',
111 | 'j_swashbuckler',
112 | 'j_troubadour',
113 | 'j_certificate',
114 | 'j_smeared',
115 | 'j_throwback',
116 | 'j_hanging_chad',
117 | 'j_rough_gem',
118 | 'j_bloodstone',
119 | 'j_arrowhead',
120 | 'j_onyx_agate',
121 | 'j_glass',
122 | 'j_ring_master',
123 | 'j_flower_pot',
124 | 'j_blueprint',
125 | 'j_wee',
126 | 'j_merry_andy',
127 | 'j_oops',
128 | 'j_idol',
129 | 'j_seeing_double',
130 | 'j_matador',
131 | 'j_hit_the_road',
132 | 'j_duo',
133 | 'j_trio',
134 | 'j_family',
135 | 'j_order',
136 | 'j_tribe',
137 | 'j_stuntman',
138 | 'j_invisible',
139 | 'j_brainstorm',
140 | 'j_satellite',
141 | 'j_shoot_the_moon',
142 | 'j_drivers_license',
143 | 'j_cartomancer',
144 | 'j_astronomer',
145 | 'j_burnt',
146 | 'j_bootstraps',
147 | 'j_caino',
148 | 'j_triboulet',
149 | 'j_yorick',
150 | 'j_chicot',
151 | 'j_perkeo',
152 | ]
153 |
154 | export const deckKeys = [
155 | 'b_red',
156 | 'b_blue',
157 | 'b_yellow',
158 | 'b_green',
159 | 'b_black',
160 | 'b_magic',
161 | 'b_nebula',
162 | 'b_ghost',
163 | 'b_abandoned',
164 | 'b_checkered',
165 | 'b_zodiac',
166 | 'b_painted',
167 | 'b_anaglyph',
168 | 'b_plasma',
169 | 'b_erratic',
170 | ]
171 |
172 | export const tarotKeys = [
173 | 'c_fool',
174 | 'c_magician',
175 | 'c_high_priestess',
176 | 'c_empress',
177 | 'c_emperor',
178 | 'c_heirophant',
179 | 'c_lovers',
180 | 'c_chariot',
181 | 'c_justice',
182 | 'c_hermit',
183 | 'c_wheel_of_fortune',
184 | 'c_strength',
185 | 'c_hanged_man',
186 | 'c_death',
187 | 'c_temperance',
188 | 'c_devil',
189 | 'c_tower',
190 | 'c_star',
191 | 'c_moon',
192 | 'c_sun',
193 | 'c_judgement',
194 | 'c_world',
195 | ]
196 |
197 | export const planetKeys = [
198 | 'c_mercury',
199 | 'c_venus',
200 | 'c_earth',
201 | 'c_mars',
202 | 'c_jupiter',
203 | 'c_saturn',
204 | 'c_uranus',
205 | 'c_neptune',
206 | 'c_pluto',
207 | 'c_planet_x',
208 | 'c_ceres',
209 | 'c_eris',
210 | ]
211 |
212 | export const spectralKeys = [
213 | 'c_familiar',
214 | 'c_grim',
215 | 'c_incantation',
216 | 'c_talisman',
217 | 'c_aura',
218 | 'c_wraith',
219 | 'c_sigil',
220 | 'c_ouija',
221 | 'c_ectoplasm',
222 | 'c_immolate',
223 | 'c_ankh',
224 | 'c_deja_vu',
225 | 'c_hex',
226 | 'c_trance',
227 | 'c_medium',
228 | 'c_cryptid',
229 | 'c_soul',
230 | 'c_black_hole',
231 | ]
232 |
233 | export const consumableKeys = tarotKeys.concat(planetKeys, spectralKeys)
234 |
235 | export const baseVoucherNames = [
236 | 'Overstock',
237 | 'Clearance Sale',
238 | 'Hone',
239 | 'Reroll Surplus',
240 | 'Crystal Ball',
241 | 'Telescope',
242 | 'Grabber',
243 | 'Wasteful',
244 | 'Tarot Merchant',
245 | 'Planet Merchant',
246 | 'Seed Money',
247 | 'Blank',
248 | 'Magic Trick',
249 | 'Hieroglpyh',
250 | "Director's Cut",
251 | 'Paint Brush',
252 | ]
253 |
254 | export const upgradedVoucherNames = [
255 | 'Overstock Plus',
256 | 'Liquidation',
257 | 'Glow Up',
258 | 'Reroll Glut',
259 | 'Omen Globe',
260 | 'Observatory',
261 | 'Nacho Tong',
262 | 'Recyclomancy',
263 | 'Tarot Tycoon',
264 | 'Planet Tycoon',
265 | 'Money Tree',
266 | 'Antimatter',
267 | 'Illusion',
268 | 'Petroglpyh',
269 | 'Retcon',
270 | 'Palette',
271 | ]
272 |
273 | export const voucherKeys = [
274 | 'v_overstock_norm',
275 | 'v_overstock_plus',
276 | 'v_clearance_sale',
277 | 'v_liquidation',
278 | 'v_hone',
279 | 'v_glow_up',
280 | 'v_reroll_surplus',
281 | 'v_reroll_glut',
282 | 'v_crystal_ball',
283 | 'v_omen_globe',
284 | 'v_telescope',
285 | 'v_observatory',
286 | 'v_grabber',
287 | 'v_nacho_tong',
288 | 'v_wasteful',
289 | 'v_recyclomancy',
290 | 'v_tarot_merchant',
291 | 'v_tarot_tycoon',
292 | 'v_planet_merchant',
293 | 'v_planet_tycoon',
294 | 'v_seed_money',
295 | 'v_money_tree',
296 | 'v_blank',
297 | 'v_antimatter',
298 | 'v_magic_trick',
299 | 'v_illusion',
300 | 'v_hieroglyph',
301 | 'v_petroglyph',
302 | 'v_directors_cut',
303 | 'v_retcon',
304 | 'v_paint_brush',
305 | 'v_palette',
306 | ]
307 |
--------------------------------------------------------------------------------
/src/lib/cards/cardMappings.ts:
--------------------------------------------------------------------------------
1 | export const jokerNames: Record = {
2 | j_joker: 'Joker',
3 | j_greedy_joker: 'Greedy Joker',
4 | j_lusty_joker: 'Lusty Joker',
5 | j_wrathful_joker: 'Wrathful Joker',
6 | j_gluttenous_joker: 'Gluttonous Joker',
7 | j_jolly: 'Jolly Joker',
8 | j_zany: 'Zany Joker',
9 | j_mad: 'Mad Joker',
10 | j_crazy: 'Crazy Joker',
11 | j_droll: 'Droll Joker',
12 | j_sly: 'Sly Joker',
13 | j_wily: 'Wily Joker',
14 | j_clever: 'Clever Joker',
15 | j_devious: 'Devious Joker',
16 | j_crafty: 'Crafty Joker',
17 | j_half: 'Half Joker',
18 | j_stencil: 'Joker Stencil',
19 | j_four_fingers: 'Four Fingers',
20 | j_mime: 'Mime',
21 | j_credit_card: 'Credit Card',
22 | j_ceremonial: 'Ceremonial Dagger',
23 | j_banner: 'Banner',
24 | j_mystic_summit: 'Mystic Summit',
25 | j_marble: 'Marble Joker',
26 | j_loyalty_card: 'Loyalty Card',
27 | j_8_ball: '8 Ball',
28 | j_misprint: 'Misprint',
29 | j_dusk: 'Dusk',
30 | j_raised_fist: 'Raised Fist',
31 | j_chaos: 'Chaos the Clown',
32 | j_fibonacci: 'Fibonacci',
33 | j_steel_joker: 'Steel Joker',
34 | j_scary_face: 'Scary Face',
35 | j_abstract: 'Abstract Joker',
36 | j_delayed_grat: 'Delayed Gratification',
37 | j_hack: 'Hack',
38 | j_pareidolia: 'Pareidolia',
39 | j_gros_michel: 'Gros Michel',
40 | j_even_steven: 'Even Steven',
41 | j_odd_todd: 'Odd Todd',
42 | j_scholar: 'Scholar',
43 | j_business: 'Business Card',
44 | j_supernova: 'Supernova',
45 | j_ride_the_bus: 'Ride the Bus',
46 | j_space: 'Space Joker',
47 | j_egg: 'Egg',
48 | j_burglar: 'Burglar',
49 | j_blackboard: 'Blackboard',
50 | j_runner: 'Runner',
51 | j_ice_cream: 'Ice Cream',
52 | j_dna: 'DNA',
53 | j_splash: 'Splash',
54 | j_blue_joker: 'Blue Joker',
55 | j_sixth_sense: 'Sixth Sense',
56 | j_constellation: 'Constellation',
57 | j_hiker: 'Hiker',
58 | j_faceless: 'Faceless Joker',
59 | j_green_joker: 'Green Joker',
60 | j_superposition: 'Superposition',
61 | j_todo_list: 'To Do List',
62 | j_cavendish: 'Cavendish',
63 | j_card_sharp: 'Card Sharp',
64 | j_red_card: 'Red Card',
65 | j_madness: 'Madness',
66 | j_square: 'Square Joker',
67 | j_seance: 'Séance',
68 | j_riff_raff: 'Riff-Raff',
69 | j_vampire: 'Vampire',
70 | j_shortcut: 'Shortcut',
71 | j_hologram: 'Hologram',
72 | j_vagabond: 'Vagabond',
73 | j_baron: 'Baron',
74 | j_cloud_9: 'Cloud 9',
75 | j_rocket: 'Rocket',
76 | j_obelisk: 'Obelisk',
77 | j_midas_mask: 'Midas Mask',
78 | j_luchador: 'Luchador',
79 | j_photograph: 'Photograph',
80 | j_gift: 'Gift Card',
81 | j_turtle_bean: 'Turtle Bean',
82 | j_erosion: 'Erosion',
83 | j_reserved_parking: 'Reserved Parking',
84 | j_mail: 'Mail-In Rebate',
85 | j_to_the_moon: 'To the Moon',
86 | j_hallucination: 'Hallucination',
87 | j_fortune_teller: 'Fortune Teller',
88 | j_juggler: 'Juggler',
89 | j_drunkard: 'Drunkard',
90 | j_stone: 'Stone Joker',
91 | j_golden: 'Golden Joker',
92 | j_lucky_cat: 'Lucky Cat',
93 | j_baseball: 'Baseball Card',
94 | j_bull: 'Bull',
95 | j_diet_cola: 'Diet Cola',
96 | j_trading: 'Trading Card',
97 | j_flash: 'Flash Card',
98 | j_popcorn: 'Popcorn',
99 | j_trousers: 'Spare Trousers',
100 | j_ancient: 'Ancient Joker',
101 | j_ramen: 'Ramen',
102 | j_walkie_talkie: 'Walkie Talkie',
103 | j_selzer: 'Seltzer',
104 | j_castle: 'Castle',
105 | j_smiley: 'Smiley Face',
106 | j_campfire: 'Campfire',
107 | j_ticket: 'Golden Ticket',
108 | j_mr_bones: 'Mr. Bones',
109 | j_acrobat: 'Acrobat',
110 | j_sock_and_buskin: 'Sock and Buskin',
111 | j_swashbuckler: 'Swashbuckler',
112 | j_troubadour: 'Troubadour',
113 | j_certificate: 'Certificate',
114 | j_smeared: 'Smeared Joker',
115 | j_throwback: 'Throwback',
116 | j_hanging_chad: 'Hanging Chad',
117 | j_rough_gem: 'Rough Gem',
118 | j_bloodstone: 'Bloodstone',
119 | j_arrowhead: 'Arrowhead',
120 | j_onyx_agate: 'Onyx Agate',
121 | j_glass: 'Glass Joker',
122 | j_ring_master: 'Showman',
123 | j_flower_pot: 'Flower Pot',
124 | j_blueprint: 'Blueprint',
125 | j_wee: 'Wee Joker',
126 | j_merry_andy: 'Merry Andy',
127 | j_oops: 'Oops! All 6s',
128 | j_idol: 'The Idol',
129 | j_seeing_double: 'Seeing Double',
130 | j_matador: 'Matador',
131 | j_hit_the_road: 'Hit the Road',
132 | j_duo: 'The Duo',
133 | j_trio: 'The Trio',
134 | j_family: 'The Family',
135 | j_order: 'The Order',
136 | j_tribe: 'The Tribe',
137 | j_stuntman: 'Stuntman',
138 | j_invisible: 'Invisible Joker',
139 | j_brainstorm: 'Brainstorm',
140 | j_satellite: 'Satellite',
141 | j_shoot_the_moon: 'Shoot the Moon',
142 | j_drivers_license: "Driver's License",
143 | j_cartomancer: 'Cartomancer',
144 | j_astronomer: 'Astronomer',
145 | j_burnt: 'Burnt Joker',
146 | j_bootstraps: 'Bootstraps',
147 | j_caino: 'Canio',
148 | j_triboulet: 'Triboulet',
149 | j_yorick: 'Yorick',
150 | j_chicot: 'Chicot',
151 | j_perkeo: 'Perkeo',
152 | }
153 |
154 | export const jokerSprites: Record = {
155 | j_joker: '0 0',
156 | j_greedy_joker: '-600% -100%',
157 | j_lusty_joker: '-700% -100%',
158 | j_wrathful_joker: '-800% -100%',
159 | j_gluttenous_joker: '-900% -100%',
160 | j_jolly: '-200% -0%',
161 | j_zany: '-300% -0%',
162 | j_mad: '-400% -0%',
163 | j_crazy: '-500% -0%',
164 | j_droll: '-600% -0%',
165 | j_sly: '-0% -1400%',
166 | j_wily: '-100% -1400%',
167 | j_clever: '-200% -1400%',
168 | j_devious: '-300% -1400%',
169 | j_crafty: '-400% -1400%',
170 | j_half: '-700% -0%',
171 | j_stencil: '-200% -500%',
172 | j_four_fingers: '-600% -600%',
173 | j_mime: '-400% -100%',
174 | j_credit_card: '-500% -100%',
175 | j_ceremonial: '-500% -500%',
176 | j_banner: '-100% -200%',
177 | j_mystic_summit: '-200% -200%',
178 | j_marble: '-300% -200%',
179 | j_loyalty_card: '-400% -200%',
180 | j_8_ball: '-0% -500%',
181 | j_misprint: '-600% -200%',
182 | j_dusk: '-400% -700%',
183 | j_raised_fist: '-800% -200%',
184 | j_chaos: '-100% -0%',
185 | j_fibonacci: '-100% -500%',
186 | j_steel_joker: '-700% -200%',
187 | j_scary_face: '-200% -300%',
188 | j_abstract: '-300% -300%',
189 | j_delayed_grat: '-400% -300%',
190 | j_hack: '-500% -200%',
191 | j_pareidolia: '-600% -300%',
192 | j_gros_michel: '-700% -600%',
193 | j_even_steven: '-800% -300%',
194 | j_odd_todd: '-900% -300%',
195 | j_scholar: '-300% -600%',
196 | j_business: '-100% -400%',
197 | j_supernova: '-200% -400%',
198 | j_ride_the_bus: '-100% -600%',
199 | j_space: '-300% -500%',
200 | j_egg: '-0% -1000%',
201 | j_burglar: '-100% -1000%',
202 | j_blackboard: '-200% -1000%',
203 | j_runner: '-300% -1000%',
204 | j_ice_cream: '-400% -1000%',
205 | j_dna: '-500% -1000%',
206 | j_splash: '-600% -1000%',
207 | j_blue_joker: '-700% -1000%',
208 | j_sixth_sense: '-800% -1000%',
209 | j_constellation: '-900% -1000%',
210 | j_hiker: '-0% -1100%',
211 | j_faceless: '-100% -1100%',
212 | j_green_joker: '-200% -1100%',
213 | j_superposition: '-300% -1100%',
214 | j_todo_list: '-400% -1100%',
215 | j_cavendish: '-500% -1100%',
216 | j_card_sharp: '-600% -1100%',
217 | j_red_card: '-700% -1100%',
218 | j_madness: '-800% -1100%',
219 | j_square: '-900% -1100%',
220 | j_seance: '-0% -1200%',
221 | j_riff_raff: '-100% -1200%',
222 | j_vampire: '-200% -1200%',
223 | j_shortcut: '-300% -1200%',
224 | j_hologram: '-400% -1200%',
225 | j_vagabond: '-500% -1200%',
226 | j_baron: '-600% -1200%',
227 | j_cloud_9: '-700% -1200%',
228 | j_rocket: '-800% -1200%',
229 | j_obelisk: '-900% -1200%',
230 | j_midas_mask: '-0% -1300%',
231 | j_luchador: '-100% -1300%',
232 | j_photograph: '-200% -1300%',
233 | j_gift: '-300% -1300%',
234 | j_turtle_bean: '-400% -1300%',
235 | j_erosion: '-500% -1300%',
236 | j_reserved_parking: '-600% -1300%',
237 | j_mail: '-700% -1300%',
238 | j_to_the_moon: '-800% -1300%',
239 | j_hallucination: '-900% -1300%',
240 | j_fortune_teller: '-700% -500%',
241 | j_juggler: '-0% -100%',
242 | j_drunkard: '-100% -100%',
243 | j_stone: '-900% -0%',
244 | j_golden: '-900% -200%',
245 | j_lucky_cat: '-500% -1400%',
246 | j_baseball: '-600% -1400%',
247 | j_bull: '-700% -1400%',
248 | j_diet_cola: '-800% -1400%',
249 | j_trading: '-900% -1400%',
250 | j_flash: '-0% -1500%',
251 | j_popcorn: '-100% -1500%',
252 | j_trousers: '-400% -1500%',
253 | j_ancient: '-700% -1500%',
254 | j_ramen: '-200% -1500%',
255 | j_walkie_talkie: '-800% -1500%',
256 | j_selzer: '-300% -1500%',
257 | j_castle: '-900% -1500%',
258 | j_smiley: '-600% -1500%',
259 | j_campfire: '-500% -1500%',
260 | j_ticket: '-500% -300%',
261 | j_mr_bones: '-300% -400%',
262 | j_acrobat: '-200% -100%',
263 | j_sock_and_buskin: '-300% -100%',
264 | j_swashbuckler: '-900% -500%',
265 | j_troubadour: '-0% -200%',
266 | j_certificate: '-800% -800%',
267 | j_smeared: '-400% -600%',
268 | j_throwback: '-500% -700%',
269 | j_hanging_chad: '-900% -600%',
270 | j_rough_gem: '-900% -700%',
271 | j_bloodstone: '-0% -800%',
272 | j_arrowhead: '-100% -800%',
273 | j_onyx_agate: '-200% -800%',
274 | j_glass: '-100% -300%',
275 | j_ring_master: '-600% -500%',
276 | j_flower_pot: '-0% -600%',
277 | j_blueprint: '-0% -300%',
278 | j_wee: '-0% -0%',
279 | j_merry_andy: '-800% -0%',
280 | j_oops: '-500% -600%',
281 | j_idol: '-600% -700%',
282 | j_seeing_double: '-400% -400%',
283 | j_matador: '-400% -500%',
284 | j_hit_the_road: '-800% -500%',
285 | j_duo: '-500% -400%',
286 | j_trio: '-600% -400%',
287 | j_family: '-700% -400%',
288 | j_order: '-800% -400%',
289 | j_tribe: '-900% -400%',
290 | j_stuntman: '-800% -600%',
291 | j_invisible: '-100% -700%',
292 | j_brainstorm: '-700% -700%',
293 | j_satellite: '-800% -700%',
294 | j_shoot_the_moon: '-200% -600%',
295 | j_drivers_license: '-0% -700%',
296 | j_cartomancer: '-700% -300%',
297 | j_astronomer: '-200% -700%',
298 | j_burnt: '-300% -700%',
299 | j_bootstraps: '-900% -800%',
300 | j_caino: '-300% -800%',
301 | j_triboulet: '-400% -800%',
302 | j_yorick: '-500% -800%',
303 | j_chicot: '-600% -800%',
304 | j_perkeo: '-700% -800%',
305 | undiscovered: '-900% -900%',
306 | }
307 |
308 | export const jokerExtraSprites: Record = {
309 | j_hologram: '-200% -900%',
310 | j_caino: '-300% -900%',
311 | j_triboulet: '-400% -900%',
312 | j_yorick: '-500% -900%',
313 | j_chicot: '-600% -900%',
314 | j_perkeo: '-700% -900%',
315 | undiscovered: '-500% -300%',
316 | }
317 |
318 | export const jokerRarity: Record = {
319 | j_joker: 'Common',
320 | j_greedy_joker: 'Common',
321 | j_lusty_joker: 'Common',
322 | j_wrathful_joker: 'Common',
323 | j_gluttenous_joker: 'Common',
324 | j_jolly: 'Common',
325 | j_zany: 'Common',
326 | j_mad: 'Common',
327 | j_crazy: 'Common',
328 | j_droll: 'Common',
329 | j_sly: 'Common',
330 | j_wily: 'Common',
331 | j_clever: 'Common',
332 | j_devious: 'Common',
333 | j_crafty: 'Common',
334 | j_half: 'Common',
335 | j_stencil: 'Uncommon',
336 | j_four_fingers: 'Uncommon',
337 | j_mime: 'Uncommon',
338 | j_credit_card: 'Common',
339 | j_ceremonial: 'Uncommon',
340 | j_banner: 'Common',
341 | j_mystic_summit: 'Common',
342 | j_marble: 'Uncommon',
343 | j_loyalty_card: 'Uncommon',
344 | j_8_ball: 'Common',
345 | j_misprint: 'Common',
346 | j_dusk: 'Uncommon',
347 | j_raised_fist: 'Common',
348 | j_chaos: 'Common',
349 | j_fibonacci: 'Uncommon',
350 | j_steel_joker: 'Uncommon',
351 | j_scary_face: 'Common',
352 | j_abstract: 'Common',
353 | j_delayed_grat: 'Common',
354 | j_hack: 'Uncommon',
355 | j_pareidolia: 'Uncommon',
356 | j_gros_michel: 'Common',
357 | j_even_steven: 'Common',
358 | j_odd_todd: 'Common',
359 | j_scholar: 'Common',
360 | j_business: 'Common',
361 | j_supernova: 'Common',
362 | j_ride_the_bus: 'Common',
363 | j_space: 'Uncommon',
364 | j_egg: 'Common',
365 | j_burglar: 'Uncommon',
366 | j_blackboard: 'Uncommon',
367 | j_runner: 'Common',
368 | j_ice_cream: 'Common',
369 | j_dna: 'Rare',
370 | j_splash: 'Common',
371 | j_blue_joker: 'Common',
372 | j_sixth_sense: 'Uncommon',
373 | j_constellation: 'Uncommon',
374 | j_hiker: 'Uncommon',
375 | j_faceless: 'Common',
376 | j_green_joker: 'Common',
377 | j_superposition: 'Common',
378 | j_todo_list: 'Common',
379 | j_cavendish: 'Common',
380 | j_card_sharp: 'Uncommon',
381 | j_red_card: 'Common',
382 | j_madness: 'Uncommon',
383 | j_square: 'Common',
384 | j_seance: 'Uncommon',
385 | j_riff_raff: 'Common',
386 | j_vampire: 'Uncommon',
387 | j_shortcut: 'Uncommon',
388 | j_hologram: 'Uncommon',
389 | j_vagabond: 'Rare',
390 | j_baron: 'Rare',
391 | j_cloud_9: 'Uncommon',
392 | j_rocket: 'Uncommon',
393 | j_obelisk: 'Rare',
394 | j_midas_mask: 'Uncommon',
395 | j_luchador: 'Uncommon',
396 | j_photograph: 'Common',
397 | j_gift: 'Uncommon',
398 | j_turtle_bean: 'Uncommon',
399 | j_erosion: 'Uncommon',
400 | j_reserved_parking: 'Common',
401 | j_mail: 'Common',
402 | j_to_the_moon: 'Uncommon',
403 | j_hallucination: 'Common',
404 | j_fortune_teller: 'Common',
405 | j_juggler: 'Common',
406 | j_drunkard: 'Common',
407 | j_stone: 'Uncommon',
408 | j_golden: 'Common',
409 | j_lucky_cat: 'Uncommon',
410 | j_baseball: 'Rare',
411 | j_bull: 'Uncommon',
412 | j_diet_cola: 'Uncommon',
413 | j_trading: 'Uncommon',
414 | j_flash: 'Uncommon',
415 | j_popcorn: 'Common',
416 | j_trousers: 'Uncommon',
417 | j_ancient: 'Rare',
418 | j_ramen: 'Uncommon',
419 | j_walkie_talkie: 'Common',
420 | j_selzer: 'Uncommon',
421 | j_castle: 'Uncommon',
422 | j_smiley: 'Common',
423 | j_campfire: 'Rare',
424 | j_ticket: 'Common',
425 | j_mr_bones: 'Uncommon',
426 | j_acrobat: 'Uncommon',
427 | j_sock_and_buskin: 'Uncommon',
428 | j_swashbuckler: 'Common',
429 | j_troubadour: 'Uncommon',
430 | j_certificate: 'Uncommon',
431 | j_smeared: 'Uncommon',
432 | j_throwback: 'Uncommon',
433 | j_hanging_chad: 'Common',
434 | j_rough_gem: 'Uncommon',
435 | j_bloodstone: 'Uncommon',
436 | j_arrowhead: 'Uncommon',
437 | j_onyx_agate: 'Uncommon',
438 | j_glass: 'Uncommon',
439 | j_ring_master: 'Uncommon',
440 | j_flower_pot: 'Uncommon',
441 | j_blueprint: 'Rare',
442 | j_wee: 'Rare',
443 | j_merry_andy: 'Uncommon',
444 | j_oops: 'Uncommon',
445 | j_idol: 'Uncommon',
446 | j_seeing_double: 'Uncommon',
447 | j_matador: 'Uncommon',
448 | j_hit_the_road: 'Rare',
449 | j_duo: 'Rare',
450 | j_trio: 'Rare',
451 | j_family: 'Rare',
452 | j_order: 'Rare',
453 | j_tribe: 'Rare',
454 | j_stuntman: 'Rare',
455 | j_invisible: 'Rare',
456 | j_brainstorm: 'Rare',
457 | j_satellite: 'Uncommon',
458 | j_shoot_the_moon: 'Common',
459 | j_drivers_license: 'Rare',
460 | j_cartomancer: 'Uncommon',
461 | j_astronomer: 'Uncommon',
462 | j_burnt: 'Rare',
463 | j_bootstraps: 'Uncommon',
464 | j_caino: 'Legendary',
465 | j_triboulet: 'Legendary',
466 | j_yorick: 'Legendary',
467 | j_chicot: 'Legendary',
468 | j_perkeo: 'Legendary',
469 | }
470 |
471 | export const deckNames: Record = {
472 | b_red: 'Red Deck',
473 | b_blue: 'Blue Deck',
474 | b_yellow: 'Yellow Deck',
475 | b_green: 'Green Deck',
476 | b_black: 'Black Deck',
477 | b_magic: 'Magic Deck',
478 | b_nebula: 'Nebula Deck',
479 | b_ghost: 'Ghost Deck',
480 | b_abandoned: 'Abandoned Deck',
481 | b_checkered: 'Checkered Deck',
482 | b_zodiac: 'Zodiac Deck',
483 | b_painted: 'Painted Deck',
484 | b_anaglyph: 'Anaglpyh Deck',
485 | b_plasma: 'Plasma Deck',
486 | b_erratic: 'Erratic Deck',
487 | }
488 |
489 | export const deckSprites: Record = {
490 | b_red: '0 0',
491 | b_blue: '0 -200%',
492 | b_yellow: '-100% -200%',
493 | b_green: '-200% -200%',
494 | b_black: '-300% -200%',
495 | b_magic: '0 -300%',
496 | b_nebula: '-300% 0',
497 | b_ghost: '-600% -200%',
498 | b_abandoned: '-300% -300%',
499 | b_checkered: '-100% -300%',
500 | b_zodiac: '-300% -400%',
501 | b_painted: '-400% -300%',
502 | b_anaglyph: '-200% -400%',
503 | b_plasma: '-400% -200%',
504 | b_erratic: '-200% -300%',
505 | locked: '-400% 0',
506 | }
507 |
508 | export const consumableNames: Record = {
509 | c_fool: 'The Fool',
510 | c_magician: 'The Magician',
511 | c_high_priestess: 'The High Priestess',
512 | c_empress: 'The Empress',
513 | c_emperor: 'The Emperor',
514 | c_heirophant: 'The Hierophant',
515 | c_lovers: 'The Lovers',
516 | c_chariot: 'The Chariot',
517 | c_justice: 'Justice',
518 | c_hermit: 'The Hermit',
519 | c_wheel_of_fortune: 'The Wheel of Fortune',
520 | c_strength: 'Strength',
521 | c_hanged_man: 'The Hanged Man',
522 | c_death: 'Death',
523 | c_temperance: 'Temperance',
524 | c_devil: 'The Devil',
525 | c_tower: 'The Tower',
526 | c_star: 'The Star',
527 | c_moon: 'The Moon',
528 | c_sun: 'The Sun',
529 | c_judgement: 'Judgement',
530 | c_world: 'The World',
531 |
532 | c_mercury: 'Mercury',
533 | c_venus: 'Venus',
534 | c_earth: 'Earth',
535 | c_mars: 'Mars',
536 | c_jupiter: 'Jupiter',
537 | c_saturn: 'Saturn',
538 | c_uranus: 'Uranus',
539 | c_neptune: 'Neptune',
540 | c_pluto: 'Pluto',
541 | c_planet_x: 'Planet X',
542 | c_ceres: 'Ceres',
543 | c_eris: 'Eris',
544 |
545 | c_familiar: 'Familiar',
546 | c_grim: 'Grim',
547 | c_incantation: 'Incantation',
548 | c_talisman: 'Talisman',
549 | c_aura: 'Aura',
550 | c_wraith: 'Wraith',
551 | c_sigil: 'Sigil',
552 | c_ouija: 'Ouija',
553 | c_ectoplasm: 'Ectoplasm',
554 | c_immolate: 'Immolate',
555 | c_ankh: 'Ankh',
556 | c_deja_vu: 'Deja Vu',
557 | c_hex: 'Hex',
558 | c_trance: 'Trance',
559 | c_medium: 'Medium',
560 | c_cryptid: 'Cryptid',
561 | c_soul: 'The Soul',
562 | c_black_hole: 'Black Hole',
563 | }
564 |
565 | export const consumableSprites: Record = {
566 | c_fool: '0 0',
567 | c_magician: '-100% 0',
568 | c_high_priestess: '-200% 0',
569 | c_empress: '-300% 0',
570 | c_emperor: '-400% 0',
571 | c_heirophant: '-500% 0',
572 | c_lovers: '-600% 0',
573 | c_chariot: '-700% 0',
574 | c_justice: '-800% 0',
575 | c_hermit: '-900% 0',
576 | c_wheel_of_fortune: '0 -100%',
577 | c_strength: '-100% -100%',
578 | c_hanged_man: '-200% -100%',
579 | c_death: '-300% -100%',
580 | c_temperance: '-400% -100%',
581 | c_devil: '-500% -100%',
582 | c_tower: '-600% -100%',
583 | c_star: '-700% -100%',
584 | c_moon: '-800% -100%',
585 | c_sun: '-900% -100%',
586 | c_judgement: '0 -200%',
587 | c_world: '-100% -200%',
588 |
589 | c_mercury: '0 -300%',
590 | c_venus: '-100% -300%',
591 | c_earth: '-200% -300%',
592 | c_mars: '-300% -300%',
593 | c_jupiter: '-400% -300%',
594 | c_saturn: '-500% -300%',
595 | c_uranus: '-600% -300%',
596 | c_neptune: '-700% -300%',
597 | c_pluto: '-800% -300%',
598 | c_planet_x: '-900% -200%',
599 | c_ceres: '-800% -200%',
600 | c_eris: '-300% -200%',
601 |
602 | c_familiar: '0 -400%',
603 | c_grim: '-100% -400%',
604 | c_incantation: '-200% -400%',
605 | c_talisman: '-300% -400%',
606 | c_aura: '-400% -400%',
607 | c_wraith: '-500% -400%',
608 | c_sigil: '-600% -400%',
609 | c_ouija: '-700% -400%',
610 | c_ectoplasm: '-800% -400%',
611 | c_immolate: '-900% -400%',
612 | c_ankh: '0 -500%',
613 | c_deja_vu: '-100% -500%',
614 | c_hex: '-200% -500%',
615 | c_trance: '-300% -500%',
616 | c_medium: '-400% -500%',
617 | c_cryptid: '-500% -500%',
618 | c_soul: '-200% -200%',
619 | c_black_hole: '-900% -300%',
620 |
621 | undiscovered: '-600% -200%',
622 | undiscovered_extra: '-600% -300%',
623 | }
624 |
625 | export const voucherNames: Record = {
626 | v_overstock_norm: 'Overstock',
627 | v_overstock_plus: 'Overstock Plus',
628 | v_clearance_sale: 'Clearance Sale',
629 | v_liquidation: 'Liquidation',
630 | v_hone: 'Hone',
631 | v_glow_up: 'Glow Up',
632 | v_reroll_surplus: 'Reroll Surplus',
633 | v_reroll_glut: 'Reroll Glut',
634 | v_crystal_ball: 'Crystal Ball',
635 | v_omen_globe: 'Omen Globe',
636 | v_telescope: 'Telescope',
637 | v_observatory: 'Observatory',
638 | v_grabber: 'Grabber',
639 | v_nacho_tong: 'Nacho Tong',
640 | v_wasteful: 'Wasteful',
641 | v_recyclomancy: 'Recyclomancy',
642 | v_tarot_merchant: 'Tarot Merchant',
643 | v_tarot_tycoon: 'Tarot Tycoon',
644 | v_planet_merchant: 'Planet Merchant',
645 | v_planet_tycoon: 'Planet Tycoon',
646 | v_seed_money: 'Seed Money',
647 | v_money_tree: 'Money Tree',
648 | v_blank: 'Blank',
649 | v_antimatter: 'Antimatter',
650 | v_magic_trick: 'Magic Trick',
651 | v_illusion: 'Illusion',
652 | v_hieroglyph: 'Hieroglpyh',
653 | v_petroglyph: 'Petroglpyh',
654 | v_directors_cut: "Director's Cut",
655 | v_retcon: 'Retcon',
656 | v_paint_brush: 'Paint Brush',
657 | v_palette: 'Palette',
658 | }
659 |
660 | export const voucherSprites: Record = {
661 | v_overstock_norm: '0 0',
662 | v_overstock_plus: '0 -100%',
663 | v_clearance_sale: '-300% 0',
664 | v_liquidation: '-300% -100%',
665 | v_hone: '-400% 0',
666 | v_glow_up: '-400% -100%',
667 | v_reroll_surplus: '0 -200%',
668 | v_reroll_glut: '0 -300%',
669 | v_crystal_ball: '-200% -200%',
670 | v_omen_globe: '-200% -300%',
671 | v_telescope: '-300% -200%',
672 | v_observatory: '-300% -300%',
673 | v_grabber: '-500% 0',
674 | v_nacho_tong: '-500% -100%',
675 | v_wasteful: '-600% 0',
676 | v_recyclomancy: '-600% -100%',
677 | v_tarot_merchant: '-100% 0',
678 | v_tarot_tycoon: '-100% -100%',
679 | v_planet_merchant: '-200% 0',
680 | v_planet_tycoon: '-200% -100%',
681 | v_seed_money: '-100% -200%',
682 | v_money_tree: '-100% -300%',
683 | v_blank: '-700% 0',
684 | v_antimatter: '-700% -100%',
685 | v_magic_trick: '-400% -200%',
686 | v_illusion: '-400% -300%',
687 | v_hieroglyph: '-500% -200%',
688 | v_petroglyph: '-500% -300%',
689 | v_directors_cut: '-600% -200%',
690 | v_retcon: '-600% -300%',
691 | v_paint_brush: '-700% -200%',
692 | v_palette: '-700% -300%',
693 | undiscovered: '-800% -200%',
694 | undiscovered_extra: '-600% -300%',
695 | }
696 |
--------------------------------------------------------------------------------
/src/lib/cards/cards.ts:
--------------------------------------------------------------------------------
1 | import {
2 | consumableNames,
3 | consumableSprites,
4 | deckNames,
5 | deckSprites,
6 | jokerExtraSprites,
7 | jokerNames,
8 | jokerRarity,
9 | jokerSprites,
10 | voucherNames,
11 | voucherSprites,
12 | } from './cardMappings'
13 | import {
14 | consumableKeys,
15 | deckKeys,
16 | jokerKeys,
17 | planetKeys,
18 | spectralKeys,
19 | tarotKeys,
20 | voucherKeys,
21 | } from './cardKeys'
22 | import { CardType } from '@/lib/types'
23 |
24 | export function initializeJoker(key: string): CardType {
25 | return {
26 | name: jokerNames[key] || key,
27 | wins: {},
28 | losses: {},
29 | count: 0,
30 | image: jokerSprites[key]
31 | ? `url(/images/cards/Jokers.png) ${jokerSprites[key]} / 1000% 1600%`
32 | : `url(/images/cards/Jokers.png) ${jokerSprites.undiscovered} / 1000%`,
33 | topImage: jokerSprites[key]
34 | ? jokerExtraSprites[key] &&
35 | `url(/images/cards/Jokers.png) ${jokerExtraSprites[key]} / 1000% 1600%`
36 | : `url(/images/cards/Enhancers.png) ${jokerExtraSprites.undiscovered} / 700% 500%`,
37 | status: jokerRarity[key] ? jokerRarity[key] : 'Unknown',
38 | }
39 | }
40 |
41 | export function initializeDeck(key: string): CardType {
42 | return {
43 | name: deckNames[key] || key,
44 | wins: {},
45 | losses: {},
46 | image: deckNames[key]
47 | ? `url(/images/cards/Enhancers.png) ${deckSprites[key]} / 700%`
48 | : `url(/images/cards/Enhancers.png) ${deckSprites.locked} / 700%`,
49 | status: 'Deck',
50 | }
51 | }
52 |
53 | export function initializeConsumable(key: string): CardType {
54 | const status = (() => {
55 | if (tarotKeys.includes(key)) {
56 | return 'Tarot'
57 | } else if (planetKeys.includes(key)) {
58 | return 'Planet'
59 | } else if (spectralKeys.includes(key)) {
60 | return 'Spectral'
61 | } else {
62 | return 'Consumable'
63 | }
64 | })()
65 |
66 | return {
67 | name: consumableNames[key] || key,
68 | count: 0,
69 | image: consumableSprites[key]
70 | ? `url(/images/cards/Tarots.png) ${consumableSprites[key]} / 1000%`
71 | : `url(/images/cards/Tarots.png) ${consumableSprites.undiscovered} / 1000%`,
72 | topImage: consumableSprites[key]
73 | ? key == 'c_soul'
74 | ? `url(/images/cards/Enhancers.png) 0 -100% / 700%`
75 | : ''
76 | : `url(/images/cards/Enhancers.png) ${consumableSprites.undiscovered_extra} / 700%`,
77 | status: status,
78 | }
79 | }
80 |
81 | export function initializeVouchers(key: string): CardType {
82 | return {
83 | name: voucherNames[key] || key,
84 | count: 0,
85 | image: voucherNames[key]
86 | ? `url(/images/cards/Vouchers.png) ${voucherSprites[key]} / 900%`
87 | : `url(/images/cards/Vouchers.png) ${voucherSprites.undiscovered} / 900%`,
88 | topImage: !voucherNames[key]
89 | ? `url(/images/cards/Enhancers.png) ${voucherSprites.undiscovered_extra} / 700%`
90 | : '',
91 | status: 'Voucher',
92 | }
93 | }
94 |
95 | export const initialJokers = () => {
96 | const jokers: Record = {}
97 | jokerKeys.map((key) => {
98 | jokers[key] = initializeJoker(key)
99 | })
100 | return jokers
101 | }
102 |
103 | export const initialDecks = () => {
104 | const decks: Record = {}
105 | deckKeys.map((key) => {
106 | decks[key] = initializeDeck(key)
107 | })
108 | return decks
109 | }
110 |
111 | export const initialConsumables = () => {
112 | const consumables: Record = {}
113 | consumableKeys.map((key) => {
114 | consumables[key] = initializeConsumable(key)
115 | })
116 | return consumables
117 | }
118 |
119 | export const initialVouchers = () => {
120 | const vouchers: Record = {}
121 | voucherKeys.map((key) => {
122 | vouchers[key] = initializeVouchers(key)
123 | })
124 | return vouchers
125 | }
126 |
--------------------------------------------------------------------------------
/src/lib/context.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 | import { initializeSettings } from './settings'
3 |
4 | // prettier-ignore
5 | export const soundContext = createContext void>>({})
6 | export const settingsContext = createContext(initializeSettings())
7 |
--------------------------------------------------------------------------------
/src/lib/profile.ts:
--------------------------------------------------------------------------------
1 | import Pako from 'pako'
2 | import { Profile } from './types'
3 |
4 | export function decompressJKR(data: ArrayBuffer) {
5 | // Can read profile.jkr, save.jkr, and meta.jkr
6 | // However, it cannot read version.jkr (it's just plaintext)
7 | const uncompressed = Pako.inflateRaw(data, { to: 'string' })
8 | const formatted = uncompressed
9 | .replace(/^return /, '')
10 | .replace(/\["(.*?)"\]=/g, '"$1":')
11 | .replace(/\[(\d+)\]=/g, '"$1":')
12 | .replace(/,}/g, '}')
13 | return JSON.parse(formatted)
14 | }
15 |
16 | export function readProfile(file: File, callback: (data: Profile) => void) {
17 | const reader = new FileReader()
18 | reader.onload = (e) => {
19 | try {
20 | const data = decompressJKR(e.target!.result as ArrayBuffer)
21 | if (data?.joker_usage) {
22 | // check if its profile.jkr
23 | callback(data)
24 | } else {
25 | alert('ERROR: File is not profile.jkr')
26 | }
27 | } catch (err) {
28 | console.error(err)
29 | alert('ERROR: Could not read file')
30 | }
31 | }
32 | reader.readAsArrayBuffer(file)
33 | }
34 |
35 | export const initialProfile: Profile = {
36 | career_stats: {
37 | c_cards_discarded: 0,
38 | c_cards_played: 0,
39 | c_cards_sold: 0,
40 | c_dollars_earned: 0,
41 | c_face_cards_played: 0,
42 | c_hands_played: 0,
43 | c_jokers_sold: 0,
44 | c_losses: 0,
45 | c_planetarium_used: 0,
46 | c_planets_bought: 0,
47 | c_playing_cards_bought: 0,
48 | c_round_interest_cap_streak: 0, // consecutive rounds at max interest
49 | c_rounds: 0,
50 | c_shop_dollars_spent: 0,
51 | c_shop_rerolls: 0,
52 | c_single_hand_round_streak: 0, // consecutive single hand round wins
53 | c_tarot_reading_used: 0,
54 | c_tarots_bought: 0,
55 | c_vouchers_bought: 0,
56 | c_wins: 0,
57 | },
58 | consumeable_usage: {},
59 | deck_stakes: {},
60 | deck_usage: {},
61 | hand_usage: {},
62 | high_scores: {
63 | boss_streak: { label: 'Most Bosses in a Row', amt: 0 },
64 | collection: { amt: 2, label: 'Collection', tot: 340 },
65 | current_streak: { label: '', amt: 0 },
66 | furthest_ante: { label: 'Highest Ante', amt: 0 },
67 | furthest_round: { label: 'Highest Round', amt: 0 },
68 | hand: { label: 'Best Hand', amt: 0 },
69 | most_money: { label: 'Most Money', amt: 0 },
70 | poker_hand: { label: 'Most Played Hand', amt: 0 },
71 | win_streak: { label: 'Best Win Streak', amt: 0 },
72 | },
73 | joker_usage: {},
74 | name: 'Jimbo',
75 | progress: {
76 | challenges: { of: 20, tally: 0 },
77 | deck_stakes: { of: 120, tally: 0 },
78 | discovered: { of: 340, tally: 2 }, // it is impossible for the tally to be 0; the player starts with Joker and Red Deck
79 | joker_stickers: { of: 1200, tally: 0 },
80 | overall_of: 4,
81 | overall_tally: 2 / (20 + 120 + 340 + 1200),
82 | },
83 | voucher_usage: {},
84 | lastUpdated: null,
85 | }
86 |
--------------------------------------------------------------------------------
/src/lib/settings.ts:
--------------------------------------------------------------------------------
1 | export function initializeSettings(): Record<
2 | string,
3 | { label: string; enabled: boolean }
4 | > {
5 | return {
6 | soundEnabled: {
7 | label: 'Enable sound',
8 | enabled: true,
9 | },
10 | cardPerspective: {
11 | label: 'Enable card perspective effect',
12 | enabled: true,
13 | },
14 | showPerGameStats: {
15 | label: 'Show per-game averages in career stats',
16 | enabled: true,
17 | },
18 | // slideAnimation: {
19 | // label: 'Enable slide animation on filters',
20 | // enabled: false
21 | // },
22 | fadeCardsWithNoRounds: {
23 | label: 'Fade unplayed cards',
24 | enabled: false,
25 | },
26 | fadeCardsWithNoWins: {
27 | label: 'Fade cards with no wins',
28 | enabled: false,
29 | },
30 | highlightChipsOnlyIfWin: {
31 | label: 'Highlight chips with wins only',
32 | enabled: false,
33 | },
34 | saveImageinNewTab: {
35 | label: 'Open saved images in new tab',
36 | enabled: false,
37 | },
38 | // disableOverwriteConfirmation: {
39 | // label: 'Disable profile overwrite confirmation prompt',
40 | // enabled: false
41 | // },
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface Profile {
2 | all_unlocked?: boolean
3 | career_stats: Record
4 | consumeable_usage: Record
5 | deck_usage: Record<
6 | string,
7 | {
8 | order: number
9 | count: number
10 | wins: Record
11 | losses: Record
12 | }
13 | >
14 | hand_usage: Record
15 | high_scores: Record
16 | joker_usage: Record<
17 | string,
18 | {
19 | order: number
20 | count: number
21 | wins: Record
22 | losses: Record
23 | }
24 | >
25 | name: string
26 | progress: {
27 | challenges: { of: number; tally: number }
28 | deck_stakes: { of: number; tally: number }
29 | discovered: { of: number; tally: number }
30 | joker_stickers: { of: number; tally: number }
31 | overall_of: number
32 | overall_tally: number
33 | }
34 | voucher_usage: Record
35 | lastUpdated: string | null
36 |
37 | // Unused stuff / stuff I don't care about
38 | MEMORY?: { deck: string; stake: number }
39 | challenge_progress?: {
40 | completed: Record
41 | unlocked: Record
42 | }
43 | challenges_unlocked?: number
44 | deck_stakes?: {}
45 | stake?: number
46 | }
47 |
48 | export interface CardType {
49 | name: string
50 | wins?: Record
51 | losses?: Record
52 | count?: number
53 | image: string
54 | topImage?: string
55 | status: string
56 | }
57 |
58 | export interface FilterType {
59 | name: string
60 | filters: Record<
61 | string,
62 | {
63 | filter: (card: CardType) => boolean
64 | enabled: boolean
65 | }
66 | >
67 | }
68 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { toBlob } from 'html-to-image'
2 | import { MutableRefObject, useCallback, useRef } from 'react'
3 | import { CardType } from './types'
4 | import FileSaver from 'file-saver'
5 |
6 | export function numberWithCommas(number: number) {
7 | // https://stackoverflow.com/questions/2901102/how-to-format-a-number-with-commas-as-thousands-separators
8 | return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
9 | }
10 |
11 | export function useToImage(
12 | filename: string,
13 | newTab?: boolean,
14 | ): [MutableRefObject, () => void] {
15 | const ref = useRef(null)
16 | const saveImage = useCallback(() => {
17 | if (ref.current === null) {
18 | return
19 | }
20 |
21 | toBlob(ref.current)
22 | .then((blob) => {
23 | if (blob) {
24 | if (newTab) {
25 | const url = URL.createObjectURL(blob)
26 | window.open(url)
27 | } else {
28 | FileSaver.saveAs(blob, filename)
29 | }
30 | }
31 | })
32 | .catch((err) => {
33 | alert(
34 | `ERROR: too many cards to save as image. Filter out cards or use Chrome to fix this.`,
35 | )
36 | console.error(err)
37 | })
38 | }, [filename, newTab, ref])
39 | return [ref, saveImage]
40 | }
41 |
42 | export function sumStakes(stakes: Record) {
43 | return Object.values(stakes).reduce((sum, x) => sum + x, 0)
44 | }
45 |
46 | export function getHighestStake(stakes: Record) {
47 | return Object.keys(stakes).length != 0
48 | ? Math.max(...Object.keys(stakes).map((key) => parseInt(key, 10)))
49 | : 0
50 | }
51 |
52 | export function filterCards(
53 | cards: CardType[],
54 | search: string,
55 | sort: string,
56 | filters: ((card: CardType) => boolean)[][],
57 | ) {
58 | let filteredCards = cards
59 |
60 | if (filters.length) {
61 | filters.forEach((group) => {
62 | if (group.length) {
63 | const combinedFilters = (card: CardType) =>
64 | group.some((filter) => filter(card))
65 | filteredCards = filteredCards.filter(combinedFilters)
66 | }
67 | })
68 | }
69 |
70 | // Check for exact match first
71 | const match = filteredCards.filter(
72 | (card) => card.name.toLowerCase() == search.toLowerCase(),
73 | )
74 | if (match.length) {
75 | return match
76 | } else {
77 | filteredCards = filteredCards.filter((card) =>
78 | card.name.toLowerCase().includes(search.toLowerCase()),
79 | )
80 | }
81 |
82 | filteredCards = sortCards(filteredCards, sort)
83 | return filteredCards
84 | }
85 |
86 | function sortCards(cards: CardType[], sort: string) {
87 | switch (sort) {
88 | default:
89 | case 'Game Order':
90 | return cards
91 |
92 | case 'Name':
93 | return cards.toSorted((a, b) => {
94 | if (a.name < b.name) {
95 | return -1
96 | } else if (a.name > b.name) {
97 | return 1
98 | }
99 | return 0
100 | })
101 |
102 | case 'Wins':
103 | return cards.toSorted((a, b) => {
104 | const aWins = a.wins ? sumStakes(a.wins) : 0
105 | const bWins = b.wins ? sumStakes(b.wins) : 0
106 | return bWins - aWins
107 | })
108 |
109 | case 'Win %':
110 | return cards.toSorted((a, b) => {
111 | const aWins = a.wins ? sumStakes(a.wins) : 0
112 | const aLosses = a.losses ? sumStakes(a.losses) : 0
113 | const aRounds = aWins + aLosses
114 | const aWinPercentage = aWins / (aRounds || 1)
115 |
116 | const bWins = b.wins ? sumStakes(b.wins) : 0
117 | const bLosses = b.losses ? sumStakes(b.losses) : 0
118 | const bRounds = bWins + bLosses
119 | const bWinPercentage = bWins / (bRounds || 1)
120 |
121 | if (aWinPercentage == bWinPercentage) {
122 | // favor cards with more wins
123 | if (aWins > bWins) {
124 | return -1
125 | } else if (aWins < bWins) {
126 | return 1
127 | // if 0 = aWins = bWins
128 | } else if (aLosses > bLosses) {
129 | return -1
130 | } else if (aLosses < bLosses) {
131 | return 1
132 | }
133 | }
134 | return bWinPercentage - aWinPercentage
135 | })
136 |
137 | case 'Wins per Loss':
138 | return cards.toSorted((a, b) => {
139 | const aWins = a.wins ? sumStakes(a.wins) : 0
140 | const aLosses = a.losses ? sumStakes(a.losses) : 0
141 | const aRatio = aWins / (aLosses || 1)
142 |
143 | const bWins = b.wins ? sumStakes(b.wins) : 0
144 | const bLosses = b.losses ? sumStakes(b.losses) : 0
145 | const bRatio = bWins / (bLosses || 1)
146 |
147 | if (bRatio < aRatio) {
148 | return -1
149 | } else if (bRatio > aRatio) {
150 | return 1
151 | }
152 |
153 | // same win ratio
154 | if (bWins < aWins) {
155 | return -1
156 | } else if (bWins > aWins) {
157 | return 1
158 | }
159 |
160 | // same wins
161 | if (bLosses < aLosses) {
162 | return 1
163 | } else if (bLosses > aLosses) {
164 | return -1
165 | }
166 | return 0
167 | })
168 |
169 | case 'Losses':
170 | return cards.toSorted((a, b) => {
171 | const aLosses = a.losses ? sumStakes(a.losses) : 0
172 | const bLosses = b.losses ? sumStakes(b.losses) : 0
173 | return bLosses - aLosses
174 | })
175 |
176 | case 'Rounds':
177 | case 'Uses':
178 | case 'Redeems':
179 | return cards.toSorted((a, b) => (b.count ?? 0) - (a.count ?? 0))
180 |
181 | case 'Rarity':
182 | return cards.toSorted((a, b) => {
183 | const rarityMapping: Record = {
184 | Common: 0,
185 | Uncommon: 1,
186 | Rare: 2,
187 | Legendary: 3,
188 | }
189 | const difference = rarityMapping[b.status] - rarityMapping[a.status]
190 |
191 | if (difference == 0) {
192 | return -1
193 | } else {
194 | return difference
195 | }
196 | })
197 |
198 | case 'Stake':
199 | return cards.toSorted((a, b) => {
200 | const aStakes = a.wins
201 | ? Object.keys(a.wins).map((key) => parseInt(key, 10))
202 | : [0]
203 | const aHighestStake = Math.max(...aStakes)
204 |
205 | const bStakes = b.wins
206 | ? Object.keys(b.wins).map((key) => parseInt(key, 10))
207 | : [0]
208 | const bHighestStake = Math.max(...bStakes)
209 |
210 | return bHighestStake - aHighestStake
211 | })
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------