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', []);
+ }
+}