the browser-facing portion of osu!
at master 196 lines 6.2 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\Exceptions\InvariantException; 9use App\Models\Beatmap; 10use App\Models\Model; 11use App\Models\ScoreToken; 12use App\Models\User; 13use Illuminate\Database\Eloquent\Relations\HasMany; 14 15/** 16 * @property json|null $allowed_mods 17 * @property Beatmap $beatmap 18 * @property int $beatmap_id 19 * @property \Carbon\Carbon|null $created_at 20 * @property int $id 21 * @property int $owner_id 22 * @property int|null $playlist_order 23 * @property json|null $required_mods 24 * @property bool $freestyle 25 * @property Room $room 26 * @property int $room_id 27 * @property int|null $ruleset_id 28 * @property \Illuminate\Database\Eloquent\Collection $scoreLinks ScoreLink 29 * @property \Carbon\Carbon|null $updated_at 30 * @property bool expired 31 * @property \Carbon\Carbon|null $played_at 32 */ 33class PlaylistItem extends Model 34{ 35 protected $table = 'multiplayer_playlist_items'; 36 protected $casts = [ 37 'allowed_mods' => 'object', 38 'expired' => 'boolean', 39 'freestyle' => 'boolean', 40 'required_mods' => 'object', 41 ]; 42 43 public static function assertBeatmapsExist(array $playlistItems) 44 { 45 $requestedBeatmapIds = array_map(function ($item) { 46 return $item->beatmap_id; 47 }, $playlistItems); 48 49 $beatmapIds = Beatmap::whereIn('beatmap_id', $requestedBeatmapIds)->pluck('beatmap_id')->all(); 50 $missing = array_diff($requestedBeatmapIds, $beatmapIds); 51 52 if ($missing !== []) { 53 $missingText = implode(', ', $missing); 54 throw new InvariantException("beatmaps not found: {$missingText}"); 55 } 56 } 57 58 public static function fromJsonParams(User $owner, $json) 59 { 60 $obj = new self(); 61 foreach (['beatmap_id', 'ruleset_id'] as $field) { 62 $value = get_int($json[$field] ?? null); 63 if ($value === null) { 64 throw new InvariantException("{$field} is required."); 65 } 66 $obj->$field = $value; 67 } 68 69 $obj->freestyle = get_bool($json['freestyle'] ?? false); 70 $obj->max_attempts = get_int($json['max_attempts'] ?? null); 71 72 $modsHelper = app('mods'); 73 $obj->allowed_mods = $modsHelper->parseInputArray( 74 $obj->ruleset_id, 75 $json['allowed_mods'] ?? [], 76 ); 77 78 $obj->required_mods = $modsHelper->parseInputArray( 79 $obj->ruleset_id, 80 $json['required_mods'] ?? [], 81 ); 82 83 $obj->owner_id = $owner->getKey(); 84 85 return $obj; 86 } 87 88 public function room() 89 { 90 return $this->belongsTo(Room::class, 'room_id'); 91 } 92 93 public function beatmap() 94 { 95 return $this->belongsTo(Beatmap::class, 'beatmap_id')->withTrashed(); 96 } 97 98 public function highScores() 99 { 100 return $this->hasMany(PlaylistItemUserHighScore::class); 101 } 102 103 public function scoreLinks() 104 { 105 return $this->hasMany(ScoreLink::class); 106 } 107 108 public function scoreTokens(): HasMany 109 { 110 return $this->hasMany(ScoreToken::class); 111 } 112 113 public function save(array $options = []) 114 { 115 $this->assertValid(); 116 117 return parent::save($options); 118 } 119 120 public function scorePercentile(): array 121 { 122 $key = "playlist_item_score_percentile:v2:{$this->getKey()}"; 123 124 if (!$this->expired && !$this->room->hasEnded()) { 125 $key .= ':ongoing'; 126 } 127 128 return \Cache::remember($key, 600, function (): array { 129 $scores = $this->highScores() 130 ->passing() 131 ->orderBy('total_score', 'DESC') 132 ->pluck('total_score'); 133 $count = count($scores); 134 135 return $count === 0 136 ? [ 137 'top_10p' => 0, 138 'top_50p' => 0, 139 ] : [ 140 'top_10p' => $scores[max(0, (int) ($count * 0.1) - 1)], 141 'top_50p' => $scores[max(0, (int) ($count * 0.5) - 1)], 142 ]; 143 }); 144 } 145 146 public function assertValid() 147 { 148 $this->assertValidMaxAttempts(); 149 $this->assertValidRuleset(); 150 $this->assertValidMods(); 151 } 152 153 private function assertValidMaxAttempts() 154 { 155 if ($this->max_attempts === null) { 156 return; 157 } 158 159 $maxAttemptsLimit = $GLOBALS['cfg']['osu']['multiplayer']['max_attempts_limit']; 160 if ($this->max_attempts < 1 || $this->max_attempts > $maxAttemptsLimit) { 161 throw new InvariantException("field 'max_attempts' must be between 1 and {$maxAttemptsLimit}"); 162 } 163 } 164 165 private function assertValidRuleset() 166 { 167 // osu beatmaps can be played in any mode, but non-osu maps can only be played in their specific modes 168 if (!$this->beatmap->canBeConvertedTo($this->ruleset_id)) { 169 throw new InvariantException("invalid ruleset_id for beatmap {$this->beatmap->beatmap_id}"); 170 } 171 } 172 173 private function assertValidMods() 174 { 175 if ($this->freestyle) { 176 if (count($this->allowed_mods) !== 0 || count($this->required_mods) !== 0) { 177 throw new InvariantException("mod isn't allowed in freestyle"); 178 } 179 return; 180 } 181 182 $allowedModIds = array_column($this->allowed_mods, 'acronym'); 183 $requiredModIds = array_column($this->required_mods, 'acronym'); 184 185 $dupeMods = array_intersect($allowedModIds, $requiredModIds); 186 if (count($dupeMods) > 0) { 187 throw new InvariantException('mod cannot be listed as both allowed and required: '.implode(', ', $dupeMods)); 188 } 189 190 $isRealtimeRoom = $this->room->isRealtime(); 191 $modsHelper = app('mods'); 192 $modsHelper->assertValidForMultiplayer($this->ruleset_id, $allowedModIds, $isRealtimeRoom, false); 193 $modsHelper->assertValidForMultiplayer($this->ruleset_id, $requiredModIds, $isRealtimeRoom, true); 194 $modsHelper->assertValidExclusivity($this->ruleset_id, $requiredModIds, $allowedModIds); 195 } 196}