Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user season score calculation workflow #11768

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ CLIENT_CHECK_VERSION=false
# SCORES_SUBMISSION_ENABLED=1
# SCORE_INDEX_MAX_ID_DISTANCE=10_000_000

# SEASONS_FACTORS_CACHE_DURATION=60

# BANCHO_BOT_USER_ID=

# OCTANE_LOCAL_CACHE_EXPIRE_SECOND=60
Expand Down
62 changes: 62 additions & 0 deletions app/Console/Commands/UserSeasonScoresRecalculate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?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'))
->select('user_id')
->get()
->pluck('user_id')
->unique();

$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_id = $season->getKey();
$seasonScore->calculate(false);
$seasonScore->save();

$bar->advance();
}
});

$bar->finish();
}
}
13 changes: 13 additions & 0 deletions app/Models/Multiplayer/Room.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,19 @@ public function completePlay(ScoreToken $scoreToken, array $params): ScoreLink
$stats->save();
}

// spotlight playlists should always be linked to one season exactly
if ($this->category === 'spotlight' && $agg->total_score > 0 && $this->seasons()->count() === 1) {
$seasonId = $this->seasons()->first()->getKey();

$seasonScore = $user->seasonScores()
->where('season_id', $seasonId)
->firstOrNew();

$seasonScore->season_id = $seasonId;
$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
4 changes: 4 additions & 0 deletions app/Models/SeasonRoom.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
* @property string|null $group_indicator
* @property int $id
* @property int $room_id
* @property int $season_id
*/
class SeasonRoom extends Model
{
use HasFactory;

public $timestamps = false;
}
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
79 changes: 79 additions & 0 deletions app/Models/UserSeasonScoreAggregate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?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
{
$rooms = $this->season->rooms()
->withPivot('group_indicator')
->get();

$userScores = UserScoreAggregate::whereIn('room_id', $rooms->pluck('id'))
->where('user_id', $this->user_id)
->get();

$factors = $this->season->score_factors;
$roomsGrouped = $rooms->groupBy('pivot.group_indicator');

if ($roomsGrouped->count() > 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'));
}
}

foreach ($roomsGrouped as $rooms) {
$groupUserScores = $userScores
->whereIn('room_id', $rooms->pluck('id'))
->pluck('total_score');

if ($groupUserScores === null) {
continue;
}

$scores[] = $groupUserScores->max();
}

rsort($factors);
rsort($scores);

$scoreCount = count($scores);
$total = 0;

for ($i = 0; $i < $scoreCount; $i++) {
$total += $scores[$i] * $factors[$i];
}

$this->total_score = $total;
}

public function season(): BelongsTo
{
return $this->belongsTo(Season::class);
}
}
8 changes: 3 additions & 5 deletions config/osu.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
'achievement' => [
'icon_prefix' => env('USER_ACHIEVEMENT_ICON_PREFIX', 'https://assets.ppy.sh/user-achievements/'),
],

'api' => [
// changing the throttle rate doesn't reset any existing timers,
// changing the prefix key is the only way to invalidate them.
Expand All @@ -27,15 +26,13 @@
'scores_download' => env('API_THROTTLE_SCORES_DOWNLOAD', '10,1,api-scores-download'),
],
],

'avatar' => [
'cache_purge_prefix' => env('AVATAR_CACHE_PURGE_PREFIX'),
'cache_purge_method' => env('AVATAR_CACHE_PURGE_METHOD'),
'cache_purge_authorization_key' => env('AVATAR_CACHE_PURGE_AUTHORIZATION_KEY'),
'default' => env('DEFAULT_AVATAR', env('APP_URL', 'http://localhost').'/images/layout/[email protected]'),
'storage' => env('AVATAR_STORAGE', 'local-avatar'),
],

'bbcode' => [
// this should be random or a config variable.
// ...who am I kidding, this shouldn't even exist at all.
Expand Down Expand Up @@ -193,12 +190,13 @@
'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics',
'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true,
],

'seasonal' => [
'contest_id' => get_int(env('SEASONAL_CONTEST_ID')),
'ends_at' => env('SEASONAL_ENDS_AT'),
],

'seasons' => [
'factors_cache_duration' => 60 * (get_float(env('SEASONS_FACTORS_CACHE_DURATION')) ?? 60), // in minutes, converted to seconds
],
'store' => [
'notice' => presence(str_replace('\n', "\n", env('STORE_NOTICE') ?? '')),
],
Expand Down
21 changes: 21 additions & 0 deletions database/factories/SeasonRoomFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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\SeasonRoom;

class SeasonRoomFactory extends Factory
{
protected $model = SeasonRoom::class;

public function definition(): array
{
// pivot table...
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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::create('user_season_score_aggregates', function (Blueprint $table) {
$table->bigInteger('user_id')->unsigned();
$table->integer('season_id')->unsigned();
$table->float('total_score');

$table->primary(['user_id', 'season_id']);
$table->index('total_score');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_season_scores');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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.

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,31 @@
<?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.

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
Loading