Skip to content

Commit

Permalink
Merge pull request #11768 from venix12/seasons-score-calculation
Browse files Browse the repository at this point in the history
Add user season score calculation workflow
  • Loading branch information
nanaya authored Jan 28, 2025
2 parents 242079c + c60df78 commit f4646be
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 6 deletions.
63 changes: 63 additions & 0 deletions app/Console/Commands/UserSeasonScoresRecalculate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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();
}
}
26 changes: 22 additions & 4 deletions app/Models/Multiplayer/Room.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
});
}
Expand Down
7 changes: 7 additions & 0 deletions app/Models/Season.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@
* @property bool $finalised
* @property string $name
* @property-read Collection<Multiplayer\Room> $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') {
Expand Down
3 changes: 1 addition & 2 deletions app/Models/SeasonRoom.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
* @property-read Collection<Score\Mania> $scoresMania
* @property-read Collection<Score\Osu> $scoresOsu
* @property-read Collection<Score\Taiko> $scoresTaiko
* @property-read Collection<UserSeasonScoreAggregate> $seasonScores
* @property-read UserStatistics\Fruits|null $statisticsFruits
* @property-read UserStatistics\Mania|null $statisticsMania
* @property-read UserStatistics\Mania4k|null $statisticsMania4k
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 69 additions & 0 deletions app/Models/UserSeasonScoreAggregate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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);
}
}
25 changes: 25 additions & 0 deletions database/factories/SeasonRoomFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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']);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. 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');
});
}
};
Loading

0 comments on commit f4646be

Please sign in to comment.