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
6declare(strict_types=1);
7
8namespace App\Transformers;
9
10use App\Libraries\Search\ScoreSearchParams;
11use App\Models\Beatmap;
12use App\Models\DeletedUser;
13use App\Models\LegacyMatch;
14use App\Models\Multiplayer\PlaylistItemUserHighScore;
15use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink;
16use App\Models\Score\Best\Model as ScoreBest;
17use App\Models\Score\Model as ScoreModel;
18use App\Models\Solo\Score as SoloScore;
19use League\Fractal\Resource\Item;
20
21class ScoreTransformer extends TransformerAbstract
22{
23 const MULTIPLAYER_BASE_INCLUDES = ['user.country', 'user.cover'];
24 // warning: the preload is actually for PlaylistItemUserHighScore, not for Score
25 const MULTIPLAYER_BASE_PRELOAD = [
26 'scoreLink.playlistItem',
27 'scoreLink.score',
28 'scoreLink.score.processHistory',
29 'scoreLink.user.country',
30 ];
31
32 const TYPE_LEGACY = 'legacy';
33 const TYPE_SOLO = 'solo';
34
35 // TODO: user include is deprecated.
36 const USER_PROFILE_INCLUDES = ['beatmap', 'beatmapset', 'user'];
37 const USER_PROFILE_INCLUDES_PRELOAD = [
38 'beatmap',
39 'beatmap.beatmapset',
40 'processHistory',
41 // it's for user profile so the user is already available
42 // 'user',
43 ];
44
45 protected array $availableIncludes = [
46 'beatmap',
47 'beatmapset',
48 'current_user_attributes',
49 'match',
50 'rank_country',
51 'rank_global',
52 'user',
53 'weight',
54
55 // Only for MultiplayerScoreLink
56 'position',
57 'scores_around',
58 ];
59
60 protected array $defaultIncludes = [
61 'current_user_attributes',
62 ];
63
64 private string $transformFunction;
65
66 public static function newSolo(): static
67 {
68 return new static(static::TYPE_SOLO);
69 }
70
71 public function __construct(?string $type = null)
72 {
73 $type ??= is_api_request() && api_version() < 20220705
74 ? static::TYPE_LEGACY
75 : static::TYPE_SOLO;
76
77 switch ($type) {
78 case static::TYPE_LEGACY:
79 $this->transformFunction = 'transformLegacy';
80 break;
81 case static::TYPE_SOLO:
82 $this->transformFunction = 'transformSolo';
83 break;
84 }
85 }
86
87 public function transform(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score)
88 {
89 $fn = $this->transformFunction;
90
91 return $this->$fn($score);
92 }
93
94 public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score)
95 {
96 $extraAttributes = [];
97
98 if ($score instanceof MultiplayerScoreLink) {
99 $extraAttributes['playlist_item_id'] = $score->playlist_item_id;
100 $extraAttributes['room_id'] = $score->playlistItem->room_id;
101 $extraAttributes['solo_score_id'] = $score->score_id;
102 $score = $score->score;
103 }
104
105 if ($score instanceof SoloScore) {
106 $extraAttributes['classic_total_score'] = $score->getClassicTotalScore();
107 $extraAttributes['preserve'] = $score->preserve;
108 $extraAttributes['processed'] = $score->isProcessed();
109 $extraAttributes['ranked'] = $score->ranked;
110 }
111
112 $hasReplay = $score->has_replay;
113 $isPerfectCombo = $score->is_perfect_combo;
114
115 return [
116 ...$extraAttributes,
117 ...$score->data->jsonSerialize(),
118 'beatmap_id' => $score->beatmap_id,
119 'best_id' => $score->best_id,
120 'id' => $score->getKey(),
121 'rank' => $score->rank,
122 'type' => $score->getMorphClass(),
123 'user_id' => $score->user_id,
124 'accuracy' => $score->accuracy,
125 'build_id' => $score->build_id,
126 'ended_at' => $score->ended_at_json,
127 'has_replay' => $hasReplay,
128 'is_perfect_combo' => $isPerfectCombo,
129 'legacy_perfect' => $score->legacy_perfect ?? $isPerfectCombo,
130 'legacy_score_id' => $score->legacy_score_id,
131 'legacy_total_score' => $score->legacy_total_score,
132 'max_combo' => $score->max_combo,
133 'passed' => $score->passed,
134 'pp' => $score->pp,
135 'ruleset_id' => $score->ruleset_id,
136 'started_at' => $score->started_at_json,
137 'total_score' => $score->total_score,
138 // TODO: remove this redundant field sometime after 2024-02
139 'replay' => $hasReplay,
140 ];
141
142 return $ret;
143 }
144
145 public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score)
146 {
147 $best = null;
148
149 if ($score instanceof ScoreModel) {
150 // this `best` relation is also used by `current_user_attributes` include.
151 $best = $score->best;
152 } elseif ($score instanceof SoloScore) {
153 $soloScore = $score;
154 $score = $soloScore->makeLegacyEntry();
155 $best = $score;
156
157 if ($soloScore->isLegacy()) {
158 $id = $soloScore->legacy_score_id;
159 if ($id > 0) {
160 // To be used later for best_id
161 $score->score_id = $id;
162 }
163 } else {
164 $id = $soloScore->getKey();
165 $type = $soloScore->getMorphClass();
166 }
167 }
168
169 $mode = $score->getMode();
170
171 $statistics = [
172 'count_100' => $score->count100,
173 'count_300' => $score->count300,
174 'count_50' => $score->count50,
175 'count_geki' => $score->countgeki,
176 'count_katu' => $score->countkatu,
177 'count_miss' => $score->countmiss,
178 ];
179
180 return [
181 'accuracy' => $score->accuracy(),
182 'best_id' => $best?->getKey(),
183 'created_at' => $score->date_json,
184 'id' => $id ?? $score->getKey(),
185 'max_combo' => $score->maxcombo,
186 'mode' => $mode,
187 'mode_int' => Beatmap::modeInt($mode),
188 'mods' => $score->enabled_mods,
189 'passed' => $score->pass,
190 'perfect' => $perfect ?? $score->perfect,
191 'pp' => $best?->pp,
192 'rank' => $score->rank,
193 'replay' => $best?->has_replay ?? false,
194 'score' => $score->score,
195 'statistics' => $statistics,
196 'type' => $type ?? $score->getMorphClass(),
197 'user_id' => $score->user_id,
198 ];
199 }
200
201 public function includeBeatmap(LegacyMatch\Score|ScoreModel|SoloScore $score)
202 {
203 $beatmap = $score->beatmap;
204
205 if ($score->getMode() !== $beatmap->mode) {
206 $beatmap->convert = true;
207 $beatmap->playmode = Beatmap::MODES[$score->getMode()];
208 }
209
210 return $this->item($beatmap, new BeatmapTransformer());
211 }
212
213 public function includeBeatmapset(LegacyMatch\Score|ScoreModel|SoloScore $score)
214 {
215 return $this->item($score->beatmap->beatmapset, new BeatmapsetCompactTransformer());
216 }
217
218 public function includeCurrentUserAttributes(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score): Item
219 {
220 return $this->item($score, new Score\CurrentUserAttributesTransformer());
221 }
222
223 public function includeMatch(LegacyMatch\Score $score)
224 {
225 return $this->primitive([
226 'slot' => $score->slot,
227 'team' => $score->team,
228 'pass' => $score->pass,
229 ]);
230 }
231
232 public function includePosition(MultiplayerScoreLink $scoreLink)
233 {
234 return $this->primitive($scoreLink->position());
235 }
236
237 public function includeScoresAround(MultiplayerScoreLink $scoreLink)
238 {
239 static $limit = 10;
240 static $transformer;
241 $transformer ??= static::newSolo();
242
243 return $this->primitive(array_map(
244 function ($item) use ($limit, $transformer) {
245 [$highScores, $hasMore] = $item['query']
246 ->with(static::MULTIPLAYER_BASE_PRELOAD)
247 ->limit($limit)
248 ->getWithHasMore();
249
250 return [
251 'scores' => json_collection($highScores->pluck('scoreLink'), $transformer, static::MULTIPLAYER_BASE_INCLUDES),
252 'params' => ['limit' => $limit, 'sort' => $item['cursorHelper']->getSortName()],
253 ...cursor_for_response($item['cursorHelper']->next($highScores, $hasMore)),
254 ];
255 },
256 PlaylistItemUserHighScore::scoresAround($scoreLink),
257 ));
258 }
259
260 public function includeRankCountry(ScoreBest|SoloScore $score)
261 {
262 return $this->primitive($score->userRank([
263 'type' => 'country',
264 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()),
265 ]));
266 }
267
268 public function includeRankGlobal(ScoreBest|SoloScore $score)
269 {
270 return $this->primitive($score->userRank([
271 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()),
272 ]));
273 }
274
275 public function includeUser(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score)
276 {
277 return $this->item(
278 $score->user ?? new DeletedUser(['user_id' => $score->user_id]),
279 new UserCompactTransformer()
280 );
281 }
282
283 public function includeWeight(LegacyMatch\Score|ScoreModel|SoloScore $score)
284 {
285 if (($score instanceof ScoreBest || $score instanceof SoloScore) && $score->weight !== null) {
286 return $this->primitive([
287 'percentage' => $score->weight * 100,
288 'pp' => $score->weightedPp(),
289 ]);
290 }
291 }
292}