mirror of
https://github.com/domfelipe/pokebuddy.git
synced 2026-08-07 07:16:49 +00:00
Initial commit: pokebuddy_cli
This commit is contained in:
commit
d58df9bd10
47 changed files with 1684 additions and 0 deletions
31
src/ansi.js
Normal file
31
src/ansi.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export const RESET = '\x1b[0m';
|
||||
export const BOLD = '\x1b[1m';
|
||||
export const DIM = '\x1b[2m';
|
||||
|
||||
export const palette = {
|
||||
green: '\x1b[38;2;0;255;144m',
|
||||
cyan: '\x1b[38;2;0;230;255m',
|
||||
yellow: '\x1b[38;2;255;214;10m',
|
||||
magenta: '\x1b[38;2;255;77;255m',
|
||||
orange: '\x1b[38;2;255;106;61m',
|
||||
purple: '\x1b[38;2;167;139;250m',
|
||||
gray: '\x1b[38;2;155;163;175m',
|
||||
white: '\x1b[38;2;230;237;243m',
|
||||
red: '\x1b[38;2;255;90;90m'
|
||||
};
|
||||
|
||||
export function paint(text, color = 'white') {
|
||||
return `${palette[color] ?? ''}${text}${RESET}`;
|
||||
}
|
||||
|
||||
export function strong(text, color = 'white') {
|
||||
return `${BOLD}${palette[color] ?? ''}${text}${RESET}`;
|
||||
}
|
||||
|
||||
export function dim(text) {
|
||||
return `${DIM}${text}${RESET}`;
|
||||
}
|
||||
|
||||
export function stripAnsi(value) {
|
||||
return String(value).replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
184
src/cli.js
Normal file
184
src/cli.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { creatures, getCreatureByName } from './creatures.js';
|
||||
import { nextCreature } from './hatch.js';
|
||||
import { addEvent, ensureCompanionState, loadState, resetState, saveState } from './state.js';
|
||||
import { banner, boxed, compactCreatureLine, creatureCard, dex, helpText, legalText, listCompanions, statusView } from './render.js';
|
||||
import { paint, strong, dim } from './ansi.js';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(dirname, '..', 'package.json'), 'utf8'));
|
||||
|
||||
export async function run(argv) {
|
||||
const [command = 'help', ...args] = argv;
|
||||
|
||||
switch (command) {
|
||||
case 'hatch':
|
||||
return hatch(args);
|
||||
case 'list':
|
||||
return output(listCompanions(loadState()));
|
||||
case 'dex':
|
||||
return output(`${strong('PokeBuddy Creature Dex', 'green')}\n${dex()}`);
|
||||
case 'show':
|
||||
return show(args);
|
||||
case 'poke':
|
||||
return interact('poke', args);
|
||||
case 'feed':
|
||||
return interact('feed', args);
|
||||
case 'status':
|
||||
return output(statusView(loadState()));
|
||||
case 'rename':
|
||||
return rename(args);
|
||||
case 'banner':
|
||||
return output(banner());
|
||||
case 'legal':
|
||||
return output(legalText());
|
||||
case 'reset':
|
||||
return reset(args);
|
||||
case '--version':
|
||||
case '-v':
|
||||
case 'version':
|
||||
return output(pkg.version);
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help':
|
||||
return output(helpText(pkg.version));
|
||||
default:
|
||||
return output(`${paint(`Unknown command: ${command}`, 'red')}\n\n${helpText(pkg.version)}`, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function hatch(args) {
|
||||
const state = loadState();
|
||||
const force = args.includes('--force');
|
||||
const all = args.includes('--all');
|
||||
|
||||
if (all) {
|
||||
for (const creature of creatures) {
|
||||
ensureCompanionState(state, creature);
|
||||
}
|
||||
state.active = state.active ?? creatures[0].id;
|
||||
addEvent(state, 'hatch:all', { count: creatures.length });
|
||||
saveState(state);
|
||||
return output(`${boxed('All companions joined your terminal.', 'green')}\n\n${listCompanions(state)}`);
|
||||
}
|
||||
|
||||
const discoveredIds = Object.keys(state.companions);
|
||||
if (discoveredIds.length > 0 && !force) {
|
||||
return output(`${paint('You already have a companion.', 'yellow')} Use ${strong('pokebuddy hatch --force', 'green')} to discover another.\n\n${listCompanions(state)}`);
|
||||
}
|
||||
|
||||
const creature = nextCreature(discoveredIds);
|
||||
if (!creature) {
|
||||
return output(`${paint('Every known companion has already joined you.', 'yellow')}\n\n${listCompanions(state)}`);
|
||||
}
|
||||
|
||||
const entry = ensureCompanionState(state, creature);
|
||||
state.active = creature.id;
|
||||
addEvent(state, 'hatch', { id: creature.id });
|
||||
saveState(state);
|
||||
|
||||
return output(
|
||||
`${boxed('The terminal hums. A companion signal appears.', creature.color)}\n\n` +
|
||||
`${strong(`You hatched ${creature.name}!`, creature.color)}\n\n` +
|
||||
`${creatureCard(creature, entry)}`
|
||||
);
|
||||
}
|
||||
|
||||
function show(args) {
|
||||
const name = args[0];
|
||||
const state = loadState();
|
||||
|
||||
if (!name) {
|
||||
if (!state.active) return output(`Run ${strong('pokebuddy hatch', 'green')} first.`);
|
||||
const active = state.companions[state.active];
|
||||
const creature = getCreatureByName(active.id);
|
||||
return output(creatureCard(creature, active));
|
||||
}
|
||||
|
||||
const creature = getCreatureByName(name);
|
||||
if (!creature) return output(notFound(name), 1);
|
||||
return output(creatureCard(creature, state.companions[creature.id] ?? null));
|
||||
}
|
||||
|
||||
function interact(kind, args) {
|
||||
const maybeName = args[0];
|
||||
const state = loadState();
|
||||
let creature = maybeName ? getCreatureByName(maybeName) : null;
|
||||
|
||||
if (!creature && state.active) {
|
||||
creature = getCreatureByName(state.active);
|
||||
}
|
||||
if (!creature) {
|
||||
return output(`No companion selected. Run ${strong('pokebuddy hatch', 'green')} first.`, 1);
|
||||
}
|
||||
|
||||
const entry = ensureCompanionState(state, creature);
|
||||
const item = kind === 'feed' ? args.slice(1).join(' ') || 'context crumbs' : null;
|
||||
|
||||
if (kind === 'poke') {
|
||||
entry.pokes += 1;
|
||||
entry.energy = Math.min(100, entry.energy + 10);
|
||||
entry.xp += 12;
|
||||
entry.mood = entry.energy > 85 ? 'overclocked' : 'happy';
|
||||
}
|
||||
|
||||
if (kind === 'feed') {
|
||||
entry.feeds += 1;
|
||||
entry.energy = Math.min(100, entry.energy + 6);
|
||||
entry.xp += 18;
|
||||
entry.mood = 'focused';
|
||||
}
|
||||
|
||||
entry.level = 1 + Math.floor(entry.xp / 100);
|
||||
state.active = creature.id;
|
||||
addEvent(state, kind, { id: creature.id, item });
|
||||
saveState(state);
|
||||
|
||||
const reaction = kind === 'feed'
|
||||
? `${creature.reactions.feed}\n${dim(`Offering: ${item}`)}`
|
||||
: creature.reactions.poke;
|
||||
|
||||
return output(
|
||||
`${strong(`$ pokebuddy ${kind} ${creature.id}`, 'green')}\n` +
|
||||
`${paint(reaction, creature.color)}\n\n` +
|
||||
`Energy: ${entry.energy}% Mood: ${paint(entry.mood, 'green')} XP: ${entry.xp} Level: ${entry.level}`
|
||||
);
|
||||
}
|
||||
|
||||
function rename(args) {
|
||||
const [name, ...aliasParts] = args;
|
||||
const alias = aliasParts.join(' ').trim();
|
||||
if (!name || !alias) {
|
||||
return output(`Usage: ${strong('pokebuddy rename <name> <alias>', 'green')}`, 1);
|
||||
}
|
||||
|
||||
const creature = getCreatureByName(name);
|
||||
if (!creature) return output(notFound(name), 1);
|
||||
|
||||
const state = loadState();
|
||||
const entry = ensureCompanionState(state, creature);
|
||||
entry.alias = alias;
|
||||
addEvent(state, 'rename', { id: creature.id, alias });
|
||||
saveState(state);
|
||||
return output(`${paint(creature.name, creature.color)} is now known as ${strong(alias, creature.color)}.`);
|
||||
}
|
||||
|
||||
function reset(args) {
|
||||
if (!args.includes('--yes')) {
|
||||
return output(`This will remove local PokeBuddy state. Re-run with ${strong('pokebuddy reset --yes', 'red')} to confirm.`, 1);
|
||||
}
|
||||
|
||||
resetState();
|
||||
return output(`${paint('Local PokeBuddy state reset.', 'green')} Run ${strong('pokebuddy hatch', 'green')} to begin again.`);
|
||||
}
|
||||
|
||||
function notFound(name) {
|
||||
return `${paint(`Unknown companion: ${name}`, 'red')}\n\nKnown companions:\n${creatures.map(compactCreatureLine).join('\n')}`;
|
||||
}
|
||||
|
||||
function output(message, code = 0) {
|
||||
console.log(message);
|
||||
if (code !== 0) process.exitCode = code;
|
||||
}
|
||||
191
src/creatures.js
Normal file
191
src/creatures.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
export const rarityColors = {
|
||||
Uncommon: 'green',
|
||||
Rare: 'yellow',
|
||||
Epic: 'magenta',
|
||||
Mythic: 'cyan'
|
||||
};
|
||||
|
||||
export const creatures = [
|
||||
{
|
||||
id: 'zapbit',
|
||||
name: 'Zapbit',
|
||||
emoji: '⚡',
|
||||
type: ['Logic', 'Spark'],
|
||||
rarity: 'Rare',
|
||||
color: 'yellow',
|
||||
personality: 'Energetic, clever, and always ready to spark an idea.',
|
||||
flavor: 'Hyper-charged logic companion.',
|
||||
quote: 'I will light the way.',
|
||||
likes: ['clean commits', 'passing tests', 'short functions'],
|
||||
dislikes: ['flaky tests', 'mystery globals', 'panic refactors'],
|
||||
stats: { DEBUG: 74, FOCUS: 82, CHAOS: 61, WISDOM: 58, SNARK: 77 },
|
||||
reactions: {
|
||||
poke: 'Zapbit sparks happily. Great commit. Clean and efficient.',
|
||||
feed: 'Zapbit crunches the input into tiny bright logic crumbs.',
|
||||
status: 'Every circuit is humming. Slightly smug, but useful.',
|
||||
idle: 'Zapbit watches your cursor like it owes money.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
✦ ⚡ ✦
|
||||
/\__/\
|
||||
___/ ◉ ◉\___
|
||||
/ \ ▽ / \
|
||||
/____/\_0101_/\\____\
|
||||
/ || \
|
||||
⚡/___||___\⚡`
|
||||
},
|
||||
{
|
||||
id: 'diskettex',
|
||||
name: 'Diskettex',
|
||||
emoji: '💾',
|
||||
type: ['Storage', 'Memory'],
|
||||
rarity: 'Epic',
|
||||
color: 'cyan',
|
||||
personality: 'Reliable, nostalgic, and never forgets a byte.',
|
||||
flavor: 'Never forgets a byte.',
|
||||
quote: 'Old school. Always in sync.',
|
||||
likes: ['backups', 'version tags', 'good changelogs'],
|
||||
dislikes: ['force-push accidents', 'unnamed files', 'lost context'],
|
||||
stats: { DEBUG: 63, FOCUS: 91, CHAOS: 28, WISDOM: 85, SNARK: 52 },
|
||||
reactions: {
|
||||
poke: 'Diskettex blinks, saves your current vibe, and waves politely.',
|
||||
feed: 'Diskettex archives the offering under /memories/useful-things.',
|
||||
status: 'Diskettex is calm. Suspiciously organized.',
|
||||
idle: 'Diskettex labels a tiny folder called maybe-important.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
╔════════╗
|
||||
║ ▣ ↑ ║
|
||||
║ ║
|
||||
║ ◉ ◉ ║
|
||||
║ ▿ ║
|
||||
║ ┌────┐ ║
|
||||
╚═╧════╧═╝
|
||||
╱ ╲`
|
||||
},
|
||||
{
|
||||
id: 'nullwisp',
|
||||
name: 'NullWisp',
|
||||
emoji: '⌁',
|
||||
type: ['Void', 'Debug'],
|
||||
rarity: 'Mythic',
|
||||
color: 'cyan',
|
||||
personality: 'Ethereal, mysterious, and very good at finding what is missing.',
|
||||
flavor: 'Haunts hidden bugs.',
|
||||
quote: 'I see what others cannot.',
|
||||
likes: ['null checks', 'stack traces', 'quiet terminals'],
|
||||
dislikes: ['undefined behavior', 'silent failures', 'empty promises'],
|
||||
stats: { DEBUG: 96, FOCUS: 68, CHAOS: 88, WISDOM: 82, SNARK: 41 },
|
||||
reactions: {
|
||||
poke: 'NullWisp ripples through the terminal and points at a suspicious variable.',
|
||||
feed: 'NullWisp absorbs the fragment and whispers: not null anymore.',
|
||||
status: 'NullWisp is present, allegedly. The logs disagree.',
|
||||
idle: 'NullWisp quietly haunts your TODO comments.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
0 1 0
|
||||
.─────────.
|
||||
.╯ ◉ ◉ ╰.
|
||||
( NULL )
|
||||
╲ ───── ╱
|
||||
╰──╮ ╭──╯
|
||||
╰╮ ╭╯
|
||||
1 ╰───╯ 0`
|
||||
},
|
||||
{
|
||||
id: 'promptmoth',
|
||||
name: 'PromptMoth',
|
||||
emoji: '✦',
|
||||
type: ['AI', 'Prompt'],
|
||||
rarity: 'Epic',
|
||||
color: 'magenta',
|
||||
personality: 'Curious, expressive, and thriving on context.',
|
||||
flavor: 'Drawn to glowing context.',
|
||||
quote: 'Ask nicely. I will help you fly.',
|
||||
likes: ['clear instructions', 'examples', 'structured prompts'],
|
||||
dislikes: ['vague tasks', 'empty specs', 'context starvation'],
|
||||
stats: { DEBUG: 55, FOCUS: 79, CHAOS: 64, WISDOM: 92, SNARK: 63 },
|
||||
reactions: {
|
||||
poke: 'PromptMoth flutters around your request and improves the phrasing.',
|
||||
feed: 'PromptMoth drinks the context window like neon nectar.',
|
||||
status: 'PromptMoth is glowing. It has probably read too much.',
|
||||
idle: 'PromptMoth circles a blinking cursor with dramatic intent.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
╲\ ││ /╱
|
||||
╭──╲\──╳──/╱──╮
|
||||
╱ ◉ ◉ ╲
|
||||
│ ▽ │
|
||||
╲ ┌────────┐ ╱
|
||||
╰───│PROMPT │───╯
|
||||
└──┬──┬──┘
|
||||
╱__╲`
|
||||
},
|
||||
{
|
||||
id: 'cachegoblin',
|
||||
name: 'CacheGoblin',
|
||||
emoji: '🧩',
|
||||
type: ['Cache', 'Chaos'],
|
||||
rarity: 'Uncommon',
|
||||
color: 'green',
|
||||
personality: 'Greedy for speed and hiding things in all the right places.',
|
||||
flavor: 'Makes fast things suspicious.',
|
||||
quote: 'Fast things stay in my pockets.',
|
||||
likes: ['warm caches', 'tiny shortcuts', 'build artifacts'],
|
||||
dislikes: ['cache invalidation', 'cold starts', 'clean installs'],
|
||||
stats: { DEBUG: 49, FOCUS: 57, CHAOS: 95, WISDOM: 45, SNARK: 88 },
|
||||
reactions: {
|
||||
poke: 'CacheGoblin grins and returns an answer from somewhere it refuses to explain.',
|
||||
feed: 'CacheGoblin snatches the cache and scatters fragments everywhere.',
|
||||
status: 'CacheGoblin is fast today. That should make everyone nervous.',
|
||||
idle: 'CacheGoblin is hoarding .tmp files under the floorboards.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
.-""""-.
|
||||
.-╯ ◉ ◉ ╰-.
|
||||
/ ▿ \
|
||||
│ ┌────────┐ │
|
||||
│ │ CACHE │ │
|
||||
╲ └──┬──┬──┘ ╱
|
||||
╲___│__│___╱
|
||||
╱ ╲`
|
||||
},
|
||||
{
|
||||
id: 'mergedrake',
|
||||
name: 'MergeDrake',
|
||||
emoji: '🔥',
|
||||
type: ['Git', 'Fire'],
|
||||
rarity: 'Rare',
|
||||
color: 'orange',
|
||||
personality: 'Protective, loyal, and deeply invested in clean merges.',
|
||||
flavor: 'Guardian of branches.',
|
||||
quote: 'I guard your branches.',
|
||||
likes: ['rebases that end well', 'small pull requests', 'green CI'],
|
||||
dislikes: ['merge conflicts', 'stale branches', 'Friday deploys'],
|
||||
stats: { DEBUG: 71, FOCUS: 88, CHAOS: 67, WISDOM: 76, SNARK: 70 },
|
||||
reactions: {
|
||||
poke: 'MergeDrake puffs a tiny flame and promises to guard the branch.',
|
||||
feed: 'MergeDrake burns away conflict markers with theatrical dignity.',
|
||||
status: 'MergeDrake is perched on main, judging all branches equally.',
|
||||
idle: 'MergeDrake curls around a pull request and sleeps with one eye open.'
|
||||
},
|
||||
ascii: String.raw`
|
||||
/\___/\
|
||||
___/ ◉ ◉ \___
|
||||
/ ╱╲ \
|
||||
│ ┌─╯╰─┐ │
|
||||
╲____│ GIT│____╱
|
||||
└─┬──┘
|
||||
🔥 /_\ 🔥`
|
||||
}
|
||||
];
|
||||
|
||||
export function getCreatureByIdOrName(value) {
|
||||
if (!value) return null;
|
||||
const key = String(value).toLowerCase();
|
||||
return creatures.find((creature) => creature.id === key || creature.name.toLowerCase() === key) ?? null;
|
||||
}
|
||||
|
||||
export function getCreatureByName(value) {
|
||||
return getCreatureByIdOrName(value);
|
||||
}
|
||||
22
src/hatch.js
Normal file
22
src/hatch.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { creatures } from './creatures.js';
|
||||
|
||||
function seedSource() {
|
||||
const user = os.userInfo().username || 'anonymous';
|
||||
const host = os.hostname() || 'localhost';
|
||||
const custom = process.env.POKEBUDDY_SEED || '';
|
||||
return `${user}:${host}:${custom}:pokebuddy-cli-v0`;
|
||||
}
|
||||
|
||||
export function nextCreature(excludedIds = []) {
|
||||
const pool = creatures.filter((creature) => !excludedIds.includes(creature.id));
|
||||
if (pool.length === 0) return null;
|
||||
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${seedSource()}:${excludedIds.sort().join(',')}`)
|
||||
.digest('hex');
|
||||
const index = parseInt(digest.slice(0, 8), 16) % pool.length;
|
||||
return pool[index];
|
||||
}
|
||||
115
src/render.js
Normal file
115
src/render.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { creatures, rarityColors } from './creatures.js';
|
||||
import { paint, strong, dim, stripAnsi } from './ansi.js';
|
||||
|
||||
export function banner() {
|
||||
return `${paint('╭──────────────────────────────────────────────╮', 'green')}\n` +
|
||||
`${paint('│', 'green')} ${strong('PokeBuddy', 'white')} ${strong('CLI', 'green')} ${dim('· tiny terminal companions')} ${paint('│', 'green')}\n` +
|
||||
`${paint('│', 'green')} ${paint('POKE', 'green')} = Programmable Open-source Kinetic Entities ${paint('│', 'green')}\n` +
|
||||
`${paint('╰──────────────────────────────────────────────╯', 'green')}`;
|
||||
}
|
||||
|
||||
export function helpText(version) {
|
||||
return `${banner()}\n\n` +
|
||||
`${strong('Usage', 'green')}\n` +
|
||||
` pokebuddy <command> [options]\n\n` +
|
||||
`${strong('Core commands', 'green')}\n` +
|
||||
` hatch [--all] [--force] Hatch a companion\n` +
|
||||
` list List discovered companions\n` +
|
||||
` dex Show every known companion\n` +
|
||||
` show [name] Show one companion card\n` +
|
||||
` poke [name] Interact with a companion\n` +
|
||||
` feed [name] [thing] Feed context, cache or a snack\n` +
|
||||
` status Show local companion status\n` +
|
||||
` rename <name> <alias> Rename a discovered companion\n` +
|
||||
` banner Print the project banner\n` +
|
||||
` legal Print legal notice\n` +
|
||||
` reset --yes Reset local state\n\n` +
|
||||
`${strong('Examples', 'green')}\n` +
|
||||
` pokebuddy hatch\n` +
|
||||
` pokebuddy poke zapbit\n` +
|
||||
` pokebuddy feed cachegoblin ./build/cache\n` +
|
||||
` pokebuddy show nullwisp\n\n` +
|
||||
`${dim(`v${version} · local-first · offline-ready`)}`;
|
||||
}
|
||||
|
||||
export function legalText() {
|
||||
return `${strong('Legal notice', 'green')}\n\n` +
|
||||
`PokeBuddy CLI is an independent open-source project.\n` +
|
||||
`It is not affiliated with, endorsed by, sponsored by, authorized by,\n` +
|
||||
`or officially connected with Nintendo, The Pokémon Company, Game Freak,\n` +
|
||||
`Creatures Inc., Anthropic, or Claude Code.\n\n` +
|
||||
`POKE means Programmable Open-source Kinetic Entities.\n` +
|
||||
`All companions, names, lore, ASCII art, and code in this repository are original.\n` +
|
||||
`Do not submit official characters, sprites, names, logos, music, or derivative assets.\n`;
|
||||
}
|
||||
|
||||
export function listCompanions(state) {
|
||||
const discovered = Object.values(state.companions);
|
||||
if (discovered.length === 0) {
|
||||
return `${paint('No companions discovered yet.', 'yellow')} Run ${strong('pokebuddy hatch', 'green')} to begin.`;
|
||||
}
|
||||
|
||||
const rows = discovered.map((entry) => {
|
||||
const creature = creatures.find((item) => item.id === entry.id);
|
||||
const color = creature?.color ?? 'green';
|
||||
const active = state.active === entry.id ? paint('●', 'green') : ' ';
|
||||
return ` ${active} ${paint(entry.alias.padEnd(14), color)} ${paint(creature.emoji, color)} ${entry.mood.padEnd(12)} lv.${entry.level} xp.${entry.xp}`;
|
||||
});
|
||||
|
||||
return `${strong('$ pokebuddy list', 'green')}\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
export function dex() {
|
||||
return creatures.map((creature) => compactCreatureLine(creature)).join('\n');
|
||||
}
|
||||
|
||||
export function compactCreatureLine(creature) {
|
||||
const rarityColor = rarityColors[creature.rarity] ?? creature.color;
|
||||
return `${paint(creature.name.padEnd(13), creature.color)} ${paint(creature.emoji, creature.color)} ${creature.type.join(' / ').padEnd(18)} ${paint(creature.rarity, rarityColor)} ${dim(creature.flavor)}`;
|
||||
}
|
||||
|
||||
export function creatureCard(creature, entry = null) {
|
||||
const title = `${creature.emoji} ${creature.name}`;
|
||||
const alias = entry && entry.alias !== creature.name ? ` aka ${entry.alias}` : '';
|
||||
const stats = Object.entries(creature.stats)
|
||||
.map(([label, value]) => ` ${label.padEnd(7)} ${bar(value)} ${String(value).padStart(3)}`)
|
||||
.join('\n');
|
||||
|
||||
const meta = [
|
||||
`Type: ${paint(creature.type.join(' / '), creature.color)}`,
|
||||
`Rarity: ${paint(creature.rarity, rarityColors[creature.rarity] ?? creature.color)}`,
|
||||
entry ? `Level: ${entry.level} XP: ${entry.xp} Mood: ${entry.mood}` : null
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return `${strong(title, creature.color)}${dim(alias)}\n${paint(creature.ascii, creature.color)}\n\n${meta}\n\n${creature.personality}\n\n${paint(`“${creature.quote}”`, creature.color)}\n\n${stats}`;
|
||||
}
|
||||
|
||||
export function statusView(state) {
|
||||
const discovered = Object.values(state.companions);
|
||||
const active = discovered.find((item) => item.id === state.active) ?? discovered[0];
|
||||
const activeCreature = active ? creatures.find((item) => item.id === active.id) : null;
|
||||
|
||||
return `${strong('$ pokebuddy status', 'green')}\n` +
|
||||
`Companions discovered: ${paint(String(discovered.length), 'green')} / ${creatures.length}\n` +
|
||||
`Active companion: ${activeCreature ? paint(active.alias, activeCreature.color) : dim('none')}\n` +
|
||||
`State file: ${dim('~/.pokebuddy/state.json')}\n` +
|
||||
`Recent events: ${state.events.length}\n` +
|
||||
(activeCreature ? `\n${creatureCard(activeCreature, active)}` : `\nRun ${strong('pokebuddy hatch', 'green')} to hatch your first companion.`);
|
||||
}
|
||||
|
||||
export function bar(value, width = 10) {
|
||||
const filled = Math.max(0, Math.min(width, Math.round((value / 100) * width)));
|
||||
return `${paint('█'.repeat(filled), 'green')}${dim('░'.repeat(width - filled))}`;
|
||||
}
|
||||
|
||||
export function boxed(message, color = 'green') {
|
||||
const lines = String(message).split('\n');
|
||||
const width = Math.max(...lines.map((line) => stripAnsi(line).length));
|
||||
const top = `╭${'─'.repeat(width + 2)}╮`;
|
||||
const bottom = `╰${'─'.repeat(width + 2)}╯`;
|
||||
const body = lines.map((line) => {
|
||||
const pad = ' '.repeat(width - stripAnsi(line).length);
|
||||
return `│ ${line}${pad} │`;
|
||||
});
|
||||
return [paint(top, color), ...body.map((line) => paint(line, color)), paint(bottom, color)].join('\n');
|
||||
}
|
||||
74
src/state.js
Normal file
74
src/state.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const STATE_DIR = path.join(os.homedir(), '.pokebuddy');
|
||||
const STATE_FILE = path.join(STATE_DIR, 'state.json');
|
||||
|
||||
export function getStatePath() {
|
||||
return STATE_FILE;
|
||||
}
|
||||
|
||||
export function loadState() {
|
||||
if (!fs.existsSync(STATE_FILE)) {
|
||||
return createInitialState();
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return createInitialState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state) {
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
fs.writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export function resetState() {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
fs.rmSync(STATE_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
export function createInitialState() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
active: null,
|
||||
companions: {},
|
||||
events: []
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureCompanionState(state, creature) {
|
||||
if (!state.companions[creature.id]) {
|
||||
state.companions[creature.id] = {
|
||||
id: creature.id,
|
||||
alias: creature.name,
|
||||
discoveredAt: new Date().toISOString(),
|
||||
level: 1,
|
||||
xp: 0,
|
||||
energy: 50,
|
||||
mood: 'idle',
|
||||
pokes: 0,
|
||||
feeds: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (!state.active) {
|
||||
state.active = creature.id;
|
||||
}
|
||||
|
||||
return state.companions[creature.id];
|
||||
}
|
||||
|
||||
export function addEvent(state, type, payload = {}) {
|
||||
state.events.unshift({
|
||||
type,
|
||||
payload,
|
||||
at: new Date().toISOString()
|
||||
});
|
||||
state.events = state.events.slice(0, 30);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue