diff --git a/app/Http/Controllers/BeatmapPacksController.php b/app/Http/Controllers/BeatmapPacksController.php index 9337dbd78c5..f8a368a3f3a 100644 --- a/app/Http/Controllers/BeatmapPacksController.php +++ b/app/Http/Controllers/BeatmapPacksController.php @@ -5,10 +5,10 @@ namespace App\Http\Controllers; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapPack; use App\Transformers\BeatmapPackTransformer; -use Auth; /** * @group Beatmap Packs @@ -100,7 +100,11 @@ public function show($idOrTag) $pack = $query->where('tag', $idOrTag)->firstOrFail(); $mode = Beatmap::modeStr($pack->playmode ?? 0); $sets = $pack->beatmapsets; - $userCompletionData = $pack->userCompletionData(Auth::user()); + $currentUser = \Auth::user(); + $userCompletionData = $pack->userCompletionData( + $currentUser, + ScoreSearchParams::showLegacyForUser($currentUser), + ); if (is_api_request()) { return json_item( diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 97158c45737..585a907af05 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -10,6 +10,9 @@ use App\Jobs\Notifications\BeatmapOwnerChange; use App\Libraries\BeatmapDifficultyAttributes; use App\Libraries\Score\BeatmapScores; +use App\Libraries\Score\UserRank; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapsetEvent; use App\Models\Score\Best\Model as BestModel; @@ -51,6 +54,66 @@ private static function baseScoreQuery(Beatmap $beatmap, $mode, $mods, $type = n return $query; } + private static function beatmapScores(string $id, ?string $scoreTransformerType, ?bool $isLegacy): array + { + $beatmap = Beatmap::findOrFail($id); + if ($beatmap->approved <= 0) { + return ['scores' => []]; + } + + $params = get_params(request()->all(), null, [ + 'limit:int', + 'mode', + 'mods:string[]', + 'type:string', + ], ['null_missing' => true]); + + if ($params['mode'] !== null) { + $rulesetId = Beatmap::MODES[$params['mode']] ?? null; + if ($rulesetId === null) { + throw new InvariantException('invalid mode specified'); + } + } + $rulesetId ??= $beatmap->playmode; + $mods = array_values(array_filter($params['mods'] ?? [])); + $type = presence($params['type'], 'global'); + $currentUser = \Auth::user(); + + static::assertSupporterOnlyOptions($currentUser, $type, $mods); + + $esFetch = new BeatmapScores([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => $isLegacy, + 'limit' => $params['limit'], + 'mods' => $mods, + 'ruleset_id' => $rulesetId, + 'type' => $type, + 'user' => $currentUser, + ]); + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); + $userScore = $esFetch->userBest(); + $scoreTransformer = new ScoreTransformer($scoreTransformerType); + + $results = [ + 'scores' => json_collection( + $scores, + $scoreTransformer, + static::DEFAULT_SCORE_INCLUDES + ), + ]; + + if (isset($userScore)) { + $results['user_score'] = [ + 'position' => $esFetch->rank($userScore), + 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), + ]; + // TODO: remove this old camelCased json field + $results['userScore'] = $results['user_score']; + } + + return $results; + } + public function __construct() { parent::__construct(); @@ -280,7 +343,7 @@ public function show($id) /** * Get Beatmap scores * - * Returns the top scores for a beatmap + * Returns the top scores for a beatmap. Depending on user preferences, this may only show legacy scores. * * --- * @@ -296,61 +359,19 @@ public function show($id) */ public function scores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode:string', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - $mode = presence($params['mode']) ?? $beatmap->mode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type']) ?? 'global'; - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $query = static::baseScoreQuery($beatmap, $mode, $mods, $type); - - if ($currentUser !== null) { - // own score shouldn't be filtered by visibleUsers() - $userScore = (clone $query)->where('user_id', $currentUser->user_id)->first(); - } - - $scoreTransformer = new ScoreTransformer(); - - $results = [ - 'scores' => json_collection( - $query->visibleUsers()->forListing($params['limit']), - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $userScore->userRank(compact('type', 'mods')), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores( + $id, + null, + // TODO: change to imported name after merge with other PRs + \App\Libraries\Search\ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); } /** - * Get Beatmap scores (temp) + * Get Beatmap scores (non-legacy) * * Returns the top scores for a beatmap from newer client. * - * This is a temporary endpoint. - * * --- * * ### Response Format @@ -365,62 +386,7 @@ public function scores($id) */ public function soloScores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - if ($params['mode'] !== null) { - $rulesetId = Beatmap::MODES[$params['mode']] ?? null; - if ($rulesetId === null) { - throw new InvariantException('invalid mode specified'); - } - } - $rulesetId ??= $beatmap->playmode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type'], 'global'); - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $esFetch = new BeatmapScores([ - 'beatmap_ids' => [$beatmap->getKey()], - 'is_legacy' => false, - 'limit' => $params['limit'], - 'mods' => $mods, - 'ruleset_id' => $rulesetId, - 'type' => $type, - 'user' => $currentUser, - ]); - $scores = $esFetch->all()->loadMissing(['beatmap', 'performance', 'user.country', 'user.userProfileCustomization']); - $userScore = $esFetch->userBest(); - $scoreTransformer = new ScoreTransformer(ScoreTransformer::TYPE_SOLO); - - $results = [ - 'scores' => json_collection( - $scores, - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $esFetch->rank($userScore), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores($id, ScoreTransformer::TYPE_SOLO, false); } public function updateOwner($id) @@ -481,13 +447,25 @@ public function userScore($beatmapId, $userId) $mode = presence($params['mode'] ?? null, $beatmap->mode); $mods = array_values(array_filter($params['mods'] ?? [])); - $score = static::baseScoreQuery($beatmap, $mode, $mods) - ->visibleUsers() - ->where('user_id', $userId) - ->firstOrFail(); + $baseParams = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'limit' => 1, + 'mods' => $mods, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $score = (new ScoreSearch($baseParams))->records()->first(); + abort_if($score === null, 404); + + $rankParams = clone $baseParams; + $rankParams->beforeScore = $score; + $rankParams->userId = null; + $rank = UserRank::getRank($rankParams); return [ - 'position' => $score->userRank(compact('mods')), + 'position' => $rank, 'score' => json_item( $score, new ScoreTransformer(), @@ -518,12 +496,14 @@ public function userScoreAll($beatmapId, $userId) { $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); $mode = presence(get_string(request('mode'))) ?? $beatmap->mode; - $scores = BestModel::getClass($mode) - ::default() - ->where([ - 'beatmap_id' => $beatmap->getKey(), - 'user_id' => $userId, - ])->get(); + $params = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $scores = (new ScoreSearch($params))->records(); return [ 'scores' => json_collection($scores, new ScoreTransformer()), diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..d29c4b7b5ef 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -12,6 +12,7 @@ use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; +use App\Libraries\Search\ScoreSearchParams; use App\Libraries\User\FindForProfilePage; use App\Libraries\UserRegistration; use App\Models\Beatmap; @@ -19,6 +20,7 @@ use App\Models\Country; use App\Models\IpBan; use App\Models\Log; +use App\Models\Solo\Score as SoloScore; use App\Models\User; use App\Models\UserAccountHistory; use App\Models\UserNotFound; @@ -176,7 +178,7 @@ public function extraPages($_id, $page) 'monthly_playcounts' => json_collection($this->user->monthlyPlaycounts, new UserMonthlyPlaycountTransformer()), 'recent' => $this->getExtraSection( 'scoresRecent', - $this->user->scores($this->mode, true)->includeFails(false)->count() + $this->user->recentScoreCount($this->mode) ), 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()), ]; @@ -191,7 +193,7 @@ public function extraPages($_id, $page) return [ 'best' => $this->getExtraSection( 'scoresBest', - count($this->user->beatmapBestScoreIds($this->mode)) + count($this->user->beatmapBestScoreIds($this->mode, ScoreSearchParams::showLegacyForUser(\Auth::user()))) ), 'firsts' => $this->getExtraSection( 'scoresFirsts', @@ -787,15 +789,25 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresBest': $transformer = new ScoreTransformer(); $includes = [...ScoreTransformer::USER_PROFILE_INCLUDES, 'weight']; - $collection = $this->user->beatmapBestScores($this->mode, $perPage, $offset, ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); + $collection = $this->user->beatmapBestScores( + $this->mode, + $perPage, + $offset, + ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); $userRelationColumn = 'user'; break; case 'scoresFirsts': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scoresFirst($this->mode, true) - ->visibleUsers() - ->reorderBy('score_id', 'desc') + $scoreQuery = $this->user->scoresFirst($this->mode, true)->unorder(); + $userFirstsQuery = $scoreQuery->select($scoreQuery->qualifyColumn('score_id')); + $query = SoloScore + ::whereIn('legacy_score_id', $userFirstsQuery) + ->where('ruleset_id', Beatmap::MODES[$this->mode]) + ->default() + ->reorderBy('id', 'desc') ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; @@ -814,9 +826,12 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresRecent': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scores($this->mode, true) + $query = $this->user->soloScores() + ->default() + ->forRuleset($this->mode) ->includeFails($options['includeFails'] ?? false) - ->with([...ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 'best']); + ->reorderBy('id', 'desc') + ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; } diff --git a/app/Jobs/RemoveBeatmapsetSoloScores.php b/app/Jobs/RemoveBeatmapsetSoloScores.php index b70d45947e4..cfe16299e1a 100644 --- a/app/Jobs/RemoveBeatmapsetSoloScores.php +++ b/app/Jobs/RemoveBeatmapsetSoloScores.php @@ -11,7 +11,6 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use DB; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -68,7 +67,6 @@ private function deleteScores(Collection $scores): void $scoresQuery->update(['preserve' => false]); $this->scoreSearch->queueForIndex($this->schemas, $ids); DB::transaction(function () use ($ids, $scoresQuery): void { - ScorePerformance::whereKey($ids)->delete(); $scoresQuery->delete(); }); } diff --git a/app/Libraries/Elasticsearch/Search.php b/app/Libraries/Elasticsearch/Search.php index 2f507589456..3e36086e175 100644 --- a/app/Libraries/Elasticsearch/Search.php +++ b/app/Libraries/Elasticsearch/Search.php @@ -22,10 +22,8 @@ abstract class Search extends HasSearch implements Queryable /** * A tag to use when logging timing of fetches. * FIXME: context-based tagging would be nicer. - * - * @var string|null */ - public $loggingTag; + public ?string $loggingTag; protected $aggregations; protected $index; diff --git a/app/Libraries/Score/FetchDedupedScores.php b/app/Libraries/Score/FetchDedupedScores.php index 4e9f1ce2813..198d6d821af 100644 --- a/app/Libraries/Score/FetchDedupedScores.php +++ b/app/Libraries/Score/FetchDedupedScores.php @@ -15,8 +15,11 @@ class FetchDedupedScores private int $limit; private array $result; - public function __construct(private string $dedupeColumn, private ScoreSearchParams $params) - { + public function __construct( + private string $dedupeColumn, + private ScoreSearchParams $params, + private ?string $searchLoggingTag = null + ) { $this->limit = $this->params->size; } @@ -24,6 +27,7 @@ public function all(): array { $this->params->size = $this->limit + 50; $search = new ScoreSearch($this->params); + $search->loggingTag = $this->searchLoggingTag; $nextCursor = null; $hasNext = true; diff --git a/app/Libraries/Search/BeatmapsetSearch.php b/app/Libraries/Search/BeatmapsetSearch.php index ec69b4ec3c2..7cd61548832 100644 --- a/app/Libraries/Search/BeatmapsetSearch.php +++ b/app/Libraries/Search/BeatmapsetSearch.php @@ -13,8 +13,9 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Follow; -use App\Models\Score; +use App\Models\Solo; use App\Models\User; +use Ds\Set; class BeatmapsetSearch extends RecordSearch { @@ -423,38 +424,36 @@ private function addTextFilter(BoolQuery $query, string $paramField, array $fiel private function getPlayedBeatmapIds(?array $rank = null) { - $unionQuery = null; + $query = Solo\Score + ::where('user_id', $this->params->user->getKey()) + ->whereIn('ruleset_id', $this->getSelectedModes()); - $select = $rank === null ? 'beatmap_id' : ['beatmap_id', 'score', 'rank']; + if ($rank === null) { + return $query->distinct('beatmap_id')->pluck('beatmap_id'); + } - foreach ($this->getSelectedModes() as $mode) { - $newQuery = Score\Best\Model::getClassByRulesetId($mode) - ::forUser($this->params->user) - ->select($select); + $topScores = []; + $scoreField = ScoreSearchParams::showLegacyForUser($this->params->user) + ? 'legacy_total_score' + : 'total_score'; + foreach ($query->get() as $score) { + $prevScore = $topScores[$score->beatmap_id] ?? null; - if ($unionQuery === null) { - $unionQuery = $newQuery; - } else { - $unionQuery->union($newQuery); + $scoreValue = $score->$scoreField; + if ($scoreValue !== null && ($prevScore === null || $prevScore->$scoreField < $scoreValue)) { + $topScores[$score->beatmap_id] = $score; } } - if ($rank === null) { - return model_pluck($unionQuery, 'beatmap_id'); - } else { - $allScores = $unionQuery->get(); - $beatmapRank = collect(); - - foreach ($allScores as $score) { - $prevScore = $beatmapRank[$score->beatmap_id] ?? null; - - if ($prevScore === null || $prevScore->score < $score->score) { - $beatmapRank[$score->beatmap_id] = $score; - } + $ret = []; + $rankSet = new Set($rank); + foreach ($topScores as $beatmapId => $score) { + if ($rankSet->contains($score->rank)) { + $ret[] = $beatmapId; } - - return $beatmapRank->whereInStrict('rank', $rank)->pluck('beatmap_id')->all(); } + + return $ret; } private function getSelectedModes() diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index c7125d1ce28..332f95d2263 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -48,6 +48,9 @@ public function getQuery(): BoolQuery if ($this->params->userId !== null) { $query->filter(['term' => ['user_id' => $this->params->userId]]); } + if ($this->params->excludeConverts) { + $query->filter(['term' => ['convert' => false]]); + } if ($this->params->excludeMods !== null && count($this->params->excludeMods) > 0) { foreach ($this->params->excludeMods as $excludedMod) { $query->mustNot(['term' => ['mods' => $excludedMod]]); @@ -67,19 +70,20 @@ public function getQuery(): BoolQuery $beforeTotalScore = $this->params->beforeTotalScore; if ($beforeTotalScore === null && $this->params->beforeScore !== null) { - $beforeTotalScore = $this->params->beforeScore->isLegacy() - ? $this->params->beforeScore->data->legacyTotalScore - : $this->params->beforeScore->data->totalScore; + $beforeTotalScore = $this->params->isLegacy + ? $this->params->beforeScore->legacy_total_score + : $this->params->beforeScore->total_score; } if ($beforeTotalScore !== null) { $scoreQuery = (new BoolQuery())->shouldMatch(1); + $scoreField = $this->params->isLegacy ? 'legacy_total_score' : 'total_score'; $scoreQuery->should((new BoolQuery())->filter(['range' => [ - 'total_score' => ['gt' => $beforeTotalScore], + $scoreField => ['gt' => $beforeTotalScore], ]])); if ($this->params->beforeScore !== null) { $scoreQuery->should((new BoolQuery()) ->filter(['range' => ['id' => ['lt' => $this->params->beforeScore->getKey()]]]) - ->filter(['term' => ['total_score' => $beforeTotalScore]])); + ->filter(['term' => [$scoreField => $beforeTotalScore]])); } $query->must($scoreQuery); @@ -142,7 +146,8 @@ private function addModsFilter(BoolQuery $query): void $allMods = $this->params->rulesetId === null ? $modsHelper->allIds : new Set(array_keys($modsHelper->mods[$this->params->rulesetId])); - $allMods->remove('PF', 'SD', 'MR'); + // CL is currently considered a "preference" mod + $allMods->remove('CL', 'PF', 'SD', 'MR'); $allSearchMods = []; foreach ($mods as $mod) { diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 13496e13df6..f4bd246534f 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -21,6 +21,7 @@ class ScoreSearchParams extends SearchParams public ?array $beatmapIds = null; public ?Score $beforeScore = null; public ?int $beforeTotalScore = null; + public bool $excludeConverts = false; public ?array $excludeMods = null; public ?bool $isLegacy = null; public ?array $mods = null; @@ -36,6 +37,7 @@ public static function fromArray(array $rawParams): static { $params = new static(); $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; + $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; $params->excludeMods = $rawParams['exclude_mods'] ?? null; $params->isLegacy = $rawParams['is_legacy'] ?? null; $params->mods = $rawParams['mods'] ?? null; @@ -93,9 +95,15 @@ public function setSort(?string $sort): void { switch ($sort) { case 'score_desc': + $sortColumn = $this->isLegacy ? 'legacy_total_score' : 'total_score'; $this->sorts = [ - new Sort('is_legacy', 'asc'), - new Sort('total_score', 'desc'), + new Sort($sortColumn, 'desc'), + new Sort('id', 'asc'), + ]; + break; + case 'pp_desc': + $this->sorts = [ + new Sort('pp', 'desc'), new Sort('id', 'asc'), ]; break; diff --git a/app/Models/BeatmapPack.php b/app/Models/BeatmapPack.php index 6bcbfe13507..84de26931fa 100644 --- a/app/Models/BeatmapPack.php +++ b/app/Models/BeatmapPack.php @@ -5,8 +5,10 @@ namespace App\Models; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Traits\WithDbCursorHelper; -use Exception; +use Ds\Set; /** * @property string $author @@ -92,69 +94,59 @@ public function getRouteKeyName(): string return 'tag'; } - public function userCompletionData($user) + public function userCompletionData($user, ?bool $isLegacy) { if ($user !== null) { $userId = $user->getKey(); - $beatmapsetIds = $this->items()->pluck('beatmapset_id')->all(); - $query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds); - - if ($this->playmode === null) { - static $scoreRelations; - - // generate list of beatmap->score relation names for each modes - // store int mode as well as it'll be used for filtering the scores - if (!isset($scoreRelations)) { - $scoreRelations = []; - foreach (Beatmap::MODES as $modeStr => $modeInt) { - $scoreRelations[] = [ - 'playmode' => $modeInt, - 'relation' => camel_case("scores_best_{$modeStr}"), - ]; - } - } - - // outer where function - // The idea is SELECT ... WHERE ... AND ( OR OR ...). - $query->where(function ($q) use ($scoreRelations, $userId) { - foreach ($scoreRelations as $scoreRelation) { - // The scores> mentioned above is generated here. - // As it's "playmode = AND EXISTS (< score for user>)", - // wrap them so it's not flat "playmode = AND EXISTS ... OR playmode = AND EXISTS ...". - $q->orWhere(function ($qq) use ($scoreRelation, $userId) { - $qq - // this playmode filter ensures the scores are limited to non-convert maps - ->where('playmode', '=', $scoreRelation['playmode']) - ->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) { - $scoreQuery->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); - }); - } - }); - } else { - $modeStr = Beatmap::modeStr($this->playmode); - - if ($modeStr === null) { - throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}"); - } - - $scoreRelation = camel_case("scores_best_{$modeStr}"); - - $query->whereHas($scoreRelation, function ($query) use ($userId) { - $query->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $query->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); + + $beatmaps = Beatmap + ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id')) + ->select(['beatmap_id', 'beatmapset_id', 'playmode']) + ->get(); + $beatmapsetIdsByBeatmapId = []; + foreach ($beatmaps as $beatmap) { + $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id; + } + $params = [ + 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId), + 'exclude_converts' => $this->playmode === null, + 'is_legacy' => $isLegacy, + 'limit' => 0, + 'ruleset_id' => $this->playmode, + 'user_id' => $userId, + ]; + if ($this->no_diff_reduction) { + $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); } - $completedBeatmapsetIds = $query->pluck('beatmapset_id')->all(); - $completed = count($completedBeatmapsetIds) === count($beatmapsetIds); + static $aggName = 'by_beatmap'; + + $search = new ScoreSearch(ScoreSearchParams::fromArray($params)); + $search->size(0); + $search->setAggregations([$aggName => [ + 'terms' => [ + 'field' => 'beatmap_id', + 'size' => max(1, count($params['beatmap_ids'])), + ], + 'aggs' => [ + 'scores' => [ + 'top_hits' => [ + 'size' => 1, + ], + ], + ], + ]]); + $response = $search->response(); + $search->assertNoError(); + $completedBeatmapIds = array_map( + fn (array $hit): int => (int) $hit['key'], + $response->aggregations($aggName)['buckets'], + ); + $completedBeatmapsetIds = (new Set(array_map( + fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId], + $completedBeatmapIds, + )))->toArray(); + $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId)); } return [ diff --git a/app/Models/Multiplayer/PlaylistItemUserHighScore.php b/app/Models/Multiplayer/PlaylistItemUserHighScore.php index 05617852f3d..8adf655d761 100644 --- a/app/Models/Multiplayer/PlaylistItemUserHighScore.php +++ b/app/Models/Multiplayer/PlaylistItemUserHighScore.php @@ -71,7 +71,7 @@ public static function scoresAround(ScoreLink $scoreLink): array { $placeholder = new static([ 'score_id' => $scoreLink->getKey(), - 'total_score' => $scoreLink->score->data->totalScore, + 'total_score' => $scoreLink->score->total_score, ]); static $typeOptions = [ @@ -117,10 +117,10 @@ public function updateWithScoreLink(ScoreLink $scoreLink): void $score = $scoreLink->score; $this->fill([ - 'accuracy' => $score->data->accuracy, + 'accuracy' => $score->accuracy, 'pp' => $score->pp, 'score_id' => $scoreLink->getKey(), - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, ])->save(); } } diff --git a/app/Models/Multiplayer/ScoreLink.php b/app/Models/Multiplayer/ScoreLink.php index 8bca690c1e2..f2977be647f 100644 --- a/app/Models/Multiplayer/ScoreLink.php +++ b/app/Models/Multiplayer/ScoreLink.php @@ -109,7 +109,7 @@ public function position(): ?int $query = PlaylistItemUserHighScore ::where('playlist_item_id', $this->playlist_item_id) ->cursorSort('score_asc', [ - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, 'score_id' => $this->getKey(), ]); diff --git a/app/Models/Multiplayer/UserScoreAggregate.php b/app/Models/Multiplayer/UserScoreAggregate.php index db0a9146378..9569542252f 100644 --- a/app/Models/Multiplayer/UserScoreAggregate.php +++ b/app/Models/Multiplayer/UserScoreAggregate.php @@ -83,7 +83,7 @@ public function addScoreLink(ScoreLink $scoreLink, ?PlaylistItemUserHighScore $h $scoreLink->playlist_item_id, ); - if ($score->data->passed && $score->data->totalScore > $highestScore->total_score) { + if ($score->passed && $score->total_score > $highestScore->total_score) { $this->updateUserTotal($scoreLink, $highestScore); $highestScore->updateWithScoreLink($scoreLink); } @@ -134,7 +134,7 @@ public function recalculate() $scoreLinks = ScoreLink ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) ->where('user_id', $this->user_id) - ->with('score.performance') + ->with('score') ->get(); foreach ($scoreLinks as $scoreLink) { $this->addScoreLink( @@ -221,8 +221,8 @@ private function updateUserTotal(ScoreLink $currentScoreLink, PlaylistItemUserHi $current = $currentScoreLink->score; - $this->total_score += $current->data->totalScore; - $this->accuracy += $current->data->accuracy; + $this->total_score += $current->total_score; + $this->accuracy += $current->accuracy; $this->pp += $current->pp; $this->completed++; $this->last_score_id = $currentScoreLink->getKey(); diff --git a/app/Models/Score/Model.php b/app/Models/Score/Model.php index a9befb727b0..16e636361fc 100644 --- a/app/Models/Score/Model.php +++ b/app/Models/Score/Model.php @@ -5,11 +5,11 @@ namespace App\Models\Score; +use App\Enums\Ruleset; use App\Exceptions\ClassNotFoundException; use App\Libraries\Mods; use App\Models\Beatmap; use App\Models\Model as BaseModel; -use App\Models\Solo\ScoreData; use App\Models\Traits\Scoreable; use App\Models\User; @@ -161,28 +161,27 @@ public function getMode(): string return snake_case(get_class_basename(static::class)); } - protected function getData() + public function statistics(): array { - $mods = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $this->enabled_mods); $statistics = [ 'miss' => $this->countmiss, 'great' => $this->count300, ]; - $ruleset = $this->getMode(); + $ruleset = Ruleset::tryFromName($this->getMode()); switch ($ruleset) { - case 'osu': + case Ruleset::osu: $statistics['ok'] = $this->count100; $statistics['meh'] = $this->count50; break; - case 'taiko': + case Ruleset::taiko: $statistics['ok'] = $this->count100; break; - case 'fruits': + case Ruleset::catch: $statistics['large_tick_hit'] = $this->count100; $statistics['small_tick_hit'] = $this->count50; $statistics['small_tick_miss'] = $this->countkatu; break; - case 'mania': + case Ruleset::mania: $statistics['perfect'] = $this->countgeki; $statistics['good'] = $this->countkatu; $statistics['ok'] = $this->count100; @@ -190,18 +189,6 @@ protected function getData() break; } - return new ScoreData([ - 'accuracy' => $this->accuracy(), - 'beatmap_id' => $this->beatmap_id, - 'ended_at' => $this->date_json, - 'max_combo' => $this->maxcombo, - 'mods' => $mods, - 'passed' => $this->pass, - 'rank' => $this->rank, - 'ruleset_id' => Beatmap::modeInt($ruleset), - 'statistics' => $statistics, - 'total_score' => $this->score, - 'user_id' => $this->user_id, - ]); + return $statistics; } } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 1c4841b188c..d0c4a5928ca 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -7,6 +7,8 @@ namespace App\Models\Solo; +use App\Enums\ScoreRank; +use App\Exceptions\InvariantException; use App\Libraries\Score\UserRank; use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; @@ -16,7 +18,6 @@ use App\Models\ScoreToken; use App\Models\Traits; use App\Models\User; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use LaravelRedis; use Storage; @@ -45,26 +46,36 @@ class Score extends Model implements Traits\ReportableInterface protected $casts = [ 'data' => ScoreData::class, + 'ended_at' => 'datetime', 'has_replay' => 'boolean', + 'passed' => 'boolean', 'preserve' => 'boolean', + 'ranked' => 'boolean', + 'started_at' => 'datetime', ]; - public static function createFromJsonOrExplode(array $params) + public static function createFromJsonOrExplode(array $params): static { - $score = new static([ - 'beatmap_id' => $params['beatmap_id'], - 'ruleset_id' => $params['ruleset_id'], - 'user_id' => $params['user_id'], - 'data' => $params, - ]); + $params['data'] = [ + 'maximum_statistics' => $params['maximum_statistics'] ?? [], + 'mods' => $params['mods'] ?? [], + 'statistics' => $params['statistics'] ?? [], + ]; + unset( + $params['maximum_statistics'], + $params['mods'], + $params['statistics'], + ); - $score->data->assertCompleted(); + $score = new static($params); + + $score->assertCompleted(); // this should potentially just be validation rather than applying this logic here, but // older lazer builds potentially submit incorrect details here (and we still want to // accept their scores. - if (!$score->data->passed) { - $score->data->rank = 'F'; + if (!$score->passed) { + $score->rank = 'F'; } $score->saveOrExplode(); @@ -72,26 +83,32 @@ public static function createFromJsonOrExplode(array $params) return $score; } - public static function extractParams(array $params, ScoreToken|MultiplayerScoreLink $scoreToken): array + public static function extractParams(array $rawParams, ScoreToken|MultiplayerScoreLink $scoreToken): array { - return [ - ...get_params($params, null, [ - 'accuracy:float', - 'max_combo:int', - 'maximum_statistics:array', - 'passed:bool', - 'rank:string', - 'statistics:array', - 'total_score:int', - ]), - 'beatmap_id' => $scoreToken->beatmap_id, - 'build_id' => $scoreToken->build_id, - 'ended_at' => json_time(Carbon::now()), - 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, get_arr($params['mods'] ?? null) ?? []), - 'ruleset_id' => $scoreToken->ruleset_id, - 'started_at' => $scoreToken->created_at_json, - 'user_id' => $scoreToken->user_id, - ]; + $params = get_params($rawParams, null, [ + 'accuracy:float', + 'max_combo:int', + 'maximum_statistics:array', + 'mods:array', + 'passed:bool', + 'rank:string', + 'statistics:array', + 'total_score:int', + ]); + + $params['maximum_statistics'] ??= []; + $params['statistics'] ??= []; + + $params['mods'] = app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []); + + $params['beatmap_id'] = $scoreToken->beatmap_id; + $params['build_id'] = $scoreToken->build_id; + $params['ended_at'] = new \DateTime(); + $params['ruleset_id'] = $scoreToken->ruleset_id; + $params['started_at'] = $scoreToken->created_at; + $params['user_id'] = $scoreToken->user_id; + + return $params; } /** @@ -119,11 +136,6 @@ public function beatmap() return $this->belongsTo(Beatmap::class, 'beatmap_id'); } - public function performance() - { - return $this->hasOne(ScorePerformance::class, 'score_id'); - } - public function user() { return $this->belongsTo(User::class, 'user_id'); @@ -134,6 +146,18 @@ public function scopeDefault(Builder $query): Builder return $query->whereHas('beatmap.beatmapset'); } + public function scopeForRuleset(Builder $query, string $ruleset): Builder + { + return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); + } + + public function scopeIncludeFails(Builder $query, bool $includeFails): Builder + { + return $includeFails + ? $query + : $query->where('passed', true); + } + /** * This should match the one used in osu-elastic-indexer. */ @@ -147,22 +171,34 @@ public function scopeIndexable(Builder $query): Builder public function getAttribute($key) { return match ($key) { + 'accuracy', 'beatmap_id', + 'build_id', 'id', + 'legacy_score_id', + 'legacy_total_score', + 'max_combo', + 'pp', 'ruleset_id', + 'total_score', 'unix_updated_at', 'user_id' => $this->getRawAttribute($key), + 'rank' => $this->getRawAttribute($key) ?? 'F', + 'data' => $this->getClassCastableAttributeValue($key, $this->getRawAttribute($key)), 'has_replay', - 'preserve', - 'ranked' => (bool) $this->getRawAttribute($key), + 'passed', + 'preserve' => (bool) $this->getRawAttribute($key), + + 'ranked' => (bool) ($this->getRawAttribute($key) ?? true), - 'created_at' => $this->getTimeFast($key), - 'created_at_json' => $this->getJsonTimeFast($key), + 'ended_at', + 'started_at' => $this->getTimeFast($key), - 'pp' => $this->performance?->pp, + 'ended_at_json', + 'started_at_json' => $this->getJsonTimeFast($key), 'beatmap', 'performance', @@ -171,6 +207,23 @@ public function getAttribute($key) }; } + public function assertCompleted(): void + { + if (ScoreRank::tryFrom($this->rank ?? '') === null) { + throw new InvariantException("'{$this->rank}' is not a valid rank."); + } + + foreach (['total_score', 'accuracy', 'max_combo', 'passed'] as $field) { + if (!present($this->$field)) { + throw new InvariantException("field missing: '{$field}'"); + } + } + + if ($this->data->statistics->isEmpty()) { + throw new InvariantException("field cannot be empty: 'statistics'"); + } + } + public function createLegacyEntryOrExplode() { $score = $this->makeLegacyEntry(); @@ -193,12 +246,12 @@ public function getReplayFile(): ?string public function isLegacy(): bool { - return $this->data->buildId === null; + return $this->legacy_score_id !== null; } public function legacyScore(): ?LegacyScore\Best\Model { - $id = $this->data->legacyScoreId; + $id = $this->legacy_score_id; return $id === null ? null @@ -216,11 +269,11 @@ public function makeLegacyEntry(): LegacyScore\Model 'beatmapset_id' => $this->beatmap?->beatmapset_id ?? 0, 'countmiss' => $statistics->miss, 'enabled_mods' => app('mods')->idsToBitset(array_column($data->mods, 'acronym')), - 'maxcombo' => $data->maxCombo, - 'pass' => $data->passed, - 'perfect' => $data->passed && $statistics->miss + $statistics->large_tick_miss === 0, - 'rank' => $data->rank, - 'score' => $data->totalScore, + 'maxcombo' => $this->max_combo, + 'pass' => $this->passed, + 'perfect' => $this->passed && $statistics->miss + $statistics->large_tick_miss === 0, + 'rank' => $this->rank, + 'score' => $this->total_score, 'scorechecksum' => "\0", 'user_id' => $this->user_id, ]); diff --git a/app/Models/Solo/ScoreData.php b/app/Models/Solo/ScoreData.php index ebebeaf61fd..51ed8bc0b98 100644 --- a/app/Models/Solo/ScoreData.php +++ b/app/Models/Solo/ScoreData.php @@ -7,30 +7,15 @@ namespace App\Models\Solo; -use App\Enums\ScoreRank; -use App\Exceptions\InvariantException; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use JsonSerializable; class ScoreData implements Castable, JsonSerializable { - public float $accuracy; - public int $beatmapId; - public ?int $buildId; - public string $endedAt; - public ?int $legacyScoreId; - public ?int $legacyTotalScore; - public int $maxCombo; public ScoreDataStatistics $maximumStatistics; public array $mods; - public bool $passed; - public string $rank; - public int $rulesetId; - public ?string $startedAt; public ScoreDataStatistics $statistics; - public int $totalScore; - public int $userId; public function __construct(array $data) { @@ -51,22 +36,9 @@ public function __construct(array $data) } } - $this->accuracy = $data['accuracy'] ?? 0; - $this->beatmapId = $data['beatmap_id']; - $this->buildId = $data['build_id'] ?? null; - $this->endedAt = $data['ended_at']; - $this->legacyScoreId = $data['legacy_score_id'] ?? null; - $this->legacyTotalScore = $data['legacy_total_score'] ?? null; - $this->maxCombo = $data['max_combo'] ?? 0; $this->maximumStatistics = new ScoreDataStatistics($data['maximum_statistics'] ?? []); $this->mods = $mods; - $this->passed = $data['passed'] ?? false; - $this->rank = $data['rank'] ?? 'F'; - $this->rulesetId = $data['ruleset_id']; - $this->startedAt = $data['started_at'] ?? null; $this->statistics = new ScoreDataStatistics($data['statistics'] ?? []); - $this->totalScore = $data['total_score'] ?? 0; - $this->userId = $data['user_id']; } public static function castUsing(array $arguments) @@ -75,25 +47,13 @@ public static function castUsing(array $arguments) { public function get($model, $key, $value, $attributes) { - $dataJson = json_decode($value, true); - $dataJson['beatmap_id'] ??= $attributes['beatmap_id']; - $dataJson['ended_at'] ??= $model->created_at_json; - $dataJson['ruleset_id'] ??= $attributes['ruleset_id']; - $dataJson['user_id'] ??= $attributes['user_id']; - - return new ScoreData($dataJson); + return new ScoreData(json_decode($value, true)); } public function set($model, $key, $value, $attributes) { if (!($value instanceof ScoreData)) { - $value = new ScoreData([ - 'beatmap_id' => $attributes['beatmap_id'] ?? null, - 'ended_at' => $attributes['created_at'] ?? null, - 'ruleset_id' => $attributes['ruleset_id'] ?? null, - 'user_id' => $attributes['user_id'] ?? null, - ...$value, - ]); + $value = new ScoreData($value); } return ['data' => json_encode($value)]; @@ -101,50 +61,12 @@ public function set($model, $key, $value, $attributes) }; } - public function assertCompleted(): void - { - if (ScoreRank::tryFrom($this->rank) === null) { - throw new InvariantException("'{$this->rank}' is not a valid rank."); - } - - foreach (['totalScore', 'accuracy', 'maxCombo', 'passed'] as $field) { - if (!present($this->$field)) { - throw new InvariantException("field missing: '{$field}'"); - } - } - - if ($this->statistics->isEmpty()) { - throw new InvariantException("field cannot be empty: 'statistics'"); - } - } - public function jsonSerialize(): array { - $ret = [ - 'accuracy' => $this->accuracy, - 'beatmap_id' => $this->beatmapId, - 'build_id' => $this->buildId, - 'ended_at' => $this->endedAt, - 'legacy_score_id' => $this->legacyScoreId, - 'legacy_total_score' => $this->legacyTotalScore, - 'max_combo' => $this->maxCombo, + return [ 'maximum_statistics' => $this->maximumStatistics, 'mods' => $this->mods, - 'passed' => $this->passed, - 'rank' => $this->rank, - 'ruleset_id' => $this->rulesetId, - 'started_at' => $this->startedAt, 'statistics' => $this->statistics, - 'total_score' => $this->totalScore, - 'user_id' => $this->userId, ]; - - foreach ($ret as $field => $value) { - if ($value === null) { - unset($ret[$field]); - } - } - - return $ret; } } diff --git a/app/Models/Solo/ScorePerformance.php b/app/Models/Solo/ScorePerformance.php deleted file mode 100644 index b30a1f9a410..00000000000 --- a/app/Models/Solo/ScorePerformance.php +++ /dev/null @@ -1,21 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Models\Solo; - -use App\Models\Model; - -/** - * @property int $score_id - * @property float|null $pp - */ -class ScorePerformance extends Model -{ - public $incrementing = false; - public $timestamps = false; - - protected $primaryKey = 'score_id'; - protected $table = 'score_performance'; -} diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index e04a9bacccf..1afe54df899 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -5,71 +5,43 @@ namespace App\Models\Traits; -use App\Libraries\Elasticsearch\BoolQuery; -use App\Libraries\Elasticsearch\SearchResponse; -use App\Libraries\Search\BasicSearch; -use App\Models\Score\Best; +use App\Libraries\Score\FetchDedupedScores; +use App\Libraries\Search\ScoreSearchParams; +use App\Models\Beatmap; +use App\Models\Solo\Score; +use Illuminate\Database\Eloquent\Collection; trait UserScoreable { - private $beatmapBestScoreIds = []; + private array $beatmapBestScoreIds = []; + private array $beatmapBestScores = []; - public function aggregatedScoresBest(string $mode, int $size): SearchResponse + public function aggregatedScoresBest(string $mode, null | true $legacyOnly, int $size): array { - $index = $GLOBALS['cfg']['osu']['elasticsearch']['prefix']."high_scores_{$mode}"; - - $search = new BasicSearch($index, "aggregatedScoresBest_{$mode}"); - $search->connectionName = 'scores'; - $search - ->size(0) // don't care about hits - ->query( - (new BoolQuery()) - ->filter(['term' => ['user_id' => $this->getKey()]]) - ) - ->setAggregations([ - 'by_beatmaps' => [ - 'terms' => [ - 'field' => 'beatmap_id', - // sort by sub-aggregation max_pp, with score_id as tie breaker - 'order' => [['max_pp' => 'desc'], ['min_score_id' => 'asc']], - 'size' => $size, - ], - 'aggs' => [ - 'top_scores' => [ - 'top_hits' => [ - 'size' => 1, - 'sort' => [['pp' => ['order' => 'desc']]], - ], - ], - // top_hits aggregation is not useable for sorting, so we need an extra aggregation to sort on. - 'max_pp' => ['max' => ['field' => 'pp']], - 'min_score_id' => ['min' => ['field' => 'score_id']], - ], - ], - ]); - - $response = $search->response(); - $search->assertNoError(); - - return $response; + return (new FetchDedupedScores('beatmap_id', ScoreSearchParams::fromArray([ + 'is_legacy' => $legacyOnly, + 'limit' => $size, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'pp_desc', + 'user_id' => $this->getKey(), + ]), "aggregatedScoresBest_{$mode}"))->all(); } - public function beatmapBestScoreIds(string $mode) + public function beatmapBestScoreIds(string $mode, null | true $legacyOnly) { - if (!isset($this->beatmapBestScoreIds[$mode])) { + $key = $mode.'-'.($legacyOnly ? '1' : '0'); + + if (!isset($this->beatmapBestScoreIds[$key])) { // aggregations do not support regular pagination. // always fetching 100 to cache; we're not supporting beyond 100, either. - $this->beatmapBestScoreIds[$mode] = cache_remember_mutexed( - "search-cache:beatmapBestScores:{$this->getKey()}:{$mode}", + $this->beatmapBestScoreIds[$key] = cache_remember_mutexed( + "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$key}", $GLOBALS['cfg']['osu']['scores']['es_cache_duration'], [], - function () use ($mode) { - // FIXME: should return some sort of error on error - $buckets = $this->aggregatedScoresBest($mode, 100)->aggregations('by_beatmaps')['buckets'] ?? []; + function () use ($key, $legacyOnly, $mode) { + $this->beatmapBestScores[$key] = $this->aggregatedScoresBest($mode, $legacyOnly, 100); - return array_map(function ($bucket) { - return array_get($bucket, 'top_scores.hits.hits.0._id'); - }, $buckets); + return array_column($this->beatmapBestScores[$key], 'id'); }, function () { // TODO: propagate a more useful message back to the client @@ -79,15 +51,22 @@ function () { ); } - return $this->beatmapBestScoreIds[$mode]; + return $this->beatmapBestScoreIds[$key]; } - public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []) + public function beatmapBestScores(string $mode, int $limit, int $offset, array $with, null | true $legacyOnly): Collection { - $ids = array_slice($this->beatmapBestScoreIds($mode), $offset, $limit); - $clazz = Best\Model::getClass($mode); + $ids = $this->beatmapBestScoreIds($mode, $legacyOnly); + $key = $mode.'-'.($legacyOnly ? '1' : '0'); + + if (isset($this->beatmapBestScores[$key])) { + $results = new Collection(array_slice($this->beatmapBestScores[$key], $offset, $limit)); + } else { + $ids = array_slice($ids, $offset, $limit); + $results = Score::whereKey($ids)->orderByField('id', $ids)->default()->get(); + } - $results = $clazz::whereIn('score_id', $ids)->orderByField('score_id', $ids)->with($with)->get(); + $results->load($with); // fill in positions for weighting // also preload the user relation diff --git a/app/Models/User.php b/app/Models/User.php index ce03568fcb7..5b5865144e0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -916,6 +916,7 @@ public function getAttribute($key) 'scoresMania', 'scoresOsu', 'scoresTaiko', + 'soloScores', 'statisticsFruits', 'statisticsMania', 'statisticsMania4k', @@ -1447,6 +1448,11 @@ public function scoresBest(string $mode, bool $returnQuery = false) return $returnQuery ? $this->$relation() : $this->$relation; } + public function soloScores(): HasMany + { + return $this->hasMany(Solo\Score::class); + } + public function topicWatches() { return $this->hasMany(TopicWatch::class); @@ -1796,6 +1802,18 @@ public function authHash(): string return hash('sha256', $this->user_email).':'.hash('sha256', $this->user_password); } + public function recentScoreCount(string $ruleset): int + { + return $this->soloScores() + ->default() + ->forRuleset($ruleset) + ->includeFails(false) + ->select('id') + ->limit(100) + ->get() + ->count(); + } + public function resetSessions(?string $excludedSessionId = null): void { $userId = $this->getKey(); diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 8526ad7ea48..8c2c91efacd 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -7,6 +7,7 @@ namespace App\Transformers; +use App\Enums\Ruleset; use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; @@ -22,7 +23,7 @@ class ScoreTransformer extends TransformerAbstract const MULTIPLAYER_BASE_INCLUDES = ['user.country', 'user.cover']; // warning: the preload is actually for PlaylistItemUserHighScore, not for Score const MULTIPLAYER_BASE_PRELOAD = [ - 'scoreLink.score.performance', + 'scoreLink.score', 'scoreLink.user.country', 'scoreLink.user.userProfileCustomization', ]; @@ -90,43 +91,67 @@ public function transform(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|Solo public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) { + $ret = [ + 'best_id' => null, + 'build_id' => null, + 'has_replay' => false, + 'legacy_perfect' => null, + 'pp' => null, + ]; if ($score instanceof ScoreModel) { - $legacyPerfect = $score->perfect; $best = $score->best; - if ($best !== null) { - $bestId = $best->getKey(); - $pp = $best->pp; - $hasReplay = $best->replay; + $ret['best_id'] = $best->getKey(); + $ret['has_replay'] = $best->replay; + $ret['pp'] = $best->pp; } + + $ret['accuracy'] = $score->accuracy(); + $ret['ended_at'] = $score->date_json; + $ret['legacy_perfect'] = $score->perfect; + $ret['max_combo'] = $score->maxcombo; + $ret['mods'] = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $score->enabled_mods); + $ret['passed'] = $score->pass; + $ret['ruleset_id'] = Ruleset::tryFromName($score->getMode())->value; + $ret['statistics'] = $score->statistics(); + $ret['total_score'] = $score->score; + + $ret['legacy_total_score'] = $ret['total_score']; } else { if ($score instanceof MultiplayerScoreLink) { - $multiplayerAttributes = [ - 'playlist_item_id' => $score->playlist_item_id, - 'room_id' => $score->playlistItem->room_id, - 'solo_score_id' => $score->score_id, - ]; + $ret['playlist_item_id'] = $score->playlist_item_id; + $ret['room_id'] = $score->playlistItem->room_id; + $ret['solo_score_id'] = $score->score_id; $score = $score->score; } - $pp = $score->pp; - $hasReplay = $score->has_replay; + $data = $score->data; + $ret['maximum_statistics'] = $data->maximumStatistics; + $ret['mods'] = $data->mods; + $ret['statistics'] = $data->statistics; + + $ret['accuracy'] = $score->accuracy; + $ret['build_id'] = $score->build_id; + $ret['ended_at'] = $score->ended_at_json; + $ret['has_replay'] = $score->has_replay; + $ret['legacy_total_score'] = $score->legacy_total_score; + $ret['max_combo'] = $score->maxcombo; + $ret['pp'] = $score->pp; + $ret['ruleset_id'] = $score->ruleset_id; + $ret['started_at'] = $score->started_at_json; + $ret['total_score'] = $score->total_score; } - $hasReplay ??= false; + $ret['beatmap_id'] = $score->beatmap_id; + $ret['id'] = $score->getKey(); + $ret['rank'] = $score->rank; + $ret['type'] = $score->getMorphClass(); + $ret['user_id'] = $score->user_id; - return [ - ...$score->data->jsonSerialize(), - ...($multiplayerAttributes ?? []), - 'best_id' => $bestId ?? null, - 'has_replay' => $hasReplay, - 'id' => $score->getKey(), - 'legacy_perfect' => $legacyPerfect ?? null, - 'pp' => $pp ?? null, - // TODO: remove this redundant field sometime after 2024-02 - 'replay' => $hasReplay, - 'type' => $score->getMorphClass(), - ]; + // TODO: remove this redundant field sometime after 2024-02 + $ret['replay'] = $ret['has_replay']; + + return $ret; } public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) @@ -146,7 +171,7 @@ public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) $soloScore = $score; $score = $soloScore->makeLegacyEntry(); $score->score_id = $soloScore->getKey(); - $createdAt = $soloScore->created_at_json; + $createdAt = $soloScore->ended_at_json; $type = $soloScore->getMorphClass(); $pp = $soloScore->pp; } else { diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index ee2766d4705..aaffbf6b81a 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -6,6 +6,7 @@ namespace App\Transformers; use App\Libraries\MorphMap; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\User; use App\Models\UserProfileCustomization; @@ -387,7 +388,10 @@ public function includeReplaysWatchedCounts(User $user) public function includeScoresBestCount(User $user) { - return $this->primitive(count($user->beatmapBestScoreIds($this->mode))); + return $this->primitive(count($user->beatmapBestScoreIds( + $this->mode, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ))); } public function includeScoresFirstCount(User $user) @@ -402,7 +406,7 @@ public function includeScoresPinnedCount(User $user) public function includeScoresRecentCount(User $user) { - return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); + return $this->primitive($user->recentScoreCount($this->mode)); } public function includeStatistics(User $user) diff --git a/database/factories/Solo/ScoreFactory.php b/database/factories/Solo/ScoreFactory.php index f7c39b3233a..de75879064f 100644 --- a/database/factories/Solo/ScoreFactory.php +++ b/database/factories/Solo/ScoreFactory.php @@ -7,6 +7,7 @@ namespace Database\Factories\Solo; +use App\Enums\ScoreRank; use App\Models\Beatmap; use App\Models\Solo\Score; use App\Models\User; @@ -19,7 +20,12 @@ class ScoreFactory extends Factory public function definition(): array { return [ + 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), 'beatmap_id' => Beatmap::factory()->ranked(), + 'ended_at' => new \DateTime(), + 'pp' => fn (): float => $this->faker->randomFloat(4, 0, 1000), + 'rank' => fn () => array_rand_val(ScoreRank::cases())->value, + 'total_score' => fn (): int => $this->faker->randomNumber(7), 'user_id' => User::factory(), // depends on beatmap_id @@ -41,19 +47,11 @@ private function makeData(?array $overrides = null): callable { return fn (array $attr): array => array_map( fn ($value) => is_callable($value) ? $value($attr) : $value, - array_merge([ - 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), - 'beatmap_id' => $attr['beatmap_id'], - 'ended_at' => fn (): string => json_time(now()), - 'max_combo' => fn (): int => rand(1, Beatmap::find($attr['beatmap_id'])->countNormal), + [ + 'statistics' => ['great' => 1], 'mods' => [], - 'passed' => true, - 'rank' => fn (): string => array_rand_val(['A', 'S', 'B', 'SH', 'XH', 'X']), - 'ruleset_id' => $attr['ruleset_id'], - 'started_at' => fn (): string => json_time(now()->subSeconds(600)), - 'total_score' => fn (): int => $this->faker->randomNumber(7), - 'user_id' => $attr['user_id'], - ], $overrides ?? []), + ...($overrides ?? []), + ], ); } } diff --git a/database/migrations/2024_01_12_115738_update_scores_table_final.php b/database/migrations/2024_01_12_115738_update_scores_table_final.php new file mode 100644 index 00000000000..8a93c4edf2c --- /dev/null +++ b/database/migrations/2024_01_12_115738_update_scores_table_final.php @@ -0,0 +1,94 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + private static function resetView(): void + { + DB::statement('DROP VIEW scores'); + DB::statement('CREATE VIEW scores AS SELECT * FROM solo_scores'); + } + + public function up(): void + { + Schema::drop('solo_scores'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `has_replay` tinyint NOT NULL DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `rank` char(2) NOT NULL DEFAULT '', + `passed` tinyint NOT NULL DEFAULT '0', + `accuracy` float NOT NULL DEFAULT '0', + `max_combo` int unsigned NOT NULL DEFAULT '0', + `total_score` int unsigned NOT NULL DEFAULT '0', + `data` json NOT NULL, + `pp` float DEFAULT NULL, + `legacy_score_id` bigint unsigned DEFAULT NULL, + `legacy_total_score` int unsigned NOT NULL DEFAULT '0', + `started_at` timestamp NULL DEFAULT NULL, + `ended_at` timestamp NOT NULL, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + `build_id` smallint unsigned DEFAULT NULL, + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`), + KEY `legacy_score_lookup` (`ruleset_id`,`legacy_score_id`) + )"); + + DB::statement('DROP VIEW score_legacy_id_map'); + Schema::drop('solo_scores_legacy_id_map'); + + DB::statement('DROP VIEW score_performance'); + Schema::drop('solo_scores_performance'); + + static::resetView(); + } + + public function down(): void + { + Schema::drop('solo_scores'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `data` json NOT NULL, + `has_replay` tinyint DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`) + )"); + + DB::statement('CREATE TABLE `solo_scores_legacy_id_map` ( + `ruleset_id` smallint unsigned NOT NULL, + `old_score_id` bigint unsigned NOT NULL, + `score_id` bigint unsigned NOT NULL, + PRIMARY KEY (`ruleset_id`,`old_score_id`) + )'); + DB::statement('CREATE VIEW score_legacy_id_map AS SELECT * FROM solo_scores_legacy_id_map'); + + DB::statement('CREATE TABLE `solo_scores_performance` ( + `score_id` bigint unsigned NOT NULL, + `pp` float DEFAULT NULL, + PRIMARY KEY (`score_id`) + )'); + DB::statement('CREATE VIEW score_performance AS SELECT * FROM solo_scores_performance'); + + static::resetView(); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 40ce3c0bcc1..297936f5e18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,7 +145,8 @@ services: - "${NGINX_PORT:-8080}:80" score-indexer: - image: pppy/osu-elastic-indexer:master + # TODO: switch to main image once updated with support for new score structure + image: nanaya/osu-elastic-indexer:v44 command: ["queue", "watch"] depends_on: redis: @@ -159,7 +160,8 @@ services: SCHEMA: "${SCHEMA:-1}" score-indexer-test: - image: pppy/osu-elastic-indexer:master + # TODO: switch to main image once updated with support for new score structure + image: nanaya/osu-elastic-indexer:v44 command: ["queue", "watch"] depends_on: redis: diff --git a/resources/css/bem/simple-menu.less b/resources/css/bem/simple-menu.less index 34ec88f10b3..986ceb81437 100644 --- a/resources/css/bem/simple-menu.less +++ b/resources/css/bem/simple-menu.less @@ -135,6 +135,12 @@ } } + &__extra { + background-color: hsl(var(--hsl-b5)); + padding: @_padding-vertical @_gutter; + margin: -@_padding-vertical -@_gutter @_padding-vertical; + } + &__form { margin: -@_padding-vertical -@_gutter; } diff --git a/resources/js/interfaces/user-preferences-json.ts b/resources/js/interfaces/user-preferences-json.ts index 06fcc5df47a..810154ec1d3 100644 --- a/resources/js/interfaces/user-preferences-json.ts +++ b/resources/js/interfaces/user-preferences-json.ts @@ -16,6 +16,7 @@ export const defaultUserPreferencesJson: UserPreferencesJson = { comments_show_deleted: false, comments_sort: 'new', forum_posts_show_deleted: true, + legacy_score_only: true, profile_cover_expanded: true, user_list_filter: 'all', user_list_sort: 'last_visit', @@ -33,6 +34,7 @@ export default interface UserPreferencesJson { comments_show_deleted: boolean; comments_sort: string; forum_posts_show_deleted: boolean; + legacy_score_only: boolean; profile_cover_expanded: boolean; user_list_filter: Filter; user_list_sort: SortMode; diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index d258d63722d..d03d6d2aa12 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -123,5 +123,7 @@ export function scoreUrl(score: SoloScoreJson) { } export function totalScore(score: SoloScoreJson) { - return score.legacy_total_score ?? score.total_score; + return core.userPreferences.get('legacy_score_only') + ? (score.legacy_total_score ?? score.total_score) + : score.total_score; } diff --git a/resources/lang/en/layout.php b/resources/lang/en/layout.php index 3f642aff3ac..5a59a7c25cc 100644 --- a/resources/lang/en/layout.php +++ b/resources/lang/en/layout.php @@ -195,6 +195,8 @@ 'account-edit' => 'Settings', 'follows' => 'Watchlists', 'friends' => 'Friends', + 'legacy_score_only_toggle' => 'Lazer mode', + 'legacy_score_only_toggle_tooltip' => 'Lazer mode shows scores set from lazer with a new scoring algorithm', 'logout' => 'Sign Out', 'profile' => 'My Profile', ], diff --git a/resources/views/layout/_popup_user.blade.php b/resources/views/layout/_popup_user.blade.php index 1ddb765c8de..cb6b364ab61 100644 --- a/resources/views/layout/_popup_user.blade.php +++ b/resources/views/layout/_popup_user.blade.php @@ -16,6 +16,10 @@ class="simple-menu__header simple-menu__header--link js-current-user-cover"
{{ Auth::user()->username }}
+
+ @include('layout._score_mode_toggle', ['class' => 'simple-menu__item']) +
+ . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} +@php + $legacyScoreOnlyValue = App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()); + $icon = $legacyScoreOnlyValue + ? 'far fa-square' + : 'fas fa-check-square'; +@endphp + diff --git a/resources/views/layout/header_mobile/user.blade.php b/resources/views/layout/header_mobile/user.blade.php index 7788db0fda6..d7875bb3c90 100644 --- a/resources/views/layout/header_mobile/user.blade.php +++ b/resources/views/layout/header_mobile/user.blade.php @@ -9,6 +9,8 @@ class="navbar-mobile-item__main js-react--user-card" data-is-current-user="1" > + @include('layout._score_mode_toggle', ['class' => 'navbar-mobile-item__main']) + ['index', 'show']]); Route::group(['prefix' => '{beatmap}'], function () { - Route::get('scores/users/{user}', 'BeatmapsController@userScore'); - Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll'); + Route::get('scores/users/{user}', 'BeatmapsController@userScore')->name('user.score'); + Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll')->name('user.scores'); Route::get('scores', 'BeatmapsController@scores')->name('scores'); Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores'); diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index eedbaf7c9f8..bf1b2e237b8 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -14,6 +14,7 @@ use App\Models\Genre; use App\Models\Group; use App\Models\Language; +use App\Models\OAuth; use App\Models\Solo\Score as SoloScore; use App\Models\User; use App\Models\UserGroup; @@ -41,108 +42,122 @@ public static function setUpBeforeClass(): void $countryAcronym = static::$user->country_acronym; static::$scores = []; - $scoreFactory = SoloScore::factory(); - foreach (['solo' => 0, 'legacy' => null] as $type => $buildId) { - $defaultData = ['build_id' => $buildId]; + $scoreFactory = SoloScore::factory()->state(['build_id' => 0]); + foreach (['solo' => null, 'legacy' => 1] as $type => $legacyScoreId) { + $scoreFactory = $scoreFactory->state([ + 'legacy_score_id' => $legacyScoreId, + ]); + $makeMods = fn (array $modNames): array => array_map( + fn (string $modName): array => [ + 'acronym' => $modName, + 'settings' => [], + ], + [...$modNames, ...($type === 'legacy' ? ['CL'] : [])], + ); - static::$scores = array_merge(static::$scores, [ - "{$type}:user" => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + static::$scores = [ + ...static::$scores, + "{$type}:user" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1100, 'user_id' => static::$user, ]), - "{$type}:userMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, - 'mods' => static::defaultMods(['DT', 'HD']), + "{$type}:userMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:userModsNC" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, - 'mods' => static::defaultMods(['NC']), + "{$type}:userModsNC" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1010, - 'mods' => static::defaultMods(['NC', 'PF']), + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC', 'PF']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1010, 'user_id' => static::$otherUser, ]), - "{$type}:userModsLowerScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['DT', 'HD']), + "{$type}:userModsLowerScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$user, ]), - "{$type}:friend" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:friend" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => $friend, ]), // With preference mods - "{$type}:otherUser" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['PF']), + "{$type}:otherUser" => $scoreFactory->withData([ + 'mods' => $makeMods(['PF']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['HD', 'PF', 'NC']), + "{$type}:otherUserMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['HD', 'PF', 'NC']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['DT', 'HD', 'HR']), + "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD', 'HR']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsUnrelated" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['FL']), + "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ + 'mods' => $makeMods(['FL']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), // Same total score but achieved later so it should come up after earlier score - "{$type}:otherUser2Later" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser2Later" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - "{$type}:otherUser3SameCountry" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser3SameCountry" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), ]), // Non-preserved score should be filtered out - "{$type}:nonPreserved" => $scoreFactory->withData($defaultData)->create([ + "{$type}:nonPreserved" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => false, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), // Unrelated score - "{$type}:unrelated" => $scoreFactory->withData($defaultData)->create([ + "{$type}:unrelated" => $scoreFactory->create([ 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - ]); + ]; } UserRelation::create([ @@ -165,6 +180,8 @@ public static function tearDownAfterClass(): void Country::truncate(); Genre::truncate(); Language::truncate(); + OAuth\Client::truncate(); + OAuth\Token::truncate(); SoloScore::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); @@ -174,25 +191,14 @@ public static function tearDownAfterClass(): void }); } - private static function defaultMods(array $modNames): array - { - return array_map( - fn ($modName) => [ - 'acronym' => $modName, - 'settings' => [], - ], - $modNames, - ); - } - /** * @dataProvider dataProviderForTestQuery * @group RequiresScoreIndexer */ - public function testQuery(array $scoreKeys, array $params) + public function testQuery(array $scoreKeys, array $params, string $route) { $resp = $this->actingAs(static::$user) - ->json('GET', route('beatmaps.solo-scores', static::$beatmap), $params) + ->json('GET', route("beatmaps.{$route}", static::$beatmap), $params) ->assertSuccessful(); $json = json_decode($resp->getContent(), true); @@ -202,46 +208,91 @@ public function testQuery(array $scoreKeys, array $params) } } + /** + * @group RequiresScoreIndexer + */ + public function testUserScore() + { + $url = route('api.beatmaps.user.score', [ + 'beatmap' => static::$beatmap->getKey(), + 'mods' => ['DT', 'HD'], + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonPath('score.id', static::$scores['legacy:userMods']->getKey()); + } + + /** + * @group RequiresScoreIndexer + */ + public function testUserScoreAll() + { + $url = route('api.beatmaps.user.scores', [ + 'beatmap' => static::$beatmap->getKey(), + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonCount(4, 'scores') + ->assertJsonPath( + 'scores.*.id', + array_map(fn (string $key): int => static::$scores[$key]->getKey(), [ + 'legacy:user', + 'legacy:userMods', + 'legacy:userModsNC', + 'legacy:userModsLowerScore', + ]) + ); + } + public static function dataProviderForTestQuery(): array { - return [ - 'no parameters' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], []], - 'by country' => [[ - 'solo:user', - 'solo:otherUser3SameCountry', - ], ['type' => 'country']], - 'by friend' => [[ - 'solo:user', - 'solo:friend', - ], ['type' => 'friend']], - 'mods filter' => [[ - 'solo:userMods', - 'solo:otherUserMods', - ], ['mods' => ['DT', 'HD']]], - 'mods with implied filter' => [[ - 'solo:userModsNC', - 'solo:otherUserModsNCPFHigherScore', - ], ['mods' => ['NC']]], - 'mods with nomods' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NC', 'NM']]], - 'nomods filter' => [[ - 'solo:user', - 'solo:friend', - 'solo:otherUser', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NM']]], - ]; + $ret = []; + foreach (['solo' => 'solo-scores', 'legacy' => 'scores'] as $type => $route) { + $ret = array_merge($ret, [ + "{$type}: no parameters" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], [], $route], + "{$type}: by country" => [[ + "{$type}:user", + "{$type}:otherUser3SameCountry", + ], ['type' => 'country'], $route], + "{$type}: by friend" => [[ + "{$type}:user", + "{$type}:friend", + ], ['type' => 'friend'], $route], + "{$type}: mods filter" => [[ + "{$type}:userMods", + "{$type}:otherUserMods", + ], ['mods' => ['DT', 'HD']], $route], + "{$type}: mods with implied filter" => [[ + "{$type}:userModsNC", + "{$type}:otherUserModsNCPFHigherScore", + ], ['mods' => ['NC']], $route], + "{$type}: mods with nomods" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NC', 'NM']], $route], + "{$type}: nomods filter" => [[ + "{$type}:user", + "{$type}:friend", + "{$type}:otherUser", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NM']], $route], + ]); + } + + return $ret; } } diff --git a/tests/Controllers/BeatmapsControllerTest.php b/tests/Controllers/BeatmapsControllerTest.php index 629272e79fd..17e97270780 100644 --- a/tests/Controllers/BeatmapsControllerTest.php +++ b/tests/Controllers/BeatmapsControllerTest.php @@ -10,12 +10,8 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\BeatmapsetEvent; -use App\Models\Country; -use App\Models\Score\Best\Model as ScoreBest; use App\Models\User; -use App\Models\UserRelation; use Illuminate\Testing\Fluent\AssertableJson; -use Illuminate\Testing\TestResponse; use Tests\TestCase; class BeatmapsControllerTest extends TestCase @@ -106,7 +102,7 @@ public function testInvalidMode() { $this->json('GET', route('beatmaps.scores', $this->beatmap), [ 'mode' => 'nope', - ])->assertStatus(404); + ])->assertStatus(422); } /** @@ -177,261 +173,6 @@ public function testScoresNonGeneralSupporter() ])->assertStatus(200); } - public function testScores() - { - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - // Same total score but achieved later so it should come up after earlier score - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - ]; - // Hidden score should be filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'hidden' => true, - 'score' => 800, - ]); - // Another score from scores[0] user (should be filtered out) - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 800, - 'user_id' => $this->user, - ]); - // Unrelated score - ScoreBest::getClass(array_rand(Beatmap::MODES))::factory()->create(); - - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', $this->beatmap)) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByCountry() - { - $countryAcronym = $this->user->country_acronym; - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $countryAcronym, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'country_acronym' => $countryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), - ]), - ]; - $otherCountry = Country::factory()->create(); - $otherCountryAcronym = $otherCountry->acronym; - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $otherCountryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $otherCountryAcronym]), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'country'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByFriend() - { - $friend = User::factory()->create(); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $friend, - ]), - // Own score is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'user_id' => $this->user, - ]), - ]; - UserRelation::create([ - 'friend' => true, - 'user_id' => $this->user->getKey(), - 'zebra_id' => $friend->getKey(), - ]); - // Non-friend score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'friend'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - // Unrelated mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['FL']), - ]); - // Extra non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'HR']), - ]); - // From same user but lower score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1000, - 'user_id' => $this->user, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'HD']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithImpliedFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NC']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // With unrelated mod - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'HD']), - 'score' => 1500, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'NC', 'NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1500, - 'enabled_mods' => 0, - ]), - // Preference mod is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - 'enabled_mods' => $modsHelper->idsToBitset(['PF']), - ]), - ]; - // Non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT']), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - public function testShowForApi() { $beatmap = Beatmap::factory()->create(); @@ -621,15 +362,6 @@ protected function setUp(): void $this->beatmap = Beatmap::factory()->qualified()->create(); } - private function assertSameScoresFromResponse(array $scores, TestResponse $response): void - { - $json = json_decode($response->getContent(), true); - $this->assertSame(count($scores), count($json['scores'])); - foreach ($json['scores'] as $i => $jsonScore) { - $this->assertSame($scores[$i]->getKey(), $jsonScore['id']); - } - } - private function createExistingFruitsBeatmap() { return Beatmap::factory()->create([ diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index d9efb5b7fb9..d42526b930c 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -23,19 +23,19 @@ public function testIndex() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { @@ -65,19 +65,19 @@ public function testShow() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { diff --git a/tests/Controllers/PasswordResetControllerTest.php b/tests/Controllers/PasswordResetControllerTest.php index 75a6462a20f..0629c19deec 100644 --- a/tests/Controllers/PasswordResetControllerTest.php +++ b/tests/Controllers/PasswordResetControllerTest.php @@ -16,6 +16,8 @@ class PasswordResetControllerTest extends TestCase { + private string $origCacheDefault; + private static function randomPassword(): string { return str_random(10); @@ -259,6 +261,15 @@ protected function setUp(): void { parent::setUp(); $this->withoutMiddleware(ThrottleRequests::class); + // There's no easy way to clear data cache in redis otherwise + $this->origCacheDefault = $GLOBALS['cfg']['cache']['default']; + config_set('cache.default', 'array'); + } + + protected function tearDown(): void + { + parent::tearDown(); + config_set('cache.default', $this->origCacheDefault); } private function generateKey(User $user): string diff --git a/tests/Controllers/ScoresControllerTest.php b/tests/Controllers/ScoresControllerTest.php index ec4a585a24f..3620fa5b494 100644 --- a/tests/Controllers/ScoresControllerTest.php +++ b/tests/Controllers/ScoresControllerTest.php @@ -33,8 +33,8 @@ public function testDownload() public function testDownloadSoloScore() { $soloScore = SoloScore::factory() - ->withData(['legacy_score_id' => $this->score->getKey()]) ->create([ + 'legacy_score_id' => $this->score->getKey(), 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 'has_replay' => true, ]); diff --git a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php index e378f83e857..df4d3cb592e 100644 --- a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php +++ b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php @@ -16,7 +16,6 @@ use App\Models\Group; use App\Models\Language; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use App\Models\User; use App\Models\UserGroup; use App\Models\UserGroupEvent; @@ -36,9 +35,6 @@ public function testHandle() fn (): Score => $this->createScore($beatmapset), array_fill(0, 10, null), ); - foreach ($scores as $i => $score) { - $score->performance()->create(['pp' => rand(0, 1000)]); - } $userAdditionalScores = array_map( fn (Score $score) => $this->createScore($beatmapset, $score->user_id, $score->ruleset_id), $scores, @@ -48,12 +44,10 @@ public function testHandle() // These scores shouldn't be deleted for ($i = 0; $i < 10; $i++) { - $score = $this->createScore($beatmapset); - $score->performance()->create(['pp' => rand(0, 1000)]); + $this->createScore($beatmapset); } $this->expectCountChange(fn () => Score::count(), count($scores) * -2, 'removes scores'); - $this->expectCountChange(fn () => ScorePerformance::count(), count($scores) * -1, 'removes score performances'); static::reindexScores(); @@ -71,7 +65,6 @@ public function testHandle() Genre::truncate(); Language::truncate(); Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed - ScorePerformance::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); UserGroupEvent::truncate(); diff --git a/tests/Models/BeatmapPackUserCompletionTest.php b/tests/Models/BeatmapPackUserCompletionTest.php index 253a0290951..ca81f9075a2 100644 --- a/tests/Models/BeatmapPackUserCompletionTest.php +++ b/tests/Models/BeatmapPackUserCompletionTest.php @@ -7,53 +7,97 @@ namespace Tests\Models; +use App\Libraries\Search\ScoreSearch; use App\Models\Beatmap; use App\Models\BeatmapPack; -use App\Models\Score\Best as ScoreBest; +use App\Models\BeatmapPackItem; +use App\Models\Beatmapset; +use App\Models\Country; +use App\Models\Genre; +use App\Models\Group; +use App\Models\Language; +use App\Models\Solo\Score; use App\Models\User; +use App\Models\UserGroup; +use App\Models\UserGroupEvent; use Tests\TestCase; +/** + * @group RequiresScoreIndexer + */ class BeatmapPackUserCompletionTest extends TestCase { + private static array $users; + private static BeatmapPack $pack; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + static::withDbAccess(function () { + $beatmap = Beatmap::factory()->ranked()->state([ + 'playmode' => Beatmap::MODES['taiko'], + ])->create(); + static::$pack = BeatmapPack::factory()->create(); + static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); + + static::$users = [ + 'convertOsu' => User::factory()->create(), + 'default' => User::factory()->create(), + 'null' => null, + 'unrelated' => User::factory()->create(), + ]; + + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'ruleset_id' => Beatmap::MODES['osu'], + 'preserve' => true, + 'user_id' => static::$users['convertOsu'], + ]); + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'preserve' => true, + 'user_id' => static::$users['default'], + ]); + + static::reindexScores(); + }); + } + + public static function tearDownAfterClass(): void + { + static::withDbAccess(function () { + Beatmap::truncate(); + BeatmapPack::truncate(); + BeatmapPackItem::truncate(); + Beatmapset::truncate(); + Country::truncate(); + Genre::truncate(); + Language::truncate(); + Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed + User::truncate(); + UserGroup::truncate(); + UserGroupEvent::truncate(); + (new ScoreSearch())->deleteAll(); + }); + + parent::tearDownAfterClass(); + } + + protected $connectionsToTransact = []; + /** * @dataProvider dataProviderForTestBasic */ public function testBasic(string $userType, ?string $packRuleset, bool $completed): void { - $beatmap = Beatmap::factory()->ranked()->state([ - 'playmode' => Beatmap::MODES['taiko'], - ])->create(); - $pack = BeatmapPack::factory()->create(); - $pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); - - $scoreUser = User::factory()->create(); - $scoreClass = ScoreBest\Taiko::class; - switch ($userType) { - case 'convertOsu': - $checkUser = $scoreUser; - $scoreClass = ScoreBest\Osu::class; - break; - case 'default': - $checkUser = $scoreUser; - break; - case 'null': - $checkUser = null; - break; - case 'unrelated': - $checkUser = User::factory()->create(); - break; - } - - $scoreClass::factory()->create([ - 'beatmap_id' => $beatmap, - 'user_id' => $scoreUser->getKey(), - ]); + $user = static::$users[$userType]; $rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset]; - $pack->update(['playmode' => $rulesetId]); - $pack->refresh(); + static::$pack->update(['playmode' => $rulesetId]); + static::$pack->refresh(); - $data = $pack->userCompletionData($checkUser); + $data = static::$pack->userCompletionData($user, null); $this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids'])); $this->assertSame($completed, $data['completed']); } diff --git a/tests/Models/ContestTest.php b/tests/Models/ContestTest.php index acad06b2096..6fb7ed5e8db 100644 --- a/tests/Models/ContestTest.php +++ b/tests/Models/ContestTest.php @@ -78,7 +78,7 @@ public function testAssertVoteRequirementPlaylistBeatmapsets( MultiplayerScoreLink::factory()->state([ 'playlist_item_id' => $playlistItem, 'user_id' => $userId, - ])->completed([], [ + ])->completed([ 'ended_at' => $endedAt, 'passed' => $passed, ])->create(); diff --git a/tests/Models/Multiplayer/ScoreLinkTest.php b/tests/Models/Multiplayer/ScoreLinkTest.php index cc1e09f7800..efc84c75220 100644 --- a/tests/Models/Multiplayer/ScoreLinkTest.php +++ b/tests/Models/Multiplayer/ScoreLinkTest.php @@ -12,11 +12,26 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\ScoreLink; use App\Models\ScoreToken; -use Carbon\Carbon; use Tests\TestCase; class ScoreLinkTest extends TestCase { + private static array $commonScoreParams; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::$commonScoreParams = [ + 'accuracy' => 0.5, + 'ended_at' => new \DateTime(), + 'max_combo' => 1, + 'statistics' => [ + 'great' => 1, + ], + 'total_score' => 1, + ]; + } + public function testRequiredModsMissing() { $playlistItem = PlaylistItem::factory()->create([ @@ -32,14 +47,10 @@ public function testRequiredModsMissing() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play does not include the mods required.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -57,14 +68,11 @@ public function testRequiredModsPresent() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -85,17 +93,14 @@ public function testExpectedAllowedMod() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -117,17 +122,14 @@ public function testUnexpectedAllowedMod() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -142,14 +144,11 @@ public function testUnexpectedModWhenNoModsAreAllowed() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -170,14 +169,11 @@ public function testUnexpectedModAcceptedIfAlwaysValidForSubmission() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'TD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'TD']], - 'statistics' => [ - 'great' => 1, - ], ]); } } diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 37ded3882f9..6e4bc8f48cf 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -240,8 +240,9 @@ private function addPlay(User $user, PlaylistItem $playlistItem, array $params): [ 'beatmap_id' => $playlistItem->beatmap_id, 'ended_at' => json_time(new \DateTime()), - 'ruleset_id' => $playlistItem->ruleset_id, + 'max_combo' => 1, 'statistics' => ['good' => 1], + 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $user->getKey(), ...$params, ], diff --git a/tests/Models/Solo/ScoreEsIndexTest.php b/tests/Models/Solo/ScoreEsIndexTest.php index 2b7fcf6d117..1b53c276887 100644 --- a/tests/Models/Solo/ScoreEsIndexTest.php +++ b/tests/Models/Solo/ScoreEsIndexTest.php @@ -40,7 +40,6 @@ public static function setUpBeforeClass(): void static::$beatmap = Beatmap::factory()->qualified()->create(); $scoreFactory = Score::factory()->state(['preserve' => true]); - $defaultData = ['build_id' => 1]; $mods = [ ['acronym' => 'DT', 'settings' => []], @@ -51,43 +50,44 @@ public static function setUpBeforeClass(): void ]; static::$scores = [ - 'otherUser' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => $otherUser, ]), - 'otherUserMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1140, + 'otherUserMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1140, 'user_id' => $otherUser, ]), - 'otherUser2' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser2' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - 'otherUser3SameCountry' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1130, + 'otherUser3SameCountry' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1130, 'user_id' => User::factory()->state(['country_acronym' => static::$user->country_acronym]), ]), - 'user' => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + 'user' => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1100, 'user_id' => static::$user, ]), - 'userMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, + 'userMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1050, 'user_id' => static::$user, ]), ]; diff --git a/tests/Models/Solo/ScoreTest.php b/tests/Models/Solo/ScoreTest.php index 5f300cc4dfb..550bc08dd04 100644 --- a/tests/Models/Solo/ScoreTest.php +++ b/tests/Models/Solo/ScoreTest.php @@ -50,8 +50,8 @@ public function testLegacyPassScoreRetainsRank() 'user_id' => 1, ]); - $this->assertTrue($score->data->passed); - $this->assertSame($score->data->rank, 'S'); + $this->assertTrue($score->passed); + $this->assertSame($score->rank, 'S'); $legacy = $score->createLegacyEntryOrExplode(); @@ -75,8 +75,8 @@ public function testLegacyFailScoreIsRankF() 'user_id' => 1, ]); - $this->assertFalse($score->data->passed); - $this->assertSame($score->data->rank, 'F'); + $this->assertFalse($score->passed); + $this->assertSame($score->rank, 'F'); $legacy = $score->createLegacyEntryOrExplode(); @@ -132,13 +132,15 @@ public function testLegacyScoreHitCountsFromStudlyCaseStatistics() public function testModsPropertyType() { - $score = new Score(['data' => [ + $score = new Score([ 'beatmap_id' => 0, + 'data' => [ + 'mods' => [['acronym' => 'DT']], + ], 'ended_at' => json_time(now()), - 'mods' => [['acronym' => 'DT']], 'ruleset_id' => 0, 'user_id' => 0, - ]]); + ]); $this->assertTrue($score->data->mods[0] instanceof stdClass, 'mods entry should be of type stdClass'); } @@ -147,8 +149,7 @@ public function testWeightedPp(): void { $pp = 10; $weight = 0.5; - $score = Score::factory()->create(); - $score->performance()->create(['pp' => $pp]); + $score = Score::factory()->create(['pp' => $pp]); $score->weight = $weight; $this->assertSame($score->weightedPp(), $pp * $weight); @@ -156,7 +157,7 @@ public function testWeightedPp(): void public function testWeightedPpWithoutPerformance(): void { - $score = Score::factory()->create(); + $score = Score::factory()->create(['pp' => null]); $score->weight = 0.5; $this->assertNull($score->weightedPp()); @@ -164,8 +165,7 @@ public function testWeightedPpWithoutPerformance(): void public function testWeightedPpWithoutWeight(): void { - $score = Score::factory()->create(); - $score->performance()->create(['pp' => 10]); + $score = Score::factory()->create(['pp' => 10]); $this->assertNull($score->weightedPp()); }