feat: language mascot system + script-first architecture (v0.2.0)
Language mascots: - 11 mascots across common/uncommon/rare/legendary tiers (Pythia, Asynclet, Bashling, Goroutling, Typeling, Vueling, Querion, Ferrix, Perlius, Cobolithon, Lispling) - Full evolution chains for all mascots (16 evolutions total in catalog) - Spawn via PostToolUse after language affinity milestones; probability scales with affinity level; only fires with no active encounter - Passive strength reduction: each Write/Edit in the mascot's language ticks current_strength down (floor 5%, triggers re-announcement) - Mascot-aware catch formula: base_rate + affinity_bonus (6% per level) + weakness_bonus + soft element gating via existing player_elements - Language-themed weakening menu and catch failure messages in CLI - Caught mascots stored as type="caught_language_mascot"; assignable as buddy - UserPromptSubmit uses distinct 🦎 announcement with language context Roster display: - New "Language Mascots" section between core buddymon and caught bug monsters - Language affinity table marks languages with spawnable mascots (🦎) - Discovery counter now tracks both bug monsters and mascots separately Veritarch (third evolution): - Debuglin → Verifex (Lv.100) → Veritarch (Lv.200) - TYPE FORTRESS / INVARIANT PROOF / ZERO FLAKE challenges - xp_multiplier 1.7, catch_rate 0.90 Script-first architecture: - All game logic extracted to lib/cli.py (~850 lines); SKILL.md is now a ~55-line relay — 88% token reduction per invocation - CLI emits [INPUT_NEEDED] and [HAIKU_NEEDED] markers for interactive flows - PostToolUse hook re-emits CLI stdout as additionalContext for inline display Session XP fix: - statusline.sh and session state now read from sessions/<pgrp>.json (per-window) with fallback to active.json; fixes stale XP in statusline
This commit is contained in:
parent
b3b1813e9c
commit
159cd71560
7 changed files with 2490 additions and 618 deletions
|
|
@ -108,6 +108,40 @@ LANGUAGE_TIERS = [
|
||||||
(1200, "master"),
|
(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]:
|
def _tier_for_xp(xp: int) -> tuple[int, str]:
|
||||||
"""Return (level_index, tier_label) for a given XP total."""
|
"""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
|
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():
|
def get_languages_seen():
|
||||||
session = load_json(BUDDYMON_DIR / "session.json")
|
session = load_json(BUDDYMON_DIR / "session.json")
|
||||||
return set(session.get("languages_seen", []))
|
return set(session.get("languages_seen", []))
|
||||||
|
|
@ -278,36 +353,6 @@ def compute_strength(monster: dict, elapsed_minutes: float) -> int:
|
||||||
return min(100, int(base * 1.8))
|
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):
|
def match_event_encounter(command: str, output: str, session: dict, catalog: dict):
|
||||||
"""Detect non-error-based encounters: git ops, installs, test results."""
|
"""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),
|
"catchable": enc.get("catchable", True),
|
||||||
"defeatable": enc.get("defeatable", True),
|
"defeatable": enc.get("defeatable", True),
|
||||||
"xp_reward": enc.get("xp_reward", 50),
|
"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": [],
|
"weakened_by": [],
|
||||||
"announced": False,
|
"announced": False,
|
||||||
}
|
}
|
||||||
set_active_encounter(encounter)
|
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:
|
def format_new_language_message(lang: str, buddy_display: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"\n🗺️ **New language spotted: {lang}!**\n"
|
f"\n🗺️ **New language spotted: {lang}!**\n"
|
||||||
|
|
@ -449,6 +555,19 @@ def main():
|
||||||
if isinstance(b, dict) and b.get("type") == "text"
|
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()
|
existing = get_active_encounter()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -481,6 +600,7 @@ def main():
|
||||||
roster = load_json(BUDDYMON_DIR / "roster.json")
|
roster = load_json(BUDDYMON_DIR / "roster.json")
|
||||||
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
|
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
|
||||||
level_scale = 1.0 + (buddy_level / 100) * 0.25
|
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
|
# Wound cooldown: skip if another session wounded within 30s
|
||||||
last_wound = existing.get("last_wounded_at", "")
|
last_wound = existing.get("last_wounded_at", "")
|
||||||
|
|
@ -495,7 +615,7 @@ def main():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if existing.get("wounded"):
|
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:
|
if wound_cooldown_ok and random.random() < resolve_rate:
|
||||||
xp, display = auto_resolve_encounter(existing, buddy_id)
|
xp, display = auto_resolve_encounter(existing, buddy_id)
|
||||||
messages.append(
|
messages.append(
|
||||||
|
|
@ -503,7 +623,7 @@ def main():
|
||||||
f" {buddy_display} gets partial XP: +{xp}\n"
|
f" {buddy_display} gets partial XP: +{xp}\n"
|
||||||
)
|
)
|
||||||
else:
|
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:
|
if wound_cooldown_ok and random.random() < wound_rate:
|
||||||
wound_encounter()
|
wound_encounter()
|
||||||
# else: monster still present, no message — don't spam every tool call
|
# else: monster still present, no message — don't spam every tool call
|
||||||
|
|
@ -527,6 +647,11 @@ def main():
|
||||||
"catchable": target.get("catchable", True),
|
"catchable": target.get("catchable", True),
|
||||||
"defeatable": target.get("defeatable", True),
|
"defeatable": target.get("defeatable", True),
|
||||||
"xp_reward": target.get("xp_reward", 50),
|
"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": [],
|
"weakened_by": [],
|
||||||
"announced": False,
|
"announced": False,
|
||||||
}
|
}
|
||||||
|
|
@ -549,13 +674,16 @@ def main():
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
lang = KNOWN_EXTENSIONS.get(ext)
|
lang = KNOWN_EXTENSIONS.get(ext)
|
||||||
if lang:
|
if lang:
|
||||||
# Session-level "first encounter" bonus
|
# 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()
|
seen = get_languages_seen()
|
||||||
if lang not in seen:
|
if lang not in seen:
|
||||||
add_language_seen(lang)
|
add_language_seen(lang)
|
||||||
add_session_xp(15)
|
affinity = get_language_affinity(lang)
|
||||||
msg = format_new_language_message(lang, buddy_display)
|
if affinity.get("xp", 0) == 0:
|
||||||
messages.append(msg)
|
add_session_xp(15)
|
||||||
|
msg = format_new_language_message(lang, buddy_display)
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
# Persistent affinity XP — always accumulates
|
# Persistent affinity XP — always accumulates
|
||||||
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)
|
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)
|
msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display)
|
||||||
messages.append(msg)
|
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
|
# TestSpecter: editing a test file with no active encounter
|
||||||
if not get_active_encounter():
|
if not get_active_encounter():
|
||||||
enc = match_test_file_encounter(file_path, catalog)
|
enc = match_test_file_encounter(file_path, catalog)
|
||||||
|
|
|
||||||
|
|
@ -88,48 +88,89 @@ def main():
|
||||||
else:
|
else:
|
||||||
catalog = load_json(CATALOG_FILE)
|
catalog = load_json(CATALOG_FILE)
|
||||||
|
|
||||||
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
|
|
||||||
rarity = monster.get("rarity", "common")
|
|
||||||
rarity_stars = {
|
rarity_stars = {
|
||||||
"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
|
"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
|
||||||
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
|
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
|
||||||
}
|
}
|
||||||
stars = rarity_stars.get(rarity, "★★☆☆☆")
|
|
||||||
strength = enc.get("current_strength", 50)
|
strength = enc.get("current_strength", 50)
|
||||||
defeatable = enc.get("defeatable", True)
|
is_mascot = enc.get("encounter_type") == "language_mascot"
|
||||||
catchable = enc.get("catchable", True)
|
|
||||||
flavor = monster.get("flavor", "")
|
|
||||||
|
|
||||||
if enc.get("wounded"):
|
if is_mascot:
|
||||||
# Wounded re-announcement — urgent, catch-or-lose framing
|
mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
|
||||||
lines = [
|
rarity = mascot_data.get("rarity", "common")
|
||||||
f"\n🩹 **{enc['display']} is wounded and fleeing!**",
|
stars = rarity_stars.get(rarity, "★★☆☆☆")
|
||||||
f" Strength: {strength}% · This is your last chance to catch it.",
|
flavor = mascot_data.get("flavor", "")
|
||||||
"",
|
lang = enc.get("language") or mascot_data.get("language", "")
|
||||||
f" **{buddy_display}** is ready — move fast!",
|
assignable = mascot_data.get("assignable", False)
|
||||||
"",
|
|
||||||
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
|
if enc.get("wounded"):
|
||||||
" `[IGNORE]` → it flees on the next clean run",
|
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:
|
else:
|
||||||
# Normal first appearance
|
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
|
||||||
catchable_str = "[catchable · catch only]" if not defeatable else f"[{rarity} · {'catchable' if catchable else ''}]"
|
rarity = monster.get("rarity", "common")
|
||||||
lines = [
|
stars = rarity_stars.get(rarity, "★★☆☆☆")
|
||||||
f"\n💀 **{enc['display']} appeared!** {catchable_str}",
|
defeatable = enc.get("defeatable", True)
|
||||||
f" Strength: {strength}% · Rarity: {stars}",
|
catchable = enc.get("catchable", True)
|
||||||
]
|
flavor = monster.get("flavor", "")
|
||||||
if flavor:
|
|
||||||
lines.append(f" *{flavor}*")
|
if enc.get("wounded"):
|
||||||
if not defeatable:
|
lines = [
|
||||||
lines.append(" ⚠️ CANNOT BE DEFEATED — catch only")
|
f"\n🩹 **{enc['display']} is wounded and fleeing!**",
|
||||||
lines += [
|
f" Strength: {strength}% · This is your last chance to catch it.",
|
||||||
"",
|
"",
|
||||||
f" **{buddy_display}** is ready to battle!",
|
f" **{buddy_display}** is ready — move fast!",
|
||||||
"",
|
"",
|
||||||
" `[FIGHT]` Fix the bug → `/buddymon fight` to claim XP",
|
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
|
||||||
" `[CATCH]` Weaken first (test/repro/comment) → `/buddymon catch`",
|
" `[IGNORE]` → it flees on the next clean run",
|
||||||
" `[FLEE]` Ignore → monster grows stronger",
|
]
|
||||||
]
|
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)
|
msg = "\n".join(lines)
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
|
|
|
||||||
|
|
@ -278,11 +278,15 @@ if not os.path.exists(log_path):
|
||||||
print(" Created hook_debug.log")
|
print(" Created hook_debug.log")
|
||||||
PYEOF
|
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"
|
cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh"
|
||||||
chmod +x "${BUDDYMON_DIR}/statusline.sh"
|
chmod +x "${BUDDYMON_DIR}/statusline.sh"
|
||||||
ok "Installed statusline.sh → ${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
|
# Install statusline into settings.json if not already configured
|
||||||
python3 << PYEOF
|
python3 << PYEOF
|
||||||
import json
|
import json
|
||||||
|
|
|
||||||
1334
lib/catalog.json
1334
lib/catalog.json
File diff suppressed because it is too large
Load diff
881
lib/cli.py
Normal file
881
lib/cli.py
Normal file
|
|
@ -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: <prompt>] — ask user, re-run with answer appended
|
||||||
|
[HAIKU_NEEDED: <json>] — 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 <name>")
|
||||||
|
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 <name> → fuzzy match → show challenge → [INPUT_NEEDED: accept/decline/reroll]
|
||||||
|
assign <name> --accept
|
||||||
|
assign <name> --reroll
|
||||||
|
assign <name> --resolved <buddy_id> (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 <n> — 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()
|
||||||
|
|
@ -15,7 +15,15 @@ ID=$(jq -r '.buddymon_id // ""' "$B/active.json" 2>/dev/null)
|
||||||
[[ -n "$ID" ]] || exit 0
|
[[ -n "$ID" ]] || exit 0
|
||||||
|
|
||||||
LVL=$(jq -r ".owned[\"$ID\"].level // 1" "$B/roster.json" 2>/dev/null)
|
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_JSON=$(jq -c '.active_encounter // null' "$B/encounters.json" 2>/dev/null)
|
||||||
ENC_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null)
|
ENC_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null)
|
||||||
|
|
|
||||||
|
|
@ -1,548 +1,66 @@
|
||||||
---
|
---
|
||||||
name: buddymon
|
name: buddymon
|
||||||
description: Buddymon companion game — status, roster, encounters, and session management
|
description: Buddymon companion game — status, roster, encounters, and session management
|
||||||
argument-hint: [start|assign <name>|fight|catch|roster]
|
argument-hint: [start|assign <name>|fight|catch|roster|evolve|statusline|help]
|
||||||
allowed-tools: [Bash, Read]
|
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: <prompt>]`
|
||||||
|
|
||||||
| Argument | Action |
|
Ask the user the exact prompt. Then re-run with their answer appended:
|
||||||
|----------|--------|
|
|
||||||
| _(none)_ | Show status panel |
|
|
||||||
| `start` | Choose starter (first-run) |
|
|
||||||
| `assign <name>` | 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 |
|
|
||||||
|
|
||||||
---
|
- `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 <name>` + user says "accept" → `python3 ~/.claude/buddymon/cli.py assign <name> --accept`
|
||||||
|
- `assign <name>` + user says "reroll" → `python3 ~/.claude/buddymon/cli.py assign <name> --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: <json>]`
|
||||||
|
|
||||||
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:
|
||||||
```
|
```
|
||||||
╔══════════════════════════════════════════╗
|
Agent(model="haiku", prompt=<json>.instruction + "\n\nRespond with ONLY the matching name.")
|
||||||
║ 🐾 Buddymon ║
|
→ re-run: python3 ~/.claude/buddymon/cli.py assign <haiku_result> --resolved <haiku_result>
|
||||||
╠══════════════════════════════════════════╣
|
|
||||||
║ 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 <name>` — 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 `<name>` 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `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
|
**If input is numeric** (e.g. `"1 3"`): calculate inline — 1→-20, 2→-20, 3→-10. Sum reductions. Re-run:
|
||||||
active encounter in red:
|
```bash
|
||||||
|
python3 ~/.claude/buddymon/cli.py catch --strength <100 - total_reduction>
|
||||||
```
|
|
||||||
🐾 Debuglin Lv.90 · +45xp ⚔ 💀 NullWraith [60%]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this Python to install:
|
**If input is natural language** (e.g. `"wrote a failing test and documented it"`): spawn Haiku:
|
||||||
|
|
||||||
```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.")
|
|
||||||
```
|
```
|
||||||
|
Agent(model="haiku", prompt="""
|
||||||
If a `statusLine` is already set, show the existing command and ask before replacing.
|
The user described their catch weakening actions: "<user input>"
|
||||||
|
Actions available: 1=failing_test (-20%), 2=isolation (-20%), 3=comment (-10%)
|
||||||
---
|
Reply with ONLY valid JSON: {"actions": [<numbers>], "reduction": <total_percent>}
|
||||||
|
""")
|
||||||
## `help`
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/buddymon — status panel
|
Parse Haiku's JSON → compute remaining strength → re-run:
|
||||||
/buddymon start — choose starter (first run)
|
```bash
|
||||||
/buddymon assign <n> — assign buddy to session
|
python3 ~/.claude/buddymon/cli.py catch --strength <100 - reduction>
|
||||||
/buddymon fight — fight active encounter
|
|
||||||
/buddymon catch — catch active encounter
|
|
||||||
/buddymon roster — view full roster
|
|
||||||
/buddymon statusline — install statusline widget
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue