feat: 添加 Buddy 相关功能,包括命令、配置和伴侣生成逻辑

buddy
abel533 1 month ago committed by liuzenghui
parent 83fb08926d
commit 0d3a6e2202
  1. 1
      scripts/build.ts
  2. 11
      src/buddy/companion.ts
  3. 8
      src/buddy/useBuddyNotification.tsx
  4. 4
      src/commands.ts
  5. 222
      src/commands/buddy/buddy.ts
  6. 12
      src/commands/buddy/index.ts
  7. 4
      src/utils/config.ts

@ -42,6 +42,7 @@ const FEATURE_FLAGS: Record<string, boolean> = {
ABLATION_BASELINE: false, ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false, DUMP_SYSTEM_PROMPT: false,
CHICAGO_MCP: false, CHICAGO_MCP: false,
BUDDY: true,
}; };
/** /**

@ -121,13 +121,12 @@ export function companionUserId(): string {
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
} }
// Regenerate bones from userId, merge with stored soul. Bones never persist // Regenerate bones from userId (or custom seed), merge with stored soul.
// so species renames and SPECIES-array edits can't break stored companions,
// and editing config.companion can't fake a rarity.
export function getCompanion(): Companion | undefined { export function getCompanion(): Companion | undefined {
const stored = getGlobalConfig().companion const config = getGlobalConfig()
const stored = config.companion
if (!stored) return undefined if (!stored) return undefined
const { bones } = roll(companionUserId()) const seed = config.companionSeed ?? companionUserId()
// bones last so stale bones fields in old-format configs get overridden const { bones } = roll(seed)
return { ...stored, ...bones } return { ...stored, ...bones }
} }

@ -10,14 +10,10 @@ import { getRainbowColor } from '../utils/thinking.js';
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // 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. // Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean { export function isBuddyTeaserWindow(): boolean {
if ("external" === 'ant') return true; return true;
const d = new Date();
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
} }
export function isBuddyLive(): boolean { export function isBuddyLive(): boolean {
if ("external" === 'ant') return true; return true;
const d = new Date();
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
} }
function RainbowText(t0) { function RainbowText(t0) {
const $ = _c(2); const $ = _c(2);

@ -115,12 +115,14 @@ const forkCmd = feature('FORK_SUBAGENT')
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js') require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
).default ).default
: null : null
const buddy = feature('BUDDY') const buddyCmd = feature('BUDDY')
? ( ? (
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js') require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
).default ).default
: null : null
/* eslint-enable @typescript-eslint/no-require-imports */ /* 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 thinkback from './commands/thinkback/index.js'
import thinkbackPlay from './commands/thinkback-play/index.js' import thinkbackPlay from './commands/thinkback-play/index.js'
import permissions from './commands/permissions/index.js' import permissions from './commands/permissions/index.js'

@ -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<string, number> = { ...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<typeof rollWithSeed>
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<null> {
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 <name> 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 <new name>', { 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
}

@ -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: '<pet|mute|card|rename|reroll>',
load: () => import('./buddy.js'),
} satisfies Command
export default buddy

@ -269,6 +269,10 @@ export type GlobalConfig = {
// /buddy companion soul — bones regenerated from userId on read. See src/buddy/. // /buddy companion soul — bones regenerated from userId on read. See src/buddy/.
companion?: import('../buddy/types.js').StoredCompanion companion?: import('../buddy/types.js').StoredCompanion
companionMuted?: boolean companionMuted?: boolean
// Track how many times each species has been rolled for fair distribution
companionRollCounts?: Record<string, number>
// Custom seed override for companion bones (bypasses userId-based generation)
companionSeed?: string
// Feedback survey tracking // Feedback survey tracking
feedbackSurveyState?: { feedbackSurveyState?: {

Loading…
Cancel
Save