fix: structured roster, stop-hook delivery, stale buddy guard
Roster (cli.py):
- Fully restructured cmd_roster with four clearly-labelled sections:
BUDDYMON (✓ levels via XP), LANGUAGE MASCOTS (✓ levels via XP),
LANGUAGE AFFINITIES (◑ passive), BUG TROPHY CASE (✗ trophies only)
- Evolution chain shown inline per-buddy (Debuglin → [Verifex] → Veritarch)
- Active buddy marked ← ACTIVE; next-evo target with levels-to-go
- Language affinities show element + 🦎 tag when a mascot is available
- Bug trophy grid: two-column, element tags, notable levels shown
- Writes roster_pending.txt to trigger Stop hook delivery (no truncation)
Stop hook (roster-stop.py + hooks.json):
- New Stop hook reads roster_pending.txt; runs cli.py roster; emits full
output as additionalContext; clears the flag — fires once per invocation
- Avoids Bash tool result truncation for long roster output
Bug fixes (post-tool-use.py):
- get_session_state(): fall through to active.json when buddymon_id is null
or missing (pgrp mismatch between CLI process and hook process)
- get_active_buddy_id(): guard against caught_bug_monster type slipping in
as the active buddy (ThrottleDemon state corruption fix)
- Language "new territory" message now uses affinity XP == 0 as the sole
signal — drops volatile session.json languages_seen check that caused
spurious "New language spotted" fires across sessions
- Already-owned encounter shortcut: instant dismiss with no wound cycle
This commit is contained in:
parent
457276e302
commit
a42c003202
5 changed files with 266 additions and 76 deletions
|
|
@ -33,14 +33,13 @@ SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
|
||||||
def get_session_state() -> dict:
|
def get_session_state() -> dict:
|
||||||
"""Read the current session's state file, falling back to global active.json."""
|
"""Read the current session's state file, falling back to global active.json."""
|
||||||
session = load_json(SESSION_FILE)
|
session = load_json(SESSION_FILE)
|
||||||
if not session:
|
# Fall back when file is missing OR when buddymon_id is null (e.g. pgrp mismatch
|
||||||
# No session file yet — inherit from global default
|
# between the CLI process that ran evolve/assign and this hook process).
|
||||||
|
if not session.get("buddymon_id"):
|
||||||
global_active = load_json(BUDDYMON_DIR / "active.json")
|
global_active = load_json(BUDDYMON_DIR / "active.json")
|
||||||
session = {
|
session["buddymon_id"] = global_active.get("buddymon_id")
|
||||||
"buddymon_id": global_active.get("buddymon_id"),
|
session.setdefault("challenge", global_active.get("challenge"))
|
||||||
"challenge": global_active.get("challenge"),
|
session.setdefault("session_xp", 0)
|
||||||
"session_xp": 0,
|
|
||||||
}
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -250,7 +249,18 @@ def is_starter_chosen():
|
||||||
|
|
||||||
|
|
||||||
def get_active_buddy_id():
|
def get_active_buddy_id():
|
||||||
return get_session_state().get("buddymon_id")
|
"""Return the active buddy ID, skipping any caught monster that slipped in."""
|
||||||
|
bid = get_session_state().get("buddymon_id")
|
||||||
|
if not bid:
|
||||||
|
return None
|
||||||
|
# Validate: caught bug/mascot types are trophies, not assignable buddies.
|
||||||
|
# If one ended up in state (e.g. from a state corruption), fall back to active.json.
|
||||||
|
roster = load_json(BUDDYMON_DIR / "roster.json")
|
||||||
|
mon_type = roster.get("owned", {}).get(bid, {}).get("type", "")
|
||||||
|
if mon_type in ("caught_bug_monster",):
|
||||||
|
fallback = load_json(BUDDYMON_DIR / "active.json")
|
||||||
|
bid = fallback.get("buddymon_id", bid)
|
||||||
|
return bid
|
||||||
|
|
||||||
|
|
||||||
def get_active_encounter():
|
def get_active_encounter():
|
||||||
|
|
@ -570,6 +580,24 @@ def main():
|
||||||
|
|
||||||
existing = get_active_encounter()
|
existing = get_active_encounter()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Already-owned shortcut: dismiss immediately, no XP, no wound cycle.
|
||||||
|
# No point fighting something already in the collection.
|
||||||
|
enc_id = existing.get("id", "")
|
||||||
|
roster_quick = load_json(BUDDYMON_DIR / "roster.json")
|
||||||
|
owned_quick = roster_quick.get("owned", {})
|
||||||
|
if (enc_id in owned_quick
|
||||||
|
and owned_quick[enc_id].get("type") in ("caught_bug_monster", "caught_language_mascot")
|
||||||
|
and not existing.get("catch_pending")):
|
||||||
|
enc_data = load_json(BUDDYMON_DIR / "encounters.json")
|
||||||
|
enc_data.setdefault("history", []).append({**existing, "outcome": "dismissed_owned"})
|
||||||
|
enc_data["active_encounter"] = None
|
||||||
|
save_json(BUDDYMON_DIR / "encounters.json", enc_data)
|
||||||
|
messages.append(
|
||||||
|
f"\n🔁 **{existing.get('display', enc_id)}** — already in your collection. Dismissed."
|
||||||
|
)
|
||||||
|
existing = None # skip further processing this run
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# On a clean Bash run (monster patterns gone), respect catch_pending,
|
# On a clean Bash run (monster patterns gone), respect catch_pending,
|
||||||
# wound a healthy monster, or auto-resolve a wounded one.
|
# wound a healthy monster, or auto-resolve a wounded one.
|
||||||
|
|
@ -674,11 +702,9 @@ 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 — only announce if genuinely new
|
# Fire "new language" bonus only when affinity XP is genuinely zero.
|
||||||
# (zero affinity XP). Languages already in the roster just get quiet XP.
|
# Affinity XP in roster.json is the reliable persistent signal;
|
||||||
seen = get_languages_seen()
|
# session.json languages_seen was volatile and caused false positives.
|
||||||
if lang not in seen:
|
|
||||||
add_language_seen(lang)
|
|
||||||
affinity = get_language_affinity(lang)
|
affinity = get_language_affinity(lang)
|
||||||
if affinity.get("xp", 0) == 0:
|
if affinity.get("xp", 0) == 0:
|
||||||
add_session_xp(15)
|
add_session_xp(15)
|
||||||
|
|
|
||||||
52
hooks-handlers/roster-stop.py
Normal file
52
hooks-handlers/roster-stop.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Buddymon Stop hook — roster re-emitter.
|
||||||
|
|
||||||
|
Checks for roster_pending.txt written by `cli.py roster`.
|
||||||
|
If present, runs the roster CLI and emits full output as additionalContext,
|
||||||
|
guaranteeing the full roster is visible without Bash-tool truncation.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||||
|
PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt"
|
||||||
|
CLI = BUDDYMON_DIR / "cli.py"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not PENDING_FLAG.exists():
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
PENDING_FLAG.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if not CLI.exists():
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", str(CLI), "roster"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
output = result.stdout.strip()
|
||||||
|
if not output:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "Stop",
|
||||||
|
"additionalContext": output,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -47,6 +47,16 @@
|
||||||
"timeout": 10
|
"timeout": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/roster-stop.py",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,9 @@ PYEOF
|
||||||
chmod +x "${BUDDYMON_DIR}/cli.py"
|
chmod +x "${BUDDYMON_DIR}/cli.py"
|
||||||
ok "Installed cli.py → ${BUDDYMON_DIR}/cli.py"
|
ok "Installed cli.py → ${BUDDYMON_DIR}/cli.py"
|
||||||
|
|
||||||
|
cp "${REPO_DIR}/lib/catalog.json" "${BUDDYMON_DIR}/catalog.json"
|
||||||
|
ok "Installed catalog.json → ${BUDDYMON_DIR}/catalog.json"
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
||||||
183
lib/cli.py
183
lib/cli.py
|
|
@ -26,8 +26,10 @@ def find_catalog() -> dict:
|
||||||
pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(pr) / "lib" / "catalog.json" if pr else None,
|
Path(pr) / "lib" / "catalog.json" if pr else None,
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/catalog.json",
|
# User-local copy updated by install.sh — checked before stale plugin cache
|
||||||
|
BUDDYMON_DIR / "catalog.json",
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
|
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
|
||||||
|
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/catalog.json",
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
||||||
]
|
]
|
||||||
for p in candidates:
|
for p in candidates:
|
||||||
|
|
@ -412,89 +414,186 @@ def cmd_catch(args: list[str]):
|
||||||
print(f"💨 {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)")
|
print(f"💨 {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)")
|
||||||
|
|
||||||
|
|
||||||
|
def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str:
|
||||||
|
"""Build a short evolution chain string: A → B → C with current marked."""
|
||||||
|
# Walk backwards to find root
|
||||||
|
root = bid
|
||||||
|
while True:
|
||||||
|
prev = owned.get(root, {}).get("evolved_from") or catalog.get("evolutions", {}).get(root, {}).get("evolves_from")
|
||||||
|
if not prev or prev not in owned:
|
||||||
|
break
|
||||||
|
root = prev
|
||||||
|
# Walk forward
|
||||||
|
chain = [root]
|
||||||
|
cur = root
|
||||||
|
while True:
|
||||||
|
nxt = owned.get(cur, {}).get("evolved_into")
|
||||||
|
if not nxt or nxt not in owned:
|
||||||
|
break
|
||||||
|
chain.append(nxt)
|
||||||
|
cur = nxt
|
||||||
|
if len(chain) < 2:
|
||||||
|
return ""
|
||||||
|
return " → ".join(
|
||||||
|
f"[{n}]" if n == bid else n
|
||||||
|
for n in chain
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_roster():
|
def cmd_roster():
|
||||||
roster = load(BUDDYMON_DIR / "roster.json")
|
roster = load(BUDDYMON_DIR / "roster.json")
|
||||||
owned = roster.get("owned", {})
|
owned = roster.get("owned", {})
|
||||||
|
active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
catalog = find_catalog()
|
catalog = find_catalog()
|
||||||
except Exception:
|
except Exception:
|
||||||
catalog = {}
|
catalog = {}
|
||||||
mascot_catalog = catalog.get("language_mascots", {})
|
mascot_catalog = catalog.get("language_mascots", {})
|
||||||
|
bug_catalog = catalog.get("bug_monsters", {})
|
||||||
|
|
||||||
core_buddymon = {k: v for k, v in owned.items()
|
core_buddymon = {k: v for k, v in owned.items()
|
||||||
if v.get("type") not in ("caught_bug_monster", "caught_language_mascot")}
|
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"}
|
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"}
|
caught_bugs = {k: v for k, v in owned.items() if v.get("type") == "caught_bug_monster"}
|
||||||
|
|
||||||
print("🐾 Your Buddymon")
|
total_owned = len(core_buddymon) + len(mascots_owned) + len(caught_bugs)
|
||||||
print("─" * 44)
|
bug_total = len(bug_catalog)
|
||||||
for bid, b in core_buddymon.items():
|
mascot_total = len(mascot_catalog)
|
||||||
|
|
||||||
|
W = 52
|
||||||
|
owned_tag = f"{total_owned} owned"
|
||||||
|
pad = W - 10 - len(owned_tag)
|
||||||
|
print("╔" + "═" * W + "╗")
|
||||||
|
print(f"║ 🐾 ROSTER{' ' * pad}{owned_tag} ║")
|
||||||
|
print("╚" + "═" * W + "╝")
|
||||||
|
|
||||||
|
# ── BUDDYMON ──────────────────────────────────────────────
|
||||||
|
print()
|
||||||
|
print("── BUDDYMON ✓ levels via XP · assignable " + "─" * 8)
|
||||||
|
for bid, b in sorted(core_buddymon.items(),
|
||||||
|
key=lambda x: -x[1].get("xp", 0)):
|
||||||
lvl = b.get("level", 1)
|
lvl = b.get("level", 1)
|
||||||
total_xp = b.get("xp", 0)
|
total_xp = b.get("xp", 0)
|
||||||
max_xp = lvl * 100
|
max_xp = lvl * 100
|
||||||
xp_in_level = total_xp % max_xp if max_xp else 0
|
xp_in = total_xp % max_xp if max_xp else 0
|
||||||
bar = xp_bar(xp_in_level, max_xp)
|
bar = xp_bar(xp_in, max_xp, width=16)
|
||||||
display = b.get("display", bid)
|
display = b.get("display", bid)
|
||||||
affinity = b.get("affinity", "")
|
affinity = b.get("affinity", "")
|
||||||
evo_note = f" → {b['evolved_into']}" if b.get("evolved_into") else ""
|
aff_tag = f" [{affinity}]" if affinity else ""
|
||||||
print(f" {display} Lv.{lvl} {affinity}{evo_note}")
|
|
||||||
print(f" XP: [{bar}] {xp_in_level}/{max_xp}")
|
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
||||||
|
chain = _evo_chain_label(bid, owned, catalog)
|
||||||
|
chain_tag = f" {chain}" if chain else ""
|
||||||
|
|
||||||
|
cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {}
|
||||||
|
evos = cat_entry.get("evolutions", [])
|
||||||
|
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
||||||
|
|
||||||
if mascots_owned:
|
|
||||||
print()
|
print()
|
||||||
print("🦎 Language Mascots")
|
print(f" {display}{aff_tag}{active_tag}")
|
||||||
print("─" * 44)
|
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
||||||
for bid, b in sorted(mascots_owned.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True):
|
if chain:
|
||||||
|
print(f" chain: {chain}")
|
||||||
|
if next_evo:
|
||||||
|
gap = next_evo["level"] - lvl
|
||||||
|
print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']} ({gap} levels to go)")
|
||||||
|
elif b.get("evolved_into") and b["evolved_into"] in owned:
|
||||||
|
print(f" ✓ fully evolved")
|
||||||
|
|
||||||
|
# ── LANGUAGE MASCOTS ──────────────────────────────────────
|
||||||
|
print()
|
||||||
|
print("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "─" * 1)
|
||||||
|
if mascots_owned:
|
||||||
|
for bid, b in sorted(mascots_owned.items(),
|
||||||
|
key=lambda x: -x[1].get("xp", 0)):
|
||||||
display = b.get("display", bid)
|
display = b.get("display", bid)
|
||||||
lang = b.get("language", "")
|
lang = b.get("language", "")
|
||||||
caught_at = b.get("caught_at", "")[:10]
|
|
||||||
lvl = b.get("level", 1)
|
lvl = b.get("level", 1)
|
||||||
|
total_xp = b.get("xp", 0)
|
||||||
|
max_xp = lvl * 100
|
||||||
|
xp_in = total_xp % max_xp if max_xp else 0
|
||||||
|
bar = xp_bar(xp_in, max_xp, width=16)
|
||||||
mc = mascot_catalog.get(bid, {})
|
mc = mascot_catalog.get(bid, {})
|
||||||
assignable = mc.get("assignable", False)
|
elem = mc.get("element", "")
|
||||||
assign_note = " ✓ assignable as buddy" if assignable else ""
|
elem_tag = f" [{elem}]" if elem else ""
|
||||||
evo_chains = mc.get("evolutions", [])
|
evos = mc.get("evolutions", [])
|
||||||
evo_note = f" → evolves at Lv.{evo_chains[0]['level']}" if evo_chains else ""
|
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
||||||
print(f" {display} [{lang}] Lv.{lvl}{assign_note}{evo_note}")
|
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
||||||
print(f" caught {caught_at}")
|
|
||||||
|
|
||||||
if caught:
|
|
||||||
print()
|
print()
|
||||||
print("🏆 Caught Bug Monsters")
|
print(f" {display} [{lang}]{elem_tag}{active_tag}")
|
||||||
print("─" * 44)
|
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
||||||
for bid, b in sorted(caught.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True):
|
if next_evo:
|
||||||
display = b.get("display", bid)
|
print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}")
|
||||||
caught_at = b.get("caught_at", "")[:10]
|
else:
|
||||||
print(f" {display} — caught {caught_at}")
|
print(f" (none yet — code in Python, JS, Rust… to encounter them)")
|
||||||
|
print(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers")
|
||||||
|
|
||||||
|
# ── LANGUAGE AFFINITIES ───────────────────────────────────
|
||||||
affinities = roster.get("language_affinities", {})
|
affinities = roster.get("language_affinities", {})
|
||||||
if affinities:
|
if affinities:
|
||||||
print()
|
print()
|
||||||
print("🗺️ Language Affinities")
|
print("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "─" * 0)
|
||||||
print("─" * 44)
|
|
||||||
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
|
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
|
||||||
tier = data.get("tier", "discovering")
|
tier = data.get("tier", "discovering")
|
||||||
level = data.get("level", 0)
|
level = data.get("level", 0)
|
||||||
xp = data.get("xp", 0)
|
xp = data.get("xp", 0)
|
||||||
emoji = TIER_EMOJI.get(tier, "🔭")
|
emoji = TIER_EMOJI.get(tier, "🔭")
|
||||||
elem = LANGUAGE_ELEMENTS.get(lang, "")
|
elem = LANGUAGE_ELEMENTS.get(lang, "")
|
||||||
elem_tag = f" [{elem}]" if elem else ""
|
e_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())
|
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
|
||||||
mascot_tag = " 🦎" if has_mascot and level >= 1 else ""
|
m_tag = " 🦎" if has_mascot else ""
|
||||||
print(f" {emoji} {lang:<12} {tier:<12} (Lv.{level} · {xp} XP){elem_tag}{mascot_tag}")
|
print(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}")
|
||||||
|
|
||||||
bug_total = len(catalog.get("bug_monsters", {}))
|
# ── BUG TROPHY CASE ───────────────────────────────────────
|
||||||
mascot_total = len(mascot_catalog)
|
print()
|
||||||
missing_bugs = bug_total - len(caught)
|
caught_count = len(caught_bugs)
|
||||||
|
print(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "─" * 0)
|
||||||
|
if caught_bugs:
|
||||||
|
# Sort: notable (level>1 or has XP) first, then alpha
|
||||||
|
def bug_sort_key(item):
|
||||||
|
b = item[1]
|
||||||
|
notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0
|
||||||
|
return (not notable, item[0])
|
||||||
|
items = sorted(caught_bugs.items(), key=bug_sort_key)
|
||||||
|
# Two-column grid — 28 chars gives room for "🦅 ReviewHawk Lv.64 [systems]"
|
||||||
|
COL = 28
|
||||||
|
for i in range(0, len(items), 2):
|
||||||
|
left_id, left = items[i]
|
||||||
|
right_id, right = items[i + 1] if i + 1 < len(items) else (None, None)
|
||||||
|
|
||||||
|
def cell(bid, b):
|
||||||
|
if b is None:
|
||||||
|
return ""
|
||||||
|
disp = b.get("display", bid)
|
||||||
|
lvl = b.get("level", 1)
|
||||||
|
bc = bug_catalog.get(bid, {})
|
||||||
|
weak = bc.get("weak_against", [])
|
||||||
|
elem = f"[{weak[0]}]" if weak else ""
|
||||||
|
lvl_tag = f" Lv.{lvl}" if lvl > 1 else ""
|
||||||
|
cell_str = f"{disp}{lvl_tag} {elem}"
|
||||||
|
return cell_str[:COL].ljust(COL)
|
||||||
|
|
||||||
|
l = cell(left_id, left)
|
||||||
|
r = cell(right_id, right) if right else ""
|
||||||
|
print(f" {l} {r}".rstrip())
|
||||||
|
else:
|
||||||
|
print(" none yet")
|
||||||
|
|
||||||
|
# ── DISCOVERY FOOTER ──────────────────────────────────────
|
||||||
|
missing_bugs = bug_total - len(caught_bugs)
|
||||||
missing_mascots = mascot_total - len(mascots_owned)
|
missing_mascots = mascot_total - len(mascots_owned)
|
||||||
if missing_bugs + missing_mascots > 0:
|
if missing_bugs + missing_mascots > 0:
|
||||||
parts = []
|
parts = []
|
||||||
if missing_bugs > 0:
|
if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters")
|
||||||
parts.append(f"{missing_bugs} bug monsters")
|
if missing_mascots > 0: parts.append(f"{missing_mascots} mascots")
|
||||||
if missing_mascots > 0:
|
print(f"\n ❓ ??? — {' and '.join(parts)} still to discover")
|
||||||
parts.append(f"{missing_mascots} language mascots")
|
|
||||||
print(f"\n❓ ??? — {' and '.join(parts)} still to discover...")
|
# Signal the Stop hook to re-emit full output as additionalContext (avoids
|
||||||
|
# Bash tool result truncation). The Stop hook reads and clears this file.
|
||||||
|
pending = BUDDYMON_DIR / "roster_pending.txt"
|
||||||
|
pending.write_text("1")
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(choice: str | None = None):
|
def cmd_start(choice: str | None = None):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue