feat: expand encounter triggers — git ops, installs, test events, test files

New event_encounters catalog section:
  🔀 MergeMaw     — git merge / rebase
  🌿 BranchSprite — git checkout -b / switch -c (catch-only)
  📦 DepGolem     — pip/npm/cargo/yarn/brew install
  🎲 FlakeDemon   — test failure output (FAILED, AssertionError, etc.)
   PhantomPass  — tests pass after session errors (rare, catch-only)
  🧪 TestSpecter  — editing a test file (50% chance)

Detection logic:
  - Bash hook now checks tool_input.command for command-pattern triggers
  - Event encounters run when no bug monster matched (priority: bugs first)
  - Edit/Write hook adds TestSpecter check on test file paths
This commit is contained in:
pyr0ball 2026-04-02 22:11:31 -07:00
parent 1930bd29bd
commit 6a81392074
2 changed files with 188 additions and 20 deletions

View file

@ -225,6 +225,58 @@ def format_encounter_message(monster: dict, strength: int, buddy_display: str) -
return "\n".join(lines)
def match_event_encounter(command: str, output: str, session: dict, catalog: dict):
"""Detect non-error-based encounters: git ops, installs, test results."""
events = catalog.get("event_encounters", {})
errors_seen = bool(session.get("errors_encountered") or session.get("tools_used", 0) > 5)
for enc_id, enc in events.items():
trigger = enc.get("trigger_type", "")
if trigger == "command":
if any(pat in command for pat in enc.get("command_patterns", [])):
return enc
elif trigger == "output":
if any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("error_patterns", [])):
return enc
elif trigger == "test_victory":
# Only spawn PhantomPass when tests go green after the session has been running a while
if errors_seen and any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("success_patterns", [])):
return enc
return None
def match_test_file_encounter(file_path: str, catalog: dict):
"""Spawn TestSpecter when editing a test file."""
enc = catalog.get("event_encounters", {}).get("TestSpecter")
if not enc:
return None
name = os.path.basename(file_path).lower()
if any(re.search(pat, name) for pat in enc.get("test_file_patterns", [])):
return enc
return None
def spawn_encounter(enc: dict) -> None:
"""Write an event encounter to active state with announced=False."""
strength = enc.get("base_strength", 30)
encounter = {
"id": enc["id"],
"display": enc["display"],
"base_strength": enc.get("base_strength", 30),
"current_strength": strength,
"catchable": enc.get("catchable", True),
"defeatable": enc.get("defeatable", True),
"xp_reward": enc.get("xp_reward", 50),
"weakened_by": [],
"announced": False,
}
set_active_encounter(encounter)
def format_new_language_message(lang: str, buddy_display: str) -> str:
return (
f"\n🗺️ **New language spotted: {lang}!**\n"
@ -279,6 +331,7 @@ def main():
# ── Bash tool: error detection + auto-resolution + commit tracking ───────
if tool_name == "Bash":
command = tool_input.get("command", "")
output = ""
# CC Bash tool_response keys: stdout, stderr, interrupted, isImage, noOutputExpected
if isinstance(tool_response, dict):
@ -306,28 +359,32 @@ def main():
f" +{xp} XP\n"
)
# else: monster persists, no message — don't spam every tool call
elif output:
# No active encounter — check for a new one
monster = match_bug_monster(output, catalog)
elif output or command:
# No active encounter — check for bug monster first, then event encounters
session = load_json(BUDDYMON_DIR / "session.json")
monster = match_bug_monster(output, catalog) if output else None
event = None if monster else match_event_encounter(command, output, session, catalog)
target = monster or event
if target and random.random() < 0.70:
if monster:
# 70% chance to trigger (avoids every minor warning spawning)
if random.random() < 0.70:
strength = compute_strength(monster, elapsed_minutes=0)
else:
strength = target.get("base_strength", 30)
encounter = {
"id": monster["id"],
"display": monster["display"],
"base_strength": monster.get("base_strength", 50),
"id": target["id"],
"display": target["display"],
"base_strength": target.get("base_strength", 50),
"current_strength": strength,
"catchable": monster.get("catchable", True),
"defeatable": monster.get("defeatable", True),
"xp_reward": monster.get("xp_reward", 50),
"catchable": target.get("catchable", True),
"defeatable": target.get("defeatable", True),
"xp_reward": target.get("xp_reward", 50),
"weakened_by": [],
"announced": False,
}
set_active_encounter(encounter)
# 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)
@ -337,7 +394,7 @@ def main():
commit_xp = 20
add_session_xp(commit_xp)
# ── Write / Edit: new language detection ──────────────────────────────
# ── Write / Edit: new language detection + test file encounters ───────
elif tool_name in ("Write", "Edit", "MultiEdit"):
file_path = tool_input.get("file_path", "")
if file_path:
@ -351,6 +408,12 @@ def main():
msg = format_new_language_message(lang, buddy_display)
messages.append(msg)
# TestSpecter: editing a test file with no active encounter
if not get_active_encounter():
enc = match_test_file_encounter(file_path, catalog)
if enc and random.random() < 0.50:
spawn_encounter(enc)
# Small XP for every file edit
add_session_xp(2)

View file

@ -287,6 +287,111 @@
}
},
"event_encounters": {
"MergeMaw": {
"id": "MergeMaw",
"display": "🔀 MergeMaw",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 45,
"xp_reward": 80,
"catchable": true,
"defeatable": true,
"trigger_type": "command",
"command_patterns": ["git merge", "git rebase"],
"description": "Emerges from the diff between two timelines. Loves conflicts.",
"flavor": "It has opinions about your whitespace."
},
"BranchSprite": {
"id": "BranchSprite",
"display": "🌿 BranchSprite",
"type": "event_encounter",
"rarity": "common",
"base_strength": 15,
"xp_reward": 50,
"catchable": true,
"defeatable": false,
"trigger_type": "command",
"command_patterns": ["git checkout -b", "git switch -c", "git branch "],
"description": "Appears when a new branch is born. Harmless. Almost cheerful.",
"flavor": "It wanted to come along for the feature."
},
"DepGolem": {
"id": "DepGolem",
"display": "📦 DepGolem",
"type": "event_encounter",
"rarity": "common",
"base_strength": 30,
"xp_reward": 45,
"catchable": true,
"defeatable": true,
"trigger_type": "command",
"command_patterns": ["pip install", "pip3 install", "npm install", "npm i ", "cargo add", "yarn add", "apt install", "brew install", "poetry add", "uv add", "uv pip install"],
"description": "Conjured from the package registry. Brings transitive dependencies.",
"flavor": "It brought 847 friends."
},
"FlakeDemon": {
"id": "FlakeDemon",
"display": "🎲 FlakeDemon",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 55,
"xp_reward": 90,
"catchable": true,
"defeatable": true,
"trigger_type": "output",
"error_patterns": [
"FAILED",
"AssertionError",
"Expected.*[Rr]eceived",
"assert.*failed",
"\\d+ failed",
"FAIL\\b",
"test.*FAILED",
"FAILURES"
],
"description": "Born from test infrastructure, not application code. The hardest kind to pin down.",
"flavor": "It passed on CI. It always passes on CI."
},
"PhantomPass": {
"id": "PhantomPass",
"display": "✅ PhantomPass",
"type": "event_encounter",
"rarity": "rare",
"base_strength": 10,
"xp_reward": 150,
"catchable": true,
"defeatable": false,
"trigger_type": "test_victory",
"success_patterns": [
"passed",
"PASSED",
"All tests passed",
"tests passed",
"✓",
"\\d+ passed",
"OK$",
"SUCCESS"
],
"description": "Appears only when tests go green after going red. Rare. Fleeting. Cannot be fought — only caught.",
"flavor": "It was hiding in the red all along."
},
"TestSpecter": {
"id": "TestSpecter",
"display": "🧪 TestSpecter",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 25,
"xp_reward": 65,
"catchable": true,
"defeatable": true,
"trigger_type": "test_file",
"test_file_patterns": ["\\.test\\.", "_test\\.", "test_", "_spec\\.", "\\.spec\\."],
"description": "Haunts test suites. Drawn to assertions. Debuglin gets excited.",
"flavor": "It wanted to make sure the test was named correctly."
}
},
"buddymon": {
"Pyrobyte": {
"id": "Pyrobyte",