From 457276e3024a2a708fdc74003aee8beb53228cdc Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 10 Apr 2026 01:31:51 -0700 Subject: [PATCH] feat: language mascot system + script-first architecture (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.json (per-window) with fallback to active.json; fixes stale XP in statusline --- hooks-handlers/post-tool-use.py | 208 +++- hooks-handlers/user-prompt-submit.py | 111 ++- install.sh | 6 +- lib/catalog.json | 1334 +++++++++++++++++++++++++- lib/cli.py | 881 +++++++++++++++++ lib/statusline.sh | 10 +- skills/buddymon/SKILL.md | 558 +---------- 7 files changed, 2490 insertions(+), 618 deletions(-) create mode 100644 lib/cli.py diff --git a/hooks-handlers/post-tool-use.py b/hooks-handlers/post-tool-use.py index 0aa09d7..2a3edad 100755 --- a/hooks-handlers/post-tool-use.py +++ b/hooks-handlers/post-tool-use.py @@ -108,6 +108,40 @@ LANGUAGE_TIERS = [ (1200, "master"), ] +# Maps each known language to its elemental type. +# Elements: systems, dynamic, typed, shell, web, data +LANGUAGE_ELEMENTS: dict[str, str] = { + "Python": "dynamic", + "JavaScript": "dynamic", + "TypeScript": "typed", + "JavaScript/React": "web", + "TypeScript/React": "web", + "Ruby": "dynamic", + "Go": "systems", + "Rust": "systems", + "C": "systems", + "C++": "systems", + "Java": "typed", + "C#": "typed", + "Swift": "typed", + "Kotlin": "typed", + "PHP": "web", + "Lua": "dynamic", + "Elixir": "dynamic", + "Haskell": "typed", + "OCaml": "typed", + "Clojure": "dynamic", + "R": "data", + "Julia": "data", + "Shell": "shell", + "SQL": "data", + "HTML": "web", + "CSS": "web", + "SCSS": "web", + "Vue": "web", + "Svelte": "web", +} + def _tier_for_xp(xp: int) -> tuple[int, str]: """Return (level_index, tier_label) for a given XP total.""" @@ -147,6 +181,47 @@ def add_language_affinity(lang: str, xp_amount: int) -> tuple[bool, str, str]: return leveled_up, old_tier, new_tier +def get_player_elements(min_level: int = 2) -> set[str]: + """Return the set of elements the player has meaningful affinity in (level >= min_level = 'comfortable'+).""" + roster = load_json(BUDDYMON_DIR / "roster.json") + elements: set[str] = set() + for lang, entry in roster.get("language_affinities", {}).items(): + if entry.get("level", 0) >= min_level: + elem = LANGUAGE_ELEMENTS.get(lang) + if elem: + elements.add(elem) + return elements + + +def element_multiplier(encounter: dict, player_elements: set[str]) -> float: + """Return a wound/resolve rate multiplier based on elemental matchup. + + super effective (+25%): player has an element the monster is weak_against + not very effective (-15%): player element is in monster's strong_against + immune (-30%): all player elements are in monster's immune_to + mixed (+5%): advantage and disadvantage cancel, slight net bonus + """ + if not player_elements: + return 1.0 + weak = set(encounter.get("weak_against", [])) + strong = set(encounter.get("strong_against", [])) + immune = set(encounter.get("immune_to", [])) + + has_advantage = bool(player_elements & weak) + has_disadvantage = bool(player_elements & strong) + fully_immune = bool(immune) and player_elements.issubset(immune) + + if fully_immune: + return 0.70 + elif has_advantage and has_disadvantage: + return 1.05 # mixed β€” slight net bonus + elif has_advantage: + return 1.25 + elif has_disadvantage: + return 0.85 + return 1.0 + + def get_languages_seen(): session = load_json(BUDDYMON_DIR / "session.json") return set(session.get("languages_seen", [])) @@ -278,36 +353,6 @@ def compute_strength(monster: dict, elapsed_minutes: float) -> int: return min(100, int(base * 1.8)) -def format_encounter_message(monster: dict, strength: int, buddy_display: str) -> str: - rarity_stars = {"very_common": "β˜…β˜†β˜†β˜†β˜†", "common": "β˜…β˜…β˜†β˜†β˜†", - "uncommon": "β˜…β˜…β˜…β˜†β˜†", "rare": "β˜…β˜…β˜…β˜…β˜†", "legendary": "β˜…β˜…β˜…β˜…β˜…"} - stars = rarity_stars.get(monster.get("rarity", "common"), "β˜…β˜…β˜†β˜†β˜†") - defeatable = monster.get("defeatable", True) - catch_note = "[catchable]" if monster.get("catchable") else "" - fight_note = "" if defeatable else "⚠️ CANNOT BE DEFEATED β€” catch only" - - catchable_str = "[catchable Β· catch only]" if not defeatable else f"[{monster.get('rarity','?')} Β· {catch_note}]" - - lines = [ - f"\nπŸ’€ **{monster['display']} appeared!** {catchable_str}", - f" Strength: {strength}% Β· Rarity: {stars}", - f" *{monster.get('flavor', '')}*", - "", - ] - if fight_note: - lines.append(f" {fight_note}") - lines.append("") - lines += [ - f" **{buddy_display}** is ready to battle!", - "", - " `[FIGHT]` Beat the bug β†’ your buddy defeats it β†’ XP reward", - " `[CATCH]` Weaken it first (write a test, isolate repro, add comment) β†’ attempt catch", - " `[FLEE]` Ignore β†’ monster grows stronger", - "", - " Use `/buddymon fight` or `/buddymon catch` to weaken + catch it.", - ] - return "\n".join(lines) - def match_event_encounter(command: str, output: str, session: dict, catalog: dict): """Detect non-error-based encounters: git ops, installs, test results.""" @@ -355,12 +400,73 @@ def spawn_encounter(enc: dict) -> None: "catchable": enc.get("catchable", True), "defeatable": enc.get("defeatable", True), "xp_reward": enc.get("xp_reward", 50), + "rarity": enc.get("rarity", "common"), + "weak_against": enc.get("weak_against", []), + "strong_against": enc.get("strong_against", []), + "immune_to": enc.get("immune_to", []), + "rival": enc.get("rival"), + # Language mascot fields (no-op for regular encounters) + "encounter_type": enc.get("type", "event"), + "language": enc.get("language"), + "passive_reduction_per_use": enc.get("passive_reduction_per_use", 0), "weakened_by": [], "announced": False, } set_active_encounter(encounter) +def try_spawn_language_mascot(lang: str, affinity_level: int, catalog: dict) -> dict | None: + """Try to spawn a language mascot for lang at the given affinity level. + + Probability = base_rate * (1 + affinity_level * affinity_scale). + Only fires if no active encounter exists. + Returns the mascot dict if spawned, None otherwise. + """ + for _mid, mascot in catalog.get("language_mascots", {}).items(): + if mascot.get("language") != lang: + continue + spawn_cfg = mascot.get("spawn", {}) + if affinity_level < spawn_cfg.get("min_affinity_level", 1): + continue + base_rate = spawn_cfg.get("base_rate", 0.02) + affinity_scale = spawn_cfg.get("affinity_scale", 0.3) + prob = base_rate * (1 + affinity_level * affinity_scale) + if random.random() < prob: + spawn_encounter(mascot) + return mascot + return None + + +def apply_passive_mascot_reduction(lang: str) -> bool: + """If the active encounter is a language mascot for lang, tick down its strength. + + Returns True if a reduction was applied. + """ + enc_file = BUDDYMON_DIR / "encounters.json" + enc_data = load_json(enc_file) + enc = enc_data.get("active_encounter") + if not enc: + return False + if enc.get("encounter_type") != "language_mascot": + return False + if enc.get("language") != lang: + return False + + reduction = enc.get("passive_reduction_per_use", 5) + old_strength = enc.get("current_strength", enc.get("base_strength", 50)) + new_strength = max(5, old_strength - reduction) + enc["current_strength"] = new_strength + + # Flag as wounded (and trigger re-announcement) when freshly floored + if new_strength <= 5 and not enc.get("wounded"): + enc["wounded"] = True + enc["announced"] = False + + enc_data["active_encounter"] = enc + save_json(enc_file, enc_data) + return True + + def format_new_language_message(lang: str, buddy_display: str) -> str: return ( f"\nπŸ—ΊοΈ **New language spotted: {lang}!**\n" @@ -449,6 +555,19 @@ def main(): if isinstance(b, dict) and b.get("type") == "text" ) + # ── Buddymon CLI display: surface output as additionalContext ───────── + # Bash tool output is collapsed by default in CC's UI. When the skill + # runs the CLI, re-emit stdout here so it's always visible inline. + if "buddymon/cli.py" in command or "buddymon cli.py" in command: + if output.strip(): + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": output.strip(), + } + })) + sys.exit(0) # Skip XP tracking / error scanning for game commands + existing = get_active_encounter() if existing: @@ -481,6 +600,7 @@ def main(): roster = load_json(BUDDYMON_DIR / "roster.json") buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1) level_scale = 1.0 + (buddy_level / 100) * 0.25 + elem_mult = element_multiplier(existing, get_player_elements()) # Wound cooldown: skip if another session wounded within 30s last_wound = existing.get("last_wounded_at", "") @@ -495,7 +615,7 @@ def main(): pass if existing.get("wounded"): - resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale) + resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale * elem_mult) if wound_cooldown_ok and random.random() < resolve_rate: xp, display = auto_resolve_encounter(existing, buddy_id) messages.append( @@ -503,7 +623,7 @@ def main(): f" {buddy_display} gets partial XP: +{xp}\n" ) else: - wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale) + wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale * elem_mult) if wound_cooldown_ok and random.random() < wound_rate: wound_encounter() # else: monster still present, no message β€” don't spam every tool call @@ -527,6 +647,11 @@ def main(): "catchable": target.get("catchable", True), "defeatable": target.get("defeatable", True), "xp_reward": target.get("xp_reward", 50), + "rarity": target.get("rarity", "common"), + "weak_against": target.get("weak_against", []), + "strong_against": target.get("strong_against", []), + "immune_to": target.get("immune_to", []), + "rival": target.get("rival"), "weakened_by": [], "announced": False, } @@ -549,13 +674,16 @@ def main(): ext = os.path.splitext(file_path)[1].lower() lang = KNOWN_EXTENSIONS.get(ext) if lang: - # Session-level "first encounter" bonus + # Session-level "first encounter" bonus β€” only announce if genuinely new + # (zero affinity XP). Languages already in the roster just get quiet XP. seen = get_languages_seen() if lang not in seen: add_language_seen(lang) - add_session_xp(15) - msg = format_new_language_message(lang, buddy_display) - messages.append(msg) + affinity = get_language_affinity(lang) + if affinity.get("xp", 0) == 0: + add_session_xp(15) + msg = format_new_language_message(lang, buddy_display) + messages.append(msg) # Persistent affinity XP β€” always accumulates leveled_up, old_tier, new_tier = add_language_affinity(lang, 3) @@ -564,6 +692,14 @@ def main(): msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display) messages.append(msg) + # Passive mascot reduction: coding in this language weakens its encounter + apply_passive_mascot_reduction(lang) + + # Language mascot spawn: try after affinity update, only if no active encounter + if not get_active_encounter(): + affinity = get_language_affinity(lang) + try_spawn_language_mascot(lang, affinity.get("level", 0), catalog) + # TestSpecter: editing a test file with no active encounter if not get_active_encounter(): enc = match_test_file_encounter(file_path, catalog) diff --git a/hooks-handlers/user-prompt-submit.py b/hooks-handlers/user-prompt-submit.py index 843c4e1..1c35f2f 100644 --- a/hooks-handlers/user-prompt-submit.py +++ b/hooks-handlers/user-prompt-submit.py @@ -88,48 +88,89 @@ def main(): else: catalog = load_json(CATALOG_FILE) - monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {}) - rarity = monster.get("rarity", "common") rarity_stars = { "very_common": "β˜…β˜†β˜†β˜†β˜†", "common": "β˜…β˜…β˜†β˜†β˜†", "uncommon": "β˜…β˜…β˜…β˜†β˜†", "rare": "β˜…β˜…β˜…β˜…β˜†", "legendary": "β˜…β˜…β˜…β˜…β˜…", } - stars = rarity_stars.get(rarity, "β˜…β˜…β˜†β˜†β˜†") strength = enc.get("current_strength", 50) - defeatable = enc.get("defeatable", True) - catchable = enc.get("catchable", True) - flavor = monster.get("flavor", "") + is_mascot = enc.get("encounter_type") == "language_mascot" - if enc.get("wounded"): - # Wounded re-announcement β€” urgent, catch-or-lose framing - 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", - ] + 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: - # Normal first appearance - 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}*") - 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", - ] + 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({ diff --git a/install.sh b/install.sh index 0f977bd..8da5a5d 100755 --- a/install.sh +++ b/install.sh @@ -278,11 +278,15 @@ if not os.path.exists(log_path): print(" Created hook_debug.log") PYEOF - # Copy statusline script to stable user-local path + # Copy lib scripts to stable user-local path (accessible even without CLAUDE_PLUGIN_ROOT) cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh" chmod +x "${BUDDYMON_DIR}/statusline.sh" ok "Installed statusline.sh β†’ ${BUDDYMON_DIR}/statusline.sh" + cp "${REPO_DIR}/lib/cli.py" "${BUDDYMON_DIR}/cli.py" + chmod +x "${BUDDYMON_DIR}/cli.py" + ok "Installed cli.py β†’ ${BUDDYMON_DIR}/cli.py" + # Install statusline into settings.json if not already configured python3 << PYEOF import json diff --git a/lib/catalog.json b/lib/catalog.json index dcfec58..4a04b4e 100644 --- a/lib/catalog.json +++ b/lib/catalog.json @@ -34,7 +34,15 @@ "strength_reduction": 10 } ], - "flavor": "It was there this whole time. You just never checked." + "flavor": "It was there this whole time. You just never checked.", + "weak_against": [ + "typed", + "dynamic" + ], + "strong_against": [ + "systems" + ], + "immune_to": [] }, "FencepostDemon": { "id": "FencepostDemon", @@ -67,7 +75,15 @@ "strength_reduction": 10 } ], - "flavor": "Always one step ahead. Or behind. It's hard to tell." + "flavor": "Always one step ahead. Or behind. It's hard to tell.", + "weak_against": [ + "typed", + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "TypeGreml": { "id": "TypeGreml", @@ -100,7 +116,14 @@ "strength_reduction": 10 } ], - "flavor": "It only attacks when you're absolutely sure about the type." + "flavor": "It only attacks when you're absolutely sure about the type.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "SyntaxSerpent": { "id": "SyntaxSerpent", @@ -130,7 +153,13 @@ "strength_reduction": 20 } ], - "flavor": "You'll be embarrassed you let this one survive long enough to catch." + "flavor": "You'll be embarrassed you let this one survive long enough to catch.", + "weak_against": [ + "typed", + "shell" + ], + "strong_against": [], + "immune_to": [] }, "CORSCurse": { "id": "CORSCurse", @@ -163,7 +192,16 @@ "strength_reduction": 10 } ], - "flavor": "It's not your fault. Well. It kind of is." + "flavor": "It's not your fault. Well. It kind of is.", + "weak_against": [ + "web", + "typed" + ], + "strong_against": [ + "systems", + "data" + ], + "immune_to": [] }, "LoopLich": { "id": "LoopLich", @@ -200,7 +238,15 @@ "strength_reduction": 15 } ], - "flavor": "The exit condition was always there. You just never believed in it." + "flavor": "The exit condition was always there. You just never believed in it.", + "weak_against": [ + "typed", + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "RacePhantom": { "id": "RacePhantom", @@ -235,7 +281,16 @@ "strength_reduction": 15 } ], - "flavor": "You've proven it exists. That's honestly impressive on its own." + "flavor": "You've proven it exists. That's honestly impressive on its own.", + "weak_against": [ + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [ + "web" + ] }, "FossilGolem": { "id": "FossilGolem", @@ -269,7 +324,13 @@ "strength_reduction": 20 } ], - "flavor": "It survived every major version. It will outlast you." + "flavor": "It survived every major version. It will outlast you.", + "weak_against": [ + "shell", + "typed" + ], + "strong_against": [], + "immune_to": [] }, "ShadowBit": { "id": "ShadowBit", @@ -314,7 +375,16 @@ "strength_reduction": 20 } ], - "flavor": "Defeat is not an option. Containment is the only victory." + "flavor": "Defeat is not an option. Containment is the only victory.", + "weak_against": [ + "systems", + "typed" + ], + "strong_against": [ + "dynamic", + "web" + ], + "immune_to": [] }, "VoidSpecter": { "id": "VoidSpecter", @@ -348,7 +418,14 @@ "strength_reduction": 10 } ], - "flavor": "It used to exist. Probably." + "flavor": "It used to exist. Probably.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "systems" + ], + "immune_to": [] }, "MemoryLeech": { "id": "MemoryLeech", @@ -390,7 +467,17 @@ "strength_reduction": 10 } ], - "flavor": "It was already there when you opened the task manager." + "flavor": "It was already there when you opened the task manager.", + "weak_against": [ + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [ + "web", + "data" + ] }, "CudaCrash": { "id": "CudaCrash", @@ -427,7 +514,17 @@ "strength_reduction": 10 } ], - "flavor": "Your model fit in VRAM yesterday. You added one layer." + "flavor": "Your model fit in VRAM yesterday. You added one layer.", + "weak_against": [ + "systems", + "data" + ], + "strong_against": [ + "web" + ], + "immune_to": [ + "shell" + ] }, "InfiniteWisp": { "id": "InfiniteWisp", @@ -462,7 +559,15 @@ "strength_reduction": 10 } ], - "flavor": "Your fan was always loud. You just never checked why." + "flavor": "Your fan was always loud. You just never checked why.", + "weak_against": [ + "typed", + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "BoundsHound": { "id": "BoundsHound", @@ -499,7 +604,15 @@ "strength_reduction": 10 } ], - "flavor": "It was always length minus one. You just forgot." + "flavor": "It was always length minus one. You just forgot.", + "weak_against": [ + "systems", + "typed" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "BranchGhost": { "id": "BranchGhost", @@ -535,7 +648,13 @@ "strength_reduction": 10 } ], - "flavor": "You were so sure that case was impossible." + "flavor": "You were so sure that case was impossible.", + "weak_against": [ + "typed", + "dynamic" + ], + "strong_against": [], + "immune_to": [] }, "SwitchTrap": { "id": "SwitchTrap", @@ -573,7 +692,14 @@ "strength_reduction": 10 } ], - "flavor": "You added that new enum value last week. The switch didn't notice." + "flavor": "You added that new enum value last week. The switch didn't notice.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "shell" + ], + "immune_to": [] }, "RecurseWraith": { "id": "RecurseWraith", @@ -609,7 +735,15 @@ "strength_reduction": 10 } ], - "flavor": "The base case was there. It just couldn't be reached." + "flavor": "The base case was there. It just couldn't be reached.", + "weak_against": [ + "typed", + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "CatchAll": { "id": "CatchAll", @@ -645,7 +779,14 @@ "strength_reduction": 15 } ], - "flavor": "If you catch everything, you learn nothing." + "flavor": "If you catch everything, you learn nothing.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] }, "LeakWraith": { "id": "LeakWraith", @@ -690,7 +831,15 @@ "strength_reduction": 20 } ], - "flavor": "Rotation is not optional. Neither is the audit." + "flavor": "Rotation is not optional. Neither is the audit.", + "weak_against": [ + "systems", + "shell" + ], + "strong_against": [ + "web" + ], + "immune_to": [] }, "CipherNull": { "id": "CipherNull", @@ -731,7 +880,18 @@ "strength_reduction": 25 } ], - "flavor": "MD5 was deprecated before some of your dependencies were written." + "flavor": "MD5 was deprecated before some of your dependencies were written.", + "weak_against": [ + "systems", + "typed" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [ + "web", + "shell" + ] }, "ConsentShadow": { "id": "ConsentShadow", @@ -776,7 +936,15 @@ "strength_reduction": 30 } ], - "flavor": "Plain-language consent. Always. No pre-checked boxes." + "flavor": "Plain-language consent. Always. No pre-checked boxes.", + "weak_against": [ + "web", + "typed" + ], + "strong_against": [ + "systems" + ], + "immune_to": [] }, "ThrottleDemon": { "id": "ThrottleDemon", @@ -816,7 +984,15 @@ "strength_reduction": 40 } ], - "flavor": "Exponential backoff with jitter. Every time." + "flavor": "Exponential backoff with jitter. Every time.", + "weak_against": [ + "typed", + "web" + ], + "strong_against": [], + "immune_to": [ + "systems" + ] }, "PrivacyLich": { "id": "PrivacyLich", @@ -845,7 +1021,17 @@ "notification.*breach" ], "weaken_actions": [], - "flavor": "Some debts cannot be paid. They can only be carried responsibly." + "flavor": "Some debts cannot be paid. They can only be carried responsibly.", + "weak_against": [ + "typed", + "web", + "data" + ], + "strong_against": [], + "immune_to": [ + "systems", + "shell" + ] }, "Sedamentalisk": { "id": "Sedamentalisk", @@ -891,7 +1077,72 @@ "strength_reduction": 35 } ], - "flavor": "Knows every line of your file. Edited them all. Saved none." + "flavor": "Knows every line of your file. Edited them all. Saved none.", + "weak_against": [ + "shell" + ], + "strong_against": [ + "typed", + "dynamic" + ], + "immune_to": [ + "web", + "data" + ] + }, + "Taborel": { + "id": "Taborel", + "display": "\u21e5 Taborel", + "rarity": "uncommon", + "affinity": "indent", + "base_strength": 55, + "catchable": true, + "defeatable": true, + "xp_reward": 90, + "flavor": "Ancient and immovable. Eight columns wide. Will outlast your linter, your team, and your company.", + "error_patterns": [ + "TabError", + "inconsistent use of tabs and spaces", + "mixed tabs and spaces", + "W191", + "E101" + ], + "weak_against": [ + "dynamic" + ], + "strong_against": [ + "systems" + ], + "immune_to": [], + "rival": "Spaciel" + }, + "Spaciel": { + "id": "Spaciel", + "display": "\u00b7\u2074 Spaciel", + "rarity": "uncommon", + "affinity": "indent", + "base_strength": 55, + "catchable": true, + "defeatable": true, + "xp_reward": 90, + "flavor": "Precisely four. Always four. Has memorized every PEP 8 clause and will recite them unprompted.", + "error_patterns": [ + "IndentationError", + "unexpected indent", + "expected an indented block", + "unindent does not match", + "W291", + "E111", + "E114" + ], + "weak_against": [ + "systems" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [], + "rival": "Taborel" } }, "event_encounters": { @@ -1398,7 +1649,13 @@ "power": 60, "catch_rate": 0.75, "xp_multiplier": 1.3 - } + }, + "evolutions": [ + { + "level": 200, + "into": "Veritarch" + } + ] }, "Nullex": { "id": "Nullex", @@ -1412,6 +1669,1033 @@ "catch_rate": 0.65, "xp_multiplier": 1.4 } + }, + "Veritarch": { + "id": "Veritarch", + "display": "\ud83c\udfdb\ufe0f Veritarch", + "type": "buddymon", + "evolves_from": "Verifex", + "affinity": "Tester", + "description": "Makes incorrect states structurally impossible. Doesn't find bugs \u2014 it prevents entire classes of them from existing.", + "base_stats": { + "power": 85, + "catch_rate": 0.9, + "xp_multiplier": 1.7 + }, + "challenges": [ + { + "name": "TYPE FORTRESS", + "description": "Eliminate all `Any` types in a file", + "xp": 500, + "difficulty": 4 + }, + { + "name": "INVARIANT PROOF", + "description": "Write property-based tests for a module", + "xp": 600, + "difficulty": 5 + }, + { + "name": "ZERO FLAKE", + "description": "Run the full test suite 3 times with no flakes", + "xp": 400, + "difficulty": 3 + } + ], + "flavor": "The type system is the test suite." + }, + "Anacondex": { + "id": "Anacondex", + "display": "\ud83d\udc0d\u2728 Anacondex", + "type": "language_mascot", + "evolves_from": "Pythia", + "language": "Python", + "element": "dynamic", + "description": "Pythia, fully grown. Wraps problems in abstractions before they know what happened.", + "base_stats": { + "power": 60, + "catch_rate": 0.55, + "xp_multiplier": 1.3 + }, + "evolutions": [ + { + "level": 120, + "into": "Constrix" + } + ], + "challenges": [ + { + "name": "DECORATOR CHAIN", + "description": "Write 3 composable decorators", + "xp": 350, + "difficulty": 3 + }, + { + "name": "ASYNC PYTHIA", + "description": "Convert a sync module to fully async", + "xp": 400, + "difficulty": 3 + } + ], + "flavor": "It already knows what you're going to type." + }, + "Constrix": { + "id": "Constrix", + "display": "\ud83d\udc0d\ud83d\udc51 Constrix", + "type": "language_mascot", + "evolves_from": "Anacondex", + "language": "Python", + "element": "dynamic", + "description": "The final form. Python's full expressive power, fully under control.", + "base_stats": { + "power": 85, + "catch_rate": 0.7, + "xp_multiplier": 1.6 + }, + "evolutions": [], + "challenges": [ + { + "name": "METACLASS MASTER", + "description": "Implement a metaclass that enforces an interface", + "xp": 700, + "difficulty": 5 + }, + { + "name": "PROTOCOL PURE", + "description": "Refactor a class hierarchy to use Protocols only", + "xp": 600, + "difficulty": 4 + } + ], + "flavor": "It doesn't need to check the type. It already knows." + }, + "Eventide": { + "id": "Eventide", + "display": "\ud83c\udf0a Eventide", + "type": "language_mascot", + "evolves_from": "Asynclet", + "language": "JavaScript", + "element": "dynamic", + "description": "Mastered the event loop. Treats Promises like promises.", + "base_stats": { + "power": 58, + "catch_rate": 0.5, + "xp_multiplier": 1.25 + }, + "evolutions": [ + { + "level": 120, + "into": "Promisarch" + } + ], + "challenges": [ + { + "name": "PROMISE CHAIN", + "description": "Untangle a nested Promise chain into clean async/await", + "xp": 300, + "difficulty": 2 + } + ], + "flavor": "The callback era is over. Eventide made sure of that." + }, + "Promisarch": { + "id": "Promisarch", + "display": "\u221e Promisarch", + "type": "language_mascot", + "evolves_from": "Eventide", + "language": "JavaScript", + "element": "dynamic", + "description": "Every async operation resolves. Every edge case is handled.", + "base_stats": { + "power": 80, + "catch_rate": 0.65, + "xp_multiplier": 1.55 + }, + "evolutions": [], + "challenges": [ + { + "name": "OBSERVABLE MIND", + "description": "Implement reactive state with no framework", + "xp": 600, + "difficulty": 5 + } + ], + "flavor": "It never rejects. It just resolves differently." + }, + "Pipewyrm": { + "id": "Pipewyrm", + "display": "\ud83d\udd17 Pipewyrm", + "type": "language_mascot", + "evolves_from": "Bashling", + "language": "Shell", + "element": "shell", + "description": "Lives in the pipes between commands. Feeds on stdout.", + "base_stats": { + "power": 55, + "catch_rate": 0.52, + "xp_multiplier": 1.25 + }, + "evolutions": [ + { + "level": 120, + "into": "Hexecutor" + } + ], + "challenges": [ + { + "name": "PIPE DREAM", + "description": "Build a 5-stage pipeline to transform data", + "xp": 250, + "difficulty": 2 + } + ], + "flavor": "| sort | uniq | head -1" + }, + "Hexecutor": { + "id": "Hexecutor", + "display": "\ud83d\udcbb Hexecutor", + "type": "language_mascot", + "evolves_from": "Pipewyrm", + "language": "Shell", + "element": "shell", + "description": "Knows every flag of every Unix command. Writes shell scripts that outlive their authors.", + "base_stats": { + "power": 78, + "catch_rate": 0.65, + "xp_multiplier": 1.5 + }, + "evolutions": [], + "challenges": [ + { + "name": "POSIX PURE", + "description": "Rewrite a bash script to work in plain sh", + "xp": 500, + "difficulty": 4 + } + ], + "flavor": "#!/bin/sh" + }, + "Concurrex": { + "id": "Concurrex", + "display": "\ud83d\udd00 Concurrex", + "type": "language_mascot", + "evolves_from": "Goroutling", + "language": "Go", + "element": "typed", + "description": "Orchestrates goroutines like a conductor. Nothing leaks. Nothing races.", + "base_stats": { + "power": 62, + "catch_rate": 0.5, + "xp_multiplier": 1.3 + }, + "evolutions": [ + { + "level": 120, + "into": "Gorchitect" + } + ], + "challenges": [ + { + "name": "CONTEXT CANCEL", + "description": "Add context cancellation to all goroutines in a package", + "xp": 350, + "difficulty": 3 + } + ], + "flavor": "Do not communicate by sharing memory. Share memory by communicating." + }, + "Gorchitect": { + "id": "Gorchitect", + "display": "\ud83c\udfd7\ufe0f Gorchitect", + "type": "language_mascot", + "evolves_from": "Concurrex", + "language": "Go", + "element": "typed", + "description": "Designs systems that scale horizontally by nature. Interfaces are its grammar.", + "base_stats": { + "power": 82, + "catch_rate": 0.65, + "xp_multiplier": 1.55 + }, + "evolutions": [], + "challenges": [ + { + "name": "INTERFACE ARCHITECT", + "description": "Redesign a package around small, composable interfaces", + "xp": 600, + "difficulty": 4 + } + ], + "flavor": "A little copying is better than a little dependency." + }, + "Schemix": { + "id": "Schemix", + "display": "\ud83d\udcd0 Schemix", + "type": "language_mascot", + "evolves_from": "Typeling", + "language": "TypeScript", + "element": "typed", + "description": "Validates at every boundary. Runtime and compile-time are one.", + "base_stats": { + "power": 65, + "catch_rate": 0.48, + "xp_multiplier": 1.35 + }, + "evolutions": [ + { + "level": 130, + "into": "Typearch" + } + ], + "challenges": [ + { + "name": "BRANDED TYPES", + "description": "Use branded/nominal types to prevent ID confusion", + "xp": 400, + "difficulty": 4 + } + ], + "flavor": "The schema is the source of truth." + }, + "Typearch": { + "id": "Typearch", + "display": "\ud83c\udfdb\ufe0f Typearch", + "type": "language_mascot", + "evolves_from": "Schemix", + "language": "TypeScript", + "element": "typed", + "description": "The type system bends to its will. Discriminated unions are its mother tongue.", + "base_stats": { + "power": 85, + "catch_rate": 0.68, + "xp_multiplier": 1.6 + }, + "evolutions": [], + "challenges": [ + { + "name": "PHANTOM TYPES", + "description": "Implement a state machine using the type system alone", + "xp": 800, + "difficulty": 5 + } + ], + "flavor": "If it compiles, it's probably right." + }, + "Componentix": { + "id": "Componentix", + "display": "\ud83d\udd37 Componentix", + "type": "language_mascot", + "evolves_from": "Vueling", + "language": "Vue", + "element": "web", + "description": "Every UI is a tree of well-composed components. Props down, events up.", + "base_stats": { + "power": 62, + "catch_rate": 0.5, + "xp_multiplier": 1.3 + }, + "evolutions": [ + { + "level": 130, + "into": "Vitemorph" + } + ], + "challenges": [ + { + "name": "HEADLESS", + "description": "Extract a headless composable from a component", + "xp": 350, + "difficulty": 3 + } + ], + "flavor": "The template is a contract. The composable is the logic." + }, + "Vitemorph": { + "id": "Vitemorph", + "display": "\ud83d\ude80 Vitemorph", + "type": "language_mascot", + "evolves_from": "Componentix", + "language": "Vue", + "element": "web", + "description": "Bundles in milliseconds. Hot-reloads before you lift your fingers.", + "base_stats": { + "power": 82, + "catch_rate": 0.66, + "xp_multiplier": 1.55 + }, + "evolutions": [], + "challenges": [ + { + "name": "ZERO HYDRATION", + "description": "Build a page with no hydration errors", + "xp": 600, + "difficulty": 4 + } + ], + "flavor": "Cold start: 0ms." + }, + "Indexer": { + "id": "Indexer", + "display": "\ud83d\udcca Indexer", + "type": "language_mascot", + "evolves_from": "Querion", + "language": "SQL", + "element": "data", + "description": "Knows the query plan before EXPLAIN runs. Every column earns its place.", + "base_stats": { + "power": 62, + "catch_rate": 0.48, + "xp_multiplier": 1.3 + }, + "evolutions": [ + { + "level": 130, + "into": "Schemarch" + } + ], + "challenges": [ + { + "name": "EXPLAIN MASTER", + "description": "Optimize a query until EXPLAIN shows no seq scans", + "xp": 400, + "difficulty": 3 + } + ], + "flavor": "The B-tree is already sorted." + }, + "Schemarch": { + "id": "Schemarch", + "display": "\ud83c\udfdb\ufe0f Schemarch", + "type": "language_mascot", + "evolves_from": "Indexer", + "language": "SQL", + "element": "data", + "description": "Designs schemas that outlive their applications. Normalization is instinct.", + "base_stats": { + "power": 80, + "catch_rate": 0.64, + "xp_multiplier": 1.55 + }, + "evolutions": [], + "challenges": [ + { + "name": "MIGRATE ZERO", + "description": "Write a migration with no downtime", + "xp": 700, + "difficulty": 5 + } + ], + "flavor": "Third normal form is a floor, not a ceiling." + }, + "Borrowkin": { + "id": "Borrowkin", + "display": "\u26d3\ufe0f Borrowkin", + "type": "language_mascot", + "evolves_from": "Ferrix", + "language": "Rust", + "element": "systems", + "description": "The borrow checker is not a constraint \u2014 it's a collaborator.", + "base_stats": { + "power": 82, + "catch_rate": 0.4, + "xp_multiplier": 1.5 + }, + "evolutions": [ + { + "level": 150, + "into": "Lifetimer" + } + ], + "challenges": [ + { + "name": "ARC WELDER", + "description": "Implement shared ownership without Mutex deadlock", + "xp": 600, + "difficulty": 5 + } + ], + "flavor": "Ownership is clear. Lifetimes are explicit. Nothing leaks." + }, + "Lifetimer": { + "id": "Lifetimer", + "display": "\u267e\ufe0f Lifetimer", + "type": "language_mascot", + "evolves_from": "Borrowkin", + "language": "Rust", + "element": "systems", + "description": "Understands lifetimes the way others understand variable names. Everything lives exactly as long as it should.", + "base_stats": { + "power": 95, + "catch_rate": 0.58, + "xp_multiplier": 1.8 + }, + "evolutions": [], + "challenges": [ + { + "name": "UNSAFE FREE", + "description": "Implement a lock-free data structure in safe Rust", + "xp": 1000, + "difficulty": 5 + } + ], + "flavor": "'static means forever. Forever is a long time." + } + }, + "language_mascots": { + "Pythia": { + "id": "Pythia", + "display": "\ud83d\udc0d Pythia", + "type": "language_mascot", + "language": "Python", + "element": "dynamic", + "rarity": "common", + "assignable": true, + "base_strength": 55, + "xp_reward": 90, + "spawn": { + "min_affinity_level": 1, + "base_rate": 0.04, + "affinity_scale": 0.4 + }, + "passive_reduction_per_use": 5, + "description": "Ancient oracle of the dynamic tongue. Speaks in generators and comprehensions.", + "base_stats": { + "power": 40, + "catch_rate": 0.4, + "xp_multiplier": 1.15 + }, + "challenges": [ + { + "name": "COMPREHENSION MASTER", + "description": "Use list/dict/set comprehension in 3 files", + "xp": 200, + "difficulty": 2 + }, + { + "name": "GENERATOR CHAIN", + "description": "Write a generator function", + "xp": 150, + "difficulty": 1 + }, + { + "name": "TYPE ANNOTATE", + "description": "Add type hints to all functions in a module", + "xp": 250, + "difficulty": 2 + } + ], + "evolutions": [ + { + "level": 50, + "into": "Anacondex" + } + ], + "flavor": "The oracle always knew what type it was going to be.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "systems" + ], + "immune_to": [] + }, + "Asynclet": { + "id": "Asynclet", + "display": "\u26a1 Asynclet", + "type": "language_mascot", + "language": "JavaScript", + "element": "dynamic", + "rarity": "common", + "assignable": true, + "base_strength": 50, + "xp_reward": 85, + "spawn": { + "min_affinity_level": 1, + "base_rate": 0.04, + "affinity_scale": 0.35 + }, + "passive_reduction_per_use": 5, + "description": "Born from the event loop. Lives between callbacks. Never blocks.", + "base_stats": { + "power": 38, + "catch_rate": 0.38, + "xp_multiplier": 1.1 + }, + "challenges": [ + { + "name": "ASYNC ALL THE WAY", + "description": "Convert a callback chain to async/await", + "xp": 200, + "difficulty": 2 + }, + { + "name": "EVENT MASTER", + "description": "Implement an EventEmitter pattern", + "xp": 180, + "difficulty": 2 + } + ], + "evolutions": [ + { + "level": 50, + "into": "Eventide" + } + ], + "flavor": "It'll get to it. Eventually. In the next tick.", + "weak_against": [ + "typed" + ], + "strong_against": [ + "systems" + ], + "immune_to": [] + }, + "Bashling": { + "id": "Bashling", + "display": "\ud83d\udcdf Bashling", + "type": "language_mascot", + "language": "Shell", + "element": "shell", + "rarity": "common", + "assignable": true, + "base_strength": 45, + "xp_reward": 80, + "spawn": { + "min_affinity_level": 1, + "base_rate": 0.05, + "affinity_scale": 0.3 + }, + "passive_reduction_per_use": 6, + "description": "Stitched together from pipes and redirects. Thrives in the terminal.", + "base_stats": { + "power": 35, + "catch_rate": 0.42, + "xp_multiplier": 1.1 + }, + "challenges": [ + { + "name": "ONE-LINER", + "description": "Accomplish a task in a single pipeline", + "xp": 150, + "difficulty": 1 + }, + { + "name": "TRAP MASTER", + "description": "Write a script with proper error traps", + "xp": 200, + "difficulty": 2 + } + ], + "evolutions": [ + { + "level": 50, + "into": "Pipewyrm" + } + ], + "flavor": "If it's not in PATH, it doesn't exist.", + "weak_against": [ + "dynamic", + "typed" + ], + "strong_against": [], + "immune_to": [ + "web" + ] + }, + "Goroutling": { + "id": "Goroutling", + "display": "\ud83d\udc39 Goroutling", + "type": "language_mascot", + "language": "Go", + "element": "typed", + "rarity": "common", + "assignable": true, + "base_strength": 60, + "xp_reward": 95, + "spawn": { + "min_affinity_level": 1, + "base_rate": 0.035, + "affinity_scale": 0.4 + }, + "passive_reduction_per_use": 5, + "description": "Spawns goroutines like they're free. Respects the context. Always handles errors.", + "base_stats": { + "power": 42, + "catch_rate": 0.38, + "xp_multiplier": 1.15 + }, + "challenges": [ + { + "name": "CHANNEL SURFER", + "description": "Implement a concurrent pipeline with channels", + "xp": 250, + "difficulty": 3 + }, + { + "name": "ERROR HANDLER", + "description": "Eliminate all ignored error returns", + "xp": 200, + "difficulty": 2 + } + ], + "evolutions": [ + { + "level": 50, + "into": "Concurrex" + } + ], + "flavor": "The gopher never panics. It returns an error.", + "weak_against": [ + "shell" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] + }, + "Typeling": { + "id": "Typeling", + "display": "\ud83d\udd37 Typeling", + "type": "language_mascot", + "language": "TypeScript", + "element": "typed", + "rarity": "uncommon", + "assignable": true, + "base_strength": 65, + "xp_reward": 110, + "spawn": { + "min_affinity_level": 2, + "base_rate": 0.025, + "affinity_scale": 0.45 + }, + "passive_reduction_per_use": 4, + "description": "JavaScript that learned to say no. Carries a schema everywhere it goes.", + "base_stats": { + "power": 48, + "catch_rate": 0.35, + "xp_multiplier": 1.2 + }, + "challenges": [ + { + "name": "NO ANY", + "description": "Remove all `any` types from a file", + "xp": 300, + "difficulty": 3 + }, + { + "name": "SCHEMA WARDEN", + "description": "Add Zod/io-ts validation to an API boundary", + "xp": 350, + "difficulty": 3 + } + ], + "evolutions": [ + { + "level": 60, + "into": "Schemix" + } + ], + "flavor": "It used to be JavaScript. It got better.", + "weak_against": [ + "shell" + ], + "strong_against": [ + "dynamic" + ], + "immune_to": [] + }, + "Vueling": { + "id": "Vueling", + "display": "\ud83d\udc9a Vueling", + "type": "language_mascot", + "language": "Vue", + "element": "web", + "rarity": "uncommon", + "assignable": true, + "base_strength": 60, + "xp_reward": 105, + "spawn": { + "min_affinity_level": 2, + "base_rate": 0.03, + "affinity_scale": 0.4 + }, + "passive_reduction_per_use": 4, + "description": "Component-first, reactive-always. Keeps the DOM in perfect harmony.", + "base_stats": { + "power": 45, + "catch_rate": 0.36, + "xp_multiplier": 1.18 + }, + "challenges": [ + { + "name": "COMPOSABLE CRAFT", + "description": "Extract logic into a reusable composable", + "xp": 280, + "difficulty": 2 + }, + { + "name": "REACTIVE CHAIN", + "description": "Build a computed chain with no side effects", + "xp": 250, + "difficulty": 2 + } + ], + "evolutions": [ + { + "level": 60, + "into": "Componentix" + } + ], + "flavor": "The template always re-renders. The composable never changes.", + "weak_against": [ + "systems" + ], + "strong_against": [ + "data" + ], + "immune_to": [] + }, + "Querion": { + "id": "Querion", + "display": "\ud83d\uddc4\ufe0f Querion", + "type": "language_mascot", + "language": "SQL", + "element": "data", + "rarity": "uncommon", + "assignable": true, + "base_strength": 65, + "xp_reward": 110, + "spawn": { + "min_affinity_level": 1, + "base_rate": 0.03, + "affinity_scale": 0.4 + }, + "passive_reduction_per_use": 4, + "description": "Ancient and relational. Knows where every join leads.", + "base_stats": { + "power": 44, + "catch_rate": 0.34, + "xp_multiplier": 1.2 + }, + "challenges": [ + { + "name": "INDEX WHISPERER", + "description": "Add an index that speeds up a slow query", + "xp": 300, + "difficulty": 3 + }, + { + "name": "N+1 SLAYER", + "description": "Eliminate an N+1 query pattern", + "xp": 350, + "difficulty": 3 + } + ], + "evolutions": [ + { + "level": 60, + "into": "Indexer" + } + ], + "flavor": "The schema was designed in 1999. It's still running.", + "weak_against": [ + "web" + ], + "strong_against": [ + "shell" + ], + "immune_to": [] + }, + "Ferrix": { + "id": "Ferrix", + "display": "\ud83e\udd80 Ferrix", + "type": "language_mascot", + "language": "Rust", + "element": "systems", + "rarity": "rare", + "assignable": true, + "base_strength": 80, + "xp_reward": 160, + "spawn": { + "min_affinity_level": 2, + "base_rate": 0.015, + "affinity_scale": 0.5 + }, + "passive_reduction_per_use": 3, + "description": "The borrow checker incarnate. No data race has ever escaped its claws.", + "base_stats": { + "power": 65, + "catch_rate": 0.28, + "xp_multiplier": 1.35 + }, + "challenges": [ + { + "name": "LIFETIME MASTER", + "description": "Resolve a lifetime error without cloning", + "xp": 500, + "difficulty": 5 + }, + { + "name": "ZERO UNSAFE", + "description": "Write a systems module with no unsafe blocks", + "xp": 450, + "difficulty": 4 + } + ], + "evolutions": [ + { + "level": 75, + "into": "Borrowkin" + } + ], + "flavor": "Memory safety isn't a feature. It's a guarantee.", + "weak_against": [ + "dynamic" + ], + "strong_against": [ + "web", + "shell" + ], + "immune_to": [] + }, + "Perlius": { + "id": "Perlius", + "display": "\ud83d\udc2a Perlius", + "type": "language_mascot", + "language": "Perl", + "element": "dynamic", + "rarity": "legendary", + "assignable": true, + "base_strength": 90, + "xp_reward": 300, + "spawn": { + "min_affinity_level": 3, + "base_rate": 0.005, + "affinity_scale": 0.6 + }, + "passive_reduction_per_use": 2, + "description": "Older than the web. Survived every 'Perl is dead' headline. Still writing regexes.", + "base_stats": { + "power": 80, + "catch_rate": 0.18, + "xp_multiplier": 1.6 + }, + "challenges": [ + { + "name": "REGEX ARTISAN", + "description": "Write a one-liner that would make Larry Wall proud", + "xp": 800, + "difficulty": 5 + }, + { + "name": "CPAN PILGRIM", + "description": "Port a CPAN module concept to a modern language", + "xp": 600, + "difficulty": 4 + } + ], + "evolutions": [], + "flavor": "There is more than one way to do it. Perlius knows all of them.", + "weak_against": [ + "typed", + "shell" + ], + "strong_against": [], + "immune_to": [ + "systems" + ] + }, + "Cobolithon": { + "id": "Cobolithon", + "display": "\ud83c\udffa Cobolithon", + "type": "language_mascot", + "language": "COBOL", + "element": "data", + "rarity": "legendary", + "assignable": true, + "base_strength": 95, + "xp_reward": 400, + "spawn": { + "min_affinity_level": 3, + "base_rate": 0.003, + "affinity_scale": 0.7 + }, + "passive_reduction_per_use": 2, + "description": "Runs 95% of the world's financial transactions. Has never been refactored.", + "base_stats": { + "power": 90, + "catch_rate": 0.12, + "xp_multiplier": 1.8 + }, + "challenges": [ + { + "name": "MAINFRAME WHISPERER", + "description": "Document a COBOL section so a modern dev can read it", + "xp": 1000, + "difficulty": 5 + }, + { + "name": "LEGACY BRIDGE", + "description": "Write an API wrapper around a COBOL-style data format", + "xp": 800, + "difficulty": 5 + } + ], + "evolutions": [], + "flavor": "Born in 1959. Still processing payroll.", + "weak_against": [ + "web" + ], + "strong_against": [], + "immune_to": [ + "dynamic", + "shell" + ] + }, + "Lispling": { + "id": "Lispling", + "display": "\ud83c\udf00 Lispling", + "type": "language_mascot", + "language": "LISP", + "element": "dynamic", + "rarity": "legendary", + "assignable": true, + "base_strength": 88, + "xp_reward": 350, + "spawn": { + "min_affinity_level": 3, + "base_rate": 0.004, + "affinity_scale": 0.65 + }, + "passive_reduction_per_use": 2, + "description": "All code is data. All data is code. All parentheses are load-bearing.", + "base_stats": { + "power": 85, + "catch_rate": 0.15, + "xp_multiplier": 1.75 + }, + "challenges": [ + { + "name": "MACRO ARCHITECT", + "description": "Write a macro that generates code at compile time", + "xp": 900, + "difficulty": 5 + }, + { + "name": "HOMOICONIC", + "description": "Implement an interpreter for a mini-language", + "xp": 1000, + "difficulty": 5 + } + ], + "evolutions": [], + "flavor": "((((((it's parentheses all the way down))))))", + "weak_against": [ + "typed", + "systems" + ], + "strong_against": [], + "immune_to": [] } } } \ No newline at end of file diff --git a/lib/cli.py b/lib/cli.py new file mode 100644 index 0000000..f4c92d1 --- /dev/null +++ b/lib/cli.py @@ -0,0 +1,881 @@ +#!/usr/bin/env python3 +"""Buddymon CLI β€” all game logic lives here. + +SKILL.md is a thin orchestrator that runs this script and handles +one round of user I/O when the script emits a marker: + + [INPUT_NEEDED: ] β€” ask user, re-run with answer appended + [HAIKU_NEEDED: ] β€” spawn Haiku agent to parse NL input, re-run with result + +Everything else is deterministic: no LLM reasoning, no context cost. +""" +import sys +import json +import os +import random +from pathlib import Path +from datetime import datetime, timezone + + +# ── Paths ───────────────────────────────────────────────────────────────────── + +BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" + + +def find_catalog() -> dict: + pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "") + candidates = [ + Path(pr) / "lib" / "catalog.json" if pr else None, + Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/catalog.json", + Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json", + Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json", + ] + for p in candidates: + if p and p.exists(): + return json.loads(p.read_text()) + raise FileNotFoundError("buddymon catalog not found β€” check plugin installation") + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load(path) -> dict: + try: + return json.loads(Path(path).read_text()) + except Exception: + return {} + + +def save(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(data, indent=2)) + + +def session_file() -> Path: + key = str(os.getpgrp()) + return BUDDYMON_DIR / "sessions" / f"{key}.json" + + +def get_buddy_id() -> str | None: + try: + ss = load(session_file()) + bid = ss.get("buddymon_id") + if bid: + return bid + except Exception: + pass + return load(BUDDYMON_DIR / "active.json").get("buddymon_id") + + +def get_session_xp() -> int: + try: + return load(session_file()).get("session_xp", 0) + except Exception: + return load(BUDDYMON_DIR / "active.json").get("session_xp", 0) + + +def add_xp(amount: int): + buddy_id = get_buddy_id() + sf = session_file() + try: + ss = load(sf) + ss["session_xp"] = ss.get("session_xp", 0) + amount + save(sf, ss) + except Exception: + active = load(BUDDYMON_DIR / "active.json") + active["session_xp"] = active.get("session_xp", 0) + amount + save(BUDDYMON_DIR / "active.json", active) + + if buddy_id: + roster = load(BUDDYMON_DIR / "roster.json") + if buddy_id in roster.get("owned", {}): + roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount + save(BUDDYMON_DIR / "roster.json", roster) + + +# ── Display helpers ─────────────────────────────────────────────────────────── + +def xp_bar(current: int, maximum: int, width: int = 20) -> str: + if maximum <= 0: + return "β–‘" * width + filled = int(width * min(current, maximum) / maximum) + return "β–ˆ" * filled + "β–‘" * (width - filled) + + +def compute_level(total_xp: int) -> int: + level = 1 + while total_xp >= level * 100: + level += 1 + return level + + +# ── Element system (mirrors post-tool-use.py) ───────────────────────────────── + +LANGUAGE_ELEMENTS = { + "Python": "dynamic", "Ruby": "dynamic", "JavaScript": "dynamic", + "TypeScript": "typed", "Java": "typed", "C#": "typed", + "Go": "typed", "Rust": "systems", "C": "systems", + "C++": "systems", "Bash": "shell", "Shell": "shell", + "PowerShell": "shell", "HTML": "web", "CSS": "web", + "Vue": "web", "React": "web", "SQL": "data", + "GraphQL": "data", "JSON": "data", "YAML": "data", + "Kotlin": "typed", "Swift": "typed", "PHP": "dynamic", + "Perl": "dynamic", "R": "data", "Julia": "data", + "TOML": "data", "Dockerfile": "systems", +} + +ELEMENT_MATCHUPS = { + # (attacker_element, defender_element): multiplier + ("dynamic", "typed"): 1.25, ("dynamic", "systems"): 0.85, + ("typed", "shell"): 1.25, ("typed", "dynamic"): 0.85, + ("systems", "web"): 1.25, ("systems", "typed"): 0.85, + ("shell", "dynamic"): 1.25, ("shell", "data"): 0.85, + ("web", "data"): 1.25, ("web", "systems"): 0.70, + ("data", "dynamic"): 1.25, ("data", "web"): 0.85, +} + +LANGUAGE_AFFINITY_TIERS = [ + (0, "discovering"), + (50, "familiar"), + (150, "comfortable"), + (350, "proficient"), + (700, "expert"), + (1200, "master"), +] +TIER_EMOJI = { + "discovering": "πŸ”­", "familiar": "πŸ“–", "comfortable": "πŸ› οΈ", + "proficient": "⚑", "expert": "🎯", "master": "πŸ‘‘", +} + + +def get_player_elements(roster: dict, min_level: int = 2) -> set: + """Return the set of elements the player has meaningful affinity in.""" + elements = set() + for lang, data in roster.get("language_affinities", {}).items(): + if data.get("level", 0) >= min_level: + elem = LANGUAGE_ELEMENTS.get(lang) + if elem: + elements.add(elem) + return elements + + +def element_multiplier(enc: dict, player_elements: set) -> float: + """Calculate catch rate bonus from element matchup.""" + enc_elems = set(enc.get("weak_against", [])) + immune_elems = set(enc.get("immune_to", [])) + strong_elems = set(enc.get("strong_against", [])) + + if player_elements & immune_elems: + return 0.70 + if player_elements & enc_elems: + return 1.25 + if player_elements & strong_elems: + return 0.85 + return 1.0 + + +# ── Subcommands ─────────────────────────────────────────────────────────────── + +def cmd_status(): + active = load(BUDDYMON_DIR / "active.json") + roster = load(BUDDYMON_DIR / "roster.json") + enc_data = load(BUDDYMON_DIR / "encounters.json") + + if not roster.get("starter_chosen"): + print("No starter chosen yet. Run: /buddymon start") + return + + buddy_id = get_buddy_id() or active.get("buddymon_id") + if not buddy_id: + print("No buddy assigned to this session. Run: /buddymon assign ") + return + + sf = session_file() + try: + ss = load(sf) + session_xp = ss.get("session_xp", 0) + challenge = ss.get("challenge") or active.get("challenge") + except Exception: + session_xp = active.get("session_xp", 0) + challenge = active.get("challenge") + + owned = roster.get("owned", {}) + buddy = owned.get(buddy_id, {}) + display = buddy.get("display", buddy_id) + total_xp = buddy.get("xp", 0) + level = buddy.get("level", compute_level(total_xp)) + max_xp = level * 100 + xp_in_level = total_xp % max_xp if max_xp else 0 + bar = xp_bar(xp_in_level, max_xp) + + ch_name = "" + if challenge: + ch_name = challenge.get("name", str(challenge)) if isinstance(challenge, dict) else str(challenge) + + evolved_from = buddy.get("evolved_from", "") + prestige = f" (prestige from {evolved_from})" if evolved_from else "" + + print("╔══════════════════════════════════════════╗") + print("β•‘ 🐾 Buddymon β•‘") + print("╠══════════════════════════════════════════╣") + lv_str = f"Lv.{level}{prestige}" + print(f"β•‘ Active: {display}") + print(f"β•‘ {lv_str}") + print(f"β•‘ XP: [{bar}] {xp_in_level}/{max_xp}") + print(f"β•‘ Session: +{session_xp} XP") + if ch_name: + print(f"β•‘ Challenge: {ch_name}") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + + enc = enc_data.get("active_encounter") + if enc: + enc_display = enc.get("display", "???") + strength = enc.get("current_strength", 100) + wounded = enc.get("wounded", False) + print(f"\nβš”οΈ Active encounter: {enc_display} [{strength}% strength]") + if wounded: + print(" ⚠️ Wounded β€” `/buddymon catch` for near-guaranteed capture.") + else: + print(" Run `/buddymon fight` or `/buddymon catch`") + + +def cmd_fight(confirmed: bool = False): + enc_data = load(BUDDYMON_DIR / "encounters.json") + enc = enc_data.get("active_encounter") + + if not enc: + print("No active encounter β€” it may have already been auto-resolved.") + return + + display = enc.get("display", "???") + strength = enc.get("current_strength", 100) + + if enc.get("id") == "ShadowBit": + print(f"⚠️ {display} cannot be defeated β€” use `/buddymon catch` instead.") + return + + if not confirmed: + print(f"βš”οΈ Fighting {display} [{strength}% strength]") + print(f"\n[INPUT_NEEDED: Have you fixed the bug that triggered {display}? (yes/no)]") + return + + # Confirmed β€” award XP and clear + xp = enc.get("xp_reward", 50) + add_xp(xp) + + enc["outcome"] = "defeated" + enc["timestamp"] = datetime.now(timezone.utc).isoformat() + enc_data.setdefault("history", []).append(enc) + enc_data["active_encounter"] = None + save(BUDDYMON_DIR / "encounters.json", enc_data) + + print(f"βœ… Defeated {display}! +{xp} XP") + + +def cmd_catch(args: list[str]): + enc_data = load(BUDDYMON_DIR / "encounters.json") + enc = enc_data.get("active_encounter") + + if not enc: + print("No active encounter.") + return + + display = enc.get("display", "???") + current_strength = enc.get("current_strength", 100) + wounded = enc.get("wounded", False) + is_mascot = enc.get("encounter_type") == "language_mascot" + + # --strength N means we already have a resolved strength value; go straight to roll + strength_override = None + if "--strength" in args: + idx = args.index("--strength") + try: + strength_override = int(args[idx + 1]) + except (IndexError, ValueError): + pass + + if strength_override is None and not wounded: + enc["catch_pending"] = True + enc_data["active_encounter"] = enc + save(BUDDYMON_DIR / "encounters.json", enc_data) + + print(f"🎯 Catching {display} [{current_strength}% strength]") + print() + if is_mascot: + lang = enc.get("language", "this language") + print("Weakening actions (language mascots weaken through coding):") + print(f" 1) Write 10+ lines in {lang} β†’ -20% strength") + print(f" 2) Refactor existing {lang} code β†’ -15% strength") + print(f" 3) Add type annotations / docs β†’ -10% strength") + else: + print("Weakening actions:") + print(" 1) Write a failing test β†’ -20% strength") + print(" 2) Isolate a minimal repro case β†’ -20% strength") + print(" 3) Add a documenting comment β†’ -10% strength") + print() + print("[INPUT_NEEDED: Which have you done? Enter numbers (e.g. \"1 2\"), describe in words, or \"none\" to throw now]") + return + + # Resolve to a final strength and roll + final_strength = strength_override if strength_override is not None else current_strength + enc["catch_pending"] = False + + roster = load(BUDDYMON_DIR / "roster.json") + buddy_id = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id") + + try: + catalog = find_catalog() + except Exception: + catalog = {} + + buddy_data = (catalog.get("buddymon", {}).get(buddy_id) + or catalog.get("evolutions", {}).get(buddy_id) or {}) + buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1) + weakness_bonus = (100 - final_strength) / 100 * 0.4 + player_elems = get_player_elements(roster) + elem_mult = element_multiplier(enc, player_elems) + + if is_mascot: + # Mascot formula: base catch from mascot catalog + affinity bonus (6% per level) + mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {}) + lang = enc.get("language") or mascot_data.get("language", "") + lang_affinity = roster.get("language_affinities", {}).get(lang, {}) + affinity_level = lang_affinity.get("level", 0) + base_catch = mascot_data.get("base_stats", {}).get("catch_rate", 0.35) + affinity_bonus = affinity_level * 0.06 + catch_rate = min(0.95, (base_catch + affinity_bonus + weakness_bonus + buddy_level * 0.01) * elem_mult) + else: + base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4) + catch_rate = min(0.95, (base_catch + weakness_bonus + buddy_level * 0.02) * elem_mult) + + success = random.random() < catch_rate + + if success: + xp = int(enc.get("xp_reward", 50) * 1.5) + + if is_mascot: + lang = enc.get("language", "") + caught_entry = { + "id": enc["id"], + "display": enc["display"], + "type": "caught_language_mascot", + "language": lang, + "level": 1, + "xp": 0, + "caught_at": datetime.now(timezone.utc).isoformat(), + } + else: + caught_entry = { + "id": enc["id"], + "display": enc["display"], + "type": "caught_bug_monster", + "level": 1, + "xp": 0, + "caught_at": datetime.now(timezone.utc).isoformat(), + } + + roster.setdefault("owned", {})[enc["id"]] = caught_entry + if buddy_id and buddy_id in roster.get("owned", {}): + roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp + save(BUDDYMON_DIR / "roster.json", roster) + + add_xp(xp) + + enc["outcome"] = "caught" + enc["timestamp"] = datetime.now(timezone.utc).isoformat() + enc_data.setdefault("history", []).append(enc) + enc_data["active_encounter"] = None + save(BUDDYMON_DIR / "encounters.json", enc_data) + + elem_hint = "" + if elem_mult > 1.0: + if is_mascot: + elem_hint = " ⚑ Affinity resonance!" + else: + elem_hint = " ⚑ Super effective!" + elif elem_mult < 0.85: + elem_hint = " πŸ›‘οΈ Resistant." + + if is_mascot: + lang_disp = enc.get("language", "") + print(f"πŸŽ‰ Caught {display}!{elem_hint} +{xp} XP") + if lang_disp: + print(f" Now assign it as your buddy to unlock {lang_disp} challenges.") + else: + print(f"πŸŽ‰ Caught {display}!{elem_hint} +{xp} XP (1.5Γ— catch bonus)") + else: + enc_data["active_encounter"] = enc + save(BUDDYMON_DIR / "encounters.json", enc_data) + if is_mascot: + lang = enc.get("language", "this language") + print(f"πŸ’¨ {display} slipped away! Keep coding in {lang} to weaken it further. ({int(catch_rate * 100)}% catch rate)") + else: + print(f"πŸ’¨ {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)") + + +def cmd_roster(): + roster = load(BUDDYMON_DIR / "roster.json") + owned = roster.get("owned", {}) + + try: + catalog = find_catalog() + except Exception: + catalog = {} + mascot_catalog = catalog.get("language_mascots", {}) + + core_buddymon = {k: v for k, v in owned.items() + if v.get("type") not in ("caught_bug_monster", "caught_language_mascot")} + mascots_owned = {k: v for k, v in owned.items() if v.get("type") == "caught_language_mascot"} + caught = {k: v for k, v in owned.items() if v.get("type") == "caught_bug_monster"} + + print("🐾 Your Buddymon") + print("─" * 44) + for bid, b in core_buddymon.items(): + lvl = b.get("level", 1) + total_xp = b.get("xp", 0) + max_xp = lvl * 100 + xp_in_level = total_xp % max_xp if max_xp else 0 + bar = xp_bar(xp_in_level, max_xp) + display = b.get("display", bid) + affinity = b.get("affinity", "") + evo_note = f" β†’ {b['evolved_into']}" if b.get("evolved_into") else "" + print(f" {display} Lv.{lvl} {affinity}{evo_note}") + print(f" XP: [{bar}] {xp_in_level}/{max_xp}") + + if mascots_owned: + print() + print("🦎 Language Mascots") + print("─" * 44) + for bid, b in sorted(mascots_owned.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True): + display = b.get("display", bid) + lang = b.get("language", "") + caught_at = b.get("caught_at", "")[:10] + lvl = b.get("level", 1) + mc = mascot_catalog.get(bid, {}) + assignable = mc.get("assignable", False) + assign_note = " βœ“ assignable as buddy" if assignable else "" + evo_chains = mc.get("evolutions", []) + evo_note = f" β†’ evolves at Lv.{evo_chains[0]['level']}" if evo_chains else "" + print(f" {display} [{lang}] Lv.{lvl}{assign_note}{evo_note}") + print(f" caught {caught_at}") + + if caught: + print() + print("πŸ† Caught Bug Monsters") + print("─" * 44) + for bid, b in sorted(caught.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True): + display = b.get("display", bid) + caught_at = b.get("caught_at", "")[:10] + print(f" {display} β€” caught {caught_at}") + + affinities = roster.get("language_affinities", {}) + if affinities: + print() + print("πŸ—ΊοΈ Language Affinities") + print("─" * 44) + for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)): + tier = data.get("tier", "discovering") + level = data.get("level", 0) + xp = data.get("xp", 0) + emoji = TIER_EMOJI.get(tier, "πŸ”­") + elem = LANGUAGE_ELEMENTS.get(lang, "") + elem_tag = f" [{elem}]" if elem else "" + # Flag languages that have a spawnable mascot + has_mascot = any(m.get("language") == lang for m in mascot_catalog.values()) + mascot_tag = " 🦎" if has_mascot and level >= 1 else "" + print(f" {emoji} {lang:<12} {tier:<12} (Lv.{level} Β· {xp} XP){elem_tag}{mascot_tag}") + + bug_total = len(catalog.get("bug_monsters", {})) + mascot_total = len(mascot_catalog) + missing_bugs = bug_total - len(caught) + missing_mascots = mascot_total - len(mascots_owned) + if missing_bugs + missing_mascots > 0: + parts = [] + if missing_bugs > 0: + parts.append(f"{missing_bugs} bug monsters") + if missing_mascots > 0: + parts.append(f"{missing_mascots} language mascots") + print(f"\n❓ ??? β€” {' and '.join(parts)} still to discover...") + + +def cmd_start(choice: str | None = None): + roster = load(BUDDYMON_DIR / "roster.json") + if roster.get("starter_chosen"): + print("Starter already chosen! Run `/buddymon status` or `/buddymon roster`.") + return + + starters = { + "1": ("Pyrobyte", "πŸ”₯", "Speedrunner β€” loves tight deadlines and feature sprints"), + "2": ("Debuglin", "πŸ”", "Tester β€” patient, methodical, ruthless bug hunter"), + "3": ("Minimox", "βœ‚οΈ", "Cleaner β€” obsessed with fewer lines and zero linter runs"), + } + + if choice is None: + print("╔══════════════════════════════════════════════════════════╗") + print("β•‘ 🐾 Choose Your Starter Buddymon β•‘") + print("╠══════════════════════════════════════════════════════════╣") + for num, (name, emoji, desc) in starters.items(): + print(f"β•‘ [{num}] {emoji} {name:<10} β€” {desc:<36}β•‘") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + print("\n[INPUT_NEEDED: Choose 1, 2, or 3]") + return + + # Normalize choice + key = choice.strip().lower() + name_map = {v[0].lower(): k for k, v in starters.items()} + if key in name_map: + key = name_map[key] + if key not in starters: + print(f"Invalid choice '{choice}'. Please choose 1, 2, or 3.") + return + + bid, emoji, _ = starters[key] + try: + catalog = find_catalog() + except Exception: + catalog = {} + + buddy_cat = catalog.get("buddymon", {}).get(bid, {}) + challenges = buddy_cat.get("challenges", []) + + roster.setdefault("owned", {})[bid] = { + "id": bid, + "display": f"{emoji} {bid}", + "affinity": buddy_cat.get("affinity", ""), + "level": 1, + "xp": 0, + } + roster["starter_chosen"] = True + save(BUDDYMON_DIR / "roster.json", roster) + + active = load(BUDDYMON_DIR / "active.json") + active["buddymon_id"] = bid + active["session_xp"] = 0 + active["challenge"] = challenges[0] if challenges else None + save(BUDDYMON_DIR / "active.json", active) + + sf = session_file() + try: + ss = load(sf) + except Exception: + ss = {} + ss["buddymon_id"] = bid + ss["session_xp"] = 0 + ss["challenge"] = active["challenge"] + save(sf, ss) + + print(f"✨ You chose {emoji} {bid}!") + if challenges: + ch = challenges[0] + ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch) + print(f" First challenge: {ch_name}") + print("\nBug monsters will appear when errors occur in your terminal.") + print("Run `/buddymon` any time to check your status.") + + +def cmd_assign(args: list[str]): + """ + assign β†’ list roster + [INPUT_NEEDED] + assign β†’ fuzzy match β†’ show challenge β†’ [INPUT_NEEDED: accept/decline/reroll] + assign --accept + assign --reroll + assign --resolved (Haiku resolved ambiguity) + """ + roster = load(BUDDYMON_DIR / "roster.json") + owned = roster.get("owned", {}) + buddymon_ids = [k for k, v in owned.items() if v.get("type") != "caught_bug_monster"] + + if not buddymon_ids: + print("No Buddymon owned yet. Run `/buddymon start` first.") + return + + # No name given + if not args or args[0].startswith("--"): + print("Your Buddymon:") + for bid in buddymon_ids: + b = owned[bid] + print(f" {b.get('display', bid)} Lv.{b.get('level', 1)}") + print("\n[INPUT_NEEDED: Which buddy do you want for this session?]") + return + + name_input = args[0] + rest_flags = args[1:] + + # --resolved bypasses fuzzy match (Haiku already picked) + if "--resolved" in rest_flags: + idx = rest_flags.index("--resolved") + try: + resolved_id = rest_flags[idx + 1] + except IndexError: + resolved_id = name_input + return cmd_assign([resolved_id] + [f for f in rest_flags if f != "--resolved" and f != resolved_id]) + + # Fuzzy match + query = name_input.lower() + matches = [bid for bid in buddymon_ids if query in bid.lower()] + + if not matches: + print(f"No Buddymon matching '{name_input}'. Your roster:") + for bid in buddymon_ids: + print(f" {owned[bid].get('display', bid)}") + return + + if len(matches) > 1: + # Emit Haiku marker to resolve ambiguity + candidates = [owned[m].get("display", m) for m in matches] + haiku_task = json.dumps({ + "task": "fuzzy_match", + "input": name_input, + "candidates": candidates, + "instruction": "The user typed a partial buddy name. Which candidate do they most likely mean? Reply with ONLY the exact candidate string.", + }) + print(f"[HAIKU_NEEDED: {haiku_task}]") + return + + bid = matches[0] + b = owned[bid] + display = b.get("display", bid) + + try: + catalog = find_catalog() + except Exception: + catalog = {} + + buddy_cat = (catalog.get("buddymon", {}).get(bid) + or catalog.get("evolutions", {}).get(bid) or {}) + challenges = buddy_cat.get("challenges", []) + + if "--accept" in rest_flags: + # Write session state + sf = session_file() + try: + ss = load(sf) + except Exception: + ss = {} + + chosen_challenge = None + if challenges: + # Pick randomly unless a specific index was passed + if "--challenge-idx" in rest_flags: + idx = rest_flags.index("--challenge-idx") + try: + ci = int(rest_flags[idx + 1]) + chosen_challenge = challenges[ci % len(challenges)] + except (IndexError, ValueError): + chosen_challenge = random.choice(challenges) + else: + chosen_challenge = random.choice(challenges) + + ss["buddymon_id"] = bid + ss["session_xp"] = ss.get("session_xp", 0) + ss["challenge"] = chosen_challenge + save(sf, ss) + + # Also update global default + active = load(BUDDYMON_DIR / "active.json") + active["buddymon_id"] = bid + active["challenge"] = chosen_challenge + save(BUDDYMON_DIR / "active.json", active) + + ch_name = "" + if chosen_challenge: + ch_name = chosen_challenge.get("name", str(chosen_challenge)) if isinstance(chosen_challenge, dict) else str(chosen_challenge) + + print(f"βœ… {display} assigned to this session!") + if ch_name: + print(f" Challenge: {ch_name}") + return + + if "--reroll" in rest_flags: + # Pick a different challenge and re-show the proposal + if not challenges: + print(f"No challenges available for {display}.") + return + challenge = random.choice(challenges) + elif challenges: + challenge = challenges[0] + else: + challenge = None + + ch_name = "" + ch_desc = "" + ch_stars = "" + if challenge: + if isinstance(challenge, dict): + ch_name = challenge.get("name", "") + ch_desc = challenge.get("description", "") + difficulty = challenge.get("difficulty", 1) + ch_stars = "β˜…" * difficulty + "β˜†" * (5 - difficulty) + else: + ch_name = str(challenge) + + print(f"🐾 Assign {display} to this session?") + if ch_name: + print(f"\n Challenge: {ch_name}") + if ch_desc: + print(f" {ch_desc}") + if ch_stars: + print(f" Difficulty: {ch_stars}") + + print("\n[INPUT_NEEDED: accept / decline / reroll]") + + +def cmd_evolve(confirmed: bool = False): + roster = load(BUDDYMON_DIR / "roster.json") + active = load(BUDDYMON_DIR / "active.json") + buddy_id = get_buddy_id() or active.get("buddymon_id") + + if not buddy_id: + print("No buddy assigned.") + return + + try: + catalog = find_catalog() + except Exception: + catalog = {} + + owned = roster.get("owned", {}) + buddy = owned.get(buddy_id, {}) + display = buddy.get("display", buddy_id) + level = buddy.get("level", 1) + + catalog_entry = (catalog.get("buddymon", {}).get(buddy_id) + or catalog.get("evolutions", {}).get(buddy_id) or {}) + evolutions = catalog_entry.get("evolutions", []) + evo = next((e for e in evolutions if level >= e.get("level", 999)), None) + + if not evo: + target_level = min((e.get("level", 100) for e in evolutions), default=100) + levels_to_go = max(0, target_level - level) + print(f"{display} is Lv.{level}. Evolution requires Lv.{target_level} ({levels_to_go} more levels).") + return + + into_id = evo["into"] + into_data = catalog.get("evolutions", {}).get(into_id, {}) + into_display = into_data.get("display", into_id) + + old_catch = catalog_entry.get("base_stats", {}).get("catch_rate", 0.4) + new_catch = into_data.get("base_stats", {}).get("catch_rate", old_catch) + old_mult = catalog_entry.get("base_stats", {}).get("xp_multiplier", 1.0) + new_mult = into_data.get("base_stats", {}).get("xp_multiplier", old_mult) + + if not confirmed: + print("╔══════════════════════════════════════════════════════════╗") + print("β•‘ ✨ Evolution Ready! β•‘") + print("╠══════════════════════════════════════════════════════════╣") + print(f"β•‘ {display} Lv.{level} β†’ {into_display}") + print(f"β•‘ catch_rate: {old_catch:.2f} β†’ {new_catch:.2f} Β· xp_multiplier: {old_mult} β†’ {new_mult}") + print("β•‘ ⚠️ Resets to Lv.1. Caught monsters stay.") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + print("\n[INPUT_NEEDED: Evolve? (y/n)]") + return + + # Execute evolution + now = datetime.now(timezone.utc).isoformat() + owned[buddy_id]["evolved_into"] = into_id + owned[buddy_id]["evolved_at"] = now + + challenges = catalog_entry.get("challenges") or into_data.get("challenges", []) + owned[into_id] = { + "id": into_id, + "display": into_display, + "affinity": into_data.get("affinity", catalog_entry.get("affinity", "")), + "level": 1, + "xp": 0, + "evolved_from": buddy_id, + "evolved_at": now, + } + roster["owned"] = owned + save(BUDDYMON_DIR / "roster.json", roster) + + sf = session_file() + try: + ss = load(sf) + except Exception: + ss = {} + ss["buddymon_id"] = into_id + ss["session_xp"] = 0 + ss["challenge"] = challenges[0] if challenges else None + save(sf, ss) + + active["buddymon_id"] = into_id + active["session_xp"] = 0 + active["challenge"] = ss["challenge"] + save(BUDDYMON_DIR / "active.json", active) + + ch_name = "" + if ss["challenge"]: + ch = ss["challenge"] + ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch) + + print(f"✨ {display} evolved into {into_display}!") + print(" Starting fresh at Lv.1 β€” the second climb is faster.") + if ch_name: + print(f" New challenge: {ch_name}") + + +def cmd_statusline(): + settings_path = Path.home() / ".claude" / "settings.json" + script_candidates = [ + Path.home() / ".claude/buddymon/statusline.sh", + Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh", + Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/statusline.sh", + ] + script_path = next((str(p) for p in script_candidates if p.exists()), None) + if not script_path: + print("❌ statusline.sh not found. Re-run install.sh.") + return + + settings = load(settings_path) + if settings.get("statusLine"): + existing = settings["statusLine"].get("command", "") + print(f"⚠️ A statusLine is already configured:\n {existing}") + print("\n[INPUT_NEEDED: Replace it? (y/n)]") + return + + settings["statusLine"] = {"type": "command", "command": f"bash {script_path}"} + save(settings_path, settings) + print(f"βœ… Buddymon statusline installed. Reload Claude Code to activate.") + + +def cmd_help(): + print("""/buddymon β€” status panel +/buddymon start β€” choose starter (first run) +/buddymon assign β€” assign buddy to session +/buddymon fight β€” fight active encounter +/buddymon catch β€” catch active encounter +/buddymon roster β€” view full roster +/buddymon evolve β€” evolve buddy (Lv.100) +/buddymon statusline β€” install statusline widget +/buddymon help β€” this list""") + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def main(): + args = sys.argv[1:] + cmd = args[0].lower() if args else "" + rest = args[1:] + + dispatch = { + "": lambda: cmd_status(), + "status": lambda: cmd_status(), + "fight": lambda: cmd_fight(confirmed="--confirmed" in rest), + "catch": lambda: cmd_catch(rest), + "roster": lambda: cmd_roster(), + "start": lambda: cmd_start(rest[0] if rest else None), + "assign": lambda: cmd_assign(rest), + "evolve": lambda: cmd_evolve(confirmed="--confirm" in rest), + "statusline": lambda: cmd_statusline(), + "help": lambda: cmd_help(), + } + + handler = dispatch.get(cmd) + if handler: + handler() + else: + print(f"Unknown subcommand '{cmd}'. Run `/buddymon help` for the full list.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/lib/statusline.sh b/lib/statusline.sh index 7cf984a..3dbd718 100644 --- a/lib/statusline.sh +++ b/lib/statusline.sh @@ -15,7 +15,15 @@ ID=$(jq -r '.buddymon_id // ""' "$B/active.json" 2>/dev/null) [[ -n "$ID" ]] || exit 0 LVL=$(jq -r ".owned[\"$ID\"].level // 1" "$B/roster.json" 2>/dev/null) -XP=$(jq -r '.session_xp // 0' "$B/active.json" 2>/dev/null) + +# Per-session XP (accurate for multi-window setups); fall back to active.json +PGRP=$(python3 -c "import os; print(os.getpgrp())" 2>/dev/null) +SESSION_FILE="$B/sessions/${PGRP}.json" +if [[ -f "$SESSION_FILE" ]]; then + XP=$(jq -r '.session_xp // 0' "$SESSION_FILE" 2>/dev/null) +else + XP=$(jq -r '.session_xp // 0' "$B/active.json" 2>/dev/null) +fi ENC_JSON=$(jq -c '.active_encounter // null' "$B/encounters.json" 2>/dev/null) ENC_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null) diff --git a/skills/buddymon/SKILL.md b/skills/buddymon/SKILL.md index 4faacb4..1d842db 100644 --- a/skills/buddymon/SKILL.md +++ b/skills/buddymon/SKILL.md @@ -1,548 +1,66 @@ --- name: buddymon description: Buddymon companion game β€” status, roster, encounters, and session management -argument-hint: [start|assign |fight|catch|roster] -allowed-tools: [Bash, Read] +argument-hint: [start|assign |fight|catch|roster|evolve|statusline|help] +allowed-tools: [Bash, Agent] --- -# /buddymon β€” Buddymon Companion +# /buddymon -The main Buddymon command. Route based on the argument provided. +Run the CLI: -**Invoked with:** `/buddymon $ARGUMENTS` +```bash +python3 ~/.claude/buddymon/cli.py $ARGUMENTS +``` + +The PostToolUse hook automatically surfaces the output as an inline system-reminder β€” no need to echo it yourself. After running, respond with one short line of acknowledgement at most (e.g. "Done." or nothing). Do not repeat the output. --- -## Subcommand Routing +## Interactive Markers -Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch: +### `[INPUT_NEEDED: ]` -| Argument | Action | -|----------|--------| -| _(none)_ | Show status panel | -| `start` | Choose starter (first-run) | -| `assign ` | Assign buddy to this session | -| `fight` | Fight active encounter | -| `catch` | Catch active encounter | -| `roster` | Full roster view | -| `evolve` | Evolve active buddy (available at Lv.100) | -| `statusline` | Install Buddymon statusline into settings.json | -| `help` | Show command list | +Ask the user the exact prompt. Then re-run with their answer appended: ---- +- `fight` + user says "yes" β†’ `python3 ~/.claude/buddymon/cli.py fight --confirmed` +- `evolve` + user says "y" β†’ `python3 ~/.claude/buddymon/cli.py evolve --confirm` +- `start` + user says "2" β†’ `python3 ~/.claude/buddymon/cli.py start 2` +- `assign ` + user says "accept" β†’ `python3 ~/.claude/buddymon/cli.py assign --accept` +- `assign ` + user says "reroll" β†’ `python3 ~/.claude/buddymon/cli.py assign --reroll` +- `statusline` + user says "y" β†’ delete existing statusLine key from settings.json, re-run `statusline` +- `catch` + user gives weakening input β†’ see catch flow below -## No argument β€” Status Panel +### `[HAIKU_NEEDED: ]` -Read state files and display: +Spawn a Haiku subagent to parse ambiguous natural language. Pass the JSON task as the prompt, get a single-line response, then re-run the CLI with the result. +Example β€” fuzzy match resolution: ``` -╔══════════════════════════════════════════╗ -β•‘ 🐾 Buddymon β•‘ -╠══════════════════════════════════════════╣ -β•‘ Active: [display] Lv.[n] β•‘ -β•‘ XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] [n]/[max] β•‘ -β•‘ β•‘ -β•‘ Challenge: [name] β•‘ -β•‘ [description] [β˜…β˜…β˜†β˜†β˜†] [XP] XP β•‘ -β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• -``` - -If an encounter is active, show it below the panel. -If no buddy assigned, prompt `/buddymon assign`. -If no starter chosen, prompt `/buddymon start`. - -State files: -- `~/.claude/buddymon/active.json` β€” active buddy + session XP -- `~/.claude/buddymon/roster.json` β€” all owned Buddymon -- `~/.claude/buddymon/encounters.json` β€” active encounter -- `~/.claude/buddymon/session.json` β€” session stats - ---- - -## `start` β€” Choose Starter (first-run only) - -Check `roster.json` β†’ `starter_chosen`. If already true, show current buddy status instead. - -If false, present: -``` -╔══════════════════════════════════════════════════════════╗ -β•‘ 🐾 Choose Your Starter Buddymon β•‘ -╠══════════════════════════════════════════════════════════╣ -β•‘ β•‘ -β•‘ [1] πŸ”₯ Pyrobyte β€” Speedrunner β•‘ -β•‘ Moves fast, thinks faster. Loves tight deadlines. β•‘ -β•‘ Challenges: speed runs, feature sprints β•‘ -β•‘ β•‘ -β•‘ [2] πŸ” Debuglin β€” Tester β•‘ -β•‘ Patient, methodical, ruthless. β•‘ -β•‘ Challenges: test coverage, bug hunts β•‘ -β•‘ β•‘ -β•‘ [3] βœ‚οΈ Minimox β€” Cleaner β•‘ -β•‘ Obsessed with fewer lines. β•‘ -β•‘ Challenges: refactors, zero-linter runs β•‘ -β•‘ β•‘ -β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• -``` - -Ask for 1, 2, or 3. On choice, write to roster + active: - -```python -import json, os -from pathlib import Path - -BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" -_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "") -_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None, - Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json", - Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"] -catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists()))) - -starters = ["Pyrobyte", "Debuglin", "Minimox"] -choice = starters[0] # replace with user's choice (index 0/1/2) -buddy = catalog["buddymon"][choice] - -roster = json.load(open(f"{BUDDYMON_DIR}/roster.json")) -roster["owned"][choice] = { - "id": choice, "display": buddy["display"], - "affinity": buddy["affinity"], "level": 1, "xp": 0, -} -roster["starter_chosen"] = True -json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2) - -active = json.load(open(f"{BUDDYMON_DIR}/active.json")) -active["buddymon_id"] = choice -active["session_xp"] = 0 -active["challenge"] = buddy["challenges"][0] if buddy.get("challenges") else None -json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2) -``` - -Greet them and explain the encounter system. - ---- - -## `assign ` β€” Assign Buddy - -Assignment is **per-session** β€” each Claude Code window can have its own buddy. -It writes to the session state file only, not the global default. - -Fuzzy-match `` against owned Buddymon (case-insensitive, partial). -If ambiguous, list matches and ask which. -If no name given, list roster and ask. - -On match, show challenge proposal with Accept / Decline / Reroll, then write: - -```python -import json, os, random -from pathlib import Path - -BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" -_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "") -_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None, - Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json", - Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"] -catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists()))) - -SESSION_KEY = str(os.getpgrp()) -SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json" -SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) - -roster = json.load(open(BUDDYMON_DIR / "roster.json")) - -buddy_id = "Debuglin" # replace with matched buddy id -buddy_catalog = (catalog.get("buddymon", {}).get(buddy_id) - or catalog.get("evolutions", {}).get(buddy_id) or {}) -challenges = buddy_catalog.get("challenges", []) - -# Load or init session state -try: - session_state = json.load(open(SESSION_FILE)) -except Exception: - session_state = {} - -session_state["buddymon_id"] = buddy_id -session_state["session_xp"] = 0 -session_state["challenge"] = random.choice(challenges) if challenges else None - -json.dump(session_state, open(SESSION_FILE, "w"), indent=2) - -# Also update global default so new sessions inherit this assignment -active = {} -try: - active = json.load(open(BUDDYMON_DIR / "active.json")) -except Exception: - pass -active["buddymon_id"] = buddy_id -json.dump(active, open(BUDDYMON_DIR / "active.json", "w"), indent=2) -``` - -Show challenge proposal with Accept / Decline / Reroll (updating `session_state["challenge"]` accordingly). - ---- - -## `fight` β€” Fight Encounter - -**Note:** Encounters auto-resolve when a clean Bash run (no matching error patterns) is detected. -Use `/buddymon fight` when the error was fixed outside Bash (e.g., in a config file) or to manually confirm a fix. - -Read `encounters.json` β†’ `active_encounter`. If none: "No active encounter β€” it may have already been auto-resolved." - -Show encounter state. Confirm the user has fixed the bug. - -On confirm: -```python -import json, os -from datetime import datetime, timezone - -BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon") - -enc_file = f"{BUDDYMON_DIR}/encounters.json" -active_file = f"{BUDDYMON_DIR}/active.json" -roster_file = f"{BUDDYMON_DIR}/roster.json" - -encounters = json.load(open(enc_file)) -active = json.load(open(active_file)) -roster = json.load(open(roster_file)) - -enc = encounters.get("active_encounter") -if enc and enc.get("defeatable", True): - xp = enc.get("xp_reward", 50) - buddy_id = active.get("buddymon_id") - active["session_xp"] = active.get("session_xp", 0) + xp - json.dump(active, open(active_file, "w"), indent=2) - if buddy_id and buddy_id in roster.get("owned", {}): - roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp - json.dump(roster, open(roster_file, "w"), indent=2) - enc["outcome"] = "defeated" - enc["timestamp"] = datetime.now(timezone.utc).isoformat() - encounters.setdefault("history", []).append(enc) - encounters["active_encounter"] = None - json.dump(encounters, open(enc_file, "w"), indent=2) - print(f"+{xp} XP") -``` - -ShadowBit (πŸ”’) cannot be defeated β€” redirect to catch. - ---- - -## `catch` β€” Catch Encounter - -Read active encounter. If none: "No active encounter." - -**Immediately set `catch_pending = True`** on the encounter to suppress auto-resolve -while the weakening Q&A is in progress: - -```python -import json, os -BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon") -enc_file = f"{BUDDYMON_DIR}/encounters.json" -encounters = json.load(open(enc_file)) -enc = encounters.get("active_encounter") -if enc: - enc["catch_pending"] = True - encounters["active_encounter"] = enc - json.dump(encounters, open(enc_file, "w"), indent=2) -``` - -**If `enc.get("wounded")` is True (strength already at 5%), skip all weakening -Q&A and go straight to the catch roll β€” do not ask, just throw.** - -Otherwise, show strength and weakening status. Explain weaken actions: -- Write a failing test β†’ -20% strength -- Isolate reproduction case β†’ -20% strength -- Add documenting comment β†’ -10% strength - -Ask which weakening actions have been done. Apply reductions to `current_strength`. - -Catch roll (clear `catch_pending` before rolling β€” success clears encounter, failure -leaves it active without the flag so auto-resolve resumes naturally): -```python -import json, os, random, glob -from datetime import datetime, timezone -from pathlib import Path - -BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" - -# PLUGIN_ROOT is not always set when skills run via Bash heredoc β€” search known paths -def find_catalog(): - plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "") - candidates = [ - Path(plugin_root) / "lib" / "catalog.json" if plugin_root else None, - Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json", - Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json", - ] - for p in candidates: - if p and p.exists(): - return json.load(open(p)) - raise FileNotFoundError("buddymon catalog not found β€” check plugin installation") - -catalog = find_catalog() - -enc_file = BUDDYMON_DIR / "encounters.json" -active_file = BUDDYMON_DIR / "active.json" -roster_file = BUDDYMON_DIR / "roster.json" - -encounters = json.load(open(enc_file)) -roster = json.load(open(roster_file)) - -# Buddy lookup: prefer per-session file, fall back to active.json -SESSION_KEY = str(os.getpgrp()) -SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json" -try: - session_state = json.load(open(SESSION_FILE)) - buddy_id = session_state.get("buddymon_id") -except Exception: - buddy_id = None -if not buddy_id: - active = json.load(open(active_file)) - buddy_id = active.get("buddymon_id") - -enc = encounters.get("active_encounter") - -# catch_pending is cleared by the PostToolUse hook after it fires on this -# Bash run β€” do NOT clear it here or the hook will see it already gone and -# may auto-resolve the encounter on the same run as the catch attempt. - -buddy_data = (catalog.get("buddymon", {}).get(buddy_id) - or catalog.get("evolutions", {}).get(buddy_id) or {}) -buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1) -base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4) - -current_strength = enc.get("current_strength", 100) -weakness_bonus = (100 - current_strength) / 100 * 0.4 -catch_rate = min(0.95, base_catch + weakness_bonus + buddy_level * 0.02) - -success = random.random() < catch_rate - -if success: - xp = int(enc.get("xp_reward", 50) * 1.5) - caught_entry = { - "id": enc["id"], "display": enc["display"], - "type": "caught_bug_monster", "level": 1, "xp": 0, - "caught_at": datetime.now(timezone.utc).isoformat(), - } - roster.setdefault("owned", {})[enc["id"]] = caught_entry - # Write XP to session file if it exists, otherwise active.json - try: - ss = json.load(open(SESSION_FILE)) - ss["session_xp"] = ss.get("session_xp", 0) + xp - json.dump(ss, open(SESSION_FILE, "w"), indent=2) - except Exception: - active = json.load(open(active_file)) - active["session_xp"] = active.get("session_xp", 0) + xp - json.dump(active, open(active_file, "w"), indent=2) - if buddy_id and buddy_id in roster.get("owned", {}): - roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp - json.dump(roster, open(roster_file, "w"), indent=2) - enc["outcome"] = "caught" - enc["timestamp"] = datetime.now(timezone.utc).isoformat() - encounters.setdefault("history", []).append(enc) - encounters["active_encounter"] = None - json.dump(encounters, open(enc_file, "w"), indent=2) - print(f"caught:{xp}") -else: - # Leave catch_pending as-is β€” the PostToolUse hook clears it after this - # Bash run completes, giving one full clean run before auto-resolve resumes. - encounters["active_encounter"] = enc - json.dump(encounters, open(enc_file, "w"), indent=2) - print(f"failed:{int(catch_rate * 100)}") -``` - -On success: "πŸŽ‰ Caught [display]! +[XP] XP (1.5Γ— catch bonus)" -On failure: "πŸ’¨ Broke free! Weaken it further and try again." - ---- - -## `roster` β€” Full Roster - -Read roster and display: - -``` -🐾 Your Buddymon -────────────────────────────────────────── - πŸ”₯ Pyrobyte Lv.3 Speedrunner - XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 450/300 - - πŸ” Debuglin Lv.1 Tester - XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 80/100 - -πŸ† Caught Bug Monsters -────────────────────────────────────────── - πŸ‘» NullWraith β€” caught 2026-04-01 - 🌐 CORSCurse β€” caught 2026-03-28 - -❓ ??? β€” [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. - ---- - -## `evolve` β€” Evolve Buddy (Prestige) - -Evolution is available when the active buddy is **Lv.100** (total XP β‰₯ 9,900). -Evolving resets the buddy to Lv.1 in their new form β€” but the evolved form has -higher base stats and a better XP multiplier, so the second climb is faster. - -Read state and check eligibility: - -```python -import json, os -from pathlib import Path - -BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" -_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "") -_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None, - Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json", - Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"] -catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists()))) - -active = json.load(open(BUDDYMON_DIR / "active.json")) -roster = json.load(open(BUDDYMON_DIR / "roster.json")) - -buddy_id = active.get("buddymon_id") -owned = roster.get("owned", {}) -buddy_data = owned.get(buddy_id, {}) -level = buddy_data.get("level", 1) -total_xp = buddy_data.get("xp", 0) - -# Check evolution entry in catalog -catalog_entry = catalog.get("buddymon", {}).get(buddy_id) or catalog.get("evolutions", {}).get(buddy_id) -evolutions = catalog_entry.get("evolutions", []) if catalog_entry else [] -evolution = next((e for e in evolutions if level >= e.get("level", 999)), None) -``` - -If `evolution` is None or level < 100: show current level and XP toward 100, no evolution available yet. - -If eligible, show evolution preview: - -``` -╔══════════════════════════════════════════════════════════╗ -β•‘ ✨ Evolution Ready! β•‘ -╠══════════════════════════════════════════════════════════╣ -β•‘ β•‘ -β•‘ πŸ” Debuglin Lv.100 β†’ πŸ”¬ Verifex β•‘ -β•‘ β•‘ -β•‘ Verifex: Sees the bug before the code is even written. β•‘ -β•‘ catch_rate: 0.60 β†’ 0.75 Β· xp_multiplier: 1.0 β†’ 1.3 β•‘ -β•‘ β•‘ -β•‘ ⚠️ Resets to Lv.1. Your caught monsters stay. β•‘ -β•‘ β•‘ -β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• -Evolve? (y/n) -``` - -On confirm, execute the evolution: - -```python -from datetime import datetime, timezone - -into_id = evolution["into"] -into_data = catalog["evolutions"][into_id] - -# Archive old form with evolution marker -owned[buddy_id]["evolved_into"] = into_id -owned[buddy_id]["evolved_at"] = datetime.now(timezone.utc).isoformat() - -# Create new form entry at Lv.1 -owned[into_id] = { - "id": into_id, - "display": into_data["display"], - "affinity": into_data.get("affinity", catalog_entry.get("affinity", "")), - "level": 1, - "xp": 0, - "evolved_from": buddy_id, - "evolved_at": datetime.now(timezone.utc).isoformat(), -} - -# Carry challenges forward from original form -challenges = catalog_entry.get("challenges") or into_data.get("challenges", []) -roster["owned"] = owned -json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2) - -# Update active to point to evolved form -active["buddymon_id"] = into_id -active["session_xp"] = 0 -active["challenge"] = challenges[0] if challenges else None -json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2) -``` - -Show result: -``` -✨ Debuglin evolved into πŸ”¬ Verifex! - Starting fresh at Lv.1 β€” the second climb is faster. - New challenge: IRON TEST +Agent(model="haiku", prompt=.instruction + "\n\nRespond with ONLY the matching name.") +β†’ re-run: python3 ~/.claude/buddymon/cli.py assign --resolved ``` --- -## `statusline` β€” Install Buddymon Statusline +## Catch Flow -Installs the Buddymon statusline into `~/.claude/settings.json`. +When `[INPUT_NEEDED]` fires during `catch`, the user describes which weakening actions they've done. -The statusline shows active buddy + level + session XP, and highlights any -active encounter in red: - -``` -🐾 Debuglin Lv.90 Β· +45xp βš” πŸ’€ NullWraith [60%] +**If input is numeric** (e.g. `"1 3"`): calculate inline β€” 1β†’-20, 2β†’-20, 3β†’-10. Sum reductions. Re-run: +```bash +python3 ~/.claude/buddymon/cli.py catch --strength <100 - total_reduction> ``` -Run this Python to install: - -```python -import json, os -from pathlib import Path - -SETTINGS = Path.home() / ".claude" / "settings.json" -# Prefer the stable user-local copy installed by install.sh -_script_candidates = [ - Path.home() / ".claude/buddymon/statusline.sh", - Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh" if os.environ.get("CLAUDE_PLUGIN_ROOT") else None, - Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/statusline.sh", -] -SCRIPT = next(str(p) for p in _script_candidates if p and p.exists()) - -settings = json.load(open(SETTINGS)) - -if settings.get("statusLine"): - print("⚠️ A statusLine is already configured. Replace it? (y/n)") - # ask user β€” if no, abort - # if yes, proceed - pass - -settings["statusLine"] = { - "type": "command", - "command": f"bash {SCRIPT}", -} -json.dump(settings, open(SETTINGS, "w"), indent=2) -print(f"βœ… Buddymon statusline installed. Reload Claude Code to activate.") +**If input is natural language** (e.g. `"wrote a failing test and documented it"`): spawn Haiku: ``` - -If a `statusLine` is already set, show the existing command and ask before replacing. - ---- - -## `help` - +Agent(model="haiku", prompt=""" +The user described their catch weakening actions: "" +Actions available: 1=failing_test (-20%), 2=isolation (-20%), 3=comment (-10%) +Reply with ONLY valid JSON: {"actions": [], "reduction": } +""") ``` -/buddymon β€” status panel -/buddymon start β€” choose starter (first run) -/buddymon assign β€” assign buddy to session -/buddymon fight β€” fight active encounter -/buddymon catch β€” catch active encounter -/buddymon roster β€” view full roster -/buddymon statusline β€” install statusline widget +Parse Haiku's JSON β†’ compute remaining strength β†’ re-run: +```bash +python3 ~/.claude/buddymon/cli.py catch --strength <100 - reduction> ```