diff --git a/app/Console/Commands/UserSeasonScoresRecalculate.php b/app/Console/Commands/UserSeasonScoresRecalculate.php new file mode 100644 index 00000000000..732af09447f --- /dev/null +++ b/app/Console/Commands/UserSeasonScoresRecalculate.php @@ -0,0 +1,63 @@ +. 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); + +namespace App\Console\Commands; + +use App\Models\Multiplayer\UserScoreAggregate; +use App\Models\Season; +use App\Models\User; +use Illuminate\Console\Command; + +class UserSeasonScoresRecalculate extends Command +{ + protected $signature = 'user-season-scores:recalculate {--season-id=}'; + protected $description = 'Recalculate user scores for all active seasons or a specified season.'; + + public function handle(): void + { + $seasonId = $this->option('season-id'); + + if (present($seasonId)) { + $this->recalculate(Season::findOrFail(get_int($seasonId))); + } else { + $activeSeasons = Season::active()->get(); + + foreach ($activeSeasons as $season) { + $this->recalculate($season); + } + } + } + + protected function recalculate(Season $season): void + { + $scoreUserIds = UserScoreAggregate::whereIn('room_id', $season->rooms->pluck('id')) + ->distinct('user_id') + ->pluck('user_id'); + + $bar = $this->output->createProgressBar($scoreUserIds->count()); + + User::whereIn('user_id', $scoreUserIds) + ->chunkById(100, function ($userChunk) use ($bar, $season) { + foreach ($userChunk as $user) { + $seasonScore = $user->seasonScores() + ->where('season_id', $season->getKey()) + ->firstOrNew(); + + $seasonScore->season()->associate($season); + $seasonScore->calculate(false); + if ($seasonScore->total_score > 0) { + $seasonScore->save(); + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(); + } +} diff --git a/app/Models/Multiplayer/Room.php b/app/Models/Multiplayer/Room.php index 15bc27784f5..95347cd19b2 100644 --- a/app/Models/Multiplayer/Room.php +++ b/app/Models/Multiplayer/Room.php @@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -41,7 +42,7 @@ * @property int $participant_count * @property \Illuminate\Database\Eloquent\Collection $playlist PlaylistItem * @property \Illuminate\Database\Eloquent\Collection $scoreLinks ScoreLink - * @property-read Collection<\App\Models\Season> $seasons + * @property-read Season $season * @property \Carbon\Carbon $starts_at * @property \Carbon\Carbon|null $updated_at * @property int $user_id @@ -186,7 +187,7 @@ public static function search(array $rawParams, ?int $maxLimit = null) } if (isset($seasonId)) { - $query->whereRelation('seasons', 'seasons.id', $seasonId); + $query->whereRelation('season', 'season_id', $seasonId); } if (in_array($category, static::CATEGORIES, true)) { @@ -259,9 +260,16 @@ public function scoreLinks() return $this->hasMany(ScoreLink::class); } - public function seasons() + public function season(): HasOneThrough { - return $this->belongsToMany(Season::class, SeasonRoom::class); + return $this->hasOneThrough( + Season::class, + SeasonRoom::class, + 'room_id', + 'id', + 'id', + 'season_id', + ); } public function userHighScores() @@ -458,6 +466,16 @@ public function completePlay(ScoreToken $scoreToken, array $params): ScoreLink $stats->save(); } + if ($this->category === 'spotlight' && $agg->total_score > 0 && $this->season !== null) { + $seasonScore = $user->seasonScores() + ->where('season_id', $this->season->getKey()) + ->firstOrNew(); + + $seasonScore->season()->associate($this->season); + $seasonScore->calculate(); + $seasonScore->save(); + } + return $scoreLink; }); } diff --git a/app/Models/Season.php b/app/Models/Season.php index f805a4f3e38..f3ce08685cf 100644 --- a/app/Models/Season.php +++ b/app/Models/Season.php @@ -15,14 +15,21 @@ * @property bool $finalised * @property string $name * @property-read Collection $rooms + * @property float[]|null $score_factors * @property string|null $url */ class Season extends Model { protected $casts = [ 'finalised' => 'boolean', + 'score_factors' => 'array', ]; + public function scopeActive($query) + { + return $query->where('finalised', false); + } + public static function latestOrId($id) { if ($id === 'latest') { diff --git a/app/Models/SeasonRoom.php b/app/Models/SeasonRoom.php index 86397032a1d..d5990ce6ed5 100644 --- a/app/Models/SeasonRoom.php +++ b/app/Models/SeasonRoom.php @@ -7,9 +7,8 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - /** + * @property string|null $group_indicator * @property int $id * @property int $room_id * @property int $season_id diff --git a/app/Models/User.php b/app/Models/User.php index cf2aed74d1f..5e6e4072eb9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -119,6 +119,7 @@ * @property-read Collection $scoresMania * @property-read Collection $scoresOsu * @property-read Collection $scoresTaiko + * @property-read Collection $seasonScores * @property-read UserStatistics\Fruits|null $statisticsFruits * @property-read UserStatistics\Mania|null $statisticsMania * @property-read UserStatistics\Mania4k|null $statisticsMania4k @@ -1359,6 +1360,11 @@ public function country() return $this->belongsTo(Country::class, 'country_acronym'); } + public function seasonScores(): HasMany + { + return $this->hasMany(UserSeasonScoreAggregate::class); + } + public function statisticsOsu() { return $this->hasOne(UserStatistics\Osu::class); diff --git a/app/Models/UserSeasonScoreAggregate.php b/app/Models/UserSeasonScoreAggregate.php new file mode 100644 index 00000000000..6bb43a890a3 --- /dev/null +++ b/app/Models/UserSeasonScoreAggregate.php @@ -0,0 +1,69 @@ +. 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); + +namespace App\Models; + +use App\Exceptions\InvariantException; +use App\Models\Multiplayer\UserScoreAggregate; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property-read Season $season + * @property int $season_id + * @property float $total_score + * @property int $user_id + */ +class UserSeasonScoreAggregate extends Model +{ + public $incrementing = false; + public $timestamps = false; + + protected $primaryKey = ':composite'; + protected $primaryKeys = ['user_id', 'season_id']; + + public function calculate(bool $muteExceptions = true): void + { + $seasonRooms = SeasonRoom::where('season_id', $this->season->getKey())->get(); + $userScores = UserScoreAggregate::whereIn('room_id', $seasonRooms->pluck('room_id')) + ->where('user_id', $this->user_id) + ->get(); + + $factors = $this->season->score_factors ?? []; + $roomGroupCount = $seasonRooms->groupBy('group_indicator')->count(); + + if ($roomGroupCount > count($factors)) { + // don't interrupt Room::completePlay() and throw exception only for recalculation command + if ($muteExceptions) { + return; + } else { + throw new InvariantException(osu_trans('rankings.seasons.validation.not_enough_factors')); + } + } + + $roomsById = $seasonRooms->keyBy('room_id'); + $scores = []; + foreach ($userScores as $score) { + $group = $roomsById[$score->room_id]->group_indicator; + $scores[$group] = max($scores[$group] ?? 0, $score->total_score); + } + + rsort($factors); + rsort($scores); + + $total = 0; + foreach ($scores as $index => $score) { + $total += $score * $factors[$index]; + } + + $this->total_score = $total; + } + + public function season(): BelongsTo + { + return $this->belongsTo(Season::class); + } +} diff --git a/database/factories/SeasonRoomFactory.php b/database/factories/SeasonRoomFactory.php new file mode 100644 index 00000000000..81a0d81acf8 --- /dev/null +++ b/database/factories/SeasonRoomFactory.php @@ -0,0 +1,25 @@ +. 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); + +namespace Database\Factories; + +use App\Models\Multiplayer\Room; +use App\Models\Season; +use App\Models\SeasonRoom; + +class SeasonRoomFactory extends Factory +{ + protected $model = SeasonRoom::class; + + public function definition(): array + { + return [ + 'room_id' => Room::factory(), + 'season_id' => Season::factory(), + ]; + } +} diff --git a/database/migrations/2025_01_22_134746_add_unique_index_to_room_id_on_season_rooms.php b/database/migrations/2025_01_22_134746_add_unique_index_to_room_id_on_season_rooms.php new file mode 100644 index 00000000000..0421044531d --- /dev/null +++ b/database/migrations/2025_01_22_134746_add_unique_index_to_room_id_on_season_rooms.php @@ -0,0 +1,33 @@ +. 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('season_rooms', function (Blueprint $table) { + $table->unique('room_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('season_rooms', function (Blueprint $table) { + $table->dropUnique(['room_id']); + }); + } +}; diff --git a/database/migrations/2025_01_22_172229_add_score_factors_on_seasons.php b/database/migrations/2025_01_22_172229_add_score_factors_on_seasons.php new file mode 100644 index 00000000000..bab1a0e1031 --- /dev/null +++ b/database/migrations/2025_01_22_172229_add_score_factors_on_seasons.php @@ -0,0 +1,33 @@ +. 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('seasons', function (Blueprint $table) { + $table->json('score_factors')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('seasons', function (Blueprint $table) { + $table->dropColumn('score_factors'); + }); + } +}; diff --git a/database/migrations/2025_01_22_174650_add_group_indicator_on_season_rooms.php b/database/migrations/2025_01_22_174650_add_group_indicator_on_season_rooms.php new file mode 100644 index 00000000000..bdd7b9138c8 --- /dev/null +++ b/database/migrations/2025_01_22_174650_add_group_indicator_on_season_rooms.php @@ -0,0 +1,33 @@ +. 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('season_rooms', function (Blueprint $table) { + $table->string('group_indicator')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('season_rooms', function (Blueprint $table) { + $table->dropColumn('group_indicator'); + }); + } +}; diff --git a/database/migrations/2025_01_22_190453_create_user_season_score_aggregates_table.php b/database/migrations/2025_01_22_190453_create_user_season_score_aggregates_table.php new file mode 100644 index 00000000000..eae232d40fe --- /dev/null +++ b/database/migrations/2025_01_22_190453_create_user_season_score_aggregates_table.php @@ -0,0 +1,36 @@ +. 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::create('user_season_score_aggregates', function (Blueprint $table) { + $table->bigInteger('user_id')->unsigned(); + $table->integer('season_id')->unsigned(); + $table->double('total_score'); + + $table->primary(['user_id', 'season_id']); + $table->index('total_score'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::drop('user_season_score_aggregates'); + } +}; diff --git a/resources/lang/en/rankings.php b/resources/lang/en/rankings.php index e1dc187c861..cdafa9bfd26 100644 --- a/resources/lang/en/rankings.php +++ b/resources/lang/en/rankings.php @@ -49,6 +49,9 @@ 'ongoing' => 'This season is still ongoing (there will be more playlists added).', 'room_count' => 'Playlist count', 'url' => 'Display more informations on that season.', + 'validation' => [ + 'not_enough_factors' => 'there is not enough score factors for proper calculation', + ], ], 'spotlight' => [ diff --git a/tests/Models/UserSeasonScoreAggregateTest.php b/tests/Models/UserSeasonScoreAggregateTest.php new file mode 100644 index 00000000000..510fa13219a --- /dev/null +++ b/tests/Models/UserSeasonScoreAggregateTest.php @@ -0,0 +1,193 @@ +. 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); + +namespace Tests\Models; + +use App\Models\Multiplayer\PlaylistItem; +use App\Models\Multiplayer\Room; +use App\Models\Season; +use App\Models\SeasonRoom; +use App\Models\User; +use App\Models\UserSeasonScoreAggregate; +use Tests\TestCase; + +class UserSeasonScoreAggregateTest extends TestCase +{ + private Season $season; + private User $user; + + public function testAddMultipleScores(): void + { + $this->createRoomWithPlay('A', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); // 10*1 + + $this->createRoomWithPlay('B', 15); + + $userScore->refresh(); + $this->assertSame(22.5, $userScore->total_score); // 15*1 + 10*0.75 + + $this->createRoomWithPlay('C', 25); + + $userScore->refresh(); + $this->assertSame(41.25, $userScore->total_score); // 25*1 + 15*0.75 + 10*0.5 + } + + public function testAddMultipleScoresWithChildrenRooms(): void + { + $this->createRoomWithPlay('A', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); // 10*1 + + $this->createRoomWithPlay('A', 15); + + $userScore->refresh(); + $this->assertSame(15.0, $userScore->total_score); // 15*1 + + $this->createRoomWithPlay('B', 20); + + $userScore->refresh(); + $this->assertSame(31.25, $userScore->total_score); // 20*1 + 15*0.75 + + $this->createRoomWithPlay('B', 20); + + $userScore->refresh(); + $this->assertSame(31.25, $userScore->total_score); // 20*1 + 15*0.75 + + $this->createRoomWithPlay('C', 10); + + $userScore->refresh(); + $this->assertSame(36.25, $userScore->total_score); // 20*1 + 15*0.75 + 10*0.5 + + $this->createRoomWithPlay('C', 30); + + $userScore->refresh(); + $this->assertSame(52.5, $userScore->total_score); // 30*1 + 20*0.75 + 15*0.5 + } + + public function testAddHigherScoreInChildRoom(): void + { + $this->createRoomWithPlay('A', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); + + $this->createRoomWithPlay('A', 15); + + $userScore->refresh(); + $this->assertSame(15.0, $userScore->total_score); + } + + public function testAddHigherScoreInParentRoom(): void + { + $this->createRoomWithPlay('A', 15); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(15.0, $userScore->total_score); + + $this->createRoomWithPlay('A', 10); + + $userScore->refresh(); + $this->assertSame(15.0, $userScore->total_score); + } + + public function testAddSameScoreInChildAndParentRoom(): void + { + $this->createRoomWithPlay('A', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); + + $this->createRoomWithPlay('A', 10); + + $userScore->refresh(); + $this->assertSame(10.0, $userScore->total_score); + } + + public function testAddScoreInChildRoomOnly(): void + { + $this->createRoom('A'); + $this->createRoomWithPlay('A', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); + } + + public function testAddScoreInSecondRoomOnly(): void + { + $this->createRoom('A'); + $this->createRoomWithPlay('B', 10); + + $userScore = UserSeasonScoreAggregate::where('user_id', $this->user->getKey()) + ->where('season_id', $this->season->getKey()) + ->first(); + + $this->assertSame(10.0, $userScore->total_score); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->season = Season::factory()->create([ + 'score_factors' => [1, 0.75, 0.5], + ]); + $this->user = User::factory()->create(); + } + + private function createRoom(string $groupIndicator): Room + { + $room = Room::factory()->create([ + 'category' => 'spotlight', + ]); + + SeasonRoom::factory()->create([ + 'group_indicator' => $groupIndicator, + 'room_id' => $room, + 'season_id' => $this->season, + ]); + + return $room; + } + + private function createRoomWithPlay(string $groupIndicator, float $totalScore): Room + { + $room = $this->createRoom($groupIndicator); + + $playlistItem = PlaylistItem::factory()->create([ + 'owner_id' => $room->host, + 'room_id' => $room, + ]); + + static::roomAddPlay($this->user, $playlistItem, [ + 'passed' => true, + 'total_score' => $totalScore, + ]); + + return $room; + } +}