Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Sources: [go-live button](resources/js/components/profile-live-button.tsx), [liv

### Achievement badges and XP

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.
Profiles have **16 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 |
| --- | --- |
Expand All @@ -151,14 +151,15 @@ Profiles have **15 achievement badges**. Earned badges appear in a single row, w
| **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!” |
| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it stays during a GitHub outage, unless GitHub has not answered for a week. |

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.

Badges are calculated from current account data, not permanently recorded awards. Removing qualifying activity can reduce progress or lock a badge again; for example, unstarring a commit can remove **stargazer**. **Shell shocked** depends only on when the account was created, so deleting activity does not remove it. When a commit posted on the web unlocks a badge, a toast names it and confetti drops; clicking an unlocked badge drops confetti again. Unlocking a badge does not normally give bonus XP; **shell shocked** contributes **100 XP** once. XP otherwise comes from **10 per commit**, **15 per day in the longest streak**, **5 per star received**, **5 per reply to another developer**, and **2 per star given to another developer**. The **luddite** penalty applies once while the badge is unlocked, not once per unfollow, and total XP cannot fall below zero. A qualifying reply contributes both commit XP and reply XP; pastéis do not add XP.
Badges are calculated from current account data, not permanently recorded awards. Removing qualifying activity can reduce progress or lock a badge again; for example, unstarring a commit can remove **stargazer**. **Shell shocked** depends only on when the account was created, and **contributor** only on the repository, so deleting activity removes neither. When a commit posted on the web unlocks a badge, a toast names it and confetti drops; clicking an unlocked badge drops confetti again. Unlocking a badge does not normally give bonus XP; **shell shocked** contributes **100 XP** once and **contributor** **25 XP** once. XP otherwise comes from **10 per commit**, **15 per day in the longest streak**, **5 per star received**, **5 per reply to another developer**, and **2 per star given to another developer**. The **luddite** penalty applies once while the badge is unlocked, not once per unfollow, and total XP cannot fall below zero. A qualifying reply contributes both commit XP and reply XP; pastéis do not add XP.

XP also determines your Unix rank: **user** at 0 XP, **daemon** at 50, **operator** at 150, **maintainer** at 400, **wheel** at 1,000, and **root** at 2,500. The achievements dialog's **Ranks** tab shows the levels and progress. Level-up notifications use your public progress, so pending or rejected commits do not trigger them. Each level above the starting rank can notify you only once, even if you lose and regain the XP; jumping ahead does not send a notification for every skipped level.

Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and XP calculation](app/Queries/UserAchievementsQuery.php), [streak calculation](app/Queries/UserStreakQuery.php), [Unix ranks](app/Enums/UnixRank.php), [level-up notifications](app/Actions/CreateLevelNotification.php), [achievements dialog](resources/js/components/achievements-dialog.tsx).
Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and XP calculation](app/Queries/UserAchievementsQuery.php), [repository contributors](app/Queries/ContributorsQuery.php), [streak calculation](app/Queries/UserStreakQuery.php), [Unix ranks](app/Enums/UnixRank.php), [level-up notifications](app/Actions/CreateLevelNotification.php), [achievements dialog](resources/js/components/achievements-dialog.tsx).

### Following developers and suggestions

Expand Down Expand Up @@ -629,7 +630,7 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts](

### About, changelog, and policies

The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy.
The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page keeps showing the last list GitHub gave in the past week, and only says GitHub cannot be reached when there is none. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy.

To count active people, /dev/null remembers only the **last day** (not the time) you were signed in and used the site or the API; background refreshes do not count. That day is never shown to other people, is included in your export, and is deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in, a `signin-click` event when you tap to sign in, a `signup` event right after an account is created, and `join-link-sent`, `join-link-visit` and `join-link-signup` when a guest asks for a link by email, opens it, and signs up from it, with nothing that identifies you.

Expand Down
5 changes: 5 additions & 0 deletions app/Enums/Achievement.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum Achievement: string
case Luddite = 'luddite';
case ShellShocked = 'shell_shocked';
case SuperSaiyan = 'super_saiyan';
case Contributor = 'contributor';

public function label(): string
{
Expand All @@ -40,6 +41,7 @@ public function label(): string
self::Luddite => 'luddite',
self::ShellShocked => 'shell shocked',
self::SuperSaiyan => 'super-saiyan',
self::Contributor => 'contributor',
};
}

Expand All @@ -61,6 +63,7 @@ public function description(): string
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!',
self::Contributor => 'Shipped code to the /dev/null repository itself. Earns 25 XP.',
};
}

Expand All @@ -82,6 +85,7 @@ public function icon(): string
self::Luddite => 'bot-off',
self::ShellShocked => 'bomb',
self::SuperSaiyan => 'dragon-ball',
self::Contributor => 'git-pull-request',
};
}

Expand All @@ -103,6 +107,7 @@ public function target(): int
self::Luddite => 1,
self::ShellShocked => 1,
self::SuperSaiyan => 9000,
self::Contributor => 1,
};
}
}
96 changes: 67 additions & 29 deletions app/Queries/ContributorsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

private const int FailedTtl = 60;

private const int LastTtl = 604800;

/**
* GitHub reports them as ordinary accounts, so they are named rather than detected.
*/
Expand Down Expand Up @@ -61,8 +63,18 @@ public function get(): ?array
}

/**
* The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every
* visitor to GitHub.
* Whether the user is a contributor, found by their GitHub account. False while GitHub can't say and there is no
* recent list to fall back on.
*/
public function includes(User $user): bool
{
return in_array($user->github_id, array_column($this->accounts() ?? [], 'github_id'), true);
}

/**
* The GitHub accounts behind the contributors. Once there is a list from the past week, nobody waits for GitHub: an
* expired list is served for another minute while GitHub is asked after the response, so other visitors don't ask
* meanwhile. Without one, GitHub is asked right away, and null means it didn't answer.
*
* @return list<Account>|null
*/
Expand All @@ -75,37 +87,63 @@ private function accounts(): ?array
$contributors = Cache::get($key);

if ($contributors === null) {
$contributors = [];

try {
for ($page = 1; $page <= self::MaxPages; $page++) {
$accounts = collect($this->github->contributors($repository, $page, self::PerPage));

$contributors = [...$contributors, ...$accounts->where('type', 'User')
->mapInto(Fluent::class)
->filter(fn (Fluent $account): bool => $account->string('login')->isNotEmpty()
&& is_int($account->get('id'))
&& is_int($account->get('contributions'))
&& ! in_array($account->string('login')->lower()->value(), self::Agents, true))
->map(fn (Fluent $account): array => [
'login' => $account->string('login')->value(),
'github_id' => (string) $account->integer('id'),
'commits' => $account->integer('contributions'),
])
->values()
->all()];

if ($accounts->count() < self::PerPage) {
break;
}
/** @var list<Account>|null $contributors */
$contributors = Cache::get("{$key}:last");

if ($contributors === null) {
$contributors = $this->refresh($repository, $key);
} else {
Cache::put($key, $contributors, self::FailedTtl);
defer(fn (): array|false => $this->refresh($repository, $key));
}
}

return $contributors === false ? null : $contributors;
}

/**
* Asks GitHub for the list and caches it for an hour, keeping it for a week to fall back on. When GitHub doesn't
* answer, that fallback, or false when there is none, is cached for a minute instead.
*
* @return list<Account>|false
*/
private function refresh(string $repository, string $key): array|false
{
$contributors = [];

try {
for ($page = 1; $page <= self::MaxPages; $page++) {
$accounts = collect($this->github->contributors($repository, $page, self::PerPage));

$contributors = [...$contributors, ...$accounts->where('type', 'User')
->mapInto(Fluent::class)
->filter(fn (Fluent $account): bool => $account->string('login')->isNotEmpty()
&& is_int($account->get('id'))
&& is_int($account->get('contributions'))
&& ! in_array($account->string('login')->lower()->value(), self::Agents, true))
->map(fn (Fluent $account): array => [
'login' => $account->string('login')->value(),
'github_id' => (string) $account->integer('id'),
'commits' => $account->integer('contributions'),
])
->values()
->all()];

if ($accounts->count() < self::PerPage) {
break;
}
} catch (HttpClientException) {
$contributors = false;
}
} catch (HttpClientException) {
/** @var list<Account>|false $contributors */
$contributors = Cache::get("{$key}:last", false);
Cache::put($key, $contributors, self::FailedTtl);

Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl);
return $contributors;
}

return $contributors === false ? null : $contributors;
Cache::put("{$key}:last", $contributors, self::LastTtl);
Cache::put($key, $contributors, self::Ttl);

return $contributors;
}
}
14 changes: 11 additions & 3 deletions app/Queries/UserAchievementsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@
* replies: XpContribution,
* stars_given: XpContribution,
* luddite: XpContribution,
* shell_shocked: XpContribution
* shell_shocked: XpContribution,
* contributor: XpContribution
* },
* ranks: list<array{rank: string, level: int, min_xp: int, description: string, achieved: bool}>,
* badges: list<array{id: string, name: string, description: string, icon: string, unlocked: bool, progress: int, target: int}>
* }
*/
final readonly class UserAchievementsQuery
{
public function __construct(private UserStreakQuery $streaks)
public function __construct(private UserStreakQuery $streaks, private ContributorsQuery $contributors)
{
//
}
Expand Down Expand Up @@ -90,6 +91,7 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array
$hasUnfollowedTom = $user->followers()->where('github_id', CreateTomFriendship::GITHUB_ID)->exists()
&& ! $user->following()->where('github_id', CreateTomFriendship::GITHUB_ID)->exists();
$isShellShocked = $user->created_at->lt('2026-10-21 14:00:00 UTC');
$isContributor = $this->contributors->includes($user);

// XP breakdown: 10 per commit, 15 per streak day, 5 per star received, 5 per reply, 2 per star given
$commitsXp = $commitsCount * 10;
Expand All @@ -99,8 +101,9 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array
$starsGivenXp = $starsGivenCount * 2;
$ludditeXp = $hasUnfollowedTom ? -100 : 0;
$shellShockedXp = $isShellShocked ? 100 : 0;
$contributorXp = $isContributor ? 25 : 0;

$xp = max(0, $commitsXp + $streakXp + $starsReceivedXp + $repliesXp + $starsGivenXp + $ludditeXp + $shellShockedXp);
$xp = max(0, $commitsXp + $streakXp + $starsReceivedXp + $repliesXp + $starsGivenXp + $ludditeXp + $shellShockedXp + $contributorXp);

$currentRank = UnixRank::forXp($xp);
$nextRank = $currentRank->next();
Expand All @@ -117,6 +120,7 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array
'stars_given' => ['count' => $starsGivenCount, 'multiplier' => 2, 'xp' => $starsGivenXp],
'luddite' => ['count' => $hasUnfollowedTom ? 1 : 0, 'multiplier' => -100, 'xp' => $ludditeXp],
'shell_shocked' => ['count' => $isShellShocked ? 1 : 0, 'multiplier' => 100, 'xp' => $shellShockedXp],
'contributor' => ['count' => $isContributor ? 1 : 0, 'multiplier' => 25, 'xp' => $contributorXp],
];

$ranks = array_map(fn (UnixRank $rank): array => [
Expand Down Expand Up @@ -188,6 +192,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array
'progress' => min($user->followers()->count(), Achievement::SuperSaiyan->target()),
'target' => Achievement::SuperSaiyan->target(),
],
Achievement::Contributor->value => [
'progress' => $isContributor ? 1 : 0,
'target' => Achievement::Contributor->target(),
],
];

$badges = array_map(function (Achievement $achievement) use ($metrics): array {
Expand Down
6 changes: 4 additions & 2 deletions resources/js/components/achievements-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
FileCode,
Flame,
GitMerge,
GitPullRequest,
Key,
Server,
Shield,
Expand Down Expand Up @@ -46,6 +47,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
'bot-off': BotOff,
bomb: Bomb,
'dragon-ball': DragonBallIcon,
'git-pull-request': GitPullRequest,
};

export function AchievementsDialog({
Expand Down Expand Up @@ -341,8 +343,8 @@ export function AchievementsDialog({
{/* Dry Unix XP Formula Footnote */}
<p className="border-t pt-2 text-center font-mono text-[11px] text-muted-foreground">
XP: 10/commit · 15/day streak · 5/star received · 5/reply ·
2/star given · −100/luddite · +100/shell shocked (minimum 0
XP)
2/star given · −100/luddite · +100/shell shocked ·
+25/contributor (minimum 0 XP)
</p>
</DialogFloatingContent>
</Dialog>
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/achievements-shelf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
FileCode,
Flame,
GitMerge,
GitPullRequest,
Key,
Server,
Shield,
Expand Down Expand Up @@ -51,6 +52,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
'bot-off': BotOff,
bomb: Bomb,
'dragon-ball': DragonBallIcon,
'git-pull-request': GitPullRequest,
};

export function AchievementsShelf({
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/achievement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type XpBreakdown = {
stars_given: XpMetric;
luddite: XpMetric;
shell_shocked: XpMetric;
contributor: XpMetric;
};

export type Achievements = {
Expand Down
Loading