feat: initial Buddymon plugin
Claude Code plugin — collectible creatures discovered through coding. - Bug monsters spawn from error output (NullWraith, RacePhantom, ShadowBit, 11 total) - 5 Buddymon with affinities, challenges, and evolution chains - SessionStart hook injects active buddy + challenge into system context - PostToolUse hook detects error patterns, new languages, and commit events - Stop hook tallies XP and checks challenge completion - Single /buddymon command with start/assign/fight/catch/roster subcommands - Local state in ~/.claude/buddymon/ (roster, encounters, active, session)
This commit is contained in:
commit
f3d1f45253
11 changed files with 1635 additions and 0 deletions
16
.claude-plugin/plugin.json
Normal file
16
.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "buddymon",
|
||||
"version": "0.1.0",
|
||||
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
|
||||
"author": {
|
||||
"name": "CircuitForge LLC",
|
||||
"email": "hello@circuitforge.tech",
|
||||
"url": "https://circuitforge.tech"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"primary": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon",
|
||||
"github": "https://github.com/CircuitForgeLLC/buddymon",
|
||||
"codeberg": "https://codeberg.org/CircuitForge/buddymon"
|
||||
}
|
||||
}
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local state (lives in ~/.claude/buddymon/, not the repo)
|
||||
*.local.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 CircuitForge LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
109
README.md
Normal file
109
README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# 🐾 Buddymon
|
||||
|
||||
A Claude Code plugin that turns your coding sessions into a creature-collecting game.
|
||||
|
||||
Buddymon are discovered, caught, and leveled up through real development work — not separate from it.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
- **Bug monsters** spawn from error output during your session (TypeErrors, CORS errors, race conditions, etc.)
|
||||
- **Buddymon** are companions you assign to sessions — they gain XP and propose challenges
|
||||
- **Challenges** are proactive goals your buddy sets at session start (write 5 tests, implement a feature in 30 min, net-negative lines)
|
||||
- **Encounters** require you to fight or catch — catch rate improves if you write a failing test, isolate the repro, or add a comment
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# From the Claude Code marketplace (once listed):
|
||||
/install buddymon
|
||||
|
||||
# Or manually — clone and add to your project's .claude/settings.json:
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon.git ~/.claude/plugins/local/buddymon
|
||||
```
|
||||
|
||||
Then add to `~/.claude/settings.json`:
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"buddymon@local": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
One command, all subcommands:
|
||||
|
||||
| Usage | Description |
|
||||
|-------|-------------|
|
||||
| `/buddymon` | Status panel — active buddy, XP, challenge, encounter |
|
||||
| `/buddymon start` | Choose your starter (first run only) |
|
||||
| `/buddymon assign <name>` | Assign a buddy to this session |
|
||||
| `/buddymon fight` | Fight the current bug monster |
|
||||
| `/buddymon catch` | Attempt to catch the current bug monster |
|
||||
| `/buddymon roster` | View full roster |
|
||||
| `/buddymon help` | Show command list |
|
||||
|
||||
---
|
||||
|
||||
## Bug Monsters
|
||||
|
||||
Spawned from error output detected by the `PostToolUse` hook:
|
||||
|
||||
| Monster | Trigger | Rarity |
|
||||
|---------|---------|--------|
|
||||
| 👻 NullWraith | NullPointerException, AttributeError: NoneType | Common |
|
||||
| 😈 FencepostDemon | IndexError, ArrayIndexOutOfBounds | Common |
|
||||
| 🔧 TypeGreml | TypeError, type mismatch | Common |
|
||||
| 🐍 SyntaxSerpent | SyntaxError, parse error | Very common |
|
||||
| 🌐 CORSCurse | CORS policy blocked | Common |
|
||||
| ♾️ LoopLich | Timeout, RecursionError, stack overflow | Uncommon |
|
||||
| 👁️ RacePhantom | Race condition, deadlock, data race | Rare |
|
||||
| 🗿 FossilGolem | DeprecationWarning, legacy API | Uncommon |
|
||||
| 🔒 ShadowBit | Security vulnerability patterns | Rare — catch only |
|
||||
| 🌫️ VoidSpecter | 404, ENOENT, route not found | Common |
|
||||
| 🩸 MemoryLeech | OOM, memory leak | Uncommon |
|
||||
|
||||
---
|
||||
|
||||
## Buddymon (Starters)
|
||||
|
||||
| Buddy | Affinity | Discover trigger |
|
||||
|-------|---------|-----------------|
|
||||
| 🔥 Pyrobyte | Speedrunner | Starter choice |
|
||||
| 🔍 Debuglin | Tester | Starter choice |
|
||||
| ✂️ Minimox | Cleaner | Starter choice |
|
||||
| 🌙 Noctara | Nocturnal | Late-night session (after 10pm, 2+ hours) |
|
||||
| 🗺️ Explorah | Explorer | First time writing in a new language |
|
||||
|
||||
---
|
||||
|
||||
## State
|
||||
|
||||
All state lives in `~/.claude/buddymon/` — never in the repo.
|
||||
|
||||
```
|
||||
~/.claude/buddymon/
|
||||
├── roster.json # owned Buddymon, XP, levels
|
||||
├── encounters.json # encounter history + active encounter
|
||||
├── active.json # current session assignment + challenge
|
||||
└── session.json # session stats (reset each session)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mirrors
|
||||
|
||||
- **Primary:** https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon
|
||||
- **GitHub:** https://github.com/CircuitForgeLLC/buddymon
|
||||
- **Codeberg:** https://codeberg.org/CircuitForge/buddymon
|
||||
|
||||
---
|
||||
|
||||
*A [CircuitForge LLC](https://circuitforge.tech) project. MIT license.*
|
||||
309
hooks-handlers/post-tool-use.py
Executable file
309
hooks-handlers/post-tool-use.py
Executable file
|
|
@ -0,0 +1,309 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Buddymon PostToolUse hook.
|
||||
|
||||
Reads tool event JSON from stdin, checks for:
|
||||
- Bug monster triggers (error patterns in Bash output)
|
||||
- New language encounters (new file extensions in Write/Edit)
|
||||
- Commit streaks (git commit via Bash)
|
||||
- Deep focus / refactor signals
|
||||
|
||||
Outputs additionalContext JSON to stdout if an encounter or event fires.
|
||||
Always exits 0.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
|
||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
|
||||
|
||||
KNOWN_EXTENSIONS = {
|
||||
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
||||
".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
|
||||
".rb": "Ruby", ".go": "Go", ".rs": "Rust", ".c": "C",
|
||||
".cpp": "C++", ".java": "Java", ".cs": "C#", ".swift": "Swift",
|
||||
".kt": "Kotlin", ".php": "PHP", ".lua": "Lua", ".ex": "Elixir",
|
||||
".hs": "Haskell", ".ml": "OCaml", ".clj": "Clojure",
|
||||
".r": "R", ".jl": "Julia", ".sh": "Shell", ".bash": "Shell",
|
||||
".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS",
|
||||
".vue": "Vue", ".svelte": "Svelte",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_state():
|
||||
active = load_json(BUDDYMON_DIR / "active.json")
|
||||
encounters = load_json(BUDDYMON_DIR / "encounters.json")
|
||||
roster = load_json(BUDDYMON_DIR / "roster.json")
|
||||
session = load_json(BUDDYMON_DIR / "session.json")
|
||||
return active, encounters, roster, session
|
||||
|
||||
|
||||
def add_session_xp(amount: int):
|
||||
active_file = BUDDYMON_DIR / "active.json"
|
||||
roster_file = BUDDYMON_DIR / "roster.json"
|
||||
|
||||
active = load_json(active_file)
|
||||
active["session_xp"] = active.get("session_xp", 0) + amount
|
||||
buddy_id = active.get("buddymon_id")
|
||||
save_json(active_file, active)
|
||||
|
||||
if buddy_id:
|
||||
roster = load_json(roster_file)
|
||||
if buddy_id in roster.get("owned", {}):
|
||||
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
|
||||
save_json(roster_file, roster)
|
||||
|
||||
|
||||
def get_languages_seen():
|
||||
session = load_json(BUDDYMON_DIR / "session.json")
|
||||
return set(session.get("languages_seen", []))
|
||||
|
||||
|
||||
def add_language_seen(lang: str):
|
||||
session_file = BUDDYMON_DIR / "session.json"
|
||||
session = load_json(session_file)
|
||||
langs = session.get("languages_seen", [])
|
||||
if lang not in langs:
|
||||
langs.append(lang)
|
||||
session["languages_seen"] = langs
|
||||
save_json(session_file, session)
|
||||
|
||||
|
||||
def increment_session_tools():
|
||||
session_file = BUDDYMON_DIR / "session.json"
|
||||
session = load_json(session_file)
|
||||
session["tools_used"] = session.get("tools_used", 0) + 1
|
||||
save_json(session_file, session)
|
||||
|
||||
|
||||
def is_starter_chosen():
|
||||
roster = load_json(BUDDYMON_DIR / "roster.json")
|
||||
return roster.get("starter_chosen", False)
|
||||
|
||||
|
||||
def get_active_buddy_id():
|
||||
active = load_json(BUDDYMON_DIR / "active.json")
|
||||
return active.get("buddymon_id")
|
||||
|
||||
|
||||
def get_active_encounter():
|
||||
encounters = load_json(BUDDYMON_DIR / "encounters.json")
|
||||
return encounters.get("active_encounter")
|
||||
|
||||
|
||||
def set_active_encounter(encounter: dict):
|
||||
enc_file = BUDDYMON_DIR / "encounters.json"
|
||||
data = load_json(enc_file)
|
||||
data["active_encounter"] = encounter
|
||||
save_json(enc_file, data)
|
||||
|
||||
|
||||
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
|
||||
"""Return the first matching bug monster from the catalog, or None."""
|
||||
if not output_text:
|
||||
return None
|
||||
|
||||
# Only check first 4000 chars to avoid scanning huge outputs
|
||||
sample = output_text[:4000]
|
||||
|
||||
for monster_id, monster in catalog.get("bug_monsters", {}).items():
|
||||
for pattern in monster.get("error_patterns", []):
|
||||
if re.search(pattern, sample, re.IGNORECASE):
|
||||
return monster
|
||||
return None
|
||||
|
||||
|
||||
def compute_strength(monster: dict, elapsed_minutes: float) -> int:
|
||||
"""Scale monster strength based on how long the error has persisted."""
|
||||
base = monster.get("base_strength", 50)
|
||||
if elapsed_minutes < 2:
|
||||
return max(10, int(base * 0.6))
|
||||
elif elapsed_minutes < 15:
|
||||
return base
|
||||
elif elapsed_minutes < 60:
|
||||
return min(100, int(base * 1.4))
|
||||
else:
|
||||
# Boss tier — persisted over an hour
|
||||
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 engage.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_new_language_message(lang: str, buddy_display: str) -> str:
|
||||
return (
|
||||
f"\n🗺️ **New language spotted: {lang}!**\n"
|
||||
f" {buddy_display} is excited — this is new territory.\n"
|
||||
f" *Explorer XP bonus earned!* +15 XP\n"
|
||||
)
|
||||
|
||||
|
||||
def format_commit_message(streak: int, buddy_display: str) -> str:
|
||||
if streak < 5:
|
||||
return ""
|
||||
milestone_xp = {5: 50, 10: 120, 25: 300, 50: 700}
|
||||
xp = milestone_xp.get(streak, 30)
|
||||
return (
|
||||
f"\n🔥 **Commit streak: {streak}!**\n"
|
||||
f" {buddy_display} approves. +{xp} XP\n"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
|
||||
# Gate: only run if starter chosen
|
||||
if not is_starter_chosen():
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = data.get("tool_name", "")
|
||||
tool_input = data.get("tool_input", {})
|
||||
tool_response = data.get("tool_response", {})
|
||||
|
||||
if not BUDDYMON_DIR.exists():
|
||||
BUDDYMON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sys.exit(0)
|
||||
|
||||
catalog = load_json(CATALOG_FILE)
|
||||
buddy_id = get_active_buddy_id()
|
||||
|
||||
# Look up display name
|
||||
buddy_display = "your buddy"
|
||||
if buddy_id:
|
||||
b = (catalog.get("buddymon", {}).get(buddy_id)
|
||||
or catalog.get("evolutions", {}).get(buddy_id))
|
||||
if b:
|
||||
buddy_display = b.get("display", buddy_id)
|
||||
|
||||
increment_session_tools()
|
||||
|
||||
messages = []
|
||||
|
||||
# ── Bash tool: error detection + commit tracking ───────────────────────
|
||||
if tool_name == "Bash":
|
||||
output = ""
|
||||
if isinstance(tool_response, dict):
|
||||
output = tool_response.get("output", "") or tool_response.get("content", "")
|
||||
elif isinstance(tool_response, str):
|
||||
output = tool_response
|
||||
|
||||
# Don't spawn new encounter if one is already active
|
||||
existing = get_active_encounter()
|
||||
|
||||
if not existing and output:
|
||||
monster = match_bug_monster(output, catalog)
|
||||
if monster:
|
||||
# 70% chance to trigger (avoid every minor warning spawning)
|
||||
if random.random() < 0.70:
|
||||
strength = compute_strength(monster, elapsed_minutes=0)
|
||||
encounter = {
|
||||
"id": monster["id"],
|
||||
"display": monster["display"],
|
||||
"base_strength": monster.get("base_strength", 50),
|
||||
"current_strength": strength,
|
||||
"catchable": monster.get("catchable", True),
|
||||
"defeatable": monster.get("defeatable", True),
|
||||
"xp_reward": monster.get("xp_reward", 50),
|
||||
"weakened_by": [],
|
||||
}
|
||||
set_active_encounter(encounter)
|
||||
msg = format_encounter_message(monster, strength, buddy_display)
|
||||
messages.append(msg)
|
||||
|
||||
# Commit detection
|
||||
command = tool_input.get("command", "")
|
||||
if "git commit" in command and "exit_code" not in str(tool_response):
|
||||
session_file = BUDDYMON_DIR / "session.json"
|
||||
session = load_json(session_file)
|
||||
session["commits_this_session"] = session.get("commits_this_session", 0) + 1
|
||||
save_json(session_file, session)
|
||||
|
||||
commit_xp = 20
|
||||
add_session_xp(commit_xp)
|
||||
|
||||
# ── Write / Edit: new language detection ──────────────────────────────
|
||||
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
||||
file_path = tool_input.get("file_path", "")
|
||||
if file_path:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
lang = KNOWN_EXTENSIONS.get(ext)
|
||||
if lang:
|
||||
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)
|
||||
|
||||
# Small XP for every file edit
|
||||
add_session_xp(2)
|
||||
|
||||
if not messages:
|
||||
sys.exit(0)
|
||||
|
||||
combined = "\n".join(messages)
|
||||
result = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": combined
|
||||
}
|
||||
}
|
||||
print(json.dumps(result))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
hooks-handlers/session-start.sh
Executable file
124
hooks-handlers/session-start.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
# Buddymon SessionStart hook
|
||||
# Initializes state, loads active buddy, injects session context via additionalContext
|
||||
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}"
|
||||
source "${PLUGIN_ROOT}/lib/state.sh"
|
||||
|
||||
buddymon_init
|
||||
|
||||
ACTIVE_ID=$(buddymon_get_active)
|
||||
SESSION_XP=$(buddymon_get_session_xp)
|
||||
|
||||
# Load catalog for buddy display info
|
||||
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
||||
|
||||
build_context() {
|
||||
local ctx=""
|
||||
|
||||
# ── No starter chosen yet ─────────────────────────────────────────────
|
||||
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
|
||||
ctx="## 🐾 Buddymon — First Encounter!\n\n"
|
||||
ctx+="Thrumble here! You don't have a Buddymon yet. Three starters are waiting.\n\n"
|
||||
ctx+='Run `/buddymon-start` to choose your starter and begin collecting!\n\n'
|
||||
ctx+='**Starters available:** 🔥 Pyrobyte (Speedrunner) · 🔍 Debuglin (Tester) · ✂️ Minimox (Cleaner)'
|
||||
echo "${ctx}"
|
||||
return
|
||||
fi
|
||||
|
||||
# ── No buddy assigned to this session ─────────────────────────────────
|
||||
if [[ -z "${ACTIVE_ID}" ]]; then
|
||||
ctx="## 🐾 Buddymon\n\n"
|
||||
ctx+="No buddy assigned to this session. Run \`/buddymon-assign <name>\` to assign one.\n"
|
||||
ctx+="Run \`/buddymon\` to see your roster."
|
||||
echo "${ctx}"
|
||||
return
|
||||
fi
|
||||
|
||||
# ── Active buddy ───────────────────────────────────────────────────────
|
||||
local buddy_display buddy_affinity buddy_level buddy_xp
|
||||
buddy_display=$(python3 -c "
|
||||
import json
|
||||
catalog = json.load(open('${CATALOG}'))
|
||||
bid = '${ACTIVE_ID}'
|
||||
b = catalog.get('buddymon', {}).get(bid) or catalog.get('evolutions', {}).get(bid)
|
||||
if b:
|
||||
print(b.get('display', bid))
|
||||
" 2>/dev/null)
|
||||
|
||||
local roster_entry
|
||||
roster_entry=$(buddymon_get_roster_entry "${ACTIVE_ID}")
|
||||
if [[ -n "${roster_entry}" ]]; then
|
||||
buddy_level=$(echo "${roster_entry}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('level',1))")
|
||||
buddy_xp=$(echo "${roster_entry}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('xp',0))")
|
||||
else
|
||||
buddy_level=1
|
||||
buddy_xp=0
|
||||
fi
|
||||
|
||||
buddy_display="${buddy_display:-${ACTIVE_ID}}"
|
||||
|
||||
# XP bar (20 chars wide)
|
||||
local xp_needed=$(( buddy_level * 100 ))
|
||||
local xp_filled=$(( buddy_xp * 20 / xp_needed ))
|
||||
[[ ${xp_filled} -gt 20 ]] && xp_filled=20
|
||||
local xp_bar=""
|
||||
for ((i=0; i<xp_filled; i++)); do xp_bar+="█"; done
|
||||
for ((i=xp_filled; i<20; i++)); do xp_bar+="░"; done
|
||||
|
||||
ctx="## 🐾 Buddymon Active: ${buddy_display}\n"
|
||||
ctx+="**Lv.${buddy_level}** XP: [${xp_bar}] ${buddy_xp}/${xp_needed}\n\n"
|
||||
|
||||
# Active challenge
|
||||
local challenge
|
||||
challenge=$(python3 -c "
|
||||
import json
|
||||
f = '${ACTIVE_FILE}'
|
||||
d = json.load(open(f))
|
||||
ch = d.get('challenge')
|
||||
if ch:
|
||||
print(ch.get('name','?') + ' — ' + ch.get('description',''))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [[ -n "${challenge}" ]]; then
|
||||
ctx+="**Challenge:** 🔥 ${challenge}\n\n"
|
||||
fi
|
||||
|
||||
# Active encounter carry-over
|
||||
local enc
|
||||
enc=$(buddymon_get_active_encounter)
|
||||
if [[ -n "${enc}" ]]; then
|
||||
local enc_id enc_display enc_strength
|
||||
enc_id=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id','?'))")
|
||||
enc_display=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display','?'))")
|
||||
enc_strength=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_strength',100))")
|
||||
ctx+="⚠️ **Unresolved encounter from last session:** ${enc_display} (strength: ${enc_strength}%)\n"
|
||||
ctx+="Run \`/buddymon-fight\` or \`/buddymon-catch\` to resolve it.\n\n"
|
||||
fi
|
||||
|
||||
ctx+="*Bug monsters appear from error output. Use \`/buddymon-fight\` or \`/buddymon-catch\`.*"
|
||||
|
||||
echo "${ctx}"
|
||||
}
|
||||
|
||||
CONTEXT=$(build_context)
|
||||
|
||||
# Escape for JSON
|
||||
CONTEXT_JSON=$(python3 -c "
|
||||
import json, sys
|
||||
print(json.dumps(sys.argv[1]))" "${CONTEXT}" 2>/dev/null)
|
||||
|
||||
if [[ -z "${CONTEXT_JSON}" ]]; then
|
||||
CONTEXT_JSON='"🐾 Buddymon loaded."'
|
||||
fi
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": ${CONTEXT_JSON}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
118
hooks-handlers/session-stop.sh
Executable file
118
hooks-handlers/session-stop.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env bash
|
||||
# Buddymon Stop hook — tally session XP, check challenge, print summary
|
||||
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}"
|
||||
source "${PLUGIN_ROOT}/lib/state.sh"
|
||||
|
||||
buddymon_init
|
||||
|
||||
ACTIVE_ID=$(buddymon_get_active)
|
||||
SESSION_XP=$(buddymon_get_session_xp)
|
||||
|
||||
if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
|
||||
# Nothing to report
|
||||
cat << 'EOF'
|
||||
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": ""}}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Load catalog for display info
|
||||
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
||||
|
||||
SUMMARY=$(python3 << PYEOF
|
||||
import json, os
|
||||
|
||||
catalog_file = '${CATALOG}'
|
||||
active_file = '${BUDDYMON_DIR}/active.json'
|
||||
roster_file = '${BUDDYMON_DIR}/roster.json'
|
||||
session_file = '${BUDDYMON_DIR}/session.json'
|
||||
encounters_file = '${BUDDYMON_DIR}/encounters.json'
|
||||
|
||||
catalog = json.load(open(catalog_file))
|
||||
active = json.load(open(active_file))
|
||||
roster = json.load(open(roster_file))
|
||||
session = json.load(open(session_file))
|
||||
|
||||
buddy_id = active.get('buddymon_id')
|
||||
if not buddy_id:
|
||||
print('')
|
||||
exit()
|
||||
|
||||
b = (catalog.get('buddymon', {}).get(buddy_id)
|
||||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
||||
display = b.get('display', buddy_id)
|
||||
|
||||
xp_earned = active.get('session_xp', 0)
|
||||
level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
|
||||
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
|
||||
xp_needed = level * 100
|
||||
|
||||
# Check level up
|
||||
leveled_up = False
|
||||
new_level = level
|
||||
while total_xp >= new_level * 100:
|
||||
new_level += 1
|
||||
leveled_up = True
|
||||
|
||||
if leveled_up:
|
||||
# Save new level
|
||||
roster['owned'][buddy_id]['level'] = new_level
|
||||
json.dump(roster, open(roster_file, 'w'), indent=2)
|
||||
|
||||
# Session stats
|
||||
commits = session.get('commits_this_session', 0)
|
||||
tools = session.get('tools_used', 0)
|
||||
langs = session.get('languages_seen', [])
|
||||
challenge_completed = session.get('challenge_completed', False)
|
||||
challenge = active.get('challenge')
|
||||
|
||||
lines = [f"\n## 🐾 Session complete — {display}"]
|
||||
lines.append(f"**+{xp_earned} XP earned** this session")
|
||||
if commits:
|
||||
lines.append(f" · {commits} commit{'s' if commits != 1 else ''}")
|
||||
if langs:
|
||||
lines.append(f" · New languages: {', '.join(langs)}")
|
||||
|
||||
if leveled_up:
|
||||
lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!")
|
||||
else:
|
||||
filled = min(20, total_xp * 20 // xp_needed)
|
||||
bar = '█' * filled + '░' * (20 - filled)
|
||||
lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}")
|
||||
|
||||
if challenge:
|
||||
if challenge_completed:
|
||||
lines.append(f"\n🏆 **Challenge complete:** {challenge.get('name','?')} — bonus XP awarded!")
|
||||
else:
|
||||
lines.append(f"\n⏳ Challenge in progress: {challenge.get('name','?')}")
|
||||
|
||||
print('\n'.join(lines))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
# Reset session XP counter for next session (keep total in roster)
|
||||
python3 << PYEOF
|
||||
import json
|
||||
active_file = '${BUDDYMON_DIR}/active.json'
|
||||
active = json.load(open(active_file))
|
||||
active['session_xp'] = 0
|
||||
json.dump(active, open(active_file, 'w'), indent=2)
|
||||
PYEOF
|
||||
|
||||
# Reset session file
|
||||
buddymon_session_reset
|
||||
|
||||
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)
|
||||
[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""'
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "Stop",
|
||||
"additionalContext": ${SUMMARY_JSON}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
39
hooks/hooks.json
Normal file
39
hooks/hooks.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"description": "Buddymon lifecycle hooks — session init, encounter detection, XP tally",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
417
lib/catalog.json
Normal file
417
lib/catalog.json
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
{
|
||||
"_version": 1,
|
||||
"_note": "Master species catalog. discovered=false entries are hidden until triggered.",
|
||||
|
||||
"bug_monsters": {
|
||||
"NullWraith": {
|
||||
"id": "NullWraith",
|
||||
"display": "👻 NullWraith",
|
||||
"type": "bug_monster",
|
||||
"rarity": "common",
|
||||
"base_strength": 20,
|
||||
"xp_reward": 40,
|
||||
"catchable": true,
|
||||
"description": "Spawned from the void between variables. Fast, slippery, embarrassing.",
|
||||
"error_patterns": [
|
||||
"NoneType.*has no attribute",
|
||||
"Cannot read propert.*of null",
|
||||
"Cannot read propert.*of undefined",
|
||||
"AttributeError.*NoneType",
|
||||
"null pointer",
|
||||
"NullPointerException",
|
||||
"null reference"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 20},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "It was there this whole time. You just never checked."
|
||||
},
|
||||
"FencepostDemon": {
|
||||
"id": "FencepostDemon",
|
||||
"display": "😈 FencepostDemon",
|
||||
"type": "bug_monster",
|
||||
"rarity": "common",
|
||||
"base_strength": 25,
|
||||
"xp_reward": 45,
|
||||
"catchable": true,
|
||||
"description": "Born from a fence with one too many posts. Or was it one too few?",
|
||||
"error_patterns": [
|
||||
"index.*out of.*range",
|
||||
"IndexError",
|
||||
"ArrayIndexOutOfBounds",
|
||||
"list index out of range",
|
||||
"index out of bounds",
|
||||
"off.by.one"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 20},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "Always one step ahead. Or behind. It's hard to tell."
|
||||
},
|
||||
"TypeGreml": {
|
||||
"id": "TypeGreml",
|
||||
"display": "🔧 TypeGreml",
|
||||
"type": "bug_monster",
|
||||
"rarity": "common",
|
||||
"base_strength": 25,
|
||||
"xp_reward": 50,
|
||||
"catchable": true,
|
||||
"description": "Sneaks in through loose type annotations. Multiplies in dynamic languages.",
|
||||
"error_patterns": [
|
||||
"TypeError",
|
||||
"type error",
|
||||
"expected.*got.*instead",
|
||||
"cannot.*convert.*to",
|
||||
"incompatible types",
|
||||
"type.*is not assignable"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 20},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "It only attacks when you're absolutely sure about the type."
|
||||
},
|
||||
"SyntaxSerpent": {
|
||||
"id": "SyntaxSerpent",
|
||||
"display": "🐍 SyntaxSerpent",
|
||||
"type": "bug_monster",
|
||||
"rarity": "very_common",
|
||||
"base_strength": 10,
|
||||
"xp_reward": 20,
|
||||
"catchable": true,
|
||||
"description": "The most ancient and humble of all bug monsters. Extremely weak.",
|
||||
"error_patterns": [
|
||||
"SyntaxError",
|
||||
"syntax error",
|
||||
"unexpected token",
|
||||
"unexpected indent",
|
||||
"invalid syntax",
|
||||
"parse error",
|
||||
"ParseError"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 30},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 20}
|
||||
],
|
||||
"flavor": "You'll be embarrassed you let this one survive long enough to catch."
|
||||
},
|
||||
"CORSCurse": {
|
||||
"id": "CORSCurse",
|
||||
"display": "🌐 CORSCurse",
|
||||
"type": "bug_monster",
|
||||
"rarity": "common",
|
||||
"base_strength": 40,
|
||||
"xp_reward": 60,
|
||||
"catchable": true,
|
||||
"description": "Every web developer has met this one. It never gets less annoying.",
|
||||
"error_patterns": [
|
||||
"CORS",
|
||||
"Cross-Origin",
|
||||
"cross origin",
|
||||
"Access-Control-Allow-Origin",
|
||||
"has been blocked by CORS policy",
|
||||
"No 'Access-Control-Allow-Origin'"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 25},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "It's not your fault. Well. It kind of is."
|
||||
},
|
||||
"LoopLich": {
|
||||
"id": "LoopLich",
|
||||
"display": "♾️ LoopLich",
|
||||
"type": "bug_monster",
|
||||
"rarity": "uncommon",
|
||||
"base_strength": 60,
|
||||
"xp_reward": 100,
|
||||
"catchable": true,
|
||||
"description": "It never stops. It never sleeps. It was running before you got there.",
|
||||
"error_patterns": [
|
||||
"infinite loop",
|
||||
"timeout",
|
||||
"Timeout",
|
||||
"ETIMEDOUT",
|
||||
"execution timed out",
|
||||
"recursion limit",
|
||||
"RecursionError",
|
||||
"maximum recursion depth",
|
||||
"stack overflow",
|
||||
"StackOverflow"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 25},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 15}
|
||||
],
|
||||
"flavor": "The exit condition was always there. You just never believed in it."
|
||||
},
|
||||
"RacePhantom": {
|
||||
"id": "RacePhantom",
|
||||
"display": "👁️ RacePhantom",
|
||||
"type": "bug_monster",
|
||||
"rarity": "rare",
|
||||
"base_strength": 80,
|
||||
"xp_reward": 200,
|
||||
"catchable": true,
|
||||
"description": "Appears only when two threads reach the same place at the same time. Almost impossible to reproduce.",
|
||||
"error_patterns": [
|
||||
"race condition",
|
||||
"concurrent modification",
|
||||
"deadlock",
|
||||
"ConcurrentModificationException",
|
||||
"data race",
|
||||
"mutex",
|
||||
"thread.*conflict",
|
||||
"async.*conflict"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 15},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 30},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 15}
|
||||
],
|
||||
"flavor": "You've proven it exists. That's honestly impressive on its own."
|
||||
},
|
||||
"FossilGolem": {
|
||||
"id": "FossilGolem",
|
||||
"display": "🗿 FossilGolem",
|
||||
"type": "bug_monster",
|
||||
"rarity": "uncommon",
|
||||
"base_strength": 35,
|
||||
"xp_reward": 70,
|
||||
"catchable": true,
|
||||
"description": "Ancient. Slow. Stubbornly still in production. Always catchable, never fully defeatable.",
|
||||
"error_patterns": [
|
||||
"deprecated",
|
||||
"DeprecationWarning",
|
||||
"was deprecated",
|
||||
"will be removed",
|
||||
"is deprecated",
|
||||
"no longer supported",
|
||||
"legacy"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 20},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 20}
|
||||
],
|
||||
"flavor": "It survived every major version. It will outlast you."
|
||||
},
|
||||
"ShadowBit": {
|
||||
"id": "ShadowBit",
|
||||
"display": "🔒 ShadowBit",
|
||||
"type": "bug_monster",
|
||||
"rarity": "rare",
|
||||
"base_strength": 90,
|
||||
"xp_reward": 300,
|
||||
"catchable": true,
|
||||
"defeatable": false,
|
||||
"catch_requires": ["write_failing_test", "isolate_reproduction", "add_documenting_comment"],
|
||||
"description": "Cannot be defeated — only properly contained. Requires full documentation + patching.",
|
||||
"error_patterns": [
|
||||
"vulnerability",
|
||||
"CVE-",
|
||||
"security",
|
||||
"injection",
|
||||
"XSS",
|
||||
"CSRF",
|
||||
"SQL injection",
|
||||
"command injection",
|
||||
"path traversal",
|
||||
"hardcoded.*secret",
|
||||
"hardcoded.*password",
|
||||
"hardcoded.*token"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 25},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 35},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 20}
|
||||
],
|
||||
"flavor": "Defeat is not an option. Containment is the only victory."
|
||||
},
|
||||
"VoidSpecter": {
|
||||
"id": "VoidSpecter",
|
||||
"display": "🌫️ VoidSpecter",
|
||||
"type": "bug_monster",
|
||||
"rarity": "common",
|
||||
"base_strength": 20,
|
||||
"xp_reward": 35,
|
||||
"catchable": true,
|
||||
"description": "Haunts missing endpoints. The URL was real once. You just can't prove it.",
|
||||
"error_patterns": [
|
||||
"404",
|
||||
"Not Found",
|
||||
"ENOENT",
|
||||
"No such file",
|
||||
"File not found",
|
||||
"route.*not found",
|
||||
"endpoint.*not found"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 25},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 25},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "It used to exist. Probably."
|
||||
},
|
||||
"MemoryLeech": {
|
||||
"id": "MemoryLeech",
|
||||
"display": "🩸 MemoryLeech",
|
||||
"type": "bug_monster",
|
||||
"rarity": "uncommon",
|
||||
"base_strength": 55,
|
||||
"xp_reward": 110,
|
||||
"catchable": true,
|
||||
"description": "Slow. Patient. Feeds on RAM one byte at a time. You won't notice until it's too late.",
|
||||
"error_patterns": [
|
||||
"MemoryError",
|
||||
"out of memory",
|
||||
"OOM",
|
||||
"heap.*exhausted",
|
||||
"memory leak",
|
||||
"Cannot allocate memory",
|
||||
"Killed.*memory"
|
||||
],
|
||||
"weaken_actions": [
|
||||
{"action": "write_failing_test", "strength_reduction": 20},
|
||||
{"action": "isolate_reproduction", "strength_reduction": 30},
|
||||
{"action": "add_documenting_comment", "strength_reduction": 10}
|
||||
],
|
||||
"flavor": "It was already there when you opened the task manager."
|
||||
}
|
||||
},
|
||||
|
||||
"buddymon": {
|
||||
"Pyrobyte": {
|
||||
"id": "Pyrobyte",
|
||||
"display": "🔥 Pyrobyte",
|
||||
"type": "buddymon",
|
||||
"affinity": "Speedrunner",
|
||||
"rarity": "starter",
|
||||
"description": "Moves fast, thinks faster. Loves tight deadlines and feature sprints.",
|
||||
"discover_trigger": {"type": "starter", "index": 0},
|
||||
"base_stats": {"power": 40, "catch_rate": 0.45, "xp_multiplier": 1.2},
|
||||
"affinity_bonus_triggers": ["fast_feature", "short_session_win"],
|
||||
"challenges": [
|
||||
{"name": "SPEED RUN", "description": "Implement a feature in under 30 minutes", "xp": 280, "difficulty": 3},
|
||||
{"name": "BLITZ", "description": "Resolve 3 bug monsters in one session", "xp": 350, "difficulty": 4}
|
||||
],
|
||||
"evolutions": [
|
||||
{"level": 10, "into": "Infernus", "requires": "affinity_challenge_x3"}
|
||||
],
|
||||
"flavor": "It already committed before you finished reading the issue."
|
||||
},
|
||||
"Debuglin": {
|
||||
"id": "Debuglin",
|
||||
"display": "🔍 Debuglin",
|
||||
"type": "buddymon",
|
||||
"affinity": "Tester",
|
||||
"rarity": "starter",
|
||||
"description": "Patient, methodical, ruthless. Lives for the reproduction case.",
|
||||
"discover_trigger": {"type": "starter", "index": 1},
|
||||
"base_stats": {"power": 35, "catch_rate": 0.60, "xp_multiplier": 1.0},
|
||||
"affinity_bonus_triggers": ["write_test", "fix_bug_with_test"],
|
||||
"challenges": [
|
||||
{"name": "IRON TEST", "description": "Write 5 tests in one session", "xp": 300, "difficulty": 2},
|
||||
{"name": "COVERAGE PUSH", "description": "Increase test coverage in a file", "xp": 250, "difficulty": 2}
|
||||
],
|
||||
"evolutions": [
|
||||
{"level": 10, "into": "Verifex", "requires": "affinity_challenge_x3"}
|
||||
],
|
||||
"flavor": "The bug isn't found until the test is written."
|
||||
},
|
||||
"Minimox": {
|
||||
"id": "Minimox",
|
||||
"display": "✂️ Minimox",
|
||||
"type": "buddymon",
|
||||
"affinity": "Cleaner",
|
||||
"rarity": "starter",
|
||||
"description": "Obsessed with fewer lines. Gets uncomfortable around anything over 300 LOC.",
|
||||
"discover_trigger": {"type": "starter", "index": 2},
|
||||
"base_stats": {"power": 30, "catch_rate": 0.50, "xp_multiplier": 1.1},
|
||||
"affinity_bonus_triggers": ["net_negative_lines", "refactor_session"],
|
||||
"challenges": [
|
||||
{"name": "CLEAN RUN", "description": "Complete session with zero linter errors", "xp": 340, "difficulty": 2},
|
||||
{"name": "SHRINK", "description": "Net negative lines of code this session", "xp": 280, "difficulty": 3}
|
||||
],
|
||||
"evolutions": [
|
||||
{"level": 10, "into": "Nullex", "requires": "affinity_challenge_x3"}
|
||||
],
|
||||
"flavor": "It deleted your comment. It was redundant."
|
||||
},
|
||||
"Noctara": {
|
||||
"id": "Noctara",
|
||||
"display": "🌙 Noctara",
|
||||
"type": "buddymon",
|
||||
"affinity": "Nocturnal",
|
||||
"rarity": "rare",
|
||||
"description": "Only appears after 10pm. Mysterious. Gives bonus XP for late-night focus runs.",
|
||||
"discover_trigger": {"type": "late_night_session", "hours_after": 22, "min_hours": 2},
|
||||
"base_stats": {"power": 55, "catch_rate": 0.35, "xp_multiplier": 1.5},
|
||||
"affinity_bonus_triggers": ["late_night_session", "deep_focus"],
|
||||
"challenges": [
|
||||
{"name": "MIDNIGHT RUN", "description": "3-hour session after 10pm", "xp": 500, "difficulty": 4},
|
||||
{"name": "DAWN COMMIT", "description": "Commit between 2am and 5am", "xp": 400, "difficulty": 3}
|
||||
],
|
||||
"evolutions": [
|
||||
{"level": 15, "into": "Umbravex", "requires": "nocturnal_sessions_x5"}
|
||||
],
|
||||
"flavor": "It remembers everything you wrote at 2am. Everything."
|
||||
},
|
||||
"Explorah": {
|
||||
"id": "Explorah",
|
||||
"display": "🗺️ Explorah",
|
||||
"type": "buddymon",
|
||||
"affinity": "Explorer",
|
||||
"rarity": "uncommon",
|
||||
"description": "Discovered when you touch a new language for the first time. Thrives on novelty.",
|
||||
"discover_trigger": {"type": "new_language"},
|
||||
"base_stats": {"power": 45, "catch_rate": 0.50, "xp_multiplier": 1.2},
|
||||
"affinity_bonus_triggers": ["new_language", "new_library", "touch_new_module"],
|
||||
"challenges": [
|
||||
{"name": "EXPEDITION", "description": "Touch 3 different modules in one session", "xp": 260, "difficulty": 2},
|
||||
{"name": "POLYGLOT", "description": "Write in 2 different languages in one session", "xp": 380, "difficulty": 4}
|
||||
],
|
||||
"evolutions": [
|
||||
{"level": 12, "into": "Wandervex", "requires": "new_languages_x5"}
|
||||
],
|
||||
"flavor": "It's already halfway through the new framework docs."
|
||||
}
|
||||
},
|
||||
|
||||
"evolutions": {
|
||||
"Infernus": {
|
||||
"id": "Infernus",
|
||||
"display": "🌋 Infernus",
|
||||
"type": "buddymon",
|
||||
"evolves_from": "Pyrobyte",
|
||||
"affinity": "Speedrunner",
|
||||
"description": "Evolved form of Pyrobyte. Moves at dangerous speeds.",
|
||||
"base_stats": {"power": 70, "catch_rate": 0.55, "xp_multiplier": 1.5}
|
||||
},
|
||||
"Verifex": {
|
||||
"id": "Verifex",
|
||||
"display": "🔬 Verifex",
|
||||
"type": "buddymon",
|
||||
"evolves_from": "Debuglin",
|
||||
"affinity": "Tester",
|
||||
"description": "Evolved form of Debuglin. Sees the bug before the code is even written.",
|
||||
"base_stats": {"power": 60, "catch_rate": 0.75, "xp_multiplier": 1.3}
|
||||
},
|
||||
"Nullex": {
|
||||
"id": "Nullex",
|
||||
"display": "🕳️ Nullex",
|
||||
"type": "buddymon",
|
||||
"evolves_from": "Minimox",
|
||||
"affinity": "Cleaner",
|
||||
"description": "Evolved form of Minimox. Has achieved true minimalism. The file was always one function.",
|
||||
"base_stats": {"power": 55, "catch_rate": 0.65, "xp_multiplier": 1.4}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
lib/state.sh
Normal file
199
lib/state.sh
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env bash
|
||||
# Buddymon state management — read/write ~/.claude/buddymon/ JSON files
|
||||
# Source this file from hook handlers: source "${CLAUDE_PLUGIN_ROOT}/lib/state.sh"
|
||||
|
||||
BUDDYMON_DIR="${HOME}/.claude/buddymon"
|
||||
ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
|
||||
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
|
||||
ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
|
||||
SESSION_FILE="${BUDDYMON_DIR}/session.json"
|
||||
|
||||
buddymon_init() {
|
||||
mkdir -p "${BUDDYMON_DIR}"
|
||||
|
||||
if [[ ! -f "${ROSTER_FILE}" ]]; then
|
||||
cat > "${ROSTER_FILE}" << 'EOF'
|
||||
{
|
||||
"_version": 1,
|
||||
"owned": {},
|
||||
"starter_chosen": false
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENCOUNTERS_FILE}" ]]; then
|
||||
cat > "${ENCOUNTERS_FILE}" << 'EOF'
|
||||
{
|
||||
"_version": 1,
|
||||
"history": [],
|
||||
"active_encounter": null
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ACTIVE_FILE}" ]]; then
|
||||
cat > "${ACTIVE_FILE}" << 'EOF'
|
||||
{
|
||||
"_version": 1,
|
||||
"buddymon_id": null,
|
||||
"challenge": null,
|
||||
"session_xp": 0
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ ! -f "${SESSION_FILE}" ]]; then
|
||||
buddymon_session_reset
|
||||
fi
|
||||
}
|
||||
|
||||
buddymon_session_reset() {
|
||||
local ts
|
||||
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
cat > "${SESSION_FILE}" << EOF
|
||||
{
|
||||
"_version": 1,
|
||||
"started_at": "${ts}",
|
||||
"xp_earned": 0,
|
||||
"tools_used": 0,
|
||||
"files_touched": [],
|
||||
"languages_seen": [],
|
||||
"errors_encountered": [],
|
||||
"commits_this_session": 0,
|
||||
"challenge_accepted": false,
|
||||
"challenge_completed": false
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
buddymon_get_active() {
|
||||
if [[ -f "${ACTIVE_FILE}" ]]; then
|
||||
python3 -c "import json; d=json.load(open('${ACTIVE_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
buddymon_get_session_xp() {
|
||||
if [[ -f "${ACTIVE_FILE}" ]]; then
|
||||
python3 -c "import json; d=json.load(open('${ACTIVE_FILE}')); print(d.get('session_xp', 0))" 2>/dev/null
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
buddymon_get_roster_entry() {
|
||||
local id="$1"
|
||||
if [[ -f "${ROSTER_FILE}" ]]; then
|
||||
python3 -c "
|
||||
import json
|
||||
d=json.load(open('${ROSTER_FILE}'))
|
||||
entry=d.get('owned',{}).get('${id}')
|
||||
if entry: print(json.dumps(entry))
|
||||
" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
buddymon_add_xp() {
|
||||
local amount="$1"
|
||||
python3 << EOF
|
||||
import json, os
|
||||
|
||||
active_file = '${ACTIVE_FILE}'
|
||||
roster_file = '${ROSTER_FILE}'
|
||||
|
||||
# Update session XP
|
||||
with open(active_file) as f:
|
||||
active = json.load(f)
|
||||
|
||||
active['session_xp'] = active.get('session_xp', 0) + ${amount}
|
||||
buddy_id = active.get('buddymon_id')
|
||||
|
||||
with open(active_file, 'w') as f:
|
||||
json.dump(active, f, indent=2)
|
||||
|
||||
# Update roster
|
||||
if buddy_id and os.path.exists(roster_file):
|
||||
with open(roster_file) as f:
|
||||
roster = json.load(f)
|
||||
if buddy_id in roster.get('owned', {}):
|
||||
roster['owned'][buddy_id]['xp'] = roster['owned'][buddy_id].get('xp', 0) + ${amount}
|
||||
with open(roster_file, 'w') as f:
|
||||
json.dump(roster, f, indent=2)
|
||||
EOF
|
||||
}
|
||||
|
||||
buddymon_set_active_encounter() {
|
||||
local encounter_json="$1"
|
||||
python3 << EOF
|
||||
import json
|
||||
enc_file = '${ENCOUNTERS_FILE}'
|
||||
with open(enc_file) as f:
|
||||
data = json.load(f)
|
||||
data['active_encounter'] = ${encounter_json}
|
||||
with open(enc_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
EOF
|
||||
}
|
||||
|
||||
buddymon_clear_active_encounter() {
|
||||
python3 << EOF
|
||||
import json
|
||||
enc_file = '${ENCOUNTERS_FILE}'
|
||||
with open(enc_file) as f:
|
||||
data = json.load(f)
|
||||
data['active_encounter'] = None
|
||||
with open(enc_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
EOF
|
||||
}
|
||||
|
||||
buddymon_log_encounter() {
|
||||
local encounter_json="$1"
|
||||
python3 << EOF
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
enc_file = '${ENCOUNTERS_FILE}'
|
||||
with open(enc_file) as f:
|
||||
data = json.load(f)
|
||||
entry = ${encounter_json}
|
||||
entry['timestamp'] = datetime.now(timezone.utc).isoformat()
|
||||
data.setdefault('history', []).append(entry)
|
||||
with open(enc_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
EOF
|
||||
}
|
||||
|
||||
buddymon_get_active_encounter() {
|
||||
if [[ -f "${ENCOUNTERS_FILE}" ]]; then
|
||||
python3 -c "
|
||||
import json
|
||||
d=json.load(open('${ENCOUNTERS_FILE}'))
|
||||
e=d.get('active_encounter')
|
||||
if e: print(json.dumps(e))
|
||||
" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
buddymon_starter_chosen() {
|
||||
python3 -c "import json; d=json.load(open('${ROSTER_FILE}')); print('true' if d.get('starter_chosen') else 'false')" 2>/dev/null
|
||||
}
|
||||
|
||||
buddymon_add_to_roster() {
|
||||
local buddy_json="$1"
|
||||
python3 << EOF
|
||||
import json
|
||||
roster_file = '${ROSTER_FILE}'
|
||||
with open(roster_file) as f:
|
||||
roster = json.load(f)
|
||||
entry = ${buddy_json}
|
||||
bid = entry.get('id')
|
||||
if bid and bid not in roster.get('owned', {}):
|
||||
entry.setdefault('xp', 0)
|
||||
entry.setdefault('level', 1)
|
||||
roster.setdefault('owned', {})[bid] = entry
|
||||
with open(roster_file, 'w') as f:
|
||||
json.dump(roster, f, indent=2)
|
||||
print('added')
|
||||
else:
|
||||
print('exists')
|
||||
EOF
|
||||
}
|
||||
272
skills/buddymon/SKILL.md
Normal file
272
skills/buddymon/SKILL.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
---
|
||||
name: buddymon
|
||||
description: Buddymon companion game — status, roster, encounters, and session management
|
||||
argument-hint: [start|assign <name>|fight|catch|roster]
|
||||
allowed-tools: [Bash, Read]
|
||||
---
|
||||
|
||||
# /buddymon — Buddymon Companion
|
||||
|
||||
The main Buddymon command. Route based on the argument provided.
|
||||
|
||||
**Invoked with:** `/buddymon $ARGUMENTS`
|
||||
|
||||
---
|
||||
|
||||
## Subcommand Routing
|
||||
|
||||
Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch:
|
||||
|
||||
| Argument | Action |
|
||||
|----------|--------|
|
||||
| _(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 |
|
||||
| `help` | Show command list |
|
||||
|
||||
---
|
||||
|
||||
## No argument — Status Panel
|
||||
|
||||
Read state files and display:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════╗
|
||||
║ 🐾 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
|
||||
|
||||
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
|
||||
|
||||
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
|
||||
|
||||
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, update `active.json` (buddy_id, reset session_xp, set challenge).
|
||||
Show challenge proposal with Accept / Decline / Reroll.
|
||||
|
||||
---
|
||||
|
||||
## `fight` — Fight Encounter
|
||||
|
||||
Read `encounters.json` → `active_encounter`. If none: "No active encounter."
|
||||
|
||||
Show encounter state. Confirm the user has actually 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."
|
||||
|
||||
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:
|
||||
```python
|
||||
import json, os, random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
|
||||
|
||||
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")
|
||||
buddy_id = active.get("buddymon_id")
|
||||
|
||||
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
|
||||
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:
|
||||
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...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `help`
|
||||
|
||||
```
|
||||
/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
|
||||
```
|
||||
Loading…
Reference in a new issue