diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0cba6a1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithEdge", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + } + } + ] +} \ No newline at end of file diff --git a/src/actions/chuni/music.ts b/src/actions/chuni/music.ts index eb3f547..91812a1 100644 --- a/src/actions/chuni/music.ts +++ b/src/actions/chuni/music.ts @@ -2,7 +2,7 @@ import { getUser, requireUser } from '@/actions/auth'; import { db } from '@/db'; -import { chuniRating } from '@/helpers/chuni/rating'; +import { sqlChuniRating } from '@/helpers/chuni/rating'; import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music'; import { UserPayload } from '@/types/user'; import { revalidatePath } from 'next/cache'; @@ -29,7 +29,7 @@ export const getMusic = async (musicId?: number) => { 'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax', 'score.maxComboCount', fn('NOT ISNULL', ['favorite.favId']).as('favorite'), - chuniRating()] as const) + sqlChuniRating()] as const) .where(({ selectFrom, eb, and, or }) => and([ eb('music.version', '=', selectFrom('chuni_static_music') .select(({ fn }) => fn.max('version').as('latest'))), diff --git a/src/actions/chuni/playlog.ts b/src/actions/chuni/playlog.ts index ebe9d93..68e6bf0 100644 --- a/src/actions/chuni/playlog.ts +++ b/src/actions/chuni/playlog.ts @@ -4,7 +4,7 @@ import { requireUser } from '@/actions/auth'; import { PlaylogFilterState } from '@/app/(with-header)/chuni/playlog/page'; import { db } from '@/db'; import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music'; -import { chuniRating } from '@/helpers/chuni/rating'; +import { sqlChuniRating } from '@/helpers/chuni/rating'; import { sql } from 'kysely'; const SORT_KEYS = { @@ -52,7 +52,7 @@ export async function getPlaylog(opts: GetPlaylogOptions) { 'playlog.isNewRecord', 'playlog.isFullCombo', 'playlog.fullChainKind', 'playlog.isAllJustice', 'playlog.playKind', 'playlog.isClear', 'playlog.placeName', ...CHUNI_MUSIC_PROPERTIES, - chuniRating(ref('playlog.score')), + sqlChuniRating(ref('playlog.score')), sql`(playlog.playerRating - (LEAD(playlog.playerRating) OVER (ORDER BY id DESC)))` .as('playerRatingChange') ] as const) diff --git a/src/actions/chuni/profile.ts b/src/actions/chuni/profile.ts index 9142ffb..718412f 100644 --- a/src/actions/chuni/profile.ts +++ b/src/actions/chuni/profile.ts @@ -3,7 +3,7 @@ import { sql } from 'kysely'; import { db, GeneratedDB } from '@/db'; -import { chuniRating } from '@/helpers/chuni/rating'; +import { sqlChuniRating } from '@/helpers/chuni/rating'; import { CHUNI_MUSIC_PROPERTIES } from '@/helpers/chuni/music'; import { UserPayload } from '@/types/user'; import { ItemKind } from '@/helpers/chuni/items'; @@ -96,7 +96,7 @@ export async function getUserRating(user: UserPayload) { .innerJoin('actaeon_chuni_static_music_ext as musicExt', join => join .onRef('music.songId', '=', 'musicExt.songId') .onRef('music.chartId', '=', 'musicExt.chartId')) - .select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, chuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)), + .select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, sqlChuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)), sql`CAST(score.scoreMax AS INT)`.as('scoreMax'), lit(1).as('pastIndex') ]) @@ -119,7 +119,7 @@ export async function getUserRating(user: UserPayload) { ])) .select([ ...CHUNI_MUSIC_PROPERTIES, 'score.scoreMax', - chuniRating() + sqlChuniRating() ]) .orderBy('rating desc') .limit(30) @@ -131,6 +131,8 @@ export async function getUserRating(user: UserPayload) { return { recent, top }; } +export type ChuniUserRating = Awaited>; + const validators = new Map, value: any) => Promise>(); const itemValidators = [ diff --git a/src/app/(with-header)/chuni/dashboard/top-rating-sidebar.tsx b/src/app/(with-header)/chuni/dashboard/top-rating-sidebar.tsx index d13396a..ded54fc 100644 --- a/src/app/(with-header)/chuni/dashboard/top-rating-sidebar.tsx +++ b/src/app/(with-header)/chuni/dashboard/top-rating-sidebar.tsx @@ -1,14 +1,14 @@ 'use client'; import { ChuniTopRating } from './top-rating'; -import { getUserRating } from '@/actions/chuni/profile'; +import { ChuniUserRating } from '@/actions/chuni/profile'; import { useState } from 'react'; import { ButtonGroup, Button } from '@nextui-org/button'; import { useBreakpoint } from '@/helpers/use-breakpoint'; import { BigDecimal } from '@/helpers/big-decimal'; import { ChuniRating } from '@/components/chuni/rating'; -export const ChuniTopRatingSidebar = ({ rating }: { rating: Awaited> }) => { +export const ChuniTopRatingSidebar = ({ rating }: { rating: ChuniUserRating }) => { const [shownRating, setShownRating] = useState<'top' | 'recent' | null>('recent'); const breakpoint = useBreakpoint(); diff --git a/src/app/(with-header)/chuni/music/[musicId]/music-detail.tsx b/src/app/(with-header)/chuni/music/[musicId]/music-detail.tsx index 9930ed9..ee45f06 100644 --- a/src/app/(with-header)/chuni/music/[musicId]/music-detail.tsx +++ b/src/app/(with-header)/chuni/music/[musicId]/music-detail.tsx @@ -12,13 +12,15 @@ import { HeartIcon as OutlineHeartIcon } from '@heroicons/react/24/outline'; import { useState } from 'react'; import { useErrorModal } from '@/components/error-modal'; import { useUser } from '@/helpers/use-user'; +import { ChuniUserRating } from '@/actions/chuni/profile'; type ChuniMusicDetailProps = { music: ChuniMusic[], - playlog: ChuniPlaylog + playlog: ChuniPlaylog, + rating: ChuniUserRating }; -export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => { +export const ChuniMusicDetail = ({ music, playlog, rating }: ChuniMusicDetailProps) => { const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1]; const [favorite, setFavorite] = useState(music[0].favorite); const [pendingFavorite, setPendingFavorite] = useState(false); @@ -48,6 +50,6 @@ export const ChuniMusicDetail = ({ music, playlog }: ChuniMusicDetailProps) => { {favorite ? : } } - + ); }; diff --git a/src/app/(with-header)/chuni/music/[musicId]/music-playlog.tsx b/src/app/(with-header)/chuni/music/[musicId]/music-playlog.tsx index 1cdae60..ad13faf 100644 --- a/src/app/(with-header)/chuni/music/[musicId]/music-playlog.tsx +++ b/src/app/(with-header)/chuni/music/[musicId]/music-playlog.tsx @@ -6,21 +6,27 @@ import { Accordion, AccordionItem } from '@nextui-org/accordion'; import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties'; import { ChuniLevelBadge } from '@/components/chuni/level-badge'; import { ChuniRating } from '@/components/chuni/rating'; -import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge'; -import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks'; +import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, ChuniScoreRankBadge, getVariantFromRank } from '@/components/chuni/score-badge'; import { ChuniPlaylogCard } from '@/components/chuni/playlog-card'; import { useState } from 'react'; +import { CalculatorIcon } from '@heroicons/react/24/outline'; +import { Tooltip } from '@nextui-org/tooltip'; +import { ChuniScoreCalculator, ChuniScoreCalculatorProps } from '@/components/chuni/score-calculator'; +import { ChuniUserRating } from '@/actions/chuni/profile'; +import { Spacer } from '@nextui-org/spacer'; type ChuniMusicPlaylogProps = { music: ChuniMusic[], - playlog: ChuniPlaylog + playlog: ChuniPlaylog, + rating: ChuniUserRating }; -export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => { +export const ChuniMusicPlaylog = ({ music, playlog, rating }: ChuniMusicPlaylogProps) => { type Music = (typeof music)[number]; type Playlog = (typeof playlog)['data'][number]; const [selected, setSelected] = useState(new Set()); + const [scoreCalculator, setScoreCalculator] = useState(null); const difficulties: (Music & { playlog: Playlog[] })[] = []; music.forEach(m => { @@ -34,27 +40,19 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => const badgeClass = 'h-6 sm:h-8'; return (
+ setScoreCalculator(null)} /> {difficulties.map((data, i) => { - const rank = CHUNI_SCORE_RANKS[data.scoreRank!]; const badges = [ - !!data.scoreRank && - {rank.endsWith('+') ? <> - {rank.slice(0, -1)} -
+
- : rank} -
, + data.scoreRank !== null && , data.isSuccess ? : null, (data.isFullCombo || data.isAllJustice) && ].filter(x => x); - - //
- return ( { + return ( { const key = i.toString(); setSelected(s => s.has(key) ? new Set([...s].filter(k => k !== key)) : new Set([...s, key])) - }}> + }} title={
@@ -63,7 +61,7 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
{CHUNI_DIFFICULTIES[i]}
{!data.playlog.length &&
No Play History
} - {data.rating ? : null} + {data.rating && data.chartId !== 5 ? : null} {data.scoreMax ?
High Score: {data.scoreMax.toLocaleString()}
: null} @@ -74,6 +72,16 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {badges.length ?
{badges}
: null} + {data.chartId !== 5 ? +
{ + e.stopPropagation(); + setScoreCalculator(data); + }}> + +
+
: } +
}>
Chart designer: {data.chartDesigner} diff --git a/src/app/(with-header)/chuni/music/[musicId]/page.tsx b/src/app/(with-header)/chuni/music/[musicId]/page.tsx index fd559ae..8e5540d 100644 --- a/src/app/(with-header)/chuni/music/[musicId]/page.tsx +++ b/src/app/(with-header)/chuni/music/[musicId]/page.tsx @@ -3,20 +3,22 @@ import { notFound } from 'next/navigation'; import { getPlaylog } from '@/actions/chuni/playlog'; import { ChuniMusicDetail } from './music-detail'; +import { getUserRating } from '@/actions/chuni/profile'; +import { requireUser } from '@/actions/auth'; export default async function ChuniMusicDetailPage({ params }: { params: { musicId: string } }) { const musicId = parseInt(params.musicId); if (Number.isNaN(musicId)) return notFound(); - const [music, playlog] = await Promise.all([ + const [music, playlog, rating] = await Promise.all([ getMusic(musicId).then(d => structuredClone(d)), - getPlaylog({ musicId, limit: 500 }) + getPlaylog({ musicId, limit: 500 }), + getUserRating(await requireUser()).then(d => structuredClone(d)) ]); - if (!music.length) return notFound(); - return () + return () } diff --git a/src/app/(with-header)/chuni/music/music-list.tsx b/src/app/(with-header)/chuni/music/music-list.tsx index ffa941d..b152595 100644 --- a/src/app/(with-header)/chuni/music/music-list.tsx +++ b/src/app/(with-header)/chuni/music/music-list.tsx @@ -11,16 +11,20 @@ import { ChuniScoreBadge, ChuniLampSuccessBadge, getVariantFromRank, ChuniLampCo import { ChuniRating } from '@/components/chuni/rating'; import Link from 'next/link'; import Image from 'next/image'; -import { HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline'; +import { CalculatorIcon, HeartIcon as OutlineHeartIcon, Squares2X2Icon } from '@heroicons/react/24/outline'; import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid'; import { Ticker, TickerHoverProvider } from '@/components/ticker'; import { useErrorModal } from '@/components/error-modal'; import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_FAVORITE, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG } from '@/helpers/chuni/filter'; import { WindowScrollerGrid } from '@/components/window-scroller-grid'; import { useUser } from '@/helpers/use-user'; +import { Tooltip } from '@nextui-org/tooltip'; +import { ChuniScoreCalculator } from '@/components/chuni/score-calculator'; +import { ChuniUserRating } from '@/actions/chuni/profile'; export type ChuniMusicListProps = { - music: ChuniMusic[] + music: ChuniMusic[], + topRating?: ChuniUserRating }; const perPage = [25, 50, 100, 250, 500, Infinity]; @@ -48,7 +52,7 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) => return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query); }; -const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListProps & { +const MusicGrid = ({ music, size, setMusicList, fullMusicList, topRating }: ChuniMusicListProps & { size: 'sm' | 'lg' | 'xs', setMusicList: (m: typeof music) => void, fullMusicList: ChuniMusicListProps['music'] @@ -75,77 +79,88 @@ const MusicGrid = ({ music, size, setMusicList, fullMusicList }: ChuniMusicListP const setError = useErrorModal(); const [pendingFavorite, setPendingFavorite] = useState(false); + const [scoreCalculator, setScoreCalculator] = useState(null); - return ( - {item => - {setHover =>
setHover(true)} - onMouseLeave={() => setHover(false)}> -
- - {item.title - - {item.rating && !item.worldsEndTag &&
- + return (<> + setScoreCalculator(null)} topRating={topRating} /> + + {item => + {setHover =>
setHover(true)} + onMouseLeave={() => setHover(false)}> +
+ + {item.title + + {item.rating && !item.worldsEndTag &&
+ {item.rating.slice(0, item.rating.indexOf('.') + 3)} - -
} - + +
} + - {!!user?.chuni && } -
-
- {size === 'lg' &&
- {item.isSuccess ? : null} -
} + + + -
- {item.scoreRank !== null && - {item.scoreMax!.toLocaleString()} - } + {!!user?.chuni && }
+
+ {size === 'lg' &&
+ {item.isSuccess ? : null} +
} -
- +
+ {item.scoreRank !== null && + {item.scoreMax!.toLocaleString()} + } +
+ +
+ +
-
- - {item.title} - - {item.artist} -
} -
} -
); + + {item.title} + + {item.artist} +
} + } + + ); }; const DISPLAY_MODES = [{ @@ -177,7 +192,7 @@ const FILTERERS = [ CHUNI_FILTER_RATING ]; -export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { +export const ChuniMusicList = ({ music, topRating }: ChuniMusicListProps) => { const [localMusic, setLocalMusic] = useState(music); return ( @@ -185,7 +200,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { displayModes={DISPLAY_MODES} searcher={searcher}> {(displayMode, data) =>
+ fullMusicList={localMusic} setMusicList={setLocalMusic} topRating={topRating} />
} ); }; diff --git a/src/app/(with-header)/chuni/music/page.tsx b/src/app/(with-header)/chuni/music/page.tsx index 0e513c0..1a2c099 100644 --- a/src/app/(with-header)/chuni/music/page.tsx +++ b/src/app/(with-header)/chuni/music/page.tsx @@ -1,11 +1,18 @@ import { getMusic } from '@/actions/chuni/music'; import { ChuniMusicList } from './music-list'; +import { getUser } from '@/actions/auth'; +import { getUserRating } from '@/actions/chuni/profile'; export default async function ChuniMusicPage() { - const music = await getMusic(); + const [music, rating] = await Promise.all([ + getMusic(), + getUser().then(async u => { + if (u) return structuredClone(await getUserRating(u)); + }) + ]); return ( - + ); } diff --git a/src/app/(with-header)/header-sidebar.tsx b/src/app/(with-header)/header-sidebar.tsx index 271fd03..15f4d40 100644 --- a/src/app/(with-header)/header-sidebar.tsx +++ b/src/app/(with-header)/header-sidebar.tsx @@ -117,7 +117,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { let parent: HTMLElement | null = e.event.target as HTMLElement; while (parent) { const data = parent.dataset; - if (data?.slot === 'thumb' || data?.dragging || + if (data?.slot === 'thumb' || data?.dragging || data?.draggable || parent?.classList?.contains('react-draggable-dragging') || parent?.classList?.contains('react-resizable-handle')) return; diff --git a/src/components/chuni/score-badge.tsx b/src/components/chuni/score-badge.tsx index 9f1c86c..a719fda 100644 --- a/src/components/chuni/score-badge.tsx +++ b/src/components/chuni/score-badge.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { CHUNI_LAMPS } from '@/helpers/chuni/lamps'; +import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks'; const BACKGROUNDS = [ 'bg-[linear-gradient(135deg,rgba(120,120,120,1)_30%,rgba(90,91,90,1)_50%,rgba(172,170,170,1)_50%,rgba(115,114,114,1)_63%,rgba(98,98,98,1)_80%,rgba(129,129,129,1)_100%)]', @@ -78,8 +79,10 @@ export const ChuniScoreBadge = ({ children, variant, className, fontSize }: Chun export const ChuniLampSuccessBadge = ({ success, className }: { className?: string, success: number }) => { const text = CHUNI_LAMPS.get(success)?.toUpperCase(); const fontSize = text?.length! > 5 ? text?.length! > 10 ? 'xs' : 'sm' : 'md'; - return ( - {text} + return ( + + {text} + ) } @@ -89,3 +92,15 @@ export const ChuniLampComboBadge = ({ className, isFullCombo, isAllJustice }: { {isAllJustice ? 'ALL JUSTICE' : 'FULL COMBO'} ) } + +export const ChuniScoreRankBadge = ({ rank, ...rest }: { rank: number } & Pick) => { + const scoreRank = CHUNI_SCORE_RANKS[rank]; + return ( +
+ {scoreRank.endsWith('+') ? <> + {scoreRank.slice(0, -1)} +
+
+ : scoreRank} +
+
); +}; diff --git a/src/components/chuni/score-calculator.tsx b/src/components/chuni/score-calculator.tsx new file mode 100644 index 0000000..608b5c9 --- /dev/null +++ b/src/components/chuni/score-calculator.tsx @@ -0,0 +1,503 @@ +import { ChuniMusic } from '@/actions/chuni/music'; +import { ChuniUserRating } from '@/actions/chuni/profile'; +import { useHashNavigation } from '@/helpers/use-hash-navigation'; +import { Modal, ModalHeader, ModalContent, ModalBody, ModalFooter } from '@nextui-org/modal'; +import { Button } from '@nextui-org/button'; +import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties'; +import { floorToDp } from '@/helpers/floor-dp'; +import { Input } from '@nextui-org/input'; +import { useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { chuniRating, chuniRatingInverse } from '@/helpers/chuni/rating'; +import { ChuniRating } from './rating'; +import { ChuniScoreRankBadge } from './score-badge'; +import { CHUNI_SCORE_RANKS, CHUNI_SCORE_THRESHOLDS, getRankFromScore } from '@/helpers/chuni/score-ranks'; +import { Tabs, Tab } from '@nextui-org/tabs'; +import { BigDecimal } from '@/helpers/big-decimal'; +import { Divider } from '@nextui-org/divider'; +import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@nextui-org/dropdown'; +import { useValueChange } from '@/helpers/use-value-change'; +import { Entries, RequireExactlyOne } from 'type-fest'; + +export type ChuniScoreCalculatorProps = { + topRating?: ChuniUserRating, + music: Pick | null, + onClose: () => void +}; + +const calculateTop = ({ topRating, subtract, rating, totalTopRating }: + { topRating?: ChuniUserRating, subtract: BigDecimal | null, rating: BigDecimal, totalTopRating: BigDecimal }) => { + if (!topRating) return [null, null]; + + let topIncrease: string | null = null; + let topIndex: number | null = null; + + if (subtract !== null && rating.compare(subtract) > 0) { + topIncrease = totalTopRating.add(rating.sub(subtract)).div(30, 4n).sub(totalTopRating.div(30, 4n)).toFixed(3); + topIndex = topRating.top.findIndex(t => rating.compare(t.rating) >= 0); + if (topIndex === -1) + topIndex = null; + } + + return [topIncrease, topIndex] as const; +}; + +type MissAttackState = { miss: string, attack: string, totalAttack: number, missPercent: number; }; +type MissAttackAction = { miss: string; } | { attack: string, recompute?: boolean; } | { miss: string, totalAttack: number; } | { missPercent: number; } | 'recompute'; +type NoteValues = { noteMiss: string, noteAttack: string, noteJustice: string, noteJusticeCritical: string; }; +type NoteState = NoteValues & { order: (keyof NoteValues)[], totalNote: number }; + +const STORAGE_KEY = 'chuni-score-calculator'; + +type StoredState = { + selectedKey: string, + score: string, + rating: string, + topRatingIncrease: string, + ratio: string, + missPercent: number +}; + +export const ChuniScoreCalculator = ({ topRating, music, onClose }: ChuniScoreCalculatorProps) => { + const onModalClose = useHashNavigation({ + onClose, + isOpen: music !== null, + hash: '#chuni-score-calculator' + }); + + const [selectedKey, setSelectedKey] = useState('rating'); + const [score, setScore] = useState(''); + const [rating, setRating] = useState(''); + const [topRatingIncrease, setTopRatingIncrease] = useState(''); + const [ratio, setRatio] = useState('100'); + const [{ miss, attack, missPercent }, dispatchScore] = useReducer((state: MissAttackState, action: MissAttackAction): MissAttackState => { + if (action === 'recompute') { + if (!Number.isInteger(+state.miss)) return state; + + const attack = state.totalAttack - +state.miss * 2; + if (attack < 0 || Number.isNaN(attack)) return state; + return { ...state, attack: attack.toString() }; + } + + if ('totalAttack' in action || 'missPercent' in action) + return { ...state, ...action }; + const totalAttack = state.totalAttack; + + if ('miss' in action) { + const val = action.miss.trim(); + if (val === '' || !Number.isInteger(+val)) + return { ...state, miss: val }; + + const miss = Math.floor(Math.max(Math.min(+action.miss, totalAttack / 2), 0)); + const attack = state.totalAttack - miss * 2; + if (Number.isNaN(miss)) return state; + const missPercent = miss / totalAttack; + return { ...state, miss: miss.toString(), attack: attack.toString(), missPercent }; + } else if ('attack' in action) { + const val = action.attack.trim(); + if (val === '' || !Number.isInteger(+val)) + return { ...state, attack: val }; + let attack = Math.floor(Math.max(Math.min(+action.attack, totalAttack), 0)); + const miss = Math.min((attack < +state.attack && action.recompute ? Math.ceil : Math.floor)((totalAttack - attack) / 2), Math.floor(totalAttack / 2)); + if (action.recompute) + attack = state.totalAttack - miss * 2; + + if (Number.isNaN(attack) || attack < 0) return state; + const missPercent = miss / totalAttack; + + return { ...state, miss: miss.toString(), attack: attack.toString(), missPercent }; + } + + return state; + }, { miss: '0', totalAttack: 0, missPercent: 0, attack: '0' }); + const [unachievable, setUnachievable] = useState(false); + const [justiceCount, setJusticeCount] = useState(0); + + useValueChange(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ selectedKey, score, rating, topRatingIncrease, ratio, missPercent })); + }, [selectedKey, score, rating, topRatingIncrease, ratio, missPercent]); + + useEffect(() => { + let data: StoredState; + try { + const val = localStorage.getItem(STORAGE_KEY); + if (val) + data = JSON.parse(val); + else + return; + } catch (e) { + console.error(e); + return; + } + + setSelectedKey(data.selectedKey); + setScore(data.score); + setRating(data.rating); + setTopRatingIncrease(data.topRatingIncrease); + setRatio(data.ratio); + dispatchScore({ missPercent: data.missPercent }); + }, []); + + const [noteCount, dispatchNote] = useReducer((state: NoteState, action: RequireExactlyOne | number) => { + if (typeof action === 'number') + return { ...state, noteMiss: '0', noteAttack: '0', noteJustice: '0', noteJusticeCritical: action.toString(), totalNote: action }; + + state = { ...state }; + const [key, val] = (Object.entries(action) as Entries)[0]; + state.order = [key, ...state.order.filter(x => x !== key)]; + state[key] = val?.trim() ?? ''; + if (state[key] !== '' && Number.isFinite(+state[key])) + state[key] = Math.floor(Math.min(Math.max(+state[key], 0), state.totalNote)).toString(); + + if (state.order.some(k => state[k] === '' || !Number.isInteger(+state[k]))) + return state; + + let sum = state.order.reduce((t, k) => t + +state[k], 0); + if (sum === state.totalNote) + return state; + + if (sum < state.totalNote) { + const k = state.order.at(-1)!; + state[k] = (+state[k] + state.totalNote - sum).toString(); + } else { + [...state.order].reverse().forEach(key => { + if (sum === state.totalNote) return; + const val = +state[key]; + const sub = Math.min(val, sum - state.totalNote); + sum -= sub; + state[key] = (val - sub).toString(); + }); + } + + return state; + }, { + noteMiss: '0', noteAttack: '0', noteJustice: '0', noteJusticeCritical: '0', totalNote: 0, + order: ['noteMiss', 'noteAttack', 'noteJustice', 'noteJusticeCritical'] + }) + + const totalTopRating = useMemo(() => topRating?.top.reduce((t, x) => t.add(x.rating), BigDecimal.ZERO) ?? BigDecimal.ZERO, [topRating]); + const musicLevel = useMemo(() => music ? new BigDecimal(music.level?.toFixed(1)!) : null, [music]); + const subtract = useMemo(() => { + if (!music) return null; + const top = topRating?.top.find(r => r.songId === music.songId && r.chartId === music.chartId); + const maxPossibleRating = musicLevel!.add('2.15'); + + const smallestRating = topRating?.top.at(-1)?.rating ?? 0; + if (maxPossibleRating.compare(smallestRating) <= 0) + return null; + + if (top) { + const rating = new BigDecimal(top.rating); + const currentRatingDiff = rating.sub(musicLevel!); + if (currentRatingDiff.compare('2.15') >= 0) + return null; + return rating; + } else { + return new BigDecimal(smallestRating); + } + }, [musicLevel, topRating, music]); + + const maxPossibleTopIncrease = useMemo(() => { + if (!music || selectedKey !== 'top') return null; + const maxPossibleRating = musicLevel!.add('2.15'); + + if (subtract === null) return null; + + return totalTopRating.add(maxPossibleRating.sub(subtract)).div(30, 4n).sub(totalTopRating.div(30, 4n)).toString() + }, [topRating, music, selectedKey, totalTopRating, musicLevel, subtract]); + + const { musicRating, reverseScore, topIncrease, topIndex, targetTopScore, targetTotalRating, computedNoteScore, computedRating } = useMemo(() => { + const data: { + musicRating: BigDecimal | null, + reverseScore: number | null, + topIncrease: string | null, + topIndex: number | null, + targetTopScore: number | null, + targetTotalRating: BigDecimal | null, + computedNoteScore: number | null, + computedRating: BigDecimal | null, + } = { musicRating: null, reverseScore: null, topIncrease: null, topIndex: null, targetTopScore: null, targetTotalRating: null, computedNoteScore: null, computedRating: null }; + + if (!music) return data; + + let computedRating: BigDecimal | null = null; + + if (selectedKey === 'rating') { + if (isNaN(+score)) return data; + + data.musicRating = computedRating = chuniRating(Math.max(Math.min(Math.floor(+score), 1010000), 0), music?.level ?? 0, 5n); + } else if (selectedKey === 'score') { + if (isNaN(+rating)) return data; + + computedRating = new BigDecimal(rating); + data.reverseScore = chuniRatingInverse(computedRating, music.level ?? 0); + } else if (selectedKey === 'top') { + if (subtract === null || isNaN(+topRatingIncrease)) return data; + + const ratingIncrease = new BigDecimal(topRatingIncrease).mul(30); + + if (ratingIncrease.compare(0) === 0) { + data.targetTopScore = 0; + data.targetTotalRating = totalTopRating.div(30, 4n); + return data; + } + + const targetSongRating = subtract.add(ratingIncrease); + const targetScore = chuniRatingInverse(targetSongRating, music.level ?? 0); + data.targetTopScore = targetScore; + + if (targetScore === Infinity) return data; + + data.targetTotalRating = totalTopRating.add(ratingIncrease).div(30, 4n); + data.topIndex = calculateTop({ topRating, subtract, totalTopRating, rating: targetSongRating })[1]; + } else { + if (noteCount.order.some(k => !Number.isInteger(+noteCount[k]))) + return data; + + data.computedNoteScore = Math.floor((+noteCount.noteJusticeCritical * 1_010_000 + +noteCount.noteJustice * 1_000_000 + +noteCount.noteAttack * 500_000) / music.allJudgeCount); + computedRating = chuniRating(data.computedNoteScore, music?.level ?? 0, 5n); + } + + if (computedRating !== null) { + data.computedRating = computedRating; + const [topIncrease, topIndex] = calculateTop({ topRating, subtract, totalTopRating, rating: computedRating }); + data.topIncrease = topIncrease; + data.topIndex = topIndex; + } + + return data; + }, [music, score, rating, selectedKey, subtract, totalTopRating, topRating, topRatingIncrease, noteCount]); + + useEffect(() => { + if (music) + dispatchNote(music.allJudgeCount); + }, [music, dispatchNote]); + + let targetScore: number | null; + if (selectedKey === 'rating') + targetScore = Math.max(Math.min(Math.floor(+score), 1010000), 0); + else if (selectedKey === 'score') + targetScore = reverseScore; + else if (selectedKey === 'top') + targetScore = targetTopScore; + else + targetScore = computedNoteScore; + const validScore = targetScore !== null && !isNaN(+targetScore) && isFinite(targetScore); + const rank = !validScore ? null : getRankFromScore(targetScore!); + + const badge = useMemo(() => rank !== null && , [rank]); + const badgeDropdown = useMemo(() => badge !== null && selectedKey !== 'top' && ( + +
+ {badge} +
+
+ + {CHUNI_SCORE_RANKS.map((name, i) => ( { + if (selectedKey === 'rating') + setScore(CHUNI_SCORE_THRESHOLDS[i].toString()); + else if (i === 1) + setRating('0.01'); + else + setRating((Math.ceil(chuniRating(CHUNI_SCORE_THRESHOLDS[i], music?.level!, 3n).mul(100).valueOf()) / 100).toString()); + }}> + + )).reverse()} + +
), [badge, selectedKey]); + + useValueChange(() => { + if (!music || targetScore === null || !Number.isInteger(targetScore)) return; + const justiceRatio = Math.min(Math.max(+ratio, 0), 100) / 100; + if (!Number.isFinite(justiceRatio)) return; + const noteCount = music.allJudgeCount; + + const maxAttackCount = targetScore <= 500000 ? noteCount : + Math.floor((noteCount * (10000 * justiceRatio - targetScore + 1000000)) / (10000 * (justiceRatio + 50))); + + if (maxAttackCount < 0) { + setUnachievable(true); + return; + } + + setUnachievable(false); + + const remainingJusticeCount = noteCount - maxAttackCount; + const justiceCriticalCount = Math.floor(remainingJusticeCount * justiceRatio); + const justiceCount = remainingJusticeCount - justiceCriticalCount; + + setJusticeCount(justiceCount); + + const miss = Math.ceil(Math.min(maxAttackCount * missPercent, Math.floor(maxAttackCount / 2))); + const attack = maxAttackCount - 2 * miss; + dispatchScore({ miss: miss.toString(), attack: attack.toString(), totalAttack: maxAttackCount }); + }, [music, targetScore, ratio], [miss, attack]); + + const scoreTargetText = useMemo(() => { + if (selectedKey === 'rating') return null; + let score: number | null = null; + if (selectedKey === 'score') + score = reverseScore; + else if (selectedKey === 'top') + score = targetTopScore; + else + score = computedNoteScore; + + return (Score: {score === Infinity ? + Error : + {score?.toLocaleString()}}); + }, [selectedKey, reverseScore, targetTopScore, computedNoteScore]); + + const topIncreaseText = topIncrease && (Top +{topIncrease}{topIndex !== null && `, #${topIndex + 1}`}); + + return ( + + {onClose => <> + + Score Calculator + + +
+ { music?.title } { CHUNI_DIFFICULTIES[music?.chartId!] } ({ music?.level }) +
+
+ {music?.scoreMax !== null && <> + High score:  + {music?.scoreMax?.toLocaleString()} ({floorToDp(+music?.rating!, 4)}) + } + + Note count: {music?.allJudgeCount} +
+ setSelectedKey(k as string)} size="sm" data-draggable="true"> + + + {musicRating &&
+ Song rating:  + {musicRating.toString()} + {topIncreaseText} + {badgeDropdown} +
} +
+ + +
+ {scoreTargetText} + {reverseScore !== Infinity && topIncreaseText} + {badgeDropdown} +
+
+ +
+ {([ + ['noteJusticeCritical', 'Justice Critical', 'dark:text-yellow-400 text-yellow-500'], + ['noteJustice', 'Justice', 'text-orange-500'], + ['noteAttack', 'Attack', 'text-emerald-600'], + ['noteMiss', 'Miss', 'text-gray-400'] + ] as const).map(([key, name, className]) => ( + {name}  + dispatchNote({ [key]: v } as any)} /> + ))} +
+
+ {scoreTargetText} + {computedNoteScore !== null && topIncreaseText} + {computedRating !== null &&
+ Rating:  + + {computedRating.toString()} + + {badge} +
} +
+
+ + {maxPossibleTopIncrease === null ?
This chart cannot increase your top rating.
:
+
+ Maximum possible increase to top rating: setTopRatingIncrease(maxPossibleTopIncrease)}> + {maxPossibleTopIncrease} + +
+ +
+ {scoreTargetText} + {targetTotalRating !== null &&  (Top rating + {targetTotalRating.toFixed(3)} + {topIndex !== null && `, #${topIndex + 1}`})} + {badge} +
+
} +
+
+ + {validScore && selectedKey !== 'note' && <> + +
+ + Score {targetScore?.toLocaleString()} with a  + + Justice  + Critical  + accuracy  + of  +
+ + +
+ is  + {unachievable && not } + achievable  + {unachievable ?
: <> + with  + no  + greater  + than:  + +
+ {!!justiceCount &&
+ {justiceCount} Justice +
} +
+ { + dispatchScore({ + attack: (e.target as HTMLInputElement).value, + recompute: (e.nativeEvent as InputEvent).inputType === 'insertReplacementText' + }); + }} + onBlur={() => dispatchScore('recompute')} + /> + + Attack + +
+
+ dispatchScore({ miss: v })} /> + + Miss + +
+
+ } +
+ } +
+ + + + } +
+
) +}; diff --git a/src/helpers/big-decimal.ts b/src/helpers/big-decimal.ts index 6ba880f..18b7268 100644 --- a/src/helpers/big-decimal.ts +++ b/src/helpers/big-decimal.ts @@ -1,13 +1,16 @@ type DecimalInput = BigDecimal | number | string; export class BigDecimal { - private val: bigint; - private decimals: bigint; + private _val: bigint; + private _decimals: bigint; + + static readonly ZERO = new BigDecimal(0); + static readonly ONE = new BigDecimal(1); constructor(val: DecimalInput) { if (val instanceof BigDecimal) { - this.val = val.val; - this.decimals = val.decimals; + this._val = val._val; + this._decimals = val._decimals; return; } @@ -16,12 +19,12 @@ export class BigDecimal { const decimalIndex = val.indexOf('.'); val = val.replace('.', ''); - this.val = BigInt(val); + this._val = BigInt(val); if (decimalIndex === -1) { - this.decimals = 0n; + this._decimals = 0n; } else { - this.decimals = BigInt(val.length - decimalIndex); + this._decimals = BigInt(val.length - decimalIndex); } } @@ -29,12 +32,12 @@ export class BigDecimal { const a = new BigDecimal(other); const b = new BigDecimal(this); - if (a.decimals > b.decimals) { - b.val *= 10n ** (a.decimals - b.decimals); - b.decimals = a.decimals; - } else if (a.decimals < b.decimals) { - a.val *= 10n ** (b.decimals - a.decimals); - a.decimals = b.decimals; + if (a._decimals > b._decimals) { + b._val *= 10n ** (a._decimals - b._decimals); + b._decimals = a._decimals; + } else if (a._decimals < b._decimals) { + a._val *= 10n ** (b._decimals - a._decimals); + a._decimals = b._decimals; } return [a, b]; @@ -42,21 +45,21 @@ export class BigDecimal { add(other: DecimalInput) { const [a, b] = this.coerceDecimals(other); - a.val += b.val; + a._val += b._val; return a; } sub(other: DecimalInput) { const [a, b] = this.coerceDecimals(other); - b.val -= a.val; + b._val -= a._val; return b; } mul(other: DecimalInput) { const a = new BigDecimal(other); const b = new BigDecimal(this); - a.val *= b.val; - a.decimals += b.decimals; + a._val *= b._val; + a._decimals += b._decimals; return a; } @@ -64,33 +67,48 @@ export class BigDecimal { const a = new BigDecimal(other); const b = new BigDecimal(this); - if ((b.decimals - a.decimals) < minDecimals) { - const exp = minDecimals - (b.decimals - a.decimals); - b.val *= 10n ** exp; - b.decimals += exp; + if ((b._decimals - a._decimals) < minDecimals) { + const exp = minDecimals - (b._decimals - a._decimals); + b._val *= 10n ** exp; + b._decimals += exp; } - b.val /= a.val; - b.decimals -= a.decimals; - if (b.decimals < 0) b.decimals = 0n; + b._val /= a._val; + b._decimals -= a._decimals; + if (b._decimals < 0) b._decimals = 0n; return b; } static stringVal(val: bigint, decimals: bigint) { - const str = val.toString(); + if (decimals === 0n || val === 0n) return val.toString(); + const str = val.toString().padStart(Number(decimals), '0'); const pos = -Number(decimals); - return str.slice(0, pos) + '.' + str.slice(pos); + return (str.slice(0, pos) || '0') + '.' + str.slice(pos); } + compare(other: DecimalInput) { + const [b, a] = this.coerceDecimals(other); + if (a._val === b._val) return 0; + if (a._val > b._val) return 1; + return -1; + } toFixed(places: number | bigint) { places = BigInt(places); - if (places >= this.decimals) - return BigDecimal.stringVal(this.val, this.decimals) + '0'.repeat(Number(places - this.decimals)); + if (places >= this._decimals) + return BigDecimal.stringVal(this._val, this._decimals) + '0'.repeat(Number(places - this._decimals)); - return BigDecimal.stringVal(this.val / (10n ** (this.decimals - places)), places); + return BigDecimal.stringVal(this._val / (10n ** (this._decimals - places)), places); + } + + get val() { + return this._val; + } + + get decimals() { + return this._decimals; } valueOf() { @@ -98,9 +116,9 @@ export class BigDecimal { } toString() { - let val = this.val; - let decimals = this.decimals; - while (val && !(val % 10n)) { + let val = this._val; + let decimals = this._decimals; + while (val && !(val % 10n) && decimals) { val /= 10n; --decimals; } diff --git a/src/helpers/chuni/rating.ts b/src/helpers/chuni/rating.ts index 0b7712a..2081c34 100644 --- a/src/helpers/chuni/rating.ts +++ b/src/helpers/chuni/rating.ts @@ -1,6 +1,7 @@ import { sql } from 'kysely'; +import { BigDecimal } from '../big-decimal'; -export const chuniRating = (score: any = sql.raw(`CAST(score.scoreMax AS INT)`), +export const sqlChuniRating = (score: any = sql.raw(`CAST(score.scoreMax AS INT)`), level: any = sql.raw(`(CAST(music.level AS DECIMAL(3, 1)) * 100)`)) => sql` CAST(GREATEST((CASE WHEN ${score} IS NULL THEN NULL @@ -14,3 +15,72 @@ CAST(GREATEST((CASE WHEN ${score} >= 500000 THEN ((${level} - 500) / 2 * (${score} - 500000)) / 300000 ELSE 0 END) / 100, 0) AS DECIMAL(10, 8)) `.as('rating'); + + +export const chuniRating = (score: number, level: number | BigDecimal, decimals = 8n) => { + level = new BigDecimal(level.toFixed(1)).mul(100); + + let rating: BigDecimal; + if (score >= 1009000) + rating = level.add(215); + else if (score >= 1007500) + rating = level.add(200).add(new BigDecimal(score - 1007500).div(100, decimals)); + else if (score >= 1005000) + rating = level.add(150).add(new BigDecimal(score - 1005000).div(50, decimals)); + else if (score >= 1000000) + rating = level.add(100).add(new BigDecimal(score - 1000000).div(100, decimals)); + else if (score >= 975000) + rating = level.add(new BigDecimal(score - 975000).div(250, decimals)); + else if (score >= 900000) + rating = level.sub(500).add(new BigDecimal(score - 900000).div(150, decimals)); + else if (score >= 800000) + rating = level.sub(500).div(2, decimals).add(new BigDecimal(score - 800000).mul(level.sub(500).div(2, decimals)).div(100000, decimals)); + else if (score >= 500000) + rating = level.sub(500).div(2).mul(score - 500000).div(300000); + else + rating = BigDecimal.ZERO; + + if (rating.val < 0n) + rating = BigDecimal.ZERO; + + return rating.div(100, decimals); +}; + +export const chuniRatingInverse = (rating: number | string | BigDecimal, level: number | BigDecimal) => { + const ratingDecimal = new BigDecimal(rating); + rating = ratingDecimal.mul(100); + if (rating.val === 0n) return 0; + + const levelDecimal = new BigDecimal(level.toFixed(1)); + level = levelDecimal.mul(100); + + const diff = rating.sub(level); + const compare = diff.compare(215); + + if (compare > 0) + return Infinity; + if (compare === 0) + return 1009000; + + let val: number; + + if (diff.compare(200) >= 0) + val = diff.add(9875).mul(100).valueOf(); + else if (diff.compare(150) >= 0) + val = diff.add(19950).mul(50).valueOf(); + else if (diff.compare(100) >= 0) + val = diff.add(9900).mul(100).valueOf(); + else if (diff.compare(0) >= 0) + val = diff.add(3900).mul(250).valueOf(); + else if (diff.compare(-500) >= 0) + val = diff.add(6500).mul(150).valueOf(); + // between BBB and A [800000, 900000) + else if (ratingDecimal.compare(chuniRating(800000, levelDecimal, ratingDecimal.decimals)) >= 0) + val = level.mul(7).add(rating.mul(2)).sub(3500).mul(100000) + .div(level.sub(500), 1n).valueOf(); + else + val = level.mul(5).add(rating.mul(6)).sub(2500).mul(100000) + .div(level.sub(500), 1n).valueOf(); + + return Math.ceil(val); +}; diff --git a/src/helpers/chuni/score-ranks.ts b/src/helpers/chuni/score-ranks.ts index 5bf8609..5b10856 100644 --- a/src/helpers/chuni/score-ranks.ts +++ b/src/helpers/chuni/score-ranks.ts @@ -1 +1,10 @@ export const CHUNI_SCORE_RANKS = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+']; +export const CHUNI_SCORE_THRESHOLDS = [0, 500000, 600000, 700000, 800000, 900000, 925000, 950000, 975000, 990000, 1000000, 1005000, 1007500, 1009000]; + + +export const getRankFromScore = (score: number) => { + for (let i = CHUNI_SCORE_THRESHOLDS.length - 1; i >= 0; --i) + if (score >= CHUNI_SCORE_THRESHOLDS[i]) + return i; + return 0; +}; diff --git a/src/helpers/use-value-change.ts b/src/helpers/use-value-change.ts new file mode 100644 index 0000000..883da15 --- /dev/null +++ b/src/helpers/use-value-change.ts @@ -0,0 +1,16 @@ +import { EffectCallback, useEffect, useRef } from 'react'; + +export const useValueChange = (onChange: EffectCallback, vals: any[], deps: any[] = []) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const lastVals = vals.map(val => useRef(val)); + + useEffect(() => { + const last = lastVals.map(r => r.current); + lastVals.forEach((ref, i) => ref.current = vals[i]); + + if (last.every((r, i) => r === vals[i])) + return; + + return onChange(); + }, [...vals, ...deps]) +};