diff --git a/app/Api/V2/UserAwards/UserAwardKind.php b/app/Api/V2/UserAwards/UserAwardKind.php index 40e0dc54b7..2796d24fe7 100644 --- a/app/Api/V2/UserAwards/UserAwardKind.php +++ b/app/Api/V2/UserAwards/UserAwardKind.php @@ -19,6 +19,7 @@ enum UserAwardKind: string case Completed = 'completed'; case Event = 'event'; case Mastered = 'mastered'; + case MediaContribution = 'media-contribution'; case PatreonSupporter = 'patreon-supporter'; case Playtest = 'playtest'; @@ -33,6 +34,7 @@ public static function fromAward(PlayerBadge $award): self AwardType::AchievementPointsYield => self::AchievementPointsYield, AwardType::PatreonSupporter => self::PatreonSupporter, AwardType::CertifiedLegend => self::CertifiedLegend, + AwardType::MediaContribution => self::MediaContribution, }; } @@ -53,6 +55,7 @@ public function apply(Builder $query): Builder self::AchievementPointsYield => $query->where('award_type', AwardType::AchievementPointsYield), self::PatreonSupporter => $query->where('award_type', AwardType::PatreonSupporter), self::CertifiedLegend => $query->where('award_type', AwardType::CertifiedLegend), + self::MediaContribution => $query->where('award_type', AwardType::MediaContribution), }; } diff --git a/app/Api/V2/UserAwards/UserAwardPresenter.php b/app/Api/V2/UserAwards/UserAwardPresenter.php index 9ce9eedfdc..84ada56868 100644 --- a/app/Api/V2/UserAwards/UserAwardPresenter.php +++ b/app/Api/V2/UserAwards/UserAwardPresenter.php @@ -35,6 +35,7 @@ public function title(): ?string AwardType::AchievementPointsYield => 'Achievement Points Earned by Others', AwardType::PatreonSupporter => 'Patreon Supporter', AwardType::CertifiedLegend => 'Certified Legend', + AwardType::MediaContribution => 'Media Contribution', }; } @@ -52,6 +53,7 @@ public function badgeUrl(): ?string AwardType::AchievementPointsYield => asset("/assets/images/badge/contribPoints-{$this->award->award_key}.png"), AwardType::PatreonSupporter => asset('/assets/images/badge/patreon.png'), AwardType::CertifiedLegend => asset('/assets/images/badge/legend.png'), + AwardType::MediaContribution => asset("/assets/images/badge/mediaContrib-{$this->award->award_key}.png"), }; } @@ -75,6 +77,10 @@ public function context(): array 'tier' => $this->award->award_key, 'threshold' => PlayerBadge::getBadgeThreshold($this->award->award_type, $this->award->award_key), ], + AwardType::MediaContribution => [ + 'tier' => $this->award->award_key, + 'threshold' => PlayerBadge::getBadgeThreshold($this->award->award_type, $this->award->award_key), + ], AwardType::PatreonSupporter, AwardType::CertifiedLegend => [ 'siteAwardType' => $this->award->award_type->value, ], diff --git a/app/Community/Actions/CreateGameClaimAction.php b/app/Community/Actions/CreateGameClaimAction.php index 704f33d9ed..1a6201642e 100644 --- a/app/Community/Actions/CreateGameClaimAction.php +++ b/app/Community/Actions/CreateGameClaimAction.php @@ -15,6 +15,7 @@ use App\Models\Game; use App\Models\Ticket; use App\Models\User; +use App\Platform\Actions\RevalidateMediaContributionBadgeEligibilityAction; use App\Support\Alerts\ClaimWithUnresolvedTicketsAlert; use App\Support\Cache\CacheKey; use Carbon\Carbon; @@ -67,6 +68,8 @@ public function execute(Game $game, ?User $currentUser = null): AchievementSetCl 'finished_at' => $expiresAt, ]); + (new RevalidateMediaContributionBadgeEligibilityAction())->execute($currentUser); + Cache::forget(CacheKey::buildUserExpiringClaimsCacheKey($currentUser->username)); addArticleComment("Server", CommentableType::SetClaim, $game->id, diff --git a/app/Community/Actions/DropGameClaimAction.php b/app/Community/Actions/DropGameClaimAction.php index b6501fb27f..34907c1d60 100644 --- a/app/Community/Actions/DropGameClaimAction.php +++ b/app/Community/Actions/DropGameClaimAction.php @@ -9,6 +9,7 @@ use App\Community\Enums\CommentableType; use App\Models\AchievementSetClaim; use App\Models\User; +use App\Platform\Actions\RevalidateMediaContributionBadgeEligibilityAction; use App\Support\Cache\CacheKey; use Carbon\Carbon; use GuzzleHttp\Client; @@ -25,6 +26,7 @@ public function execute(AchievementSetClaim $claim, User $actingUser): void $claim->save(); Cache::forget(CacheKey::buildUserExpiringClaimsCacheKey($claim->user->username)); + (new RevalidateMediaContributionBadgeEligibilityAction())->execute($claim->user); // If the primary claim was dropped and there's a collaboration claim, promote it to primary. $firstCollabClaim = ($claim->claim_type === ClaimType::Primary) ? diff --git a/app/Community/Actions/UpdateGameClaimAction.php b/app/Community/Actions/UpdateGameClaimAction.php index 7126de6af8..a51a0c84eb 100644 --- a/app/Community/Actions/UpdateGameClaimAction.php +++ b/app/Community/Actions/UpdateGameClaimAction.php @@ -19,6 +19,7 @@ use App\Notifications\Achievement\SetAchievementsPublishedNotification; use App\Notifications\Achievement\SetRevisionNotification; use App\Platform\Actions\CheckForAchievementSetChangesAction; +use App\Platform\Actions\RevalidateMediaContributionBadgeEligibilityAction; use Carbon\Carbon; use GuzzleHttp\Client; use Illuminate\Support\Facades\Auth; @@ -97,6 +98,10 @@ public function execute(AchievementSetClaim $claim, array $newValues): void if ($claim->isDirty()) { $claim->save(); + if ($claim->wasChanged('status')) { + (new RevalidateMediaContributionBadgeEligibilityAction())->execute($claim->user); + } + addArticleComment("Server", CommentableType::SetClaim, $claim->game_id, $auditMessage); } } diff --git a/app/Community/Enums/AwardType.php b/app/Community/Enums/AwardType.php index 79bea2da11..533de49f58 100644 --- a/app/Community/Enums/AwardType.php +++ b/app/Community/Enums/AwardType.php @@ -27,6 +27,8 @@ enum AwardType: string case Playtest = 'playtest'; + case MediaContribution = 'media_contribution'; + /** * Returns all standard award type cases, excluding Event. * Event is excluded because it's handled specially and shouldn't @@ -41,6 +43,7 @@ public static function standardCases(): array self::PatreonSupporter, self::CertifiedLegend, self::GameBeaten, + self::MediaContribution, ]; } @@ -68,6 +71,7 @@ public function label(): string self::GameBeaten => 'Game Beaten', self::Event => 'Event', self::Playtest => 'Playtest', + self::MediaContribution => 'Media Contribution', }; } @@ -87,6 +91,7 @@ public function toLegacyInteger(): int self::GameBeaten => 8, self::Event => 9, self::Playtest => 10, + self::MediaContribution => 11, }; } @@ -105,6 +110,7 @@ public static function fromLegacyInteger(int $value): self 8 => self::GameBeaten, 9 => self::Event, 10 => self::Playtest, + 11 => self::MediaContribution, default => throw new InvalidArgumentException("Invalid legacy AwardType value: {$value}"), }; } diff --git a/app/Filament/Resources/GameScreenshotModerationResource.php b/app/Filament/Resources/GameScreenshotModerationResource.php index 4afd2ec070..25c5d807ae 100644 --- a/app/Filament/Resources/GameScreenshotModerationResource.php +++ b/app/Filament/Resources/GameScreenshotModerationResource.php @@ -10,6 +10,7 @@ use App\Models\User; use App\Platform\Actions\ApproveGameScreenshotAction; use App\Platform\Actions\RejectGameScreenshotAction; +use App\Platform\Actions\RevalidateMediaContributionBadgeEligibilityAction; use App\Platform\Enums\GameScreenshotRejectionReason; use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; @@ -214,6 +215,9 @@ public static function table(Table $table): Table ->action(function (GameScreenshot $record) use ($user) { try { (new ApproveGameScreenshotAction())->execute($record, $user); + if ($record->capturedBy) { + (new RevalidateMediaContributionBadgeEligibilityAction())->execute($record->capturedBy); + } Notification::make() ->success() diff --git a/app/Helpers/render/site-award.php b/app/Helpers/render/site-award.php index 1ef1788965..50b663f855 100644 --- a/app/Helpers/render/site-award.php +++ b/app/Helpers/render/site-award.php @@ -3,7 +3,9 @@ use App\Community\Enums\AwardType; use App\Models\Event; use App\Models\EventAward; +use App\Models\GameScreenshot; use App\Models\PlayerBadge; +use App\Models\User; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection as SupportCollection; use Illuminate\Support\Str; @@ -352,6 +354,19 @@ function RenderAward( $imagepath = asset('/assets/images/badge/patreon.png'); $imgclass = 'goldimage'; $linkdest = route('patreon-supporter.index'); + } elseif ($awardTypeEnum === AwardType::MediaContribution) { + $description = getMediaContributionDescription($ownerUsername, (int) $awardData); + echo avatar("mediaContributionAward", $awardData, + tooltip: "", + iconUrl: asset("/assets/images/badge/mediaContrib-$awardData.png"), + iconSize: $imageSize, + iconClass: 'goldimage', + context: $ownerUsername, + altText: 'Media Contribution', + hasLink: false, + ); + + return; } elseif ($awardTypeEnum === AwardType::CertifiedLegend) { $tooltip = 'Specially Awarded to a Certified RetroAchievements Legend'; $imagepath = asset('/assets/images/badge/legend.png'); @@ -443,6 +458,8 @@ function RenderAwardOrderTable( $awardTitle = "Achievement Points Earned by Others"; } elseif ($awardTypeEnum === AwardType::PatreonSupporter) { $awardTitle = "Patreon Supporter"; + } elseif ($awardTypeEnum === AwardType::MediaContribution) { + $awardTitle = "Media Contribution"; } elseif ($awardTypeEnum === AwardType::CertifiedLegend) { $awardTitle = "Certified Legend"; } @@ -551,6 +568,33 @@ function getInitialSectionOrders(array $gameAwards, array $eventAwards, array $s ]; } +function getMediaContributionDescription(string $username, int $currentTier): string +{ + $currentThreshold = PlayerBadge::getBadgeThreshold(AwardType::MediaContribution, $currentTier); + $nextThreshold = PlayerBadge::getBadgeThreshold(AwardType::MediaContribution, $currentTier + 1); + + $formattedCurrent = number_format($currentThreshold); + $achievement = "

Awarded for contributing {$formattedCurrent} approved screenshots to game galleries.

"; + + if ($nextThreshold === 0) { + return $achievement; + } + + $user = User::whereName($username)->first(); + $eligibleCount = $user + ? GameScreenshot::query()->eligibleForMediaContributionBy($user)->count() + : 0; + + $remaining = $nextThreshold - $eligibleCount; + if ($remaining <= 0) { + return $achievement; + } + + $formattedRemaining = number_format($remaining); + + return $achievement . "

{$formattedRemaining} more to next tier.

"; +} + function generateManualMoveButtons( int $awardCounter, int $moveValue, diff --git a/app/Models/GameScreenshot.php b/app/Models/GameScreenshot.php index a58db8bc63..2958299e2d 100644 --- a/app/Models/GameScreenshot.php +++ b/app/Models/GameScreenshot.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Community\Enums\ClaimStatus; use App\Platform\Enums\GameScreenshotRejectionReason; use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; @@ -153,6 +154,29 @@ public function scopePrimary(Builder $query): Builder return $query->where('is_primary', true); } + /** + * Grab screenshots that currently count toward a user's media contribution badge. + * + * @param Builder $query + * @return Builder + */ + public function scopeEligibleForMediaContributionBy(Builder $query, User $user): Builder + { + return $query->approved() + ->where('captured_by_user_id', $user->id) + ->whereColumn('captured_by_user_id', '!=', 'reviewed_by_user_id') + ->whereDoesntHave('game.achievements', function (Builder $query) use ($user) { + /** @var Builder $query */ + $query->withTrashed()->where('user_id', $user->id); + }) + ->whereDoesntHave('game.achievementSetClaims', function (Builder $query) use ($user) { + /** @var Builder $query */ + $query + ->where('user_id', $user->id) + ->where('status', '!=', ClaimStatus::Dropped); + }); + } + /** * This is separate, but complementary, to GameScreenshotPolicy * stuff because we actually need to filter a query by these values. diff --git a/app/Models/PlayerBadge.php b/app/Models/PlayerBadge.php index b37c2826cf..83b1b6289d 100644 --- a/app/Models/PlayerBadge.php +++ b/app/Models/PlayerBadge.php @@ -79,11 +79,21 @@ class PlayerBadge extends BaseModel 50_000_000, ]; + private const MEDIA_CONTRIBUTION_BOUNDARIES = [ + 10, + 30, + 100, + 300, + 1000, + 3000, + ]; + private static function getThresholds(AwardType $awardType): ?array { return match ($awardType) { AwardType::AchievementUnlocksYield => self::DEVELOPER_COUNT_BOUNDARIES, AwardType::AchievementPointsYield => self::DEVELOPER_POINT_BOUNDARIES, + AwardType::MediaContribution => self::MEDIA_CONTRIBUTION_BOUNDARIES, default => null, }; } @@ -199,6 +209,7 @@ public function isCountedAsSiteAward(): bool AwardType::PatreonSupporter, AwardType::CertifiedLegend, AwardType::Playtest, + AwardType::MediaContribution, ], true); } @@ -286,6 +297,7 @@ public function scopeCanonicalForApiUser(Builder $query, int $userId): Builder AwardType::AchievementPointsYield->value, AwardType::PatreonSupporter->value, AwardType::CertifiedLegend->value, + AwardType::MediaContribution->value, ]; $gameTypes = AwardType::gameValues(); diff --git a/app/Platform/Actions/RevalidateMediaContributionBadgeEligibilityAction.php b/app/Platform/Actions/RevalidateMediaContributionBadgeEligibilityAction.php new file mode 100644 index 0000000000..830d9512c6 --- /dev/null +++ b/app/Platform/Actions/RevalidateMediaContributionBadgeEligibilityAction.php @@ -0,0 +1,68 @@ +eligibleForMediaContributionBy($user) + ->count(); + $expectedTier = PlayerBadge::getNewBadgeTier(AwardType::MediaContribution, 0, $eligibleCount); + + $existingBadges = $user->playerBadges() + ->where('award_type', AwardType::MediaContribution) + ->orderByDesc('award_key') + ->get(); + + if ($expectedTier === null) { + // Media contribution badges represent current community screenshot credit. + // If later dev activity makes those screenshots ineligible, remove the badge. + if ($existingBadges->isNotEmpty()) { + PlayerBadge::whereKey($existingBadges->modelKeys())->delete(); + } + + return null; + } + + $previousHighestBadge = $existingBadges->first(); + + // If only some screenshots stopped counting, keep the earned tier that still + // matches current eligibility and remove any higher tiers. + $tooHighIds = $existingBadges + ->where('award_key', '>', $expectedTier) + ->modelKeys(); + if ($tooHighIds) { + PlayerBadge::whereKey($tooHighIds)->delete(); + } + + $expectedBadge = $existingBadges->first( + fn (PlayerBadge $badge) => $badge->award_key === $expectedTier && $badge->award_tier === 0, + ); + if ($expectedBadge) { + return $expectedBadge; + } + + $newBadge = AddSiteAward( + user: $user, + awardType: AwardType::MediaContribution, + data: $expectedTier, + displayOrder: $previousHighestBadge?->order_column ?? PlayerBadge::getNextDisplayOrder($user), + ); + + if ($previousHighestBadge === null || $newBadge->award_key > $previousHighestBadge->award_key) { + SiteBadgeAwarded::dispatch($newBadge); + } + + return $newBadge; + } +} diff --git a/app/Platform/EventServiceProvider.php b/app/Platform/EventServiceProvider.php index d2d4f9582b..a3a82cb717 100755 --- a/app/Platform/EventServiceProvider.php +++ b/app/Platform/EventServiceProvider.php @@ -44,6 +44,7 @@ use App\Platform\Listeners\RecalculateLeaderboardTopEntriesForUser; use App\Platform\Listeners\ResetPlayerProgress; use App\Platform\Listeners\ResumePlayerSession; +use App\Platform\Listeners\RevalidateMediaContributionBadgeEligibility; use App\Platform\Listeners\UpdateAuthorYieldUnlocksForUser; use App\Platform\Listeners\UpdateTotalGamesCount; use App\Platform\Observers\GameScreenshotObserver; @@ -57,6 +58,7 @@ class EventServiceProvider extends ServiceProvider protected $listen = [ AchievementCreated::class => [ DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated + RevalidateMediaContributionBadgeEligibility::class, ], AchievementDeleted::class => [ DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated @@ -67,11 +69,13 @@ class EventServiceProvider extends ServiceProvider AchievementMoved::class => [ DispatchUpdateGamePlayerCountJob::class, DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated + RevalidateMediaContributionBadgeEligibility::class, ], AchievementPromoted::class => [ DispatchUpdateGamePlayerCountJob::class, DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated DispatchUpdateDeveloperContributionYieldJob::class, // dispatches UpdateDeveloperContributionYield + RevalidateMediaContributionBadgeEligibility::class, EnsureTriggerVersionedOnPromotion::class, UpdateTotalGamesCount::class, // TODO Notify player/developer when moved to AchievementSetPublished event diff --git a/app/Platform/Listeners/RevalidateMediaContributionBadgeEligibility.php b/app/Platform/Listeners/RevalidateMediaContributionBadgeEligibility.php new file mode 100644 index 0000000000..08825009c0 --- /dev/null +++ b/app/Platform/Listeners/RevalidateMediaContributionBadgeEligibility.php @@ -0,0 +1,39 @@ +achievement->developer; + if (!$developer || $developer->trashed()) { + return; + } + + $gameIds = [$event->achievement->game_id]; + if ($event instanceof AchievementMoved) { + $gameIds[] = $event->originalGame->id; + } + + $hasScreenshotOnGame = GameScreenshot::query() + ->where('captured_by_user_id', $developer->id) + ->whereIn('game_id', array_unique($gameIds)) + ->approved() + ->exists(); + if (!$hasScreenshotOnGame) { + return; + } + + (new RevalidateMediaContributionBadgeEligibilityAction())->execute($developer); + } +} diff --git a/public/assets/images/badge/README.md b/public/assets/images/badge/README.md new file mode 100644 index 0000000000..1b59b498b0 --- /dev/null +++ b/public/assets/images/badge/README.md @@ -0,0 +1,8 @@ +Credits: + +mediaContrib-0.png - https://retroachievements.org/user/Cruzelion +mediaContrib-1.png - https://retroachievements.org/user/Cruzelion +mediaContrib-2.png - https://retroachievements.org/user/Cruzelion +mediaContrib-3.png - https://retroachievements.org/user/Cruzelion +mediaContrib-4.png - https://retroachievements.org/user/salvadorbastard +mediaContrib-5.png - https://retroachievements.org/user/Gollawiz diff --git a/public/assets/images/badge/mediaContrib-0.png b/public/assets/images/badge/mediaContrib-0.png new file mode 100644 index 0000000000..7a7a5195f7 Binary files /dev/null and b/public/assets/images/badge/mediaContrib-0.png differ diff --git a/public/assets/images/badge/mediaContrib-1.png b/public/assets/images/badge/mediaContrib-1.png new file mode 100644 index 0000000000..ceed99cac0 Binary files /dev/null and b/public/assets/images/badge/mediaContrib-1.png differ diff --git a/public/assets/images/badge/mediaContrib-2.png b/public/assets/images/badge/mediaContrib-2.png new file mode 100644 index 0000000000..39ba0fcc63 Binary files /dev/null and b/public/assets/images/badge/mediaContrib-2.png differ diff --git a/public/assets/images/badge/mediaContrib-3.png b/public/assets/images/badge/mediaContrib-3.png new file mode 100644 index 0000000000..76d76ff183 Binary files /dev/null and b/public/assets/images/badge/mediaContrib-3.png differ diff --git a/public/assets/images/badge/mediaContrib-4.png b/public/assets/images/badge/mediaContrib-4.png new file mode 100644 index 0000000000..aa741f82d0 Binary files /dev/null and b/public/assets/images/badge/mediaContrib-4.png differ diff --git a/public/assets/images/badge/mediaContrib-5.png b/public/assets/images/badge/mediaContrib-5.png new file mode 100644 index 0000000000..62a9a0828c Binary files /dev/null and b/public/assets/images/badge/mediaContrib-5.png differ diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 5381302b92..560b551308 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1,5 +1,5 @@ declare namespace App.Api.V2.UserAwards { -export type UserAwardKind = 'achievement-points-yield' | 'achievement-unlocks-yield' | 'beaten-hardcore' | 'beaten-softcore' | 'certified-legend' | 'completed' | 'event' | 'mastered' | 'patreon-supporter' | 'playtest'; +export type UserAwardKind = 'achievement-points-yield' | 'achievement-unlocks-yield' | 'beaten-hardcore' | 'beaten-softcore' | 'certified-legend' | 'completed' | 'event' | 'mastered' | 'media-contribution' | 'patreon-supporter' | 'playtest'; } declare namespace App.Community.Data { export type AchievementChecklistGroup = { @@ -189,7 +189,7 @@ requestedUsername: string | null; }; } declare namespace App.Community.Enums { -export type AwardType = 'mastery' | 'achievement_unlocks_yield' | 'achievement_points_yield' | 'patreon_supporter' | 'certified_legend' | 'game_beaten' | 'event' | 'playtest'; +export type AwardType = 'mastery' | 'achievement_unlocks_yield' | 'achievement_points_yield' | 'patreon_supporter' | 'certified_legend' | 'game_beaten' | 'event' | 'playtest' | 'media_contribution'; export type ClaimSetType = 'new_set' | 'revision'; export type ClaimSpecial = 'none' | 'own_revision' | 'free_rollout' | 'scheduled_release'; export type ClaimStatus = 'active' | 'complete' | 'dropped' | 'in_review'; diff --git a/tests/Feature/Api/V2/UserAwardsTest.php b/tests/Feature/Api/V2/UserAwardsTest.php index fed6377343..559f3e6623 100644 --- a/tests/Feature/Api/V2/UserAwardsTest.php +++ b/tests/Feature/Api/V2/UserAwardsTest.php @@ -501,6 +501,38 @@ public function testItUsesTheSiteAwardLabelAsThePlaytestTitle(): void $this->assertEquals($siteAward->id, $response->json('data.0.attributes.context.siteAwardId')); } + public function testItExposesMediaContributionAwards(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $player = User::factory()->create(); + + PlayerBadge::factory()->create([ + 'user_id' => $player->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 2, + 'award_tier' => 0, + 'order_column' => 0, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('user-awards') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/awards?filter[kind]=media-contribution"); + + // Assert + $response->assertSuccessful(); + + $this->assertCount(1, $response->json('data')); + $this->assertEquals('media-contribution', $response->json('data.0.attributes.kind')); + $this->assertEquals('Media Contribution', $response->json('data.0.attributes.title')); + $this->assertStringEndsWith('/assets/images/badge/mediaContrib-2.png', $response->json('data.0.attributes.badgeUrl')); + $this->assertEquals(2, $response->json('data.0.attributes.context.tier')); + $this->assertEquals(100, $response->json('data.0.attributes.context.threshold')); + $this->assertEquals(1, $response->json('meta.siteAwardsCount')); + } + public function testItCanSortByAwardedAtDescending(): void { // Arrange @@ -813,6 +845,41 @@ public function testItCollapsesDeveloperYieldAwardsToTheHighestTier(): void $this->assertEquals(1, $response->json('meta.totalAwardsCount')); } + public function testItCollapsesMediaContributionAwardsToTheHighestTier(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $player = User::factory()->create(); + + PlayerBadge::factory()->create([ + 'user_id' => $player->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 1, + 'order_column' => 0, + ]); + PlayerBadge::factory()->create([ + 'user_id' => $player->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 2, + 'order_column' => 0, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('user-awards') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/awards"); + + // Assert + $response->assertSuccessful(); + + $this->assertCount(1, $response->json('data')); + $this->assertEquals('media-contribution', $response->json('data.0.attributes.kind')); + $this->assertEquals(2, $response->json('data.0.attributes.context.tier')); + $this->assertEquals(1, $response->json('meta.totalAwardsCount')); + $this->assertEquals(1, $response->json('meta.siteAwardsCount')); + } + public function testItPaginatesBy50ByDefault(): void { // Arrange diff --git a/tests/Feature/Platform/Actions/ApproveGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/ApproveGameScreenshotActionTest.php index 7ae8493b8a..6d6bbccf84 100644 --- a/tests/Feature/Platform/Actions/ApproveGameScreenshotActionTest.php +++ b/tests/Feature/Platform/Actions/ApproveGameScreenshotActionTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Community\Enums\AwardType; use App\Community\Enums\SubscriptionSubjectType; use App\Models\Game; use App\Models\GameScreenshot; +use App\Models\PlayerBadge; use App\Models\System; use App\Models\User; use App\Models\UserDelayedSubscription; @@ -12,8 +14,10 @@ use App\Platform\Actions\SubmitPendingGameScreenshotAction; use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; +use App\Platform\Events\SiteBadgeAwarded; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use Spatie\MediaLibrary\Conversions\FileManipulator; @@ -108,7 +112,7 @@ function createPendingScreenshotForApprovalTest( expect($delayedSubscription->subject_id)->toEqual($fresh->id); expect($delayedSubscription->first_update_id)->toEqual($fresh->id); - expect(App\Models\PlayerBadge::count())->toEqual(0); + expect(PlayerBadge::count())->toEqual(0); }); it('does not notify the submitter when they approve their own screenshot', function () { @@ -133,6 +137,38 @@ function createPendingScreenshotForApprovalTest( expect(UserDelayedSubscription::count())->toEqual(0); }); +it('does not award media contribution badges outside the moderation resource', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + GameScreenshot::factory()->for($game)->ingame()->primary()->create([ + 'order_column' => 1, + ]); + GameScreenshot::factory()->for($game)->ingame()->create([ + 'captured_by_user_id' => $submitter->id, + 'status' => GameScreenshotStatus::Approved, + ]); + + $pending = createPendingScreenshotForApprovalTest($game, $submitter, ScreenshotType::Ingame); + + $fileManipulator = new ApproveGameScreenshotActionTestFileManipulator(); + app()->instance(FileManipulator::class, $fileManipulator); + + Event::fake(); + + // ACT + (new ApproveGameScreenshotAction())->execute($pending, $reviewer); + + // ASSERT + expect(PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count())->toEqual(0); + + Event::assertNotDispatched(SiteBadgeAwarded::class); +}); + it('replaces the existing approved title screenshot when a new one is approved', function () { // ARRANGE $game = Game::factory()->create(['system_id' => System::factory()]); diff --git a/tests/Feature/Platform/Actions/RevalidateMediaContributionBadgeEligibilityActionTest.php b/tests/Feature/Platform/Actions/RevalidateMediaContributionBadgeEligibilityActionTest.php new file mode 100644 index 0000000000..c2f10010da --- /dev/null +++ b/tests/Feature/Platform/Actions/RevalidateMediaContributionBadgeEligibilityActionTest.php @@ -0,0 +1,409 @@ +for($game) + ->ingame() + ->create([ + 'captured_by_user_id' => $submitter->id, + 'reviewed_by_user_id' => $reviewer->id, + 'reviewed_at' => now(), + 'status' => GameScreenshotStatus::Approved, + ]); + } +} + +it('awards a tier 0 badge when eligible screenshots cross the first threshold', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + Event::fake([SiteBadgeAwarded::class]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge)->not->toBeNull(); + expect($badge->award_type)->toEqual(AwardType::MediaContribution); + expect($badge->award_key)->toEqual(0); + expect($badge->award_tier)->toEqual(0); + + Event::assertDispatched( + SiteBadgeAwarded::class, + fn (SiteBadgeAwarded $event) => $event->playerBadge->is($badge), + ); +}); + +it('excludes self-approved screenshots from the eligible count', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $submitter); + + Event::fake([SiteBadgeAwarded::class]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge)->toBeNull(); + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count() + )->toEqual(0); + + Event::assertNotDispatched(SiteBadgeAwarded::class); +}); + +it('excludes screenshots for games with active claims and counts those with dropped claims', function () { + // ARRANGE + $activeClaimGame = Game::factory()->create(['system_id' => System::factory()]); + $droppedClaimGame = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + AchievementSetClaim::factory()->create([ + 'game_id' => $activeClaimGame->id, + 'user_id' => $submitter->id, + ]); + AchievementSetClaim::factory()->create([ + 'game_id' => $droppedClaimGame->id, + 'user_id' => $submitter->id, + 'status' => ClaimStatus::Dropped, + ]); + + // ... the active claim game contributes a screenshot that should be ignored ... + createApprovedScreenshotsForRevalidateActionTest(1, $activeClaimGame, $submitter, $reviewer); + + // ... the dropped claim game contributes screenshots that should still count ... + createApprovedScreenshotsForRevalidateActionTest(10, $droppedClaimGame, $submitter, $reviewer); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge)->not->toBeNull(); + expect($badge->award_key)->toEqual(0); +}); + +it('removes existing badges and returns null when no screenshots are eligible', function () { + // ARRANGE + $submitter = User::factory()->create(); + + PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 1, + 'award_tier' => 0, + ]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge)->toBeNull(); + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count() + )->toEqual(0); +}); + +it('reuses an existing badge at the expected tier without dispatching an event', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + $existing = PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 0, + 'award_tier' => 0, + 'order_column' => 7, + ]); + + Event::fake([SiteBadgeAwarded::class]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge->id)->toEqual($existing->id); + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count() + )->toEqual(1); + + Event::assertNotDispatched(SiteBadgeAwarded::class); +}); + +it('downgrades by deleting higher tiers and returning the matching lower tier', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 0, + 'award_tier' => 0, + ]); + PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 1, + 'award_tier' => 0, + ]); + + Event::fake([SiteBadgeAwarded::class]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge->award_key)->toEqual(0); + expect(PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->where('award_key', 1) + ->count())->toEqual(0); + + Event::assertNotDispatched(SiteBadgeAwarded::class); +}); + +it('preserves the order_column from the previous highest tier when upgrading', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(30, $game, $submitter, $reviewer); + + PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 0, + 'award_tier' => 0, + 'order_column' => 7, + ]); + + Event::fake([SiteBadgeAwarded::class]); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge->award_key)->toEqual(1); + expect($badge->order_column)->toEqual(7); + + Event::assertDispatched( + SiteBadgeAwarded::class, + fn (SiteBadgeAwarded $event) => $event->playerBadge->is($badge), + ); +}); + +it('awards upgraded tiers at the time they are earned', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(30, $game, $submitter, $reviewer); + + $originalAwardedAt = Carbon::parse('2025-01-15 12:00:00'); + $upgradedAt = Carbon::parse('2025-02-15 12:00:00'); + + PlayerBadge::factory()->create([ + 'user_id' => $submitter->id, + 'award_type' => AwardType::MediaContribution, + 'award_key' => 0, + 'award_tier' => 0, + 'awarded_at' => $originalAwardedAt, + 'order_column' => 7, + ]); + + Carbon::setTestNow($upgradedAt); + + // ACT + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + + // ASSERT + expect($badge->award_key)->toEqual(1); + expect($badge->order_column)->toEqual(7); + expect($badge->awarded_at->toDateTimeString())->toEqual($upgradedAt->toDateTimeString()); + expect($badge->awarded_at->toDateTimeString())->not->toEqual($originalAwardedAt->toDateTimeString()); + + Carbon::setTestNow(); +}); + +it('revalidates the badge when a game claim is created', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + expect($badge?->award_key)->toEqual(0); + + // ACT + (new CreateGameClaimAction())->execute($game, $submitter); + + // ASSERT + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count() + )->toEqual(0); +}); + +it('revalidates the badge when a game claim is dropped', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + $claim = AchievementSetClaim::factory()->create([ + 'game_id' => $game->id, + 'user_id' => $submitter->id, + ]); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + expect($badge)->toBeNull(); + + // ACT + (new DropGameClaimAction())->execute($claim, $submitter); + + // ASSERT + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->sole() + ->award_key + )->toEqual(0); +}); + +it('revalidates the badge when a game claim status is updated to dropped', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + $claim = AchievementSetClaim::factory()->create([ + 'game_id' => $game->id, + 'user_id' => $submitter->id, + ]); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + expect($badge)->toBeNull(); + + // ACT + $this->actingAs($reviewer); + (new UpdateGameClaimAction())->execute($claim, ['status' => ClaimStatus::Dropped->value]); + + // ASSERT + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->sole() + ->award_key + )->toEqual(0); +}); + +it('revalidates the badge when the submitter authors an achievement for the game', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + createApprovedScreenshotsForRevalidateActionTest(10, $game, $submitter, $reviewer); + + $badge = (new RevalidateMediaContributionBadgeEligibilityAction())->execute($submitter); + expect($badge?->award_key)->toEqual(0); // they have a badge from their screenshots + + // ACT + Achievement::factory()->for($game)->create(['user_id' => $submitter->id]); + + // ASSERT + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->count() + )->toEqual(0); +}); + +it('revalidates the original game when an achievement moves away from submitted screenshots', function () { + // ARRANGE + $originalGame = Game::factory()->create(['system_id' => System::factory()]); + $newGame = Game::factory()->create(['system_id' => System::factory()]); + $submitter = User::factory()->create(); + $reviewer = User::factory()->create(); + + $achievement = Achievement::factory()->for($originalGame)->create(['user_id' => $submitter->id]); + + createApprovedScreenshotsForRevalidateActionTest(10, $originalGame, $submitter, $reviewer); + + $achievement->game_id = $newGame->id; + $achievement->saveQuietly(); + + // ACT + (new RevalidateMediaContributionBadgeEligibility())->handle( + new AchievementMoved($achievement, $originalGame), + ); + + // ASSERT + expect( + PlayerBadge::where('user_id', $submitter->id) + ->where('award_type', AwardType::MediaContribution) + ->sole() + ->award_key + )->toEqual(0); +});