diff --git a/server/state.ts b/server/state.ts index 8eabff3..875b248 100644 --- a/server/state.ts +++ b/server/state.ts @@ -454,6 +454,10 @@ export interface StatusState { reaction: string; muted: boolean; achievement: string; + /** Epoch ms the achievement was awarded; 0 when none. The statusline expires + * the trophy banner on this, otherwise it latches into the shared + * status.json and pins every session's bubble to the last unlock. */ + achievementAt: number; frames: string[]; compactFrames: string[]; minimalFrames: string[]; @@ -543,6 +547,7 @@ export function writeStatusState( reaction: "", muted: muted ?? false, achievement: achievement ?? "", + achievementAt: achievement ? Date.now() : 0, frames, compactFrames, minimalFrames, diff --git a/statusline/buddy-status.sh b/statusline/buddy-status.sh index 62404a8..b8d1c15 100755 --- a/statusline/buddy-status.sh +++ b/statusline/buddy-status.sh @@ -58,6 +58,9 @@ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null) SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null) REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json" ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null) +# "absent" distinguishes a legacy status.json (no field at all) from an explicit +# 0, which means "no achievement pending" and must not render. +ACHIEVEMENT_AT=$(jq -r 'if has("achievementAt") then (.achievementAt // 0) else "absent" end' "$STATE" 2>/dev/null) LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null) MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null) @@ -229,10 +232,10 @@ DETECTED_COLS="$COLS" DETECTED_ROWS="$ROWS" # ─── Reaction bubble (with TTL check) ──────────────────────────────────────── +# The achievement banner is resolved further down, once REACTION_TTL is known — +# it expires on the same clock as a reaction. Without that it latches into the +# shared status.json and pins every session's bubble to the last trophy. BUBBLE="" -if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then - BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT" -fi REACTION_TTL=900 INNER_W=44 MARGIN=8 @@ -307,6 +310,31 @@ _sweep_expired_reactions() { } _sweep_expired_reactions + +# Achievement banner: shown only while fresh. ACHIEVEMENT_AT is epoch ms, written +# alongside the name by writeStatusState. A legacy status.json without the field +# (pre-upgrade, or a snapshot fixture) is treated as fresh — the next write from +# the server backfills it. +# +# Validity and age are separate gates on purpose. A zeroed or malformed +# achievementAt means "nothing pending" and must never render, including under +# reactionTTL=0 — that opt-out disables *expiry*, not the field's meaning. +if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ]; then + ACH_FRESH=1 + if [ "$ACHIEVEMENT_AT" != "absent" ]; then + case "$ACHIEVEMENT_AT" in + ''|0|*[!0-9]*) ACH_FRESH=0 ;; + *) + if [ "$REACTION_TTL" -gt 0 ] 2>/dev/null; then + ACH_AGE=$(( ($(date +%s) * 1000 - ACHIEVEMENT_AT) / 1000 )) + [ "$ACH_AGE" -ge "$REACTION_TTL" ] && ACH_FRESH=0 + fi + ;; + esac + fi + [ "$ACH_FRESH" -eq 1 ] && BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT" +fi + REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null) if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; then FRESH=0 diff --git a/statusline/buddy-status.test.ts b/statusline/buddy-status.test.ts index 5ed3576..3c840d9 100644 --- a/statusline/buddy-status.test.ts +++ b/statusline/buddy-status.test.ts @@ -584,3 +584,86 @@ describe("statusline density", () => { expect(narrow.stdout.toString()).not.toContain("long-reaction-text"); }); }); + +describe("achievement banner expiry", () => { + function writeStatus(stateDir: string, extra: Record) { + writeFileSync(join(stateDir, "status.json"), JSON.stringify({ + name: "Nimbus", + rarity: "common", + stars: "★", + shiny: false, + reaction: "", + level: 1, + mood: "focused", + frames: [" art"], + frameSequence: [0], + ...extra, + })); + } + + test("shows a freshly awarded achievement", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat", achievementAt: Date.now() }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); + + test("drops an achievement older than the reaction TTL", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { + achievement: "🕊️ Diplomat", + achievementAt: Date.now() - 901_000, + }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("does not render a banner when achievementAt is zeroed", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat", achievementAt: 0 }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("never renders a zeroed achievementAt even when reactionTTL disables expiry", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 0 }); + writeStatus(stateDir, { achievement: "\u{1F54A}\uFE0F Diplomat", achievementAt: 0 }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).not.toContain("Diplomat"); + }); + + test("reactionTTL=0 keeps a validly stamped achievement from expiring", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 0 }); + writeStatus(stateDir, { + achievement: "\u{1F54A}\uFE0F Diplomat", + achievementAt: Date.now() - 86_400_000, + }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); + + test("treats a legacy status.json without achievementAt as fresh", () => { + const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 900 }); + writeStatus(stateDir, { achievement: "🕊️ Diplomat" }); + + const result = runStatusline(configDir); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain("Diplomat"); + }); +});