the browser-facing portion of osu!
at master 6.6 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Models\Multiplayer; 7 8use App\Models\Model; 9use App\Models\Traits\WithDbCursorHelper; 10use App\Models\User; 11 12/** 13 * Aggregate root for user multiplayer high scores. 14 * Updates should be done via this root and not directly against the models. 15 * 16 * @property float $accuracy 17 * @property int $attempts 18 * @property int $completed 19 * @property \Carbon\Carbon $created_at 20 * @property int $id 21 * @property int|null $last_score_id 22 * @property bool $in_room 23 * @property int $room_id 24 * @property int $total_score 25 * @property \Carbon\Carbon $updated_at 26 * @property int $user_id 27 */ 28class UserScoreAggregate extends Model 29{ 30 use WithDbCursorHelper; 31 32 const SORTS = [ 33 'score_asc' => [ 34 ['column' => 'total_score', 'order' => 'ASC'], 35 ['column' => 'last_score_id', 'order' => 'DESC'], 36 ], 37 ]; 38 39 const DEFAULT_SORT = 'score_asc'; 40 41 protected $casts = [ 42 'in_room' => 'boolean', 43 ]; 44 protected $table = 'multiplayer_rooms_high'; 45 46 public static function lookupOrDefault(User $user, Room $room): static 47 { 48 return static::firstOrNew([ 49 'room_id' => $room->getKey(), 50 'user_id' => $user->getKey(), 51 ], [ 52 'accuracy' => 0, 53 'attempts' => 0, 54 'completed' => 0, 55 'total_score' => 0, 56 ]); 57 } 58 59 public static function new(User $user, Room $room): self 60 { 61 $obj = static::lookupOrDefault($user, $room); 62 63 if (!$obj->exists) { 64 $obj->save(); // force a save now to avoid being trolled later. 65 $obj->recalculate(); 66 } 67 68 return $obj; 69 } 70 71 public function addScoreLink(ScoreLink $scoreLink, ?PlaylistItemUserHighScore $highestScore = null) 72 { 73 return $this->getConnection()->transaction(function () use ($scoreLink) { 74 $isNewHighScore = PlaylistItemUserHighScore::lookupOrDefault( 75 $scoreLink->user_id, 76 $scoreLink->playlist_item_id, 77 )->updateWithScoreLink($scoreLink); 78 79 if ($isNewHighScore) { 80 $this->refreshStatistics(); 81 } 82 83 return true; 84 }); 85 } 86 87 public function averageAccuracy() 88 { 89 return $this->completed > 0 ? $this->accuracy / $this->completed : 0; 90 } 91 92 public function playlistItemAttempts(): array 93 { 94 $playlistItemAggs = PlaylistItemUserHighScore 95 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 96 ->where('user_id', $this->user_id) 97 ->get(); 98 99 $ret = []; 100 foreach ($playlistItemAggs as $agg) { 101 $ret[] = [ 102 'attempts' => $agg->attempts, 103 'id' => $agg->playlist_item_id, 104 ]; 105 } 106 107 return $ret; 108 } 109 110 public function recalculate() 111 { 112 $this->getConnection()->transaction(function () { 113 $this->removeRunningTotals(); 114 $playlistItemAggs = PlaylistItemUserHighScore 115 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 116 ->where('user_id', $this->user_id) 117 ->get() 118 ->keyBy('playlist_item_id'); 119 $this->attempts = $playlistItemAggs->reduce(fn ($acc, $agg) => $acc + $agg->attempts, 0); 120 121 $scoreLinks = ScoreLink 122 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 123 ->where('user_id', $this->user_id) 124 ->with('score') 125 ->get(); 126 foreach ($scoreLinks as $scoreLink) { 127 ($playlistItemAggs[$scoreLink->playlist_item_id] 128 ?? PlaylistItemUserHighScore::lookupOrDefault( 129 $scoreLink->user_id, 130 $scoreLink->playlist_item_id, 131 ) 132 )->updateWithScoreLink($scoreLink); 133 } 134 $this->refreshStatistics(); 135 $this->save(); 136 }); 137 } 138 139 public function removeRunningTotals() 140 { 141 PlaylistItemUserHighScore 142 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 143 ->where('user_id', $this->user_id) 144 ->update([ 145 'accuracy' => 0, 146 'score_id' => null, 147 'total_score' => 0, 148 ]); 149 150 static $resetAttributes = [ 151 'accuracy', 152 'attempts', 153 'completed', 154 'last_score_id', 155 'total_score', 156 ]; 157 158 foreach ($resetAttributes as $key) { 159 // init if required 160 $this->$key = 0; 161 } 162 } 163 164 public function room() 165 { 166 return $this->belongsTo(Room::class); 167 } 168 169 public function scopeForRanking($query) 170 { 171 return $query 172 ->where('completed', '>', 0) 173 ->whereHas('user', function ($userQuery) { 174 $userQuery->default(); 175 }) 176 ->orderBy('total_score', 'DESC') 177 ->orderBy('last_score_id', 'ASC'); 178 } 179 180 public function updateUserAttempts() 181 { 182 $this->incrementInstance('attempts'); 183 } 184 185 public function user() 186 { 187 return $this->belongsTo(User::class, 'user_id'); 188 } 189 190 public function userRank() 191 { 192 if ($this->total_score === null || $this->last_score_id === null) { 193 return; 194 } 195 196 $query = static::where('room_id', $this->room_id)->forRanking() 197 ->cursorSort('score_asc', $this); 198 199 return 1 + $query->count(); 200 } 201 202 private function refreshStatistics(): void 203 { 204 $agg = PlaylistItemUserHighScore 205 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 206 ->whereNotNull('score_id') 207 ->selectRaw(' 208 SUM(accuracy) AS accuracy_sum, 209 SUM(total_score) AS total_score_sum, 210 COUNT(*) AS completed, 211 MAX(score_id) AS last_score_id 212 ')->firstWhere('user_id', $this->user_id); 213 214 $this->fill([ 215 'accuracy' => $agg->getRawAttribute('accuracy_sum') ?? 0, 216 'completed' => $agg->getRawAttribute('completed') ?? 0, 217 'last_score_id' => $agg->getRawAttribute('last_score_id'), 218 'total_score' => $agg->getRawAttribute('total_score_sum') ?? 0, 219 ])->save(); 220 } 221}