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
186 lines
6.6 KiB
Python
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()
|