feat: build
This commit is contained in:
commit
f90eee85d8
1906 files changed
+514339
No files matched your search
@@ -0,0 +1,371 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { stringWidth } from '../ink/stringWidth.js';
|
||||
import { Box, Text } from '../ink.js';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
import { getCompanion } from './companion.js';
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||
import { RARITY_COLORS } from './types.js';
|
||||
const TICK_MS = 500;
|
||||
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
|
||||
|
||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · '];
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
function SpeechBubble(t0) {
|
||||
const $ = _c(31);
|
||||
const {
|
||||
text,
|
||||
color,
|
||||
fading,
|
||||
tail
|
||||
} = t0;
|
||||
let T0;
|
||||
let borderColor;
|
||||
let t1;
|
||||
let t2;
|
||||
let t3;
|
||||
let t4;
|
||||
let t5;
|
||||
let t6;
|
||||
if ($[0] !== color || $[1] !== fading || $[2] !== text) {
|
||||
const lines = wrap(text, 30);
|
||||
borderColor = fading ? "inactive" : color;
|
||||
T0 = Box;
|
||||
t1 = "column";
|
||||
t2 = "round";
|
||||
t3 = borderColor;
|
||||
t4 = 1;
|
||||
t5 = 34;
|
||||
let t7;
|
||||
if ($[11] !== fading) {
|
||||
t7 = (l, i) => <Text key={i} italic={true} dimColor={!fading} color={fading ? "inactive" : undefined}>{l}</Text>;
|
||||
$[11] = fading;
|
||||
$[12] = t7;
|
||||
} else {
|
||||
t7 = $[12];
|
||||
}
|
||||
t6 = lines.map(t7);
|
||||
$[0] = color;
|
||||
$[1] = fading;
|
||||
$[2] = text;
|
||||
$[3] = T0;
|
||||
$[4] = borderColor;
|
||||
$[5] = t1;
|
||||
$[6] = t2;
|
||||
$[7] = t3;
|
||||
$[8] = t4;
|
||||
$[9] = t5;
|
||||
$[10] = t6;
|
||||
} else {
|
||||
T0 = $[3];
|
||||
borderColor = $[4];
|
||||
t1 = $[5];
|
||||
t2 = $[6];
|
||||
t3 = $[7];
|
||||
t4 = $[8];
|
||||
t5 = $[9];
|
||||
t6 = $[10];
|
||||
}
|
||||
let t7;
|
||||
if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) {
|
||||
t7 = <T0 flexDirection={t1} borderStyle={t2} borderColor={t3} paddingX={t4} width={t5}>{t6}</T0>;
|
||||
$[13] = T0;
|
||||
$[14] = t1;
|
||||
$[15] = t2;
|
||||
$[16] = t3;
|
||||
$[17] = t4;
|
||||
$[18] = t5;
|
||||
$[19] = t6;
|
||||
$[20] = t7;
|
||||
} else {
|
||||
t7 = $[20];
|
||||
}
|
||||
const bubble = t7;
|
||||
if (tail === "right") {
|
||||
let t8;
|
||||
if ($[21] !== borderColor) {
|
||||
t8 = <Text color={borderColor}>─</Text>;
|
||||
$[21] = borderColor;
|
||||
$[22] = t8;
|
||||
} else {
|
||||
t8 = $[22];
|
||||
}
|
||||
let t9;
|
||||
if ($[23] !== bubble || $[24] !== t8) {
|
||||
t9 = <Box flexDirection="row" alignItems="center">{bubble}{t8}</Box>;
|
||||
$[23] = bubble;
|
||||
$[24] = t8;
|
||||
$[25] = t9;
|
||||
} else {
|
||||
t9 = $[25];
|
||||
}
|
||||
return t9;
|
||||
}
|
||||
let t8;
|
||||
if ($[26] !== borderColor) {
|
||||
t8 = <Box flexDirection="column" alignItems="flex-end" paddingRight={6}><Text color={borderColor}>╲ </Text><Text color={borderColor}>╲</Text></Box>;
|
||||
$[26] = borderColor;
|
||||
$[27] = t8;
|
||||
} else {
|
||||
t8 = $[27];
|
||||
}
|
||||
let t9;
|
||||
if ($[28] !== bubble || $[29] !== t8) {
|
||||
t9 = <Box flexDirection="column" alignItems="flex-end" marginRight={1}>{bubble}{t8}</Box>;
|
||||
$[28] = bubble;
|
||||
$[29] = t8;
|
||||
$[30] = t9;
|
||||
} else {
|
||||
t9 = $[30];
|
||||
}
|
||||
return t9;
|
||||
}
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
function spriteColWidth(nameWidth: number): number {
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
// correctly. In fullscreen the bubble floats over scrollback (no extra
|
||||
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0;
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||
const nameWidth = stringWidth(companion.name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
export function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
const setAppState = useSetAppState();
|
||||
const {
|
||||
columns
|
||||
} = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const lastSpokeTick = useRef(0);
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const [{
|
||||
petStartTick,
|
||||
forPetAt
|
||||
}, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({
|
||||
petStartTick: tick,
|
||||
forPetAt: petAt
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : {
|
||||
...prev,
|
||||
companionReaction: undefined
|
||||
}), BUBBLE_SHOW * TICK_MS, setAppState);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||
}, [reaction, setAppState]);
|
||||
if (!feature('BUDDY')) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name));
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
|
||||
return <Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
||||
<Text bold color={color}>
|
||||
{renderFace(companion)}
|
||||
</Text>{' '}
|
||||
<Text italic dimColor={!focused && !reaction} bold={focused} inverse={focused && !reaction} color={reaction ? fading ? 'inactive' : color : focused ? color : undefined}>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>;
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
let spriteFrame: number;
|
||||
let blink = false;
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount;
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
|
||||
if (step === -1) {
|
||||
spriteFrame = 0;
|
||||
blink = true;
|
||||
} else {
|
||||
spriteFrame = step % frameCount;
|
||||
}
|
||||
}
|
||||
const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line);
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
// PromptInputFooter's right column so this row stays one line and the
|
||||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = <Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{sprite.map((line, i) => <Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>)}
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${companion.name} ` : companion.name}
|
||||
</Text>
|
||||
</Box>;
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
// FullscreenLayout's bottomFloat slot (the bottom slot's overflowY:hidden
|
||||
// would clip a position:absolute overlay here). Sprite body only.
|
||||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
|
||||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
export function CompanionFloatingBubble() {
|
||||
const $ = _c(8);
|
||||
const reaction = useAppState(_temp);
|
||||
let t0;
|
||||
if ($[0] !== reaction) {
|
||||
t0 = {
|
||||
tick: 0,
|
||||
forReaction: reaction
|
||||
};
|
||||
$[0] = reaction;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
const [t1, setTick] = useState(t0);
|
||||
const {
|
||||
tick,
|
||||
forReaction
|
||||
} = t1;
|
||||
if (reaction !== forReaction) {
|
||||
setTick({
|
||||
tick: 0,
|
||||
forReaction: reaction
|
||||
});
|
||||
}
|
||||
let t2;
|
||||
let t3;
|
||||
if ($[2] !== reaction) {
|
||||
t2 = () => {
|
||||
if (!reaction) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(_temp3, TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
};
|
||||
t3 = [reaction];
|
||||
$[2] = reaction;
|
||||
$[3] = t2;
|
||||
$[4] = t3;
|
||||
} else {
|
||||
t2 = $[3];
|
||||
t3 = $[4];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
if (!feature("BUDDY") || !reaction) {
|
||||
return null;
|
||||
}
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) {
|
||||
return null;
|
||||
}
|
||||
const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
let t5;
|
||||
if ($[5] !== reaction || $[6] !== t4) {
|
||||
t5 = <SpeechBubble text={reaction} color={RARITY_COLORS[companion.rarity]} fading={t4} tail="down" />;
|
||||
$[5] = reaction;
|
||||
$[6] = t4;
|
||||
$[7] = t5;
|
||||
} else {
|
||||
t5 = $[7];
|
||||
}
|
||||
return t5;
|
||||
}
|
||||
function _temp3(set) {
|
||||
return set(_temp2);
|
||||
}
|
||||
function _temp2(s_0) {
|
||||
return {
|
||||
...s_0,
|
||||
tick: s_0.tick + 1
|
||||
};
|
||||
}
|
||||
function _temp(s) {
|
||||
return s.companionReaction;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiZmlndXJlcyIsIlJlYWN0IiwidXNlRWZmZWN0IiwidXNlUmVmIiwidXNlU3RhdGUiLCJ1c2VUZXJtaW5hbFNpemUiLCJzdHJpbmdXaWR0aCIsIkJveCIsIlRleHQiLCJ1c2VBcHBTdGF0ZSIsInVzZVNldEFwcFN0YXRlIiwiQXBwU3RhdGUiLCJnZXRHbG9iYWxDb25maWciLCJpc0Z1bGxzY3JlZW5BY3RpdmUiLCJUaGVtZSIsImdldENvbXBhbmlvbiIsInJlbmRlckZhY2UiLCJyZW5kZXJTcHJpdGUiLCJzcHJpdGVGcmFtZUNvdW50IiwiUkFSSVRZX0NPTE9SUyIsIlRJQ0tfTVMiLCJCVUJCTEVfU0hPVyIsIkZBREVfV0lORE9XIiwiUEVUX0JVUlNUX01TIiwiSURMRV9TRVFVRU5DRSIsIkgiLCJoZWFydCIsIlBFVF9IRUFSVFMiLCJ3cmFwIiwidGV4dCIsIndpZHRoIiwid29yZHMiLCJzcGxpdCIsImxpbmVzIiwiY3VyIiwidyIsImxlbmd0aCIsInB1c2giLCJTcGVlY2hCdWJibGUiLCJ0MCIsIiQiLCJfYyIsImNvbG9yIiwiZmFkaW5nIiwidGFpbCIsIlQwIiwiYm9yZGVyQ29sb3IiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ0NyIsImwiLCJpIiwidW5kZWZpbmVkIiwibWFwIiwiYnViYmxlIiwidDgiLCJ0OSIsIk1JTl9DT0xTX0ZPUl9GVUxMX1NQUklURSIsIlNQUklURV9CT0RZX1dJRFRIIiwiTkFNRV9ST1dfUEFEIiwiU1BSSVRFX1BBRERJTkdfWCIsIkJVQkJMRV9XSURUSCIsIk5BUlJPV19RVUlQX0NBUCIsInNwcml0ZUNvbFdpZHRoIiwibmFtZVdpZHRoIiwiTWF0aCIsIm1heCIsImNvbXBhbmlvblJlc2VydmVkQ29sdW1ucyIsInRlcm1pbmFsQ29sdW1ucyIsInNwZWFraW5nIiwiY29tcGFuaW9uIiwiY29tcGFuaW9uTXV0ZWQiLCJuYW1lIiwiQ29tcGFuaW9uU3ByaXRlIiwiUmVhY3ROb2RlIiwicmVhY3Rpb24iLCJzIiwiY29tcGFuaW9uUmVhY3Rpb24iLCJwZXRBdCIsImNvbXBhbmlvblBldEF0IiwiZm9jdXNlZCIsImZvb3RlclNlbGVjdGlvbiIsInNldEFwcFN0YXRlIiwiY29sdW1ucyIsInRpY2siLCJzZXRUaWNrIiwibGFzdFNwb2tlVGljayIsInBldFN0YXJ0VGljayIsImZvclBldEF0Iiwic2V0UGV0U3RhcnQiLCJ0aW1lciIsInNldEludGVydmFsIiwic2V0VCIsInQiLCJjbGVhckludGVydmFsIiwiY3VycmVudCIsInNldFRpbWVvdXQiLCJzZXRBIiwicHJldiIsImNsZWFyVGltZW91dCIsInJhcml0eSIsImNvbFdpZHRoIiwiYnViYmxlQWdlIiwicGV0QWdlIiwiSW5maW5pdHkiLCJwZXR0aW5nIiwicXVpcCIsInNsaWNlIiwibGFiZWwiLCJmcmFtZUNvdW50Iiwic3BlY2llcyIsImhlYXJ0RnJhbWUiLCJzcHJpdGVGcmFtZSIsImJsaW5rIiwic3RlcCIsImJvZHkiLCJsaW5lIiwicmVwbGFjZUFsbCIsImV5ZSIsInNwcml0ZSIsInNwcml0ZUNvbHVtbiIsIkNvbXBhbmlvbkZsb2F0aW5nQnViYmxlIiwiX3RlbXAiLCJmb3JSZWFjdGlvbiIsIl90ZW1wMyIsInNldCIsIl90ZW1wMiIsInNfMCJdLCJzb3VyY2VzIjpbIkNvbXBhbmlvblNwcml0ZS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgZmlndXJlcyBmcm9tICdmaWd1cmVzJ1xuaW1wb3J0IFJlYWN0LCB7IHVzZUVmZmVjdCwgdXNlUmVmLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlVGVybWluYWxTaXplIH0gZnJvbSAnLi4vaG9va3MvdXNlVGVybWluYWxTaXplLmpzJ1xuaW1wb3J0IHsgc3RyaW5nV2lkdGggfSBmcm9tICcuLi9pbmsvc3RyaW5nV2lkdGguanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyB1c2VBcHBTdGF0ZSwgdXNlU2V0QXBwU3RhdGUgfSBmcm9tICcuLi9zdGF0ZS9BcHBTdGF0ZS5qcydcbmltcG9ydCB0eXBlIHsgQXBwU3RhdGUgfSBmcm9tICcuLi9zdGF0ZS9BcHBTdGF0ZVN0b3JlLmpzJ1xuaW1wb3J0IHsgZ2V0R2xvYmFsQ29uZmlnIH0gZnJvbSAnLi4vdXRpbHMvY29uZmlnLmpzJ1xuaW1wb3J0IHsgaXNGdWxsc2NyZWVuQWN0aXZlIH0gZnJvbSAnLi4vdXRpbHMvZnVsbHNjcmVlbi5qcydcbmltcG9ydCB0eXBlIHsgVGhlbWUgfSBmcm9tICcuLi91dGlscy90aGVtZS5qcydcbmltcG9ydCB7IGdldENvbXBhbmlvbiB9IGZyb20gJy4vY29tcGFuaW9uLmpzJ1xuaW1wb3J0IHsgcmVuZGVyRmFjZSwgcmVuZGVyU3ByaXRlLCBzcHJpdGVGcmFtZUNvdW50IH0gZnJvbSAnLi9zcHJpdGVzLmpzJ1xuaW1wb3J0IHsgUkFSSVRZX0NPTE9SUyB9IGZyb20gJy4vdHlwZXMuanMnXG5cbmNvbnN0IFRJQ0tfTVMgPSA1MDBcbmNvbnN0IEJVQkJMRV9TSE9XID0gMjAgLy8gdGlja3Mg4oaSIH4xMHMgYXQgNTAwbXNcbmNvbnN0IEZBREVfV0lORE9XID0gNiAvLyBsYXN0IH4zcyB0aGUgYnViYmxlIGRpbXMgc28geW91IGtub3cgaXQncyBhYm91dCB0byBnb1xuY29uc3QgUEVUX0JVUlNUX01TID0gMjUwMCAvLyBob3cgbG9uZyBoZWFydHMgZmxvYXQgYWZ0ZXIgL2J1ZGR5IHBldFxuXG4vLyBJZGxlIHNlcXVlbmNlOiBtb3N0bHkgcmVzdCAoZnJhbWUgMCksIG9jY2FzaW9uYWwgZmlkZ2V0IChmcmFtZXMgMS0yKSwgcmFyZSBibGluay5cbi8vIFNlcXVlbmNlIGluZGljZXMgbWFwIHRvIHNwcml0ZSBmcmFtZXM7IC0xIG1lYW5zIFwiYmxpbmsgb24gZnJhbWUgMFwiLlxuY29uc3QgSURMRV9TRVFVRU5DRSA9IFswLCAwLCAwLCAwLCAxLCAwLCAwLCAwLCAtMSwgMCwgMCwgMiwgMCwgMCwgMF1cblxuLy8gSGVhcnRzIGZsb2F0IHVwLWFuZC1vdXQgb3ZlciA1IHRpY2tzICh+Mi41cykuIFByZXBlbmRlZCBhYm92ZSB0aGUgc3ByaXRlLlxuY29uc3QgSCA9IGZpZ3VyZXMuaGVhcnRcbmNvbnN0IFBFVF9IRUFSVFMgPSBbXG4gIGAgICAke0h9ICAgICR7SH0gICBgLFxuICBgICAke0h9ICAke0h9ICAgJHtIfSAgYCxcbiAgYCAke0h9ICAgJHtIfSAgJHtIfSAgIGAsXG4gIGAke0h9ICAke0h9ICAgICAgJHtIfSBgLFxuICAnwrcgICAgwrcgICDCtyAgJyxcbl1cblxuZnVuY3Rpb24gd3JhcCh0ZXh0OiBzdHJpbmcsIHdpZHRoOiBudW1iZXIpOiBzdHJpbmdbXSB7XG4gIGNvbnN0IHdvcmRzID0gdGV4dC5zcGxpdCgnICcpXG4gIGNvbnN0IGxpbmVzOiBzdHJpbmdbXSA9IFtdXG4gIGxldCBjdXIgPSAnJ1xuICBmb3IgKGNvbnN0IHcgb2Ygd29yZHMpIHtcbiAgICBpZiAoY3VyLmxlbmd0aCArIHcubGVuZ3RoICsgMSA+IHdpZHRoICYmIGN1cikge1xuICAgICAgbGluZXMucHVzaChjdXIpXG4gICAgICBjdXIgPSB3XG4gICAgfSBlbHNlIHtcbiAgICAgIGN1ciA9IGN1ciA/IGAke2N1cn0gJHt3fWAgOiB3XG4gICAgfVxuICB9XG4gIGlmIChjdXIpIGxpbmVzLnB1c2goY3VyKVxuICByZXR1cm4gbGluZXNcbn1cblxuZnVuY3Rpb24gU3BlZWNoQnViYmxlKHtcbiAgdGV4dCxcbiAgY29sb3IsXG4gIGZhZGluZyxcbiAgdGFpbCxcbn06IHtcbiAgdGV4dDogc3RyaW5nXG4gIGNvbG9yOiBrZXlvZiBUaGVtZVxuICBmYWRpbmc6IGJvb2xlYW5cbiAgdGFpbDogJ2Rvd24nIHwgJ3JpZ2h0J1xufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IGxpbmVzID0gd3JhcCh0ZXh0LCAzMClcbiAgY29uc3QgYm9yZGVyQ29sb3IgPSBmYWRpbmcgPyAnaW5hY3RpdmUnIDogY29sb3JcbiAgY29uc3QgYnViYmxlID0gKFxuICAgIDxCb3hcbiAgICAgIGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIlxuICAgICAgYm9yZGVyU3R5bGU9XCJyb3VuZFwiXG4gICAgICBib3JkZXJ
Line truncated
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import {
|
||||
type Companion,
|
||||
type CompanionBones,
|
||||
EYES,
|
||||
HATS,
|
||||
RARITIES,
|
||||
RARITY_WEIGHTS,
|
||||
type Rarity,
|
||||
SPECIES,
|
||||
STAT_NAMES,
|
||||
type StatName,
|
||||
} from './types.js'
|
||||
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
||||
function mulberry32(seed: number): () => number {
|
||||
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 hashString(s: string): number {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
|
||||
}
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!
|
||||
}
|
||||
|
||||
function rollRarity(rng: () => number): Rarity {
|
||||
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
||||
let roll = rng() * total
|
||||
for (const rarity of RARITIES) {
|
||||
roll -= RARITY_WEIGHTS[rarity]
|
||||
if (roll < 0) return rarity
|
||||
}
|
||||
return 'common'
|
||||
}
|
||||
|
||||
const RARITY_FLOOR: Record<Rarity, number> = {
|
||||
common: 5,
|
||||
uncommon: 15,
|
||||
rare: 25,
|
||||
epic: 35,
|
||||
legendary: 50,
|
||||
}
|
||||
|
||||
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
|
||||
function rollStats(
|
||||
rng: () => number,
|
||||
rarity: Rarity,
|
||||
): Record<StatName, number> {
|
||||
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 = {} as Record<StatName, number>
|
||||
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
|
||||
}
|
||||
|
||||
const SALT = 'friend-2026-401'
|
||||
|
||||
export type Roll = {
|
||||
bones: CompanionBones
|
||||
inspirationSeed: number
|
||||
}
|
||||
|
||||
function rollFrom(rng: () => number): Roll {
|
||||
const rarity = rollRarity(rng)
|
||||
const bones: CompanionBones = {
|
||||
rarity,
|
||||
species: pick(rng, SPECIES),
|
||||
eye: pick(rng, EYES),
|
||||
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
||||
shiny: rng() < 0.01,
|
||||
stats: rollStats(rng, rarity),
|
||||
}
|
||||
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
|
||||
}
|
||||
|
||||
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
|
||||
// per-turn observer) with the same userId → cache the deterministic result.
|
||||
let rollCache: { key: string; value: Roll } | undefined
|
||||
export function roll(userId: string): Roll {
|
||||
const key = userId + SALT
|
||||
if (rollCache?.key === key) return rollCache.value
|
||||
const value = rollFrom(mulberry32(hashString(key)))
|
||||
rollCache = { key, value }
|
||||
return value
|
||||
}
|
||||
|
||||
export function rollWithSeed(seed: string): Roll {
|
||||
return rollFrom(mulberry32(hashString(seed)))
|
||||
}
|
||||
|
||||
export function companionUserId(): string {
|
||||
const config = getGlobalConfig()
|
||||
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.
|
||||
export function getCompanion(): Companion | undefined {
|
||||
const stored = getGlobalConfig().companion
|
||||
if (!stored) return undefined
|
||||
const { bones } = roll(companionUserId())
|
||||
// bones last so stale bones fields in old-format configs get overridden
|
||||
return { ...stored, ...bones }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
|
||||
export function companionIntroText(name: string, species: string): string {
|
||||
return `# Companion
|
||||
|
||||
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
|
||||
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
|
||||
}
|
||||
|
||||
export function getCompanionIntroAttachment(
|
||||
messages: Message[] | undefined,
|
||||
): Attachment[] {
|
||||
if (!feature('BUDDY')) return []
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
|
||||
// Skip if already announced for this companion.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment.type !== 'companion_intro') continue
|
||||
if (msg.attachment.name === companion.name) return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'companion_intro',
|
||||
name: companion.name,
|
||||
species: companion.species,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { CompanionBones, Eye, Hat, Species } from './types.js'
|
||||
import {
|
||||
axolotl,
|
||||
blob,
|
||||
cactus,
|
||||
capybara,
|
||||
cat,
|
||||
chonk,
|
||||
dragon,
|
||||
duck,
|
||||
ghost,
|
||||
goose,
|
||||
mushroom,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
rabbit,
|
||||
robot,
|
||||
snail,
|
||||
turtle,
|
||||
} from './types.js'
|
||||
|
||||
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
|
||||
// Multiple frames per species for idle fidget animation.
|
||||
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
|
||||
const BODIES: Record<Species, string[][]> = {
|
||||
[duck]: [
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( .__> ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[goose]: [
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}>> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
],
|
||||
[blob]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .------. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .--. ',
|
||||
' ({E} {E}) ',
|
||||
' ( ) ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[cat]: [
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(")~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\-/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
],
|
||||
[dragon]: [
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
],
|
||||
[octopus]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' \\/\\/\\/\\/ ',
|
||||
],
|
||||
[
|
||||
' o ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
],
|
||||
[owl]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' .----. ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})(-)) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
],
|
||||
[penguin]: [
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' |( )| ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
' ~ ~ ',
|
||||
],
|
||||
],
|
||||
[turtle]: [
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[======]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
],
|
||||
[snail]: [
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' | ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~ ',
|
||||
],
|
||||
],
|
||||
[ghost]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~`~``~`~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' `~`~~`~` ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~~`~~`~~ ',
|
||||
],
|
||||
],
|
||||
[axolotl]: [
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'~}(______){~',
|
||||
'~}({E} .. {E}){~',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( -- ) ',
|
||||
' ~_/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[capybara]: [
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( Oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' u______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[cactus]: [
|
||||
[
|
||||
' ',
|
||||
' n ____ n ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ____ ',
|
||||
' n |{E} {E}| n ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' n n ',
|
||||
' | ____ | ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
],
|
||||
[robot]: [
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ -==- ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' * ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[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} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /| ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´~ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const HAT_LINES: Record<Hat, string> = {
|
||||
none: '',
|
||||
crown: ' \\^^^/ ',
|
||||
tophat: ' [___] ',
|
||||
propeller: ' -+- ',
|
||||
halo: ' ( ) ',
|
||||
wizard: ' /^\\ ',
|
||||
beanie: ' (___) ',
|
||||
tinyduck: ' ,> ',
|
||||
}
|
||||
|
||||
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
|
||||
const frames = BODIES[bones.species]
|
||||
const body = frames[frame % frames.length]!.map(line =>
|
||||
line.replaceAll('{E}', bones.eye),
|
||||
)
|
||||
const lines = [...body]
|
||||
// Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc)
|
||||
if (bones.hat !== 'none' && !lines[0]!.trim()) {
|
||||
lines[0] = HAT_LINES[bones.hat]
|
||||
}
|
||||
// Drop blank hat slot — wastes a row in the Card and ambient sprite when
|
||||
// there's no hat and the frame isn't using it for smoke/antenna/etc.
|
||||
// Only safe when ALL frames have blank line 0; otherwise heights oscillate.
|
||||
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
|
||||
return lines
|
||||
}
|
||||
|
||||
export function spriteFrameCount(species: Species): number {
|
||||
return BODIES[species].length
|
||||
}
|
||||
|
||||
export function renderFace(bones: CompanionBones): string {
|
||||
const eye: Eye = bones.eye
|
||||
switch (bones.species) {
|
||||
case duck:
|
||||
case goose:
|
||||
return `(${eye}>`
|
||||
case blob:
|
||||
return `(${eye}${eye})`
|
||||
case cat:
|
||||
return `=${eye}ω${eye}=`
|
||||
case dragon:
|
||||
return `<${eye}~${eye}>`
|
||||
case octopus:
|
||||
return `~(${eye}${eye})~`
|
||||
case owl:
|
||||
return `(${eye})(${eye})`
|
||||
case penguin:
|
||||
return `(${eye}>)`
|
||||
case turtle:
|
||||
return `[${eye}_${eye}]`
|
||||
case snail:
|
||||
return `${eye}(@)`
|
||||
case ghost:
|
||||
return `/${eye}${eye}\\`
|
||||
case axolotl:
|
||||
return `}${eye}.${eye}{`
|
||||
case capybara:
|
||||
return `(${eye}oo${eye})`
|
||||
case cactus:
|
||||
return `|${eye} ${eye}|`
|
||||
case robot:
|
||||
return `[${eye}${eye}]`
|
||||
case rabbit:
|
||||
return `(${eye}..${eye})`
|
||||
case mushroom:
|
||||
return `|${eye} ${eye}|`
|
||||
case chonk:
|
||||
return `(${eye}.${eye})`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export const RARITIES = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
] as const
|
||||
export type Rarity = (typeof RARITIES)[number]
|
||||
|
||||
// One species name collides with a model-codename canary in excluded-strings.txt.
|
||||
// The check greps build output (not source), so runtime-constructing the value keeps
|
||||
// the literal out of the bundle while the check stays armed for the actual codename.
|
||||
// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle).
|
||||
const c = String.fromCharCode
|
||||
// biome-ignore format: keep the species list compact
|
||||
|
||||
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
|
||||
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
|
||||
export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
|
||||
export const cat = c(0x63, 0x61, 0x74) as 'cat'
|
||||
export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
|
||||
export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus'
|
||||
export const owl = c(0x6f, 0x77, 0x6c) as 'owl'
|
||||
export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'
|
||||
export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle'
|
||||
export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail'
|
||||
export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost'
|
||||
export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl'
|
||||
export const capybara = c(
|
||||
0x63,
|
||||
0x61,
|
||||
0x70,
|
||||
0x79,
|
||||
0x62,
|
||||
0x61,
|
||||
0x72,
|
||||
0x61,
|
||||
) as 'capybara'
|
||||
export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus'
|
||||
export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot'
|
||||
export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit'
|
||||
export const mushroom = c(
|
||||
0x6d,
|
||||
0x75,
|
||||
0x73,
|
||||
0x68,
|
||||
0x72,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x6d,
|
||||
) as 'mushroom'
|
||||
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
|
||||
|
||||
export const SPECIES = [
|
||||
duck,
|
||||
goose,
|
||||
blob,
|
||||
cat,
|
||||
dragon,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
turtle,
|
||||
snail,
|
||||
ghost,
|
||||
axolotl,
|
||||
capybara,
|
||||
cactus,
|
||||
robot,
|
||||
rabbit,
|
||||
mushroom,
|
||||
chonk,
|
||||
] as const
|
||||
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
|
||||
|
||||
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
|
||||
export type Eye = (typeof EYES)[number]
|
||||
|
||||
export const HATS = [
|
||||
'none',
|
||||
'crown',
|
||||
'tophat',
|
||||
'propeller',
|
||||
'halo',
|
||||
'wizard',
|
||||
'beanie',
|
||||
'tinyduck',
|
||||
] as const
|
||||
export type Hat = (typeof HATS)[number]
|
||||
|
||||
export const STAT_NAMES = [
|
||||
'DEBUGGING',
|
||||
'PATIENCE',
|
||||
'CHAOS',
|
||||
'WISDOM',
|
||||
'SNARK',
|
||||
] as const
|
||||
export type StatName = (typeof STAT_NAMES)[number]
|
||||
|
||||
// Deterministic parts — derived from hash(userId)
|
||||
export type CompanionBones = {
|
||||
rarity: Rarity
|
||||
species: Species
|
||||
eye: Eye
|
||||
hat: Hat
|
||||
shiny: boolean
|
||||
stats: Record<StatName, number>
|
||||
}
|
||||
|
||||
// Model-generated soul — stored in config after first hatch
|
||||
export type CompanionSoul = {
|
||||
name: string
|
||||
personality: string
|
||||
}
|
||||
|
||||
export type Companion = CompanionBones &
|
||||
CompanionSoul & {
|
||||
hatchedAt: number
|
||||
}
|
||||
|
||||
// What actually persists in config. Bones are regenerated from hash(userId)
|
||||
// on every read so species renames don't break stored companions and users
|
||||
// can't edit their way to a legendary.
|
||||
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
|
||||
|
||||
export const RARITY_WEIGHTS = {
|
||||
common: 60,
|
||||
uncommon: 25,
|
||||
rare: 10,
|
||||
epic: 4,
|
||||
legendary: 1,
|
||||
} as const satisfies Record<Rarity, number>
|
||||
|
||||
export const RARITY_STARS = {
|
||||
common: '★',
|
||||
uncommon: '★★',
|
||||
rare: '★★★',
|
||||
epic: '★★★★',
|
||||
legendary: '★★★★★',
|
||||
} as const satisfies Record<Rarity, string>
|
||||
|
||||
export const RARITY_COLORS = {
|
||||
common: 'inactive',
|
||||
uncommon: 'success',
|
||||
rare: 'permission',
|
||||
epic: 'autoAccept',
|
||||
legendary: 'warning',
|
||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { feature } from 'bun:bundle';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { Text } from '../ink.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { getRainbowColor } from '../utils/thinking.js';
|
||||
|
||||
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
|
||||
// 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;
|
||||
}
|
||||
export function isBuddyLive(): boolean {
|
||||
if ("external" === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
|
||||
}
|
||||
function RainbowText(t0) {
|
||||
const $ = _c(2);
|
||||
const {
|
||||
text
|
||||
} = t0;
|
||||
let t1;
|
||||
if ($[0] !== text) {
|
||||
t1 = <>{[...text].map(_temp)}</>;
|
||||
$[0] = text;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
return t1;
|
||||
}
|
||||
|
||||
// Rainbow /buddy teaser shown on startup when no companion hatched yet.
|
||||
// Idle presence and reactions are handled by CompanionSprite directly.
|
||||
function _temp(ch, i) {
|
||||
return <Text key={i} color={getRainbowColor(i)}>{ch}</Text>;
|
||||
}
|
||||
export function useBuddyNotification() {
|
||||
const $ = _c(4);
|
||||
const {
|
||||
addNotification,
|
||||
removeNotification
|
||||
} = useNotifications();
|
||||
let t0;
|
||||
let t1;
|
||||
if ($[0] !== addNotification || $[1] !== removeNotification) {
|
||||
t0 = () => {
|
||||
if (!feature("BUDDY")) {
|
||||
return;
|
||||
}
|
||||
const config = getGlobalConfig();
|
||||
if (config.companion || !isBuddyTeaserWindow()) {
|
||||
return;
|
||||
}
|
||||
addNotification({
|
||||
key: "buddy-teaser",
|
||||
jsx: <RainbowText text="/buddy" />,
|
||||
priority: "immediate",
|
||||
timeoutMs: 15000
|
||||
});
|
||||
return () => removeNotification("buddy-teaser");
|
||||
};
|
||||
t1 = [addNotification, removeNotification];
|
||||
$[0] = addNotification;
|
||||
$[1] = removeNotification;
|
||||
$[2] = t0;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
t1 = $[3];
|
||||
}
|
||||
useEffect(t0, t1);
|
||||
}
|
||||
export function findBuddyTriggerPositions(text: string): Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
}> {
|
||||
if (!feature('BUDDY')) return [];
|
||||
const triggers: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
}> = [];
|
||||
const re = /\/buddy\b/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
triggers.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length
|
||||
});
|
||||
}
|
||||
return triggers;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiUmVhY3QiLCJ1c2VFZmZlY3QiLCJ1c2VOb3RpZmljYXRpb25zIiwiVGV4dCIsImdldEdsb2JhbENvbmZpZyIsImdldFJhaW5ib3dDb2xvciIsImlzQnVkZHlUZWFzZXJXaW5kb3ciLCJkIiwiRGF0ZSIsImdldEZ1bGxZZWFyIiwiZ2V0TW9udGgiLCJnZXREYXRlIiwiaXNCdWRkeUxpdmUiLCJSYWluYm93VGV4dCIsInQwIiwiJCIsIl9jIiwidGV4dCIsInQxIiwibWFwIiwiX3RlbXAiLCJjaCIsImkiLCJ1c2VCdWRkeU5vdGlmaWNhdGlvbiIsImFkZE5vdGlmaWNhdGlvbiIsInJlbW92ZU5vdGlmaWNhdGlvbiIsImNvbmZpZyIsImNvbXBhbmlvbiIsImtleSIsImpzeCIsInByaW9yaXR5IiwidGltZW91dE1zIiwiZmluZEJ1ZGR5VHJpZ2dlclBvc2l0aW9ucyIsIkFycmF5Iiwic3RhcnQiLCJlbmQiLCJ0cmlnZ2VycyIsInJlIiwibSIsIlJlZ0V4cEV4ZWNBcnJheSIsImV4ZWMiLCJwdXNoIiwiaW5kZXgiLCJsZW5ndGgiXSwic291cmNlcyI6WyJ1c2VCdWRkeU5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgUmVhY3QsIHsgdXNlRWZmZWN0IH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnLi4vY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uL2luay5qcydcbmltcG9ydCB7IGdldEdsb2JhbENvbmZpZyB9IGZyb20gJy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGdldFJhaW5ib3dDb2xvciB9IGZyb20gJy4uL3V0aWxzL3RoaW5raW5nLmpzJ1xuXG4vLyBMb2NhbCBkYXRlLCBub3QgVVRDIOKAlCAyNGggcm9sbGluZyB3YXZlIGFjcm9zcyB0aW1lem9uZXMuIFN1c3RhaW5lZCBUd2l0dGVyXG4vLyBidXp6IGluc3RlYWQgb2YgYSBzaW5nbGUgVVRDLW1pZG5pZ2h0IHNwaWtlLCBnZW50bGVyIG9uIHNvdWwtZ2VuIGxvYWQuXG4vLyBUZWFzZXIgd2luZG93OiBBcHJpbCAxLTcsIDIwMjYgb25seS4gQ29tbWFuZCBzdGF5cyBsaXZlIGZvcmV2ZXIgYWZ0ZXIuXG5leHBvcnQgZnVuY3Rpb24gaXNCdWRkeVRlYXNlcldpbmRvdygpOiBib29sZWFuIHtcbiAgaWYgKFwiZXh0ZXJuYWxcIiA9PT0gJ2FudCcpIHJldHVybiB0cnVlXG4gIGNvbnN0IGQgPSBuZXcgRGF0ZSgpXG4gIHJldHVybiBkLmdldEZ1bGxZZWFyKCkgPT09IDIwMjYgJiYgZC5nZXRNb250aCgpID09PSAzICYmIGQuZ2V0RGF0ZSgpIDw9IDdcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGlzQnVkZHlMaXZlKCk6IGJvb2xlYW4ge1xuICBpZiAoXCJleHRlcm5hbFwiID09PSAnYW50JykgcmV0dXJuIHRydWVcbiAgY29uc3QgZCA9IG5ldyBEYXRlKClcbiAgcmV0dXJuIChcbiAgICBkLmdldEZ1bGxZZWFyKCkgPiAyMDI2IHx8IChkLmdldEZ1bGxZZWFyKCkgPT09IDIwMjYgJiYgZC5nZXRNb250aCgpID49IDMpXG4gIClcbn1cblxuZnVuY3Rpb24gUmFpbmJvd1RleHQoeyB0ZXh0IH06IHsgdGV4dDogc3RyaW5nIH0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDw+XG4gICAgICB7Wy4uLnRleHRdLm1hcCgoY2gsIGkpID0+IChcbiAgICAgICAgPFRleHQga2V5PXtpfSBjb2xvcj17Z2V0UmFpbmJvd0NvbG9yKGkpfT5cbiAgICAgICAgICB7Y2h9XG4gICAgICAgIDwvVGV4dD5cbiAgICAgICkpfVxuICAgIDwvPlxuICApXG59XG5cbi8vIFJhaW5ib3cgL2J1ZGR5IHRlYXNlciBzaG93biBvbiBzdGFydHVwIHdoZW4gbm8gY29tcGFuaW9uIGhhdGNoZWQgeWV0LlxuLy8gSWRsZSBwcmVzZW5jZSBhbmQgcmVhY3Rpb25zIGFyZSBoYW5kbGVkIGJ5IENvbXBhbmlvblNwcml0ZSBkaXJlY3RseS5cbmV4cG9ydCBmdW5jdGlvbiB1c2VCdWRkeU5vdGlmaWNhdGlvbigpOiB2b2lkIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoIWZlYXR1cmUoJ0JVRERZJykpIHJldHVyblxuICAgIGNvbnN0IGNvbmZpZyA9IGdldEdsb2JhbENvbmZpZygpXG4gICAgaWYgKGNvbmZpZy5jb21wYW5pb24gfHwgIWlzQnVkZHlUZWFzZXJXaW5kb3coKSkgcmV0dXJuXG4gICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgIGtleTogJ2J1ZGR5LXRlYXNlcicsXG4gICAgICBqc3g6IDxSYWluYm93VGV4dCB0ZXh0PVwiL2J1ZGR5XCIgLz4sXG4gICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICB0aW1lb3V0TXM6IDE1XzAwMCxcbiAgICB9KVxuICAgIHJldHVybiAoKSA9PiByZW1vdmVOb3RpZmljYXRpb24oJ2J1ZGR5LXRlYXNlcicpXG4gIH0sIFthZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbl0pXG59XG5cbmV4cG9ydCBmdW5jdGlvbiBmaW5kQnVkZHlUcmlnZ2VyUG9zaXRpb25zKFxuICB0ZXh0OiBzdHJpbmcsXG4pOiBBcnJheTx7IHN0YXJ0OiBudW1iZXI7IGVuZDogbnVtYmVyIH0+IHtcbiAgaWYgKCFmZWF0dXJlKCdCVUREWScpKSByZXR1cm4gW11cbiAgY29uc3QgdHJpZ2dlcnM6IEFycmF5PHsgc3RhcnQ6IG51bWJlcjsgZW5kOiBudW1iZXIgfT4gPSBbXVxuICBjb25zdCByZSA9IC9cXC9idWRkeVxcYi9nXG4gIGxldCBtOiBSZWdFeHBFeGVjQXJyYXkgfCBudWxsXG4gIHdoaWxlICgobSA9IHJlLmV4ZWModGV4dCkpICE9PSBudWxsKSB7XG4gICAgdHJpZ2dlcnMucHVzaCh7IHN0YXJ0OiBtLmluZGV4LCBlbmQ6IG0uaW5kZXggKyBtWzBdLmxlbmd0aCB9KVxuICB9XG4gIHJldHVybiB0cmlnZ2Vyc1xufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsU0FBU0EsT0FBTyxRQUFRLFlBQVk7QUFDcEMsT0FBT0MsS0FBSyxJQUFJQyxTQUFTLFFBQVEsT0FBTztBQUN4QyxTQUFTQyxnQkFBZ0IsUUFBUSw2QkFBNkI7QUFDOUQsU0FBU0MsSUFBSSxRQUFRLFdBQVc7QUFDaEMsU0FBU0MsZUFBZSxRQUFRLG9CQUFvQjtBQUNwRCxTQUFTQyxlQUFlLFFBQVEsc0JBQXNCOztBQUV0RDtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQVNDLG1CQUFtQkEsQ0FBQSxDQUFFLEVBQUUsT0FBTyxDQUFDO0VBQzdDLElBQUksVUFBVSxLQUFLLEtBQUssRUFBRSxPQUFPLElBQUk7RUFDckMsTUFBTUMsQ0FBQyxHQUFHLElBQUlDLElBQUksQ0FBQyxDQUFDO0VBQ3BCLE9BQU9ELENBQUMsQ0FBQ0UsV0FBVyxDQUFDLENBQUMsS0FBSyxJQUFJLElBQUlGLENBQUMsQ0FBQ0csUUFBUSxDQUFDLENBQUMsS0FBSyxDQUFDLElBQUlILENBQUMsQ0FBQ0ksT0FBTyxDQUFDLENBQUMsSUFBSSxDQUFDO0FBQzNFO0FBRUEsT0FBTyxTQUFTQyxXQUFXQSxDQUFBLENBQUUsRUFBRSxPQUFPLENBQUM7RUFDckMsSUFBSSxVQUFVLEtBQUssS0FBSyxFQUFFLE9BQU8sSUFBSTtFQUNyQyxNQUFNTCxDQUFDLEdBQUcsSUFBSUMsSUFBSSxDQUFDLENBQUM7RUFDcEIsT0FDRUQsQ0FBQyxDQUFDRSxXQUFXLENBQUMsQ0FBQyxHQUFHLElBQUksSUFBS0YsQ0FBQyxDQUFDRSxXQUFXLENBQUMsQ0FBQyxLQUFLLElBQUksSUFBSUYsQ0FBQyxDQUFDRyxRQUFRLENBQUMsQ0FBQyxJQUFJLENBQUU7QUFFN0U7QUFFQSxTQUFBRyxZQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXFCO0lBQUFDO0VBQUEsSUFBQUgsRUFBMEI7RUFBQSxJQUFBSSxFQUFBO0VBQUEsSUFBQUgsQ0FBQSxRQUFBRSxJQUFBO0lBRTNDQyxFQUFBLEtBQ0csS0F
Line truncated
|
||||
Reference in new issue
Block a user