Skip to content

Commit

Permalink
Use solo scores index for beatmap pack completion check
Browse files Browse the repository at this point in the history
  • Loading branch information
nanaya committed Jun 21, 2023
1 parent 5aba3d3 commit 3c07b4c
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 89 deletions.
3 changes: 3 additions & 0 deletions app/Libraries/Search/ScoreSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public function getQuery(): BoolQuery
{
$query = new BoolQuery();

if ($this->params->excludeConverts) {
$query->filter(['term' => ['convert' => false]]);
}
if (config('osu.scores.es_enable_legacy_filter') && $this->params->isLegacy !== null) {
$query->filter(['term' => ['is_legacy' => $this->params->isLegacy]]);
}
Expand Down
2 changes: 2 additions & 0 deletions app/Libraries/Search/ScoreSearchParams.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public static function fromArray(array $rawParams): static
{
$params = new static();
$params->beatmapIds = $rawParams['beatmap_ids'] ?? null;
$params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts;
$params->excludeMods = $rawParams['exclude_mods'] ?? null;
$params->isLegacy = $rawParams['is_legacy'] ?? null;
$params->mods = $rawParams['mods'] ?? null;
Expand All @@ -42,6 +43,7 @@ public static function fromArray(array $rawParams): static
public ?array $beatmapIds = null;
public ?Score $beforeScore = null;
public ?int $beforeTotalScore = null;
public bool $excludeConverts = false;
public ?array $excludeMods = null;
public ?bool $isLegacy = null;
public ?array $mods = null;
Expand Down
108 changes: 50 additions & 58 deletions app/Models/BeatmapPack.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

namespace App\Models;

use Exception;
use App\Libraries\Search\ScoreSearch;
use App\Libraries\Search\ScoreSearchParams;
use Ds\Set;

/**
* @property string $author
Expand Down Expand Up @@ -86,65 +88,55 @@ public function userCompletionData($user)
{
if ($user !== null) {
$userId = $user->getKey();
$beatmapsetIds = $this->items()->pluck('beatmapset_id')->all();
$query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds);

if ($this->playmode === null) {
static $scoreRelations;

// generate list of beatmap->score relation names for each modes
// store int mode as well as it'll be used for filtering the scores
if (!isset($scoreRelations)) {
$scoreRelations = [];
foreach (Beatmap::MODES as $modeStr => $modeInt) {
$scoreRelations[] = [
'playmode' => $modeInt,
'relation' => camel_case("scores_best_{$modeStr}"),
];
}
}

// outer where function
// The idea is SELECT ... WHERE ... AND (<has osu scores> OR <has taiko scores> OR ...).
$query->where(function ($q) use ($scoreRelations, $userId) {
foreach ($scoreRelations as $scoreRelation) {
// The <has <mode> scores> mentioned above is generated here.
// As it's "playmode = <mode> AND EXISTS (<<mode> score for user>)",
// wrap them so it's not flat "playmode = <mode> AND EXISTS ... OR playmode = <mode> AND EXISTS ...".
$q->orWhere(function ($qq) use ($scoreRelation, $userId) {
$qq
// this playmode filter ensures the scores are limited to non-convert maps
->where('playmode', '=', $scoreRelation['playmode'])
->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) {
$scoreQuery->where('user_id', '=', $userId);

if ($this->no_diff_reduction) {
$scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray());
}
});
});
}
});
} else {
$modeStr = Beatmap::modeStr($this->playmode);

if ($modeStr === null) {
throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}");
}

$scoreRelation = camel_case("scores_best_{$modeStr}");

$query->whereHas($scoreRelation, function ($query) use ($userId) {
$query->where('user_id', '=', $userId);

if ($this->no_diff_reduction) {
$query->withoutMods(app('mods')->difficultyReductionIds->toArray());
}
});

$beatmaps = Beatmap
::whereIn('beatmapset_id', $this->items()->select('beatmapset_id'))
->select(['beatmap_id', 'beatmapset_id', 'playmode'])
->get();
$beatmapsetIdsByBeatmapId = [];
foreach ($beatmaps as $beatmap) {
$beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id;
}
$params = [
'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId),
'exclude_converts' => $this->playmode === null,
'is_legacy' => true,
'limit' => 0,
'ruleset_id' => $this->playmode,
'user_id' => $userId,
];
if ($this->no_diff_reduction) {
$params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray();
}

$completedBeatmapsetIds = $query->pluck('beatmapset_id')->all();
$completed = count($completedBeatmapsetIds) === count($beatmapsetIds);
static $aggName = 'by_beatmap';

$search = new ScoreSearch(ScoreSearchParams::fromArray($params));
$search->size(0);
$search->setAggregations([$aggName => [
'terms' => [
'field' => 'beatmap_id',
'size' => max(1, count($params['beatmap_ids'])),
],
'aggs' => [
'scores' => [
'top_hits' => [
'size' => 1,
],
],
],
]]);
$response = $search->response();
$search->assertNoError();
$completedBeatmapIds = array_map(
fn (array $hit): int => (int) $hit['key'],
$response->aggregations($aggName)['buckets'],
);
$completedBeatmapsetIds = (new Set(array_map(
fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId],
$completedBeatmapIds,
)))->toArray();
$completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId));
}

return [
Expand Down
105 changes: 74 additions & 31 deletions tests/Models/BeatmapPackUserCompletionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,96 @@

namespace Tests\Models;

use App\Libraries\Search\ScoreSearch;
use App\Models\Beatmap;
use App\Models\BeatmapPack;
use App\Models\Score\Best as ScoreBest;
use App\Models\BeatmapPackItem;
use App\Models\Beatmapset;
use App\Models\Country;
use App\Models\Genre;
use App\Models\Group;
use App\Models\Language;
use App\Models\Solo\Score;
use App\Models\User;
use App\Models\UserGroup;
use App\Models\UserGroupEvent;
use Tests\TestCase;

/**
* @group EsSoloScores
*/
class BeatmapPackUserCompletionTest extends TestCase
{
/**
* @dataProvider dataProviderForTestBasic
*/
public function testBasic(string $userType, ?string $packRuleset, bool $completed): void
private static array $users;
private static BeatmapPack $pack;

public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();

(new static())->refreshApplication();
$beatmap = Beatmap::factory()->ranked()->state([
'playmode' => Beatmap::MODES['taiko'],
])->create();
$pack = BeatmapPack::factory()->create();
$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]);

$scoreUser = User::factory()->create();
$scoreClass = ScoreBest\Taiko::class;
switch ($userType) {
case 'convertOsu':
$checkUser = $scoreUser;
$scoreClass = ScoreBest\Osu::class;
break;
case 'default':
$checkUser = $scoreUser;
break;
case 'null':
$checkUser = null;
break;
case 'unrelated':
$checkUser = User::factory()->create();
break;
}

$scoreClass::factory()->create([
static::$pack = BeatmapPack::factory()->create();
static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]);

static::$users = [
'convertOsu' => User::factory()->create(),
'default' => User::factory()->create(),
'null' => null,
'unrelated' => User::factory()->create(),
];

Score::factory()->create([
'beatmap_id' => $beatmap,
'user_id' => $scoreUser->getKey(),
'ruleset_id' => Beatmap::MODES['osu'],
'preserve' => true,
'user_id' => static::$users['convertOsu'],
]);
Score::factory()->create([
'beatmap_id' => $beatmap,
'preserve' => true,
'user_id' => static::$users['default'],
]);

static::reindexScores();
}

public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();

(new static())->refreshApplication();
Beatmap::truncate();
BeatmapPack::truncate();
BeatmapPackItem::truncate();
Beatmapset::truncate();
Country::truncate();
Genre::truncate();
Group::truncate();
Language::truncate();
Score::truncate();
User::truncate();
UserGroup::truncate();
UserGroupEvent::truncate();
(new ScoreSearch())->deleteAll();
}

protected $connectionsToTransact = [];

/**
* @dataProvider dataProviderForTestBasic
*/
public function testBasic(string $userType, ?string $packRuleset, bool $completed): void
{
$user = static::$users[$userType];

$rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset];
$pack->update(['playmode' => $rulesetId]);
$pack->refresh();
static::$pack->update(['playmode' => $rulesetId]);
static::$pack->refresh();

$data = $pack->userCompletionData($checkUser);
$data = static::$pack->userCompletionData($user);
$this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids']));
$this->assertSame($completed, $data['completed']);
}
Expand Down

0 comments on commit 3c07b4c

Please sign in to comment.