diff --git a/.gitignore b/.gitignore index ac93624bd8b..ac1520534e3 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ composer-installer /database/ip2asn/*.idx /database/ip2asn/*.tsv + +# Closed-source components +/hush-hush-medals/ diff --git a/app/Libraries/MedalUnlockHelpers.php b/app/Libraries/MedalUnlockHelpers.php new file mode 100644 index 00000000000..8128eda0816 --- /dev/null +++ b/app/Libraries/MedalUnlockHelpers.php @@ -0,0 +1,89 @@ +. 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 []; + }); + } +} diff --git a/app/Listeners/MedalUnlocks/MedalUnlock.php b/app/Listeners/MedalUnlocks/MedalUnlock.php new file mode 100644 index 00000000000..e8974dbf97c --- /dev/null +++ b/app/Listeners/MedalUnlocks/MedalUnlock.php @@ -0,0 +1,126 @@ +. 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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8b9a59b0335..2a099e56d6d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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; @@ -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'); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index b534b8a3dcd..0123fdf48b5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; +use App\Libraries\MedalUnlockHelpers; use App\Listeners; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -23,4 +24,9 @@ class EventServiceProvider extends ServiceProvider Listeners\Fulfillments\PaymentSubscribers::class, Listeners\Fulfillments\ValidationSubscribers::class, ]; + + protected function discoveredEvents(): array + { + return MedalUnlockHelpers::discoverMedalUnlocks(); + } } diff --git a/composer.json b/composer.json index 092e8687b68..5e728be29d2 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/phpcs.xml b/phpcs.xml index c282efb7c27..b69b6377f28 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -59,6 +59,8 @@ + + diff --git a/phpunit.xml b/phpunit.xml index 18724d60148..38285deecc8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,12 +3,16 @@ xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" + defaultTestSuite="app" > - + ./tests/ ./tests/Browser + + ./hush-hush-medals/tests/ + diff --git a/tests/MedalUnlocks/MedalUnlockTestCase.php b/tests/MedalUnlocks/MedalUnlockTestCase.php new file mode 100644 index 00000000000..dc2e781961c --- /dev/null +++ b/tests/MedalUnlocks/MedalUnlockTestCase.php @@ -0,0 +1,129 @@ +. 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 Tests\MedalUnlocks; + +use App\Models\Achievement; +use App\Models\Beatmap; +use App\Models\User; +use Illuminate\Events\CallQueuedListener; +use Illuminate\Queue\QueueManager; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Testing\Fakes\QueueFake; +use Tests\TestCase; + +abstract class MedalUnlockTestCase extends TestCase +{ + /** + * The medal to test. + */ + protected Achievement $medal; + + /** + * The user that may unlock the medal. + */ + protected User $user; + + private QueueFake $queueFake; + private QueueManager $queueReal; + + /** + * Get the medal unlock class to test. + */ + abstract protected static function getMedalUnlockClass(): string; + + /** + * Assert that the user has unlocked the medal. + */ + protected function assertMedalUnlocked(bool $unlocked = true): void + { + $this->handleQueuedMedalUnlocks(); + + $this->assertSame( + $unlocked, + $this->user + ->userAchievements() + ->where('achievement_id', $this->medal->getKey()) + ->exists(), + ); + } + + /** + * Assert that the user has unlocked the medal, and that the unlock is + * associated with the given beatmap. + */ + protected function assertMedalUnlockedWithBeatmap(?Beatmap $beatmap): void + { + $this->handleQueuedMedalUnlocks(); + + $medal = $this->user + ->userAchievements() + ->where('achievement_id', $this->medal->getKey()) + ->first(); + + $this->assertNotNull($medal); + $this->assertSame($beatmap?->getKey(), $medal->beatmap_id); + } + + /** + * Assert that the medal unlock has been queued for handling. + */ + protected function assertMedalUnlockQueued(bool $queued = true): void + { + $method = $queued ? 'assertPushed' : 'assertNotPushed'; + + Queue::$method(CallQueuedListener::class, function (CallQueuedListener $job) { + return $job->class === static::getMedalUnlockClass(); + }); + } + + /** + * Reset the user's medal unlock. + */ + protected function resetMedalProgress(): void + { + $this->invokeSetProperty(app('queue'), 'jobs', []); + + $this->user + ->userAchievements() + ->where('achievement_id', $this->medal->getKey()) + ->delete(); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->queueReal = Queue::getFacadeRoot(); + $this->queueFake = Queue::fake(); + + $this->medal = Achievement::factory()->create([ + 'slug' => static::getMedalUnlockClass()::getMedalSlug(), + ]); + $this->user = User::factory()->create(); + } + + private function handleQueuedMedalUnlocks(): void + { + $listenerJobs = Queue::pushedJobs()[CallQueuedListener::class] ?? []; + + if (empty($listenerJobs)) { + return; + } + + Queue::swap($this->queueReal); + + foreach ($listenerJobs as $job) { + if ($job['job']->class === static::getMedalUnlockClass()) { + dispatch_sync($job['job']); + } + } + + Queue::swap($this->queueFake); + $this->invokeSetProperty(app('queue'), 'jobs', []); + } +}