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\Libraries\Search;
7
8use App\Libraries\Elasticsearch\BoolQuery;
9use App\Libraries\Elasticsearch\RecordSearch;
10use App\Models\Solo\Score;
11use Ds\Set;
12use Exception;
13use LaravelRedis;
14
15class ScoreSearch extends RecordSearch
16{
17 public $connectionName = 'solo_scores';
18
19 protected $source = false;
20
21 public function __construct(?ScoreSearchParams $params = null)
22 {
23 parent::__construct(
24 $GLOBALS['cfg']['osu']['elasticsearch']['prefix'].'scores',
25 $params ?? new ScoreSearchParams(),
26 Score::class
27 );
28 }
29
30 public function getActiveSchemas(): array
31 {
32 return LaravelRedis::smembers('osu-queue:score-index:'.$GLOBALS['cfg']['osu']['elasticsearch']['prefix'].'active-schemas');
33 }
34
35 public function getQuery(): BoolQuery
36 {
37 $query = new BoolQuery();
38
39 if ($this->params->isLegacy !== null) {
40 $query->filter(['term' => ['is_legacy' => $this->params->isLegacy]]);
41 }
42 if ($this->params->rulesetId !== null) {
43 $query->filter(['term' => ['ruleset_id' => $this->params->rulesetId]]);
44 }
45 if ($this->params->beatmapIds !== null && count($this->params->beatmapIds) > 0) {
46 $query->filter(['terms' => ['beatmap_id' => $this->params->beatmapIds]]);
47 }
48 if ($this->params->userId !== null) {
49 $query->filter(['term' => ['user_id' => $this->params->userId]]);
50 }
51 if ($this->params->excludeConverts) {
52 $query->filter(['term' => ['convert' => false]]);
53 }
54 if ($this->params->excludeMods !== null && count($this->params->excludeMods) > 0) {
55 foreach ($this->params->excludeMods as $excludedMod) {
56 $query->mustNot(['term' => ['mods' => $excludedMod]]);
57 }
58 }
59 if ($this->params->excludeWithoutPp === true) {
60 $query->filter(['exists' => ['field' => 'pp']]);
61 }
62
63 $this->addModsFilter($query);
64
65 switch ($this->params->getType()) {
66 case 'country':
67 $query->filter(['term' => ['country_code' => $this->params->getCountryCode()]]);
68 break;
69 case 'friend':
70 $query->filter(['terms' => ['user_id' => $this->params->getFriendIds()]]);
71 break;
72 }
73
74 $beforeTotalScore = $this->params->beforeTotalScore;
75 if ($beforeTotalScore === null && $this->params->beforeScore !== null) {
76 $beforeTotalScore = $this->params->isLegacy
77 ? $this->params->beforeScore->legacy_total_score
78 : $this->params->beforeScore->total_score;
79 }
80 if ($beforeTotalScore !== null) {
81 $scoreQuery = (new BoolQuery())->shouldMatch(1);
82 $scoreField = $this->params->isLegacy ? 'legacy_total_score' : 'total_score';
83 $scoreQuery->should((new BoolQuery())->filter(['range' => [
84 $scoreField => ['gt' => $beforeTotalScore],
85 ]]));
86 if ($this->params->beforeScore !== null) {
87 $scoreQuery->should((new BoolQuery())
88 ->filter(['range' => ['id' => ['lt' => $this->params->beforeScore->getKey()]]])
89 ->filter(['term' => [$scoreField => $beforeTotalScore]]));
90 }
91
92 $query->must($scoreQuery);
93 }
94
95 return $query;
96 }
97
98 public function indexWait(float $maxWaitSecond = 5): void
99 {
100 $count = Score::indexable()->count();
101 $loopWait = 500000; // 0.5s in microsecond
102 $loops = (int) ceil($maxWaitSecond * 1000000.0 / $loopWait);
103
104 for ($i = 0; $i < $loops; $i++) {
105 usleep($loopWait);
106 $this->refresh();
107 $indexedCount = $this->client()->count(['index' => $this->index])['count'];
108 if ($indexedCount === $count) {
109 return;
110 }
111 }
112
113 throw new Exception("Indexable and indexed score counts still don't match. Queue runner is probably either having problem, not running, or too slow");
114 }
115
116 public function queueForIndex(?array $schemas, array $ids): void
117 {
118 $count = count($ids);
119
120 if ($count === 0) {
121 return;
122 }
123
124 $schemas ??= $this->getActiveSchemas();
125
126 $values = array_map(
127 static fn (int $id): string => json_encode(['ScoreId' => $id]),
128 $ids,
129 );
130
131 foreach ($schemas as $schema) {
132 LaravelRedis::lpush("osu-queue:{$schema}", ...$values);
133 }
134 }
135
136 public function setSchema(string $schema): void
137 {
138 LaravelRedis::set('osu-queue:score-index:'.$GLOBALS['cfg']['osu']['elasticsearch']['prefix'].'schema', $schema);
139 }
140
141 private function addModsFilter(BoolQuery $query): void
142 {
143 $mods = $this->params->mods;
144 if ($mods === null || count($mods) === 0) {
145 return;
146 }
147
148 $modsHelper = app('mods');
149 $allMods = $this->params->rulesetId === null
150 ? $modsHelper->allIds
151 : new Set(array_keys($modsHelper->mods[$this->params->rulesetId]));
152 // CL is currently considered a "preference" mod
153 $allMods->remove('CL', 'PF', 'SD', 'MR');
154
155 $allSearchMods = [];
156 foreach ($mods as $mod) {
157 if ($mod === 'NM') {
158 if (!isset($noModSubQuery)) {
159 $noModSubQuery = new BoolQuery();
160 foreach ($allMods->toArray() as $excludedMod) {
161 $noModSubQuery->mustNot(['term' => ['mods' => $excludedMod]]);
162 }
163 }
164 continue;
165 }
166 $modsSubQuery ??= new BoolQuery();
167 $searchMods = [$mod];
168 $impliedBy = array_search_null($mod, $modsHelper::IMPLIED_MODS);
169 if ($impliedBy !== null) {
170 $searchMods[] = $impliedBy;
171 }
172 $modsSubQuery->filter(['terms' => ['mods' => $searchMods]]);
173 $allSearchMods = [...$allSearchMods, ...$searchMods];
174 }
175
176 if (isset($modsSubQuery)) {
177 $excludedMods = array_values(array_diff($allMods->toArray(), $allSearchMods));
178 if (count($excludedMods) > 0) {
179 foreach ($excludedMods as $excludedMod) {
180 $modsSubQuery->mustNot(['term' => ['mods' => $excludedMod]]);
181 }
182 }
183 }
184
185 foreach ([$noModSubQuery ?? null, $modsSubQuery ?? null] as $subQuery) {
186 if ($subQuery !== null) {
187 $shouldSubQueries ??= (new BoolQuery())->shouldMatch(1);
188 $shouldSubQueries->should($subQuery);
189 }
190 }
191
192 if (isset($shouldSubQueries)) {
193 $query->must($shouldSubQueries);
194 }
195 }
196}