Skip to content

Commit 3bdf62c

Browse files
committed
feat(clerk-bird): add leaderboard, audio cue, and surface in help
- Local top-10 leaderboard with name entry on game-over and ↑↓/jk + D to inspect and delete rows; stored as JSON in `~/.flap-rankings.json` - ASCII BEL on each pipe-pass and on death — cross-platform audio feedback that respects the host terminal's own bell setting - `clerk bird` is no longer hidden; it now appears as the last item in `clerk --help`, rendered below the auto-generated help row
1 parent b581d5f commit 3bdf62c

5 files changed

Lines changed: 687 additions & 35 deletions

File tree

.changeset/clerk-bird-rankings.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
Add a local leaderboard to the hidden `clerk bird` easter egg: from the GAME OVER screen, press `N` to enter your name and `L` to view the top scores. On the leaderboard, use ``/`` (or `j`/`k`) to select a row and `D` to delete it (with `Y`/`N` confirmation). `k` now also flaps in-game, alongside `SPACE`, ``, `W`, and `ENTER`. Rankings are stored as JSON in `~/.flap-rankings.json` (top 10, ties broken by older entry). The existing `~/.flap-best` file is unchanged. Pipe-passes and the death event now emit a short bell tone (ASCII BEL) so the `+1` and the GAME OVER moment each have audio feedback; terminals with the bell disabled stay silent and the host terminal handles cross-platform behavior on Windows, macOS, Linux, and any POSIX TTY. The `bird` command is no longer hidden and now appears at the bottom of `clerk --help` (after the `help` row) so the easter egg is discoverable without cluttering the main command surface.

packages/cli-core/src/lib/help.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,14 @@ export function clerkHelpConfig(): Partial<Help> {
7777
output = output.concat(helper.formatItemList("Options:", items, helper));
7878
}
7979

80-
// Commands — three-column layout: name | args | description
81-
const visibleCmds = helper.visibleCommands(cmd);
80+
// Commands — three-column layout: name | args | description.
81+
// Easter-egg commands (`bird`) render after the help row so the
82+
// serious surface stays at the top.
83+
const allCmds = helper.visibleCommands(cmd);
84+
const eastereggs = allCmds.filter((c) => c.name() === "bird");
85+
const visibleCmds = eastereggs.length
86+
? [...allCmds.filter((c) => c.name() !== "bird"), ...eastereggs]
87+
: allCmds;
8288
if (visibleCmds.length > 0) {
8389
let maxNameLen = 0;
8490
const cmdData = visibleCmds.map((sub) => {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { beep, insertEntry, loadRankings, removeRanking } from "./flap.ts";
7+
8+
const tmpDirs: string[] = [];
9+
10+
function makeTmpDir(): string {
11+
const dir = mkdtempSync(join(tmpdir(), "flap-rankings-"));
12+
tmpDirs.push(dir);
13+
return dir;
14+
}
15+
16+
afterEach(() => {
17+
while (tmpDirs.length > 0) {
18+
const dir = tmpDirs.pop();
19+
if (dir) rmSync(dir, { recursive: true, force: true });
20+
}
21+
});
22+
23+
describe("insertEntry", () => {
24+
it("inserts the first entry at rank 1", () => {
25+
const result = insertEntry([], { name: "alice", score: 10, ts: 1 });
26+
expect(result.rank).toBe(1);
27+
expect(result.list).toEqual([{ name: "alice", score: 10, ts: 1 }]);
28+
});
29+
30+
it("sorts higher scores above lower scores", () => {
31+
const list = [{ name: "alice", score: 10, ts: 1 }];
32+
const result = insertEntry(list, { name: "bob", score: 20, ts: 2 });
33+
expect(result.rank).toBe(1);
34+
expect(result.list.map((e) => e.name)).toEqual(["bob", "alice"]);
35+
});
36+
37+
it("breaks ties by earlier timestamp", () => {
38+
const list = [{ name: "alice", score: 10, ts: 5 }];
39+
const result = insertEntry(list, { name: "bob", score: 10, ts: 1 });
40+
expect(result.rank).toBe(1);
41+
expect(result.list.map((e) => e.name)).toEqual(["bob", "alice"]);
42+
});
43+
44+
it("preserves existing order when new entry tied but later", () => {
45+
const list = [{ name: "alice", score: 10, ts: 1 }];
46+
const result = insertEntry(list, { name: "bob", score: 10, ts: 5 });
47+
expect(result.rank).toBe(2);
48+
expect(result.list.map((e) => e.name)).toEqual(["alice", "bob"]);
49+
});
50+
51+
it("evicts the lowest score when the cap is reached", () => {
52+
const list = Array.from({ length: 10 }, (_, i) => ({
53+
name: `p${i}`,
54+
score: 100 - i, // 100, 99, ..., 91
55+
ts: i,
56+
}));
57+
const result = insertEntry(list, { name: "new", score: 95, ts: 999 });
58+
// p5 also has score 95 with older ts:5, so it ranks above the new entry.
59+
expect(result.rank).toBe(7);
60+
expect(result.list).toHaveLength(10);
61+
expect(result.list.map((e) => e.name)).not.toContain("p9"); // score 91 evicted
62+
expect(result.list.map((e) => e.name)).toContain("new");
63+
});
64+
65+
it("returns null rank and unchanged list when score does not qualify", () => {
66+
const list = Array.from({ length: 10 }, (_, i) => ({
67+
name: `p${i}`,
68+
score: 100 - i, // 91 is the lowest
69+
ts: i,
70+
}));
71+
const result = insertEntry(list, { name: "loser", score: 5, ts: 999 });
72+
expect(result.rank).toBeNull();
73+
expect(result.list).toHaveLength(10);
74+
expect(result.list.map((e) => e.name)).not.toContain("loser");
75+
});
76+
77+
it("respects a custom cap", () => {
78+
const list = [
79+
{ name: "a", score: 10, ts: 1 },
80+
{ name: "b", score: 5, ts: 2 },
81+
];
82+
const result = insertEntry(list, { name: "c", score: 7, ts: 3 }, 2);
83+
expect(result.rank).toBe(2);
84+
expect(result.list.map((e) => e.name)).toEqual(["a", "c"]);
85+
});
86+
});
87+
88+
describe("removeRanking", () => {
89+
const list = [
90+
{ name: "alice", score: 30, ts: 1 },
91+
{ name: "bob", score: 20, ts: 2 },
92+
{ name: "carol", score: 10, ts: 3 },
93+
];
94+
95+
it.each([
96+
[1, ["bob", "carol"]],
97+
[2, ["alice", "carol"]],
98+
[3, ["alice", "bob"]],
99+
] as const)("removes the entry at 1-based rank %i", (rank, expected) => {
100+
expect(removeRanking(list, rank).map((e) => e.name)).toEqual([...expected]);
101+
});
102+
103+
it("does not mutate the input list", () => {
104+
const before = list.slice();
105+
removeRanking(list, 2);
106+
expect(list).toEqual(before);
107+
});
108+
109+
it.each([-1, 0, 4])("returns the same reference when rank is out of range (%i)", (rank) => {
110+
expect(removeRanking(list, rank)).toBe(list);
111+
});
112+
113+
it("handles an empty list", () => {
114+
expect(removeRanking([], 1)).toEqual([]);
115+
});
116+
117+
it("removing the last remaining entry yields an empty list", () => {
118+
expect(removeRanking([{ name: "solo", score: 1, ts: 1 }], 1)).toEqual([]);
119+
});
120+
});
121+
122+
describe("loadRankings", () => {
123+
it("returns an empty list when the file does not exist", async () => {
124+
const dir = makeTmpDir();
125+
const result = await loadRankings(join(dir, "missing.json"));
126+
expect(result).toEqual([]);
127+
});
128+
129+
it("returns an empty list when the file contains malformed JSON", async () => {
130+
const dir = makeTmpDir();
131+
const file = join(dir, "rankings.json");
132+
await Bun.write(file, "{ this is not json");
133+
const result = await loadRankings(file);
134+
expect(result).toEqual([]);
135+
});
136+
137+
it("returns an empty list when the entries field is missing", async () => {
138+
const dir = makeTmpDir();
139+
const file = join(dir, "rankings.json");
140+
await Bun.write(file, JSON.stringify({ version: 1 }));
141+
const result = await loadRankings(file);
142+
expect(result).toEqual([]);
143+
});
144+
145+
it("filters out malformed entries while keeping valid ones", async () => {
146+
const dir = makeTmpDir();
147+
const file = join(dir, "rankings.json");
148+
await Bun.write(
149+
file,
150+
JSON.stringify({
151+
version: 1,
152+
entries: [
153+
{ name: "alice", score: 10, ts: 1 },
154+
{ name: "bob" }, // missing score/ts
155+
null,
156+
{ name: 42, score: 5, ts: 2 }, // wrong name type
157+
{ name: "carol", score: 20, ts: 3 },
158+
],
159+
}),
160+
);
161+
const result = await loadRankings(file);
162+
expect(result.map((e) => e.name)).toEqual(["carol", "alice"]); // sorted desc
163+
});
164+
165+
it("truncates overlong names to MAX_NAME_LEN", async () => {
166+
const dir = makeTmpDir();
167+
const file = join(dir, "rankings.json");
168+
await Bun.write(
169+
file,
170+
JSON.stringify({
171+
version: 1,
172+
entries: [{ name: "this-name-is-way-too-long", score: 1, ts: 1 }],
173+
}),
174+
);
175+
const result = await loadRankings(file);
176+
expect(result).toHaveLength(1);
177+
expect(result[0]?.name.length).toBeLessThanOrEqual(12);
178+
});
179+
180+
it("caps loaded entries to MAX_RANKINGS", async () => {
181+
const dir = makeTmpDir();
182+
const file = join(dir, "rankings.json");
183+
await Bun.write(
184+
file,
185+
JSON.stringify({
186+
version: 1,
187+
entries: Array.from({ length: 50 }, (_, i) => ({ name: `p${i}`, score: i, ts: i })),
188+
}),
189+
);
190+
const result = await loadRankings(file);
191+
expect(result).toHaveLength(10);
192+
expect(result[0]?.score).toBe(49);
193+
});
194+
});
195+
196+
describe("beep", () => {
197+
it("writes the ASCII BEL byte to the provided stream", () => {
198+
const chunks: string[] = [];
199+
beep({ write: (s) => chunks.push(s) });
200+
expect(chunks).toEqual(["\x07"]);
201+
});
202+
});

0 commit comments

Comments
 (0)