diff --git a/app/Models/DailyChallengeUserStats.php b/app/Models/DailyChallengeUserStats.php index 82d3435592e..c5de148719d 100644 --- a/app/Models/DailyChallengeUserStats.php +++ b/app/Models/DailyChallengeUserStats.php @@ -27,6 +27,7 @@ class DailyChallengeUserStats extends Model ]; protected $casts = [ + 'last_percentile_calculation' => 'datetime', 'last_update' => 'datetime', 'last_weekly_streak' => 'datetime', ]; @@ -62,9 +63,7 @@ public static function calculate(CarbonImmutable $date): void $highScoresByUserId[$highScore->user_id] = $highScore; } $statsByUserId = static - ::where(fn ($q) => $q - ->where('last_weekly_streak', '>=', $previousWeek->subDays(1)) - ->where('last_update', '<', $startTime)) + ::where('last_weekly_streak', '>=', $previousWeek->subDays(1)) ->orWhereIn('user_id', array_keys($highScoresByUserId)) ->get() ->keyBy('user_id'); @@ -73,40 +72,25 @@ public static function calculate(CarbonImmutable $date): void $stats = $statsByUserId[$userId] ?? new static([ 'user_id' => $userId, ]); - // ignore processed scores - if (($stats->last_update ?? $previousWeek) >= $startTime) { - continue; - } $highScore = $highScoresByUserId[$userId] ?? null; - if ($highScore === null) { - $stats->daily_streak_current = 0; - if ($stats->last_weekly_streak < $previousWeek) { - $stats->weekly_streak_current = 0; - } - } else { - $stats->playcount += 1; - - $stats->daily_streak_current += 1; - if (($stats->last_weekly_streak ?? $previousWeek) < $currentWeek) { - $stats->weekly_streak_current += 1; - } - $stats->last_weekly_streak = $currentWeek; - foreach (['daily', 'weekly'] as $type) { - if ($stats["{$type}_streak_best"] < $stats["{$type}_streak_current"]) { - $stats["{$type}_streak_best"] = $stats["{$type}_streak_current"]; - } - } + $stats->updateStreak( + $highScore !== null, + $startTime, + currentWeek: $currentWeek, + previousWeek: $previousWeek, + ); + if ($highScore !== null && ($stats->last_percentile_calculation ?? $previousWeek) < $startTime) { if ($highScore->total_score >= $top10p) { $stats->top_10p_placements += 1; } if ($highScore->total_score >= $top50p) { $stats->top_50p_placements += 1; } + $stats->last_percentile_calculation = $startTime; } - $stats->last_update = $startTime; $stats->save(); } } @@ -120,4 +104,39 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + public function updateStreak( + bool $incrementing, + CarbonImmutable $startTime, + ?CarbonImmutable $currentWeek = null, + ?CarbonImmutable $previousWeek = null + ): void { + $currentWeek ??= static::startOfWeek($startTime); + $previousWeek ??= $currentWeek->subWeek(1); + + if ($incrementing) { + if (($this->last_update ?? $previousWeek) < $startTime) { + $this->playcount += 1; + $this->daily_streak_current += 1; + } + + if (($this->last_weekly_streak ?? $previousWeek) < $currentWeek) { + $this->weekly_streak_current += 1; + } + $this->last_weekly_streak = $currentWeek; + + foreach (['daily', 'weekly'] as $type) { + if ($this["{$type}_streak_best"] < $this["{$type}_streak_current"]) { + $this["{$type}_streak_best"] = $this["{$type}_streak_current"]; + } + } + } else { + $this->daily_streak_current = 0; + if ($this->last_weekly_streak === null || $this->last_weekly_streak < $previousWeek) { + $this->weekly_streak_current = 0; + } + } + + $this->last_update = $startTime; + } } diff --git a/app/Models/Multiplayer/Room.php b/app/Models/Multiplayer/Room.php index f40a34e544e..236c7f1bd4c 100644 --- a/app/Models/Multiplayer/Room.php +++ b/app/Models/Multiplayer/Room.php @@ -421,7 +421,14 @@ public function completePlay(ScoreToken $scoreToken, array $params): ScoreLink return $this->getConnection()->transaction(function () use ($params, $scoreToken) { $scoreLink = ScoreLink::complete($scoreToken, $params); - UserScoreAggregate::new($scoreLink->user, $this)->addScoreLink($scoreLink); + $user = $scoreLink->user; + $agg = UserScoreAggregate::new($user, $this); + $agg->addScoreLink($scoreLink); + if ($this->category === 'daily_challenge' && $agg->total_score > 0) { + $stats = $user->dailyChallengeUserStats()->firstOrNew(); + $stats->updateStreak(true, $this->starts_at->toImmutable()->startOfDay()); + $stats->save(); + } return $scoreLink; }); diff --git a/database/migrations/2024_07_26_100752_add_last_percentile_calculation_to_daily_challenge_user_stats.php b/database/migrations/2024_07_26_100752_add_last_percentile_calculation_to_daily_challenge_user_stats.php new file mode 100644 index 00000000000..b9fcc4dddde --- /dev/null +++ b/database/migrations/2024_07_26_100752_add_last_percentile_calculation_to_daily_challenge_user_stats.php @@ -0,0 +1,35 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('daily_challenge_user_stats', function (Blueprint $table) { + $table->timestamp('last_percentile_calculation')->nullable(true); + $table->timestamp('last_update')->useCurrent()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('daily_challenge_user_stats', function (Blueprint $table) { + $table->dropColumn('last_percentile_calculation'); + $table->timestamp('last_update')->useCurrent()->useCurrentOnUpdate()->change(); + }); + } +}; diff --git a/tests/Models/DailyChallengeUserStatsTest.php b/tests/Models/DailyChallengeUserStatsTest.php index 5934cbf0d6a..b9314469125 100644 --- a/tests/Models/DailyChallengeUserStatsTest.php +++ b/tests/Models/DailyChallengeUserStatsTest.php @@ -61,92 +61,6 @@ public function testCalculateFromStart(): void $this->assertTrue($playTime->equalTo($stats->last_update)); } - public function testCalculateTwiceADay(): void - { - $playTime = static::startOfWeek(); - $playlistItem = static::preparePlaylistItem($playTime); - - $user = User::factory()->create(); - ScoreLink::factory()->passed()->create([ - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - ]); - UserScoreAggregate::new($user, $playlistItem->room)->save(); - - DailyChallengeUserStats::calculate($playTime); - DailyChallengeUserStats::calculate($playTime); - - $stats = DailyChallengeUserStats::find($user->getKey()); - $this->assertSame(1, $stats->playcount); - } - - public function testCalculateIncrementAll(): void - { - $playTime = static::startOfWeek(); - $playlistItem = static::preparePlaylistItem($playTime); - - $user = User::factory()->create(); - ScoreLink::factory()->passed()->create([ - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - ]); - UserScoreAggregate::new($user, $playlistItem->room)->save(); - - DailyChallengeUserStats::create([ - 'user_id' => $user->getKey(), - 'playcount' => 1, - 'daily_streak_current' => 1, - 'daily_streak_best' => 1, - 'weekly_streak_current' => 1, - 'weekly_streak_best' => 1, - 'top_10p_placements' => 1, - 'top_50p_placements' => 1, - 'last_weekly_streak' => $playTime->subWeeks(1), - 'last_update' => $playTime->subDays(1), - ]); - - $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 0); - - DailyChallengeUserStats::calculate($playTime); - - $stats = DailyChallengeUserStats::find($user->getKey()); - $this->assertSame(2, $stats->daily_streak_current); - $this->assertSame(2, $stats->daily_streak_best); - $this->assertSame(2, $stats->weekly_streak_current); - $this->assertSame(2, $stats->weekly_streak_best); - $this->assertSame(2, $stats->top_10p_placements); - $this->assertSame(2, $stats->top_50p_placements); - $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); - $this->assertTrue($playTime->equalTo($stats->last_update)); - } - - public function testCalculateIncrementWeeklyStreak(): void - { - $playTime = static::startOfWeek(); - $playlistItem = static::preparePlaylistItem($playTime); - - $user = User::factory()->create(); - ScoreLink::factory()->passed()->create([ - 'playlist_item_id' => $playlistItem, - 'user_id' => $user, - ]); - UserScoreAggregate::new($user, $playlistItem->room)->save(); - - DailyChallengeUserStats::create([ - 'user_id' => $user->getKey(), - 'weekly_streak_current' => 1, - 'weekly_streak_best' => 1, - 'last_weekly_streak' => $playTime->subWeeks(1), - 'last_update' => $playTime->subDays(1), - ]); - DailyChallengeUserStats::calculate($playTime); - - $stats = DailyChallengeUserStats::find($user->getKey()); - $this->assertSame(2, $stats->weekly_streak_current); - $this->assertSame(2, $stats->weekly_streak_best); - $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); - } - public function testCalculateNoPlaysBreaksDailyStreak(): void { $playTime = static::startOfWeek(); @@ -231,4 +145,130 @@ public function testCalculatePercentile(): void $this->assertSame($count50p, $stats->top_50p_placements, "i: {$i}"); } } + + public function testFlowFromStart(): void + { + $playTime = static::startOfWeek(); + $playlistItem = static::preparePlaylistItem($playTime); + $user = User::factory()->create(); + + $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 1); + + $this->roomAddPlay($user, $playlistItem, ['passed' => true]); + + $stats = DailyChallengeUserStats::find($user->getKey()); + $this->assertSame(1, $stats->playcount); + $this->assertSame(1, $stats->daily_streak_current); + $this->assertSame(1, $stats->daily_streak_best); + $this->assertSame(1, $stats->weekly_streak_current); + $this->assertSame(1, $stats->weekly_streak_best); + $this->assertSame(0, $stats->top_10p_placements); + $this->assertSame(0, $stats->top_50p_placements); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + $this->assertTrue($playTime->equalTo($stats->last_update)); + + // increments percentile and nothing else + DailyChallengeUserStats::calculate($playTime); + + $stats->refresh(); + $this->assertSame(1, $stats->playcount); + $this->assertSame(1, $stats->daily_streak_current); + $this->assertSame(1, $stats->daily_streak_best); + $this->assertSame(1, $stats->weekly_streak_current); + $this->assertSame(1, $stats->weekly_streak_best); + $this->assertSame(1, $stats->top_10p_placements); + $this->assertSame(1, $stats->top_50p_placements); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + $this->assertTrue($playTime->equalTo($stats->last_update)); + } + + public function testFlowMultipleTimes(): void + { + $playTime = static::startOfWeek(); + $playlistItem = static::preparePlaylistItem($playTime); + $user = User::factory()->create(); + + $this->roomAddPlay($user, $playlistItem, ['passed' => true]); + $this->roomAddPlay($user, $playlistItem, ['passed' => true]); + + DailyChallengeUserStats::calculate($playTime); + DailyChallengeUserStats::calculate($playTime); + + $stats = DailyChallengeUserStats::find($user->getKey()); + $this->assertSame(1, $stats->playcount); + } + + public function testFlowIncrementAll(): void + { + $playTime = static::startOfWeek(); + $playlistItem = static::preparePlaylistItem($playTime); + $user = User::factory()->create(); + + DailyChallengeUserStats::create([ + 'user_id' => $user->getKey(), + 'playcount' => 1, + 'daily_streak_current' => 1, + 'daily_streak_best' => 1, + 'weekly_streak_current' => 1, + 'weekly_streak_best' => 1, + 'top_10p_placements' => 1, + 'top_50p_placements' => 1, + 'last_weekly_streak' => $playTime->subWeeks(1), + 'last_update' => $playTime->subDays(1), + ]); + + $this->expectCountChange(fn () => DailyChallengeUserStats::count(), 0); + + $this->roomAddPlay($user, $playlistItem, ['passed' => true]); + $stats = DailyChallengeUserStats::find($user->getKey()); + $this->assertSame(2, $stats->daily_streak_current); + $this->assertSame(2, $stats->daily_streak_best); + $this->assertSame(2, $stats->weekly_streak_current); + $this->assertSame(2, $stats->weekly_streak_best); + $this->assertSame(1, $stats->top_10p_placements); + $this->assertSame(1, $stats->top_50p_placements); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + $this->assertTrue($playTime->equalTo($stats->last_update)); + + DailyChallengeUserStats::calculate($playTime); + + $stats->refresh(); + $this->assertSame(2, $stats->daily_streak_current); + $this->assertSame(2, $stats->daily_streak_best); + $this->assertSame(2, $stats->weekly_streak_current); + $this->assertSame(2, $stats->weekly_streak_best); + $this->assertSame(2, $stats->top_10p_placements); + $this->assertSame(2, $stats->top_50p_placements); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + $this->assertTrue($playTime->equalTo($stats->last_update)); + } + + public function testFlowIncrementWeeklyStreak(): void + { + $playTime = static::startOfWeek(); + $playlistItem = static::preparePlaylistItem($playTime); + $user = User::factory()->create(); + + DailyChallengeUserStats::create([ + 'user_id' => $user->getKey(), + 'weekly_streak_current' => 1, + 'weekly_streak_best' => 1, + 'last_weekly_streak' => $playTime->subWeeks(1), + 'last_update' => $playTime->subDays(1), + ]); + + $this->roomAddPlay($user, $playlistItem, ['passed' => true]); + + $stats = DailyChallengeUserStats::find($user->getKey()); + $this->assertSame(2, $stats->weekly_streak_current); + $this->assertSame(2, $stats->weekly_streak_best); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + + DailyChallengeUserStats::calculate($playTime); + + $stats->refresh(); + $this->assertSame(2, $stats->weekly_streak_current); + $this->assertSame(2, $stats->weekly_streak_best); + $this->assertTrue($playTime->equalTo($stats->last_weekly_streak)); + } } diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 3aa4bf177fe..710e5d52399 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -9,7 +9,6 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\Room; -use App\Models\Multiplayer\ScoreLink; use App\Models\Multiplayer\UserScoreAggregate; use App\Models\User; use App\Transformers\Multiplayer\UserScoreAggregateTransformer; @@ -25,7 +24,7 @@ public function testAddingHigherScore(): void $playlistItem = $this->createPlaylistItem(); // first play - $scoreLink = $this->addPlay($user, $playlistItem, [ + $scoreLink = $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -38,7 +37,7 @@ public function testAddingHigherScore(): void $this->assertSame($scoreLink->getKey(), $agg->last_score_id); // second, higher score play - $scoreLink2 = $this->addPlay($user, $playlistItem, [ + $scoreLink2 = $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 1, 'passed' => true, 'total_score' => 100, @@ -57,7 +56,7 @@ public function testAddingLowerScore(): void $playlistItem = $this->createPlaylistItem(); // first play - $scoreLink = $this->addPlay($user, $playlistItem, [ + $scoreLink = $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -70,7 +69,7 @@ public function testAddingLowerScore(): void $this->assertSame($scoreLink->getKey(), $agg->last_score_id); // second, lower score play - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 1, 'passed' => true, 'total_score' => 1, @@ -90,7 +89,7 @@ public function testAddingEqualScore(): void $playlistItem = $this->createPlaylistItem(); // first user sets play - $firstUserPlay = $this->addPlay($firstUser, $playlistItem, [ + $firstUserPlay = $this->roomAddPlay($firstUser, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -104,7 +103,7 @@ public function testAddingEqualScore(): void $this->assertSame($firstUserPlay->getKey(), $firstUserAgg->last_score_id); // second user sets play with same total, so they get second place due to being late - $secondUserPlay = $this->addPlay($secondUser, $playlistItem, [ + $secondUserPlay = $this->roomAddPlay($secondUser, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -118,7 +117,7 @@ public function testAddingEqualScore(): void $this->assertSame($secondUserPlay->getKey(), $secondUserAgg->last_score_id); // first user sets play with same total again, but their rank should not move now - $this->addPlay($firstUser, $playlistItem, [ + $this->roomAddPlay($firstUser, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -142,7 +141,7 @@ public function testAddingMultiplePlaylistItems(): void $playlistItem2 = $this->createPlaylistItem(); // first playlist item - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 10, @@ -155,7 +154,7 @@ public function testAddingMultiplePlaylistItems(): void $this->assertSame(10, $agg->total_score); // second playlist item - $scoreLink = $this->addPlay($user, $playlistItem2, [ + $scoreLink = $this->roomAddPlay($user, $playlistItem2, [ 'accuracy' => 1, 'passed' => true, 'total_score' => 100, @@ -186,14 +185,14 @@ public function testFailedScoresAreAttemptsOnly(): void $user = User::factory()->create(); $playlistItem = $this->createPlaylistItem(); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.1, 'passed' => false, 'total_score' => 10, ]); $playlistItem2 = $this->createPlaylistItem(); - $this->addPlay($user, $playlistItem2, [ + $this->roomAddPlay($user, $playlistItem2, [ 'accuracy' => 1, 'passed' => true, 'total_score' => 1, @@ -212,7 +211,7 @@ public function testPassedScoresIncrementsCompletedCount(): void $user = User::factory()->create(); $playlistItem = $this->createPlaylistItem(); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 1, 'passed' => true, 'total_score' => 1, @@ -230,25 +229,25 @@ public function testPassedScoresAreAveragedInTransformer(): void $playlistItem = $this->createPlaylistItem(); $playlistItem2 = $this->createPlaylistItem(); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.1, 'passed' => false, 'total_score' => 1, ]); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.3, 'passed' => false, 'total_score' => 1, ]); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.5, 'passed' => true, 'total_score' => 1, ]); - $this->addPlay($user, $playlistItem2, [ + $this->roomAddPlay($user, $playlistItem2, [ 'accuracy' => 0.8, 'passed' => true, 'total_score' => 1, @@ -265,7 +264,7 @@ public function testRecalculate(): void { $playlistItem = $this->createPlaylistItem(); $user = User::factory()->create(); - $this->addPlay($user, $playlistItem, [ + $this->roomAddPlay($user, $playlistItem, [ 'accuracy' => 0.3, 'passed' => true, 'total_score' => 1, @@ -287,22 +286,6 @@ protected function setUp(): void $this->room = Room::factory()->create(); } - private function addPlay(User $user, PlaylistItem $playlistItem, array $params): ScoreLink - { - return $playlistItem->room->completePlay( - $playlistItem->room->startPlay($user, $playlistItem, 0), - [ - 'beatmap_id' => $playlistItem->beatmap_id, - 'ended_at' => json_time(new \DateTime()), - 'max_combo' => 1, - 'statistics' => ['good' => 1], - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $user->getKey(), - ...$params, - ], - ); - } - private function createPlaylistItem(): PlaylistItem { return PlaylistItem::factory()->create([ diff --git a/tests/TestCase.php b/tests/TestCase.php index f09c8945915..5cb5d5d2de1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,6 +13,8 @@ use App\Libraries\Session\Store as SessionStore; use App\Models\Beatmapset; use App\Models\Build; +use App\Models\Multiplayer\PlaylistItem; +use App\Models\Multiplayer\ScoreLink; use App\Models\OAuth\Client; use App\Models\User; use Artisan; @@ -32,6 +34,32 @@ class TestCase extends BaseTestCase { use ArraySubsetAsserts, CreatesApplication, DatabaseTransactions; + protected $connectionsToTransact = [ + 'mysql', + 'mysql-chat', + 'mysql-mp', + 'mysql-store', + 'mysql-updates', + ]; + + protected array $expectedCountsCallbacks = []; + + public static function regularOAuthScopesDataProvider() + { + $data = []; + + foreach (Passport::scopes()->pluck('id') as $scope) { + // just skip over any scopes that require special conditions for now. + if (in_array($scope, ['chat.read', 'chat.write', 'chat.write_manage', 'delegate'], true)) { + continue; + } + + $data[] = [$scope]; + } + + return $data; + } + public static function withDbAccess(callable $callback): void { $db = static::createApp()->make('db'); @@ -79,30 +107,22 @@ protected static function resetAppDb(DatabaseManager $database): void } } - protected $connectionsToTransact = [ - 'mysql', - 'mysql-chat', - 'mysql-mp', - 'mysql-store', - 'mysql-updates', - ]; - - protected array $expectedCountsCallbacks = []; - - public static function regularOAuthScopesDataProvider() + protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, array $scoreParams): ScoreLink { - $data = []; - - foreach (Passport::scopes()->pluck('id') as $scope) { - // just skip over any scopes that require special conditions for now. - if (in_array($scope, ['chat.read', 'chat.write', 'chat.write_manage', 'delegate'], true)) { - continue; - } - - $data[] = [$scope]; - } - - return $data; + return $playlistItem->room->completePlay( + $playlistItem->room->startPlay($user, $playlistItem, 0), + [ + 'accuracy' => 0.5, + 'beatmap_id' => $playlistItem->beatmap_id, + 'ended_at' => json_time(new \DateTime()), + 'max_combo' => 1, + 'ruleset_id' => $playlistItem->ruleset_id, + 'statistics' => ['good' => 1], + 'total_score' => 10, + 'user_id' => $user->getKey(), + ...$scoreParams, + ], + ); } protected function setUp(): void