mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
feat(buddy): hero pixel-art companions with signature Enter animations (#1972)
* feat(buddy): hero pixel-art companions with signature Enter animations Rebuild the buddy system as heroes-only. The 18 legacy rolled species are removed; the hatch pool is now 7 hero forms — robinhood, kaio, strawhat, merlin, kage, ember, corsair — each hand-pickable via /buddy set. Every hero has 22x16 truecolor half-block pixel art (idle + action poses), a line-art fallback for low-color terminals, a narrow-mode face, and a signature effect that fires on every message submission: arrow with impact thunk, charging full-width energy wave, stretchy punch that extends and snaps back, twinkling sparkle stream, spinning shuriken, gradient fire cone, and cannonball with smoke trail. Engine: companion animation moves from a raw 500ms setInterval to the shared animation clock (useAnimationFrame; pauses when hidden, respects prefersReducedMotion), with a one-shot 50ms burst driver (useShotClock, arm-then-anchor to avoid stale-tick draw-phase skips) and a general ActionEffect system (pure draw/travel/impact functions, frame-tested). Effects travel right-to-left toward the prompt — matching where the sprite actually stands. Commands: /buddy set <form|random>, /buddy name <name>, muted-buddy feedback (silent no-op pets now explain themselves), and a hatch-message fix so the announced species always matches the displayed sprite (the message previously rolled with a different seed). BREAKING: existing rolled pets transform into a hero on upgrade (name and personality persist; speciesOverride pins are unaffected). Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * fix(buddy): address CodeRabbit review on PR #1972 - useShotClock: consume an in-flight shot when playback becomes ineligible mid-flight (mute/reduced-motion/resize), so re-enabling can't resume a stale animation. - /buddy unmute: emit a greeting reaction — the sprite reads companionMuted non-reactively and its clock is paused while hidden, so a config-only unmute left it invisible until an unrelated re-render. - /buddy name: strip ANSI escapes and control/format characters before saving, and cap by display width (stringWidth) instead of UTF-16 length. - CompanionSprite: track bubble age in sync-render state instead of an effect-updated ref, so a fresh reaction can't render pre-faded. - companion_intro already keyed on name+species (prior commit); tests now pin exact faces for all seven heroes, separate idle/shoot pixel frame counts, and decode-guard the charCode species constants. - CompanionActionFX tests: deterministic companion fixture via complete-config module mock; raw (untrimmed) output compared against a rendered-null baseline so a spurious blank FX row fails. - companion.test: re-register the real config module in afterAll (mock.restore does not undo mock.module). - Types: SPECIES_COLORS and FORM_FLAVOR are full Records (compile error on a colorless/flavorless future hero); dead RARITY_COLORS removed. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * test(buddy): regression coverage for bubble age reset on reaction change Renders CompanionSprite against a fake shared clock: ages the first bubble past the fade threshold, swaps the reaction WITHOUT advancing the clock, and asserts the fresh bubble renders unfaded. Fading is detected structurally (border and text collapse to one color when fading) so the test is independent of the active theme's exact values. Requested by CodeRabbit on PR #1972. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
co-authored by
OpenClaude
parent
7b9e477519
commit
d683e85395
@@ -0,0 +1,84 @@
|
||||
import { afterAll, describe, expect, mock, test } from 'bun:test'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
import React from 'react'
|
||||
import {
|
||||
type AppState,
|
||||
AppStateProvider,
|
||||
getDefaultAppState,
|
||||
} from '../state/AppState.js'
|
||||
import { renderToString } from '../utils/staticRender.js'
|
||||
import { robinhood } from './types.js'
|
||||
|
||||
// Deterministic companion fixture (complete-config mock, cache-busted real
|
||||
// module — see companion.test.ts for the pattern and why). Without this, the
|
||||
// tests would silently pass through `companion === undefined` on machines
|
||||
// with no hatched buddy instead of exercising the eligibility gates.
|
||||
const actualConfig = await import(`../utils/config.js?real=${Date.now()}`)
|
||||
mock.module('../utils/config.js', () => ({
|
||||
...actualConfig,
|
||||
getGlobalConfig: () => ({
|
||||
...actualConfig.getGlobalConfig(),
|
||||
userID: 'fx-test-user',
|
||||
oauthAccount: undefined,
|
||||
companionMuted: false,
|
||||
companion: {
|
||||
name: 'Testbud',
|
||||
personality: 'Test personality.',
|
||||
hatchedAt: 1,
|
||||
speciesOverride: robinhood,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const { CompanionActionFX } = await import('./CompanionActionFX.js')
|
||||
|
||||
afterAll(() => {
|
||||
mock.module('../utils/config.js', () => actualConfig)
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
// Raw output on purpose — NO trim(): an incorrectly rendered blank FX row
|
||||
// adds a line to the output and must stay observable. Each render is
|
||||
// compared against a rendered-null baseline through the identical wrapper,
|
||||
// so renderToString's own framing (trailing newline) cancels out.
|
||||
async function render(node: React.ReactNode, state: AppState): Promise<string> {
|
||||
const out = await renderToString(
|
||||
<AppStateProvider initialState={state}>{node}</AppStateProvider>,
|
||||
120,
|
||||
)
|
||||
return stripVTControlCharacters(out)
|
||||
}
|
||||
|
||||
async function expectRendersNothing(state: AppState): Promise<void> {
|
||||
const baseline = await render(null, state)
|
||||
expect(await render(<CompanionActionFX />, state)).toBe(baseline)
|
||||
}
|
||||
|
||||
describe('CompanionActionFX', () => {
|
||||
test('renders nothing when no shot token is set', async () => {
|
||||
const state = getDefaultAppState()
|
||||
expect(state.companionShotAt).toBeUndefined()
|
||||
await expectRendersNothing(state)
|
||||
})
|
||||
|
||||
test('a token that predates the mount is consumed, never replayed', async () => {
|
||||
// Regression for the remount-replay bug: the companion is eligible
|
||||
// (hatched robinhood, unmuted, wide terminal, no reduced motion), yet a
|
||||
// shot stamped BEFORE this component existed must not render an FX row.
|
||||
const state = { ...getDefaultAppState(), companionShotAt: 12345 } as AppState
|
||||
await expectRendersNothing(state)
|
||||
})
|
||||
|
||||
test('renders nothing under reduced motion even with a shot token', async () => {
|
||||
const base = getDefaultAppState()
|
||||
const state = {
|
||||
...base,
|
||||
companionShotAt: 12345,
|
||||
settings: {
|
||||
...base.settings,
|
||||
prefersReducedMotion: true,
|
||||
},
|
||||
} as AppState
|
||||
await expectRendersNothing(state)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
// Raw ink Text: effect runs carry raw rgb() colors, which the themed
|
||||
// wrapper does not accept.
|
||||
import RawText from '../ink/components/Text.js'
|
||||
import { Box } from '../ink.js'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { effectTotalMs, getActionEffect } from './actionEffects.js'
|
||||
import {
|
||||
companionReservedColumns,
|
||||
MIN_COLS_FOR_FULL_SPRITE,
|
||||
} from './CompanionSprite.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { isBuddyEnabled } from './feature.js'
|
||||
import { useShotClock } from './useShotClock.js'
|
||||
|
||||
// Mirrors PromptInput's textInputColumns gutter so the FX row's travel lane
|
||||
// matches the prompt text width.
|
||||
const PROMPT_GUTTER = 3
|
||||
const MIN_FX_WIDTH = 8
|
||||
|
||||
/**
|
||||
* Transient height-1 row above the prompt input: blank while the companion
|
||||
* plays its draw/cast poses, then the hero's signature effect (arrow, energy
|
||||
* wave, stretchy punch, ...) travels right→left toward the prompt. Mounts /
|
||||
* unmounts around the action — the same transient-row class as
|
||||
* CompletionFlash. Skipped in narrow terminals and under reduced motion.
|
||||
*/
|
||||
export const CompanionActionFX = React.memo(
|
||||
function CompanionActionFX(): React.ReactNode {
|
||||
const shotAt = useAppState(s => s.companionShotAt)
|
||||
const speaking = useAppState(s => s.companionReaction !== undefined)
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings?.prefersReducedMotion === true
|
||||
const { columns } = useTerminalSize()
|
||||
|
||||
// Skip the config/companion lookups entirely until the first shot token
|
||||
// exists — this component is mounted whenever the prompt is visible, so
|
||||
// idle keystrokes shouldn't pay for lookups that end in `return null`.
|
||||
const hasToken = shotAt !== undefined
|
||||
const companion = hasToken && isBuddyEnabled() ? getCompanion() : undefined
|
||||
const fx = companion ? getActionEffect(companion.species) : undefined
|
||||
const eligible =
|
||||
fx !== undefined &&
|
||||
getGlobalConfig().companionMuted !== true &&
|
||||
!reducedMotion &&
|
||||
columns >= MIN_COLS_FOR_FULL_SPRITE
|
||||
// shotAt passes through UNCONDITIONALLY so the hook can consume tokens
|
||||
// stamped while ineligible (see useShotClock docs).
|
||||
const elapsed = useShotClock(shotAt, eligible, fx ? effectTotalMs(fx) : 0)
|
||||
|
||||
if (elapsed === null || fx === undefined || companion === undefined) {
|
||||
return null
|
||||
}
|
||||
const width = Math.max(
|
||||
MIN_FX_WIDTH,
|
||||
columns - PROMPT_GUTTER - companionReservedColumns(columns, speaking),
|
||||
)
|
||||
const runs = elapsed < fx.drawMs ? [] : fx.render(elapsed - fx.drawMs, width)
|
||||
if (runs === null) return null
|
||||
return (
|
||||
<Box height={1} width="100%" flexShrink={0}>
|
||||
<RawText wrap="truncate">
|
||||
{runs.length === 0 ? (
|
||||
' '
|
||||
) : (
|
||||
runs.map((run, i) => (
|
||||
<RawText key={i} color={run.color}>
|
||||
{run.text}
|
||||
</RawText>
|
||||
))
|
||||
)}
|
||||
</RawText>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterAll, expect, mock, test } from 'bun:test'
|
||||
import chalk from 'chalk'
|
||||
import React, { useEffect } from 'react'
|
||||
import { createRoot } from '../ink.js'
|
||||
import { type Clock, ClockContext } from '../ink/components/ClockContext.js'
|
||||
import {
|
||||
type AppState,
|
||||
AppStateProvider,
|
||||
getDefaultAppState,
|
||||
useSetAppState,
|
||||
} from '../state/AppState.js'
|
||||
import { robinhood } from './types.js'
|
||||
|
||||
// Deterministic companion fixture (complete-config mock, cache-busted real
|
||||
// module — see companion.test.ts for the pattern and why).
|
||||
const actualConfig = await import(`../utils/config.js?real=${Date.now()}`)
|
||||
mock.module('../utils/config.js', () => ({
|
||||
...actualConfig,
|
||||
getGlobalConfig: () => ({
|
||||
...actualConfig.getGlobalConfig(),
|
||||
userID: 'sprite-test-user',
|
||||
oauthAccount: undefined,
|
||||
companionMuted: false,
|
||||
companion: {
|
||||
name: 'Testbud',
|
||||
personality: 'Test personality.',
|
||||
hatchedAt: 1,
|
||||
speciesOverride: robinhood,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const { CompanionSprite } = await import('./CompanionSprite.js')
|
||||
|
||||
// The fade check reads colors; pin chalk to truecolor so the SGR assertions
|
||||
// below can never silently pass in a color-stripped environment.
|
||||
const originalChalkLevel = chalk.level
|
||||
chalk.level = 3
|
||||
|
||||
afterAll(() => {
|
||||
chalk.level = originalChalkLevel
|
||||
mock.module('../utils/config.js', () => actualConfig)
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
const TICK_MS = 500
|
||||
const FADE_AT_MS = (20 - 6) * TICK_MS // BUBBLE_SHOW - FADE_WINDOW ticks
|
||||
|
||||
function createFakeClock(): Clock & { advance: (ms: number) => void } {
|
||||
const subscribers = new Set<() => void>()
|
||||
let now = 0
|
||||
return {
|
||||
now: () => now,
|
||||
setTickInterval: () => {},
|
||||
subscribe(onChange) {
|
||||
subscribers.add(onChange)
|
||||
return () => subscribers.delete(onChange)
|
||||
},
|
||||
advance(ms: number) {
|
||||
now += ms
|
||||
for (const onChange of [...subscribers]) onChange()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(cond: () => boolean, what: string): Promise<void> {
|
||||
const deadline = Date.now() + 5000
|
||||
while (!cond()) {
|
||||
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
}
|
||||
|
||||
test('a newly displayed bubble starts at age zero instead of inheriting the previous reaction age', async () => {
|
||||
const stdout = new PassThrough()
|
||||
const tty = stdout as unknown as NodeJS.WriteStream & {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
tty.columns = 120
|
||||
tty.rows = 40
|
||||
let output = ''
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const clock = createFakeClock()
|
||||
let updateAppState: ReturnType<typeof useSetAppState> | undefined
|
||||
function StateController(): null {
|
||||
const setAppState = useSetAppState()
|
||||
useEffect(() => {
|
||||
updateAppState = setAppState
|
||||
}, [setAppState])
|
||||
return null
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
companionReaction: 'first words',
|
||||
}
|
||||
|
||||
const root = await createRoot({
|
||||
stdout: tty,
|
||||
patchConsole: false,
|
||||
})
|
||||
root.render(
|
||||
<ClockContext.Provider value={clock}>
|
||||
<AppStateProvider initialState={initialState}>
|
||||
<CompanionSprite />
|
||||
<StateController />
|
||||
</AppStateProvider>
|
||||
</ClockContext.Provider>,
|
||||
)
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() => output.includes('first words') && updateAppState !== undefined,
|
||||
'initial bubble render',
|
||||
)
|
||||
|
||||
// Fading is observable structurally, independent of the active theme's
|
||||
// exact colors: a FRESH bubble draws its border in the species color and
|
||||
// its text in a different (dim-blended) color, while a FADING bubble
|
||||
// collapses both to the same `inactive` color. Compare the border's
|
||||
// foreground SGR with the quip text's.
|
||||
const borderAndTextColors = (text: string): [string, string] => {
|
||||
const line = output
|
||||
.split('\n')
|
||||
.reverse()
|
||||
.find(l => l.includes(text))
|
||||
expect(line, `no rendered line contains "${text}"`).toBeDefined()
|
||||
const border = /\x1b\[38;2;(\d+;\d+;\d+)m/.exec(line!)
|
||||
const quip = /\x1b\[38;2;(\d+;\d+;\d+)m\x1b\[3m/.exec(line!)
|
||||
expect(border, 'bubble border color missing').not.toBeNull()
|
||||
expect(quip, 'bubble text color missing').not.toBeNull()
|
||||
return [border![1]!, quip![1]!]
|
||||
}
|
||||
{
|
||||
const [border, text] = borderAndTextColors('first words')
|
||||
expect(border).not.toBe(text) // fresh: species border, dimmed text
|
||||
}
|
||||
|
||||
// Age the first bubble past the fade threshold: it turns inactive.
|
||||
output = ''
|
||||
clock.advance(FADE_AT_MS + TICK_MS)
|
||||
await waitFor(() => output.includes('first words'), 'faded re-render')
|
||||
{
|
||||
const [border, text] = borderAndTextColors('first words')
|
||||
expect(border).toBe(text) // faded: everything inactive
|
||||
}
|
||||
|
||||
// Change the reaction WITHOUT advancing the clock. Before the fix, the
|
||||
// render still read the previous bubble's age, so the fresh bubble
|
||||
// appeared pre-faded; now its age resets to zero synchronously.
|
||||
output = ''
|
||||
updateAppState!(prev => ({ ...prev, companionReaction: 'second words' }))
|
||||
await waitFor(() => output.includes('second words'), 'fresh bubble render')
|
||||
{
|
||||
const [border, text] = borderAndTextColors('second words')
|
||||
expect(border).not.toBe(text) // age reset: fresh styling again
|
||||
}
|
||||
} finally {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
+436
-300
@@ -1,162 +1,151 @@
|
||||
import { c as _c } from "react-compiler-runtime";
|
||||
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 { isBuddyEnabled } from './feature.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
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
// Raw ink Text (not the themed wrapper): pixel art needs arbitrary rgb()
|
||||
// values for BOTH foreground and background, and ThemedText only accepts
|
||||
// theme keys for backgroundColor.
|
||||
import RawText from '../ink/components/Text.js'
|
||||
import { stringWidth } from '../ink/stringWidth.js'
|
||||
import { Box, Text, useAnimationFrame } 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 { effectTotalMs, getActionEffect } from './actionEffects.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { isBuddyEnabled } from './feature.js'
|
||||
import {
|
||||
hasPixelSprite,
|
||||
isPixelColorCapable,
|
||||
PIXEL_WIDTH,
|
||||
pixelIdleFrameCount,
|
||||
pixelShootFrameCount,
|
||||
type PixelRun,
|
||||
renderPixelSprite,
|
||||
} from './pixelSprites.js'
|
||||
import {
|
||||
renderFace,
|
||||
renderShootSprite,
|
||||
renderSprite,
|
||||
shootFrameCount,
|
||||
spriteFrameCount,
|
||||
} from './sprites.js'
|
||||
import { companionColor } from './types.js'
|
||||
import { useShotClock } from './useShotClock.js'
|
||||
|
||||
/** Sprite pose index during the effect's draw phase; clamps to the release
|
||||
* pose while the projectile travels. Pose count derives from the actual
|
||||
* frame set so heroes with more/fewer poses animate correctly. */
|
||||
function drawPoseIndex(
|
||||
drawMs: number,
|
||||
elapsedMs: number,
|
||||
poseCount: number,
|
||||
): number {
|
||||
if (poseCount <= 1) return 0
|
||||
return Math.min(
|
||||
poseCount - 1,
|
||||
Math.max(0, Math.floor(elapsedMs / (drawMs / poseCount))),
|
||||
)
|
||||
}
|
||||
|
||||
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];
|
||||
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} `, '· · · '];
|
||||
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 = '';
|
||||
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;
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
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];
|
||||
|
||||
function SpeechBubble({
|
||||
text,
|
||||
color,
|
||||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'right' | 'down'
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
const bubble = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
width={34}
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
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;
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
{bubble}
|
||||
<Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
|
||||
<Text color={borderColor}>╲ </Text>
|
||||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
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;
|
||||
|
||||
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);
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
@@ -164,115 +153,303 @@ function spriteColWidth(nameWidth: number): number {
|
||||
// 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 (!isBuddyEnabled()) 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;
|
||||
// Pixel mode is a pure function of species + terminal color support, so
|
||||
// the reservation math and the sprite renderer can never disagree.
|
||||
function pixelModeActive(species: Parameters<typeof hasPixelSprite>[0]): boolean {
|
||||
return hasPixelSprite(species) && isPixelColorCapable()
|
||||
}
|
||||
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);
|
||||
|
||||
// Single source of truth for the sprite column width — used by BOTH the
|
||||
// reservation math and the renderer so they cannot drift.
|
||||
function companionColumnWidth(companion: {
|
||||
species: Parameters<typeof hasPixelSprite>[0]
|
||||
name: string
|
||||
}): number {
|
||||
const base = spriteColWidth(stringWidth(companion.name))
|
||||
return pixelModeActive(companion.species) ? Math.max(base, PIXEL_WIDTH) : base
|
||||
}
|
||||
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!isBuddyEnabled()) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
||||
return companionColumnWidth(companion) + SPRITE_PADDING_X + bubble
|
||||
}
|
||||
|
||||
// Map the shared idle logic onto pixel frames: 0=rest, 1=blink, 2=fidget.
|
||||
// Heroes with only rest+blink frames fall back to rest for fidget steps
|
||||
// (clamping to the last frame would double as an extra blink).
|
||||
function pixelIdleFrame(step: number, blink: boolean, frameCount: number): number {
|
||||
if (blink) return Math.min(1, frameCount - 1)
|
||||
if (step === 0) return 0
|
||||
return frameCount > 2 ? 2 : 0
|
||||
}
|
||||
|
||||
function PixelRows({ rows }: { rows: PixelRun[][] }): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
{rows.map((runs, i) => (
|
||||
<RawText key={i} wrap="truncate">
|
||||
{runs.map((run, j) => (
|
||||
<RawText
|
||||
key={j}
|
||||
color={run.color}
|
||||
backgroundColor={run.backgroundColor}
|
||||
>
|
||||
{run.text}
|
||||
</RawText>
|
||||
))}
|
||||
</RawText>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// React.memo: this file ships as plain source (not committed react-compiler
|
||||
// output), and the component is a propless child of REPL, which re-renders
|
||||
// per keystroke. memo skips those idle re-renders; animation re-renders come
|
||||
// from the component's own clock subscriptions and are unaffected.
|
||||
export const CompanionSprite = React.memo(function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const shotAt = useAppState(s => s.companionShotAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings?.prefersReducedMotion === true
|
||||
|
||||
// Plain reads (not hooks) — safe before the early returns below, and
|
||||
// needed up here so the clock subscription can pause when hidden.
|
||||
const companion = isBuddyEnabled() ? getCompanion() : undefined
|
||||
const hidden = !companion || getGlobalConfig().companionMuted === true
|
||||
|
||||
// Shared animation clock at the idle cadence. Paused entirely when the
|
||||
// sprite is hidden or the user prefers reduced motion (time freezes, so
|
||||
// reduced-motion renders below must not derive frames from it).
|
||||
const [, time] = useAnimationFrame(hidden || reducedMotion ? null : TICK_MS)
|
||||
const tick = Math.floor(time / TICK_MS)
|
||||
|
||||
// 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
|
||||
});
|
||||
// has petStartTime=time and petAge=0 — otherwise frame 0 is skipped.
|
||||
const [{ petStartTime, forPetAt }, setPetStart] = useState({
|
||||
petStartTime: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({
|
||||
petStartTick: tick,
|
||||
forPetAt: petAt
|
||||
});
|
||||
setPetStart({ petStartTime: time, forPetAt: petAt })
|
||||
}
|
||||
|
||||
// Bubble age uses the same sync-during-render pattern (a ref updated in an
|
||||
// effect would leave the FIRST render of a new reaction reading the
|
||||
// previous bubble's age — a fresh bubble could appear already faded).
|
||||
const [{ spokeTime, forSpoken }, setSpoke] = useState({
|
||||
spokeTime: 0,
|
||||
forSpoken: reaction,
|
||||
})
|
||||
if (reaction !== forSpoken) {
|
||||
setSpoke({ spokeTime: time, forSpoken: reaction })
|
||||
}
|
||||
|
||||
// Signature action: a 50ms burst clock that runs only while a shot is live.
|
||||
const actionFx =
|
||||
companion !== undefined ? getActionEffect(companion.species) : undefined
|
||||
const shotEligible =
|
||||
!hidden &&
|
||||
!reducedMotion &&
|
||||
actionFx !== undefined &&
|
||||
columns >= MIN_COLS_FOR_FULL_SPRITE
|
||||
// Pass shotAt UNCONDITIONALLY — the hook consumes tokens even while
|
||||
// ineligible so a stale token can't replay after remount/eligibility
|
||||
// flips (see useShotClock docs).
|
||||
const shotElapsed = useShotClock(
|
||||
shotAt,
|
||||
shotEligible,
|
||||
actionFx !== undefined ? effectTotalMs(actionFx) : 0,
|
||||
)
|
||||
|
||||
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 (!isBuddyEnabled()) 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;
|
||||
if (!reaction) return
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [reaction, setAppState])
|
||||
|
||||
if (!companion || hidden) return null
|
||||
|
||||
const color = companionColor(companion)
|
||||
const colWidth = companionColumnWidth(companion)
|
||||
const bubbleAgeMs =
|
||||
reaction !== undefined && reaction === forSpoken ? time - spokeTime : 0
|
||||
const fading =
|
||||
!reducedMotion &&
|
||||
reaction !== undefined &&
|
||||
bubbleAgeMs >= (BUBBLE_SHOW - FADE_WINDOW) * TICK_MS
|
||||
const petAgeMs = petAt !== undefined ? time - petStartTime : Infinity
|
||||
const petting = !reducedMotion && petAgeMs < 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">
|
||||
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}>
|
||||
<Text
|
||||
italic
|
||||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>;
|
||||
</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;
|
||||
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting
|
||||
? PET_HEARTS[Math.floor(petAgeMs / TICK_MS) % PET_HEARTS.length]
|
||||
: null
|
||||
const shooting = shotElapsed !== null
|
||||
const pixelMode = pixelModeActive(companion.species)
|
||||
|
||||
// Shared frame selection for both render modes.
|
||||
let idleStep = 0
|
||||
let blink = false
|
||||
if (!reducedMotion && !shooting) {
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
idleStep = tick % frameCount
|
||||
} else {
|
||||
spriteFrame = step % frameCount;
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
if (step === -1) {
|
||||
blink = true
|
||||
} else {
|
||||
idleStep = step % frameCount
|
||||
}
|
||||
}
|
||||
}
|
||||
const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line);
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
||||
|
||||
let pixelRows: PixelRun[][] | null = null
|
||||
let sprite: string[] = []
|
||||
if (pixelMode) {
|
||||
const idleCount = pixelIdleFrameCount(companion.species)
|
||||
if (shooting && actionFx !== undefined) {
|
||||
pixelRows = renderPixelSprite(
|
||||
companion.species,
|
||||
drawPoseIndex(
|
||||
actionFx.drawMs,
|
||||
shotElapsed,
|
||||
pixelShootFrameCount(companion.species),
|
||||
),
|
||||
'shoot',
|
||||
)
|
||||
} else if (!reducedMotion && (reaction || petting)) {
|
||||
// Excited: cycle the pixel frames directly — mapping the 3-step
|
||||
// line-art cycle through pixelIdleFrame would freeze 2-frame heroes
|
||||
// on their rest pose.
|
||||
pixelRows = renderPixelSprite(companion.species, tick % idleCount, 'idle')
|
||||
} else {
|
||||
pixelRows = renderPixelSprite(
|
||||
companion.species,
|
||||
pixelIdleFrame(idleStep, blink, idleCount),
|
||||
'idle',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let body: string[]
|
||||
if (shooting && actionFx !== undefined) {
|
||||
// Draw/cast poses; clamps to the release pose while the effect flies.
|
||||
body = renderShootSprite(
|
||||
companion,
|
||||
drawPoseIndex(
|
||||
actionFx.drawMs,
|
||||
shotElapsed,
|
||||
shootFrameCount(companion.species),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
body = renderSprite(companion, idleStep).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
}
|
||||
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}>
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
{pixelRows !== null ? (
|
||||
<>
|
||||
{heartFrame && <Text color="autoAccept">{heartFrame}</Text>}
|
||||
<PixelRows rows={pixelRows} />
|
||||
</>
|
||||
) : (
|
||||
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>;
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
@@ -281,90 +458,49 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// 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 paddingX={1}>{spriteColumn}</Box>
|
||||
}
|
||||
return <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>;
|
||||
}
|
||||
</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;
|
||||
export const CompanionFloatingBubble = React.memo(
|
||||
function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings?.prefersReducedMotion === true
|
||||
const [, time] = useAnimationFrame(
|
||||
reaction && !reducedMotion ? TICK_MS : null,
|
||||
)
|
||||
const [{ start, forReaction }, setStart] = useState({
|
||||
start: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
if (reaction !== forReaction) {
|
||||
setTick({
|
||||
tick: 0,
|
||||
forReaction: reaction
|
||||
});
|
||||
setStart({ start: time, 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 (!isBuddyEnabled() || !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;
|
||||
}
|
||||
if (!isBuddyEnabled() || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
const fading =
|
||||
!reducedMotion &&
|
||||
reaction === forReaction &&
|
||||
time - start >= (BUBBLE_SHOW - FADE_WINDOW) * TICK_MS
|
||||
return (
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={companionColor(companion)}
|
||||
fading={fading}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
ACTION_EFFECTS,
|
||||
effectTotalMs,
|
||||
getActionEffect,
|
||||
} from './actionEffects.js'
|
||||
import { SPECIES, type Species } from './types.js'
|
||||
|
||||
const WIDTH = 80
|
||||
|
||||
describe('action effects', () => {
|
||||
test('every hero form has a signature effect', () => {
|
||||
for (const species of SPECIES) {
|
||||
expect(getActionEffect(species as Species)).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
for (const [species, fx] of Object.entries(ACTION_EFFECTS)) {
|
||||
describe(species, () => {
|
||||
test('phases are sane', () => {
|
||||
expect(fx.drawMs).toBeGreaterThan(0)
|
||||
expect(fx.travelMs).toBeGreaterThan(0)
|
||||
expect(fx.impactMs).toBeGreaterThanOrEqual(0)
|
||||
expect(effectTotalMs(fx)).toBe(fx.drawMs + fx.travelMs + fx.impactMs)
|
||||
})
|
||||
|
||||
test('rows sum to exactly the row width at every 50ms sample', () => {
|
||||
for (
|
||||
let t = 0;
|
||||
t < fx.travelMs + fx.impactMs;
|
||||
t += 50
|
||||
) {
|
||||
const runs = fx.render(t, WIDTH)
|
||||
if (runs === null) continue
|
||||
const total = runs.reduce((n, r) => n + r.text.length, 0)
|
||||
if (total !== WIDTH) {
|
||||
throw new Error(`${species} at t=${t}: row is ${total} wide`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('finishes: null after travel+impact', () => {
|
||||
expect(fx.render(fx.travelMs + fx.impactMs, WIDTH)).toBeNull()
|
||||
expect(fx.render(fx.travelMs + fx.impactMs + 500, WIDTH)).toBeNull()
|
||||
})
|
||||
|
||||
test('too-narrow rows render nothing', () => {
|
||||
expect(fx.render(0, 2)).toBeNull()
|
||||
})
|
||||
|
||||
test('colors are rgb() strings', () => {
|
||||
for (let t = 0; t < fx.travelMs; t += 100) {
|
||||
for (const run of fx.render(t, WIDTH) ?? []) {
|
||||
if (run.color !== undefined) {
|
||||
expect(run.color.startsWith('rgb(')).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('projectile heads travel right→left where applicable', () => {
|
||||
// The arrow, shuriken, and cannonball have a single distinct head glyph
|
||||
// whose position must be non-increasing over time.
|
||||
for (const [species, head] of [
|
||||
['robinhood', '←'],
|
||||
['kage', null], // glyph rotates — use first non-space run position
|
||||
['corsair', '●'],
|
||||
] as const) {
|
||||
const fx = ACTION_EFFECTS[species as Species]!
|
||||
let prev = Number.POSITIVE_INFINITY
|
||||
for (let t = 0; t < fx.travelMs; t += 50) {
|
||||
const runs = fx.render(t, WIDTH)!
|
||||
let pos = 0
|
||||
for (const run of runs) {
|
||||
if (run.color !== undefined && run.text.trim() !== '') break
|
||||
pos += run.text.length
|
||||
}
|
||||
if (head !== null) {
|
||||
const row = runs.map(r => r.text).join('')
|
||||
expect(row.indexOf(head)).toBe(pos)
|
||||
}
|
||||
expect(pos).toBeLessThanOrEqual(prev)
|
||||
prev = pos
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('strawhat punch extends then retracts', () => {
|
||||
const fx = ACTION_EFFECTS['strawhat' as Species]!
|
||||
const posAt = (t: number): number => {
|
||||
const runs = fx.render(t, WIDTH)!
|
||||
let pos = 0
|
||||
for (const run of runs) {
|
||||
if (run.color !== undefined && run.text.trim() !== '') break
|
||||
pos += run.text.length
|
||||
}
|
||||
return pos
|
||||
}
|
||||
const mid = fx.travelMs / 2
|
||||
expect(posAt(mid - 50)).toBeLessThan(posAt(0)) // extending left
|
||||
expect(posAt(fx.travelMs - 50)).toBeGreaterThan(posAt(mid + 50)) // snapping back
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
// Signature per-hero action effects: pure functions from (elapsed ms, row
|
||||
// width) to a row of colored runs. Rendered by CompanionActionFX in the
|
||||
// transient row above the prompt while the sprite plays its shoot/cast
|
||||
// poses. All effects travel right→left (the companion stands at the right
|
||||
// edge of the screen) and finish with a short impact/fade phase.
|
||||
//
|
||||
// Pure and React-free so every effect is unit-testable frame by frame.
|
||||
|
||||
import { clamp } from '../ink/layout/geometry.js'
|
||||
import type { Species } from './types.js'
|
||||
import {
|
||||
corsair,
|
||||
ember,
|
||||
kage,
|
||||
kaio,
|
||||
merlin,
|
||||
robinhood,
|
||||
strawhat,
|
||||
} from './types.js'
|
||||
|
||||
export type FxRun = {
|
||||
text: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type ActionEffect = {
|
||||
/** Sprite draw-pose phase before the projectile appears. */
|
||||
drawMs: number
|
||||
/** Projectile/beam phase length. */
|
||||
travelMs: number
|
||||
/** Impact/fade phase after travel. */
|
||||
impactMs: number
|
||||
/** Runs for the FX row at elapsed ms since the TRAVEL phase began
|
||||
* (impact phase elapsed continues past travelMs), or null for an
|
||||
* empty row. Width is the full FX row width. */
|
||||
render: (elapsedMs: number, width: number) => FxRun[] | null
|
||||
}
|
||||
|
||||
export function effectTotalMs(fx: ActionEffect): number {
|
||||
return fx.drawMs + fx.travelMs + fx.impactMs
|
||||
}
|
||||
|
||||
// Shared palette for effects (raw rgb() strings — the FX row renders with
|
||||
// the raw ink Text, same as the pixel sprites).
|
||||
const C = {
|
||||
wood: 'rgb(158,112,66)',
|
||||
steel: 'rgb(200,205,210)',
|
||||
smoke: 'rgb(120,120,120)',
|
||||
ember: 'rgb(255,140,40)',
|
||||
flameCore: 'rgb(255,240,200)',
|
||||
flameMid: 'rgb(255,170,60)',
|
||||
flameDim: 'rgb(180,70,30)',
|
||||
beamCore: 'rgb(245,250,255)',
|
||||
beamEdge: 'rgb(90,160,255)',
|
||||
spark: 'rgb(255,230,120)',
|
||||
sparkDim: 'rgb(190,150,220)',
|
||||
skin: 'rgb(232,190,152)',
|
||||
burst: 'rgb(255,210,80)',
|
||||
green: 'rgb(72,158,74)',
|
||||
}
|
||||
|
||||
/** Right→left position for a head glyph: returns the column of the LEFT
|
||||
* edge of a payload of `payloadWidth` at `fraction` (0 = right edge,
|
||||
* 1 = left edge). */
|
||||
function leftPos(fraction: number, width: number, payloadWidth: number): number {
|
||||
const span = Math.max(0, width - payloadWidth)
|
||||
return span - Math.min(span, Math.floor(clamp(fraction, 0, 1) * (span + 1)))
|
||||
}
|
||||
|
||||
function pad(n: number): FxRun {
|
||||
return { text: ' '.repeat(Math.max(0, n)) }
|
||||
}
|
||||
|
||||
/** Shared impact/fade at the left edge: `early` glyphs for the first half
|
||||
* of impactMs, `late` for the second, null when spent. */
|
||||
function impactBurst(
|
||||
impactElapsed: number,
|
||||
impactMs: number,
|
||||
width: number,
|
||||
color: string,
|
||||
early = '✺',
|
||||
late = '·',
|
||||
): FxRun[] | null {
|
||||
if (impactElapsed >= impactMs) return null
|
||||
const glyphs = impactElapsed < impactMs / 2 ? early : late
|
||||
return [{ text: glyphs, color }, pad(width - glyphs.length)]
|
||||
}
|
||||
|
||||
// ── Robin Hood: arrow with an impact thunk ─────────────────────────────
|
||||
const arrowFx: ActionEffect = {
|
||||
drawMs: 300,
|
||||
travelMs: 800,
|
||||
impactMs: 200,
|
||||
render(elapsed, width) {
|
||||
const glyph = '←──«'
|
||||
if (width < glyph.length + 1) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
return impactBurst(elapsed - this.travelMs, this.impactMs, width, C.wood)
|
||||
}
|
||||
const x = leftPos(elapsed / this.travelMs, width, glyph.length)
|
||||
return [pad(x), { text: glyph, color: C.green }, pad(width - x - glyph.length)]
|
||||
},
|
||||
}
|
||||
|
||||
// ── Kaio: charging orb, then a full-width energy wave ──────────────────
|
||||
const ORB_FRAMES = ['∘', '○', '◎', '●']
|
||||
const kaioFx: ActionEffect = {
|
||||
drawMs: 500, // longer charge — the orb grows during this phase
|
||||
travelMs: 700,
|
||||
impactMs: 350,
|
||||
render(elapsed, width) {
|
||||
if (width < 8) return null
|
||||
if (elapsed < 0) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
// Crackle fade: residual energy at the left edge.
|
||||
return impactBurst(
|
||||
elapsed - this.travelMs,
|
||||
this.impactMs,
|
||||
width,
|
||||
C.beamEdge,
|
||||
'✺✧·',
|
||||
'· ·',
|
||||
)
|
||||
}
|
||||
// The beam FRONT advances right→left; behind it the beam is solid:
|
||||
// edge▒core█core▒edge, drawn from the front to the right edge.
|
||||
const front = leftPos(elapsed / this.travelMs, width, 1)
|
||||
const beamLen = width - front
|
||||
if (beamLen <= 0) return null
|
||||
const runs: FxRun[] = [pad(front)]
|
||||
// Head of the beam is the white-hot core; body alternates core/edge
|
||||
// for a crackling look that shimmers as the front advances.
|
||||
const head = Math.min(2, beamLen)
|
||||
runs.push({ text: '█'.repeat(head), color: C.beamCore })
|
||||
let remaining = beamLen - head
|
||||
let block = 3
|
||||
let bright = false
|
||||
while (remaining > 0) {
|
||||
const n = Math.min(block, remaining)
|
||||
runs.push({
|
||||
text: (bright ? '█' : '▓').repeat(n),
|
||||
color: bright ? C.beamCore : C.beamEdge,
|
||||
})
|
||||
remaining -= n
|
||||
bright = !bright
|
||||
block = 4
|
||||
}
|
||||
return runs
|
||||
},
|
||||
}
|
||||
|
||||
// ── Strawhat: stretchy punch — extends, hits, snaps back ───────────────
|
||||
const strawhatFx: ActionEffect = {
|
||||
drawMs: 300,
|
||||
travelMs: 900, // 0..450 extend, 450..900 retract
|
||||
impactMs: 0, // the hit happens mid-travel, not after
|
||||
render(elapsed, width) {
|
||||
if (width < 4) return null
|
||||
if (elapsed >= this.travelMs) return null
|
||||
const half = this.travelMs / 2
|
||||
const extendFraction =
|
||||
elapsed < half ? elapsed / half : (this.travelMs - elapsed) / half
|
||||
const fist = '●'
|
||||
const x = leftPos(extendFraction, width, fist.length)
|
||||
const armLen = Math.max(0, width - x - fist.length)
|
||||
const hit = elapsed >= half - 60 && elapsed < half + 120
|
||||
const runs: FxRun[] = []
|
||||
if (hit && x > 0) {
|
||||
runs.push(pad(x - 1), { text: '✺', color: C.burst })
|
||||
} else {
|
||||
runs.push(pad(x))
|
||||
}
|
||||
runs.push({ text: fist, color: C.skin })
|
||||
if (armLen > 0) {
|
||||
runs.push({ text: '━'.repeat(armLen), color: C.skin })
|
||||
}
|
||||
return runs
|
||||
},
|
||||
}
|
||||
|
||||
// ── Merlin: twinkling sparkle stream with a starburst ──────────────────
|
||||
const SPARKLES = ['✦', '✧', '·', '˚']
|
||||
const merlinFx: ActionEffect = {
|
||||
drawMs: 300,
|
||||
travelMs: 800,
|
||||
impactMs: 250,
|
||||
render(elapsed, width) {
|
||||
if (width < 6) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
return impactBurst(
|
||||
elapsed - this.travelMs,
|
||||
this.impactMs,
|
||||
width,
|
||||
C.spark,
|
||||
'✺✧',
|
||||
'˚·',
|
||||
)
|
||||
}
|
||||
// A comet of sparkles: head advances, trail twinkles behind it.
|
||||
const head = leftPos(elapsed / this.travelMs, width, 1)
|
||||
const phase = Math.floor(elapsed / 100)
|
||||
const runs: FxRun[] = [pad(head)]
|
||||
const trailLen = Math.min(10, width - head - 1)
|
||||
runs.push({ text: '✦', color: C.spark })
|
||||
let cells = ''
|
||||
for (let i = 0; i < trailLen; i++) {
|
||||
cells += i % 2 === phase % 2 ? SPARKLES[(i + phase) % SPARKLES.length]! : ' '
|
||||
}
|
||||
if (cells) runs.push({ text: cells, color: C.sparkDim })
|
||||
runs.push(pad(width - head - 1 - trailLen))
|
||||
return runs
|
||||
},
|
||||
}
|
||||
|
||||
// ── Kage: spinning shuriken ─────────────────────────────────────────────
|
||||
const SHURIKEN = ['✕', '✖', '✦', '✖']
|
||||
const kageFx: ActionEffect = {
|
||||
drawMs: 200, // ninjas are fast
|
||||
travelMs: 600,
|
||||
impactMs: 200,
|
||||
render(elapsed, width) {
|
||||
if (width < 3) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
return impactBurst(elapsed - this.travelMs, this.impactMs, width, C.steel)
|
||||
}
|
||||
const glyph = SHURIKEN[Math.floor(elapsed / 80) % SHURIKEN.length]!
|
||||
const x = leftPos(elapsed / this.travelMs, width, 1)
|
||||
return [pad(x), { text: glyph, color: C.steel }, pad(width - x - 1)]
|
||||
},
|
||||
}
|
||||
|
||||
// ── Ember: fire cone with warm gradient, scorches out ──────────────────
|
||||
const emberFx: ActionEffect = {
|
||||
drawMs: 300,
|
||||
travelMs: 750,
|
||||
impactMs: 300,
|
||||
render(elapsed, width) {
|
||||
if (width < 8) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
return impactBurst(
|
||||
elapsed - this.travelMs,
|
||||
this.impactMs,
|
||||
width,
|
||||
C.flameDim,
|
||||
'▒░·',
|
||||
'· ·',
|
||||
)
|
||||
}
|
||||
// Flame cone: hot core at the front, cooling tail behind it. The cone
|
||||
// detaches from the mouth and travels (length capped).
|
||||
const front = leftPos(elapsed / this.travelMs, width, 1)
|
||||
const coneLen = Math.min(12, width - front)
|
||||
const runs: FxRun[] = [pad(front)]
|
||||
for (let i = 0; i < coneLen; i++) {
|
||||
const heat = i / coneLen
|
||||
runs.push(
|
||||
heat < 0.25
|
||||
? { text: '█', color: C.flameCore }
|
||||
: heat < 0.6
|
||||
? { text: '▓', color: C.flameMid }
|
||||
: { text: '▒', color: C.flameDim },
|
||||
)
|
||||
}
|
||||
runs.push(pad(width - front - coneLen))
|
||||
return runs
|
||||
},
|
||||
}
|
||||
|
||||
// ── Corsair: cannonball with smoke trail ────────────────────────────────
|
||||
const corsairFx: ActionEffect = {
|
||||
drawMs: 300,
|
||||
travelMs: 650,
|
||||
impactMs: 300,
|
||||
render(elapsed, width) {
|
||||
if (width < 6) return null
|
||||
if (elapsed >= this.travelMs) {
|
||||
return impactBurst(
|
||||
elapsed - this.travelMs,
|
||||
this.impactMs,
|
||||
width,
|
||||
C.burst,
|
||||
'✺✺',
|
||||
'° ˚',
|
||||
)
|
||||
}
|
||||
const x = leftPos(elapsed / this.travelMs, width, 1)
|
||||
const runs: FxRun[] = [pad(x), { text: '●', color: 'rgb(60,60,64)' }]
|
||||
const trailLen = Math.min(6, width - x - 1)
|
||||
if (trailLen > 0) {
|
||||
runs.push({
|
||||
text: '°˚· ˚·'.slice(0, trailLen),
|
||||
color: C.smoke,
|
||||
})
|
||||
}
|
||||
runs.push(pad(width - x - 1 - trailLen))
|
||||
return runs
|
||||
},
|
||||
}
|
||||
|
||||
export const ACTION_EFFECTS: Partial<Record<Species, ActionEffect>> = {
|
||||
[robinhood]: arrowFx,
|
||||
[kaio]: kaioFx,
|
||||
[strawhat]: strawhatFx,
|
||||
[merlin]: merlinFx,
|
||||
[kage]: kageFx,
|
||||
[ember]: emberFx,
|
||||
[corsair]: corsairFx,
|
||||
}
|
||||
|
||||
export function getActionEffect(species: Species): ActionEffect | undefined {
|
||||
return ACTION_EFFECTS[species]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterAll, describe, expect, mock, test } from 'bun:test'
|
||||
import type { StoredCompanion } from './types.js'
|
||||
import { SPECIES } from './types.js'
|
||||
|
||||
// Mock the config module with a COMPLETE config (spread the real one) —
|
||||
// bun's mock.module leaks across files in the same process, so a partial
|
||||
// mock silently breaks unrelated suites (see repo testing conventions).
|
||||
// The cache-busting query loads a second, unmocked instance of the module;
|
||||
// spreading the plain specifier would capture the mock and recurse.
|
||||
const actualConfig = await import(`../utils/config.js?real=${Date.now()}`)
|
||||
let mockCompanion: StoredCompanion | undefined
|
||||
|
||||
mock.module('../utils/config.js', () => ({
|
||||
...actualConfig,
|
||||
getGlobalConfig: () => ({
|
||||
...actualConfig.getGlobalConfig(),
|
||||
userID: 'buddy-test-user',
|
||||
oauthAccount: undefined,
|
||||
companion: mockCompanion,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { getCompanion, rollWithSeed } = await import('./companion.js')
|
||||
|
||||
afterAll(() => {
|
||||
// mock.restore() does NOT undo mock.module() — re-register the real module
|
||||
// so the stub can't bleed into later test files in the same process.
|
||||
mock.module('../utils/config.js', () => actualConfig)
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
const SOUL: StoredCompanion = {
|
||||
name: 'Testbud',
|
||||
personality: 'Test personality.',
|
||||
hatchedAt: 1,
|
||||
}
|
||||
|
||||
describe('getCompanion speciesOverride', () => {
|
||||
test('no override → deterministically rolled hero from the pool', () => {
|
||||
mockCompanion = SOUL
|
||||
const base = getCompanion()!
|
||||
expect(SPECIES as readonly string[]).toContain(base.species)
|
||||
// Deterministic: same user id → same species on every read.
|
||||
expect(getCompanion()!.species).toBe(base.species)
|
||||
})
|
||||
|
||||
test('override changes species but not rarity/stats/eye', () => {
|
||||
mockCompanion = SOUL
|
||||
const base = getCompanion()!
|
||||
// Pick a hero that differs from the rolled one so the change is visible.
|
||||
const target = SPECIES.find(s => s !== base.species)!
|
||||
mockCompanion = { ...SOUL, speciesOverride: target }
|
||||
const overridden = getCompanion()!
|
||||
expect(overridden.species).toBe(target)
|
||||
expect(overridden.rarity).toBe(base.rarity)
|
||||
expect(overridden.eye).toBe(base.eye)
|
||||
expect(overridden.stats).toEqual(base.stats)
|
||||
expect(overridden.name).toBe(SOUL.name)
|
||||
})
|
||||
|
||||
test('garbage override is ignored and falls back to the rolled species', () => {
|
||||
mockCompanion = SOUL
|
||||
const base = getCompanion()!
|
||||
mockCompanion = {
|
||||
...SOUL,
|
||||
speciesOverride: 'unicorn' as StoredCompanion['speciesOverride'],
|
||||
}
|
||||
expect(getCompanion()!.species).toBe(base.species)
|
||||
})
|
||||
|
||||
test('no companion stored → undefined regardless of override plumbing', () => {
|
||||
mockCompanion = undefined
|
||||
expect(getCompanion()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deterministic roll pool', () => {
|
||||
test('rollWithSeed always lands in the hero pool and is stable per seed', () => {
|
||||
for (const seed of ['a', 'b', 'c', 'buddy', 'seed-42', 'kevin']) {
|
||||
const { bones } = rollWithSeed(seed)
|
||||
expect(SPECIES as readonly string[]).toContain(bones.species)
|
||||
expect(rollWithSeed(seed).bones.species).toBe(bones.species)
|
||||
}
|
||||
})
|
||||
})
|
||||
+28
-4
@@ -12,7 +12,7 @@ import {
|
||||
type StatName,
|
||||
} from './types.js'
|
||||
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking heroes
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return function () {
|
||||
@@ -124,10 +124,34 @@ export function companionUserId(): string {
|
||||
// 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.
|
||||
// getCompanion is called per render from CompanionSprite, CompanionActionFX,
|
||||
// and companionReservedColumns (per keystroke via PromptInput) — cache the
|
||||
// merged object keyed on the stored companion's identity. getGlobalConfig
|
||||
// returns a new object per save, so a config write invalidates naturally.
|
||||
let companionCache: { stored: object; userId: string; value: Companion } | undefined
|
||||
|
||||
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 }
|
||||
const userId = companionUserId()
|
||||
if (companionCache?.stored === stored && companionCache.userId === userId) {
|
||||
return companionCache.value
|
||||
}
|
||||
const { bones } = roll(userId)
|
||||
// bones last so stale bones fields in old-format configs get overridden.
|
||||
// speciesOverride (a validated /buddy set choice) wins over the rolled
|
||||
// species only — rarity/stats/eye stay rolled. Validate against
|
||||
// SPECIES because config files can contain arbitrary values.
|
||||
const override =
|
||||
stored.speciesOverride !== undefined &&
|
||||
(SPECIES as readonly string[]).includes(stored.speciesOverride)
|
||||
? stored.speciesOverride
|
||||
: undefined
|
||||
const value: Companion = {
|
||||
...stored,
|
||||
...bones,
|
||||
...(override !== undefined ? { species: override } : {}),
|
||||
}
|
||||
companionCache = { stored, userId, value }
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Shared FNV-1a hash + deterministic pick for buddy flavor content (names,
|
||||
// canned reactions). companion.ts keeps its OWN hash with a Bun fast path —
|
||||
// its output seeds the persisted companion roll and must never change; these
|
||||
// helpers only seed ephemeral or hatch-time flavor picks.
|
||||
|
||||
export function fnvHash(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
export function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||
return items[fnvHash(seed) % items.length]!
|
||||
}
|
||||
+1
-13
@@ -2,6 +2,7 @@ import type { Message } from '../types/message.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getUserMessageText } from '../utils/messages.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { pickDeterministic } from './deterministic.js'
|
||||
|
||||
const DIRECT_REPLIES = [
|
||||
'I am observing.',
|
||||
@@ -19,19 +20,6 @@ const PET_REPLIES = [
|
||||
'looks pleased',
|
||||
] as const
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||
return items[hashString(seed) % items.length]!
|
||||
}
|
||||
|
||||
export async function fireCompanionObserver(
|
||||
messages: Message[],
|
||||
onReaction: (reaction: string | undefined) => void,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
_allPixelFramesForTesting,
|
||||
_paletteCharsForTesting,
|
||||
hasPixelSprite,
|
||||
PIXEL_WIDTH,
|
||||
pixelIdleFrameCount,
|
||||
pixelShootFrameCount,
|
||||
renderPixelSprite,
|
||||
} from './pixelSprites.js'
|
||||
import { SPECIES, robinhood, type Species } from './types.js'
|
||||
|
||||
const PIXEL_HEIGHT = 16
|
||||
|
||||
// Every hero form ships pixel art; robinhood has 3 idle frames, the rest 2.
|
||||
const EXPECTED_FRAME_COUNTS: Record<string, number> = Object.fromEntries(
|
||||
SPECIES.map(s => [s, s === robinhood ? 6 : 5]),
|
||||
)
|
||||
|
||||
describe('pixel sprite frames', () => {
|
||||
test('every hero frame is a full 22x16 grid of palette chars', () => {
|
||||
const palette = _paletteCharsForTesting()
|
||||
for (const species of SPECIES) {
|
||||
expect(hasPixelSprite(species)).toBe(true)
|
||||
// Idle and shoot counts asserted separately — the animation consumes
|
||||
// each set independently and clamps silently, so a combined total
|
||||
// would permit an invalid split.
|
||||
expect(pixelIdleFrameCount(species)).toBe(species === robinhood ? 3 : 2)
|
||||
expect(pixelShootFrameCount(species)).toBe(3)
|
||||
const frames = _allPixelFramesForTesting(species)
|
||||
expect(frames).toBeDefined()
|
||||
expect(frames!.length).toBe(EXPECTED_FRAME_COUNTS[species]!)
|
||||
for (const [fi, frame] of frames!.entries()) {
|
||||
expect(frame.length).toBe(PIXEL_HEIGHT)
|
||||
for (const [ri, row] of frame.entries()) {
|
||||
if (row.length !== PIXEL_WIDTH) {
|
||||
throw new Error(
|
||||
`${species} frame ${fi} row ${ri} is ${row.length} wide: "${row}"`,
|
||||
)
|
||||
}
|
||||
for (const ch of row) {
|
||||
if (!palette.has(ch)) {
|
||||
throw new Error(
|
||||
`${species} frame ${fi} row ${ri} has non-palette char "${ch}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('renderPixelSprite', () => {
|
||||
test('produces 8 rows whose runs sum to exactly 22 columns for every hero', () => {
|
||||
for (const species of SPECIES) {
|
||||
for (const mode of ['idle', 'shoot'] as const) {
|
||||
for (const frame of [0, 1, 2]) {
|
||||
const rows = renderPixelSprite(species as Species, frame, mode)
|
||||
expect(rows).not.toBeNull()
|
||||
expect(rows!.length).toBe(PIXEL_HEIGHT / 2)
|
||||
for (const runs of rows!) {
|
||||
const width = runs.reduce((n, r) => n + r.text.length, 0)
|
||||
expect(width).toBe(PIXEL_WIDTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('clamps out-of-range frames instead of crashing', () => {
|
||||
expect(renderPixelSprite(robinhood, 99, 'idle')).toEqual(
|
||||
renderPixelSprite(robinhood, 2, 'idle'),
|
||||
)
|
||||
expect(renderPixelSprite(robinhood, -1, 'shoot')).toEqual(
|
||||
renderPixelSprite(robinhood, 0, 'shoot'),
|
||||
)
|
||||
})
|
||||
|
||||
test('runs carry rgb() colors only, never raw palette letters', () => {
|
||||
const rows = renderPixelSprite(robinhood, 0, 'idle')!
|
||||
let colored = 0
|
||||
for (const runs of rows) {
|
||||
for (const run of runs) {
|
||||
if (run.color !== undefined) {
|
||||
colored++
|
||||
expect(run.color!.startsWith('rgb(')).toBe(true)
|
||||
}
|
||||
if (run.backgroundColor !== undefined) {
|
||||
expect(run.backgroundColor!.startsWith('rgb(')).toBe(true)
|
||||
}
|
||||
expect(/^[▀▄ ]+$/.test(run.text)).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(colored).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,869 @@
|
||||
import chalk from 'chalk'
|
||||
import type { Species } from './types.js'
|
||||
import {
|
||||
corsair,
|
||||
ember,
|
||||
kage,
|
||||
kaio,
|
||||
merlin,
|
||||
robinhood,
|
||||
strawhat,
|
||||
} from './types.js'
|
||||
|
||||
// Truecolor half-block pixel sprites. Each terminal cell renders two
|
||||
// vertically stacked pixels via '▀' (fg = top pixel, bg = bottom pixel),
|
||||
// so a 22×16-pixel sprite occupies 22 columns × 8 rows. chalk degrades
|
||||
// rgb() to 256/16-color automatically at lower levels; below level 2 the
|
||||
// art would render as uncolored blocks, so callers must gate on
|
||||
// isPixelColorCapable() and fall back to the line-art sprites.
|
||||
//
|
||||
// Frames are palette-indexed strings: one char per pixel, '.' = transparent.
|
||||
// All rows in a frame must be exactly PIXEL_WIDTH chars.
|
||||
|
||||
export const PIXEL_WIDTH = 22
|
||||
const PIXEL_HEIGHT = 16
|
||||
|
||||
const PALETTE: Record<string, string> = {
|
||||
G: 'rgb(34,102,51)', // cap / tunic dark green
|
||||
g: 'rgb(72,158,74)', // tunic highlight green
|
||||
R: 'rgb(204,51,51)', // feather / vest / scarf red
|
||||
S: 'rgb(232,190,152)', // skin
|
||||
s: 'rgb(198,148,110)', // skin shadow
|
||||
E: 'rgb(38,34,32)', // eye
|
||||
H: 'rgb(122,82,46)', // hair brown
|
||||
B: 'rgb(112,74,40)', // bow / belt / boots brown
|
||||
b: 'rgb(158,112,66)', // quiver / bow highlight
|
||||
Y: 'rgb(214,168,60)', // gold trim / buckle
|
||||
W: 'rgb(240,240,240)', // eye glint / bowstring / white
|
||||
A: 'rgb(255,220,80)', // super-warrior gold hair / aura
|
||||
U: 'rgb(70,120,230)', // blue belt / boots / shorts
|
||||
w: 'rgb(235,238,245)', // white cloth (gi, shirt, beard)
|
||||
T: 'rgb(230,200,90)', // straw hat
|
||||
K: 'rgb(40,38,36)', // black hair / eyepatch
|
||||
P: 'rgb(120,70,190)', // deep purple robe/hat
|
||||
p: 'rgb(165,115,230)', // light purple highlight
|
||||
X: 'rgb(200,205,210)', // steel / silver
|
||||
N: 'rgb(70,74,82)', // ninja gi grey
|
||||
n: 'rgb(50,53,60)', // ninja gi dark
|
||||
D: 'rgb(190,50,40)', // dragon red dark
|
||||
d: 'rgb(235,90,60)', // dragon red bright
|
||||
C: 'rgb(245,220,170)', // cream belly / horns
|
||||
V: 'rgb(40,60,110)', // navy coat / tricorn
|
||||
O: 'rgb(255,140,40)', // flame orange accent
|
||||
}
|
||||
|
||||
// Robin Hood faces LEFT (toward the prompt — the sprite sits on the right
|
||||
// edge of the screen and arrows fly right→left). 22×16 pixels.
|
||||
//
|
||||
// Idle frame 0 — at rest, bow slung on the left arm.
|
||||
const ROBIN_IDLE_0 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG.RR....',
|
||||
'....GGgggggggGGRRR....',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEWSSSEWSSs.......',
|
||||
'....SSSSSSSSSSs.......',
|
||||
'.....sSSSSSSs.........',
|
||||
'..B...GGggGGg.........',
|
||||
'..B..GgggggggG.bb.....',
|
||||
'..W..GgggggggG.bb.....',
|
||||
'..B...GBBYBBG..bb.....',
|
||||
'..B...GGgggGG.........',
|
||||
'.......GG.GG..........',
|
||||
'.......GG.GG..........',
|
||||
'......BBB.BBB.........',
|
||||
]
|
||||
|
||||
// Idle frame 1 — blink (eyes closed to a line).
|
||||
const ROBIN_IDLE_1 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG.RR....',
|
||||
'....GGgggggggGGRRR....',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEESSSEESSs.......',
|
||||
'....SSSSSSSSSSs.......',
|
||||
'.....sSSSSSSs.........',
|
||||
'..B...GGggGGg.........',
|
||||
'..B..GgggggggG.bb.....',
|
||||
'..W..GgggggggG.bb.....',
|
||||
'..B...GBBYBBG..bb.....',
|
||||
'..B...GGgggGG.........',
|
||||
'.......GG.GG..........',
|
||||
'.......GG.GG..........',
|
||||
'......BBB.BBB.........',
|
||||
]
|
||||
|
||||
// Idle frame 2 — feather flutters, slight weight shift.
|
||||
const ROBIN_IDLE_2 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG..RR...',
|
||||
'....GGgggggggGG.RRR...',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEWSSSEWSSs.......',
|
||||
'....SSSSSSSSSSs.......',
|
||||
'.....sSSSSSSs.........',
|
||||
'..B...GGggGGg.........',
|
||||
'..B..GgggggggG.bb.....',
|
||||
'..W..GgggggggG.bb.....',
|
||||
'..B...GBBYBBG..bb.....',
|
||||
'..B...GGgggGG.........',
|
||||
'......GG...GG.........',
|
||||
'......GG...GG.........',
|
||||
'.....BBB..BBB.........',
|
||||
]
|
||||
|
||||
// Shoot frame 0 — nock: bow arm raises toward the left, arrow on string.
|
||||
const ROBIN_SHOOT_0 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG.RR....',
|
||||
'....GGgggggggGGRRR....',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEWSSSEWSSs.......',
|
||||
'....SSSSSSSSSSs.......',
|
||||
'.....sSSSSSSs.........',
|
||||
'.B....GGggGGg.........',
|
||||
'.B.SSGgggggggG.bb.....',
|
||||
'.W.SSGgggggggG.bb.....',
|
||||
'.B....GBBYBBG..bb.....',
|
||||
'.B....GGgggGG.........',
|
||||
'.......GG.GG..........',
|
||||
'.......GG.GG..........',
|
||||
'......BBB.BBB.........',
|
||||
]
|
||||
|
||||
// Shoot frame 1 — full draw: string pulled to the cheek, arrow level.
|
||||
const ROBIN_SHOOT_1 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG.RR....',
|
||||
'....GGgggggggGGRRR....',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEWSSSEWSSs.......',
|
||||
'B...SSSSSSSSSSs.......',
|
||||
'B....sSSSSSSs.........',
|
||||
'B.WBBBBSSWWWWs........',
|
||||
'B.W..GgggggggG.bb.....',
|
||||
'B.W..GgggggggG.bb.....',
|
||||
'B.....GBBYBBG..bb.....',
|
||||
'B.....GGgggGG.........',
|
||||
'.......GG.GG..........',
|
||||
'.......GG.GG..........',
|
||||
'......BBB.BBB.........',
|
||||
]
|
||||
|
||||
// Shoot frame 2 — loose: string forward, arm extended, arrow gone.
|
||||
const ROBIN_SHOOT_2 = [
|
||||
'.......GGGGGG.........',
|
||||
'.....GGggggggGG.RR....',
|
||||
'....GGgggggggGGRRR....',
|
||||
'...GGGGGGGGGGGGGR.....',
|
||||
'.....HSSSSSSSH........',
|
||||
'....SEWSSSEWSSs.......',
|
||||
'....SSSSSSSSSSs.......',
|
||||
'.....sSSSSSSs.........',
|
||||
'BW.SSsGGggGGg.........',
|
||||
'BW...GgggggggG.bb.....',
|
||||
'BW...GgggggggG.bb.....',
|
||||
'B.....GBBYBBG..bb.....',
|
||||
'B.....GGgggGG.........',
|
||||
'.......GG.GG..........',
|
||||
'.......GG.GG..........',
|
||||
'......BBB.BBB.........',
|
||||
]
|
||||
|
||||
// ── Kaio: gold spiky hair, white gi, blue belt/boots ────────────────────
|
||||
const KAIO_IDLE_0 = [
|
||||
'.....A..A..A..........',
|
||||
'.....AAAAAAAA.........',
|
||||
'....AAAAAAAAAA........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....wwwwwwwwww........',
|
||||
'...wwwwwwwwwwww.......',
|
||||
'...ww.wwwwww.ww.......',
|
||||
'...SS.UUUUUU.SS.......',
|
||||
'.....wwwwwwww.........',
|
||||
'.....ww....ww.........',
|
||||
'.....ww....ww.........',
|
||||
'....UUU....UUU........',
|
||||
'......................',
|
||||
]
|
||||
const KAIO_IDLE_1 = [
|
||||
'.....A..A..A..........',
|
||||
'.....AAAAAAAA.........',
|
||||
'....AAAAAAAAAA........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEESSSEESs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....wwwwwwwwww........',
|
||||
'...wwwwwwwwwwww.......',
|
||||
'...ww.wwwwww.ww.......',
|
||||
'...SS.UUUUUU.SS.......',
|
||||
'.....wwwwwwww.........',
|
||||
'.....ww....ww.........',
|
||||
'.....ww....ww.........',
|
||||
'....UUU....UUU........',
|
||||
'......................',
|
||||
]
|
||||
// Charge: hands come together at the left, aura flickers.
|
||||
const KAIO_SHOOT_0 = [
|
||||
'....A..A..A..A........',
|
||||
'.....AAAAAAAA.........',
|
||||
'....AAAAAAAAAA........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'...wwwwwwwwwww........',
|
||||
'..wwwwwwwwwwwww.......',
|
||||
'..SSwwwwwwww.ww.......',
|
||||
'..SS.UUUUUUU.SS.......',
|
||||
'.....wwwwwwww.........',
|
||||
'.....ww....ww.........',
|
||||
'.....ww....ww.........',
|
||||
'....UUU....UUU........',
|
||||
'......................',
|
||||
]
|
||||
const KAIO_SHOOT_1 = [
|
||||
'..A..A..A..A..A.......',
|
||||
'....AAAAAAAAA.........',
|
||||
'...AAAAAAAAAAA........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'..AAwwwwwwwwww........',
|
||||
'.AASSwwwwwwwwww.......',
|
||||
'.AASSwwwwwww.ww.......',
|
||||
'..AA.UUUUUUU.SS.......',
|
||||
'.....wwwwwwww.........',
|
||||
'.....ww....ww.........',
|
||||
'.....ww....ww.........',
|
||||
'....UUU....UUU........',
|
||||
'......................',
|
||||
]
|
||||
// Release: arms extended left, beam hands off to the FX row.
|
||||
const KAIO_SHOOT_2 = [
|
||||
'....A..A..A..A........',
|
||||
'.....AAAAAAAA.........',
|
||||
'....AAAAAAAAAA........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'SSSSwwwwwwwwww........',
|
||||
'SSSSwwwwwwwwwww.......',
|
||||
'....wwwwwwww.ww.......',
|
||||
'.....UUUUUUU.SS.......',
|
||||
'.....wwwwwwww.........',
|
||||
'.....ww....ww.........',
|
||||
'.....ww....ww.........',
|
||||
'....UUU....UUU........',
|
||||
'......................',
|
||||
]
|
||||
|
||||
// ── Strawhat: straw hat with red band, red vest, blue shorts ────────────
|
||||
const STRAWHAT_IDLE_0 = [
|
||||
'......TTTTTT..........',
|
||||
'....TTTTTTTTTT........',
|
||||
'...TTTRRRRRRTTT.......',
|
||||
'..TTTTTTTTTTTTTT......',
|
||||
'.....KSSSSSSK.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....RRRRRRRRR.........',
|
||||
'...SSRRRRRRRSS........',
|
||||
'...SS.RRRRR.SS........',
|
||||
'.....UUUUUUU..........',
|
||||
'.....UU....UU.........',
|
||||
'.....SS....SS.........',
|
||||
'.....SS....SS.........',
|
||||
'....BBB....BBB........',
|
||||
]
|
||||
const STRAWHAT_IDLE_1 = [
|
||||
'......TTTTTT..........',
|
||||
'....TTTTTTTTTT........',
|
||||
'...TTTRRRRRRTTT.......',
|
||||
'..TTTTTTTTTTTTTT......',
|
||||
'.....KSSSSSSK.........',
|
||||
'....SEESSSEESs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....RRRRRRRRR.........',
|
||||
'...SSRRRRRRRSS........',
|
||||
'...SS.RRRRR.SS........',
|
||||
'.....UUUUUUU..........',
|
||||
'.....UU....UU.........',
|
||||
'.....SS....SS.........',
|
||||
'.....SS....SS.........',
|
||||
'....BBB....BBB........',
|
||||
]
|
||||
// Wind-up: arm pulls BACK (right) before the stretch punch.
|
||||
const STRAWHAT_SHOOT_0 = [
|
||||
'......TTTTTT..........',
|
||||
'....TTTTTTTTTT........',
|
||||
'...TTTRRRRRRTTT.......',
|
||||
'..TTTTTTTTTTTTTT......',
|
||||
'.....KSSSSSSK.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....RRRRRRRRR.........',
|
||||
'....RRRRRRRRSSSS......',
|
||||
'...SS.RRRRR.SSSS......',
|
||||
'.....UUUUUUU..........',
|
||||
'.....UU....UU.........',
|
||||
'.....SS....SS.........',
|
||||
'.....SS....SS.........',
|
||||
'....BBB....BBB........',
|
||||
]
|
||||
const STRAWHAT_SHOOT_1 = [
|
||||
'......TTTTTT..........',
|
||||
'....TTTTTTTTTT........',
|
||||
'...TTTRRRRRRTTT.......',
|
||||
'..TTTTTTTTTTTTTT......',
|
||||
'.....KSSSSSSK.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'..SSRRRRRRRRR.........',
|
||||
'SSSSRRRRRRRRSS........',
|
||||
'......RRRRR.SS........',
|
||||
'.....UUUUUUU..........',
|
||||
'.....UU....UU.........',
|
||||
'.....SS....SS.........',
|
||||
'.....SS....SS.........',
|
||||
'....BBB....BBB........',
|
||||
]
|
||||
const STRAWHAT_SHOOT_2 = [
|
||||
'......TTTTTT..........',
|
||||
'....TTTTTTTTTT........',
|
||||
'...TTTRRRRRRTTT.......',
|
||||
'..TTTTTTTTTTTTTT......',
|
||||
'.....KSSSSSSK.........',
|
||||
'....SEWSSSEWSs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'SSSSRRRRRRRRR.........',
|
||||
'....RRRRRRRRSS........',
|
||||
'......RRRRR.SS........',
|
||||
'.....UUUUUUU..........',
|
||||
'.....UU....UU.........',
|
||||
'.....SS....SS.........',
|
||||
'.....SS....SS.........',
|
||||
'....BBB....BBB........',
|
||||
]
|
||||
|
||||
// ── Merlin: purple wizard hat with gold star, beard, robe, staff ────────
|
||||
const MERLIN_IDLE_0 = [
|
||||
'.........PP...........',
|
||||
'........PPPP..........',
|
||||
'.......PPAPPP.........',
|
||||
'......PPPPPPPP........',
|
||||
'....PPPPPPPPPPPP......',
|
||||
'.....SSEWSSEWS........',
|
||||
'....wwSSSSSSSww.......',
|
||||
'....wwwwwwwwwww.......',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B.PPpppppPPP........',
|
||||
'..Y.PPpppppPPP........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B.PPPPPPPPPP........',
|
||||
'......................',
|
||||
]
|
||||
const MERLIN_IDLE_1 = [
|
||||
'.........PP...........',
|
||||
'........PPPP..........',
|
||||
'.......PPAPPP.........',
|
||||
'......PPPPPPPP........',
|
||||
'....PPPPPPPPPPPP......',
|
||||
'.....SSEESSEES........',
|
||||
'....wwSSSSSSSww.......',
|
||||
'....wwwwwwwwwww.......',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B.PPpppppPPP........',
|
||||
'..Y.PPpppppPPP........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B..PPPPPPPP.........',
|
||||
'..B.PPPPPPPPPP........',
|
||||
'......................',
|
||||
]
|
||||
// Cast: staff raises left, tip glows.
|
||||
const MERLIN_SHOOT_0 = [
|
||||
'.........PP...........',
|
||||
'........PPPP..........',
|
||||
'.......PPAPPP.........',
|
||||
'......PPPPPPPP........',
|
||||
'....PPPPPPPPPPPP......',
|
||||
'.....SSEWSSEWS........',
|
||||
'.B..wwSSSSSSSww.......',
|
||||
'.B..wwwwwwwwwww.......',
|
||||
'.B...PPPPPPPP.........',
|
||||
'.B.SPPpppppPPP........',
|
||||
'.Y.SPPpppppPPP........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'....PPPPPPPPPP........',
|
||||
'......................',
|
||||
]
|
||||
const MERLIN_SHOOT_1 = [
|
||||
'.A.......PP...........',
|
||||
'.YA.....PPPP..........',
|
||||
'.B.....PPAPPP.........',
|
||||
'.B....PPPPPPPP........',
|
||||
'.B..PPPPPPPPPPPP......',
|
||||
'.B...SSEWSSEWS........',
|
||||
'.B..wwSSSSSSSww.......',
|
||||
'.B.SwwwwwwwwwWw.......',
|
||||
'.B.S.PPPPPPPP.........',
|
||||
'...SPPpppppPPP........',
|
||||
'....PPpppppPPP........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'....PPPPPPPPPP........',
|
||||
'......................',
|
||||
]
|
||||
const MERLIN_SHOOT_2 = [
|
||||
'.A.A.....PP...........',
|
||||
'.AYA....PPPP..........',
|
||||
'.A.A...PPAPPP.........',
|
||||
'.B....PPPPPPPP........',
|
||||
'.B..PPPPPPPPPPPP......',
|
||||
'.B...SSEWSSEWS........',
|
||||
'.B..wwSSSSSSSww.......',
|
||||
'.B.SwwwwwwwwwWw.......',
|
||||
'.B.S.PPPPPPPP.........',
|
||||
'...SPPpppppPPP........',
|
||||
'....PPpppppPPP........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'.....PPPPPPPP.........',
|
||||
'....PPPPPPPPPP........',
|
||||
'......................',
|
||||
]
|
||||
|
||||
// ── Kage: grey hood, eye slit, red scarf ────────────────────────────────
|
||||
const KAGE_IDLE_0 = [
|
||||
'......NNNNNNN.........',
|
||||
'.....NNNNNNNNN........',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'....NNSEWSSEWNN.......',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'.....NNnnnnnNN........',
|
||||
'....RRRRRRRRR.........',
|
||||
'....NNNNNNNNNRR.......',
|
||||
'...NNNNNNNNNNNRR......',
|
||||
'...NN.NNNNNN.NN.......',
|
||||
'...ss.nnnnnn.ss.......',
|
||||
'.....NNNNNNN..........',
|
||||
'.....NN....NN.........',
|
||||
'.....NN....NN.........',
|
||||
'....nnn....nnn........',
|
||||
'......................',
|
||||
]
|
||||
const KAGE_IDLE_1 = [
|
||||
'......NNNNNNN.........',
|
||||
'.....NNNNNNNNN........',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'....NNSEESSEENN.......',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'.....NNnnnnnNN........',
|
||||
'....RRRRRRRRR.........',
|
||||
'....NNNNNNNNNRR.......',
|
||||
'...NNNNNNNNNNNRR......',
|
||||
'...NN.NNNNNN.NN.......',
|
||||
'...ss.nnnnnn.ss.......',
|
||||
'.....NNNNNNN..........',
|
||||
'.....NN....NN.........',
|
||||
'.....NN....NN.........',
|
||||
'....nnn....nnn........',
|
||||
'......................',
|
||||
]
|
||||
// Throw: arm whips left with the shuriken.
|
||||
const KAGE_SHOOT_0 = [
|
||||
'......NNNNNNN.........',
|
||||
'.....NNNNNNNNN........',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'....NNSEWSSEWNN.......',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'.....NNnnnnnNN........',
|
||||
'....RRRRRRRRR.........',
|
||||
'..ssNNNNNNNNNRR.......',
|
||||
'.XssNNNNNNNNNNRR......',
|
||||
'.....NNNNNNN.NN.......',
|
||||
'.....nnnnnnn.ss.......',
|
||||
'.....NNNNNNN..........',
|
||||
'.....NN....NN.........',
|
||||
'.....NN....NN.........',
|
||||
'....nnn....nnn........',
|
||||
'......................',
|
||||
]
|
||||
const KAGE_SHOOT_1 = [
|
||||
'......NNNNNNN.........',
|
||||
'.....NNNNNNNNN........',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'....NNSEWSSEWNN.......',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'.....NNnnnnnNN........',
|
||||
'ss..RRRRRRRRR.........',
|
||||
'ssNNNNNNNNNNNRR.......',
|
||||
'...NNNNNNNNNNNRR......',
|
||||
'.....NNNNNNN.NN.......',
|
||||
'.....nnnnnnn.ss.......',
|
||||
'.....NNNNNNN..........',
|
||||
'.....NN....NN.........',
|
||||
'.....NN....NN.........',
|
||||
'....nnn....nnn........',
|
||||
'......................',
|
||||
]
|
||||
const KAGE_SHOOT_2 = [
|
||||
'......NNNNNNN.........',
|
||||
'.....NNNNNNNNN........',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'....NNSEWSSEWNN.......',
|
||||
'....NNnnnnnnnNN.......',
|
||||
'.....NNnnnnnNN........',
|
||||
'....RRRRRRRRRR........',
|
||||
'ssssNNNNNNNNNRR.......',
|
||||
'...NNNNNNNNNNNRR......',
|
||||
'.....NNNNNNN.NN.......',
|
||||
'.....nnnnnnn.ss.......',
|
||||
'.....NNNNNNN..........',
|
||||
'.....NN....NN.........',
|
||||
'.....NN....NN.........',
|
||||
'....nnn....nnn........',
|
||||
'......................',
|
||||
]
|
||||
|
||||
// ── Ember: small red dragon, cream belly, folded wings ──────────────────
|
||||
const EMBER_IDLE_0 = [
|
||||
'.....C...C............',
|
||||
'....DDDDDDDD..........',
|
||||
'..DDDAEDDDDDDD........',
|
||||
'..DDDDDDDDDDDDD.......',
|
||||
'....dddddddddDDD......',
|
||||
'....DCCCCCCDD.DDD.....',
|
||||
'...DDCCCCCCDDDDDd.....',
|
||||
'...DDCCCCCCDDddd......',
|
||||
'...DDCCCCCCDD.........',
|
||||
'...DDDCCCCDDD.........',
|
||||
'....DDDDDDDDDd........',
|
||||
'.....DD..DD..dd.......',
|
||||
'....DDD..DDD..dd......',
|
||||
'......................',
|
||||
'......................',
|
||||
'......................',
|
||||
]
|
||||
const EMBER_IDLE_1 = [
|
||||
'.....C...C............',
|
||||
'....DDDDDDDD..........',
|
||||
'..DDDDEDDDDDDD........',
|
||||
'..DDDDDDDDDDDDD.......',
|
||||
'....dddddddddDDD......',
|
||||
'....DCCCCCCDD.DDD.....',
|
||||
'...DDCCCCCCDDDDDd.....',
|
||||
'...DDCCCCCCDDddd......',
|
||||
'...DDCCCCCCDD.........',
|
||||
'...DDDCCCCDDD.........',
|
||||
'....DDDDDDDDDd........',
|
||||
'.....DD..DD..dd.......',
|
||||
'....DDD..DDD..dd......',
|
||||
'......................',
|
||||
'......................',
|
||||
'......................',
|
||||
]
|
||||
// Breathe: head rears back, mouth opens, flame builds.
|
||||
const EMBER_SHOOT_0 = [
|
||||
'.....C...C............',
|
||||
'....DDDDDDDD..........',
|
||||
'..DDDAEDDDDDDD........',
|
||||
'.ODDDDDDDDDDDDD.......',
|
||||
'....dddddddddDDD......',
|
||||
'....DCCCCCCDD.DDD.....',
|
||||
'...DDCCCCCCDDDDDd.....',
|
||||
'...DDCCCCCCDDddd......',
|
||||
'...DDCCCCCCDD.........',
|
||||
'...DDDCCCCDDD.........',
|
||||
'....DDDDDDDDDd........',
|
||||
'.....DD..DD..dd.......',
|
||||
'....DDD..DDD..dd......',
|
||||
'......................',
|
||||
'......................',
|
||||
'......................',
|
||||
]
|
||||
const EMBER_SHOOT_1 = [
|
||||
'.....C...C............',
|
||||
'....DDDDDDDD..........',
|
||||
'..DDDAEDDDDDDD........',
|
||||
'OODDDDDDDDDDDDD.......',
|
||||
'.OO.dddddddddDDD......',
|
||||
'....DCCCCCCDD.DDD.....',
|
||||
'..DDDCCCCCCDDDDDd.....',
|
||||
'..DDDCCCCCCDDddd......',
|
||||
'...DDCCCCCCDD.........',
|
||||
'...DDDCCCCDDD.........',
|
||||
'....DDDDDDDDDd........',
|
||||
'.....DD..DD..dd.......',
|
||||
'....DDD..DDD..dd......',
|
||||
'......................',
|
||||
'......................',
|
||||
'......................',
|
||||
]
|
||||
const EMBER_SHOOT_2 = [
|
||||
'.....C...C............',
|
||||
'....DDDDDDDD..........',
|
||||
'..DDDAEDDDDDDD........',
|
||||
'.ODDDDDDDDDDDDD.......',
|
||||
'..O.dddddddddDDD......',
|
||||
'....DCCCCCCDD.DDD.....',
|
||||
'...DDCCCCCCDDDDDd.....',
|
||||
'...DDCCCCCCDDddd......',
|
||||
'...DDCCCCCCDD.........',
|
||||
'...DDDCCCCDDD.........',
|
||||
'....DDDDDDDDDd........',
|
||||
'.....DD..DD..dd.......',
|
||||
'....DDD..DDD..dd......',
|
||||
'......................',
|
||||
'......................',
|
||||
'......................',
|
||||
]
|
||||
|
||||
// ── Corsair: navy tricorn with gold trim, eyepatch, red coat ────────────
|
||||
const CORSAIR_IDLE_0 = [
|
||||
'....VV.......VV.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'....YYYYYYYYYYY.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSKKKs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....RRRRRRRRR.........',
|
||||
'...SSRwwwwwRSS........',
|
||||
'...SS.RYRYR.SS........',
|
||||
'.....RRRRRRR..........',
|
||||
'.....VV....VV.........',
|
||||
'.....VV....VV.........',
|
||||
'....VVV....VVV........',
|
||||
'......................',
|
||||
]
|
||||
const CORSAIR_IDLE_1 = [
|
||||
'....VV.......VV.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'....YYYYYYYYYYY.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEESSSKKKs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'....RRRRRRRRR.........',
|
||||
'...SSRwwwwwRSS........',
|
||||
'...SS.RYRYR.SS........',
|
||||
'.....RRRRRRR..........',
|
||||
'.....VV....VV.........',
|
||||
'.....VV....VV.........',
|
||||
'....VVV....VVV........',
|
||||
'......................',
|
||||
]
|
||||
// Fire: cannon barrel appears at the left, recoil pose.
|
||||
const CORSAIR_SHOOT_0 = [
|
||||
'....VV.......VV.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'....YYYYYYYYYYY.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSKKKs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'.XX.RRRRRRRRR.........',
|
||||
'.XXSSRwwwwwRSS........',
|
||||
'.XX...RYRYR.SS........',
|
||||
'.....RRRRRRR..........',
|
||||
'.....VV....VV.........',
|
||||
'.....VV....VV.........',
|
||||
'....VVV....VVV........',
|
||||
'......................',
|
||||
]
|
||||
const CORSAIR_SHOOT_1 = [
|
||||
'....VV.......VV.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'....YYYYYYYYYYY.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSKKKs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'XXXXRRRRRRRRR.........',
|
||||
'XXXXSRwwwwwRSS........',
|
||||
'XXXX..RYRYR.SS........',
|
||||
'.....RRRRRRR..........',
|
||||
'.....VV....VV.........',
|
||||
'.....VV....VV.........',
|
||||
'....VVV....VVV........',
|
||||
'......................',
|
||||
]
|
||||
const CORSAIR_SHOOT_2 = [
|
||||
'....VV.......VV.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'....YYYYYYYYYYY.......',
|
||||
'.....VVVVVVVVV........',
|
||||
'.....SSSSSSSS.........',
|
||||
'....SEWSSSKKKs........',
|
||||
'....SSSSSSSSSs........',
|
||||
'.....sSSSSSSs.........',
|
||||
'.XX.RRRRRRRRR.........',
|
||||
'.XXSSRwwwwwRSS........',
|
||||
'.XX...RYRYR.SS........',
|
||||
'.....RRRRRRR..........',
|
||||
'.....VV....VV.........',
|
||||
'.....VV....VV.........',
|
||||
'....VVV....VVV........',
|
||||
'......................',
|
||||
]
|
||||
|
||||
const PIXEL_SPRITES: Partial<
|
||||
Record<Species, { idle: string[][]; shoot: string[][] }>
|
||||
> = {
|
||||
[robinhood]: {
|
||||
idle: [ROBIN_IDLE_0, ROBIN_IDLE_1, ROBIN_IDLE_2],
|
||||
shoot: [ROBIN_SHOOT_0, ROBIN_SHOOT_1, ROBIN_SHOOT_2],
|
||||
},
|
||||
[kaio]: {
|
||||
idle: [KAIO_IDLE_0, KAIO_IDLE_1],
|
||||
shoot: [KAIO_SHOOT_0, KAIO_SHOOT_1, KAIO_SHOOT_2],
|
||||
},
|
||||
[strawhat]: {
|
||||
idle: [STRAWHAT_IDLE_0, STRAWHAT_IDLE_1],
|
||||
shoot: [STRAWHAT_SHOOT_0, STRAWHAT_SHOOT_1, STRAWHAT_SHOOT_2],
|
||||
},
|
||||
[merlin]: {
|
||||
idle: [MERLIN_IDLE_0, MERLIN_IDLE_1],
|
||||
shoot: [MERLIN_SHOOT_0, MERLIN_SHOOT_1, MERLIN_SHOOT_2],
|
||||
},
|
||||
[kage]: {
|
||||
idle: [KAGE_IDLE_0, KAGE_IDLE_1],
|
||||
shoot: [KAGE_SHOOT_0, KAGE_SHOOT_1, KAGE_SHOOT_2],
|
||||
},
|
||||
[ember]: {
|
||||
idle: [EMBER_IDLE_0, EMBER_IDLE_1],
|
||||
shoot: [EMBER_SHOOT_0, EMBER_SHOOT_1, EMBER_SHOOT_2],
|
||||
},
|
||||
[corsair]: {
|
||||
idle: [CORSAIR_IDLE_0, CORSAIR_IDLE_1],
|
||||
shoot: [CORSAIR_SHOOT_0, CORSAIR_SHOOT_1, CORSAIR_SHOOT_2],
|
||||
},
|
||||
}
|
||||
|
||||
import type { FxRun } from './actionEffects.js'
|
||||
|
||||
// Same run contract as the FX row, plus a background for the ▀ lower pixel.
|
||||
export type PixelRun = FxRun & {
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export function hasPixelSprite(species: Species): boolean {
|
||||
return PIXEL_SPRITES[species] !== undefined
|
||||
}
|
||||
|
||||
export function pixelIdleFrameCount(species: Species): number {
|
||||
return PIXEL_SPRITES[species]?.idle.length ?? 0
|
||||
}
|
||||
|
||||
export function pixelShootFrameCount(species: Species): number {
|
||||
return PIXEL_SPRITES[species]?.shoot.length ?? 0
|
||||
}
|
||||
|
||||
/** True when the terminal can render colored pixel art (chalk approximates
|
||||
* rgb() at level 2; level <2 would print uncolored blocks). */
|
||||
export function isPixelColorCapable(): boolean {
|
||||
return chalk.level >= 2
|
||||
}
|
||||
|
||||
// Exposed for tests — every frame must be a full PIXEL_WIDTH×PIXEL_HEIGHT
|
||||
// grid of palette chars or '.'.
|
||||
export function _allPixelFramesForTesting(
|
||||
species: Species,
|
||||
): string[][] | undefined {
|
||||
const sprite = PIXEL_SPRITES[species]
|
||||
return sprite ? [...sprite.idle, ...sprite.shoot] : undefined
|
||||
}
|
||||
|
||||
export function _paletteCharsForTesting(): ReadonlySet<string> {
|
||||
return new Set([...Object.keys(PALETTE), '.'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a pixel frame as rows of color runs. Each output row covers two
|
||||
* pixel rows via half-blocks; adjacent cells with identical colors merge
|
||||
* into a single run to keep the Text node count low.
|
||||
*/
|
||||
// Frames are finite and deterministic, so rendered runs are cached for the
|
||||
// lifetime of the process — CompanionSprite calls this every animation tick
|
||||
// (and on every parent re-render), and rebuilding run arrays each time is
|
||||
// pure waste. Callers must treat the result as immutable.
|
||||
const renderCache = new Map<string, PixelRun[][]>()
|
||||
|
||||
export function renderPixelSprite(
|
||||
species: Species,
|
||||
frame: number,
|
||||
mode: 'idle' | 'shoot',
|
||||
): PixelRun[][] | null {
|
||||
const sprite = PIXEL_SPRITES[species]
|
||||
if (!sprite) return null
|
||||
const frames = sprite[mode]
|
||||
const clamped = Math.min(Math.max(frame, 0), frames.length - 1)
|
||||
const cacheKey = `${species}:${mode}:${clamped}`
|
||||
const cached = renderCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const grid = frames[clamped]!
|
||||
|
||||
const rows: PixelRun[][] = []
|
||||
for (let y = 0; y < PIXEL_HEIGHT; y += 2) {
|
||||
const top = grid[y]!
|
||||
const bottom = grid[y + 1]!
|
||||
const runs: PixelRun[] = []
|
||||
for (let x = 0; x < PIXEL_WIDTH; x++) {
|
||||
const topColor = PALETTE[top[x]!]
|
||||
const bottomColor = PALETTE[bottom[x]!]
|
||||
let cell: PixelRun
|
||||
if (topColor === undefined && bottomColor === undefined) {
|
||||
cell = { text: ' ' }
|
||||
} else if (topColor !== undefined && bottomColor !== undefined) {
|
||||
cell = { text: '▀', color: topColor, backgroundColor: bottomColor }
|
||||
} else if (topColor !== undefined) {
|
||||
cell = { text: '▀', color: topColor }
|
||||
} else {
|
||||
cell = { text: '▄', color: bottomColor }
|
||||
}
|
||||
const prev = runs[runs.length - 1]
|
||||
// Merge only when colors AND glyph match. (Blank cells have no colors,
|
||||
// so the color equality already restricts blank-merging to blank runs.)
|
||||
if (
|
||||
prev !== undefined &&
|
||||
prev.color === cell.color &&
|
||||
prev.backgroundColor === cell.backgroundColor &&
|
||||
prev.text[prev.text.length - 1] === cell.text
|
||||
) {
|
||||
prev.text += cell.text
|
||||
} else {
|
||||
runs.push(cell)
|
||||
}
|
||||
}
|
||||
rows.push(runs)
|
||||
}
|
||||
renderCache.set(cacheKey, rows)
|
||||
return rows
|
||||
}
|
||||
+10
-3
@@ -7,7 +7,7 @@ import { isBuddyEnabled } from './feature.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.
|
||||
A tiny ${species} companion 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.`
|
||||
}
|
||||
@@ -19,11 +19,18 @@ export function getCompanionIntroAttachment(
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
|
||||
// Skip if already announced for this companion.
|
||||
// Skip if already announced for this companion IN ITS CURRENT FORM —
|
||||
// /buddy set changes the species without changing the name, and the model
|
||||
// should hear about the new form once.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment.type !== 'companion_intro') continue
|
||||
if (msg.attachment.name === companion.name) return []
|
||||
if (
|
||||
msg.attachment.name === companion.name &&
|
||||
msg.attachment.species === companion.species
|
||||
) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
renderFace,
|
||||
renderShootSprite,
|
||||
renderSprite,
|
||||
shootFrameCount,
|
||||
spriteFrameCount,
|
||||
} from './sprites.js'
|
||||
import {
|
||||
type CompanionBones,
|
||||
robinhood,
|
||||
SPECIES,
|
||||
type Species,
|
||||
} from './types.js'
|
||||
|
||||
const SPRITE_WIDTH = 12
|
||||
|
||||
function bones(species: Species): CompanionBones {
|
||||
return {
|
||||
rarity: 'common',
|
||||
species,
|
||||
eye: '·',
|
||||
hat: 'none',
|
||||
shiny: false,
|
||||
stats: { DEBUGGING: 1, PATIENCE: 1, CHAOS: 1, WISDOM: 1, SNARK: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe('sprites', () => {
|
||||
test('every species renders frames of uniform 12-col width', () => {
|
||||
for (const species of SPECIES) {
|
||||
const frameCount = spriteFrameCount(species)
|
||||
expect(frameCount).toBeGreaterThanOrEqual(1)
|
||||
for (let frame = 0; frame < frameCount; frame++) {
|
||||
const lines = renderSprite(bones(species), frame)
|
||||
// Every hero keeps its headwear on row 0 — height is always 5.
|
||||
expect(lines.length).toBe(5)
|
||||
expect(lines[0]!.trim()).not.toBe('')
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBe(SPRITE_WIDTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('robinhood keeps the cap row in every idle frame (stable 5-row height, hats suppressed)', () => {
|
||||
for (let frame = 0; frame < spriteFrameCount(robinhood); frame++) {
|
||||
const lines = renderSprite(bones(robinhood), frame)
|
||||
expect(lines.length).toBe(5)
|
||||
expect(lines[0]!.trim()).not.toBe('')
|
||||
}
|
||||
// A rolled hat must not replace the cap.
|
||||
const hatted = renderSprite({ ...bones(robinhood), hat: 'crown' }, 0)
|
||||
expect(hatted[0]).toBe(renderSprite(bones(robinhood), 0)[0]!)
|
||||
})
|
||||
|
||||
test('renderShootSprite returns 5x12 frames for robinhood and clamps out-of-range', () => {
|
||||
for (const frame of [0, 1, 2]) {
|
||||
const lines = renderShootSprite(bones(robinhood), frame)
|
||||
expect(lines).not.toBeNull()
|
||||
expect(lines!.length).toBe(5)
|
||||
for (const line of lines!) {
|
||||
expect(line.length).toBe(SPRITE_WIDTH)
|
||||
expect(line).not.toContain('{E}')
|
||||
}
|
||||
expect(lines![0]!.trim()).not.toBe('')
|
||||
}
|
||||
// Clamps: past the end returns the loose pose, negative returns the nock.
|
||||
expect(renderShootSprite(bones(robinhood), 99)).toEqual(
|
||||
renderShootSprite(bones(robinhood), 2),
|
||||
)
|
||||
expect(renderShootSprite(bones(robinhood), -1)).toEqual(
|
||||
renderShootSprite(bones(robinhood), 0),
|
||||
)
|
||||
})
|
||||
|
||||
test('every hero has line-art shoot frames of uniform width', () => {
|
||||
// Without these, low-color terminals would show a projectile flying out
|
||||
// of a motionless sprite.
|
||||
for (const species of SPECIES) {
|
||||
expect(shootFrameCount(species)).toBeGreaterThanOrEqual(3)
|
||||
for (let frame = 0; frame < shootFrameCount(species); frame++) {
|
||||
const lines = renderShootSprite(bones(species), frame)
|
||||
expect(lines.length).toBe(5)
|
||||
for (const line of lines) {
|
||||
expect(line.length).toBe(SPRITE_WIDTH)
|
||||
expect(line).not.toContain('{E}')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('renderFace maps every species to its exact face', () => {
|
||||
const EXPECTED_FACES: Record<Species, string> = {
|
||||
robinhood: '«(·)',
|
||||
kaio: '\\(·)/',
|
||||
strawhat: '∩(·)',
|
||||
merlin: '^(·)',
|
||||
kage: '|··|',
|
||||
ember: '<··>',
|
||||
corsair: '(·x)',
|
||||
}
|
||||
for (const species of SPECIES) {
|
||||
expect(renderFace(bones(species))).toBe(EXPECTED_FACES[species])
|
||||
}
|
||||
void robinhood
|
||||
})
|
||||
})
|
||||
+327
-443
@@ -1,471 +1,376 @@
|
||||
import type { CompanionBones, Eye, Hat, Species } from './types.js'
|
||||
import type { CompanionBones, Eye, Species } from './types.js'
|
||||
import {
|
||||
axolotl,
|
||||
blob,
|
||||
cactus,
|
||||
capybara,
|
||||
cat,
|
||||
chonk,
|
||||
dragon,
|
||||
duck,
|
||||
ghost,
|
||||
goose,
|
||||
mushroom,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
rabbit,
|
||||
robot,
|
||||
snail,
|
||||
turtle,
|
||||
corsair,
|
||||
ember,
|
||||
kage,
|
||||
kaio,
|
||||
merlin,
|
||||
robinhood,
|
||||
strawhat,
|
||||
} 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.
|
||||
// Line-art fallback sprites for low-color terminals (the primary rendering
|
||||
// is the truecolor pixel art in pixelSprites.ts). Each sprite is 5 lines
|
||||
// tall, 12 wide (after {E}→1char substitution), with multiple frames for
|
||||
// idle fidget animation. Row 0 is the signature headwear/hair and is
|
||||
// non-blank in every frame, so heights never oscillate.
|
||||
//
|
||||
// All heroes face LEFT: the sprite sits on the right edge of the screen
|
||||
// and signature effects travel toward the prompt.
|
||||
const BODIES: Record<Species, string[][]> = {
|
||||
[duck]: [
|
||||
[robinhood]: [
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´ ',
|
||||
' <,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' /( )\\ ',
|
||||
' (| | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´~ ',
|
||||
' <<,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' /( )\\ ',
|
||||
' (| | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( .__> ',
|
||||
' `--´ ',
|
||||
' <,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' (( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[goose]: [
|
||||
[kaio]: [
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
' \\|/|/ ',
|
||||
' ({E}.{E}) ',
|
||||
' /( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
' \\|/|// ',
|
||||
' ({E}.{E}) ',
|
||||
' /( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}>> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
' *\\|/|/* ',
|
||||
' ({E}.{E}) ',
|
||||
' /( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[blob]: [
|
||||
[strawhat]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {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 ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
' <____> ',
|
||||
' ({E}.{E}) ',
|
||||
' /|~~|\\ ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' n n ',
|
||||
' | ____ | ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
' ____ ',
|
||||
' <____>~ ',
|
||||
' ({E}.{E}) ',
|
||||
' /|~~|\\ ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ____ ',
|
||||
' <____> ',
|
||||
' ({E}.{E}) ',
|
||||
' \\|~~|/ ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[robot]: [
|
||||
[merlin]: [
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
' /\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' /|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ -==- ] ',
|
||||
' `------´ ',
|
||||
' */\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' /|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
[
|
||||
' * ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
' /\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' |/|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
],
|
||||
[rabbit]: [
|
||||
[kage]: [
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
' ____ ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' |==|~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (|__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
' ____ ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' |==|~~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( . . )= ',
|
||||
' (")__(") ',
|
||||
' ____ * ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' |==|~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
],
|
||||
[mushroom]: [
|
||||
[ember]: [
|
||||
[
|
||||
' ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
' ^^ ',
|
||||
' <({E}{E}) ',
|
||||
' (~~~~)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .-O-oo-O-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
' o ^^ ',
|
||||
' <({E}{E}) ',
|
||||
' (~~~~)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
[
|
||||
' . o . ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
' ^^ ',
|
||||
' <({E}{E}) ',
|
||||
' (^^^^)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[chonk]: [
|
||||
[corsair]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
' _/\\_ ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
' /|++|\\ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /| ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
' _/\\_ ,> ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
' /|++|\\ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´~ ',
|
||||
' _/\\_ ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
' \\|++|/ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const HAT_LINES: Record<Hat, string> = {
|
||||
none: '',
|
||||
crown: ' \\^^^/ ',
|
||||
tophat: ' [___] ',
|
||||
propeller: ' -+- ',
|
||||
halo: ' ( ) ',
|
||||
wizard: ' /^\\ ',
|
||||
beanie: ' (___) ',
|
||||
tinyduck: ' ,> ',
|
||||
// One-shot draw/cast poses for the signature action, line-art fallback.
|
||||
// Kept out of BODIES so spriteFrameCount and the excited all-frames cycle
|
||||
// never leak action poses into idle animation. EVERY hero needs an entry:
|
||||
// without one, low-color terminals show a projectile flying out of a
|
||||
// motionless sprite.
|
||||
const SHOOT_FRAMES: Record<Species, string[][]> = {
|
||||
[robinhood]: [
|
||||
[
|
||||
// nock
|
||||
' <,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' <-( )\\ ',
|
||||
' (| | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
// full draw
|
||||
' <,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' <==( )> ',
|
||||
' (| | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
// loose — string vibrates; the arrow hands off to CompanionActionFX
|
||||
' <,___ ',
|
||||
' ({E}.{E}) ',
|
||||
' ~( )\\ ',
|
||||
' (| | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[kaio]: [
|
||||
[
|
||||
' \\|/|/ ',
|
||||
' ({E}.{E}) ',
|
||||
' (( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' \\|/|/ ',
|
||||
' ({E}.{E}) ',
|
||||
' o(( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' \\|/|/ ',
|
||||
' ({E}.{E}) ',
|
||||
' =(( )\\ ',
|
||||
' | | ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[strawhat]: [
|
||||
[
|
||||
' ____ ',
|
||||
' <____> ',
|
||||
' ({E}.{E}) ',
|
||||
' /|~~|> ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ____ ',
|
||||
' <____> ',
|
||||
' ({E}.{E}) ',
|
||||
' o|~~|\\ ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
[
|
||||
' ____ ',
|
||||
' <____> ',
|
||||
' ({E}.{E}) ',
|
||||
' o-|~~|\\ ',
|
||||
' _/ \\_ ',
|
||||
],
|
||||
],
|
||||
[merlin]: [
|
||||
[
|
||||
' /\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' |/|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
[
|
||||
' */\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' |/|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
[
|
||||
' **/\\ ',
|
||||
' /__\\ ',
|
||||
' ~({E}.{E})~ ',
|
||||
' |/|##|\\ ',
|
||||
' _/~~\\_ ',
|
||||
],
|
||||
],
|
||||
[kage]: [
|
||||
[
|
||||
' ____ ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' -|==|~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ____ * ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' -|==|~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' ____ ',
|
||||
' /____\\ ',
|
||||
' |({E}{E})| ',
|
||||
' ~|==|~ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
],
|
||||
[ember]: [
|
||||
[
|
||||
' ^^ ',
|
||||
' <<({E}{E}) ',
|
||||
' (~~~~)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
[
|
||||
' ^^ ',
|
||||
'~<({E}{E}) ',
|
||||
' (^^^^)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
[
|
||||
' ^^ ',
|
||||
' <({E}{E})~ ',
|
||||
' (~~~~)> ',
|
||||
' | | ',
|
||||
' _/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[corsair]: [
|
||||
[
|
||||
' _/\\_ ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
' =/|++|\\ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' _/\\_ ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
'==/|++|\\ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
[
|
||||
' _/\\_ ',
|
||||
' [____] ',
|
||||
' ({E}x) ',
|
||||
' */|++|\\ ',
|
||||
' _/\\_ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
|
||||
const frames = BODIES[bones.species]
|
||||
const body = frames[frame % frames.length]!.map(line =>
|
||||
return 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 renderShootSprite(
|
||||
bones: CompanionBones,
|
||||
frame: number,
|
||||
): string[] {
|
||||
const frames = SHOOT_FRAMES[bones.species]
|
||||
const clamped = Math.min(Math.max(frame, 0), frames.length - 1)
|
||||
return frames[clamped]!.map(line => line.replaceAll('{E}', bones.eye))
|
||||
}
|
||||
|
||||
export function shootFrameCount(species: Species): number {
|
||||
return SHOOT_FRAMES[species].length
|
||||
}
|
||||
|
||||
export function spriteFrameCount(species: Species): number {
|
||||
@@ -475,40 +380,19 @@ export function spriteFrameCount(species: Species): number {
|
||||
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})`
|
||||
case robinhood:
|
||||
return `«(${eye})`
|
||||
case kaio:
|
||||
return `\\(${eye})/`
|
||||
case strawhat:
|
||||
return `∩(${eye})`
|
||||
case merlin:
|
||||
return `^(${eye})`
|
||||
case kage:
|
||||
return `|${eye}${eye}|`
|
||||
case ember:
|
||||
return `<${eye}${eye}>`
|
||||
case corsair:
|
||||
return `(${eye}x)`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
corsair,
|
||||
ember,
|
||||
kage,
|
||||
kaio,
|
||||
merlin,
|
||||
robinhood,
|
||||
SPECIES,
|
||||
strawhat,
|
||||
} from './types.js'
|
||||
|
||||
describe('species constants', () => {
|
||||
// The constants are runtime-constructed via String.fromCharCode (see the
|
||||
// canary note in types.ts) and force-cast to their literal types — a single
|
||||
// wrong byte would produce a different runtime string that TypeScript
|
||||
// cannot catch. Plain literals are safe HERE because test files are never
|
||||
// part of the build output the canary check greps.
|
||||
test('charCode-encoded constants decode to their declared literals', () => {
|
||||
expect(robinhood).toBe('robinhood')
|
||||
expect(kaio).toBe('kaio')
|
||||
expect(strawhat).toBe('strawhat')
|
||||
expect(merlin).toBe('merlin')
|
||||
expect(kage).toBe('kage')
|
||||
expect(ember).toBe('ember')
|
||||
expect(corsair).toBe('corsair')
|
||||
})
|
||||
|
||||
test('the pool contains each hero exactly once', () => {
|
||||
expect(new Set(SPECIES).size).toBe(SPECIES.length)
|
||||
expect(SPECIES.length).toBe(7)
|
||||
})
|
||||
})
|
||||
+59
-66
@@ -14,62 +14,45 @@ export type Rarity = (typeof RARITIES)[number]
|
||||
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,
|
||||
export const robinhood = c(
|
||||
0x72,
|
||||
0x6f,
|
||||
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,
|
||||
0x69,
|
||||
0x6e,
|
||||
0x68,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x64,
|
||||
) as 'robinhood'
|
||||
export const kaio = c(0x6b, 0x61, 0x69, 0x6f) as 'kaio'
|
||||
export const strawhat = c(
|
||||
0x73,
|
||||
0x74,
|
||||
0x72,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x6d,
|
||||
) as 'mushroom'
|
||||
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
|
||||
0x61,
|
||||
0x77,
|
||||
0x68,
|
||||
0x61,
|
||||
0x74,
|
||||
) as 'strawhat'
|
||||
export const merlin = c(0x6d, 0x65, 0x72, 0x6c, 0x69, 0x6e) as 'merlin'
|
||||
export const kage = c(0x6b, 0x61, 0x67, 0x65) as 'kage'
|
||||
export const ember = c(0x65, 0x6d, 0x62, 0x65, 0x72) as 'ember'
|
||||
export const corsair = c(0x63, 0x6f, 0x72, 0x73, 0x61, 0x69, 0x72) as 'corsair'
|
||||
|
||||
// The deterministic hatch pool — every hero form. NEVER reorder or grow
|
||||
// this list casually: pick(rng, SPECIES) depends on SPECIES.length and
|
||||
// ordering, so any change re-rolls every existing user's hatched species
|
||||
// (their speciesOverride, if set, still wins).
|
||||
export const SPECIES = [
|
||||
duck,
|
||||
goose,
|
||||
blob,
|
||||
cat,
|
||||
dragon,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
turtle,
|
||||
snail,
|
||||
ghost,
|
||||
axolotl,
|
||||
capybara,
|
||||
cactus,
|
||||
robot,
|
||||
rabbit,
|
||||
mushroom,
|
||||
chonk,
|
||||
robinhood,
|
||||
kaio,
|
||||
strawhat,
|
||||
merlin,
|
||||
kage,
|
||||
ember,
|
||||
corsair,
|
||||
] as const
|
||||
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
|
||||
|
||||
@@ -121,7 +104,12 @@ export type Companion = CompanionBones &
|
||||
// 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 type StoredCompanion = CompanionSoul & {
|
||||
hatchedAt: number
|
||||
// Persisted /buddy set choice. Applied over the rolled bones' species in
|
||||
// getCompanion(); does NOT touch rarity/stats (can't fake a legendary).
|
||||
speciesOverride?: Species
|
||||
}
|
||||
|
||||
export const RARITY_WEIGHTS = {
|
||||
common: 60,
|
||||
@@ -131,18 +119,23 @@ export const RARITY_WEIGHTS = {
|
||||
legendary: 1,
|
||||
} as const satisfies Record<Rarity, number>
|
||||
|
||||
export const RARITY_STARS = {
|
||||
common: '★',
|
||||
uncommon: '★★',
|
||||
rare: '★★★',
|
||||
epic: '★★★★',
|
||||
legendary: '★★★★★',
|
||||
} as const satisfies Record<Rarity, string>
|
||||
// Every hero's signature color. A full Record so adding a species without a
|
||||
// color is a compile error, not a silent fallback.
|
||||
export const SPECIES_COLORS: Record<
|
||||
Species,
|
||||
keyof import('../utils/theme.js').Theme
|
||||
> = {
|
||||
[robinhood]: 'success',
|
||||
[kaio]: 'warning',
|
||||
[strawhat]: 'error',
|
||||
[merlin]: 'autoAccept',
|
||||
[kage]: 'inactive',
|
||||
[ember]: 'error',
|
||||
[corsair]: 'permission',
|
||||
}
|
||||
|
||||
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>
|
||||
export function companionColor(
|
||||
companion: Pick<CompanionBones, 'species'>,
|
||||
): keyof import('../utils/theme.js').Theme {
|
||||
return SPECIES_COLORS[companion.species]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useAnimationFrame } from '../ink.js'
|
||||
|
||||
// Spinner convention — see SpinnerAnimationRow's 50ms clock.
|
||||
export const ACTION_BURST_INTERVAL_MS = 50
|
||||
|
||||
type ShotState = {
|
||||
forShotAt: number | undefined // shot token this state belongs to
|
||||
armTime: number // clock time at the render where the shot was observed
|
||||
anchor: number | null // clock time of animation start; null while arming
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a one-shot burst animation off the shared animation clock.
|
||||
* Returns elapsed ms since the shot started (0 while arming), or null when
|
||||
* idle/finished/disabled. Subscribes to the clock at 50ms only while a shot
|
||||
* is live — zero clock load otherwise.
|
||||
*
|
||||
* Arming subtlety: at the render where a new shot token is observed, `time`
|
||||
* from any other (slower) clock subscription can be one idle interval stale;
|
||||
* anchoring on it would swallow the draw phase. So the observing render only
|
||||
* arms (records armTime), and the anchor is set on the first FRESH tick
|
||||
* (time !== armTime). The sync-during-render setState mirrors the sprite's
|
||||
* existing pet-heart anchor pattern.
|
||||
*/
|
||||
/**
|
||||
* IMPORTANT: callers must pass `shotAt` UNCONDITIONALLY (not gated on their
|
||||
* own eligibility) and use `enabled` to suppress playback. The hook consumes
|
||||
* every token it sees — including on mount and while disabled — so a stale
|
||||
* token can never replay after a remount (prompt hidden by a tool, resize
|
||||
* across the narrow boundary) or an eligibility flip (unmute, reduced-motion
|
||||
* off, resize wider). Gating the token at the call site would hide it from
|
||||
* the disabled-consume path and resurrect exactly those replays.
|
||||
*/
|
||||
export function useShotClock(
|
||||
shotAt: number | undefined,
|
||||
enabled: boolean,
|
||||
totalMs: number,
|
||||
): number | null {
|
||||
const [s, set] = useState<ShotState>(() => ({
|
||||
// Consume the mount-time token: whatever shot happened before this
|
||||
// component existed is history, not something to replay.
|
||||
forShotAt: shotAt,
|
||||
armTime: 0,
|
||||
anchor: null,
|
||||
done: true,
|
||||
}))
|
||||
const playable = enabled && totalMs > 0 && shotAt !== undefined
|
||||
const live = playable && (shotAt !== s.forShotAt || !s.done)
|
||||
const [, time] = useAnimationFrame(live ? ACTION_BURST_INTERVAL_MS : null)
|
||||
|
||||
if (shotAt !== s.forShotAt) {
|
||||
if (playable) {
|
||||
// New shot token while eligible — arm.
|
||||
set({ forShotAt: shotAt, armTime: time, anchor: null, done: false })
|
||||
} else {
|
||||
// New token while ineligible — consume silently so it can't arm later.
|
||||
set({ forShotAt: shotAt, armTime: 0, anchor: null, done: true })
|
||||
}
|
||||
} else if (!playable && !s.done) {
|
||||
// Playback became ineligible mid-flight (mute, reduced-motion, resize
|
||||
// narrow) — consume the in-flight shot so re-enabling doesn't resume it.
|
||||
set({ ...s, anchor: null, done: true })
|
||||
} else if (live && s.anchor === null && time !== s.armTime) {
|
||||
// First fresh tick — anchor the animation start.
|
||||
set({ ...s, anchor: time })
|
||||
} else if (live && s.anchor !== null && time - s.anchor >= totalMs) {
|
||||
// Finished — unsubscribe on the next render.
|
||||
set({ ...s, done: true })
|
||||
}
|
||||
|
||||
if (!live) return null
|
||||
if (s.anchor === null || shotAt !== s.forShotAt) return 0 // arming
|
||||
return Math.max(0, Math.min(totalMs, time - s.anchor))
|
||||
}
|
||||
+170
-32
@@ -1,9 +1,55 @@
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { stringWidth } from '../../ink/stringWidth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { companionUserId, getCompanion, rollWithSeed } from '../../buddy/companion.js'
|
||||
import type { StoredCompanion } from '../../buddy/types.js'
|
||||
import { companionUserId, getCompanion } from '../../buddy/companion.js'
|
||||
import { pickDeterministic } from '../../buddy/deterministic.js'
|
||||
import type { Species, StoredCompanion } from '../../buddy/types.js'
|
||||
import {
|
||||
corsair,
|
||||
ember,
|
||||
kage,
|
||||
kaio,
|
||||
merlin,
|
||||
SPECIES,
|
||||
robinhood,
|
||||
strawhat,
|
||||
} from '../../buddy/types.js'
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||
|
||||
// Flavor for /buddy set confirmations. A full Record so adding a hero
|
||||
// without flavor text is a compile error.
|
||||
const FORM_FLAVOR: Record<Species, { don: string; hint: string }> = {
|
||||
[robinhood]: {
|
||||
don: 'dons the green hood',
|
||||
hint: 'Submit any message to see the arrow fly.',
|
||||
},
|
||||
[kaio]: {
|
||||
don: 'powers up — hair blazing gold',
|
||||
hint: 'Submit any message to fire the energy wave.',
|
||||
},
|
||||
[strawhat]: {
|
||||
don: 'puts on the straw hat with a grin',
|
||||
hint: 'Submit any message to throw the stretchy punch.',
|
||||
},
|
||||
[merlin]: {
|
||||
don: 'raises the star-tipped staff',
|
||||
hint: 'Submit any message to cast the sparkle stream.',
|
||||
},
|
||||
[kage]: {
|
||||
don: 'melts into the shadows',
|
||||
hint: 'Submit any message to throw the shuriken.',
|
||||
},
|
||||
[ember]: {
|
||||
don: 'puffs a proud little smoke ring',
|
||||
hint: 'Submit any message to breathe fire.',
|
||||
},
|
||||
[corsair]: {
|
||||
don: 'tips the tricorn',
|
||||
hint: 'Submit any message to fire the cannon.',
|
||||
},
|
||||
}
|
||||
|
||||
const NAME_PREFIXES = [
|
||||
'Byte',
|
||||
'Echo',
|
||||
@@ -46,26 +92,12 @@ const PET_REACTIONS = [
|
||||
'wiggles happily',
|
||||
] as const
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||
return items[hashString(seed) % items.length]!
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
function createStoredCompanion(): StoredCompanion {
|
||||
const userId = companionUserId()
|
||||
const { bones } = rollWithSeed(`${userId}:buddy`)
|
||||
const prefix = pickDeterministic(NAME_PREFIXES, `${userId}:prefix`)
|
||||
const suffix = pickDeterministic(NAME_SUFFIXES, `${userId}:suffix`)
|
||||
const personality = pickDeterministic(PERSONALITIES, `${userId}:personality`)
|
||||
@@ -91,7 +123,7 @@ function setCompanionReaction(
|
||||
|
||||
function showHelp(onDone: LocalJSXCommandOnDone): void {
|
||||
onDone(
|
||||
'Usage: /buddy [status|mute|unmute]\n\nRun /buddy with no args to hatch your companion the first time, then pet it on later runs.',
|
||||
`Usage: /buddy [status|mute|unmute|set <form|random>|name <new name>]\n\nForms: ${SPECIES.join(', ')}\n\nRun /buddy with no args to hatch your companion the first time, then pet it on later runs. /buddy set picks a hero form with its own Enter animation; /buddy set random restores the rolled one; /buddy name renames your companion.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
}
|
||||
@@ -103,16 +135,6 @@ export async function call(
|
||||
): Promise<null> {
|
||||
const arg = args?.trim().toLowerCase() ?? ''
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(arg) || arg === '') {
|
||||
const existing = getCompanion()
|
||||
if (arg !== '' || existing) {
|
||||
if (arg !== '') {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(arg)) {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
@@ -126,8 +148,11 @@ export async function call(
|
||||
})
|
||||
return null
|
||||
}
|
||||
const chosenForm = getGlobalConfig().companion?.speciesOverride
|
||||
? ' (chosen form — /buddy set random to revert)'
|
||||
: ''
|
||||
onDone(
|
||||
`${companion.name} is your ${titleCase(companion.rarity)} ${companion.species}. ${companion.personality}`,
|
||||
`${companion.name} is your ${titleCase(companion.rarity)} ${companion.species}${chosenForm}. ${companion.personality}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
@@ -141,11 +166,114 @@ export async function call(
|
||||
}))
|
||||
if (muted) {
|
||||
setCompanionReaction(context, undefined)
|
||||
} else {
|
||||
// The sprite reads companionMuted non-reactively and its animation
|
||||
// clock is paused while hidden, so a config-only unmute would leave it
|
||||
// invisible until an unrelated re-render. The reaction is an AppState
|
||||
// change that re-renders the sprite immediately (and greets the user).
|
||||
const companion = getCompanion()
|
||||
if (companion) {
|
||||
setCompanionReaction(context, `${companion.name} is back.`)
|
||||
}
|
||||
}
|
||||
onDone(`Buddy ${muted ? 'muted' : 'unmuted'}.`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
const [sub, ...rest] = arg.split(/\s+/)
|
||||
|
||||
if (sub === 'name') {
|
||||
if (!getGlobalConfig().companion) {
|
||||
onDone('No buddy hatched yet. Run /buddy to hatch one first.', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
// Parse from the raw args (not `arg`) so capitalization is preserved.
|
||||
// Sanitize: the name renders verbatim in the sprite column, so ANSI
|
||||
// escapes / control / zero-width characters would corrupt the TUI line
|
||||
// until the next rename. Cap by DISPLAY width, not UTF-16 length.
|
||||
const newName = stripVTControlCharacters(
|
||||
(args ?? '').trim().split(/\s+/).slice(1).join(' '),
|
||||
)
|
||||
.replace(/[\p{Cc}\p{Cf}]/gu, '')
|
||||
.trim()
|
||||
if (!newName) {
|
||||
onDone('Usage: /buddy name <new name>', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
if (stringWidth(newName) > 20) {
|
||||
onDone('Buddy names are capped at 20 columns.', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
const previous = getCompanion()!.name
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
companion: current.companion
|
||||
? { ...current.companion, name: newName }
|
||||
: current.companion,
|
||||
}))
|
||||
if (!getGlobalConfig().companionMuted) {
|
||||
setCompanionReaction(context, `${newName} answers to their new name.`)
|
||||
}
|
||||
onDone(`${previous} is now called ${newName}.`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
if (sub === 'set') {
|
||||
if (!getGlobalConfig().companion) {
|
||||
onDone('No buddy hatched yet. Run /buddy to hatch one first.', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
const target = rest[0]
|
||||
if (target === 'random') {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
companion: current.companion
|
||||
? { ...current.companion, speciesOverride: undefined }
|
||||
: current.companion,
|
||||
}))
|
||||
const companion = getCompanion()!
|
||||
onDone(
|
||||
`${companion.name} is back to their rolled form: ${titleCase(companion.rarity)} ${companion.species}.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (
|
||||
target !== undefined &&
|
||||
(SPECIES as readonly string[]).includes(target)
|
||||
) {
|
||||
const form = target as Species
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
companion: current.companion
|
||||
? { ...current.companion, speciesOverride: form }
|
||||
: current.companion,
|
||||
}))
|
||||
const companion = getCompanion()!
|
||||
const flavor = FORM_FLAVOR[form]
|
||||
setCompanionReaction(context, `${companion.name} ${flavor.don}.`)
|
||||
const mutedHint = getGlobalConfig().companionMuted
|
||||
? ' Note: your buddy is muted and hidden — run /buddy unmute to see them.'
|
||||
: ''
|
||||
onDone(
|
||||
`${companion.name} is now a ${form}. ${flavor.hint} /buddy set random reverts.${mutedHint}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
onDone(
|
||||
`Unknown form '${rest[0] ?? ''}'. Available: ${[...SPECIES, 'random'].join(', ')}.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (arg !== '') {
|
||||
showHelp(onDone)
|
||||
return null
|
||||
@@ -159,10 +287,10 @@ export async function call(
|
||||
companion: stored,
|
||||
companionMuted: false,
|
||||
}))
|
||||
companion = {
|
||||
...rollWithSeed(`${companionUserId()}:buddy`).bones,
|
||||
...stored,
|
||||
}
|
||||
// Read back through getCompanion() so the hatch message names the SAME
|
||||
// species the sprite will display (the display roll is seeded with
|
||||
// userId+SALT, not the `:buddy` seed used for hatch-time stats).
|
||||
companion = getCompanion()!
|
||||
setCompanionReaction(
|
||||
context,
|
||||
`${companion.name} the ${companion.species} has hatched.`,
|
||||
@@ -175,6 +303,16 @@ export async function call(
|
||||
return null
|
||||
}
|
||||
|
||||
// Muted: the sprite is hidden and reactions never render, so a silent
|
||||
// pet reads as "/buddy did nothing". Say so instead.
|
||||
if (getGlobalConfig().companionMuted) {
|
||||
onDone(
|
||||
`${companion.name} is hidden (buddy is muted). Run /buddy unmute to show them.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const reaction = `${companion.name} ${pickDeterministic(
|
||||
PET_REACTIONS,
|
||||
`${Date.now()}:${companion.name}`,
|
||||
|
||||
@@ -5,7 +5,7 @@ const buddy = {
|
||||
name: 'buddy',
|
||||
description: 'Hatch, pet, and manage your OpenClaude companion',
|
||||
immediate: true,
|
||||
argumentHint: '[status|mute|unmute|help]',
|
||||
argumentHint: '[status|mute|unmute|set <form>|name <name>|help]',
|
||||
load: () => import('./buddy.js'),
|
||||
} satisfies Command
|
||||
|
||||
|
||||
@@ -272,6 +272,7 @@ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/We
|
||||
import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js';
|
||||
import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js';
|
||||
import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js';
|
||||
import { CompanionActionFX } from '../buddy/CompanionActionFX.js';
|
||||
import { isBuddyEnabled } from '../buddy/feature.js';
|
||||
import { fireCompanionObserver } from '../buddy/observer.js';
|
||||
// Session manager removed - using AppState now
|
||||
@@ -3639,6 +3640,13 @@ export function REPL({
|
||||
setInputMode('prompt');
|
||||
setIDESelection(undefined);
|
||||
setSubmitCount(_ => _ + 1);
|
||||
if (isBuddyEnabled() && !isSlashCommand && inputMode === 'prompt') {
|
||||
// Change token for the companion's signature action (one Enter = one
|
||||
// shot; queued messages intentionally don't re-fire on dequeue).
|
||||
// Real prompts only — slash commands and bash lines aren't "sending
|
||||
// a message" and shouldn't launch projectiles.
|
||||
setAppState(prev => ({ ...prev, companionShotAt: Date.now() }));
|
||||
}
|
||||
helpers.clearBuffer();
|
||||
tipPickedThisTurnRef.current = false;
|
||||
|
||||
@@ -5137,7 +5145,7 @@ export function REPL({
|
||||
{/* Frustration-triggered transcript sharing prompt */}
|
||||
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||
{showIssueFlagBanner && <IssueFlagBanner />}
|
||||
{ }
|
||||
{isBuddyEnabled() && companionVisible && !companionNarrow && <CompanionActionFX />}
|
||||
<PromptInput debug={debug} ideSelection={ideSelection} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={renderCommands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
|
||||
// Works during isLoading — edit cancels first; uuid selection survives appends.
|
||||
feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} />
|
||||
|
||||
@@ -172,6 +172,10 @@ export type AppState = DeepImmutable<{
|
||||
companionReaction?: string
|
||||
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
||||
companionPetAt?: number
|
||||
// Change token set on each immediate prompt submission (REPL onSubmit).
|
||||
// Drives the companion arrow-shot animation. The value is Date.now() but
|
||||
// is only compared for change, never to clock time.
|
||||
companionShotAt?: number
|
||||
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
|
||||
mcp: {
|
||||
clients: MCPServerConnection[]
|
||||
|
||||
Reference in New Issue
Block a user