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\Libraries\Search\ScoreSearch;
9use App\Libraries\Search\ScoreSearchParams;
10use App\Models\Traits\WithDbCursorHelper;
11use Ds\Set;
12
13/**
14 * @property string $author
15 * @property \Carbon\Carbon $date
16 * @property bool $hidden
17 * @property \Illuminate\Database\Eloquent\Collection $items BeatmapPackItem
18 * @property string $name
19 * @property int $pack_id
20 * @property int|null $playmode
21 * @property string $tag
22 * @property string $url
23 */
24class BeatmapPack extends Model
25{
26 use WithDbCursorHelper;
27
28 protected const DEFAULT_SORT = 'id_desc';
29 protected const SORTS = [
30 'id_desc' => [
31 ['column' => 'pack_id', 'order' => 'DESC'],
32 ],
33 ];
34
35 const DEFAULT_TYPE = 'standard';
36
37 // also display order for listing page
38 const TAG_MAPPINGS = [
39 'standard' => 'S',
40 'featured' => 'F',
41 'tournament' => 'P', // since 'T' is taken and 'P' goes for 'pool'
42 'loved' => 'L',
43 'chart' => 'R',
44 'theme' => 'T',
45 'artist' => 'A',
46 ];
47
48 protected $table = 'osu_beatmappacks';
49 protected $primaryKey = 'pack_id';
50
51 protected $casts = [
52 'date' => 'datetime',
53 'hidden' => 'boolean',
54 'no_diff_reduction' => 'boolean',
55 ];
56
57 public $timestamps = false;
58
59 public static function getPacks($type)
60 {
61 $tag = static::TAG_MAPPINGS[$type] ?? null;
62
63 if ($tag === null) {
64 return null;
65 }
66
67 return static::default()->where('tag', 'like', "{$tag}%")->orderBy('pack_id', 'desc');
68 }
69
70 public function scopeDefault($query)
71 {
72 $query->where(['hidden' => false]);
73 }
74
75 public function items()
76 {
77 return $this->hasMany(BeatmapPackItem::class);
78 }
79
80 public function beatmapsets()
81 {
82 return $this->hasManyThrough(
83 Beatmapset::class,
84 BeatmapPackItem::class,
85 'pack_id',
86 'beatmapset_id',
87 null,
88 'beatmapset_id',
89 );
90 }
91
92 public function getRouteKeyName(): string
93 {
94 return 'tag';
95 }
96
97 public function userCompletionData($user, ?bool $isLegacy)
98 {
99 if ($user !== null) {
100 $userId = $user->getKey();
101
102 $beatmaps = Beatmap
103 ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id'))
104 ->select(['beatmap_id', 'beatmapset_id', 'playmode'])
105 ->get();
106 $beatmapsetIdsByBeatmapId = [];
107 foreach ($beatmaps as $beatmap) {
108 $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id;
109 }
110 $params = [
111 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId),
112 'exclude_converts' => $this->playmode === null,
113 'is_legacy' => $isLegacy,
114 'limit' => 0,
115 'ruleset_id' => $this->playmode,
116 'user_id' => $userId,
117 ];
118 if ($this->no_diff_reduction) {
119 $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray();
120 if ($isLegacy !== true) {
121 // the intended meaning of this check is that the scores should not include mods
122 // that disqualify them from granting pp.
123 // mods are not the only reason why pp might be missing, but it's the best that we have for now.
124 // see also: https://github.com/ppy/osu-queue-score-statistics/pull/234
125 $params['exclude_without_pp'] = true;
126 }
127 }
128
129 static $aggName = 'by_beatmap';
130
131 $search = new ScoreSearch(ScoreSearchParams::fromArray($params));
132 $search->size(0);
133 $search->setAggregations([$aggName => [
134 'terms' => [
135 'field' => 'beatmap_id',
136 'size' => max(1, count($params['beatmap_ids'])),
137 ],
138 'aggs' => [
139 'scores' => [
140 'top_hits' => [
141 'size' => 1,
142 ],
143 ],
144 ],
145 ]]);
146 $response = $search->response();
147 $search->assertNoError();
148 $completedBeatmapIds = array_map(
149 fn (array $hit): int => (int) $hit['key'],
150 $response->aggregations($aggName)['buckets'],
151 );
152 $completedBeatmapsetIds = (new Set(array_map(
153 fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId],
154 $completedBeatmapIds,
155 )))->toArray();
156 $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId));
157 }
158
159 return [
160 'completed' => $completed ?? false,
161 'beatmapset_ids' => $completedBeatmapsetIds ?? [],
162 ];
163 }
164}