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\Models\Model;
9use App\Models\Traits\WithDbCursorHelper;
10use App\Models\User;
11
12/**
13 * Aggregate root for user multiplayer high scores.
14 * Updates should be done via this root and not directly against the models.
15 *
16 * @property float $accuracy
17 * @property int $attempts
18 * @property int $completed
19 * @property \Carbon\Carbon $created_at
20 * @property int $id
21 * @property int|null $last_score_id
22 * @property bool $in_room
23 * @property int $room_id
24 * @property int $total_score
25 * @property \Carbon\Carbon $updated_at
26 * @property int $user_id
27 */
28class UserScoreAggregate extends Model
29{
30 use WithDbCursorHelper;
31
32 const SORTS = [
33 'score_asc' => [
34 ['column' => 'total_score', 'order' => 'ASC'],
35 ['column' => 'last_score_id', 'order' => 'DESC'],
36 ],
37 ];
38
39 const DEFAULT_SORT = 'score_asc';
40
41 protected $casts = [
42 'in_room' => 'boolean',
43 ];
44 protected $table = 'multiplayer_rooms_high';
45
46 public static function lookupOrDefault(User $user, Room $room): static
47 {
48 return static::firstOrNew([
49 'room_id' => $room->getKey(),
50 'user_id' => $user->getKey(),
51 ], [
52 'accuracy' => 0,
53 'attempts' => 0,
54 'completed' => 0,
55 'total_score' => 0,
56 ]);
57 }
58
59 public static function new(User $user, Room $room): self
60 {
61 $obj = static::lookupOrDefault($user, $room);
62
63 if (!$obj->exists) {
64 $obj->save(); // force a save now to avoid being trolled later.
65 $obj->recalculate();
66 }
67
68 return $obj;
69 }
70
71 public function addScoreLink(ScoreLink $scoreLink, ?PlaylistItemUserHighScore $highestScore = null)
72 {
73 return $this->getConnection()->transaction(function () use ($scoreLink) {
74 $isNewHighScore = PlaylistItemUserHighScore::lookupOrDefault(
75 $scoreLink->user_id,
76 $scoreLink->playlist_item_id,
77 )->updateWithScoreLink($scoreLink);
78
79 if ($isNewHighScore) {
80 $this->refreshStatistics();
81 }
82
83 return true;
84 });
85 }
86
87 public function averageAccuracy()
88 {
89 return $this->completed > 0 ? $this->accuracy / $this->completed : 0;
90 }
91
92 public function playlistItemAttempts(): array
93 {
94 $playlistItemAggs = PlaylistItemUserHighScore
95 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id))
96 ->where('user_id', $this->user_id)
97 ->get();
98
99 $ret = [];
100 foreach ($playlistItemAggs as $agg) {
101 $ret[] = [
102 'attempts' => $agg->attempts,
103 'id' => $agg->playlist_item_id,
104 ];
105 }
106
107 return $ret;
108 }
109
110 public function recalculate()
111 {
112 $this->getConnection()->transaction(function () {
113 $this->removeRunningTotals();
114 $playlistItemAggs = PlaylistItemUserHighScore
115 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id))
116 ->where('user_id', $this->user_id)
117 ->get()
118 ->keyBy('playlist_item_id');
119 $this->attempts = $playlistItemAggs->reduce(fn ($acc, $agg) => $acc + $agg->attempts, 0);
120
121 $scoreLinks = ScoreLink
122 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id))
123 ->where('user_id', $this->user_id)
124 ->with('score')
125 ->get();
126 foreach ($scoreLinks as $scoreLink) {
127 ($playlistItemAggs[$scoreLink->playlist_item_id]
128 ?? PlaylistItemUserHighScore::lookupOrDefault(
129 $scoreLink->user_id,
130 $scoreLink->playlist_item_id,
131 )
132 )->updateWithScoreLink($scoreLink);
133 }
134 $this->refreshStatistics();
135 $this->save();
136 });
137 }
138
139 public function removeRunningTotals()
140 {
141 PlaylistItemUserHighScore
142 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id))
143 ->where('user_id', $this->user_id)
144 ->update([
145 'accuracy' => 0,
146 'score_id' => null,
147 'total_score' => 0,
148 ]);
149
150 static $resetAttributes = [
151 'accuracy',
152 'attempts',
153 'completed',
154 'last_score_id',
155 'total_score',
156 ];
157
158 foreach ($resetAttributes as $key) {
159 // init if required
160 $this->$key = 0;
161 }
162 }
163
164 public function room()
165 {
166 return $this->belongsTo(Room::class);
167 }
168
169 public function scopeForRanking($query)
170 {
171 return $query
172 ->where('completed', '>', 0)
173 ->whereHas('user', function ($userQuery) {
174 $userQuery->default();
175 })
176 ->orderBy('total_score', 'DESC')
177 ->orderBy('last_score_id', 'ASC');
178 }
179
180 public function updateUserAttempts()
181 {
182 $this->incrementInstance('attempts');
183 }
184
185 public function user()
186 {
187 return $this->belongsTo(User::class, 'user_id');
188 }
189
190 public function userRank()
191 {
192 if ($this->total_score === null || $this->last_score_id === null) {
193 return;
194 }
195
196 $query = static::where('room_id', $this->room_id)->forRanking()
197 ->cursorSort('score_asc', $this);
198
199 return 1 + $query->count();
200 }
201
202 private function refreshStatistics(): void
203 {
204 $agg = PlaylistItemUserHighScore
205 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id))
206 ->whereNotNull('score_id')
207 ->selectRaw('
208 SUM(accuracy) AS accuracy_sum,
209 SUM(total_score) AS total_score_sum,
210 COUNT(*) AS completed,
211 MAX(score_id) AS last_score_id
212 ')->firstWhere('user_id', $this->user_id);
213
214 $this->fill([
215 'accuracy' => $agg->getRawAttribute('accuracy_sum') ?? 0,
216 'completed' => $agg->getRawAttribute('completed') ?? 0,
217 'last_score_id' => $agg->getRawAttribute('last_score_id'),
218 'total_score' => $agg->getRawAttribute('total_score_sum') ?? 0,
219 ])->save();
220 }
221}