diff --git a/packages/claude-code-plugin/hooks/lib/achievement_tracker.py b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py index 29c17002..24ba6547 100644 --- a/packages/claude-code-plugin/hooks/lib/achievement_tracker.py +++ b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py @@ -73,22 +73,27 @@ "en": { "unlocked": "Achievement Unlocked!", "congrats": "Congratulations!", + "more_unlocked": "+{n} more achievements unlocked!", }, "ko": { "unlocked": "업적 달성!", "congrats": "축하합니다!", + "more_unlocked": "+{n}개의 업적을 추가 달성!", }, "ja": { "unlocked": "実績解除!", "congrats": "おめでとうございます!", + "more_unlocked": "+{n}件の実績を解除!", }, "zh": { "unlocked": "成就解锁!", "congrats": "恭喜!", + "more_unlocked": "+{n}个成就已解锁!", }, "es": { "unlocked": "Logro desbloqueado!", "congrats": "Felicitaciones!", + "more_unlocked": "+{n} logros más desbloqueados!", }, } @@ -383,6 +388,37 @@ def render_achievement_celebration( return "\n".join(lines) +def render_batch_celebration( + newly_unlocked: List[Dict[str, Any]], + language: str = "en", +) -> str: + """Render a batch celebration for one or more newly unlocked achievements. + + When a single achievement is unlocked, delegates to render_achievement_celebration. + When multiple are unlocked, shows the first in detail and summarises the rest. + + Args: + newly_unlocked: List of achievement definition dicts just unlocked. + language: Language code. + + Returns: + Formatted celebration string, or empty string if list is empty. + """ + if not newly_unlocked: + return "" + + if len(newly_unlocked) == 1: + return render_achievement_celebration(newly_unlocked[0], language) + + # Multiple: show first achievement in detail, summarise the rest + top = newly_unlocked[0] + detail = render_achievement_celebration(top, language) + msgs = CELEBRATION_MESSAGES.get(language, CELEBRATION_MESSAGES["en"]) + remaining = len(newly_unlocked) - 1 + summary_line = f" {msgs['more_unlocked'].format(n=remaining)}" + return f"{detail}\n{summary_line}" + + def render_achievement_badges( unlocked: List[Dict[str, Any]], language: str = "en", diff --git a/packages/claude-code-plugin/hooks/stop.py b/packages/claude-code-plugin/hooks/stop.py index b2e3d6c2..0d1e2441 100644 --- a/packages/claude-code-plugin/hooks/stop.py +++ b/packages/claude-code-plugin/hooks/stop.py @@ -131,7 +131,7 @@ def handle_stop(data: dict): try: from achievement_tracker import ( AchievementTracker, - render_achievement_celebration, + render_batch_celebration, render_achievement_badges, ) @@ -145,10 +145,10 @@ def handle_stop(data: dict): newly_unlocked = tracker.check_achievements() if newly_unlocked: - for achievement in newly_unlocked: - celebration = render_achievement_celebration( - achievement, language - ) + celebration = render_batch_celebration( + newly_unlocked, language + ) + if celebration: print(celebration, file=sys.stderr) # Show badge summary if any unlocked diff --git a/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py index fe07951e..bdb2fa90 100644 --- a/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py +++ b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py @@ -20,6 +20,7 @@ get_achievement_definitions, render_achievement_badges, render_achievement_celebration, + render_batch_celebration, ) @@ -350,3 +351,54 @@ def test_render_unknown_id(self): unlocked = [{"id": "unknown_xyz", "name": "Mystery"}] result = render_achievement_badges(unlocked, "en") assert "Mystery" in result + + +class TestRenderBatchCelebration: + """Test batch celebration rendering (#1042).""" + + def test_empty_list(self): + result = render_batch_celebration([], "en") + assert result == "" + + def test_single_achievement(self): + defn = get_achievement_by_id("tdd_champion") + result = render_batch_celebration([defn], "en") + expected = render_achievement_celebration(defn, "en") + assert result == expected + + def test_three_achievements(self): + ids = ["tdd_champion", "agent_master", "streak"] + achievements = [] + for aid in ids: + defn = get_achievement_by_id(aid) + if defn: + achievements.append(defn) + # Pad with fallback dicts if not enough real definitions + while len(achievements) < 3: + achievements.append({"name": f"Test{len(achievements)}", "icon": "\U0001f3c6"}) + + result = render_batch_celebration(achievements, "en") + # First achievement should be detailed + assert achievements[0]["name"] in result + assert "Achievement Unlocked!" in result + # Summary line for remaining + assert "+2 more achievements unlocked!" in result + + def test_three_achievements_korean(self): + achievements = [ + {"name": "First", "icon": "\U0001f3c6", "face": "\u2605\u203f\u2605"}, + {"name": "Second", "icon": "\U0001f3c6"}, + {"name": "Third", "icon": "\U0001f3c6"}, + ] + result = render_batch_celebration(achievements, "ko") + assert "업적 달성!" in result + assert "+2개의 업적을 추가 달성!" in result + + def test_two_achievements(self): + achievements = [ + {"name": "Alpha", "icon": "\U0001f3c6", "face": "\u2605\u203f\u2605"}, + {"name": "Beta", "icon": "\U0001f3c6"}, + ] + result = render_batch_celebration(achievements, "en") + assert "Alpha" in result + assert "+1 more achievements unlocked!" in result