chuni: add score calculator

This commit is contained in:
sk1982 2024-04-17 04:48:35 -04:00
parent 71c0450ea3
commit 3a8595a1d4
17 changed files with 839 additions and 140 deletions

32
.vscode/launch.json vendored Normal file
View File

@ -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": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithEdge",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}

View File

@ -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<boolean>('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'))),

View File

@ -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<number>`(playlog.playerRating - (LEAD(playlog.playerRating) OVER (ORDER BY id DESC)))`
.as('playerRatingChange')
] as const)

View File

@ -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<string>`CAST(score.scoreMax AS INT)`.as('scoreMax'),
lit<number>(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<ReturnType<typeof getUserRating>>;
const validators = new Map<keyof GeneratedDB['chuni_profile_data'], (user: UserPayload, profile: NonNullable<ChuniUserData>, value: any) => Promise<any>>();
const itemValidators = [

View File

@ -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<ReturnType<typeof getUserRating>> }) => {
export const ChuniTopRatingSidebar = ({ rating }: { rating: ChuniUserRating }) => {
const [shownRating, setShownRating] = useState<'top' | 'recent' | null>('recent');
const breakpoint = useBreakpoint();

View File

@ -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 ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
</Button>}
</MusicPlayer>
<ChuniMusicPlaylog music={music} playlog={playlog} />
<ChuniMusicPlaylog music={music} playlog={playlog} rating={rating} />
</div>);
};

View File

@ -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<string>());
const [scoreCalculator, setScoreCalculator] = useState<ChuniScoreCalculatorProps['music']>(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 (<div className="flex flex-col w-full px-1 sm:px-0">
<ChuniScoreCalculator music={scoreCalculator} topRating={rating} onClose={() => setScoreCalculator(null)} />
<Accordion selectionMode="multiple" selectedKeys={selected}>
{difficulties.map((data, i) => {
const rank = CHUNI_SCORE_RANKS[data.scoreRank!];
const badges = [
!!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={`${badgeClass} tracking-[0.05cqw]`} key="1">
{rank.endsWith('+') ? <>
{rank.slice(0, -1)}
<div className="inline-block translate-y-[-15cqh]">+</div>
</> : rank}
</ChuniScoreBadge>,
data.scoreRank !== null && <ChuniScoreRankBadge key="1" rank={data.scoreRank} className={badgeClass} />,
data.isSuccess ? <ChuniLampSuccessBadge key="2" className={badgeClass} success={data.isSuccess} /> : null,
(data.isFullCombo || data.isAllJustice) && <ChuniLampComboBadge key="3" className={badgeClass} {...data} />
].filter(x => x);
// <div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap items-center">
return (<AccordionItem key={i.toString()} classNames={{ trigger: 'py-0 my-2' }} title={<div className="flex flex-row flex-wrap items-center gap-y-1.5"
onClick={() => {
return (<AccordionItem key={i.toString()} classNames={{ trigger: 'py-0 my-2' }} onClick={() => {
const key = i.toString();
setSelected(s => s.has(key) ? new Set([...s].filter(k => k !== key)) : new Set([...s, key]))
}}>
}} title={<div className="flex flex-row flex-wrap items-center gap-y-1.5 -mr-3">
<div className={`flex items-center gap-2 flex-wrap lg:flex-grow ${data.playlog.length ? 'cursor-pointer w-full lg:w-auto' : 'flex-grow'}`}>
<div className="flex items-center">
<div className="w-14 mr-2 p-0.5 bg-black">
@ -63,7 +61,7 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div>
</div>
{!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>}
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
{data.rating && data.chartId !== 5 ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0 max-sm:text-sm">
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
</div> : null}
@ -74,6 +72,16 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
{badges.length ? <div className={`flex-grow items-center lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`}>
{badges}
</div> : null}
{data.chartId !== 5 ? <Tooltip content="Score calculator">
<div className="ml-2 text-gray-400 rounded-full w-10 h-10 flex items-center justify-center transition hover:bg-default"
onClick={e => {
e.stopPropagation();
setScoreCalculator(data);
}}>
<CalculatorIcon className="h-3/4" />
</div>
</Tooltip> : <Spacer />}
</div>}>
<div className="flex flex-wrap gap-x-4 gap-y-2 mb-3 justify-center sm:justify-end max-sm:text-xs">
<span className="mr-auto max-sm:w-full text-center"><span className="font-semibold">Chart designer:</span> {data.chartDesigner}</span>

View File

@ -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 (<ChuniMusicDetail music={music} playlog={playlog} />)
return (<ChuniMusicDetail music={music} playlog={playlog} rating={rating} />)
}

View File

@ -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<ChuniMusic | null>(null);
return (<WindowScrollerGrid rowSize={itemHeight} colSize={itemWidth} items={music}>
{item => <TickerHoverProvider>
{setHover => <div className={itemClass}><ChuniDifficultyContainer difficulty={item.chartId!}
containerClassName="flex flex-col"
className="w-full h-full border border-gray-500/75 rounded-md"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}>
<div className="aspect-square w-full p-[0.2rem] relative">
<Link href={`/chuni/music/${item.songId}`}>
<Image width={100} height={100}
src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)}
alt={item.title ?? 'Music'} className="rounded w-full h-full" />
</Link>
{item.rating && !item.worldsEndTag && <div className={`${size === 'lg' ? 'text-2xl' : ''} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm moz-no-backdrop-blur px-0.5 rounded`}>
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
return (<>
<ChuniScoreCalculator music={scoreCalculator} onClose={() => setScoreCalculator(null)} topRating={topRating} />
<WindowScrollerGrid rowSize={itemHeight} colSize={itemWidth} items={music}>
{item => <TickerHoverProvider>
{setHover => <div className={itemClass}><ChuniDifficultyContainer difficulty={item.chartId!}
containerClassName="flex flex-col"
className="w-full h-full border border-gray-500/75 rounded-md"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}>
<div className="aspect-square w-full p-[0.2rem] relative">
<Link href={`/chuni/music/${item.songId}`}>
<Image width={100} height={100}
src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)}
alt={item.title ?? 'Music'} className="rounded w-full h-full" />
</Link>
{item.rating && !item.worldsEndTag && <div className={`${size === 'lg' ? 'text-2xl' : ''} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm moz-no-backdrop-blur px-0.5 rounded`}>
<ChuniRating rating={+item.rating * 100} className="-my-0.5">
{item.rating.slice(0, item.rating.indexOf('.') + 3)}
</ChuniRating>
</div>}
<ChuniLevelBadge className={`${size === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
</ChuniRating>
</div>}
<ChuniLevelBadge className={`${size === 'lg' ? 'h-14' : 'w-14'} absolute bottom-px right-px`} music={item} />
{!!user?.chuni && <Button isIconOnly className={`absolute top-0 left-0 pt-1 bg-gray-600/25 ${item.favorite ? 'text-red-500' : ''}`}
size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
onPress={() => {
if (pendingFavorite) return;
const favorite = item.favorite;
setMusicList(fullMusicList.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite: !favorite };
}));
setPendingFavorite(true);
(item.favorite ? removeFavoriteMusic : addFavoriteMusic)(item.songId!)
.then(res => {
if (res?.error) {
setMusicList(fullMusicList.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite };
}));
return setError(`Failed to set favorite: ${res.message}`);
}
})
.finally(() => setPendingFavorite(false));
}}>
{item.favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
</Button>}
</div>
<div className="px-0.5 mb-1 flex">
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
{item.isSuccess ? <ChuniLampSuccessBadge success={item.isSuccess} /> : null}
</div>}
<Tooltip content="Score Calculator">
<Button isIconOnly className="absolute top-0 right-0 pt-0.5 bg-gray-600/25" size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
onPress={() => setScoreCalculator(item)}>
<CalculatorIcon className="w-3/4" />
</Button>
</Tooltip>
<div className={`h-full ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
{item.scoreMax!.toLocaleString()}
</ChuniScoreBadge>}
{!!user?.chuni && <Button isIconOnly className={`absolute top-0 left-0 pt-1 bg-gray-600/25 ${item.favorite ? 'text-red-500' : ''}`}
size={size === 'xs' ? 'sm' : 'md'} variant="flat" radius="full"
onPress={() => {
if (pendingFavorite) return;
const favorite = item.favorite;
setMusicList(fullMusicList.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite: !favorite };
}));
setPendingFavorite(true);
(item.favorite ? removeFavoriteMusic : addFavoriteMusic)(item.songId!)
.then(res => {
if (res?.error) {
setMusicList(fullMusicList.map(m => {
if (m.songId !== item.songId)
return m;
return { ...m, favorite };
}));
return setError(`Failed to set favorite: ${res.message}`);
}
})
.finally(() => setPendingFavorite(false));
}}>
{item.favorite ? <SolidHeartIcon className="w-3/4" /> : <OutlineHeartIcon className="w-3/4" />}
</Button>}
</div>
<div className="px-0.5 mb-1 flex">
{size === 'lg' && <div className="h-full w-1/3 mr-0.5">
{item.isSuccess ? <ChuniLampSuccessBadge success={item.isSuccess} /> : null}
</div>}
<div className={`h-full ml-0.5 ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
<ChuniLampComboBadge {...item} />
<div className={`h-full ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
{item.scoreRank !== null && <ChuniScoreBadge variant={getVariantFromRank(item.scoreRank)} className="h-full">
{item.scoreMax!.toLocaleString()}
</ChuniScoreBadge>}
</div>
<div className={`h-full ml-0.5 ${size === 'lg' ? 'w-1/3' : 'w-1/2'}`}>
<ChuniLampComboBadge {...item} />
</div>
</div>
</div>
<Link href={`/chuni/music/${item.songId}`}
className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
<Ticker hoverOnly noDelay>{item.title}</Ticker>
</Link>
<Ticker className={`${size === 'lg' ? 'text-medium mb-1.5' : 'text-xs mb-0.5' } text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
</ChuniDifficultyContainer></div>}
</TickerHoverProvider>}
</WindowScrollerGrid>);
<Link href={`/chuni/music/${item.songId}`}
className={`${size === 'lg' ? 'text-lg' : 'text-xs'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
<Ticker hoverOnly noDelay>{item.title}</Ticker>
</Link>
<Ticker className={`${size === 'lg' ? 'text-medium mb-1.5' : 'text-xs mb-0.5'} text-center px-1 drop-shadow-2xl text-white`} hoverOnly noDelay>{item.artist}</Ticker>
</ChuniDifficultyContainer></div>}
</TickerHoverProvider>}
</WindowScrollerGrid>
</>);
};
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) => <div className="w-full flex-grow my-2">
<MusicGrid music={data} size={DISPLAY_IDS[displayMode as keyof typeof DISPLAY_IDS]}
fullMusicList={localMusic} setMusicList={setLocalMusic} />
fullMusicList={localMusic} setMusicList={setLocalMusic} topRating={topRating} />
</div>}
</FilterSorter>);
};

View File

@ -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 (
<ChuniMusicList music={structuredClone(music)} />
<ChuniMusicList music={structuredClone(music)} topRating={rating} />
);
}

View File

@ -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;

View File

@ -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 (<ChuniScoreBadge variant={getVariantFromLamp(success)} className={`${className ?? ''} ${fontSize === 'md' ? 'tracking-[0.1cqw]' : 'tracking-[0.025cqw]'}`} fontSize={fontSize}>
{text}
return (<ChuniScoreBadge variant={getVariantFromLamp(success)} className={className} fontSize={fontSize}>
<span className={fontSize === 'md' ? 'tracking-[1.5cqw]' : 'tracking-[0.75cqw]'}>
{text}
</span>
</ChuniScoreBadge>)
}
@ -89,3 +92,15 @@ export const ChuniLampComboBadge = ({ className, isFullCombo, isAllJustice }: {
{isAllJustice ? 'ALL JUSTICE' : 'FULL COMBO'}
</ChuniScoreBadge>)
}
export const ChuniScoreRankBadge = ({ rank, ...rest }: { rank: number } & Pick<ChuniScoreBadgeProps, 'className' | 'fontSize'>) => {
const scoreRank = CHUNI_SCORE_RANKS[rank];
return (<ChuniScoreBadge variant={getVariantFromRank(rank)} {...rest}>
<div className="tracking-[1cqw]">
{scoreRank.endsWith('+') ? <>
{scoreRank.slice(0, -1)}
<div className="inline-block translate-y-[-15cqh]">+</div>
</> : scoreRank}
</div>
</ChuniScoreBadge>);
};

View File

@ -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<ChuniMusic, 'scoreMax' | 'allJudgeCount' | 'level' | 'songId' | 'chartId' | 'title' | 'rating'> | 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<NoteValues> | 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<typeof action>)[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 && <ChuniScoreRankBadge className="h-5 sm:h-8 ml-auto my-auto" rank={rank} />, [rank]);
const badgeDropdown = useMemo(() => badge !== null && selectedKey !== 'top' && (<Dropdown className="!min-w-0">
<DropdownTrigger>
<div className="ml-auto my-auto">
{badge}
</div>
</DropdownTrigger>
<DropdownMenu>
{CHUNI_SCORE_RANKS.map((name, i) => (<DropdownItem key={i} className="py-1 px-0.5" onPress={() => {
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());
}}>
<ChuniScoreRankBadge className="h-6" rank={i} />
</DropdownItem>)).reverse()}
</DropdownMenu>
</Dropdown>), [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 (<span className="pt-0.5"><span className="sm:text-lg text-xs">Score: </span>{score === Infinity ?
<span className="text-danger">Error</span> :
<span className="text-xs sm:text-xl font-semibold">{score?.toLocaleString()}</span>}</span>);
}, [selectedKey, reverseScore, targetTopScore, computedNoteScore]);
const topIncreaseText = topIncrease && <span className="text-xs sm:text-sm mt-auto pb-0.5 sm:pb-1 ml-2.5">(Top +{topIncrease}{topIndex !== null && `, #${topIndex + 1}`})</span>;
return (<Modal isOpen={music !== null} onClose={onModalClose} size="3xl">
<ModalContent>
{onClose => <>
<ModalHeader>
Score Calculator
</ModalHeader>
<ModalBody>
<header className="font-semibold text-lg flex">
{ music?.title } <span className="ml-auto text-right">{ CHUNI_DIFFICULTIES[music?.chartId!] } ({ music?.level }) </span>
</header>
<div className="flex text-xs sm:text-sm md:text-medium">
{music?.scoreMax !== null && <>
<span className="font-semibold">High score:&nbsp;</span>
{music?.scoreMax?.toLocaleString()} ({floorToDp(+music?.rating!, 4)})
</>}
<span className="ml-auto text-right">Note count: {music?.allJudgeCount}</span>
</div>
<Tabs fullWidth selectedKey={selectedKey} onSelectionChange={k => setSelectedKey(k as string)} size="sm" data-draggable="true">
<Tab key="rating" title="Score to Rating" className="p-0 m-0">
<Input labelPlacement="outside-left" placeholder="Enter score" label="Score" type="number" inputMode="numeric" min="0" max="1010000" step="100"
size="md" classNames={{ mainWrapper: 'w-full' }}
value={score} onValueChange={setScore} />
{musicRating && <div className="sm:text-lg flex mt-2.5 flex-wrap">
<span className="mt-auto text-sm sm:text-lg">Song rating:&nbsp;</span>
<ChuniRating rating={musicRating.mul(100).valueOf()} className="inline text-lg max-sm:-mb-1 sm:text-2xl mt-auto">{musicRating.toString()}</ChuniRating>
{topIncreaseText}
{badgeDropdown}
</div>}
</Tab>
<Tab key="score" title="Rating to Score" className="p-0 m-0">
<Input labelPlacement="outside-left" placeholder="Enter rating" label="Rating" type="number" inputMode="numeric" min="0" max={music?.level! + 2.15} step="0.01"
size="md" classNames={{ mainWrapper: 'w-full' }}
value={rating} onValueChange={setRating} />
<div className="mt-2.5 flex">
{scoreTargetText}
{reverseScore !== Infinity && topIncreaseText}
{badgeDropdown}
</div>
</Tab>
<Tab key="note" title="Note Count">
<div className="flex w-full justify-around text-xs sm:text-sm md:text-medium gap-x-2 items-end">
{([
['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]) => (<span key={key} className={`${className} drop-shadow text-center max-sm:flex-1`}>
{name}&nbsp;
<Input className="max-sm:mt-1 inline-block max-w-[80px]" size="sm" type="number" inputMode="numeric" min="0" max={music?.allJudgeCount}
classNames={{ input: '!text-inherit' }} value={noteCount[key]} onValueChange={v => dispatchNote({ [key]: v } as any)} />
</span>))}
</div>
<div className="mt-2.5 flex items-center flex-wrap">
{scoreTargetText}
{computedNoteScore !== null && topIncreaseText}
{computedRating !== null && <div className="w-full flex">
<span className="text-xs sm:text-sm md:text-medium self-center">Rating:&nbsp;</span>
<ChuniRating rating={computedRating.mul(100).valueOf()} className="inline-block text-lg sm:text-xl md:text-2xl">
{computedRating.toString()}
</ChuniRating>
{badge}
</div>}
</div>
</Tab>
<Tab key="top" title="Top Rating" className="p-0 m-0">
{maxPossibleTopIncrease === null ? <header className="italic text-gray-500 w-full text-center mt-3 max-sm:text-sm">This chart cannot increase your top rating.</header> : <div>
<div className="w-full text-center text-xs sm:text-sm text-gray-500 mb-3 mt-1">
Maximum possible increase to top rating: <span className="cursor-pointer underline transition hover:text-foreground"
onClick={() => setTopRatingIncrease(maxPossibleTopIncrease)}>
{maxPossibleTopIncrease}
</span>
</div>
<Input labelPlacement="outside-left" placeholder="Enter rating" label="Top rating increase" type="number" inputMode="numeric" min="0" max={maxPossibleTopIncrease} step="0.01"
size="md" classNames={{ mainWrapper: 'w-full', label: 'text-nowrap mr-1.5' }}
value={topRatingIncrease}
onValueChange={setTopRatingIncrease} />
<div className="mt-2.5 flex items-center">
{scoreTargetText}
{targetTotalRating !== null && <span className="max-sm:mt-0.5 text-xs sm:text-sm md:text-medium">&nbsp;(Top rating <ChuniRating className="inline-block text-sm md:text-lg" rating={targetTotalRating.mul(100)?.valueOf()!}>
{targetTotalRating.toFixed(3)}
</ChuniRating>{topIndex !== null && `, #${topIndex + 1}`})</span>}
{badge}
</div>
</div>}
</Tab>
</Tabs>
{validScore && selectedKey !== 'note' && <>
<Divider />
<section className="text-xs md:text-sm lg:text-medium flex gap-y-2 flex-wrap items-center">
<span>
Score {targetScore?.toLocaleString()} with a&nbsp;
</span>
<span className="dark:text-yellow-400 text-yellow-500 drop-shadow">Justice&nbsp;</span>
<span className="dark:text-yellow-400 text-yellow-500 drop-shadow">Critical&nbsp;</span>
<span>accuracy&nbsp;</span>
<span>of&nbsp;</span>
<div>
<Input className="inline-block w-[83px]" size="sm" inputMode="numeric" type="number" min="0" max="100"
value={ratio} onValueChange={setRatio} />
<span>%&nbsp;</span>
</div>
<span>is&nbsp;</span>
{unachievable && <span className="font-bold">not&nbsp;</span>}
<span>achievable&nbsp;</span>
{unachievable ? <div className="h-8 w-full" /> : <>
<span>with&nbsp;</span>
<span>no&nbsp;</span>
<span>greater&nbsp;</span>
<span>than:&nbsp;</span>
<div className="flex gap-6 max-sm:w-full max-sm:mx-auto ml-auto items-center justify-center">
{!!justiceCount && <div>
<span className="text-orange-500">{justiceCount} Justice</span>
</div>}
<div>
<Input className="inline-block w-20 mr-1" size="sm" inputMode="numeric" type="number" min="0" step="1" classNames={{ input: '!text-emerald-600' }}
value={attack.toString()}
onInput={e => {
dispatchScore({
attack: (e.target as HTMLInputElement).value,
recompute: (e.nativeEvent as InputEvent).inputType === 'insertReplacementText'
});
}}
onBlur={() => dispatchScore('recompute')}
/>
<span className="text-emerald-600">
Attack
</span>
</div>
<div>
<Input className="inline-block w-20 mr-1" size="sm" inputMode="numeric" type="number" min="0" step="1" classNames={{ input: '!text-gray-400' }}
value={miss.toString()} onValueChange={v => dispatchScore({ miss: v })} />
<span className="text-gray-400">
Miss
</span>
</div>
</div>
</>}
</section>
</>}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>)
};

View File

@ -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;
}

View File

@ -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<string>`
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);
};

View File

@ -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;
};

View File

@ -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])
};