the browser-facing portion of osu!
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}