buddymon/hooks-handlers/user-prompt-submit.py
pyr0ball 457276e302 feat: language mascot system + script-first architecture (v0.2.0)
Language mascots:
- 11 mascots across common/uncommon/rare/legendary tiers (Pythia, Asynclet,
  Bashling, Goroutling, Typeling, Vueling, Querion, Ferrix, Perlius,
  Cobolithon, Lispling)
- Full evolution chains for all mascots (16 evolutions total in catalog)
- Spawn via PostToolUse after language affinity milestones; probability
  scales with affinity level; only fires with no active encounter
- Passive strength reduction: each Write/Edit in the mascot's language
  ticks current_strength down (floor 5%, triggers re-announcement)
- Mascot-aware catch formula: base_rate + affinity_bonus (6% per level) +
  weakness_bonus + soft element gating via existing player_elements
- Language-themed weakening menu and catch failure messages in CLI
- Caught mascots stored as type="caught_language_mascot"; assignable as buddy
- UserPromptSubmit uses distinct 🦎 announcement with language context

Roster display:
- New "Language Mascots" section between core buddymon and caught bug monsters
- Language affinity table marks languages with spawnable mascots (🦎)
- Discovery counter now tracks both bug monsters and mascots separately

Veritarch (third evolution):
- Debuglin → Verifex (Lv.100) → Veritarch (Lv.200)
- TYPE FORTRESS / INVARIANT PROOF / ZERO FLAKE challenges
- xp_multiplier 1.7, catch_rate 0.90

Script-first architecture:
- All game logic extracted to lib/cli.py (~850 lines); SKILL.md is now
  a ~55-line relay — 88% token reduction per invocation
- CLI emits [INPUT_NEEDED] and [HAIKU_NEEDED] markers for interactive flows
- PostToolUse hook re-emits CLI stdout as additionalContext for inline display

Session XP fix:
- statusline.sh and session state now read from sessions/<pgrp>.json
  (per-window) with fallback to active.json; fixes stale XP in statusline
2026-04-10 01:31:51 -07:00

186 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
Buddymon UserPromptSubmit hook.
Fires on every user message. Checks for an unannounced active encounter
and surfaces it exactly once via additionalContext, then marks it announced
so the dedup loop breaks. Exits silently if nothing is pending.
"""
import json
import os
import sys
from pathlib import Path
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
def get_session_state() -> dict:
try:
with open(SESSION_FILE) as f:
return json.load(f)
except Exception:
global_active = {}
try:
with open(BUDDYMON_DIR / "active.json") as f:
global_active = json.load(f)
except Exception:
pass
return {
"buddymon_id": global_active.get("buddymon_id"),
"challenge": global_active.get("challenge"),
"session_xp": 0,
}
def load_json(path):
try:
with open(path) as f:
return json.load(f)
except Exception:
return {}
def save_json(path, data):
try:
with open(path, "w") as f:
json.dump(data, f, indent=2)
except Exception:
pass
def main():
try:
json.load(sys.stdin)
except Exception:
pass
roster = load_json(BUDDYMON_DIR / "roster.json")
if not roster.get("starter_chosen", False):
sys.exit(0)
enc_file = BUDDYMON_DIR / "encounters.json"
enc_data = load_json(enc_file)
enc = enc_data.get("active_encounter")
if not enc or enc.get("announced", False):
sys.exit(0)
# Mark announced FIRST — prevents re-announce even if output delivery fails
enc["announced"] = True
enc_data["active_encounter"] = enc
save_json(enc_file, enc_data)
# Resolve buddy display name from session-specific state
buddy_id = get_session_state().get("buddymon_id")
buddy_display = "your buddy"
if buddy_id:
catalog = load_json(CATALOG_FILE)
b = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id))
if b:
buddy_display = b.get("display", buddy_id)
else:
catalog = load_json(CATALOG_FILE)
rarity_stars = {
"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
}
strength = enc.get("current_strength", 50)
is_mascot = enc.get("encounter_type") == "language_mascot"
if is_mascot:
mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
rarity = mascot_data.get("rarity", "common")
stars = rarity_stars.get(rarity, "★★☆☆☆")
flavor = mascot_data.get("flavor", "")
lang = enc.get("language") or mascot_data.get("language", "")
assignable = mascot_data.get("assignable", False)
if enc.get("wounded"):
lines = [
f"\n🩹 **{enc['display']} is weakened and retreating!**",
f" Strength: {strength}% · Your {lang} work has worn it down.",
"",
f" **{buddy_display}** senses the opportunity — act now!",
"",
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
" `[IGNORE]` → it fades on your next edit",
]
else:
lines = [
f"\n🦎 **{enc['display']} appeared!** [language mascot · {rarity}]",
f" Language: {lang} · Strength: {strength}% · Rarity: {stars}",
]
if flavor:
lines.append(f" *{flavor}*")
if assignable:
lines.append(f" ✓ Catchable and assignable as buddy — has its own challenges.")
lines += [
"",
f" **{buddy_display}** is intrigued!",
"",
f" `[CATCH]` Code more in {lang} to weaken it → `/buddymon catch`",
" `[FLEE]` Ignore → it retreats as your affinity fades",
]
else:
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
rarity = monster.get("rarity", "common")
stars = rarity_stars.get(rarity, "★★☆☆☆")
defeatable = enc.get("defeatable", True)
catchable = enc.get("catchable", True)
flavor = monster.get("flavor", "")
if enc.get("wounded"):
lines = [
f"\n🩹 **{enc['display']} is wounded and fleeing!**",
f" Strength: {strength}% · This is your last chance to catch it.",
"",
f" **{buddy_display}** is ready — move fast!",
"",
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
" `[IGNORE]` → it flees on the next clean run",
]
else:
catchable_str = "[catchable · catch only]" if not defeatable else f"[{rarity} · {'catchable' if catchable else ''}]"
lines = [
f"\n💀 **{enc['display']} appeared!** {catchable_str}",
f" Strength: {strength}% · Rarity: {stars}",
]
if flavor:
lines.append(f" *{flavor}*")
rival_id = enc.get("rival") or monster.get("rival")
if rival_id:
rival_entry = (catalog.get("bug_monsters", {}).get(rival_id)
or catalog.get("event_encounters", {}).get(rival_id, {}))
rival_display = rival_entry.get("display", rival_id)
lines.append(f" ⚔️ Eternal rival of **{rival_display}** — catch both to settle the debate.")
if not defeatable:
lines.append(" ⚠️ CANNOT BE DEFEATED — catch only")
lines += [
"",
f" **{buddy_display}** is ready to battle!",
"",
" `[FIGHT]` Fix the bug → `/buddymon fight` to claim XP",
" `[CATCH]` Weaken first (test/repro/comment) → `/buddymon catch`",
" `[FLEE]` Ignore → monster grows stronger",
]
msg = "\n".join(lines)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": msg,
}
}))
sys.exit(0)
if __name__ == "__main__":
main()