From 0d3a6e2202c17f69e43dd5adeac4730c8482a7fe Mon Sep 17 00:00:00 2001 From: abel533 Date: Wed, 1 Apr 2026 00:48:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Buddy=20=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E3=80=81=E9=85=8D=E7=BD=AE=E5=92=8C=E4=BC=B4=E4=BE=A3?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build.ts | 1 + src/buddy/companion.ts | 11 +- src/buddy/useBuddyNotification.tsx | 8 +- src/commands.ts | 4 +- src/commands/buddy/buddy.ts | 222 +++++++++++++++++++++++++++++ src/commands/buddy/index.ts | 12 ++ src/utils/config.ts | 4 + 7 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 src/commands/buddy/buddy.ts create mode 100644 src/commands/buddy/index.ts diff --git a/scripts/build.ts b/scripts/build.ts index 064b38b..bfa567f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -42,6 +42,7 @@ const FEATURE_FLAGS: Record = { ABLATION_BASELINE: false, DUMP_SYSTEM_PROMPT: false, CHICAGO_MCP: false, + BUDDY: true, }; /** diff --git a/src/buddy/companion.ts b/src/buddy/companion.ts index 09c3838..90187d0 100644 --- a/src/buddy/companion.ts +++ b/src/buddy/companion.ts @@ -121,13 +121,12 @@ export function companionUserId(): string { return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' } -// Regenerate bones from userId, merge with stored soul. Bones never persist -// so species renames and SPECIES-array edits can't break stored companions, -// and editing config.companion can't fake a rarity. +// Regenerate bones from userId (or custom seed), merge with stored soul. export function getCompanion(): Companion | undefined { - const stored = getGlobalConfig().companion + const config = getGlobalConfig() + const stored = config.companion if (!stored) return undefined - const { bones } = roll(companionUserId()) - // bones last so stale bones fields in old-format configs get overridden + const seed = config.companionSeed ?? companionUserId() + const { bones } = roll(seed) return { ...stored, ...bones } } diff --git a/src/buddy/useBuddyNotification.tsx b/src/buddy/useBuddyNotification.tsx index d6eed22..f9edb42 100644 --- a/src/buddy/useBuddyNotification.tsx +++ b/src/buddy/useBuddyNotification.tsx @@ -10,14 +10,10 @@ import { getRainbowColor } from '../utils/thinking.js'; // buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // Teaser window: April 1-7, 2026 only. Command stays live forever after. export function isBuddyTeaserWindow(): boolean { - if ("external" === 'ant') return true; - const d = new Date(); - return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; + return true; } export function isBuddyLive(): boolean { - if ("external" === 'ant') return true; - const d = new Date(); - return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3; + return true; } function RainbowText(t0) { const $ = _c(2); diff --git a/src/commands.ts b/src/commands.ts index 10f03b2..20ca213 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -115,12 +115,14 @@ const forkCmd = feature('FORK_SUBAGENT') require('./commands/fork/index.js') as typeof import('./commands/fork/index.js') ).default : null -const buddy = feature('BUDDY') +const buddyCmd = feature('BUDDY') ? ( require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js') ).default : null /* eslint-enable @typescript-eslint/no-require-imports */ +import buddyFallback from './commands/buddy/index.js' +const buddy = buddyCmd ?? buddyFallback import thinkback from './commands/thinkback/index.js' import thinkbackPlay from './commands/thinkback-play/index.js' import permissions from './commands/permissions/index.js' diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts new file mode 100644 index 0000000..a5814e7 --- /dev/null +++ b/src/commands/buddy/buddy.ts @@ -0,0 +1,222 @@ +import type { ToolUseContext } from '../../Tool.js' +import type { + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { getCompanion, roll, rollWithSeed } from '../../buddy/companion.js' +import { renderSprite, renderFace } from '../../buddy/sprites.js' +import { RARITY_STARS, SPECIES } from '../../buddy/types.js' + +// Fair reroll: pick a random seed whose species has the fewest rolls. +// Once all species reach the same count, the pool resets effectively. +function fairRoll(): { seed: string; species: string } { + const config = getGlobalConfig() + const counts: Record = { ...config.companionRollCounts } + + // Find the minimum roll count + const minCount = Math.min(...SPECIES.map(s => counts[s] ?? 0)) + + // Candidates: species at the minimum count + const candidates = SPECIES.filter(s => (counts[s] ?? 0) === minCount) + + // Pick a random candidate + const target = candidates[Math.floor(Math.random() * candidates.length)]! + + // Generate random seeds until we hit the target species + let seed: string + let result: ReturnType + let attempts = 0 + do { + seed = `reroll-${Date.now()}-${Math.random().toString(36).slice(2)}-${attempts}` + result = rollWithSeed(seed) + attempts++ + } while (result.bones.species !== target && attempts < 500) + + return { seed, species: result.bones.species } +} + +function renderCard(): string { + const companion = getCompanion() + if (!companion) return 'No companion hatched yet. Run /buddy to hatch one!' + + const sprite = renderSprite(companion) + const face = renderFace(companion) + const stars = RARITY_STARS[companion.rarity] + const shinyTag = companion.shiny ? ' ✨ SHINY' : '' + const statsLines = Object.entries(companion.stats) + .map(([k, v]) => { + const bar = '█'.repeat(Math.round(v / 5)) + '░'.repeat(20 - Math.round(v / 5)) + return ` ${k.padEnd(10)} ${bar} ${v}` + }) + .join('\n') + + // Show collection progress + const config = getGlobalConfig() + const counts = config.companionRollCounts ?? {} + const seen = SPECIES.filter(s => (counts[s] ?? 0) > 0).length + const total = SPECIES.length + + return [ + `┌─── ${companion.name} ───┐`, + ` Species: ${companion.species} ${stars}${shinyTag}`, + ` Rarity: ${companion.rarity}`, + ` Hat: ${companion.hat}`, + ` Eyes: ${companion.eye}`, + ` Face: ${face}`, + '', + ...sprite.map(l => ` ${l}`), + '', + ' Stats:', + statsLines, + '', + ` Personality: ${companion.personality}`, + ` Hatched: ${new Date(companion.hatchedAt).toLocaleDateString()}`, + ` Collection: ${seen}/${total} species discovered`, + `└${'─'.repeat(companion.name.length + 6)}┘`, + ].join('\n') +} + +function hatchWithSeed(seed: string) { + const { bones } = rollWithSeed(seed) + const name = `Buddy_${bones.species}` + const soul = { + name, + personality: `A cheerful ${bones.species} companion with a knack for ${bones.rarity === 'legendary' ? 'legendary wisdom' : 'coding adventures'}.`, + hatchedAt: Date.now(), + } + + // Update config: save companion, seed, and bump roll count + saveGlobalConfig(cfg => { + const counts = { ...cfg.companionRollCounts } + counts[bones.species] = (counts[bones.species] ?? 0) + 1 + return { ...cfg, companion: soul, companionSeed: seed, companionRollCounts: counts } + }) + + return { ...soul, ...bones } +} + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, + args: string, +): Promise { + const sub = args.trim().toLowerCase() + + // No companion yet — hatch one + if (!sub || sub === 'hatch') { + const config = getGlobalConfig() + if (config.companion) { + onDone(renderCard(), { display: 'system' }) + return null + } + const { seed } = fairRoll() + const companion = hatchWithSeed(seed) + const sprite = renderSprite(companion) + const stars = RARITY_STARS[companion.rarity] + const shinyTag = companion.shiny ? ' ✨ SHINY!' : '' + onDone( + [ + '🥚 *crack* ...', + '', + ...sprite.map(l => ` ${l}`), + '', + ` A ${companion.rarity} ${companion.species} appeared! ${stars}${shinyTag}`, + ` Name: ${companion.name}`, + '', + ' Use /buddy card to see full stats.', + ' Use /buddy rename to rename.', + ' Use /buddy pet to show some love!', + ].join('\n'), + { display: 'system' }, + ) + return null + } + + if (sub === 'card' || sub === 'stats') { + onDone(renderCard(), { display: 'system' }) + return null + } + + if (sub === 'pet') { + const companion = getCompanion() + if (!companion) { + onDone('No companion to pet! Use /buddy to hatch one.', { display: 'system' }) + return null + } + context.setAppState(prev => ({ ...prev, companionPetAt: Date.now() })) + onDone(`You pet ${companion.name}! 💕`, { display: 'system' }) + return null + } + + if (sub === 'mute') { + const config = getGlobalConfig() + const newMuted = !config.companionMuted + saveGlobalConfig(cfg => ({ ...cfg, companionMuted: newMuted })) + onDone( + newMuted + ? 'Companion muted. Use /buddy mute to unmute.' + : 'Companion unmuted!', + { display: 'system' }, + ) + return null + } + + if (sub.startsWith('rename ')) { + const newName = args.trim().slice('rename '.length).trim() + if (!newName) { + onDone('Usage: /buddy rename ', { display: 'system' }) + return null + } + const config = getGlobalConfig() + if (!config.companion) { + onDone('No companion to rename! Use /buddy to hatch one.', { display: 'system' }) + return null + } + saveGlobalConfig(cfg => ({ + ...cfg, + companion: { ...cfg.companion!, name: newName }, + })) + onDone(`Companion renamed to ${newName}!`, { display: 'system' }) + return null + } + + if (sub === 'reroll') { + const { seed, species } = fairRoll() + const companion = hatchWithSeed(seed) + const sprite = renderSprite(companion) + const stars = RARITY_STARS[companion.rarity] + const shinyTag = companion.shiny ? ' ✨ SHINY!' : '' + const config = getGlobalConfig() + const counts = config.companionRollCounts ?? {} + const seen = SPECIES.filter(s => (counts[s] ?? 0) > 0).length + onDone( + [ + '🔄 Rerolling...', + '', + ...sprite.map(l => ` ${l}`), + '', + ` A ${companion.rarity} ${companion.species} appeared! ${stars}${shinyTag}`, + ` Name: ${companion.name}`, + ` Collection: ${seen}/${SPECIES.length} species discovered`, + ].join('\n'), + { display: 'system' }, + ) + return null + } + + onDone( + [ + 'Usage: /buddy [subcommand]', + '', + ' /buddy — Hatch or view your companion', + ' /buddy card — Show companion card with stats', + ' /buddy pet — Pet your companion 💕', + ' /buddy mute — Toggle companion speech bubbles', + ' /buddy rename — Rename your companion', + ' /buddy reroll — Release and re-hatch (fair rotation)', + ].join('\n'), + { display: 'system' }, + ) + return null +} diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts new file mode 100644 index 0000000..9f2ddc5 --- /dev/null +++ b/src/commands/buddy/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const buddy = { + type: 'local-jsx', + name: 'buddy', + description: 'Interact with your companion', + immediate: true, + argumentHint: '', + load: () => import('./buddy.js'), +} satisfies Command + +export default buddy diff --git a/src/utils/config.ts b/src/utils/config.ts index eecbf0c..870315a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -269,6 +269,10 @@ export type GlobalConfig = { // /buddy companion soul — bones regenerated from userId on read. See src/buddy/. companion?: import('../buddy/types.js').StoredCompanion companionMuted?: boolean + // Track how many times each species has been rolled for fair distribution + companionRollCounts?: Record + // Custom seed override for companion bones (bypasses userId-based generation) + companionSeed?: string // Feedback survey tracking feedbackSurveyState?: {