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 medal unlock components #9611

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,6 @@ composer-installer

/database/ip2asn/*.idx
/database/ip2asn/*.tsv

# Closed-source components
/hush-hush-medals/
89 changes: 89 additions & 0 deletions app/Libraries/MedalUnlockHelpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?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\Libraries;

use App\Listeners\MedalUnlocks\MedalUnlock;
use Illuminate\Support\Facades\Queue;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionUnionType;
use Symfony\Component\Finder\Finder;

class MedalUnlockHelpers
{
public static function discoverMedalUnlocks(): array
{
$dirs = array_filter([
app_path('Listeners/MedalUnlocks'),
base_path('hush-hush-medals/src'),
], 'is_dir');
$files = (new Finder())->files()->name('*.php')->in($dirs);

$discoveredEvents = [];

foreach ($files as $file) {
$medalUnlockClass = substr($file->getRealPath(), 0, -4); // Remove ".php"
$medalUnlockClass = str_replace_first(app_path(), 'App', $medalUnlockClass);
$medalUnlockClass = str_replace_first(
base_path('hush-hush-medals'.DIRECTORY_SEPARATOR.'src'),
'App\\Listeners\\MedalUnlocks\\HushHush',
$medalUnlockClass,
);
$medalUnlockClass = str_replace(DIRECTORY_SEPARATOR, '\\', $medalUnlockClass);

try {
$medalUnlock = new ReflectionClass($medalUnlockClass);
} catch (ReflectionException) {
continue;
}

if (
!$medalUnlock->isInstantiable() ||
!$medalUnlock->isSubclassOf(MedalUnlock::class) ||
!$medalUnlock->hasProperty('event')
) {
continue;
}

$eventPropertyType = $medalUnlock->getProperty('event')->getType();
$eventPropertyTypes = array_filter(
match (true) {
$eventPropertyType instanceof ReflectionNamedType => [$eventPropertyType],
$eventPropertyType instanceof ReflectionUnionType => $eventPropertyType->getTypes(),
default => [],
},
fn (ReflectionNamedType $type) => !$type->isBuiltin(),
);

foreach ($eventPropertyTypes as $type) {
$typeName = $type->getName();
$discoveredEvents[$typeName] ??= [];
$discoveredEvents[$typeName][] = "{$medalUnlock->name}@handle";
}
}

return $discoveredEvents;
}

public static function registerQueueCreatePayloadHook(): void
{
Queue::createPayloadUsing(function ($connection, $queue, array $payload) {
$medalUnlock = $payload['displayName'];

if (str_starts_with($medalUnlock, get_class_namespace(MedalUnlock::class))) {
return ['data' => array_merge(
$payload['data'],
['state' => $medalUnlock::getQueueableState()],
)];
}

return [];
});
}
}
126 changes: 126 additions & 0 deletions app/Listeners/MedalUnlocks/MedalUnlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?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\Listeners\MedalUnlocks;

use App\Models\Achievement;
use App\Models\Beatmap;
use App\Models\User;
use App\Models\UserAchievement;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

/**
* Listener for the unlock conditions of a medal.
*
* In subclasses of this type, declare an `$event` property with the types of
* events the unlock should listen for.
*/
abstract class MedalUnlock implements ShouldQueue
{
use InteractsWithQueue;

public bool $afterCommit = true;

/**
* State recorded at queue time.
*/
protected mixed $state;

/**
* Get the medal's slug.
*/
abstract public static function getMedalSlug(): string;

/**
* Get additional state at queue time that should be made available at
* `$this->state` when handling.
*/
public static function getQueueableState(): mixed
{
return null;
}

private static function getMedal(): ?Achievement
{
return app('medals')->bySlug(static::getMedalSlug());
}

/**
* Get the users that may be able to unlock the medal.
*/
abstract protected function getApplicableUsers(): Collection|User|array;

/**
* Test whether this unlock should be queued for handling.
*/
abstract protected function shouldHandle(): bool;

/**
* Test whether the given user meets the unlock conditions for the medal.
*
* This is also an appropriate time to store tracking information about
* the user's progress on the medal unlock, if necessary.
*/
abstract protected function shouldUnlockForUser(User $user): bool;

final public function handle(object $event): void
{
if (($medal = static::getMedal()) === null) {
return;
}

$this->event = $event;
$this->state = $this->job->payload()['data']['state'];

$users = Collection::wrap($this->getApplicableUsers())
->unique('user_id')
->filter(
fn (User $user) =>
$user
->userAchievements()
->where('achievement_id', $medal->getKey())
->doesntExist() &&
$this->shouldUnlockForUser($user),
);

if ($users->isEmpty()) {
return;
}

DB::transaction(function () use ($medal, $users) {
foreach ($users as $user) {
UserAchievement::unlock(
$user,
$medal,
$this->getBeatmapForUser($user),
);
}
});
}

final public function shouldQueue(object $event): bool
{
if (static::getMedal() === null) {
return false;
}

$this->event = $event;

return $this->shouldHandle();
}

/**
* Get the beatmap associated with this medal unlock for the given user.
*/
protected function getBeatmapForUser(User $user): ?Beatmap
{
return null;
}
}
3 changes: 3 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Libraries\LayoutCache;
use App\Libraries\LocalCacheManager;
use App\Libraries\Medals;
use App\Libraries\MedalUnlockHelpers;
use App\Libraries\Mods;
use App\Libraries\MorphMap;
use App\Libraries\OsuAuthorize;
Expand Down Expand Up @@ -83,6 +84,8 @@ public function boot()
);
});

MedalUnlockHelpers::registerQueueCreatePayloadHook();

$this->app->make('translator')->setSelector(new OsuMessageSelector());

app('url')->forceScheme(substr(config('app.url'), 0, 5) === 'https' ? 'https' : 'http');
Expand Down
6 changes: 6 additions & 0 deletions app/Providers/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

namespace App\Providers;

use App\Libraries\MedalUnlockHelpers;
use App\Listeners;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

Expand All @@ -23,4 +24,9 @@ class EventServiceProvider extends ServiceProvider
Listeners\Fulfillments\PaymentSubscribers::class,
Listeners\Fulfillments\ValidationSubscribers::class,
];

protected function discoveredEvents(): array
{
return MedalUnlockHelpers::discoverMedalUnlocks();
}
}
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@
"autoload": {
"psr-4": {
"App\\": "app/",
"App\\Listeners\\MedalUnlocks\\HushHush\\": "hush-hush-medals/src/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
"Tests\\": "tests/",
"Tests\\MedalUnlocks\\HushHush\\": "hush-hush-medals/tests/"
}
},
"scripts": {
Expand Down
2 changes: 2 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
<element key="app" value="App"/>
<element key="database/factories" value="Database\Factories"/>
<element key="database/seeders" value="Database\Seeders"/>
<element key="hush-hush-medals/src" value="App\Listeners\MedalUnlocks\HushHush"/>
<element key="hush-hush-medals/tests" value="Tests\MedalUnlocks\HushHush"/>
<element key="tests" value="Tests"/>
</property>
</properties>
Expand Down
6 changes: 5 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
defaultTestSuite="app"
>
<testsuites>
<testsuite name="Application Test Suite">
<testsuite name="app">
<directory suffix="Test.php">./tests/</directory>
<exclude>./tests/Browser</exclude>
</testsuite>
<testsuite name="hush-hush-medals">
<directory suffix="Test.php">./hush-hush-medals/tests/</directory>
</testsuite>
</testsuites>
<php>
<env name="ALLOW_REGISTRATION" value="true"/>
Expand Down
Loading