feat: 添加 Buddy 相关功能,包括命令、配置和伴侣生成逻辑
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
+3
-1
@@ -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?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user