feat: language affinity system — persistent XP + tier progression

Adds LANGUAGE_TIERS with 6 tiers: discovering → familiar → comfortable
→ proficient → expert → master (thresholds: 0/50/150/350/700/1200 XP).

add_language_affinity() writes to roster.json['language_affinities'],
accumulating across sessions. Returns (leveled_up, old_tier, new_tier)
so the Edit/Write branch can fire a level-up message immediately (Edit
PostToolUse additionalContext surfaces fine).

Session-level languages_seen remains for the one-time Explorer bonus.
Roster skill view updated to show language affinity section.
This commit is contained in:
pyr0ball 2026-04-02 22:23:31 -07:00
parent d2006727a1
commit a9c5610914
2 changed files with 90 additions and 1 deletions

View file

@ -77,6 +77,54 @@ def add_session_xp(amount: int):
save_json(roster_file, roster) save_json(roster_file, roster)
LANGUAGE_TIERS = [
(0, "discovering"),
(50, "familiar"),
(150, "comfortable"),
(350, "proficient"),
(700, "expert"),
(1200, "master"),
]
def _tier_for_xp(xp: int) -> tuple[int, str]:
"""Return (level_index, tier_label) for a given XP total."""
level = 0
label = LANGUAGE_TIERS[0][1]
for i, (threshold, name) in enumerate(LANGUAGE_TIERS):
if xp >= threshold:
level = i
label = name
return level, label
def get_language_affinity(lang: str) -> dict:
"""Return the affinity entry for lang from roster.json, or a fresh one."""
roster = load_json(BUDDYMON_DIR / "roster.json")
return roster.get("language_affinities", {}).get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
def add_language_affinity(lang: str, xp_amount: int) -> tuple[bool, str, str]:
"""Add XP to lang's affinity. Returns (leveled_up, old_tier, new_tier)."""
roster_file = BUDDYMON_DIR / "roster.json"
roster = load_json(roster_file)
affinities = roster.setdefault("language_affinities", {})
entry = affinities.get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
old_level, old_tier = _tier_for_xp(entry["xp"])
entry["xp"] = entry.get("xp", 0) + xp_amount
new_level, new_tier = _tier_for_xp(entry["xp"])
entry["level"] = new_level
entry["tier"] = new_tier
affinities[lang] = entry
roster["language_affinities"] = affinities
save_json(roster_file, roster)
leveled_up = new_level > old_level
return leveled_up, old_tier, new_tier
def get_languages_seen(): def get_languages_seen():
session = load_json(BUDDYMON_DIR / "session.json") session = load_json(BUDDYMON_DIR / "session.json")
return set(session.get("languages_seen", [])) return set(session.get("languages_seen", []))
@ -285,6 +333,23 @@ def format_new_language_message(lang: str, buddy_display: str) -> str:
) )
def format_language_levelup_message(lang: str, old_tier: str, new_tier: str, total_xp: int, buddy_display: str) -> str:
tier_emojis = {
"discovering": "🔭",
"familiar": "📖",
"comfortable": "🛠️",
"proficient": "",
"expert": "🎯",
"master": "👑",
}
emoji = tier_emojis.get(new_tier, "⬆️")
return (
f"\n{emoji} **{lang} affinity: {old_tier}{new_tier}!**\n"
f" {buddy_display} has grown more comfortable in {lang}.\n"
f" *Total {lang} XP: {total_xp}*\n"
)
def format_commit_message(streak: int, buddy_display: str) -> str: def format_commit_message(streak: int, buddy_display: str) -> str:
if streak < 5: if streak < 5:
return "" return ""
@ -394,13 +459,14 @@ def main():
commit_xp = 20 commit_xp = 20
add_session_xp(commit_xp) add_session_xp(commit_xp)
# ── Write / Edit: new language detection + test file encounters ────── # ── Write / Edit: new language detection + affinity + test file encounters ─
elif tool_name in ("Write", "Edit", "MultiEdit"): elif tool_name in ("Write", "Edit", "MultiEdit"):
file_path = tool_input.get("file_path", "") file_path = tool_input.get("file_path", "")
if file_path: if file_path:
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()
lang = KNOWN_EXTENSIONS.get(ext) lang = KNOWN_EXTENSIONS.get(ext)
if lang: if lang:
# Session-level "first encounter" bonus
seen = get_languages_seen() seen = get_languages_seen()
if lang not in seen: if lang not in seen:
add_language_seen(lang) add_language_seen(lang)
@ -408,6 +474,13 @@ def main():
msg = format_new_language_message(lang, buddy_display) msg = format_new_language_message(lang, buddy_display)
messages.append(msg) messages.append(msg)
# Persistent affinity XP — always accumulates
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)
if leveled_up:
affinity = get_language_affinity(lang)
msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display)
messages.append(msg)
# TestSpecter: editing a test file with no active encounter # TestSpecter: editing a test file with no active encounter
if not get_active_encounter(): if not get_active_encounter():
enc = match_test_file_encounter(file_path, catalog) enc = match_test_file_encounter(file_path, catalog)

View file

@ -259,8 +259,24 @@ Read roster and display:
🌐 CORSCurse — caught 2026-03-28 🌐 CORSCurse — caught 2026-03-28
❓ ??? — [n] more creatures to discover... ❓ ??? — [n] more creatures to discover...
🗺️ Language Affinities
──────────────────────────────────────────
🛠️ Python comfortable (Lv.2 · 183 XP)
📖 TypeScript familiar (Lv.1 · 72 XP)
🔭 Rust discovering (Lv.0 · 9 XP)
``` ```
Tier emoji mapping:
- 🔭 discovering (0 XP)
- 📖 familiar (50 XP)
- 🛠️ comfortable (150 XP)
- ⚡ proficient (350 XP)
- 🎯 expert (700 XP)
- 👑 master (1200 XP)
Read `roster.json``language_affinities`. Skip this section if empty.
--- ---
## `help` ## `help`