Skip to content

Commit 0d0304d

Browse files
Merge branch 'pr/smallflyingpig/36'
2 parents 47d8847 + 70f32e2 commit 0d0304d

6 files changed

Lines changed: 297 additions & 14 deletions

File tree

src/buddy/companion.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,21 @@ export function rollWithSeed(seed: string): Roll {
116116
return rollFrom(mulberry32(hashString(seed)))
117117
}
118118

119+
export function generateSeed(): string {
120+
return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
121+
}
122+
119123
export function companionUserId(): string {
120124
const config = getGlobalConfig()
121125
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
122126
}
123127

124-
// Regenerate bones from userId, merge with stored soul. Bones never persist
125-
// so species renames and SPECIES-array edits can't break stored companions,
126-
// and editing config.companion can't fake a rarity.
128+
// Regenerate bones from seed or userId, merge with stored soul.
127129
export function getCompanion(): Companion | undefined {
128130
const stored = getGlobalConfig().companion
129131
if (!stored) return undefined
130-
const { bones } = roll(companionUserId())
132+
const seed = stored.seed ?? companionUserId()
133+
const { bones } = rollWithSeed(seed)
131134
// bones last so stale bones fields in old-format configs get overridden
132135
return { ...stored, ...bones }
133136
}

src/buddy/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export type CompanionBones = {
111111
export type CompanionSoul = {
112112
name: string
113113
personality: string
114+
seed?: string
114115
}
115116

116117
export type Companion = CompanionBones &

src/commands.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,8 @@ const forkCmd = feature('FORK_SUBAGENT')
115115
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
116116
).default
117117
: null
118-
const buddy = feature('BUDDY')
119-
? (
120-
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
121-
).default
122-
: null
118+
// buddy loaded directly (not feature-gated) for this build
119+
import buddy from './commands/buddy/index.js'
123120
/* eslint-enable @typescript-eslint/no-require-imports */
124121
import thinkback from './commands/thinkback/index.js'
125122
import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -319,7 +316,7 @@ const COMMANDS = memoize((): Command[] => [
319316
vim,
320317
...(webCmd ? [webCmd] : []),
321318
...(forkCmd ? [forkCmd] : []),
322-
...(buddy ? [buddy] : []),
319+
buddy,
323320
...(proactive ? [proactive] : []),
324321
...(briefCommand ? [briefCommand] : []),
325322
...(assistantCommand ? [assistantCommand] : []),

src/commands/buddy/buddy.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import {
2+
getCompanion,
3+
rollWithSeed,
4+
generateSeed,
5+
type Roll,
6+
} from '../../buddy/companion.js'
7+
import {
8+
type StoredCompanion,
9+
RARITY_STARS,
10+
STAT_NAMES,
11+
SPECIES,
12+
} from '../../buddy/types.js'
13+
import { renderSprite } from '../../buddy/sprites.js'
14+
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
15+
import type { LocalCommandCall } from '../../types/command.js'
16+
17+
// Species → default name fragments for hatch (no API needed)
18+
const SPECIES_NAMES: Record<string, string> = {
19+
duck: 'Waddles',
20+
goose: 'Goosberry',
21+
blob: 'Gooey',
22+
cat: 'Whiskers',
23+
dragon: 'Ember',
24+
octopus: 'Inky',
25+
owl: 'Hoots',
26+
penguin: 'Waddleford',
27+
turtle: 'Shelly',
28+
snail: 'Trailblazer',
29+
ghost: 'Casper',
30+
axolotl: 'Axie',
31+
capybara: 'Chill',
32+
cactus: 'Spike',
33+
robot: 'Byte',
34+
rabbit: 'Flops',
35+
mushroom: 'Spore',
36+
chonk: 'Chonk',
37+
}
38+
39+
const SPECIES_PERSONALITY: Record<string, string> = {
40+
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
41+
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
42+
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
43+
cat: 'Independent and judgmental. Watches you type with mild disdain.',
44+
dragon: 'Fiery and passionate about architecture. Hoards good variable names.',
45+
octopus: 'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
46+
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
47+
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
48+
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
49+
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
50+
ghost: 'Ethereal and appears at the worst possible moments with spooky insights.',
51+
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
52+
capybara: 'Zen master. Remains calm while everything around is on fire.',
53+
cactus: 'Prickly on the outside but full of good intentions. Thrives on neglect.',
54+
robot: 'Efficient and literal. Processes feedback in binary.',
55+
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
56+
mushroom: 'Quietly insightful. Grows on you over time.',
57+
chonk: 'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
58+
}
59+
60+
function speciesLabel(species: string): string {
61+
return species.charAt(0).toUpperCase() + species.slice(1)
62+
}
63+
64+
function renderStats(stats: Record<string, number>): string {
65+
const lines = STAT_NAMES.map(name => {
66+
const val = stats[name] ?? 0
67+
const filled = Math.round(val / 5)
68+
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled)
69+
return ` ${name.padEnd(10)} ${bar} ${val}`
70+
})
71+
return lines.join('\n')
72+
}
73+
74+
function companionInfoText(roll: Roll): string {
75+
const { bones } = roll
76+
const sprite = renderSprite(bones, 0)
77+
const stars = RARITY_STARS[bones.rarity]
78+
const name = SPECIES_NAMES[bones.species] ?? 'Buddy'
79+
const shiny = bones.shiny ? ' ✨ Shiny!' : ''
80+
81+
return [
82+
sprite.join('\n'),
83+
'',
84+
` ${name} the ${speciesLabel(bones.species)}${shiny}`,
85+
` Rarity: ${stars} (${bones.rarity})`,
86+
` Eye: ${bones.eye} Hat: ${bones.hat}`,
87+
'',
88+
' Stats:',
89+
renderStats(bones.stats),
90+
].join('\n')
91+
}
92+
93+
export const call: LocalCommandCall = async (args, _context) => {
94+
const sub = args.trim().toLowerCase()
95+
const config = getGlobalConfig()
96+
97+
// /buddy — show current companion or hint to hatch
98+
if (sub === '') {
99+
const companion = getCompanion()
100+
if (!companion) {
101+
return {
102+
type: 'text',
103+
value:
104+
"You don't have a companion yet! Use /buddy hatch to get one.",
105+
}
106+
}
107+
const stars = RARITY_STARS[companion.rarity]
108+
const sprite = renderSprite(companion, 0)
109+
const shiny = companion.shiny ? ' ✨ Shiny!' : ''
110+
111+
const lines = [
112+
sprite.join('\n'),
113+
'',
114+
` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`,
115+
` Rarity: ${stars} (${companion.rarity})`,
116+
` Eye: ${companion.eye} Hat: ${companion.hat}`,
117+
companion.personality ? `\n "${companion.personality}"` : '',
118+
'',
119+
' Stats:',
120+
renderStats(companion.stats),
121+
'',
122+
' Commands: /buddy pet /buddy mute /buddy unmute /buddy hatch /buddy rehatch',
123+
]
124+
return { type: 'text', value: lines.join('\n') }
125+
}
126+
127+
// /buddy hatch — create a new companion
128+
if (sub === 'hatch') {
129+
if (config.companion) {
130+
return {
131+
type: 'text',
132+
value: `You already have a companion! Use /buddy to see it.\n(Tip: /buddy hatch again will re-roll a new one.)`,
133+
}
134+
}
135+
136+
const seed = generateSeed()
137+
const r = rollWithSeed(seed)
138+
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
139+
const personality =
140+
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
141+
142+
const stored: StoredCompanion = {
143+
name,
144+
personality,
145+
seed,
146+
hatchedAt: Date.now(),
147+
}
148+
149+
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
150+
151+
const stars = RARITY_STARS[r.bones.rarity]
152+
const sprite = renderSprite(r.bones, 0)
153+
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
154+
155+
const lines = [
156+
' 🎉 A wild companion appeared!',
157+
'',
158+
sprite.join('\n'),
159+
'',
160+
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
161+
` Rarity: ${stars} (${r.bones.rarity})`,
162+
` "${personality}"`,
163+
'',
164+
' Your companion will now appear beside your input box!',
165+
]
166+
return { type: 'text', value: lines.join('\n') }
167+
}
168+
169+
// /buddy pet — trigger heart animation
170+
if (sub === 'pet') {
171+
const companion = getCompanion()
172+
if (!companion) {
173+
return {
174+
type: 'text',
175+
value:
176+
"You don't have a companion yet! Use /buddy hatch to get one.",
177+
}
178+
}
179+
180+
// Import setAppState dynamically to update companionPetAt
181+
try {
182+
const { setAppState } = await import('../../state/AppStateStore.js')
183+
setAppState(prev => ({
184+
...prev,
185+
companionPetAt: Date.now(),
186+
}))
187+
} catch {
188+
// If AppState is not available (non-interactive), just show text
189+
}
190+
191+
return {
192+
type: 'text',
193+
value: ` ${renderSprite(companion, 0).join('\n')}\n\n ${companion.name} purrs happily! ♥`,
194+
}
195+
}
196+
197+
// /buddy mute
198+
if (sub === 'mute') {
199+
if (config.companionMuted) {
200+
return { type: 'text', value: ' Companion is already muted.' }
201+
}
202+
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true }))
203+
return { type: 'text', value: ' Companion muted. It will hide quietly. Use /buddy unmute to bring it back.' }
204+
}
205+
206+
// /buddy unmute
207+
if (sub === 'unmute') {
208+
if (!config.companionMuted) {
209+
return { type: 'text', value: ' Companion is not muted.' }
210+
}
211+
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
212+
return { type: 'text', value: ' Companion unmuted! Welcome back.' }
213+
}
214+
215+
// /buddy rehatch — re-roll a new companion (replaces existing)
216+
if (sub === 'rehatch') {
217+
const seed = generateSeed()
218+
const r = rollWithSeed(seed)
219+
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
220+
const personality =
221+
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
222+
223+
const stored: StoredCompanion = {
224+
name,
225+
personality,
226+
seed,
227+
hatchedAt: Date.now(),
228+
}
229+
230+
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
231+
232+
const stars = RARITY_STARS[r.bones.rarity]
233+
const sprite = renderSprite(r.bones, 0)
234+
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
235+
236+
const lines = [
237+
' 🎉 A new companion appeared!',
238+
'',
239+
sprite.join('\n'),
240+
'',
241+
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
242+
` Rarity: ${stars} (${r.bones.rarity})`,
243+
` "${personality}"`,
244+
'',
245+
' Your old companion has been replaced!',
246+
]
247+
return { type: 'text', value: lines.join('\n') }
248+
}
249+
250+
// Unknown subcommand
251+
return {
252+
type: 'text',
253+
value:
254+
' Unknown command: /buddy ' +
255+
sub +
256+
'\n Commands: /buddy (info) /buddy hatch /buddy rehatch /buddy pet /buddy mute /buddy unmute',
257+
}
258+
}

src/commands/buddy/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1-
// Auto-generated stub — replace with real implementation
2-
const _default: Record<string, unknown> = {};
3-
export default _default;
1+
import type { Command } from '../../commands.js'
2+
3+
const buddy = {
4+
type: 'local',
5+
name: 'buddy',
6+
description: 'View and manage your companion buddy',
7+
supportsNonInteractive: false,
8+
load: () => import('./buddy.js'),
9+
} satisfies Command
10+
11+
export default buddy

src/entrypoints/cli.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
#!/usr/bin/env bun
2-
import { feature } from 'bun:bundle';
32

3+
// Runtime polyfill for bun:bundle (build-time macros)
4+
const feature = (name: string) => name === "BUDDY";
5+
if (typeof globalThis.MACRO === "undefined") {
6+
(globalThis as any).MACRO = {
7+
VERSION: "2.1.888",
8+
BUILD_TIME: new Date().toISOString(),
9+
FEEDBACK_CHANNEL: "",
10+
ISSUES_EXPLAINER: "",
11+
NATIVE_PACKAGE_URL: "",
12+
PACKAGE_URL: "",
13+
VERSION_CHANGELOG: "",
14+
};
15+
}
16+
// Build-time constants — normally replaced by Bun bundler at compile time
17+
(globalThis as any).BUILD_TARGET = "external";
18+
(globalThis as any).BUILD_ENV = "production";
19+
(globalThis as any).INTERFACE_TYPE = "stdio";
420

521
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
622
// eslint-disable-next-line custom-rules/no-top-level-side-effects

0 commit comments

Comments
 (0)