From f7a561a39128fb961e2095afe7ff600765e65a91 Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 26 Jul 2023 02:03:56 +0900 Subject: [PATCH] Store multiplayer score in solo score table --- .../Rooms/Playlist/ScoresController.php | 117 ++++++------ .../Controllers/Solo/ScoresController.php | 22 +-- app/Libraries/MorphMap.php | 2 + app/Models/Contest.php | 4 +- app/Models/Multiplayer/PlaylistItem.php | 10 +- .../Multiplayer/PlaylistItemUserHighScore.php | 30 +-- app/Models/Multiplayer/Room.php | 43 +++-- app/Models/Multiplayer/Score.php | 157 ---------------- app/Models/Multiplayer/ScoreLink.php | 174 ++++++++++++++++++ app/Models/Multiplayer/UserScoreAggregate.php | 46 ++--- app/Models/Solo/Score.php | 26 ++- app/Models/Solo/ScoreData.php | 8 +- app/Models/Traits/SoloScoreInterface.php | 16 ++ .../Multiplayer/ScoreTransformer.php | 94 ---------- .../CurrentUserAttributesTransformer.php | 3 +- app/Transformers/ScoreTransformer.php | 84 ++++++++- .../factories/Multiplayer/ScoreFactory.php | 53 ------ .../Multiplayer/ScoreLinkFactory.php | 52 ++++++ ..._103744_create_multiplayer_score_links.php | 45 +++++ ...ore_link_id_to_multiplayer_scores_high.php | 35 ++++ ...core_link_id_to_multiplayer_rooms_high.php | 33 ++++ .../ModelSeeders/MultiplayerSeeder.php | 20 +- .../Chat/ChannelsControllerTest.php | 12 +- .../Rooms/Playlist/ScoresControllerTest.php | 22 +-- tests/Models/ContestTest.php | 29 ++- tests/Models/Multiplayer/RoomTest.php | 32 ++-- .../Multiplayer/UserScoreAggregateTest.php | 153 +++++++-------- 27 files changed, 737 insertions(+), 585 deletions(-) delete mode 100644 app/Models/Multiplayer/Score.php create mode 100644 app/Models/Multiplayer/ScoreLink.php create mode 100644 app/Models/Traits/SoloScoreInterface.php delete mode 100644 app/Transformers/Multiplayer/ScoreTransformer.php delete mode 100644 database/factories/Multiplayer/ScoreFactory.php create mode 100644 database/factories/Multiplayer/ScoreLinkFactory.php create mode 100644 database/migrations/2023_07_26_103744_create_multiplayer_score_links.php create mode 100644 database/migrations/2023_08_01_064505_add_score_link_id_to_multiplayer_scores_high.php create mode 100644 database/migrations/2023_08_01_101614_add_last_score_link_id_to_multiplayer_rooms_high.php diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index f2bec1bfd6a..3e2ed735a5c 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -5,14 +5,13 @@ namespace App\Http\Controllers\Multiplayer\Rooms\Playlist; -use App\Exceptions\InvariantException; use App\Http\Controllers\Controller as BaseController; use App\Libraries\ClientCheck; use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\PlaylistItemUserHighScore; use App\Models\Multiplayer\Room; -use App\Transformers\Multiplayer\ScoreTransformer; -use Carbon\Carbon; +use App\Models\Solo\Score; +use App\Transformers\ScoreTransformer; /** * @group Multiplayer @@ -53,14 +52,15 @@ public function index($roomId, $playlistId) [$highScores, $hasMore] = $playlist ->highScores() ->cursorSort($cursorHelper, cursor_from_params($params)) - ->with(ScoreTransformer::BASE_PRELOAD) + ->with(ScoreTransformer::MULTIPLAYER_BASE_PRELOAD) ->limit($limit) ->getWithHasMore(); + $transformer = ScoreTransformer::newSolo(); $scoresJson = json_collection( $highScores->pluck('score'), - 'Multiplayer\Score', - ScoreTransformer::BASE_INCLUDES + $transformer, + ScoreTransformer::MULTIPLAYER_BASE_INCLUDES ); $total = $playlist->highScores()->count(); @@ -70,7 +70,7 @@ public function index($roomId, $playlistId) $userHighScore = $playlist->highScores()->where('user_id', $user->getKey())->first(); if ($userHighScore !== null) { - $userScoreJson = json_item($userHighScore->score, 'Multiplayer\Score', ScoreTransformer::BASE_INCLUDES); + $userScoreJson = json_item($userHighScore->score, $transformer, ScoreTransformer::BASE_INCLUDES); } } @@ -102,12 +102,16 @@ public function show($roomId, $playlistId, $id) { $room = Room::find($roomId) ?? abort(404, 'Invalid room id'); $playlistItem = $room->playlist()->find($playlistId) ?? abort(404, 'Invalid playlist id'); - $score = $playlistItem->scores()->findOrFail($id); + $scoreLinks = $playlistItem->scoreLinks()->findOrFail($id); return json_item( - $score, - 'Multiplayer\Score', - array_merge(['position', 'scores_around'], ScoreTransformer::BASE_INCLUDES) + $scoreLinks, + ScoreTransformer::newSolo(), + [ + ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, + 'position', + 'scores_around', + ], ); } @@ -134,8 +138,12 @@ public function showUser($roomId, $playlistId, $userId) return json_item( $score, - 'Multiplayer\Score', - array_merge(['position', 'scores_around'], ScoreTransformer::BASE_INCLUDES) + ScoreTransformer::newSolo(), + [ + ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, + 'position', + 'scores_around', + ], ); } @@ -149,14 +157,12 @@ public function store($roomId, $playlistId) $user = auth()->user(); $params = request()->all(); - ClientCheck::findBuild($user, $params); + $buildId = ClientCheck::findBuild($user, $params)?->getKey() + ?? config('osu.client.default_build_id'); - $score = $room->startPlay($user, $playlistItem); + $score = $room->startPlay($user, $playlistItem, $buildId); - return json_item( - $score, - 'Multiplayer\Score' - ); + return json_item($score, ScoreTransformer::newSolo()); } /** @@ -164,49 +170,40 @@ public function store($roomId, $playlistId) */ public function update($roomId, $playlistId, $scoreId) { - $room = Room::findOrFail($roomId); - - $playlistItem = $room->playlist() - ->where('id', $playlistId) - ->firstOrFail(); - - $roomScore = $playlistItem->scores() - ->where('user_id', auth()->user()->getKey()) - ->where('id', $scoreId) - ->firstOrFail(); - - try { - $score = $room->completePlay( - $roomScore, - $this->extractScoreParams(request()->all(), $playlistItem) - ); - - return json_item( - $score, - 'Multiplayer\Score', - array_merge(['position', 'scores_around'], ScoreTransformer::BASE_INCLUDES) - ); - } catch (InvariantException $e) { - return error_popup($e->getMessage(), $e->getStatusCode()); + $scoreLink = \DB::transaction(function () use ($roomId, $playlistId, $scoreId) { + $room = Room::findOrFail($roomId); + + $scoreLink = $room + ->scoreLinks() + ->where([ + 'user_id' => \Auth::id(), + 'playlist_item_id' => $playlistId, + ])->with('playlistItem') + ->lockForUpdate() + ->findOrFail($scoreId); + + $params = Score::extractParams(\Request::all(), $scoreLink); + + $room->completePlay($scoreLink, $params); + + return $scoreLink; + }); + + $score = $scoreLink->score; + $transformer = ScoreTransformer::newSolo(); + if ($score->wasRecentlyCreated) { + $scoreJson = json_item($score, $transformer); + $score::queueForProcessing($scoreJson); } - } - private function extractScoreParams(array $params, PlaylistItem $playlistItem) - { - $mods = app('mods')->parseInputArray( - $playlistItem->ruleset_id, - $params['mods'] ?? [], + return json_item( + $scoreLink, + $transformer, + [ + ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, + 'position', + 'scores_around', + ], ); - - return [ - 'rank' => $params['rank'] ?? null, - 'total_score' => get_int($params['total_score'] ?? null), - 'accuracy' => get_float($params['accuracy'] ?? null), - 'max_combo' => get_int($params['max_combo'] ?? null), - 'ended_at' => Carbon::now(), - 'passed' => get_bool($params['passed'] ?? null), - 'mods' => $mods, - 'statistics' => $params['statistics'] ?? null, - ]; } } diff --git a/app/Http/Controllers/Solo/ScoresController.php b/app/Http/Controllers/Solo/ScoresController.php index 3bef06a3722..8de9548e9f0 100644 --- a/app/Http/Controllers/Solo/ScoresController.php +++ b/app/Http/Controllers/Solo/ScoresController.php @@ -29,27 +29,7 @@ public function store($beatmapId, $tokenId) // return existing score otherwise (assuming duplicated submission) if ($scoreToken->score_id === null) { - $params = get_params(request()->all(), null, [ - 'accuracy:float', - 'max_combo:int', - 'maximum_statistics:array', - 'mods:array', - 'passed:bool', - 'rank:string', - 'statistics:array', - 'total_score:int', - ]); - - $params = array_merge($params, [ - 'beatmap_id' => $scoreToken->beatmap_id, - 'build_id' => $scoreToken->build_id, - 'ended_at' => json_time(now()), - 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []), - 'ruleset_id' => $scoreToken->ruleset_id, - 'started_at' => $scoreToken->created_at_json, - 'user_id' => $scoreToken->user_id, - ]); - + $params = Score::extractParams(\Request::all(), $scoreToken); $score = Score::createFromJsonOrExplode($params); $score->createLegacyEntryOrExplode(); $scoreToken->fill(['score_id' => $score->getKey()])->saveOrExplode(); diff --git a/app/Libraries/MorphMap.php b/app/Libraries/MorphMap.php index 91f41953dae..d79ad41cb88 100644 --- a/app/Libraries/MorphMap.php +++ b/app/Libraries/MorphMap.php @@ -14,6 +14,7 @@ use App\Models\Comment; use App\Models\Forum; use App\Models\LegacyMatch; +use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; use App\Models\NewsPost; use App\Models\Score; use App\Models\Solo; @@ -32,6 +33,7 @@ class MorphMap Forum\Topic::class => 'forum_topic', LegacyMatch\Score::class => 'legacy_match_score', Message::class => 'message', + MultiplayerScoreLink::class => 'multiplayer_score_link', NewsPost::class => 'news_post', Score\Best\Fruits::class => 'score_best_fruits', Score\Best\Mania::class => 'score_best_mania', diff --git a/app/Models/Contest.php b/app/Models/Contest.php index aa6c8797c2a..951177f6a04 100644 --- a/app/Models/Contest.php +++ b/app/Models/Contest.php @@ -85,13 +85,13 @@ public function assertVoteRequirement(?User $user): void $mustPass = $requirement['must_pass'] ?? true; $beatmapIdsQuery = Multiplayer\PlaylistItem::whereIn('room_id', $roomIds)->select('beatmap_id'); $requiredBeatmapsetCount = Beatmap::whereIn('beatmap_id', $beatmapIdsQuery)->distinct('beatmapset_id')->count(); - $playedBeatmapIdsQuery = Multiplayer\Score + $playedBeatmapIdsQuery = Multiplayer\ScoreLink ::whereIn('room_id', $roomIds) ->where(['user_id' => $user->getKey()]) ->completed() ->select('beatmap_id'); if ($mustPass) { - $playedBeatmapIdsQuery->where('passed', true); + $playedBeatmapIdsQuery->whereHas('playlistItemUserHighScore'); } $playedBeatmapsetCount = Beatmap::whereIn('beatmap_id', $playedBeatmapIdsQuery)->distinct('beatmapset_id')->count(); diff --git a/app/Models/Multiplayer/PlaylistItem.php b/app/Models/Multiplayer/PlaylistItem.php index cdeceffcdab..12a60be8cad 100644 --- a/app/Models/Multiplayer/PlaylistItem.php +++ b/app/Models/Multiplayer/PlaylistItem.php @@ -23,7 +23,7 @@ * @property Room $room * @property int $room_id * @property int|null $ruleset_id - * @property \Illuminate\Database\Eloquent\Collection $scores Score + * @property \Illuminate\Database\Eloquent\Collection $scoreLinks ScoreLink * @property \Carbon\Carbon|null $updated_at * @property bool expired * @property \Carbon\Carbon|null $played_at @@ -96,17 +96,17 @@ public function highScores() return $this->hasMany(PlaylistItemUserHighScore::class); } - public function scores() + public function scoreLinks() { - return $this->hasMany(Score::class); + return $this->hasMany(ScoreLink::class); } public function topScores() { return $this->highScores() - ->with('score') + ->with('scoreLink.score') ->orderBy('total_score', 'desc') - ->orderBy('score_id', 'asc'); + ->orderBy('score_link_id', 'asc'); } private function assertValidMaxAttempts() diff --git a/app/Models/Multiplayer/PlaylistItemUserHighScore.php b/app/Models/Multiplayer/PlaylistItemUserHighScore.php index 1368b455dd0..1330ab58dbc 100644 --- a/app/Models/Multiplayer/PlaylistItemUserHighScore.php +++ b/app/Models/Multiplayer/PlaylistItemUserHighScore.php @@ -17,9 +17,8 @@ * @property int $id * @property int $playlist_item_id * @property float|null $pp - * @property int $score_id - * @property Score $score - * @property int $total_score + * @property int $score_link_id + * @property ScoreLink $scoreLink * @property \Carbon\Carbon $updated_at * @property int $user_id */ @@ -30,11 +29,11 @@ class PlaylistItemUserHighScore extends Model const SORTS = [ 'score_desc' => [ ['column' => 'total_score', 'order' => 'DESC'], - ['column' => 'score_id', 'order' => 'ASC'], + ['column' => 'score_link_id', 'order' => 'ASC'], ], 'score_asc' => [ ['column' => 'total_score', 'order' => 'ASC'], - ['column' => 'score_id', 'order' => 'DESC'], + ['column' => 'score_link_id', 'order' => 'DESC'], ], ]; @@ -42,11 +41,11 @@ class PlaylistItemUserHighScore extends Model protected $table = 'multiplayer_scores_high'; - public static function lookupOrDefault(Score $score): static + public static function lookupOrDefault(ScoreLink $scoreLink): static { return static::firstOrNew([ - 'playlist_item_id' => $score->playlist_item_id, - 'user_id' => $score->user_id, + 'playlist_item_id' => $scoreLink->playlist_item_id, + 'user_id' => $scoreLink->user_id, ], [ 'accuracy' => 0, 'pp' => 0, @@ -54,18 +53,21 @@ public static function lookupOrDefault(Score $score): static ]); } - public function score() + public function scoreLink() { - return $this->belongsTo(Score::class); + return $this->belongsTo(ScoreLink::class); } - public function updateWithScore(Score $score): void + public function updateWithScoreLink(ScoreLink $scoreLink): void { + $score = $scoreLink->score; + $this->fill([ - 'accuracy' => $score->accuracy, + 'accuracy' => $score->data->accuracy, 'pp' => $score->pp, - 'score_id' => $score->getKey(), - 'total_score' => $score->total_score, + 'score_id' => 0, // TODO: remove after migrated + 'score_link_id' => $scoreLink->getKey(), + 'total_score' => $score->data->totalScore, ])->save(); } } diff --git a/app/Models/Multiplayer/Room.php b/app/Models/Multiplayer/Room.php index c310f408a5f..13bc6d99ad0 100644 --- a/app/Models/Multiplayer/Room.php +++ b/app/Models/Multiplayer/Room.php @@ -35,7 +35,7 @@ * @property string $name * @property int $participant_count * @property \Illuminate\Database\Eloquent\Collection $playlist PlaylistItem - * @property \Illuminate\Database\Eloquent\Collection $scores Score + * @property \Illuminate\Database\Eloquent\Collection $scoreLinks ScoreLink * @property-read Collection<\App\Models\Season> $seasons * @property \Carbon\Carbon $starts_at * @property \Carbon\Carbon|null $updated_at @@ -227,9 +227,9 @@ public function playlist() return $this->hasMany(PlaylistItem::class); } - public function scores() + public function scoreLinks() { - return $this->hasMany(Score::class); + return $this->hasMany(ScoreLink::class); } public function seasons() @@ -258,11 +258,9 @@ public function scopeEnded($query) public function scopeHasParticipated($query, User $user) { - return $query->whereIn( - 'id', - // SoftDelete scope is ignored, fixed in 5.8: - // https://github.com/laravel/framework/pull/26198 - Score::withoutTrashed()->where('user_id', $user->getKey())->select('room_id') + return $query->whereHas( + 'scoreLinks', + fn ($q) => $q->where('user_id', $user->getKey()), ); } @@ -386,23 +384,23 @@ public function getRecentParticipantIdsAttribute() public function calculateMissingTopScores() { // just run through all the users, UserScoreAggregate::new will calculate and persist if necessary. - $users = User::whereIn('user_id', Score::where('room_id', $this->getKey())->select('user_id')); + $users = User::whereIn('user_id', ScoreLink::where('room_id', $this->getKey())->select('user_id')); $users->each(function ($user) { UserScoreAggregate::new($user, $this); }); } - public function completePlay(Score $score, array $params) + public function completePlay(ScoreLink $scoreLink, array $params) { - priv_check_user($score->user, 'MultiplayerScoreSubmit')->ensureCan(); + priv_check_user($scoreLink->user, 'MultiplayerScoreSubmit')->ensureCan(); $this->assertValidCompletePlay(); - return $score->getConnection()->transaction(function () use ($params, $score) { - $score->complete($params); - UserScoreAggregate::new($score->user, $this)->addScore($score); + return $scoreLink->getConnection()->transaction(function () use ($params, $scoreLink) { + $scoreLink->complete($params); + UserScoreAggregate::new($scoreLink->user, $this)->addScoreLink($scoreLink); - return $score; + return $scoreLink; }); } @@ -589,13 +587,13 @@ public function startGame(User $host, array $rawParams) return $this->fresh(); } - public function startPlay(User $user, PlaylistItem $playlistItem) + public function startPlay(User $user, PlaylistItem $playlistItem, int $buildId) { priv_check_user($user, 'MultiplayerScoreSubmit')->ensureCan(); $this->assertValidStartPlay($user, $playlistItem); - return $this->getConnection()->transaction(function () use ($user, $playlistItem) { + return $this->getConnection()->transaction(function () use ($buildId, $user, $playlistItem) { $agg = UserScoreAggregate::new($user, $this); if ($agg->wasRecentlyCreated) { $this->incrementInstance('participant_count'); @@ -603,11 +601,12 @@ public function startPlay(User $user, PlaylistItem $playlistItem) $agg->updateUserAttempts(); - return Score::start([ - 'user_id' => $user->getKey(), - 'room_id' => $this->getKey(), - 'playlist_item_id' => $playlistItem->getKey(), + return ScoreLink::create([ 'beatmap_id' => $playlistItem->beatmap_id, + 'build_id' => $buildId, + 'playlist_item_id' => $playlistItem->getKey(), + 'room_id' => $this->getKey(), + 'user_id' => $user->getKey(), ]); }); } @@ -683,7 +682,7 @@ private function assertValidStartPlay(User $user, PlaylistItem $playlistItem) } if ($playlistItem->max_attempts !== null) { - $playlistAttempts = $playlistItem->scores()->where('user_id', $user->getKey())->count(); + $playlistAttempts = $playlistItem->scoreLinks()->where('user_id', $user->getKey())->count(); if ($playlistAttempts >= $playlistItem->max_attempts) { throw new InvariantException('You have reached the maximum number of tries allowed.'); } diff --git a/app/Models/Multiplayer/Score.php b/app/Models/Multiplayer/Score.php deleted file mode 100644 index c321a966689..00000000000 --- a/app/Models/Multiplayer/Score.php +++ /dev/null @@ -1,157 +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\Multiplayer; - -use App\Exceptions\GameCompletedException; -use App\Exceptions\InvariantException; -use App\Models\Model; -use App\Models\Solo\ScoreData; -use App\Models\User; -use Carbon\Carbon; -use Illuminate\Database\Eloquent\SoftDeletes; - -/** - * @property float|null $accuracy - * @property int $beatmap_id - * @property \Carbon\Carbon|null $created_at - * @property \Carbon\Carbon|null $deleted_at - * @property \Carbon\Carbon|null $ended_at - * @property int $id - * @property int|null $max_combo - * @property array|null $mods - * @property bool|null $passed - * @property PlaylistItem $playlistItem - * @property int $playlist_item_id - * @property float|null $pp - * @property mixed|null $rank - * @property Room $room - * @property int $room_id - * @property \Carbon\Carbon $started_at - * @property \stdClass|null $statistics - * @property int|null $total_score - * @property \Carbon\Carbon|null $updated_at - * @property User $user - * @property int $user_id - */ -class Score extends Model -{ - use SoftDeletes; - - protected $casts = [ - 'ended_at' => 'datetime', - 'mods' => 'object', - 'passed' => 'boolean', - 'started_at' => 'datetime', - 'statistics' => 'object', - ]; - protected $table = 'multiplayer_scores'; - - public static function start(array $params) - { - // TODO: move existence checks here? - $score = new static($params); - $score->started_at = Carbon::now(); - - $score->save(); - - return $score; - } - - public function playlistItem() - { - return $this->belongsTo(PlaylistItem::class, 'playlist_item_id'); - } - - public function room() - { - return $this->belongsTo(Room::class, 'room_id'); - } - - public function user() - { - return $this->belongsTo(User::class, 'user_id'); - } - - public function getDataAttribute() - { - // FIXME: convert this class to the new score table layout - $params = $this->getAttributes(); - $params['mods'] = json_decode($params['mods'], true); - $params['passed'] = get_bool($params['passed']); - $params['ruleset_id'] = $this->playlistItem->ruleset_id; - $params['statistics'] = json_decode($params['statistics'], true); - $params['ruleset_id'] = $this->playlistItem->ruleset_id; - - return new ScoreData($params); - } - - public function scopeCompleted($query) - { - return $query->whereNotNull('ended_at'); - } - - public function scopeForPlaylistItem($query, $playlistItemId) - { - return $query->where('playlist_item_id', $playlistItemId); - } - - public function isCompleted() - { - return present($this->ended_at); - } - - public function complete(array $params) - { - if ($this->isCompleted()) { - throw new GameCompletedException('cannot modify score after submission'); - } - - $this->fill($params); - - if (!empty($this->playlistItem->required_mods)) { - $missingMods = array_diff( - array_column($this->playlistItem->required_mods, 'acronym'), - array_column($this->mods, 'acronym') - ); - - if (!empty($missingMods)) { - throw new InvariantException('This play does not include the mods required.'); - } - } - - if (!empty($this->playlistItem->allowed_mods)) { - $missingMods = array_diff( - array_column($this->mods, 'acronym'), - array_column($this->playlistItem->required_mods, 'acronym'), - array_column($this->playlistItem->allowed_mods, 'acronym') - ); - - if (!empty($missingMods)) { - throw new InvariantException('This play includes mods that are not allowed.'); - } - } - - $this->data->assertCompleted(); - - $this->save(); - } - - public function userRank() - { - if ($this->total_score === null || $this->getKey() === null) { - return; - } - - $query = PlaylistItemUserHighScore - ::where('playlist_item_id', $this->playlist_item_id) - ->cursorSort('score_asc', [ - 'total_score' => $this->total_score, - 'score_id' => $this->getKey(), - ]); - - return 1 + $query->count(); - } -} diff --git a/app/Models/Multiplayer/ScoreLink.php b/app/Models/Multiplayer/ScoreLink.php new file mode 100644 index 00000000000..2a686535683 --- /dev/null +++ b/app/Models/Multiplayer/ScoreLink.php @@ -0,0 +1,174 @@ +. 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\Multiplayer; + +use App\Exceptions\GameCompletedException; +use App\Exceptions\InvariantException; +use App\Models\Model; +use App\Models\Solo\Score; +use App\Models\Traits\SoloScoreInterface; +use App\Models\User; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property int $build_id + * @property \Carbon\Carbon|null $created_at + * @property int $id + * @property PlaylistItem $playlistItem + * @property int $playlist_item_id + * @property Room $room + * @property int $room_id + * @property Score $score + * @property int|null $score_id + * @property \Carbon\Carbon|null $updated_at + * @property User $user + * @property int $user_id + */ +class ScoreLink extends Model implements SoloScoreInterface +{ + protected $table = 'multiplayer_score_links'; + + private Score $defaultScore; + + public function playlistItem() + { + return $this->belongsTo(PlaylistItem::class, 'playlist_item_id'); + } + + public function playlistItemUserHighScore() + { + return $this->hasOne(PlaylistItemUserHighScore::class); + } + + public function room() + { + return $this->belongsTo(Room::class, 'room_id'); + } + + public function score(): BelongsTo + { + return $this->belongsTo(Score::class, 'score_id'); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function getAttribute($key) + { + return match ($key) { + 'build_id', + 'id', + 'playlist_item_id', + 'room_id', + 'score_id', + 'user_id' => $this->getRawAttribute($key), + + 'data' => $this->getScoreOrDefault()->data, + 'has_replay' => $this->getScoreOrDefault()->has_replay ?? false, + 'pp' => $this->getScoreOrDefault()->pp ?? 0.0, + + 'beatmap_id' => $this->playlistItem?->beatmap_id, + 'ruleset_id' => $this->playlistItem?->ruleset_id, + + 'created_at', + 'updated_at' => $this->getTimeFast($key), + + 'created_at_json', + 'updated_at_json' => $this->getJsonTimeFast($key), + + 'playlistItem', + 'playlistItemUserHighScore', + 'room', + 'score', + 'user' => $this->getRelationValue($key), + }; + } + + public function scopeCompleted($query) + { + return $query->whereNotNull('score_id'); + } + + public function scopeForPlaylistItem($query, $playlistItemId) + { + return $query->where('playlist_item_id', $playlistItemId); + } + + public function isCompleted(): bool + { + return $this->score_id !== null; + } + + public function complete(array $params) + { + $this->getConnection()->transaction(function () use ($params) { + if ($this->isCompleted()) { + throw new GameCompletedException('cannot modify score after submission'); + } + + $score = Score::createFromJsonOrExplode($params); + $mods = $score->data->mods; + + if (!empty($this->playlistItem->required_mods)) { + $missingMods = array_diff( + array_column($this->playlistItem->required_mods, 'acronym'), + array_column($mods, 'acronym') + ); + + if (!empty($missingMods)) { + throw new InvariantException('This play does not include the mods required.'); + } + } + + if (!empty($this->playlistItem->allowed_mods)) { + $missingMods = array_diff( + array_column($mods, 'acronym'), + array_column($this->playlistItem->required_mods, 'acronym'), + array_column($this->playlistItem->allowed_mods, 'acronym') + ); + + if (!empty($missingMods)) { + throw new InvariantException('This play includes mods that are not allowed.'); + } + } + + $this->score()->associate($score); + $this->save(); + }); + } + + public function position(): ?int + { + $score = $this->score; + + if ($score === null) { + return null; + } + + $query = PlaylistItemUserHighScore + ::where('playlist_item_id', $this->playlist_item_id) + ->cursorSort('score_asc', [ + 'total_score' => $score->data->totalScore, + 'score_link_id' => $this->getKey(), + ]); + + return 1 + $query->count(); + } + + private function getScoreOrDefault(): Score + { + return $this->score ?? ($this->defaultScore ??= new Score([ + 'beatmap_id' => $this->beatmap_id, + 'ruleset_id' => $this->ruleset_id, + 'user_id' => $this->user_id, + 'created_at' => Carbon::now(), + 'data' => [], + ])); + } +} diff --git a/app/Models/Multiplayer/UserScoreAggregate.php b/app/Models/Multiplayer/UserScoreAggregate.php index f0d68ecd52a..472efb6a5a7 100644 --- a/app/Models/Multiplayer/UserScoreAggregate.php +++ b/app/Models/Multiplayer/UserScoreAggregate.php @@ -8,6 +8,7 @@ use App\Models\Model; use App\Models\Traits\WithDbCursorHelper; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; /** * Aggregate root for user multiplayer high scores. @@ -32,7 +33,7 @@ class UserScoreAggregate extends Model const SORTS = [ 'score_asc' => [ ['column' => 'total_score', 'order' => 'ASC'], - ['column' => 'last_score_id', 'order' => 'DESC'], + ['column' => 'last_score_link_id', 'order' => 'DESC'], ], ]; @@ -69,18 +70,19 @@ public static function new(User $user, Room $room): self return $obj; } - public function addScore(Score $score) + public function addScoreLink(ScoreLink $scoreLink) { - return $this->getConnection()->transaction(function () use ($score) { - if (!$score->isCompleted()) { + return $this->getConnection()->transaction(function () use ($scoreLink) { + $score = $scoreLink->score; + if ($score === null) { return false; } - $highestScore = PlaylistItemUserHighScore::lookupOrDefault($score); + $highestScore = PlaylistItemUserHighScore::lookupOrDefault($scoreLink); - if ($score->passed && $score->total_score > $highestScore->total_score) { - $this->updateUserTotal($score, $highestScore); - $highestScore->updateWithScore($score); + if ($score->data->passed && $score->data->totalScore > $highestScore->total_score) { + $this->updateUserTotal($scoreLink, $highestScore); + $highestScore->updateWithScoreLink($scoreLink); } return true; @@ -97,23 +99,21 @@ public function averagePp() return $this->completed > 0 ? $this->pp / $this->completed : 0; } - public function getScores() + public function scoreLinks(): Builder { - return Score + return ScoreLink ::where('room_id', $this->room_id) - ->where('user_id', $this->user_id) - ->get(); + ->where('user_id', $this->user_id); } public function recalculate() { $this->getConnection()->transaction(function () { $this->removeRunningTotals(); - $this->getScores()->each(function ($score) { + foreach ($this->scoreLinks()->with('score.performance')->get() as $scoreLink) { $this->attempts++; - $this->addScore($score); - }); - + $this->addScoreLink($scoreLink); + } $this->save(); }); } @@ -144,7 +144,7 @@ public function scopeForRanking($query) $userQuery->default(); }) ->orderBy('total_score', 'DESC') - ->orderBy('last_score_id', 'ASC'); + ->orderBy('last_score_link_id', 'ASC'); } public function updateUserAttempts() @@ -159,7 +159,7 @@ public function user() public function userRank() { - if ($this->total_score === null || $this->last_score_id === null) { + if ($this->total_score === null || $this->last_score_link_id === null) { return; } @@ -169,7 +169,7 @@ public function userRank() return 1 + $query->count(); } - private function updateUserTotal(Score $current, PlaylistItemUserHighScore $prev) + private function updateUserTotal(ScoreLink $currentScoreLink, PlaylistItemUserHighScore $prev) { if ($prev->exists) { $this->total_score -= $prev->total_score; @@ -178,11 +178,13 @@ private function updateUserTotal(Score $current, PlaylistItemUserHighScore $prev $this->completed--; } - $this->total_score += $current->total_score; - $this->accuracy += $current->accuracy; + $current = $currentScoreLink->score; + + $this->total_score += $current->data->totalScore; + $this->accuracy += $current->data->accuracy; $this->pp += $current->pp; $this->completed++; - $this->last_score_id = $current->getKey(); + $this->last_score_link_id = $currentScoreLink->getKey(); $this->save(); } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 6bca92c036d..5765d243f4f 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -11,9 +11,11 @@ use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\Model; +use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; use App\Models\Score as LegacyScore; use App\Models\Traits; use App\Models\User; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use LaravelRedis; use Storage; @@ -30,7 +32,7 @@ * @property User $user * @property int $user_id */ -class Score extends Model implements Traits\ReportableInterface +class Score extends Model implements Traits\ReportableInterface, Traits\SoloScoreInterface { use Traits\Reportable, Traits\WithWeightedPp; @@ -66,6 +68,28 @@ public static function createFromJsonOrExplode(array $params) return $score; } + public static function extractParams(array $params, 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, + ]; + } + /** * Queue the item for score processing * diff --git a/app/Models/Solo/ScoreData.php b/app/Models/Solo/ScoreData.php index 2338fa1d010..5fd564293c4 100644 --- a/app/Models/Solo/ScoreData.php +++ b/app/Models/Solo/ScoreData.php @@ -87,7 +87,13 @@ public function get($model, $key, $value, $attributes) public function set($model, $key, $value, $attributes) { if (!($value instanceof ScoreData)) { - $value = new ScoreData($value); + $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, + ]); } return ['data' => json_encode($value)]; diff --git a/app/Models/Traits/SoloScoreInterface.php b/app/Models/Traits/SoloScoreInterface.php new file mode 100644 index 00000000000..3541b877c57 --- /dev/null +++ b/app/Models/Traits/SoloScoreInterface.php @@ -0,0 +1,16 @@ +. 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); + +namespace App\Models\Traits; + +interface SoloScoreInterface +{ + // Eloquent attributes + // public \App\Models\Solo\ScoreData $data; + // public bool $has_replay + // public float $pp; +} diff --git a/app/Transformers/Multiplayer/ScoreTransformer.php b/app/Transformers/Multiplayer/ScoreTransformer.php deleted file mode 100644 index d5b2e46974a..00000000000 --- a/app/Transformers/Multiplayer/ScoreTransformer.php +++ /dev/null @@ -1,94 +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\Transformers\Multiplayer; - -use App\Models\Multiplayer\PlaylistItemUserHighScore; -use App\Models\Multiplayer\Score; -use App\Transformers\TransformerAbstract; -use App\Transformers\UserCompactTransformer; - -class ScoreTransformer extends TransformerAbstract -{ - // warning: this is actually for PlaylistItemUserHighScore, not for Score - const BASE_PRELOAD = ['score.user.userProfileCustomization', 'score.user.country']; - const BASE_INCLUDES = ['user.country', 'user.cover']; - - protected array $availableIncludes = [ - 'position', - 'scores_around', - 'user', - ]; - - public function transform(Score $score) - { - return [ - 'id' => $score->id, - 'user_id' => $score->user_id, - 'room_id' => $score->room_id, - 'playlist_item_id' => $score->playlist_item_id, - 'beatmap_id' => $score->beatmap_id, - 'rank' => $score->rank, - 'total_score' => $score->total_score, - 'accuracy' => $score->accuracy, - 'max_combo' => $score->max_combo, - 'mods' => $score->mods, - 'statistics' => $score->statistics, - 'passed' => $score->passed, - - 'started_at' => json_time($score->started_at), - 'ended_at' => json_time($score->ended_at), - ]; - } - - public function includePosition(Score $score) - { - return $this->primitive($score->userRank()); - } - - public function includeScoresAround(Score $score) - { - $limit = 10; - - $highScorePlaceholder = new PlaylistItemUserHighScore([ - 'score_id' => $score->getKey(), - 'total_score' => $score->total_score, - ]); - - $typeOptions = [ - 'higher' => 'score_asc', - 'lower' => 'score_desc', - ]; - - $ret = []; - - foreach ($typeOptions as $type => $sortName) { - $cursorHelper = PlaylistItemUserHighScore::makeDbCursorHelper($sortName); - [$highScores, $hasMore] = PlaylistItemUserHighScore - ::cursorSort($cursorHelper, $highScorePlaceholder) - ->with(static::BASE_PRELOAD) - ->where('playlist_item_id', $score->playlist_item_id) - ->where('user_id', '<>', $score->user_id) - ->limit($limit) - ->getWithHasMore(); - - $ret[$type] = [ - 'scores' => json_collection($highScores->pluck('score'), new static(), static::BASE_INCLUDES), - 'params' => ['limit' => $limit, 'sort' => $cursorHelper->getSortName()], - ...cursor_for_response($cursorHelper->next($highScores, $hasMore)), - ]; - } - - return $this->primitive($ret); - } - - public function includeUser(Score $score) - { - return $this->item( - $score->user, - new UserCompactTransformer() - ); - } -} diff --git a/app/Transformers/Score/CurrentUserAttributesTransformer.php b/app/Transformers/Score/CurrentUserAttributesTransformer.php index 5ed7b0fa8b6..ced66eb3dbe 100644 --- a/app/Transformers/Score/CurrentUserAttributesTransformer.php +++ b/app/Transformers/Score/CurrentUserAttributesTransformer.php @@ -10,11 +10,12 @@ use App\Models\LegacyMatch; use App\Models\Score\Model as ScoreModel; use App\Models\Solo\Score as SoloScore; +use App\Models\Traits\SoloScoreInterface; use App\Transformers\TransformerAbstract; class CurrentUserAttributesTransformer extends TransformerAbstract { - public function transform(LegacyMatch\Score|ScoreModel|SoloScore $score): array + public function transform(LegacyMatch\Score|ScoreModel|SoloScoreInterface $score): array { $pinnable = $score instanceof ScoreModel ? $score->best diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index d0d63f225ed..fcd373fe902 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -10,13 +10,24 @@ use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; +use App\Models\Multiplayer\PlaylistItemUserHighScore; +use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; use App\Models\Score\Best\Model as ScoreBest; use App\Models\Score\Model as ScoreModel; use App\Models\Solo\Score as SoloScore; +use App\Models\Traits\SoloScoreInterface; use League\Fractal\Resource\Item; 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.user.country', + 'scoreLink.user.userProfileCustomization', + ]; + const TYPE_LEGACY = 'legacy'; const TYPE_SOLO = 'solo'; @@ -38,6 +49,10 @@ class ScoreTransformer extends TransformerAbstract 'rank_global', 'user', 'weight', + + // Only for MultiplayerScoreLink + 'position', + 'scores_around', ]; protected array $defaultIncludes = [ @@ -46,6 +61,11 @@ class ScoreTransformer extends TransformerAbstract private string $transformFunction; + public static function newSolo(): static + { + return new static(static::TYPE_SOLO); + } + public function __construct(?string $type = null) { $type ??= is_api_request() && api_version() < 20220705 @@ -62,14 +82,14 @@ public function __construct(?string $type = null) } } - public function transform(LegacyMatch\Score|ScoreModel|SoloScore $score) + public function transform(LegacyMatch\Score|ScoreModel|SoloScoreInterface $score) { $fn = $this->transformFunction; return $this->$fn($score); } - public function transformSolo(ScoreModel|SoloScore $score) + public function transformSolo(ScoreModel|SoloScoreInterface $score) { if ($score instanceof ScoreModel) { $legacyPerfect = $score->perfect; @@ -80,19 +100,28 @@ public function transformSolo(ScoreModel|SoloScore $score) $pp = $best->pp; $replay = $best->replay; } - } elseif ($score instanceof SoloScore) { + } elseif ($score instanceof SoloScoreInterface) { $pp = $score->pp; $replay = $score->has_replay; + + if ($score instanceof MultiplayerScoreLink) { + $multiplayerAttributes = [ + 'room_id' => $score->room_id, + 'playlist_item_id' => $score->playlist_item_id, + ]; + } } - return array_merge($score->data->jsonSerialize(), [ + return [ + ...$score->data->jsonSerialize(), + ...($multiplayerAttributes ?? []), 'best_id' => $bestId ?? null, 'id' => $score->getKey(), 'legacy_perfect' => $legacyPerfect ?? null, 'pp' => $pp ?? null, 'replay' => $replay ?? false, 'type' => $score->getMorphClass(), - ]); + ]; } public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) @@ -170,7 +199,7 @@ public function includeBeatmapset(LegacyMatch\Score|ScoreModel|SoloScore $score) return $this->item($score->beatmap->beatmapset, new BeatmapsetCompactTransformer()); } - public function includeCurrentUserAttributes(LegacyMatch\Score|ScoreModel|SoloScore $score): Item + public function includeCurrentUserAttributes(LegacyMatch\Score|ScoreModel|SoloScoreInterface $score): Item { return $this->item($score, new Score\CurrentUserAttributesTransformer()); } @@ -184,6 +213,47 @@ public function includeMatch(LegacyMatch\Score $score) ]); } + public function includePosition(MultiplayerScoreLink $scoreLink) + { + return $this->primitive($scoreLink->position()); + } + + public function includeScoresAround(MultiplayerScoreLink $scoreLink) + { + $limit = 10; + + $highScorePlaceholder = new PlaylistItemUserHighScore([ + 'score_link_id' => $scoreLink->getKey(), + 'total_score' => $scoreLink->data->totalScore, + ]); + + $typeOptions = [ + 'higher' => 'score_asc', + 'lower' => 'score_desc', + ]; + + $ret = []; + + foreach ($typeOptions as $type => $sortName) { + $cursorHelper = PlaylistItemUserHighScore::makeDbCursorHelper($sortName); + [$highScores, $hasMore] = PlaylistItemUserHighScore + ::cursorSort($cursorHelper, $highScorePlaceholder) + ->with(static::MULTIPLAYER_BASE_PRELOAD) + ->where('playlist_item_id', $scoreLink->playlist_item_id) + ->where('user_id', '<>', $scoreLink->user_id) + ->limit($limit) + ->getWithHasMore(); + + $ret[$type] = [ + 'scores' => json_collection($highScores->pluck('scoreLink.score'), new static(), static::MULTIPLAYER_BASE_INCLUDES), + 'params' => ['limit' => $limit, 'sort' => $cursorHelper->getSortName()], + ...cursor_for_response($cursorHelper->next($highScores, $hasMore)), + ]; + } + + return $this->primitive($ret); + } + public function includeRankCountry(ScoreBest|SoloScore $score) { return $this->primitive($score->userRank(['type' => 'country'])); @@ -194,7 +264,7 @@ public function includeRankGlobal(ScoreBest|SoloScore $score) return $this->primitive($score->userRank([])); } - public function includeUser(LegacyMatch\Score|ScoreModel|SoloScore $score) + public function includeUser(LegacyMatch\Score|ScoreModel|SoloScoreInterface $score) { return $this->item( $score->user ?? new DeletedUser(['user_id' => $score->user_id]), diff --git a/database/factories/Multiplayer/ScoreFactory.php b/database/factories/Multiplayer/ScoreFactory.php deleted file mode 100644 index e72320d3713..00000000000 --- a/database/factories/Multiplayer/ScoreFactory.php +++ /dev/null @@ -1,53 +0,0 @@ -. 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); - -namespace Database\Factories\Multiplayer; - -use App\Models\Multiplayer\PlaylistItem; -use App\Models\Multiplayer\Score; -use App\Models\User; -use Carbon\Carbon; -use Database\Factories\Factory; - -class ScoreFactory extends Factory -{ - protected $model = Score::class; - - public function completed(): static - { - return $this->state(['ended_at' => Carbon::now()->subMinutes(1)]); - } - - public function definition(): array - { - return [ - 'playlist_item_id' => PlaylistItem::factory(), - 'beatmap_id' => fn(array $attributes) => PlaylistItem::find($attributes['playlist_item_id'])->beatmap_id, - 'room_id' => fn(array $attributes) => PlaylistItem::find($attributes['playlist_item_id'])->room_id, - 'user_id' => User::factory(), - 'total_score' => 1, - 'started_at' => fn() => Carbon::now()->subMinutes(5), - 'accuracy' => 0.5, - 'pp' => 1, - ]; - } - - public function failed(): static - { - return $this->completed()->state(['passed' => false]); - } - - public function passed(): static - { - return $this->completed()->state(['passed' => true]); - } - - public function scoreless(): static - { - return $this->state(['total_score' => 0]); - } -} diff --git a/database/factories/Multiplayer/ScoreLinkFactory.php b/database/factories/Multiplayer/ScoreLinkFactory.php new file mode 100644 index 00000000000..947d5679632 --- /dev/null +++ b/database/factories/Multiplayer/ScoreLinkFactory.php @@ -0,0 +1,52 @@ +. 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); + +namespace Database\Factories\Multiplayer; + +use App\Models\Multiplayer\PlaylistItem; +use App\Models\Multiplayer\ScoreLink; +use App\Models\Solo\Score; +use App\Models\User; +use Database\Factories\Factory; + +class ScoreLinkFactory extends Factory +{ + protected $model = ScoreLink::class; + + public function completed(?array $scoreAttr = [], ?array $scoreDataAttr = []): static + { + return $this->state([ + 'score_id' => fn (array $attr) => Score::factory([ + 'beatmap_id' => $attr['beatmap_id'], + 'user_id' => $attr['user_id'], + ...$scoreAttr, + ])->withData($scoreDataAttr), + ]); + } + + public function definition(): array + { + return [ + 'playlist_item_id' => PlaylistItem::factory(), + 'user_id' => User::factory(), + + // depends on PlaylistItem + 'beatmap_id' => fn (array $attr) => PlaylistItem::find($attr['playlist_item_id'])->beatmap_id, + 'room_id' => fn (array $attr) => PlaylistItem::find($attr['playlist_item_id'])->room_id, + ]; + } + + public function failed(): static + { + return $this->completed([], ['passed' => false]); + } + + public function passed(): static + { + return $this->completed([], ['passed' => true]); + } +} diff --git a/database/migrations/2023_07_26_103744_create_multiplayer_score_links.php b/database/migrations/2023_07_26_103744_create_multiplayer_score_links.php new file mode 100644 index 00000000000..56ef5d490cb --- /dev/null +++ b/database/migrations/2023_07_26_103744_create_multiplayer_score_links.php @@ -0,0 +1,45 @@ +. 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\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('multiplayer_score_links', function (Blueprint $table) { + $table->id(); + + $table->unsignedInteger('user_id'); + $table->unsignedBigInteger('room_id'); + $table->unsignedBigInteger('playlist_item_id'); + $table->unsignedMediumInteger('beatmap_id'); + $table->unsignedMediumInteger('build_id')->default(0); + $table->unsignedBigInteger('score_id')->nullable(); + + $table->timestampsTz(); + + $table->index('score_id'); + $table->index(['room_id', 'user_id']); + $table->index('playlist_item_id'); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('multiplayer_score_links'); + } +}; diff --git a/database/migrations/2023_08_01_064505_add_score_link_id_to_multiplayer_scores_high.php b/database/migrations/2023_08_01_064505_add_score_link_id_to_multiplayer_scores_high.php new file mode 100644 index 00000000000..7718661bd61 --- /dev/null +++ b/database/migrations/2023_08_01_064505_add_score_link_id_to_multiplayer_scores_high.php @@ -0,0 +1,35 @@ +. 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\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('multiplayer_scores_high', function (Blueprint $table) { + $table->unsignedBigInteger('score_link_id')->nullable()->after('score_id'); + $table->index(['playlist_item_id', DB::raw('total_score DESC'), 'score_link_id'], 'top_scores_linked'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('multiplayer_scores_high', function (Blueprint $table) { + $table->dropIndex('top_scores_linked'); + $table->dropColumn('score_link_id'); + }); + } +}; diff --git a/database/migrations/2023_08_01_101614_add_last_score_link_id_to_multiplayer_rooms_high.php b/database/migrations/2023_08_01_101614_add_last_score_link_id_to_multiplayer_rooms_high.php new file mode 100644 index 00000000000..55819991c91 --- /dev/null +++ b/database/migrations/2023_08_01_101614_add_last_score_link_id_to_multiplayer_rooms_high.php @@ -0,0 +1,33 @@ +. 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\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('multiplayer_rooms_high', function (Blueprint $table) { + $table->unsignedBigInteger('last_score_link_id')->nullable()->after('last_score_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('multiplayer_rooms_high', function (Blueprint $table) { + $table->dropColumn('last_score_link_id'); + }); + } +}; diff --git a/database/seeders/ModelSeeders/MultiplayerSeeder.php b/database/seeders/ModelSeeders/MultiplayerSeeder.php index 89562aea728..43472f1f026 100644 --- a/database/seeders/ModelSeeders/MultiplayerSeeder.php +++ b/database/seeders/ModelSeeders/MultiplayerSeeder.php @@ -10,7 +10,7 @@ use App\Models\Beatmap; use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\Room; -use App\Models\Multiplayer\Score; +use App\Models\Multiplayer\ScoreLink; use App\Models\User; use Carbon\Carbon; use Illuminate\Database\Seeder; @@ -39,18 +39,22 @@ public function run() $attempts = rand(1, 10); for ($i = 0; $i < $attempts; $i++) { $completed = rand(0, 100) > 20; - Score::factory()->create([ + $scoreLink = ScoreLink::factory()->make([ 'playlist_item_id' => $playlistItem->getKey(), 'user_id' => $user->getKey(), 'beatmap_id' => $beatmap->getKey(), 'room_id' => $room->getKey(), - 'total_score' => rand(10000, 100000), - 'started_at' => Carbon::now()->subMinutes(5), - 'ended_at' => $completed ? Carbon::now() : null, - 'passed' => $completed ? rand(0, 100) > 20 : null, - 'accuracy' => rand(50, 100) / 100, - 'pp' => rand(100, 200), ]); + if ($completed) { + $scoreLink = $scoreLink->completed([ + 'pp' => rand(100, 200), + ], [ + 'total_score' => rand(10000, 100000), + 'started_at' => Carbon::now()->subMinutes(5), + 'passed' => rand(0, 100) > 20, + 'accuracy' => rand(50, 100) / 100, + ]); + } } } } diff --git a/tests/Controllers/Chat/ChannelsControllerTest.php b/tests/Controllers/Chat/ChannelsControllerTest.php index 519f0fd57af..603879f66db 100644 --- a/tests/Controllers/Chat/ChannelsControllerTest.php +++ b/tests/Controllers/Chat/ChannelsControllerTest.php @@ -10,7 +10,7 @@ use App\Libraries\UserChannelList; use App\Models\Chat\Channel; use App\Models\Chat\Message; -use App\Models\Multiplayer\Score; +use App\Models\Multiplayer\ScoreLink; use App\Models\User; use Illuminate\Testing\AssertableJsonString; use Illuminate\Testing\Fluent\AssertableJson; @@ -181,11 +181,11 @@ public function testChannelJoinPM() // fail public function testChannelJoinMultiplayerWhenNotParticipated() { - $score = Score::factory()->create(); + $scoreLink = ScoreLink::factory()->create(); $this->actAsScopedUser($this->user, ['*']); $request = $this->json('PUT', route('api.chat.channels.join', [ - 'channel' => $score->room->channel_id, + 'channel' => $scoreLink->room->channel_id, 'user' => $this->user->getKey(), ])); @@ -194,15 +194,15 @@ public function testChannelJoinMultiplayerWhenNotParticipated() public function testChannelJoinMultiplayerWhenParticipated() { - $score = Score::factory()->create(['user_id' => $this->user]); + $scoreLink = ScoreLink::factory()->create(['user_id' => $this->user]); $this->actAsScopedUser($this->user, ['*']); $request = $this->json('PUT', route('api.chat.channels.join', [ - 'channel' => $score->room->channel_id, + 'channel' => $scoreLink->room->channel_id, 'user' => $this->user->getKey(), ])); - $request->assertStatus(200)->assertJsonFragment(['channel_id' => $score->room->channel_id, 'type' => Channel::TYPES['multiplayer']]); + $request->assertStatus(200)->assertJsonFragment(['channel_id' => $scoreLink->room->channel_id, 'type' => Channel::TYPES['multiplayer']]); } //endregion diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index dc4e6ceec0f..ce254f7eb6d 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -7,7 +7,7 @@ use App\Models\Build; use App\Models\Multiplayer\PlaylistItem; -use App\Models\Multiplayer\Score; +use App\Models\Multiplayer\ScoreLink; use App\Models\User; use Tests\TestCase; @@ -15,15 +15,15 @@ class ScoresControllerTest extends TestCase { public function testShow() { - $score = Score::factory()->create(); + $scoreLink = ScoreLink::factory()->create(); $user = User::factory()->create(); $this->actAsScopedUser($user, ['*']); $this->json('GET', route('api.rooms.playlist.scores.show', [ - 'room' => $score->room_id, - 'playlist' => $score->playlist_item_id, - 'score' => $score->getKey(), + 'room' => $scoreLink->room_id, + 'playlist' => $scoreLink->playlist_item_id, + 'score' => $scoreLink->getKey(), ]))->assertSuccessful(); } @@ -35,7 +35,6 @@ public function testStore($allowRanking, $hashParam, $status) $user = User::factory()->create(); $playlistItem = PlaylistItem::factory()->create(); $build = Build::factory()->create(['allow_ranking' => $allowRanking]); - $initialScoresCount = Score::count(); $this->actAsScopedUser($user, ['*']); @@ -44,14 +43,13 @@ public function testStore($allowRanking, $hashParam, $status) $params['version_hash'] = $hashParam ? bin2hex($build->hash) : md5('invalid_'); } + $countDiff = ((string) $status)[0] === '2' ? 1 : 0; + $this->expectCountChange(fn () => ScoreLink::count(), $countDiff); + $this->json('POST', route('api.rooms.playlist.scores.store', [ 'room' => $playlistItem->room_id, 'playlist' => $playlistItem->getKey(), ]), $params)->assertStatus($status); - - $countDiff = ((string) $status)[0] === '2' ? 1 : 0; - - $this->assertSame($initialScoresCount + $countDiff, Score::count()); } /** @@ -63,14 +61,14 @@ public function testUpdate($bodyParams, $status) $playlistItem = PlaylistItem::factory()->create(); $room = $playlistItem->room; $build = Build::factory()->create(['allow_ranking' => true]); - $score = $room->startPlay($user, $playlistItem); + $scoreLink = $room->startPlay($user, $playlistItem, 0); $this->actAsScopedUser($user, ['*']); $url = route('api.rooms.playlist.scores.update', [ 'room' => $room, 'playlist' => $playlistItem, - 'score' => $score, + 'score' => $scoreLink, ]); $this->json('PUT', $url, $bodyParams)->assertStatus($status); diff --git a/tests/Models/ContestTest.php b/tests/Models/ContestTest.php index 63626390278..7203182c993 100644 --- a/tests/Models/ContestTest.php +++ b/tests/Models/ContestTest.php @@ -14,8 +14,10 @@ use App\Models\ContestEntry; use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\Room; -use App\Models\Multiplayer\Score as MultiplayerScore; +use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; +use App\Models\Multiplayer\UserScoreAggregate; use App\Models\User; +use Carbon\Carbon; use Tests\TestCase; class ContestTest extends TestCase @@ -56,28 +58,37 @@ public function testAssertVoteRequirementPlaylistBeatmapsets(bool $loggedIn, boo ]); $entries = ContestEntry::factory()->count(2)->create(['contest_id' => $contest->getKey()]); - if (!$canVote) { - $this->expectException(InvariantException::class); - } - $user = $loggedIn ? User::factory()->create() : null; if ($loggedIn && $played) { $userId = $user->getKey(); - $endedAt = now(); + $endedAt = json_time(Carbon::now()); foreach ($beatmapsets as $beatmapset) { $room = array_rand_val($rooms); $playlistItem = $room ->playlist() ->whereIn('beatmap_id', array_column($beatmapset->beatmaps->all(), 'beatmap_id')) ->first(); - MultiplayerScore::factory()->create([ - 'ended_at' => $completed ? $endedAt : null, - 'passed' => $passed, + + $scoreLink = MultiplayerScoreLink::factory()->state([ 'playlist_item_id' => $playlistItem, 'user_id' => $userId, ]); + if ($completed) { + $scoreLink = $scoreLink->completed([], [ + 'ended_at' => $endedAt, + 'passed' => $passed, + ]); + } + $scoreLink->create(); } + foreach ($rooms as $room) { + UserScoreAggregate::lookupOrDefault($user, $room)->recalculate(); + } + } + + if (!$canVote) { + $this->expectException(InvariantException::class); } $contest->assertVoteRequirement($user); diff --git a/tests/Models/Multiplayer/RoomTest.php b/tests/Models/Multiplayer/RoomTest.php index e10a870fe00..29ea49cd44d 100644 --- a/tests/Models/Multiplayer/RoomTest.php +++ b/tests/Models/Multiplayer/RoomTest.php @@ -100,7 +100,7 @@ public function testRoomHasEnded() ]); $this->expectException(InvariantException::class); - $room->startPlay($user, $playlistItem); + $room->startPlay($user, $playlistItem, 0); } public function testStartPlay(): void @@ -111,12 +111,12 @@ public function testStartPlay(): void $this->expectCountChange(fn () => $room->participant_count, 1); $this->expectCountChange(fn () => $room->userHighScores()->count(), 1); - $this->expectCountChange(fn () => $room->scores()->count(), 1); + $this->expectCountChange(fn () => $room->scoreLinks()->count(), 1); - $room->startPlay($user, $playlistItem); + $room->startPlay($user, $playlistItem, 0); $room->refresh(); - $this->assertSame($user->getKey(), $room->scores()->last()->user_id); + $this->assertSame($user->getKey(), $room->scoreLinks()->last()->user_id); } public function testMaxAttemptsReached() @@ -126,14 +126,14 @@ public function testMaxAttemptsReached() $playlistItem1 = PlaylistItem::factory()->create(['room_id' => $room]); $playlistItem2 = PlaylistItem::factory()->create(['room_id' => $room]); - $room->startPlay($user, $playlistItem1); + $room->startPlay($user, $playlistItem1, 0); $this->assertTrue(true); - $room->startPlay($user, $playlistItem2); + $room->startPlay($user, $playlistItem2, 0); $this->assertTrue(true); $this->expectException(InvariantException::class); - $room->startPlay($user, $playlistItem1); + $room->startPlay($user, $playlistItem1, 0); } public function testMaxAttemptsForItemReached() @@ -149,21 +149,21 @@ public function testMaxAttemptsForItemReached() 'max_attempts' => 1, ]); - $initialCount = $room->scores()->count(); - $room->startPlay($user, $playlistItem1); - $this->assertSame($initialCount + 1, $room->scores()->count()); + $initialCount = $room->scoreLinks()->count(); + $room->startPlay($user, $playlistItem1, 0); + $this->assertSame($initialCount + 1, $room->scoreLinks()->count()); - $initialCount = $room->scores()->count(); + $initialCount = $room->scoreLinks()->count(); try { - $room->startPlay($user, $playlistItem1); + $room->startPlay($user, $playlistItem1, 0); } catch (Exception $ex) { $this->assertTrue($ex instanceof InvariantException); } - $this->assertSame($initialCount, $room->scores()->count()); + $this->assertSame($initialCount, $room->scoreLinks()->count()); - $initialCount = $room->scores()->count(); - $room->startPlay($user, $playlistItem2); - $this->assertSame($initialCount + 1, $room->scores()->count()); + $initialCount = $room->scoreLinks()->count(); + $room->startPlay($user, $playlistItem2, 0); + $this->assertSame($initialCount + 1, $room->scoreLinks()->count()); } public function testCannotStartPlayedItem() diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 0992b270597..21a8e3ef04b 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -7,7 +7,7 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\Room; -use App\Models\Multiplayer\Score; +use App\Models\Multiplayer\ScoreLink; use App\Models\Multiplayer\UserScoreAggregate; use App\Models\User; use Tests\TestCase; @@ -21,7 +21,7 @@ public function testStartingPlayIncreasesAttempts() $user = User::factory()->create(); $playlistItem = $this->playlistItem(); - $this->room->startPlay($user, $playlistItem); + $this->room->startPlay($user, $playlistItem, 0); $agg = UserScoreAggregate::new($user, $this->room); $this->assertSame(1, $agg->attempts); @@ -34,14 +34,14 @@ public function testInCompleteScoresAreNotCounted() $playlistItem = $this->playlistItem(); $agg = UserScoreAggregate::new($user, $this->room); - $score = Score::factory() - ->create([ + $scoreLink = ScoreLink::factory() + ->state([ 'room_id' => $this->room, 'playlist_item_id' => $playlistItem, 'user_id' => $user, - ]); + ])->create(); - $agg->addScore($score); + $agg->addScoreLink($scoreLink); $result = json_item($agg, 'Multiplayer\UserScoreAggregate'); $this->assertSame(0, $result['completed']); @@ -54,24 +54,25 @@ public function testFailedScoresAreAttemptsOnly() $playlistItem = $this->playlistItem(); $agg = UserScoreAggregate::new($user, $this->room); - $agg->addScore( - Score::factory() - ->failed() - ->create([ + $agg->addScoreLink( + ScoreLink + ::factory() + ->state([ 'room_id' => $this->room, 'playlist_item_id' => $playlistItem, 'user_id' => $user, - ]) + ])->failed() + ->create() ); - $agg->addScore( - Score::factory() - ->passed() - ->create([ + $agg->addScoreLink( + ScoreLink::factory() + ->state([ 'room_id' => $this->room, 'playlist_item_id' => $playlistItem, 'user_id' => $user, - ]) + ])->completed([], ['passed' => true, 'total_score' => 1]) + ->create() ); $result = json_item($agg, 'Multiplayer\UserScoreAggregate'); @@ -86,14 +87,14 @@ public function testPassedScoresIncrementsCompletedCount() $playlistItem = $this->playlistItem(); $agg = UserScoreAggregate::new($user, $this->room); - $agg->addScore( - Score::factory() - ->passed() - ->create([ - 'room_id' => $this->room, - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - ]) + $agg->addScoreLink( + ScoreLink::factory() + ->state([ + 'room_id' => $this->room, + 'playlist_item_id' => $playlistItem, + 'user_id' => $user, + ])->completed([], ['passed' => true, 'total_score' => 1]) + ->create() ); $result = json_item($agg, 'Multiplayer\UserScoreAggregate'); @@ -109,56 +110,60 @@ public function testPassedScoresAreAveraged() $playlistItem2 = $this->playlistItem(); $agg = UserScoreAggregate::new($user, $this->room); - $agg->addScore( - Score::factory() - ->create([ - 'room_id' => $this->room, - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - 'total_score' => 1, - 'pp' => 0.2, - 'pp' => 0.2, - ]) - ); - - $agg->addScore( - Score::factory() - ->failed() - ->create([ - 'room_id' => $this->room, - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - 'total_score' => 1, - 'accuracy' => 0.3, - 'pp' => 0.3, - ]) - ); - - $agg->addScore( - Score::factory() - ->passed() - ->create([ - 'room_id' => $this->room, - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - 'total_score' => 1, - 'accuracy' => 0.5, - 'pp' => 0.5, - ]) - ); - - $agg->addScore( - Score::factory() - ->passed() - ->create([ - 'room_id' => $this->room, - 'playlist_item_id' => $playlistItem2, - 'user_id' => $user, - 'total_score' => 1, - 'accuracy' => 0.8, - 'pp' => 0.8, - ]) - ); + $agg->addScoreLink(tap( + ScoreLink::factory() + ->state([ + 'room_id' => $this->room, + 'playlist_item_id' => $playlistItem, + 'user_id' => $user, + ])->completed([], [ + 'total_score' => 1, + 'passed' => false, + ])->create(), + fn ($l) => $l->score->performance()->create(['pp' => 0.2]), + )); + + $agg->addScoreLink(tap( + ScoreLink::factory() + ->state([ + 'room_id' => $this->room, + 'playlist_item_id' => $playlistItem, + 'user_id' => $user, + ])->completed([], [ + 'total_score' => 1, + 'accuracy' => 0.3, + 'passed' => false, + ])->create(), + fn ($l) => $l->score->performance()->create(['pp' => 0.3]), + )); + + $agg->addScoreLink(tap( + ScoreLink::factory() + ->state([ + 'room_id' => $this->room, + 'playlist_item_id' => $playlistItem, + 'user_id' => $user, + ])->completed([], [ + 'total_score' => 1, + 'accuracy' => 0.5, + 'passed' => true, + ])->create(), + fn ($l) => $l->score->performance()->create(['pp' => 0.5]), + )); + + $agg->addScoreLink(tap( + ScoreLink::factory() + ->state([ + 'room_id' => $this->room, + 'playlist_item_id' => $playlistItem2, + 'user_id' => $user, + ])->completed([], [ + 'total_score' => 1, + 'accuracy' => 0.8, + 'passed' => true, + ])->create(), + fn ($l) => $l->score->performance()->create(['pp' => 0.8]), + )); $result = json_item($agg, 'Multiplayer\UserScoreAggregate');