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);
+});