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\Models;
9
10use App\Models\Multiplayer\PlaylistItemUserHighScore;
11use Carbon\CarbonImmutable;
12use Ds\Set;
13use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
15class DailyChallengeUserStats extends Model
16{
17 const array INITIAL_VALUES = [
18 'daily_streak_best' => 0,
19 'daily_streak_current' => 0,
20 'last_percentile_calculation' => '2000-01-01 00:00:00',
21 'last_update' => '2000-01-01 00:00:00',
22 'last_weekly_streak' => '2000-01-01 00:00:00',
23 'playcount' => 0,
24 'top_10p_placements' => 0,
25 'top_50p_placements' => 0,
26 'weekly_streak_best' => 0,
27 'weekly_streak_current' => 0,
28 ];
29
30 public $incrementing = false;
31 public $timestamps = false;
32
33 protected $attributes = self::INITIAL_VALUES;
34
35 protected $casts = [
36 'last_percentile_calculation' => 'datetime',
37 'last_update' => 'datetime',
38 'last_weekly_streak' => 'datetime',
39 ];
40 protected $primaryKey = 'user_id';
41 protected $table = 'daily_challenge_user_stats';
42
43 public static function calculate(CarbonImmutable $date): void
44 {
45 $startTime = $date->startOfDay();
46 $currentWeek = static::startOfWeek($startTime);
47 $previousWeek = $currentWeek->subWeeks(1);
48 // this function assumes one daily challenge per day and one playlist item per daily challenge
49 $playlist = Multiplayer\Room::dailyChallengeFor($startTime)?->playlist[0] ?? null;
50
51 if ($playlist === null) {
52 // or maybe do something with existing streaks
53 return;
54 }
55
56 $highScoresByUserId = $playlist
57 ->highScores()
58 ->passing()
59 ->get()
60 ->keyBy('user_id');
61 $statsByUserId = static
62 ::where('last_weekly_streak', '>=', $previousWeek->subWeeks(1))
63 ->orWhereIn('user_id', $highScoresByUserId->keys())
64 ->get()
65 ->keyBy('user_id');
66 $percentile = $playlist->scorePercentile();
67
68 $userIds = new Set([...$statsByUserId->keys(), ...$highScoresByUserId->keys()]);
69 foreach ($userIds as $userId) {
70 $stats = $statsByUserId[$userId] ?? new static([
71 'user_id' => $userId,
72 ]);
73 $highScore = $highScoresByUserId[$userId] ?? null;
74
75 $stats->updateStreak(
76 $highScore !== null,
77 $startTime,
78 currentWeek: $currentWeek,
79 previousWeek: $previousWeek,
80 );
81
82 $stats->updatePercentile($percentile, $highScore, $startTime);
83
84 $stats->save();
85 }
86 }
87
88 public static function startOfWeek(CarbonImmutable $date): CarbonImmutable
89 {
90 return $date->startOfWeek(CarbonImmutable::THURSDAY);
91 }
92
93 public function user(): BelongsTo
94 {
95 return $this->belongsTo(User::class, 'user_id');
96 }
97
98 public function fix(): void
99 {
100 $highScores = PlaylistItemUserHighScore
101 ::where('user_id', $this->user_id)
102 ->whereRelation('playlistItem.room', 'category', 'daily_challenge')
103 ->passing()
104 ->with('playlistItem.room')
105 ->orderBy('created_at')
106 ->get();
107
108 $this->fill(static::INITIAL_VALUES);
109
110 foreach ($highScores as $highScore) {
111 $playlistItem = $highScore->playlistItem;
112 $room = $playlistItem->room;
113 $startTime = $room->starts_at->toImmutable()->startOfDay();
114 $this->updateStreak(true, $startTime);
115 if ($room->hasEnded()) {
116 $this->updatePercentile($playlistItem->scorePercentile(), $highScore, $startTime);
117 }
118 }
119 $streakBreakDay = CarbonImmutable::yesterday();
120 if ($this->last_update < $streakBreakDay) {
121 $this->updateStreak(false, $streakBreakDay);
122 }
123
124 $this->saveOrExplode();
125 }
126
127 public function updateStreak(
128 bool $incrementing,
129 CarbonImmutable $startTime,
130 ?CarbonImmutable $currentWeek = null,
131 ?CarbonImmutable $previousWeek = null
132 ): void {
133 $currentWeek ??= static::startOfWeek($startTime);
134 $previousWeek ??= $currentWeek->subWeek(1);
135
136 $lastUpdate = $this->last_update;
137 if ($lastUpdate >= $startTime) {
138 return;
139 }
140
141 if ($incrementing) {
142 $previousDay = $startTime->subDays(1);
143
144 if ($lastUpdate < $previousDay) {
145 $this->updateStreak(false, $previousDay);
146 }
147
148 $this->playcount += 1;
149 $this->daily_streak_current += 1;
150 $this->last_update = $startTime;
151
152 if ($this->last_weekly_streak < $currentWeek) {
153 $this->weekly_streak_current += 1;
154 $this->last_weekly_streak = $currentWeek;
155 }
156
157 foreach (['daily', 'weekly'] as $type) {
158 if ($this["{$type}_streak_best"] < $this["{$type}_streak_current"]) {
159 $this["{$type}_streak_best"] = $this["{$type}_streak_current"];
160 }
161 }
162 } else {
163 $this->daily_streak_current = 0;
164 if ($this->last_weekly_streak < $previousWeek) {
165 $this->weekly_streak_current = 0;
166 }
167 }
168 }
169
170 private function updatePercentile(
171 array $playlistPercentile,
172 ?PlaylistItemUserHighScore $highScore,
173 CarbonImmutable $startTime
174 ): void {
175 if ($highScore === null || $this->last_percentile_calculation >= $startTime) {
176 return;
177 }
178
179 foreach ($playlistPercentile as $p => $totalScore) {
180 if ($highScore->total_score >= $totalScore) {
181 $this->{"{$p}_placements"}++;
182 }
183 }
184 $this->last_percentile_calculation = $startTime;
185 }
186}