the browser-facing portion of osu!
at master 15 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; 7 8use App\Exceptions\InvariantException; 9use App\Traits\Memoizes; 10use App\Transformers\ContestEntryTransformer; 11use App\Transformers\ContestTransformer; 12use App\Transformers\UserContestEntryTransformer; 13use Cache; 14use Exception; 15use Illuminate\Database\Eloquent\Collection; 16use Illuminate\Database\Eloquent\Relations\BelongsToMany; 17use Illuminate\Database\Eloquent\Relations\HasMany; 18 19/** 20 * @property \Carbon\Carbon|null $created_at 21 * @property string $description_enter 22 * @property string|null $description_voting 23 * @property-read Collection<ContestEntry> $entries 24 * @property \Carbon\Carbon|null $entry_ends_at 25 * @property mixed $thumbnail_shape 26 * @property \Carbon\Carbon|null $entry_starts_at 27 * @property json|null $extra_options 28 * @property string $header_url 29 * @property int $id 30 * @property mixed $link_icon 31 * @property-read Collection<ContestJudge> $judges 32 * @property int $max_entries 33 * @property int $max_votes 34 * @property string $name 35 * @property bool $show_votes 36 * @property mixed $type 37 * @property mixed $unmasked 38 * @property-read Collection<ContestScoringCategory> $scoringCategories 39 * @property bool $show_names 40 * @property \Carbon\Carbon|null $updated_at 41 * @property bool $visible 42 * @property-read Collection<ContestVote> $votes 43 * @property \Carbon\Carbon|null $voting_ends_at 44 * @property \Carbon\Carbon|null $voting_starts_at 45 */ 46class Contest extends Model 47{ 48 use Memoizes; 49 50 protected $casts = [ 51 'entry_ends_at' => 'datetime', 52 'entry_starts_at' => 'datetime', 53 'extra_options' => 'array', 54 'show_votes' => 'boolean', 55 'visible' => 'boolean', 56 'voting_ends_at' => 'datetime', 57 'voting_starts_at' => 'datetime', 58 ]; 59 60 public function entries() 61 { 62 return $this->hasMany(ContestEntry::class); 63 } 64 65 public function judges(): BelongsToMany 66 { 67 return $this->belongsToMany(User::class, ContestJudge::class); 68 } 69 70 public function userContestEntries() 71 { 72 return $this->hasMany(UserContestEntry::class); 73 } 74 75 public function votes() 76 { 77 return $this->hasMany(ContestVote::class); 78 } 79 80 public function assertVoteRequirement(?User $user): void 81 { 82 $requirement = $this->getExtraOptions()['requirement'] ?? null; 83 84 if ($requirement === null) { 85 return; 86 } 87 88 if ($user === null) { 89 throw new InvariantException(osu_trans('authorization.require_login')); 90 } 91 92 switch ($requirement['name']) { 93 // requires playing (and optionally passing) all the beatmapsets in the specified room ids 94 case 'playlist_beatmapsets': 95 $roomIds = $requirement['room_ids']; 96 $mustPass = $requirement['must_pass'] ?? true; 97 $beatmapIdsQuery = Multiplayer\PlaylistItem::whereIn('room_id', $roomIds)->select('beatmap_id'); 98 $requiredBeatmapsetCount = Beatmap::whereIn('beatmap_id', $beatmapIdsQuery)->distinct('beatmapset_id')->count(); 99 $playedScoreIdsQuery = Multiplayer\ScoreLink 100 ::whereHas('playlistItem', fn ($q) => $q->whereIn('room_id', $roomIds)) 101 ->where(['user_id' => $user->getKey()]) 102 ->select('score_id'); 103 if ($mustPass) { 104 $playedScoreIdsQuery->whereHas('playlistItemUserHighScore'); 105 } 106 $playedBeatmapIdsQuery = Solo\Score::whereIn('id', $playedScoreIdsQuery)->select('beatmap_id'); 107 $playedBeatmapsetCount = Beatmap::whereIn('beatmap_id', $playedBeatmapIdsQuery)->distinct('beatmapset_id')->count(); 108 109 if ($playedBeatmapsetCount !== $requiredBeatmapsetCount) { 110 throw new InvariantException(osu_trans('contest.voting.requirement.playlist_beatmapsets.incomplete_play')); 111 } 112 break; 113 default: 114 throw new Exception('unknown requirement'); 115 } 116 } 117 118 public function isBestOf(): bool 119 { 120 return isset($this->getExtraOptions()['best_of']); 121 } 122 123 public function isJudge(User $user): bool 124 { 125 $judges = $this->judges(); 126 127 return $judges->where($judges->qualifyColumn('user_id'), $user->getKey())->exists(); 128 } 129 130 public function isJudged(): bool 131 { 132 return $this->getExtraOptions()['judged'] ?? false; 133 } 134 135 public function isJudgingActive(): bool 136 { 137 return $this->isJudged() && $this->isVotingStarted() && !$this->show_votes; 138 } 139 140 public function isSubmittedBeatmaps(): bool 141 { 142 return $this->isBestOf() || ($this->getExtraOptions()['submitted_beatmaps'] ?? false); 143 } 144 145 public function isSubmissionOpen() 146 { 147 return $this->entry_starts_at !== null && $this->entry_starts_at->isPast() && 148 $this->entry_ends_at !== null && $this->entry_ends_at->isFuture(); 149 } 150 151 public function isVotingOpen() 152 { 153 return $this->isVotingStarted() && 154 $this->voting_ends_at !== null && $this->voting_ends_at->isFuture(); 155 } 156 157 public function isVotingStarted() 158 { 159 //the react page handles both voting and results display. 160 return $this->voting_starts_at !== null && $this->voting_starts_at->isPast(); 161 } 162 163 public function scoringCategories(): HasMany 164 { 165 return $this->hasMany(ContestScoringCategory::class); 166 } 167 168 public function state() 169 { 170 if ($this->entry_starts_at === null || $this->entry_starts_at->isFuture()) { 171 return 'preparing'; 172 } 173 174 if ($this->isSubmissionOpen()) { 175 return 'entry'; 176 } 177 178 if ($this->isVotingOpen()) { 179 return 'voting'; 180 } 181 182 if ($this->show_votes) { 183 return 'results'; 184 } 185 186 return 'over'; 187 } 188 189 public function hasThumbnails(): bool 190 { 191 return $this->type === 'art' || 192 ($this->type === 'external' && isset($this->getExtraOptions()['thumbnail_shape'])); 193 } 194 195 public function getThumbnailShapeAttribute(): ?string 196 { 197 if (!$this->hasThumbnails()) { 198 return null; 199 } 200 201 return $this->getExtraOptions()['thumbnail_shape'] ?? 'square'; 202 } 203 204 public function getUnmaskedAttribute() 205 { 206 return $this->getExtraOptions()['unmasked'] ?? false; 207 } 208 209 public function getShowNamesAttribute() 210 { 211 return $this->getExtraOptions()['show_names'] ?? false; 212 } 213 214 public function getLinkIconAttribute() 215 { 216 return $this->getExtraOptions()['link_icon'] ?? 'download'; 217 } 218 219 public function currentPhaseEndDate() 220 { 221 switch ($this->state()) { 222 case 'entry': 223 return $this->entry_ends_at; 224 case 'voting': 225 return $this->voting_ends_at; 226 } 227 } 228 229 public function currentPhaseDateRange() 230 { 231 switch ($this->state()) { 232 case 'preparing': 233 $date = $this->entry_starts_at === null 234 ? osu_trans('contest.dates.starts.soon') 235 : i18n_date($this->entry_starts_at); 236 237 return osu_trans('contest.dates.starts._', ['date' => $date]); 238 case 'entry': 239 return i18n_date($this->entry_starts_at).' - '.i18n_date($this->entry_ends_at); 240 case 'voting': 241 return i18n_date($this->voting_starts_at).' - '.i18n_date($this->voting_ends_at); 242 default: 243 if ($this->voting_ends_at === null) { 244 return osu_trans('contest.dates.ended_no_date'); 245 } else { 246 return osu_trans('contest.dates.ended', ['date' => i18n_date($this->voting_ends_at)]); 247 } 248 } 249 } 250 251 public function currentDescription() 252 { 253 if ($this->isVotingStarted()) { 254 return $this->description_voting; 255 } else { 256 return $this->description_enter; 257 } 258 } 259 260 public function vote(User $user, ContestEntry $entry) 261 { 262 $vote = $this->votes()->where('user_id', $user->user_id)->where('contest_entry_id', $entry->id); 263 if ($vote->exists()) { 264 $vote->delete(); 265 } else { 266 $this->assertVoteRequirement($user); 267 // there's probably a race-condition here, but abusing this just results in the user diluting their vote... so *shrug* 268 if ($this->votes()->where('user_id', $user->user_id)->count() < $this->max_votes) { 269 $this->votes()->create(['user_id' => $user->user_id, 'contest_entry_id' => $entry->id]); 270 } 271 } 272 } 273 274 public function entriesByType($user = null, array $preloads = []) 275 { 276 $entries = $this->entries()->with(['contest', ...$preloads]); 277 278 if ($this->show_votes) { 279 return Cache::remember("contest_entries_with_votes_{$this->id}", 300, function () use ($entries) { 280 $orderValue = 'votes_count'; 281 282 if ($this->isBestOf()) { 283 $entries = $entries 284 ->selectRaw('*') 285 ->selectRaw('(SELECT FLOOR(SUM(`weight`)) FROM `contest_votes` WHERE `contest_entries`.`id` = `contest_votes`.`contest_entry_id`) AS votes_count') 286 ->limit(50); // best of contests tend to have a _lot_ of entries... 287 } else if ($this->isJudged()) { 288 $entries = $entries->withSum('scores', 'value'); 289 $orderValue = 'scores_sum_value'; 290 } else { 291 $entries = $entries->withCount('votes'); 292 } 293 294 return $entries->orderBy($orderValue, 'desc')->get(); 295 }); 296 } else { 297 if ($this->isBestOf()) { 298 if ($user === null) { 299 return []; 300 } 301 302 // Only return contest entries that a user has actually played 303 return $entries 304 ->whereIn('entry_url', function ($query) use ($user) { 305 $options = $this->getExtraOptions()['best_of']; 306 $ruleset = $options['mode'] ?? 'osu'; 307 $query->select('beatmapset_id') 308 ->from('osu_beatmaps') 309 ->where('osu_beatmaps.playmode', Beatmap::MODES[$ruleset]) 310 ->whereIn('beatmap_id', function ($query) use ($user) { 311 $query->select('beatmap_id') 312 ->from('osu_user_beatmap_playcount') 313 ->where('user_id', '=', $user->user_id); 314 }); 315 316 if ($ruleset === 'mania' && isset($options['variant'])) { 317 if ($options['variant'] === 'nk') { 318 $query->whereNotIn('osu_beatmaps.diff_size', [4, 7]); 319 } else { 320 $keys = match ($options['variant']) { 321 '4k' => 4, 322 '7k' => 7, 323 }; 324 $query->where('osu_beatmaps.diff_size', $keys); 325 } 326 } 327 })->get(); 328 } 329 } 330 331 return $entries->get(); 332 } 333 334 public function defaultJson($user = null) 335 { 336 $includes = []; 337 $preloads = []; 338 339 if ($this->type === 'art') { 340 $includes[] = 'artMeta'; 341 } 342 343 $showVotes = $this->show_votes; 344 if ($showVotes) { 345 $includes[] = 'results'; 346 } 347 if ($this->showEntryUser()) { 348 $includes[] = 'user'; 349 $preloads[] = 'user'; 350 } 351 352 $contestJson = json_item( 353 $this, 354 new ContestTransformer(), 355 $showVotes ? ['users_voted_count'] : null, 356 ); 357 if ($this->isVotingStarted()) { 358 $contestJson['entries'] = json_collection( 359 $this->entriesByType($user, $preloads), 360 new ContestEntryTransformer(), 361 $includes, 362 ); 363 } 364 365 if (!empty($contestJson['entries'])) { 366 if (!$showVotes) { 367 if ($this->unmasked) { 368 // For unmasked contests, we sort alphabetically. 369 usort($contestJson['entries'], function ($a, $b) { 370 return strnatcasecmp($a['title'], $b['title']); 371 }); 372 } else { 373 // We want the results to appear randomized to the user but be 374 // deterministic (i.e. we don't want the rows shuffling each time 375 // the user votes), so we seed based on user_id (when logged in) 376 $seed = $user ? $user->user_id : time(); 377 seeded_shuffle($contestJson['entries'], $seed); 378 } 379 } 380 } 381 382 return json_encode([ 383 'contest' => $contestJson, 384 'userVotes' => ($this->isVotingStarted() ? $this->votesForUser($user) : []), 385 ]); 386 } 387 388 public function votesForUser($user = null) 389 { 390 if ($user === null) { 391 return []; 392 } 393 394 $votes = ContestVote::where('contest_id', $this->id)->where('user_id', $user->user_id)->get(); 395 396 return $votes->map(function ($v) { 397 return $v->contest_entry_id; 398 })->toArray(); 399 } 400 401 public function userEntries($user = null) 402 { 403 if ($user === null) { 404 return []; 405 } 406 407 return json_collection( 408 UserContestEntry::where(['contest_id' => $this->id, 'user_id' => $user->user_id])->get(), 409 new UserContestEntryTransformer() 410 ); 411 } 412 413 public function usersVotedCount(): int 414 { 415 return cache()->remember( 416 static::class.':'.__FUNCTION__.':'.$this->getKey(), 417 300, 418 fn () => $this->votes()->distinct('user_id')->count(), 419 ); 420 } 421 422 public function url() 423 { 424 return route('contests.show', $this->id); 425 } 426 427 public function setExtraOption($key, $value): void 428 { 429 $this->extra_options = array_merge($this->extra_options ?? [], [$key => $value]); 430 $this->resetMemoized(); 431 } 432 433 public function getExtraOptions() 434 { 435 return $this->memoize(__FUNCTION__, function () { 436 return $this->extra_options; 437 }); 438 } 439 440 public function getForcedWidth() 441 { 442 return $this->getExtraOptions()['forced_width'] ?? null; 443 } 444 445 public function getForcedHeight() 446 { 447 return $this->getExtraOptions()['forced_height'] ?? null; 448 } 449 450 public function showEntryUser(): bool 451 { 452 return $this->show_votes || ($this->getExtraOptions()['show_entry_user'] ?? false); 453 } 454}