
Buddy Reroll
- 15 installs
- 5 repo stars
- Updated April 1, 2026
- kevintsai1202/buddy
Helps with ai & agent building tasks.
About
buddy-reroll is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- buddy-reroll
- AI & Agent Building
- AI-coding skill
Buddy Reroll by the numbers
- 15 all-time installs (skills.sh)
- Ranked #11,165 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kevintsai1202/buddy --skill buddy-rerollAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 5 |
| Last updated | April 1, 2026 |
| Repository | kevintsai1202/buddy ↗ |
What it does
Helps with ai & agent building tasks.
Files
Claude Code Buddy Reroll
背景原理
Claude Code 的 /buddy 寵物系統原理:
- Bones(骨架):外觀、物種、稀有度、屬性,全部由
hash(userID + SALT)確定性生成,每次啟動實時計算 - Soul(靈魂):名字與個性,由模型生成,存在
~/.claude.json的companion欄位 - SALT:
'friend-2026-401'(Claude Code 2.1.89+) - reroll 原理:暴力搜尋新的 15 字元 salt,使
hash(userID + newSalt)骰出目標寵物,然後 patch Claude Code binary
userID 來源
config.oauthAccount?.accountUuid // OAuth 登入用戶
?? config.userID // API Key 用戶(存在 ~/.claude.json)
?? 'anon'Python 腳本自動偵測 userID,OAuth 和 API Key 用戶均適用。
---
操作流程
步驟 1:確認 Python 已安裝
py --version # Windows
python3 --version # Mac / LinuxBun 無需另行安裝:若已安裝 Claude Code,Bun 通常已在系統中。若 bun --version 無法執行,再依下方說明安裝。>
Windows(PowerShell): powershell -c "irm bun.sh/install.ps1 | iex"Mac / Linux: curl -fsSL https://bun.sh/install | bash>
安裝後重新開啟終端機再繼續。
步驟 2:執行 reroll 腳本
cd <skill-path>/scripts/claude-pet-rebirth
# Windows
py reroll.py --species owl --rarity legendary
# Mac / Linux
python3 reroll.py --species owl --rarity legendary常用選項:
# 指定眼睛 + 帽子 + 閃光
py reroll.py --species dragon --rarity legendary --eye ✦ --hat wizard --shiny
# 指定最強屬性
py reroll.py --species cat --rarity epic --peak PATIENCE
# 指定名字(省略則腳本結束前詢問)
py reroll.py --species owl --rarity legendary --name Nipple步驟 3:重啟並領取
1. 關閉 Claude Code 主程式(Terminal / Desktop App),VSCode 擴充不需關閉 2. 正常重新開啟 3. 輸入 /buddy 領取新寵物
---
可用物種
| 物種 | 中文名 | Emoji |
|---|---|---|
| duck | 小鴨 | 🦆 |
| goose | 鵝鵝 | 🪿 |
| blob | 軟泥怪 | 🫧 |
| cat | 貓貓 | 🐱 |
| dragon | 龍 | 🐉 |
| octopus | 章魚 | 🐙 |
| owl | 貓頭鷹 | 🦉 |
| penguin | 企鵝 | 🐧 |
| turtle | 烏龜 | 🐢 |
| snail | 蝸牛 | 🐌 |
| ghost | 幽靈 | 👻 |
| axolotl | 六角恐龍 | 🦎 |
| capybara | 水豚 | 🦫 |
| cactus | 仙人掌 | 🌵 |
| robot | 機器人 | 🤖 |
| rabbit | 兔兔 | 🐰 |
| mushroom | 蘑菇 | 🍄 |
| chonk | 肥貓 | 🐈 |
稀有度(由低到高):common(60%) → uncommon(25%) → rare(10%) → epic(4%) → legendary(1%)
---
選項說明
| 選項 | 說明 | 預設 |
|---|---|---|
--species | 物種 | 任意 |
--rarity | 稀有度 | legendary |
--eye | 眼睛(· ✦ × ◉ @ °) | 任意 |
--hat | 帽子(none/crown/tophat/propeller/halo/wizard/beanie/tinyduck) | 任意 |
--shiny | 閃光版(約 1% 機率,大幅增加搜尋時間) | false |
--peak | 最強屬性(DEBUGGING/PATIENCE/CHAOS/WISDOM/SNARK) | 任意 |
--dump | 最弱屬性 | 任意 |
--name | 寵物名字 | 腳本結束前詢問 |
⚠️ 條件越多搜尋越慢:legendary + shiny + 指定眼睛 + 帽子 + peak 同時指定,期望嘗試次數可達數千萬次。建議優先指定物種與稀有度,其他條件選填。
---
Claude Code 輔助執行
Step 1:確認 Python
py --version 2>&1 || python3 --version 2>&1 || python --version 2>&1Step 2:詢問用戶偏好(若未在對話中指定)
一次列出所有選項讓用戶選擇:
物種(必問):
🦆 duck 🪿 goose 🫧 blob 🐱 cat
🐉 dragon 🐙 octopus 🦉 owl 🐧 penguin
🐢 turtle 🐌 snail 👻 ghost 🦎 axolotl
🦫 capybara 🌵 cactus 🤖 robot 🐰 rabbit
🍄 mushroom 🐈 chonk
(不指定則任意)稀有度(必問):
⚪ common 🟢 uncommon 🔵 rare 🟣 epic 🟡 legendary(預設)眼睛(選填):
· ✦ × ◉ @ °
(不指定則任意)帽子(選填):
none(無) crown(皇冠) tophat(大禮帽) propeller(螺旋帽)
halo(光環) wizard(巫師帽) beanie(毛帽) tinyduck(小鴨帽)
(不指定則任意)閃光(選填):
yes / no(預設 no,開啟後搜尋時間大幅增加)最強屬性(選填):
DEBUGGING PATIENCE CHAOS WISDOM SNARK
(不指定則任意)名字(必問)
Step 3:提醒關閉 Claude Code
⚠️ 執行前必須關閉 Claude Code 主程式(Terminal / Desktop App),VSCode 擴充不需關閉。否則 binary 被佔用會導致 patch 失敗。
Step 4:組合指令並執行
將偏好組合成 reroll.py 參數,帶入 --name 避免互動輸入:
cd <skill-path>/scripts/claude-pet-rebirth
py reroll.py --species <物種> --rarity legendary --name <名字>Step 5:提示重啟並執行 /buddy
---
常見問題
Q:套用後重啟還是舊寵物? A:確認 binary 已成功修補(顯示 verified=True),且 companion 欄位已更新。
Q:搜尋跑很久找不到? A:條件越多越慢。legendary + shiny + 指定眼睛帽子 peak 全部指定,機率約 1/4000萬,需數分鐘。建議減少限制條件。
Q:SALT 改了怎麼辦? A:更新 scripts/claude-pet-rebirth/patcher.py 的 ORIGINAL_SALT 常數。
Q:Windows 上 `python` 找不到? A:改用 py 指令(Windows Python Launcher)。
#!/usr/bin/env node
// buddy-reroll.js
// Buddy reroll script — supports both Node.js and Bun
// Bun.hash results match actual Claude Code; Node.js (FNV-1a) results do NOT.
//
// Usage:
// node buddy-reroll.js [options] # runs with FNV-1a (Node mode)
// bun buddy-reroll.js [options] # runs with Bun.hash (Bun mode)
//
// Options:
// --species <name> Target species (duck, cat, dragon, ...)
// --rarity <name> Minimum rarity (common, uncommon, rare, epic, legendary)
// --eye <char> Target eye style (· ✦ × ◉ @ °)
// --hat <name> Target hat (none, crown, tophat, propeller, halo, wizard, beanie, tinyduck)
// --shiny Require shiny
// --min-stats [value] Require ALL stats >= value (default: 90)
// --max <number> Max iterations (default: 50000000)
// --count <number> Number of results to find (default: 3)
// --check <uid> Check what buddy a specific userID produces
//
// Examples:
// bun buddy-reroll.js --species duck --rarity legendary --shiny
// bun buddy-reroll.js --species dragon --min-stats 80
// bun buddy-reroll.js --check f17c2742a00b2345c22fddc830959a6847ceb561fa06adb26b74b1a91ac657bc
const crypto = require('crypto')
// --- Constants (must match Claude Code source) ---
const SALT = 'friend-2026-401'
const SPECIES = ['duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl', 'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara', 'cactus', 'robot', 'rabbit', 'mushroom', 'chonk']
const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary']
const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }
const RARITY_RANK = { common: 0, uncommon: 1, rare: 2, epic: 3, legendary: 4 }
const EYES = ['·', '✦', '×', '◉', '@', '°']
const HATS = ['none', 'crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck']
const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK']
const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 }
// --- Hash functions ---
function hashFNV1a(s) {
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
function hashBun(s) {
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
}
// --- PRNG (Mulberry32 — same as Claude Code) ---
function mulberry32(seed) {
let a = seed >>> 0
return function () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function pick(rng, arr) {
return arr[Math.floor(rng() * arr.length)]
}
function rollRarity(rng) {
let roll = rng() * 100
for (const r of RARITIES) {
roll -= RARITY_WEIGHTS[r]
if (roll < 0) return r
}
return 'common'
}
function rollStats(rng, rarity) {
const floor = RARITY_FLOOR[rarity]
const peak = pick(rng, STAT_NAMES)
let dump = pick(rng, STAT_NAMES)
while (dump === peak) dump = pick(rng, STAT_NAMES)
const stats = {}
for (const name of STAT_NAMES) {
if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
else stats[name] = floor + Math.floor(rng() * 40)
}
return stats
}
function createRoller(hashFn) {
return function rollFull(uid) {
const rng = mulberry32(hashFn(uid + SALT))
const rarity = rollRarity(rng)
const species = pick(rng, SPECIES)
const eye = pick(rng, EYES)
const hat = rarity === 'common' ? 'none' : pick(rng, HATS)
const shiny = rng() < 0.01
const stats = rollStats(rng, rarity)
return { rarity, species, eye, hat, shiny, stats }
}
}
// --- Parse CLI args ---
function parseArgs() {
const args = process.argv.slice(2)
const opts = { max: 50_000_000, count: 3 }
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--species': opts.species = args[++i]; break
case '--rarity': opts.rarity = args[++i]; break
case '--eye': opts.eye = args[++i]; break
case '--hat': opts.hat = args[++i]; break
case '--shiny': opts.shiny = true; break
case '--min-stats': {
const next = args[i + 1]
opts.minStatsVal = (next && !next.startsWith('--')) ? parseInt(args[++i]) : 90
break
}
case '--max': opts.max = parseInt(args[++i]); break
case '--count': opts.count = parseInt(args[++i]); break
case '--check': opts.check = args[++i]; break
case '--help': case '-h':
console.log(`Usage: node/bun buddy-reroll.js [options]
Options:
--species <name> ${SPECIES.join(', ')}
--rarity <name> ${RARITIES.join(', ')}
--eye <char> ${EYES.join(' ')}
--hat <name> ${HATS.join(', ')}
--shiny Require shiny
--min-stats [value] Require ALL stats >= value (default: 90)
--max <number> Max iterations (default: 50000000)
--count <number> Results to find (default: 3)
--check <uid> Check what buddy a specific userID produces`)
process.exit(0)
}
}
// Validate
if (opts.species && !SPECIES.includes(opts.species)) {
console.error(`Unknown species: ${opts.species}\nAvailable: ${SPECIES.join(', ')}`)
process.exit(1)
}
if (opts.rarity && !RARITIES.includes(opts.rarity)) {
console.error(`Unknown rarity: ${opts.rarity}\nAvailable: ${RARITIES.join(', ')}`)
process.exit(1)
}
if (opts.eye && !EYES.includes(opts.eye)) {
console.error(`Unknown eye: ${opts.eye}\nAvailable: ${EYES.join(' ')}`)
process.exit(1)
}
if (opts.hat && !HATS.includes(opts.hat)) {
console.error(`Unknown hat: ${opts.hat}\nAvailable: ${HATS.join(', ')}`)
process.exit(1)
}
return opts
}
// --- Main ---
const opts = parseArgs()
const isBun = typeof Bun !== 'undefined'
const hashFn = isBun ? hashBun : hashFNV1a
const rollFull = createRoller(hashFn)
const runtimeLabel = isBun ? 'bun (Bun.hash)' : 'node (FNV-1a)'
const RARITY_STARS = { common: '★', uncommon: '★★', rare: '★★★', epic: '★★★★', legendary: '★★★★★' }
// --- Check mode ---
if (opts.check) {
console.log(`Runtime: ${runtimeLabel}`)
console.log(`Checking userID: ${opts.check}\n`)
const r = rollFull(opts.check)
console.log(` Species : ${r.species}`)
console.log(` Rarity : ${r.rarity} ${RARITY_STARS[r.rarity]}`)
console.log(` Eye : ${r.eye}`)
console.log(` Hat : ${r.hat}`)
console.log(` Shiny : ${r.shiny}`)
console.log(` Stats :`)
for (const name of STAT_NAMES) {
const val = r.stats[name]
const bar = '█'.repeat(Math.floor(val / 5)) + '░'.repeat(20 - Math.floor(val / 5))
console.log(` ${name.padEnd(10)} ${bar} ${val}`)
}
process.exit(0)
}
const filters = []
if (opts.species) filters.push(`species=${opts.species}`)
if (opts.rarity) filters.push(`rarity>=${opts.rarity}`)
if (opts.eye) filters.push(`eye=${opts.eye}`)
if (opts.hat) filters.push(`hat=${opts.hat}`)
if (opts.shiny) filters.push('shiny=true')
if (opts.minStatsVal) filters.push(`all stats>=${opts.minStatsVal}`)
console.log(`Runtime: ${runtimeLabel}${isBun ? '' : ' (results will NOT match Claude Code)'}`)
console.log(`Searching: ${filters.join(', ') || 'any'} (max ${opts.max.toLocaleString()}, find ${opts.count})`)
console.log('')
const minRarityRank = opts.rarity ? RARITY_RANK[opts.rarity] : 0
let found = 0
const startTime = Date.now()
for (let i = 0; i < opts.max; i++) {
const uid = crypto.randomBytes(32).toString('hex')
const r = rollFull(uid)
if (opts.rarity && RARITY_RANK[r.rarity] < minRarityRank) continue
if (opts.species && r.species !== opts.species) continue
if (opts.eye && r.eye !== opts.eye) continue
if (opts.hat && r.hat !== opts.hat) continue
if (opts.shiny && !r.shiny) continue
if (opts.minStatsVal && !Object.values(r.stats).every(v => v >= opts.minStatsVal)) continue
found++
const statsStr = STAT_NAMES.map(n => `${n}:${r.stats[n]}`).join(' ')
console.log(`#${found} [${r.rarity}] ${r.species} eye=${r.eye} hat=${r.hat} shiny=${r.shiny}`)
console.log(` stats: ${statsStr}`)
console.log(` uid: ${uid}`)
console.log('')
if (found >= opts.count) break
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
if (found === 0) {
console.log(`No match found in ${opts.max.toLocaleString()} iterations (${elapsed}s)`)
} else {
console.log(`Found ${found} match(es) in ${elapsed}s`)
}import multiprocessing
import sys
import time
import lang
from lang import t, species_name
from ui import color, rarity_color, stat_bar, pad_right
from constants import RARITY_STARS, STAT_NAMES, DEFAULT_PERSONALITIES
def apply_pet(pet, desired_override=None):
"""Apply a favorite pet to Claude Code by finding a salt and patching the binary."""
from patcher import (
get_user_id, find_claude_binary, get_current_salt,
find_salt, patch_binary, save_anybuddy_config, update_companion,
)
from pick import pick_from_list
print(color(f"\n === {t('apply_title')} ===\n", "bold"))
# Step 1: Get userId
try:
user_id = get_user_id()
print(color(f" User ID: {user_id[:12]}...", "dim"))
except Exception as e:
print(color(f" {t('error')}: {e}", "red"))
return False
# Step 2: Find binary
try:
binary_path = find_claude_binary()
print(color(f" Binary: {binary_path}", "dim"))
except Exception as e:
print(color(f" {t('error')}: {e}", "red"))
return False
# Step 3: Get current salt
try:
old_salt = get_current_salt(binary_path)
print(color(f" Current salt: {old_salt}", "dim"))
except Exception as e:
print(color(f" {t('error')}: {e}", "red"))
return False
# Step 4: Show target
if desired_override:
desired = desired_override
else:
desired = {
"species": pet["species"],
"rarity": pet["rarity"],
"eye": pet["eye"],
"hat": pet["hat"],
"shiny": pet.get("shiny", False),
}
stars = RARITY_STARS[pet["rarity"]]
sp = species_name(pet["species"])
print()
print(rarity_color(f" {t('apply_target')}: {sp} {stars} {t('eyes')}:{pet['eye']} {t('hat')}:{pet['hat']}", pet["rarity"]))
print()
# Step 5: Brute-force search
cpu_count = multiprocessing.cpu_count()
num_workers = max(1, cpu_count)
if lang.current_lang == "zh":
print(color(f" 偵測到 {cpu_count} 核心 → 使用 {num_workers} 工作進程", "dim"))
print(color(f" {t('apply_searching')}", "cyan"))
else:
print(color(f" Detected {cpu_count} CPU cores → using {num_workers} workers", "dim"))
print(color(f" {t('apply_searching')}", "cyan"))
def on_progress(info):
rate = info["rate"]
rate_str = f"{rate/1000:.0f}k/s" if rate < 1e6 else f"{rate/1e6:.1f}M/s"
attempts = info["attempts"]
elapsed = info["elapsed"]
sys.stdout.write(
f"\r {color(f'{attempts:,} tried', 'cyan')} "
f"{color(rate_str, 'dim')} "
f"{color(f'{elapsed:.1f}s', 'dim')} "
f"{color(f'[{num_workers}w]', 'dim')} "
)
sys.stdout.flush()
# Step 5b: Search loop — keep re-searching until user accepts the stats
while True:
result = find_salt(user_id, desired, on_progress=on_progress, num_workers=num_workers)
sys.stdout.write("\r" + " " * 60 + "\r")
print(color(f" {t('apply_found')} {result['attempts']:,} {t('apply_attempts')} ({result['elapsed']:.1f}s)", "green"))
print()
# Show the actual stats this salt will produce in /buddy
actual_stats = result.get("stats", {})
if actual_stats:
if lang.current_lang == "zh":
print(color(" /buddy 實際會顯示的屬性:", "bold"))
else:
print(color(" Actual stats that will appear in /buddy:", "bold"))
best_stat = max(actual_stats, key=lambda k: actual_stats[k])
worst_stat = min(actual_stats, key=lambda k: actual_stats[k])
peak_label = " (最強)" if lang.current_lang == "zh" else " (peak)"
dump_label = " (最弱)" if lang.current_lang == "zh" else " (dump)"
for name in STAT_NAMES:
value = actual_stats[name]
bar = stat_bar(value)
display_name = pad_right(t(name), 10)
suffix = peak_label if name == best_stat else (dump_label if name == worst_stat else "")
label = f" {display_name} {bar} {value:>3}{suffix}"
if name == best_stat:
print(color(label, "green"))
elif name == worst_stat:
print(color(label, "red"))
else:
print(label)
print()
if lang.current_lang == "zh":
prompt = " [y]=套用 [Enter]=重搜 [p]=指定最強/弱屬性後重搜: "
else:
prompt = " [y]=Apply [Enter]=Re-search [p]=Set peak/dump then re-search: "
choice = input(color(prompt, "yellow")).strip().lower()
if choice == "y":
break
elif choice == "p":
# Let user pick peak/dump constraints then re-search
from constants import STAT_NAMES as _STAT_NAMES
stat_opts = ["skip"] + list(_STAT_NAMES)
def _stat_label(s):
return color("skip", "dim") if s == "skip" else t(s)
peak_pick = pick_from_list(
"最強屬性 (可跳過)" if lang.current_lang == "zh" else "Best stat (optional)",
stat_opts, _stat_label)
new_peak = None if (peak_pick == "skip" or peak_pick is None) else peak_pick
new_dump = None
if new_peak:
dump_opts = ["skip"] + [s for s in _STAT_NAMES if s != new_peak]
dump_pick = pick_from_list(
"最弱屬性 (可跳過)" if lang.current_lang == "zh" else "Worst stat (optional)",
dump_opts, _stat_label)
new_dump = None if (dump_pick == "skip" or dump_pick is None) else dump_pick
desired = {k: v for k, v in desired.items() if k not in ("peak", "dump")}
if new_peak:
desired["peak"] = new_peak
if new_dump:
desired["dump"] = new_dump
if lang.current_lang == "zh":
print(color(" 重新搜尋中...\n", "dim"))
else:
print(color(" Re-searching...\n", "dim"))
else:
# plain re-search, keep existing peak/dump
if lang.current_lang == "zh":
print(color(" 重新搜尋中...\n", "dim"))
else:
print(color(" Re-searching...\n", "dim"))
# Step 6: Patch binary
try:
patch_result = patch_binary(binary_path, old_salt, result["salt"])
print(color(f" {t('apply_patched')} {patch_result['replacements']} {t('apply_replacements')}: {patch_result['verified']}", "green"))
print(color(f" {t('apply_backup')}: {patch_result['backup_path']}", "dim"))
except Exception as e:
print(color(f" {t('apply_patch_failed')}: {e}", "red"))
return False
# Step 7: Save config
save_anybuddy_config({
"salt": result["salt"],
"previousSalt": old_salt,
"species": desired["species"],
"rarity": desired["rarity"],
"eye": desired["eye"],
"hat": desired["hat"],
"appliedTo": binary_path,
"appliedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
})
# Step 8: Set companion name and personality directly
species = pet["species"]
default_personality = DEFAULT_PERSONALITIES.get(species, "A loyal coding companion.")
print()
prompt_text = t("apply_name").format(species=species_name(species))
new_name = input(color(f" {prompt_text}: ", "cyan")).strip()
if not new_name:
new_name = species.capitalize()
try:
update_companion(name=new_name, personality=default_personality)
print(color(f" {t('apply_set')}: {new_name}", "green"))
print(color(f" {t('apply_personality')}: \"{default_personality[:60]}...\"", "dim"))
except Exception as e:
print(color(f" {t('error')}: {e}", "red"))
print(color(f"\n {t('apply_done')}\n", "bold"))
return True
RARITIES = ["common", "uncommon", "rare", "epic", "legendary"]
RARITY_WEIGHTS = {
"common": 60,
"uncommon": 25,
"rare": 10,
"epic": 4,
"legendary": 1,
}
RARITY_STARS = {
"common": "★",
"uncommon": "★★",
"rare": "★★★",
"epic": "★★★★",
"legendary": "★★★★★",
}
SPECIES = [
"duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
"turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot",
"rabbit", "mushroom", "chonk",
]
EYES = ["·", "✦", "×", "◉", "@", "°"]
HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"]
STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"]
RARITY_FLOOR = {
"common": 5,
"uncommon": 15,
"rare": 25,
"epic": 35,
"legendary": 50,
}
RARITY_COLORS = {
"common": "white",
"uncommon": "green",
"rare": "purple",
"epic": "magenta",
"legendary": "yellow",
}
DEFAULT_PERSONALITIES = {
"duck": "A cheerful quacker who celebrates your wins with enthusiastic honks and judges your variable names with quiet side-eye.",
"goose": "An agent of chaos who thrives on your merge conflicts and honks menacingly whenever you write a TODO comment.",
"blob": "A formless, chill companion who absorbs your stress and responds to everything with gentle, unhurried wisdom.",
"cat": "An aloof code reviewer who pretends not to care about your bugs but quietly bats at syntax errors when you're not looking.",
"dragon": "A fierce guardian of clean code who breathes fire at spaghetti logic and hoards well-written functions.",
"octopus": "A multitasking genius who juggles eight concerns at once and offers tentacle-loads of unsolicited architectural advice.",
"owl": "A nocturnal sage who comes alive during late-night debugging sessions and asks annoyingly insightful questions.",
"penguin": "A tuxedo-wearing professional who waddles through your codebase with dignified concern and dry wit.",
"turtle": "A patient mentor who reminds you that slow, steady refactoring beats heroic rewrites every time.",
"snail": "A zen minimalist who moves at their own pace and leaves a trail of thoughtful, unhurried observations.",
"ghost": "A spectral presence who haunts your dead code and whispers about the bugs you thought you fixed.",
"axolotl": "A regenerative optimist who believes every broken build can be healed and every test can be unflaked.",
"capybara": "The most relaxed companion possible -- nothing fazes them, not even production outages at 3am.",
"cactus": "A prickly but lovable desert dweller who thrives on neglect and offers sharp, pointed feedback.",
"robot": "A logical companion who speaks in precise technical observations and occasionally glitches endearingly.",
"rabbit": "A fast-moving, hyperactive buddy who speed-reads your diffs and bounces between topics at alarming pace.",
"mushroom": "A wry fungal sage who speaks in meandering tangents about your bugs while secretly enjoying the chaos.",
"chonk": "An absolute unit of a companion who sits on your terminal with maximum gravitational presence and minimal urgency.",
}
import random
import math
from constants import (
RARITIES, RARITY_WEIGHTS, RARITY_FLOOR, SPECIES, EYES, HATS, STAT_NAMES,
)
def mulberry32(seed):
"""Mulberry32 seeded PRNG - matches the original companion.ts logic."""
a = seed & 0xFFFFFFFF
def next_val():
nonlocal a
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
t = a
t = ((t ^ (t >> 15)) * (1 | t)) & 0xFFFFFFFF
imul = ((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF
t = (((t + imul) & 0xFFFFFFFF) ^ t) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296
return next_val
def pick(rng, arr):
return arr[int(rng() * len(arr))]
def roll_rarity(rng):
total = sum(RARITY_WEIGHTS.values())
roll = rng() * total
for rarity in RARITIES:
roll -= RARITY_WEIGHTS[rarity]
if roll < 0:
return rarity
return "common"
def roll_stats(rng, rarity):
floor = RARITY_FLOOR[rarity]
peak = pick(rng, STAT_NAMES)
dump = pick(rng, STAT_NAMES)
while dump == peak:
dump = pick(rng, STAT_NAMES)
stats = {}
for name in STAT_NAMES:
if name == peak:
stats[name] = min(100, floor + 50 + int(rng() * 30))
elif name == dump:
stats[name] = max(1, floor - 10 + int(rng() * 15))
else:
stats[name] = floor + int(rng() * 40)
return stats
def roll_pet(seed=None):
"""Roll a random pet. If seed is None, generate a random seed."""
if seed is None:
seed = random.randint(0, 2**32 - 1)
rng = mulberry32(seed)
rarity = roll_rarity(rng)
pet = {
"seed": seed,
"rarity": rarity,
"species": pick(rng, SPECIES),
"eye": pick(rng, EYES),
"hat": "none" if rarity == "common" else pick(rng, HATS),
"shiny": rng() < 0.01,
"stats": roll_stats(rng, rarity),
}
return pet
# ─── i18n ───
LANG = {
"en": {
"banner_title": "ClaudePetRebirth",
"banner_sub": "Roll your Claude Code companion pet!",
"roll": "Roll",
"rarity": "Rarity",
"eyes": "Eyes",
"hat": "Hat",
"stats": "Stats",
"commands": "Commands",
"cmd_reroll": "Reroll a new random pet",
"cmd_keep": "Keep this pet (add to favorites)",
"cmd_favs": "View favorites",
"cmd_remove": "Remove last favorite",
"cmd_save": "Save favorites to file",
"cmd_anim": "Toggle animation preview",
"cmd_help": "Show this help",
"cmd_lang": "Switch language",
"cmd_quit": "Quit",
"no_favs": "No favorites yet! Press [k] to keep a pet.",
"favs_title": "Favorites",
"fav_apply": "Apply that pet to Claude Code",
"fav_remove": "Remove last favorite",
"fav_back": "Go back",
"fav_invalid": "Invalid number. Choose",
"kept": "Kept",
"favs_total": "favorites total",
"removed": "Removed",
"from_favs": "from favorites.",
"no_favs_remove": "No favorites to remove.",
"saved": "Saved",
"favs_to": "favorites to",
"no_favs_save": "No favorites to save.",
"anim": "Animation",
"press_enter": "Press Enter to continue...",
"apply_title": "Applying pet to Claude Code",
"apply_target": "Target",
"apply_searching": "Searching for matching salt...",
"apply_found": "Found in",
"apply_attempts": "attempts",
"apply_patched": "Patched!",
"apply_replacements": "replacements, verified",
"apply_backup": "Backup",
"apply_patch_failed": "Patch failed",
"apply_confirm": "Apply this pet to Claude Code? [y/N]",
"apply_name": "Give your {species} a name",
"apply_set": "Companion set",
"apply_personality": "Personality",
"apply_done": "Done! Restart Claude Code to see your new pet.",
"apply_cancelled": "Cancelled.",
"bye": "Bye!",
"error": "Error",
"cmd_pick": "Custom pick mode (choose species/rarity/eyes/hat)",
"pick_title": "Custom Pick Mode",
"pick_species": "Choose species",
"pick_rarity": "Choose rarity",
"pick_eyes": "Choose eyes",
"pick_hat": "Choose hat",
"pick_shiny": "Shiny? [y/N]",
"pick_preview": "Your custom pet",
"pick_confirm": "Apply this pet to Claude Code? [y/N]",
"pick_no_hat": "Common rarity = no hat",
# Stats
"DEBUGGING": "DEBUGGING",
"PATIENCE": "PATIENCE",
"CHAOS": "CHAOS",
"WISDOM": "WISDOM",
"SNARK": "SNARK",
# Rarity
"common": "Common",
"uncommon": "Uncommon",
"rare": "Rare",
"epic": "Epic",
"legendary": "Legendary",
},
"zh": {
"banner_title": "ClaudePetRebirth",
"banner_sub": "重新抽取你的 Claude Code 寵物!",
"roll": "第",
"rarity": "稀有度",
"eyes": "眼睛",
"hat": "帽子",
"stats": "屬性",
"commands": "操作說明",
"cmd_reroll": "重新抽取",
"cmd_keep": "收藏此寵物",
"cmd_favs": "查看收藏",
"cmd_remove": "移除最後一個收藏",
"cmd_save": "儲存收藏到檔案",
"cmd_anim": "開關動畫預覽",
"cmd_help": "顯示說明",
"cmd_lang": "切換語言",
"cmd_quit": "離開",
"no_favs": "還沒有收藏! 按 [k] 收藏寵物。",
"favs_title": "收藏列表",
"fav_apply": "套用該寵物到 Claude Code",
"fav_remove": "移除最後一個收藏",
"fav_back": "返回",
"fav_invalid": "無效編號,請輸入",
"kept": "已收藏",
"favs_total": "個收藏",
"removed": "已移除",
"from_favs": "",
"no_favs_remove": "沒有收藏可以移除。",
"saved": "已儲存",
"favs_to": "個收藏到",
"no_favs_save": "沒有收藏可以儲存。",
"anim": "動畫",
"press_enter": "按 Enter 繼續...",
"apply_title": "正在套用寵物到 Claude Code",
"apply_target": "目標",
"apply_searching": "搜尋匹配的 salt 中...",
"apply_found": "找到了! 共",
"apply_attempts": "次嘗試",
"apply_patched": "修補完成!",
"apply_replacements": "處替換, 驗證",
"apply_backup": "備份",
"apply_patch_failed": "修補失敗",
"apply_confirm": "套用此寵物到 Claude Code? [y/N]",
"apply_name": "幫你的{species}取個名字",
"apply_set": "夥伴已設定",
"apply_personality": "個性",
"apply_done": "完成! 重啟 Claude Code 即可看到新寵物。",
"apply_cancelled": "已取消。",
"bye": "掰掰!",
"error": "錯誤",
"cmd_pick": "自選模式 (選擇物種/稀有度/眼睛/帽子)",
"pick_title": "自選模式",
"pick_species": "選擇物種",
"pick_rarity": "選擇稀有度",
"pick_eyes": "選擇眼睛",
"pick_hat": "選擇帽子",
"pick_shiny": "閃光? [y/N]",
"pick_preview": "你的寵物",
"pick_confirm": "套用此寵物到 Claude Code? [y/N]",
"pick_no_hat": "普通稀有度 = 沒有帽子",
# Stats
"DEBUGGING": "除錯力",
"PATIENCE": "耐心",
"CHAOS": "混沌",
"WISDOM": "智慧",
"SNARK": "嘴砲",
# Rarity
"common": "普通",
"uncommon": "非凡",
"rare": "稀有",
"epic": "史詩",
"legendary": "傳說",
},
}
SPECIES_ZH = {
"duck": "鴨子", "goose": "鵝", "blob": "果凍", "cat": "貓",
"dragon": "龍", "octopus": "章魚", "owl": "貓頭鷹", "penguin": "企鵝",
"turtle": "烏龜", "snail": "蝸牛", "ghost": "幽靈", "axolotl": "六角恐龍",
"capybara": "水豚", "cactus": "仙人掌", "robot": "機器人", "rabbit": "兔子",
"mushroom": "蘑菇", "chonk": "胖胖",
}
current_lang = "en"
def set_lang(lang_code):
global current_lang
current_lang = lang_code
def t(key):
return LANG[current_lang].get(key, key)
def species_name(species):
if current_lang == "zh":
return f"{species} ({SPECIES_ZH.get(species, species)})"
return species
#!/usr/bin/env python3
"""
ClaudePetRebirth - Reroll your Claude Code companion pet!
Roll a random pet and keep rerolling until you find the one you love.
"""
import os
import sys
import time
# Ensure UTF-8 output on Windows
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
os.system("") # Enable ANSI escape codes on Windows
import lang
from lang import t, species_name, set_lang
from ui import (
color, clear_screen, display_banner, display_help,
display_pet, display_favorites, animate_pet,
)
from save import auto_save, load_save
from apply import apply_pet
from pick import custom_pick
from generation import roll_pet
def main():
clear_screen()
display_banner()
display_help()
favorites, roll_count = load_save()
show_animation = False
current_pet = None
while True:
# Roll a new pet
if current_pet is None:
current_pet = roll_pet()
roll_count += 1
auto_save(favorites, roll_count)
clear_screen()
display_banner()
if lang.current_lang == "zh":
print(color(f" {t('roll')} {roll_count} 抽", "dim"))
else:
print(color(f" {t('roll')} #{roll_count}", "dim"))
print()
display_pet(current_pet, show_stats=False)
if show_animation:
animate_pet(current_pet, duration=2.0)
# Prompt
fav_count = f" | {len(favorites)} {t('favs_title')}" if favorites else ""
if lang.current_lang == "zh":
prompt = color(f" [Enter]=重抽 [k]=收藏 [f]=收藏列表 [p]=自選 [l]=語言 [h]=說明 [q]=離開{fav_count}", "dim")
else:
prompt = color(f" [Enter]=Reroll [k]=Keep [f]=Favs [p]=Pick [l]=Lang [h]=Help [q]=Quit{fav_count}", "dim")
print(prompt)
try:
choice = input(color(" > ", "cyan")).strip().lower()
except (KeyboardInterrupt, EOFError):
print(color(f"\n\n {t('bye')}\n", "dim"))
break
if choice == "q":
print(color(f"\n {t('bye')}\n", "dim"))
break
elif choice == "k":
favorites.append(current_pet)
auto_save(favorites, roll_count)
sp = species_name(current_pet["species"])
print(color(f"\n {t('kept')} {sp}! ({len(favorites)} {t('favs_total')})", "green"))
time.sleep(0.8)
current_pet = None
elif choice == "f":
clear_screen()
display_banner()
has_favs = display_favorites(favorites)
if has_favs:
fav_choice = input(color(" > ", "cyan")).strip().lower()
if fav_choice == "d" and favorites:
removed = favorites.pop()
auto_save(favorites, roll_count)
sp = species_name(removed["species"])
print(color(f"\n {t('removed')} {sp} {t('from_favs')}", "yellow"))
time.sleep(0.8)
elif fav_choice.isdigit():
idx = int(fav_choice) - 1
if 0 <= idx < len(favorites):
clear_screen()
display_banner()
display_pet(favorites[idx], show_index=idx + 1, total=len(favorites), show_stats=False)
apply_choice = input(color(f" {t('apply_confirm')} ", "yellow")).strip().lower()
if apply_choice == "y":
apply_pet(favorites[idx])
input(color(f" {t('press_enter')}", "dim"))
else:
print(color(f" {t('fav_invalid')} 1-{len(favorites)}.", "red"))
time.sleep(0.8)
else:
input(color(f" {t('press_enter')}", "dim"))
elif choice == "d":
if favorites:
removed = favorites.pop()
auto_save(favorites, roll_count)
sp = species_name(removed["species"])
print(color(f"\n {t('removed')} {sp} {t('from_favs')}", "yellow"))
time.sleep(0.8)
else:
print(color(f"\n {t('no_favs_remove')}", "dim"))
time.sleep(0.8)
elif choice == "a":
show_animation = not show_animation
status = "ON" if show_animation else "OFF"
print(color(f"\n {t('anim')}: {status}", "cyan"))
time.sleep(0.5)
elif choice == "p":
custom_pick(favorites, roll_count)
elif choice == "l":
set_lang("zh" if lang.current_lang == "en" else "en")
elif choice == "h":
clear_screen()
display_banner()
display_help()
input(color(f" {t('press_enter')}", "dim"))
elif choice == "":
current_pet = None
else:
current_pet = None
if __name__ == "__main__":
main()
"""
Patcher module - finds salt via brute-force and patches Claude Code binary.
Flow:
1. Read userId from ~/.claude.json
2. Use Bun's wyhash to match Claude Code's internal hashing
3. Brute-force search for a salt that produces the desired pet
4. Patch the binary (replace old salt with new one)
"""
import json
import os
import sys
import random
import string
import shutil
import struct
import subprocess
import threading
import time
from pathlib import Path
from constants import (
RARITIES, RARITY_WEIGHTS, RARITY_FLOOR, SPECIES, EYES, HATS, STAT_NAMES,
)
# ─── Config paths ───
def get_claude_json_path():
return Path.home() / ".claude.json"
def get_anybuddy_config_path():
return Path.home() / ".claude-code-any-buddy.json"
def get_user_id():
"""Read userId from ~/.claude.json, matching any-buddy's priority:
oauthAccount.accountUuid > userID > userId
"""
path = get_claude_json_path()
if not path.exists():
raise FileNotFoundError(f"Cannot find {path}")
data = json.loads(path.read_text(encoding="utf-8"))
# Claude Code uses oauthAccount.accountUuid when present (same priority as any-buddy)
oauth = data.get("oauthAccount")
if isinstance(oauth, dict) and oauth.get("accountUuid"):
return oauth["accountUuid"]
user_id = data.get("userID") or data.get("userId")
if not user_id:
raise ValueError("No userId found in ~/.claude.json")
return user_id
def load_anybuddy_config():
path = get_anybuddy_config_path()
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def save_anybuddy_config(config):
path = get_anybuddy_config_path()
path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
# ─── Binary detection ───
ORIGINAL_SALT = "friend-2026-401"
def find_claude_binary():
"""Find the Claude Code binary."""
# Check CLAUDE_BINARY env var override
env_path = os.environ.get("CLAUDE_BINARY")
if env_path:
real = os.path.realpath(env_path)
if os.path.isfile(real):
return real
raise FileNotFoundError(f"CLAUDE_BINARY={env_path!r} does not exist")
home = Path.home()
# Windows-specific detection
if sys.platform == "win32":
app_data = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = []
# npm global install — the actual JS bundle to patch
npm_cli = app_data / "npm" / "node_modules" / "@anthropic-ai" / "claude-code" / "cli.mjs"
candidates.append(npm_cli)
# Resolve via `where claude` → might return the .cmd shim
try:
result = subprocess.run(["where", "claude"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
# `where` may return multiple lines; try each
for line in result.stdout.strip().splitlines():
line = line.strip()
if not line:
continue
if line.endswith(".cmd"):
# Parse the .cmd shim to find the actual cli.mjs
try:
content = Path(line).read_text(encoding="utf-8", errors="replace")
import re
m = re.search(r'node_modules[\\/]@anthropic-ai[\\/]claude-code[\\/][^\s"]+', content)
if m:
target = Path(line).parent / m.group(0).replace("\\", os.sep)
if target.is_file():
candidates.insert(0, target)
except Exception:
pass
else:
real = os.path.realpath(line)
if os.path.isfile(real):
candidates.insert(0, Path(real))
except Exception:
pass
# Desktop app (Electron)
candidates.append(local_app_data / "Programs" / "claude" / "claude.exe")
for c in candidates:
if Path(c).exists() and Path(c).is_file():
return str(os.path.realpath(c))
raise FileNotFoundError(
"Cannot find Claude Code binary.\n"
"Set CLAUDE_BINARY to the path of cli.mjs (e.g. "
r'%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\cli.mjs'
")."
)
# Unix: try 'which claude' first
try:
result = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
path = result.stdout.strip()
real = os.path.realpath(path)
if os.path.isfile(real):
return real
except Exception:
pass
# Known Unix paths
candidates = [
home / ".local" / "bin" / "claude",
]
# Check versioned paths
versions_dir = home / ".local" / "share" / "claude" / "versions"
if versions_dir.exists():
for d in sorted(versions_dir.iterdir(), reverse=True):
if d.is_dir():
candidates.append(d / "claude")
elif d.is_file() and "anybuddy-bak" not in d.name:
candidates.append(d)
for c in candidates:
if c.exists() and c.is_file():
return str(os.path.realpath(c))
raise FileNotFoundError("Cannot find Claude Code binary. Set CLAUDE_BINARY env var.")
def get_current_salt(binary_path):
"""Determine what salt is currently in the binary."""
data = open(binary_path, "rb").read()
# Check original salt
if data.count(ORIGINAL_SALT.encode()) >= 3:
return ORIGINAL_SALT
# Check any-buddy config for current salt
config = load_anybuddy_config()
if config and config.get("salt"):
salt = config["salt"]
if data.count(salt.encode()) >= 3:
return salt
raise ValueError("Cannot determine current salt in binary.")
# ─── Persistent bun hash process ───
# One long-lived bun subprocess per Python process, reused across all batches.
# Eliminates the ~100ms per-batch startup cost.
_BUN_HASH_SCRIPT = r"""
process.stdin.setEncoding('utf8');
let buf = '';
process.stdin.on('data', (chunk) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.length > 0) {
process.stdout.write(
String(Number(BigInt(Bun.hash(line)) & 0xffffffffn)) + '\n'
);
}
}
});
"""
_bun_proc = None
_bun_lock = threading.Lock()
def _get_bun_proc():
"""Return the module-level persistent bun process, starting it if needed."""
global _bun_proc
with _bun_lock:
if _bun_proc is None or _bun_proc.poll() is not None:
_bun_proc = subprocess.Popen(
["bun", "-e", _BUN_HASH_SCRIPT],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
return _bun_proc
def bun_hash(s):
"""Hash a single string via the persistent bun process."""
return bun_hash_batch([s])[0]
# ─── Mulberry32 PRNG (matches Claude Code) ───
def mulberry32(seed):
a = seed & 0xFFFFFFFF
def next_val():
nonlocal a
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
t = a
t = ((t ^ (t >> 15)) * (1 | t)) & 0xFFFFFFFF
imul = ((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF
t = (((t + imul) & 0xFFFFFFFF) ^ t) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296
return next_val
def pick(rng, arr):
return arr[int(rng() * len(arr))]
def roll_rarity(rng):
total = sum(RARITY_WEIGHTS.values())
roll = rng() * total
for rarity in RARITIES:
roll -= RARITY_WEIGHTS[rarity]
if roll < 0:
return rarity
return "common"
def roll_from_salt(user_id, salt):
"""Roll a pet using userId + salt, matching Claude Code's logic exactly."""
key = user_id + salt
h = bun_hash(key)
rng = mulberry32(h)
rarity = roll_rarity(rng)
species = pick(rng, SPECIES)
eye = pick(rng, EYES)
hat = "none" if rarity == "common" else pick(rng, HATS)
shiny = rng() < 0.01
return {
"rarity": rarity,
"species": species,
"eye": eye,
"hat": hat,
"shiny": shiny,
}
# ─── Batch hash for speed ───
def bun_hash_batch(strings):
"""Hash multiple strings using the persistent bun process.
Writing to stdin and reading from stdout run concurrently via a thread
to prevent pipe-buffer deadlock when batches are large.
"""
global _bun_proc
try:
proc = _get_bun_proc()
input_bytes = "\n".join(strings).encode("utf-8") + b"\n"
write_exc = []
def _write():
try:
proc.stdin.write(input_bytes)
proc.stdin.flush()
except Exception as e:
write_exc.append(e)
writer = threading.Thread(target=_write, daemon=True)
writer.start()
hashes = []
for _ in strings:
line = proc.stdout.readline()
if not line:
raise RuntimeError("bun process exited unexpectedly")
hashes.append(int(line.strip()))
writer.join()
if write_exc:
raise write_exc[0]
return hashes
except Exception as e:
with _bun_lock:
_bun_proc = None # force restart on next call
raise RuntimeError(f"Batch hash failed: {e}")
# ─── Salt brute-force search ───
def generate_salt(length=15):
"""Generate a random salt string of exact length (must match original salt length)."""
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
def roll_stats_from_rng(rng, rarity):
"""Roll stats matching Claude Code's logic. Returns (stats_dict, peak_name, dump_name)."""
floor = RARITY_FLOOR[rarity]
peak = pick(rng, STAT_NAMES)
dump = pick(rng, STAT_NAMES)
while dump == peak:
dump = pick(rng, STAT_NAMES)
stats = {}
for name in STAT_NAMES:
if name == peak:
stats[name] = min(100, floor + 50 + int(rng() * 30))
elif name == dump:
stats[name] = max(1, floor - 10 + int(rng() * 15))
else:
stats[name] = floor + int(rng() * 40)
return stats, peak, dump
def estimate_attempts(desired):
"""Estimate how many attempts needed to find a matching salt."""
rarity_chance = RARITY_WEIGHTS[desired["rarity"]] / 100
species_chance = 1 / len(SPECIES)
eye_chance = 1 / len(EYES)
hat_chance = 1.0
if desired["rarity"] != "common" and desired.get("hat", "none") != "none":
hat_chance = 1 / (len(HATS) - 1) # exclude 'none'
shiny_chance = 0.01 if desired.get("shiny") else 1.0
peak_chance = 1 / len(STAT_NAMES) if desired.get("peak") else 1.0
dump_chance = 1 / (len(STAT_NAMES) - 1) if desired.get("dump") else 1.0
total_chance = rarity_chance * species_chance * eye_chance * hat_chance * shiny_chance * peak_chance * dump_chance
return int(1 / total_chance) if total_chance > 0 else 999999
def _check_batch(salts, hashes, desired, want_shiny, want_peak, want_dump):
"""Check a batch of salts against desired traits. Returns matching salt or None."""
for salt, h in zip(salts, hashes):
rng = mulberry32(h)
rarity = roll_rarity(rng)
if rarity != desired["rarity"]:
continue
species = pick(rng, SPECIES)
if species != desired["species"]:
continue
eye = pick(rng, EYES)
if eye != desired["eye"]:
continue
if rarity == "common":
hat = "none"
else:
hat = pick(rng, HATS)
if hat != desired["hat"]:
continue
shiny = rng() < 0.01
if want_shiny and not shiny:
continue
# Check peak/dump stats
stats, peak, dump = roll_stats_from_rng(rng, rarity)
if want_peak and peak != want_peak:
continue
if want_dump and dump != want_dump:
continue
return salt, stats
return None
def _worker(args):
"""Worker task: generate a batch of salts, hash them, and check for a match."""
user_id, desired, want_shiny, want_peak, want_dump, batch_size, worker_seed = args
rnd = random.Random(worker_seed)
chars = string.ascii_letters + string.digits
# rnd.choices is ~10x faster than calling rnd.choice in a loop
salts = ["".join(rnd.choices(chars, k=15)) for _ in range(batch_size)]
keys = [user_id + s for s in salts]
hashes = bun_hash_batch(keys)
result = _check_batch(salts, hashes, desired, want_shiny, want_peak, want_dump)
return result # (salt, stats) or None
def find_salt(user_id, desired, on_progress=None, batch_size=10000, num_workers=None):
"""
Brute-force search for a salt that produces the desired pet traits.
Uses a pipelined ProcessPoolExecutor: as soon as any worker finishes,
a new task is immediately submitted so all cores stay busy.
desired: dict with species, rarity, eye, hat, and optionally shiny, peak, dump
"""
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, wait, FIRST_COMPLETED
if num_workers is None:
num_workers = max(1, multiprocessing.cpu_count())
total_attempts = 0
start_time = time.time()
want_shiny = desired.get("shiny", False)
want_peak = desired.get("peak")
want_dump = desired.get("dump")
# For tiny searches, single-thread is faster (no process spawn overhead)
est = estimate_attempts(desired)
if est < 10_000:
num_workers = 1
def make_args():
return (user_id, desired, want_shiny, want_peak, want_dump,
batch_size, random.randint(0, 2**32))
if num_workers == 1:
while True:
result = _worker(make_args())
total_attempts += batch_size
if result:
salt, stats = result
return {"salt": salt, "attempts": total_attempts,
"elapsed": time.time() - start_time, "stats": stats}
if on_progress:
elapsed = time.time() - start_time
rate = total_attempts / elapsed if elapsed > 0 else 0
on_progress({"attempts": total_attempts, "elapsed": elapsed, "rate": rate})
else:
# Pipelined parallel search:
# Keep (num_workers * 2) tasks in-flight so workers are never starved.
# As soon as any task completes, check the result and immediately
# submit a replacement — no waiting for the whole batch to finish.
queue_depth = num_workers * 2
with ProcessPoolExecutor(max_workers=num_workers) as executor:
pending = {executor.submit(_worker, make_args()) for _ in range(queue_depth)}
while True:
done, pending = wait(pending, return_when=FIRST_COMPLETED)
for future in done:
total_attempts += batch_size
result = future.result()
if result:
# Found — cancel queued (not yet running) tasks and return
for f in pending:
f.cancel()
salt, stats = result
return {"salt": salt, "attempts": total_attempts,
"elapsed": time.time() - start_time, "stats": stats}
# Slot freed — submit a replacement immediately
pending.add(executor.submit(_worker, make_args()))
if on_progress:
elapsed = time.time() - start_time
rate = total_attempts / elapsed if elapsed > 0 else 0
on_progress({"attempts": total_attempts, "elapsed": elapsed, "rate": rate})
# ─── Binary patching ───
def patch_binary(binary_path, old_salt, new_salt):
"""
Replace old_salt with new_salt in the binary (all occurrences).
Uses atomic rename for safety.
"""
if len(old_salt) != len(new_salt):
raise ValueError(f"Salt length mismatch: {len(old_salt)} vs {len(new_salt)}")
data = open(binary_path, "rb").read()
old_bytes = old_salt.encode("utf-8")
new_bytes = new_salt.encode("utf-8")
count = data.count(old_bytes)
if count == 0:
raise ValueError(f"Salt '{old_salt}' not found in binary.")
# Create backup if not exists
backup_path = binary_path + ".rebirth-bak"
if not os.path.exists(backup_path):
shutil.copy2(binary_path, backup_path)
# Replace
patched = data.replace(old_bytes, new_bytes)
# Write to temp file then atomic rename
tmp_path = binary_path + ".tmp"
with open(tmp_path, "wb") as f:
f.write(patched)
# Copy permissions
st = os.stat(binary_path)
os.chmod(tmp_path, st.st_mode)
# Atomic rename
os.replace(tmp_path, binary_path)
# Verify
verify_data = open(binary_path, "rb").read()
verified = verify_data.count(new_bytes) >= count
return {
"replacements": count,
"verified": verified,
"backup_path": backup_path,
}
# ─── Update companion in .claude.json ───
def update_companion(name=None, personality=None):
"""Update companion name/personality in ~/.claude.json."""
path = get_claude_json_path()
data = json.loads(path.read_text(encoding="utf-8"))
if "companion" not in data:
data["companion"] = {}
if name:
data["companion"]["name"] = name
if personality:
data["companion"]["personality"] = personality
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def delete_companion():
"""Delete companion from ~/.claude.json so Claude re-hatches on next /buddy."""
path = get_claude_json_path()
data = json.loads(path.read_text(encoding="utf-8"))
if "companion" in data:
del data["companion"]
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
import time
import lang
from lang import t, species_name, SPECIES_ZH
from ui import color, rarity_color, clear_screen, display_banner, display_pet, stat_bar, pad_right
from save import auto_save
from constants import SPECIES, RARITIES, EYES, HATS, RARITY_STARS, STAT_NAMES
from sprites import render_sprite, BODIES
from patcher import estimate_attempts
from apply import apply_pet
def pick_from_list(prompt_text, items, display_fn=None):
"""Let user pick from a numbered list. Returns selected item or None."""
print(color(f"\n {prompt_text}:", "bold"))
for i, item in enumerate(items, 1):
label = display_fn(item) if display_fn else str(item)
print(color(f" {i:>2}. ", "cyan") + label)
print()
while True:
raw = input(color(" > ", "cyan")).strip()
if raw == "":
return None
if raw.isdigit():
idx = int(raw) - 1
if 0 <= idx < len(items):
return items[idx]
print(color(f" 1-{len(items)}", "red"))
def custom_pick(favorites, roll_count):
"""Interactive custom pick mode: user chooses species, rarity, eyes, hat, stats."""
clear_screen()
display_banner()
print(color(f" === {t('pick_title')} ===\n", "bold"))
# 1. Species — full sprite gallery (3 columns)
def _print_species_gallery():
preview = {"eye": "\u00b7", "hat": "none", "rarity": "common", "shiny": False}
col_width = 26
cols = 3
print(color(f"\n {t('pick_species')}:", "bold"))
print()
for row_start in range(0, len(SPECIES), cols):
row_species = SPECIES[row_start:row_start + cols]
# Sprite lines (5 lines per species)
sprites = []
for s in row_species:
preview["species"] = s
lines = render_sprite(preview, frame=0)
sprites.append(lines)
# Number + name header row
header = ""
for idx, s in enumerate(row_species):
num = row_start + idx + 1
if lang.current_lang == "zh":
zh = SPECIES_ZH.get(s, "")
label = f"{num:>2}. {s} ({zh})"
else:
label = f"{num:>2}. {s}"
header += label.ljust(col_width)
print(color(" " + header, "cyan"))
# Sprite rows
for line_idx in range(5):
row_str = ""
for i, s in enumerate(row_species):
sprite_line = sprites[i][line_idx] if line_idx < len(sprites[i]) else ""
row_str += sprite_line.ljust(col_width)
print(" " + row_str)
print()
_print_species_gallery()
while True:
raw = input(color(" > ", "cyan")).strip()
if raw == "":
species = None
break
if raw.isdigit():
idx = int(raw) - 1
if 0 <= idx < len(SPECIES):
species = SPECIES[idx]
break
print(color(f" 1-{len(SPECIES)}", "red"))
if species is None:
return
if species is None:
return
# Show full sprite after selection
preview = {"species": species, "eye": "\u00b7", "hat": "none", "rarity": "common", "shiny": False}
print()
for line in render_sprite(preview):
print(color(f" {line}", "cyan"))
print()
# 2. Rarity
def rarity_label(r):
stars = RARITY_STARS[r]
name = t(r)
return rarity_color(f"{pad_right(name, 10)} {stars}", r)
rarity = pick_from_list(t("pick_rarity"), RARITIES, rarity_label)
if rarity is None:
return
# 3. Eyes — with live face preview
def eye_label(e):
from sprites import BODIES as _BODIES
# Find the face line directly from body definition to avoid render_sprite
# empty-line inconsistency (some species have non-empty decoration lines
# in other frames, which prevents the empty top line from being removed)
body_frame = _BODIES[species][0]
face_line = next((l for l in body_frame if "{E}" in l), body_frame[2])
face = face_line.replace("{E}", e).strip()
return f"{e} {face}"
eye = pick_from_list(t("pick_eyes"), EYES, eye_label)
if eye is None:
return
# 4. Hat
if rarity == "common":
hat = "none"
print(color(f"\n {t('pick_no_hat')}\n", "dim"))
else:
hat_choices = [h for h in HATS if h != "none"]
HAT_ZH = {
"crown": "皇冠", "tophat": "高帽", "propeller": "螺旋槳",
"halo": "光環", "wizard": "巫師帽", "beanie": "毛帽", "tinyduck": "小鴨",
}
def hat_label(h):
p = {"species": species, "eye": eye, "hat": h, "rarity": rarity, "shiny": False}
sprite_lines = render_sprite(p)
top = sprite_lines[0].strip() if sprite_lines else ""
if lang.current_lang == "zh":
zh = HAT_ZH.get(h, "")
return f"{h:<12} ({zh}) {top}"
return f"{h:<12} {top}"
hat = pick_from_list(t("pick_hat"), hat_choices, hat_label)
if hat is None:
return
# Show full preview with current selections
preview_pet = {"species": species, "eye": eye, "hat": hat, "rarity": rarity, "shiny": False}
print()
for line in render_sprite(preview_pet):
print(rarity_color(f" {line}", rarity))
print()
# 5. Shiny
shiny_input = input(color(f" {t('pick_shiny')} ", "yellow")).strip().lower()
shiny = shiny_input == "y"
# 6. Peak stat (optional)
if lang.current_lang == "zh":
peak_prompt = "最強屬性 (可跳過)"
else:
peak_prompt = "Best stat (optional)"
stat_choices = ["skip"] + list(STAT_NAMES)
def stat_label(s):
if s == "skip":
return color("Skip / 跳過", "dim") if lang.current_lang == "zh" else color("Skip", "dim")
return t(s)
peak = pick_from_list(peak_prompt, stat_choices, stat_label)
if peak == "skip" or peak is None:
peak = None
# 7. Dump stat (optional)
dump = None
if peak:
if lang.current_lang == "zh":
dump_prompt = "最弱屬性 (可跳過)"
else:
dump_prompt = "Worst stat (optional)"
dump_choices = ["skip"] + [s for s in STAT_NAMES if s != peak]
dump = pick_from_list(dump_prompt, dump_choices, stat_label)
if dump == "skip" or dump is None:
dump = None
# Build desired config
desired = {
"species": species,
"rarity": rarity,
"eye": eye,
"hat": hat,
"shiny": shiny,
}
if peak:
desired["peak"] = peak
if dump:
desired["dump"] = dump
est = estimate_attempts(desired)
# Build preview pet
pet = {
"species": species,
"rarity": rarity,
"eye": eye,
"hat": hat,
"shiny": shiny,
"stats": {s: 50 for s in STAT_NAMES},
"seed": 0,
}
# Final preview
clear_screen()
display_banner()
print(color(f" === {t('pick_preview')} ===\n", "bold"))
display_pet(pet, show_stats=False)
if peak:
if lang.current_lang == "zh":
print(color(f" 最強屬性: {t(peak)}", "green"))
else:
print(color(f" Best stat: {peak}", "green"))
if dump:
if lang.current_lang == "zh":
print(color(f" 最弱屬性: {t(dump)}", "red"))
else:
print(color(f" Worst stat: {dump}", "red"))
if lang.current_lang == "zh":
print(color(f" 預估搜尋: ~{est:,} 次\n", "dim"))
else:
print(color(f" Estimated: ~{est:,} attempts\n", "dim"))
# Confirm apply
apply_choice = input(color(f" {t('pick_confirm')} ", "yellow")).strip().lower()
if apply_choice == "y":
apply_pet(pet, desired_override=desired)
input(color(f" {t('press_enter')}", "dim"))
else:
keep = input(color(f" [k] = {t('cmd_keep')}, [Enter] = {t('fav_back')} ", "dim")).strip().lower()
if keep == "k":
favorites.append(pet)
auto_save(favorites, roll_count)
sp = species_name(species)
print(color(f"\n {t('kept')} {sp}! ({len(favorites)} {t('favs_total')})", "green"))
time.sleep(0.8)
#!/usr/bin/env python3
"""
reroll.py - 直接指定特徵,一鍵搜尋並套用目標寵物。
用法:
python reroll.py --species owl --rarity legendary
python reroll.py --species dragon --rarity legendary --hat wizard --shiny
python reroll.py --species cat --rarity epic --eye ✦ --peak PATIENCE
選項:
--species 物種(duck/goose/blob/cat/dragon/octopus/owl/penguin/
turtle/snail/ghost/axolotl/capybara/cactus/robot/
rabbit/mushroom/chonk)
--rarity 稀有度(common/uncommon/rare/epic/legendary,預設 legendary)
--eye 眼睛(· / ✦ / × / ◉ / @ / °,不指定則任意)
--hat 帽子(none/crown/tophat/propeller/halo/wizard/beanie/tinyduck,
不指定則任意)
--shiny 閃光版(加上此旗標即啟用)
--peak 最強屬性(DEBUGGING/PATIENCE/CHAOS/WISDOM/SNARK,不指定則任意)
--dump 最弱屬性(同上,不指定則任意)
--name 套用後的寵物名字(不指定則使用物種名)
"""
import argparse
import multiprocessing
import sys
import time
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
import os
os.system("")
from constants import SPECIES, RARITIES, EYES, HATS, STAT_NAMES, DEFAULT_PERSONALITIES
from patcher import (
get_user_id,
find_claude_binary,
get_current_salt,
patch_binary,
save_anybuddy_config,
update_companion,
)
def parse_args():
parser = argparse.ArgumentParser(
description="直接指定特徵,搜尋並套用目標寵物",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--species", choices=SPECIES, help="物種")
parser.add_argument("--rarity", choices=RARITIES, default="legendary", help="稀有度(預設 legendary)")
parser.add_argument("--eye", choices=EYES, help="眼睛樣式")
parser.add_argument("--hat", choices=HATS, help="帽子")
parser.add_argument("--shiny", action="store_true", help="閃光版")
parser.add_argument("--peak", choices=STAT_NAMES, help="最強屬性")
parser.add_argument("--dump", choices=STAT_NAMES, help="最弱屬性")
parser.add_argument("--name", help="套用後的寵物名字(不指定則使用物種名)")
return parser.parse_args()
def main():
args = parse_args()
# 若未指定 species,eye,hat 則填入「任意」佔位,find_salt 需要完整 desired dict
# 這裡採用「不指定就不限制」策略:隨機補全缺少的欄位後傳入
import random
from constants import EYES as ALL_EYES, HATS as ALL_HATS
desired = {
"rarity": args.rarity,
"species": args.species or random.choice(SPECIES),
"eye": args.eye or random.choice(ALL_EYES),
"hat": args.hat or ("none" if args.rarity == "common" else random.choice(ALL_HATS)),
"shiny": args.shiny,
}
if args.peak:
desired["peak"] = args.peak
if args.dump:
desired["dump"] = args.dump
# 若未指定某欄位,find_salt 需要改為「接受任意值」
# 重新包裝 desired,用 None 表示「不限制」
desired_filter = {
"rarity": args.rarity,
"species": args.species, # None = 任意
"eye": args.eye, # None = 任意
"hat": args.hat, # None = 任意
"shiny": args.shiny if args.shiny else None,
"peak": args.peak,
"dump": args.dump,
}
print(f"\n目標特徵:")
print(f" 物種:{args.species or '任意'}")
print(f" 稀有度:{args.rarity}")
print(f" 眼睛:{args.eye or '任意'}")
print(f" 帽子:{args.hat or '任意'}")
print(f" 閃光:{'是' if args.shiny else '否'}")
if args.peak:
print(f" 最強屬性:{args.peak}")
if args.dump:
print(f" 最弱屬性:{args.dump}")
print()
# 取得 userID
try:
user_id = get_user_id()
print(f"User ID: {user_id[:12]}...")
except Exception as e:
print(f"錯誤:無法讀取 userID - {e}")
sys.exit(1)
# 取得 binary
try:
binary_path = find_claude_binary()
print(f"Binary: {binary_path}")
except Exception as e:
print(f"錯誤:無法找到 Claude Code binary - {e}")
sys.exit(1)
# 取得目前 salt
try:
old_salt = get_current_salt(binary_path)
print(f"目前 salt: {old_salt}\n")
except Exception as e:
print(f"錯誤:無法讀取目前 salt - {e}")
sys.exit(1)
# 搜尋 salt
cpu_count = multiprocessing.cpu_count()
print(f"開始搜尋({cpu_count} 核心)...")
_last_print = [0.0]
def on_progress(info):
now = info["elapsed"]
if now - _last_print[0] < 2.0:
return
_last_print[0] = now
rate = info["rate"]
rate_str = f"{rate/1000:.0f}k/s" if rate < 1e6 else f"{rate/1e6:.1f}M/s"
sys.stdout.write(
f"\r {info['attempts']:,} 次嘗試 {rate_str} {now:.0f}s"
)
sys.stdout.flush()
# 建立實際用於搜尋的 desired(缺少欄位用隨機值填補,讓 find_salt 內部匹配)
# 採用修改後的 _find_salt_flexible 方式
result = _find_salt_flexible(user_id, desired_filter, on_progress)
sys.stdout.write("\r" + " " * 60 + "\r")
print(f"找到!共嘗試 {result['attempts']:,} 次,耗時 {result['elapsed']:.1f}s")
print(f"新 salt:{result['salt']}\n")
# 顯示實際屬性
stats = result.get("stats", {})
if stats:
print("實際屬性:")
for name, val in stats.items():
print(f" {name:<12} {val}")
print()
# Patch binary
try:
patch = patch_binary(binary_path, old_salt, result["salt"])
print(f"Binary 已修補({patch['replacements']} 處,verified={patch['verified']})")
print(f"備份:{patch['backup_path']}")
except Exception as e:
print(f"錯誤:patch 失敗 - {e}")
sys.exit(1)
# 儲存設定
save_anybuddy_config({
"salt": result["salt"],
"previousSalt": old_salt,
"species": result["species"],
"rarity": args.rarity,
"eye": result["eye"],
"hat": result["hat"],
"appliedTo": binary_path,
"appliedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
})
# 更新 companion
species = result["species"]
personality = DEFAULT_PERSONALITIES.get(species, "A loyal coding companion.")
if args.name:
name = args.name
else:
name = input(f"幫你的 {species} 取個名字(直接 Enter 使用 {species.capitalize()}):").strip()
if not name:
name = species.capitalize()
update_companion(name=name, personality=personality)
print(f"\n寵物名字:{name}")
print("完成!重啟 Claude Code 後執行 /buddy 即可看到新寵物。\n")
def _worker_flexible(args):
"""多核心 worker:搜尋一個批次,回傳符合條件的 salt 或 None。"""
import random
import string
from constants import SPECIES, EYES, HATS
from patcher import bun_hash_batch, mulberry32, pick, roll_rarity, roll_stats_from_rng
user_id, desired_filter, batch_size, seed = args
rnd = random.Random(seed)
chars = string.ascii_letters + string.digits
rarity = desired_filter["rarity"]
want_species = desired_filter["species"]
want_eye = desired_filter["eye"]
want_hat = desired_filter["hat"]
want_shiny = desired_filter.get("shiny")
want_peak = desired_filter.get("peak")
want_dump = desired_filter.get("dump")
salts = ["".join(rnd.choices(chars, k=15)) for _ in range(batch_size)]
keys = [user_id + s for s in salts]
hashes = bun_hash_batch(keys)
for salt, h in zip(salts, hashes):
rng = mulberry32(h)
r = roll_rarity(rng)
if r != rarity:
continue
sp = pick(rng, SPECIES)
if want_species and sp != want_species:
continue
eye = pick(rng, EYES)
if want_eye and eye != want_eye:
continue
hat = "none" if r == "common" else pick(rng, HATS)
if want_hat and hat != want_hat:
continue
shiny = rng() < 0.01
if want_shiny and not shiny:
continue
stats, peak, dump = roll_stats_from_rng(rng, r)
if want_peak and peak != want_peak:
continue
if want_dump and dump != want_dump:
continue
return salt, sp, eye, hat, shiny, stats
return None
def _find_salt_flexible(user_id, desired_filter, on_progress):
"""
支援 None 欄位(不限制)的多核心 salt 搜尋。
"""
import random
import time
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, wait, FIRST_COMPLETED
batch_size = 10000
num_workers = max(1, multiprocessing.cpu_count())
queue_depth = num_workers * 2
total_attempts = 0
start_time = time.time()
def make_args():
return (user_id, desired_filter, batch_size, random.randint(0, 2**32))
with ProcessPoolExecutor(max_workers=num_workers) as executor:
pending = {executor.submit(_worker_flexible, make_args()) for _ in range(queue_depth)}
while True:
done, pending = wait(pending, return_when=FIRST_COMPLETED)
for future in done:
total_attempts += batch_size
result = future.result()
if result:
for f in pending:
f.cancel()
salt, sp, eye, hat, shiny, stats = result
return {
"salt": salt,
"species": sp,
"eye": eye,
"hat": hat,
"shiny": shiny,
"stats": stats,
"attempts": total_attempts,
"elapsed": time.time() - start_time,
}
pending.add(executor.submit(_worker_flexible, make_args()))
if on_progress:
elapsed = time.time() - start_time
rate = total_attempts / elapsed if elapsed > 0 else 0
on_progress({"attempts": total_attempts, "elapsed": elapsed, "rate": rate})
if __name__ == "__main__":
main()
from pathlib import Path
import json
SAVE_PATH = Path("save_data.json")
def auto_save(favorites, roll_count):
"""Auto-save favorites and roll count."""
data = {
"roll_count": roll_count,
"favorites": [],
}
for pet in favorites:
data["favorites"].append({
"seed": pet.get("seed", 0),
"species": pet["species"],
"rarity": pet["rarity"],
"eye": pet["eye"],
"hat": pet["hat"],
"shiny": pet.get("shiny", False),
"stats": pet["stats"],
})
SAVE_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def load_save():
"""Load favorites and roll count from save file."""
# Migrate from old favorites.json
old_path = Path("favorites.json")
if not SAVE_PATH.exists() and old_path.exists():
try:
favs = json.loads(old_path.read_text(encoding="utf-8"))
return favs, 0
except (json.JSONDecodeError, KeyError):
return [], 0
if not SAVE_PATH.exists():
return [], 0
try:
data = json.loads(SAVE_PATH.read_text(encoding="utf-8"))
return data.get("favorites", []), data.get("roll_count", 0)
except (json.JSONDecodeError, KeyError):
return [], 0
# Each sprite is 5 lines tall. {E} = eye placeholder.
# 3 frames per species for idle animation.
BODIES = {
"duck": [
[" ", " __ ", " <({E} )___ ", " ( ._> ", " `--\u00b4 "],
[" ", " __ ", " <({E} )___ ", " ( ._> ", " `--\u00b4~ "],
[" ", " __ ", " <({E} )___ ", " ( .__> ", " `--\u00b4 "],
],
"goose": [
[" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
[" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
[" ", " ({E}>> ", " || ", " _(__)_ ", " ^^^^ "],
],
"blob": [
[" ", " .----. ", " ( {E} {E} ) ", " ( ) ", " `----\u00b4 "],
[" ", " .------. ", " ( {E} {E} ) ", " ( ) ", " `------\u00b4 "],
[" ", " .--. ", " ({E} {E}) ", " ( ) ", " `--\u00b4 "],
],
"cat": [
[" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", ' (")_(") '],
[" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", ' (")_(")~ '],
[" ", " /\\-/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", ' (")_(") '],
],
"dragon": [
[" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-\u00b4 "],
[" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ) ", " `-vvvv-\u00b4 "],
[" ~ ~ ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-\u00b4 "],
],
"octopus": [
[" ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
[" ", " .----. ", " ( {E} {E} ) ", " (______) ", " \\/\\/\\/\\/ "],
[" o ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
],
"owl": [
[" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " `----\u00b4 "],
[" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " .----. "],
[" ", " /\\ /\\ ", " (({E})(-)) ", " ( >< ) ", " `----\u00b4 "],
],
"penguin": [
[" ", " .---. ", " ({E}>{E}) ", " /( )\\ ", " `---\u00b4 "],
[" ", " .---. ", " ({E}>{E}) ", " |( )| ", " `---\u00b4 "],
[" .---. ", " ({E}>{E}) ", " /( )\\ ", " `---\u00b4 ", " ~ ~ "],
],
"turtle": [
[" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
[" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
[" ", " _,--._ ", " ( {E} {E} ) ", " /[======]\\ ", " `` `` "],
],
"snail": [
[" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--\u00b4 ", " ~~~~~~~ "],
[" ", " {E} .--. ", " | ( @ ) ", " \\_`--\u00b4 ", " ~~~~~~~ "],
[" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--\u00b4 ", " ~~~~~~ "],
],
"ghost": [
[" ", " .----. ", " / {E} {E} \\ ", " | | ", " ~`~``~`~ "],
[" ", " .----. ", " / {E} {E} \\ ", " | | ", " `~`~~`~` "],
[" ~ ~ ", " .----. ", " / {E} {E} \\ ", " | | ", " ~~`~~`~~ "],
],
"axolotl": [
[" ", "}~(______)~{", "}~({E} .. {E})~{", " ( .--. ) ", " (_/ \\_) "],
[" ", "~}(______){~", "~}({E} .. {E}){~", " ( .--. ) ", " (_/ \\_) "],
[" ", "}~(______)~{", "}~({E} .. {E})~{", " ( -- ) ", " ~_/ \\_~ "],
],
"capybara": [
[" ", " n______n ", " ( {E} {E} ) ", " ( oo ) ", " `------\u00b4 "],
[" ", " n______n ", " ( {E} {E} ) ", " ( Oo ) ", " `------\u00b4 "],
[" ~ ~ ", " u______n ", " ( {E} {E} ) ", " ( oo ) ", " `------\u00b4 "],
],
"cactus": [
[" ", " n ____ n ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
[" ", " ____ ", " n |{E} {E}| n ", " |_| |_| ", " | | "],
[" n n ", " | ____ | ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
],
"robot": [
[" ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------\u00b4 "],
[" ", " .[||]. ", " [ {E} {E} ] ", " [ -==- ] ", " `------\u00b4 "],
[" * ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------\u00b4 "],
],
"rabbit": [
[" ", " (\\__/) ", " ( {E} {E} ) ", " =( .. )= ", ' (")__(") '],
[" ", " (|__/) ", " ( {E} {E} ) ", " =( .. )= ", ' (")__(") '],
[" ", " (\\__/) ", " ( {E} {E} ) ", " =( . . )= ", ' (")__(") '],
],
"mushroom": [
[" ", " .-o-OO-o-. ", "(__________)", " |{E} {E}| ", " |____| "],
[" ", " .-O-oo-O-. ", "(__________)", " |{E} {E}| ", " |____| "],
[" . o . ", " .-o-OO-o-. ", "(__________)", " |{E} {E}| ", " |____| "],
],
"chonk": [
[" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------\u00b4 "],
[" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------\u00b4 "],
[" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------\u00b4~ "],
],
}
HAT_LINES = {
"none": "",
"crown": " \\^^^/ ",
"tophat": " [___] ",
"propeller": " -+- ",
"halo": " ( ) ",
"wizard": " /^\\ ",
"beanie": " (___) ",
"tinyduck": " ,> ",
}
def render_sprite(pet, frame=0):
"""Render a pet sprite as a list of strings."""
frames = BODIES[pet["species"]]
body = [line.replace("{E}", pet["eye"]) for line in frames[frame % len(frames)]]
lines = list(body)
# Add hat on first line if it's empty
if pet["hat"] != "none" and not lines[0].strip():
lines[0] = HAT_LINES[pet["hat"]]
# Remove empty top line if all frames have empty top
if not lines[0].strip() and all(not f[0].strip() for f in frames):
lines.pop(0)
return lines
import os
import sys
import time
import lang
from lang import t, species_name
from constants import RARITY_STARS, RARITY_COLORS, STAT_NAMES, DEFAULT_PERSONALITIES
from sprites import render_sprite, BODIES
def display_width(s):
"""Calculate display width accounting for CJK double-width characters."""
width = 0
for ch in s:
if ord(ch) > 0x7F:
width += 2
else:
width += 1
return width
def pad_right(s, total_width):
"""Pad string to total_width columns, accounting for CJK characters."""
current = display_width(s)
if current >= total_width:
return s
return s + " " * (total_width - current)
# ─── ANSI Colors ───
COLORS = {
"white": "\033[37m",
"green": "\033[32m",
"blue": "\033[34m",
"magenta": "\033[35m",
"purple": "\033[38;5;135m",
"yellow": "\033[33m",
"cyan": "\033[36m",
"red": "\033[31m",
"dim": "\033[2m",
"bold": "\033[1m",
"reset": "\033[0m",
}
def color(text, c):
return f"{COLORS.get(c, '')}{text}{COLORS['reset']}"
def rarity_color(text, rarity):
return color(text, RARITY_COLORS.get(rarity, "white"))
def clear_screen():
os.system("cls" if os.name == "nt" else "clear")
# ─── Display ───
def stat_bar(value, width=20):
filled = min(width, round(value / 100 * width))
empty = width - filled
return color("\u2588" * filled, "cyan") + color("\u2591" * empty, "dim")
def display_pet(pet, show_index=None, total=None, show_stats=True):
"""Display a pet with its sprite, stats, and info."""
rarity = pet["rarity"]
stars = RARITY_STARS[rarity]
shiny_tag = color(" SHINY!", "yellow") if pet["shiny"] else ""
# Header
sp = species_name(pet["species"]).upper()
header = f" {sp} {stars}{shiny_tag}"
if show_index is not None and total is not None:
header = f" [{show_index}/{total}] {sp} {stars}{shiny_tag}"
print(rarity_color(header, rarity))
# Info line
info = f" {t('rarity')}: {t(rarity)} | {t('eyes')}: {pet['eye']} | {t('hat')}: {pet['hat']}"
print(rarity_color(info, rarity))
print()
# Sprite
lines = render_sprite(pet, frame=0)
for line in lines:
print(rarity_color(f" {line}", rarity))
print()
if show_stats:
# Stats
print(color(f" {t('stats')}:", "bold"))
best_stat = max(pet["stats"], key=lambda k: pet["stats"][k])
worst_stat = min(pet["stats"], key=lambda k: pet["stats"][k])
for name in STAT_NAMES:
value = pet["stats"][name]
bar = stat_bar(value)
display_name = pad_right(t(name), 10)
label = f" {display_name} {bar} {value:>3}"
if name == best_stat:
print(color(label, "green"))
elif name == worst_stat:
print(color(label, "red"))
else:
print(label)
print()
# Personality
personality = DEFAULT_PERSONALITIES.get(pet["species"], "")
if personality:
print(color(f" \"{personality}\"", "dim"))
print()
def display_banner():
print(color(f"\n {t('banner_title')}", "bold"))
print(color(f" {t('banner_sub')}\n", "dim"))
def display_help():
print(color(f" {t('commands')}:", "bold"))
print(color(" [Enter] ", "cyan") + t("cmd_reroll"))
print(color(" [k] ", "cyan") + t("cmd_keep"))
print(color(" [f] ", "cyan") + t("cmd_favs"))
print(color(" [d] ", "cyan") + t("cmd_remove"))
print(color(" [a] ", "cyan") + t("cmd_anim"))
print(color(" [p] ", "cyan") + t("cmd_pick"))
print(color(" [l] ", "cyan") + t("cmd_lang"))
print(color(" [h] ", "cyan") + t("cmd_help"))
print(color(" [q] ", "cyan") + t("cmd_quit"))
print()
# ─── Animation ───
def animate_pet(pet, duration=3.0):
"""Show a short animation of the pet cycling through frames."""
num_frames = len(BODIES[pet["species"]])
rarity = pet["rarity"]
fps = 3
total_frames = int(duration * fps)
for i in range(total_frames):
lines = render_sprite(pet, frame=i % num_frames)
if i > 0:
sys.stdout.write(f"\033[{len(lines)}A")
for line in lines:
print(rarity_color(f" {line}", rarity))
sys.stdout.flush()
time.sleep(1 / fps)
# ─── Favorites ───
def display_favorites(favorites):
if not favorites:
print(color(f" {t('no_favs')}\n", "dim"))
return False
print(color(f" === {t('favs_title')} ({len(favorites)}) ===\n", "bold"))
for i, pet in enumerate(favorites, 1):
rarity = pet["rarity"]
stars = RARITY_STARS[rarity]
shiny = color(" SHINY!", "yellow") if pet.get("shiny") else ""
sp = species_name(pet["species"])
header = f" {i}. {sp} {stars}{shiny}"
print(rarity_color(header, rarity))
info = f" {t('rarity')}: {t(rarity)} | {t('eyes')}: {pet['eye']} | {t('hat')}: {pet['hat']}"
print(rarity_color(info, rarity))
# Sprite preview
lines = render_sprite(pet, frame=0)
for line in lines:
print(rarity_color(f" {line}", rarity))
# Stats with best/worst coloring
best_stat = max(pet["stats"], key=lambda k: pet["stats"][k])
worst_stat = min(pet["stats"], key=lambda k: pet["stats"][k])
for name in STAT_NAMES:
value = pet["stats"][name]
bar = stat_bar(value, width=10)
display_name = pad_right(t(name), 10)
label = f" {display_name} {bar} {value:>3}"
if name == best_stat:
print(color(label, "green"))
elif name == worst_stat:
print(color(label, "red"))
else:
print(label)
print()
print(color(f" [number] = {t('fav_apply')}", "cyan"))
print(color(f" [d] = {t('fav_remove')}", "cyan"))
print(color(f" [Enter] = {t('fav_back')}", "dim"))
print()
return True