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}