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 13 commits into
base: master
Choose a base branch
from
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 <contact@ppy.sh>. 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();
$seasonScore->save();

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

$bar->finish();
}
}
14 changes: 14 additions & 0 deletions app/Models/Multiplayer/Room.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* @property int $id
* @property int|null $max_attempts
* @property string $name
* @property int|null $parent_id
* @property int $participant_count
* @property \Illuminate\Database\Eloquent\Collection $playlist PlaylistItem
* @property \Illuminate\Database\Eloquent\Collection $scoreLinks ScoreLink
Expand Down Expand Up @@ -446,6 +447,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
27 changes: 27 additions & 0 deletions app/Models/Season.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Carbon\Carbon;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
* @property bool $finalised
Expand All @@ -23,6 +24,11 @@ class Season extends Model
'finalised' => 'boolean',
];

public function scopeActive($query)
{
return $query->where('finalised', false);
}

public static function latestOrId($id)
{
if ($id === 'latest') {
Expand All @@ -45,6 +51,27 @@ public function endDate(): ?Carbon
: null;
}

public function scoreFactors(): HasMany
{
return $this->hasMany(SeasonScoreFactor::class);
}

public function scoreFactorsOrderedForCalculation(): array
{
return cache_remember_mutexed(
"score_factors:{$this->id}",
$GLOBALS['cfg']['osu']['seasons']['factors_cache_duration'],
[],
function () {
return $this->scoreFactors()
->orderByDesc('factor')
->get()
->pluck('factor')
->toArray();
}
);
}

public function startDate(): ?Carbon
{
return $this->rooms->min('starts_at');
Expand Down
3 changes: 3 additions & 0 deletions app/Models/SeasonRoom.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace App\Models;

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

/**
Expand All @@ -16,5 +17,7 @@
*/
class SeasonRoom extends Model
{
use HasFactory;

public $timestamps = false;
}
22 changes: 22 additions & 0 deletions app/Models/SeasonScoreFactor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 Illuminate\Database\Eloquent\Factories\HasFactory;

/**
* @property int $id
* @property float $factor
* @property int $season_id
*/
class SeasonScoreFactor extends Model
{
use HasFactory;

public $timestamps = false;
}
5 changes: 5 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,11 @@ public function country()
return $this->belongsTo(Country::class, 'country_acronym');
}

public function seasonScores(): HasMany
{
return $this->hasMany(UserSeasonScore::class);
}

public function statisticsOsu()
{
return $this->hasOne(UserStatistics\Osu::class);
Expand Down
79 changes: 79 additions & 0 deletions app/Models/UserSeasonScore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 int $season_id
* @property float $total_score
* @property int $user_id
*/
class UserSeasonScore extends Model
{
public $incrementing = false;
public $timestamps = false;

protected $primaryKey = ':composite';
protected $primaryKeys = ['user_id', 'season_id'];

public function calculate(): void
{
$userScores = UserScoreAggregate::whereIn('room_id', $this->season->rooms->pluck('id'))
->where('user_id', $this->user_id)
->get();

$factors = $this->season->scoreFactorsOrderedForCalculation();
$parentRooms = $this->season->rooms->where('parent_id', null);

if ($parentRooms->count() > count($factors)) {
throw new InvariantException(osu_trans('rankings.seasons.validation.not_enough_factors'));
}

$scores = [];

foreach ($parentRooms as $room) {
$totalScore = $userScores->where('room_id', $room->getKey())
->first()
?->total_score;

$childRoomId = $this->season->rooms
->where('parent_id', $room->getKey())
->first()
?->getKey();

$totalScoreChild = $userScores->where('room_id', $childRoomId)
->first()
?->total_score;

if ($totalScore === null && $totalScoreChild === null) {
continue;
}

$scores[] = max([$totalScore, $totalScoreChild]);
}

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/avatar-guest@2x.png'),
'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 <contact@ppy.sh>. 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 [];
}
}
22 changes: 22 additions & 0 deletions database/factories/SeasonScoreFactorFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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\SeasonScoreFactor;

class SeasonScoreFactorFactory extends Factory
{
protected $model = SeasonScoreFactor::class;

public function definition(): array
{
return [
'factor' => 1,
];
}
}
Loading
Loading