diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 894c7a17c..d81ae1a83 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -115,7 +115,7 @@ Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [live ### Achievement badges and XP -Profiles have **14 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog. +Profiles have **15 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog. | Badge | When it unlocks | | --- | --- | @@ -133,6 +133,7 @@ Profiles have **14 achievement badges**. Earned badges appear in a single row, w | **sweet tooth** | Receive **100 pastéis across your account**, not on a single commit. Pastéis mode does not need to be enabled, and the total survives deletion of the awarded commits. | | **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. | | **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. | +| **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” | Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone. diff --git a/app/Enums/Achievement.php b/app/Enums/Achievement.php index 35629e85f..8a878196c 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -20,6 +20,7 @@ enum Achievement: string case SweetTooth = 'sweet_tooth'; case Luddite = 'luddite'; case ShellShocked = 'shell_shocked'; + case SuperSaiyan = 'super_saiyan'; public function label(): string { @@ -38,6 +39,7 @@ public function label(): string self::SweetTooth => 'sweet tooth', self::Luddite => 'luddite', self::ShellShocked => 'shell shocked', + self::SuperSaiyan => 'super-saiyan', }; } @@ -58,6 +60,7 @@ public function description(): string self::SweetTooth => 'Received 100 pastéis across your account.', self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.', self::ShellShocked => "Survived the php2shell downtime of Oct '26. Earns 100 XP.", + self::SuperSaiyan => 'Y—your follower count. It... it’s over 9000!!!1!', }; } @@ -78,6 +81,7 @@ public function icon(): string self::SweetTooth => 'cookie', self::Luddite => 'bot-off', self::ShellShocked => 'bomb', + self::SuperSaiyan => 'dragon-ball', }; } @@ -98,6 +102,7 @@ public function target(): int self::SweetTooth => 100, self::Luddite => 1, self::ShellShocked => 1, + self::SuperSaiyan => 9000, }; } } diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 1e8f98a6d..0c27f7ef7 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -184,6 +184,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => $isShellShocked ? 1 : 0, 'target' => Achievement::ShellShocked->target(), ], + Achievement::SuperSaiyan->value => [ + 'progress' => min($user->followers()->count(), Achievement::SuperSaiyan->target()), + 'target' => Achievement::SuperSaiyan->target(), + ], ]; $badges = array_map(function (Achievement $achievement) use ($metrics): array { diff --git a/resources/js/components/achievements-dialog.tsx b/resources/js/components/achievements-dialog.tsx index 9af83f8c6..431af14b2 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; +import { DragonBallIcon } from '@/components/dragon-ball-icon'; import { Dialog, DialogContent, @@ -44,6 +45,7 @@ const iconMap: Record> = { cookie: Cookie, 'bot-off': BotOff, bomb: Bomb, + 'dragon-ball': DragonBallIcon, }; export function AchievementsDialog({ diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index 39855031a..e7e812987 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { DragonBallIcon } from '@/components/dragon-ball-icon'; import { Popover, PopoverContent, @@ -49,6 +50,7 @@ const iconMap: Record> = { cookie: Cookie, 'bot-off': BotOff, bomb: Bomb, + 'dragon-ball': DragonBallIcon, }; export function AchievementsShelf({ diff --git a/resources/js/components/dragon-ball-icon.tsx b/resources/js/components/dragon-ball-icon.tsx new file mode 100644 index 000000000..78dc744a2 --- /dev/null +++ b/resources/js/components/dragon-ball-icon.tsx @@ -0,0 +1,28 @@ +import type { SVGAttributes } from 'react'; + +export function DragonBallIcon(props: SVGAttributes) { + return ( + + + + + + + ); +} diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index 7ec4f0c65..ebd27bf12 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -28,7 +28,7 @@ ->and($result['ranks'])->toHaveCount(6) ->and($result['xp_breakdown']['commits']['xp'])->toBe(0) ->and($result['xp_breakdown']['shell_shocked']['xp'])->toBe(0) - ->and($result['badges'])->toHaveCount(14); + ->and($result['badges'])->toHaveCount(15); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -275,6 +275,29 @@ 'above threshold' => [125, 100, true], ]); +it('unlocks super-saiyan at 9000 current followers without adding xp', function (): void { + $user = User::factory()->create(); + $followers = User::factory()->count(9001)->create(); + $user->following()->attach($followers); + $query = resolve(UserAchievementsQuery::class); + + foreach ([[0, 0, false], [8999, 8999, false], [9000, 9000, true], [9001, 9000, true], [8999, 8999, false]] as [$count, $progress, $unlocked]) { + $user->followers()->sync($followers->take($count)->modelKeys()); + + $result = $query->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', 'super_saiyan'))->toBe([ + 'id' => 'super_saiyan', + 'name' => 'super-saiyan', + 'description' => 'Y—your follower count. It... it’s over 9000!!!1!', + 'icon' => 'dragon-ball', + 'unlocked' => $unlocked, + 'progress' => $progress, + 'target' => 9000, + ])->and($result['xp'])->toBe(0); + } +}); + it('does not grant luddite to Tom or someone he has never befriended', function (): void { $tom = User::factory()->create(['github_id' => CreateTomFriendship::GITHUB_ID]); $user = User::factory()->create();