import { expose } from "comlink"; import initDb from "../db/db"; import scoresRepository from "../db/repository/scores"; import rankedsRepository from "../db/repository/rankeds"; import eventBus from "../utils/broadcast-channel-pubsub"; import { convertArrayToObjectByKey } from "../utils/js"; import { diffColors } from "../utils/scoresaber/format"; import { getAccFromScore } from "../utils/scoresaber/song"; import { getTotalPpFromSortedPps, WEIGHT_COEFFICIENT, } from "../utils/scoresaber/pp"; import makePendingPromisePool from "../utils/pending-promises"; import produce, { setAutoFreeze } from "immer"; import beatmapsEnhancer from "../stores/http/enhancers/common/beatmaps"; import accEnhancer from "../stores/http/enhancers/scores/acc"; let db = null; let rankeds = null; const resolvePromiseOrWaitForPending = makePendingPromisePool(); const getPlayerScores = async (playerId) => scoresRepository().getAllFromIndex("scores-playerId", playerId, true); const getRankedsFromDb = async (refreshCache = false) => { const dbRankeds = await rankedsRepository().getAll(refreshCache); return dbRankeds ? convertArrayToObjectByKey(dbRankeds, "leaderboardId") : {}; }; const getRankeds = async (refreshCache = false) => resolvePromiseOrWaitForPending(`rankeds/${refreshCache}`, () => getRankedsFromDb(), ); async function init() { if (db) return; db = await initDb(); // setup immer.js // WORKAROUND for immer.js esm (see https://github.com/immerjs/immer/issues/557) self.process = { env: { NODE_ENV: "production" } }; setAutoFreeze(false); rankeds = getRankeds(); eventBus.on("rankeds-changed", () => (rankeds = getRankeds(true))); } const getRankedScores = async (playerId, withStars = false) => { const scores = await getPlayerScores(playerId); if (!scores || !scores.length) return null; let allRankeds = null; if (withStars) { allRankeds = await rankeds; } return withStars ? ( await Promise.all( scores .filter((score) => score?.score?.pp) .map(async (score) => { score = await produce( await produce(score, (draft) => beatmapsEnhancer(draft, true)), (draft) => accEnhancer(draft), ); return { ...score, stars: allRankeds[score?.leaderboardId]?.stars ?? null, }; }), ) ).filter((s) => s.stars) : scores.filter((score) => score?.score?.pp); }; const getPlayerRankedScoresWithStars = async (playerId) => getRankedScores(playerId, true); const calcPlayerStats = async (playerId) => { await init(); const rankedScores = await getRankedScores(playerId); if (!rankedScores) return null; const stats = rankedScores .filter( (score) => (score?.score?.score && score?.score?.maxScore) || score?.score?.acc, ) .reduce( (cum, s) => { const leaderboardId = s?.leaderboard?.leaderboardId; const pp = s?.score?.pp; const score = s?.score?.unmodifiedScore ?? s?.score?.score ?? 0; const accFromScore = getAccFromScore({ ...s.score, leaderboardId }); const scoreAcc = s?.score?.acc; if (!accFromScore && !scoreAcc) return cum; let acc = accFromScore ? accFromScore : scoreAcc; if (!acc || isNaN(acc)) return cum; s.score.acc = acc; cum.totalScore += score; cum.totalAcc += acc; if (cum.topAcc < acc) cum.topAcc = acc; if (cum.topPp < pp) cum.topPp = pp; cum.badges.forEach((badge) => { if ( (!badge.min || badge.min <= acc) && (!badge.max || badge.max > acc) ) badge.value++; }); return cum; }, { playerId, badges: [ { label: "SS+", min: 95, max: null, value: 0, bgColor: diffColors.expertPlus, }, { label: "SS", min: 90, max: 95, value: 0, bgColor: diffColors.expert, }, { label: "S+", min: 85, max: 90, value: 0, bgColor: diffColors.hard }, { label: "S", min: 80, max: 85, value: 0, bgColor: diffColors.normal, }, { label: "A", min: null, max: 80, value: 0, bgColor: diffColors.easy, }, ], topAcc: 0, topPp: 0, totalAcc: 0, totalScore: 0, avgAcc: 0, playCount: rankedScores.length, medianAcc: 0, stdDeviation: 0, }, ); stats.medianAcc = rankedScores.length > 1 ? rankedScores.sort((a, b) => a.score.acc - b.score.acc)[ Math.ceil(rankedScores.length / 2) ].score.acc : stats.totalAcc; stats.avgAcc = stats.totalAcc / rankedScores.length; stats.stdDeviation = Math.sqrt( rankedScores.reduce( (sum, s) => sum + Math.pow(stats.avgAcc - s.score.acc, 2), 0, ) / rankedScores.length, ); delete stats.totalAcc; eventBus.publish("player-stats-calculated", stats); return stats; }; const calcPpBoundary = async (playerId, expectedPp = 1) => { const rankedScores = await getRankedScores(playerId); if (!rankedScores) return null; const calcRawPpAtIdx = (bottomScores, idx, expected) => { const oldBottomPp = getTotalPpFromSortedPps(bottomScores, idx); const newBottomPp = getTotalPpFromSortedPps(bottomScores, idx + 1); // 0.965^idx * rawPpToFind = expected + oldBottomPp - newBottomPp; // rawPpToFind = (expected + oldBottomPp - newBottomPp) / 0.965^idx; return ( (expected + oldBottomPp - newBottomPp) / Math.pow(WEIGHT_COEFFICIENT, idx) ); }; const rankedScorePps = rankedScores.map((s) => s.pp).sort((a, b) => b - a); let idx = rankedScorePps.length - 1; while (idx >= 0) { const bottomSlice = rankedScorePps.slice(idx); const bottomPp = getTotalPpFromSortedPps(bottomSlice, idx); bottomSlice.unshift(rankedScorePps[idx]); const modifiedBottomPp = getTotalPpFromSortedPps(bottomSlice, idx); const diff = modifiedBottomPp - bottomPp; if (diff > expectedPp) { const ppBoundary = calcRawPpAtIdx( rankedScorePps.slice(idx + 1), idx + 1, expectedPp, ); eventBus.publish("player-pp-boundary-calculated", { playerId, expectedPp, ppBoundary, }); return ppBoundary; } idx--; } const ppBoundary = calcRawPpAtIdx(rankedScorePps, 0, expectedPp); eventBus.publish("player-pp-boundary-calculated", { playerId, expectedPp, ppBoundary, }); return ppBoundary; }; const worker = { init, calcPlayerStats, calcPpBoundary, getPlayerRankedScoresWithStars, }; expose(worker);