bugcap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/components/Game.d.ts +2 -0
- package/dist/components/Game.js +172 -0
- package/dist/economy.d.ts +3 -0
- package/dist/economy.js +77 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +87 -0
- package/dist/watcher.d.ts +4 -0
- package/dist/watcher.js +170 -0
- package/package.json +34 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { resetState, loadState } from "./state.js";
|
|
6
|
+
import { startWatcher, startEmbeddedWatcher } from "./watcher.js";
|
|
7
|
+
import Game from "./components/Game.js";
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name("bugcap")
|
|
11
|
+
.description("Turn your AI coding into a terminal idle game")
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
// Default command — watcher + TUI together
|
|
14
|
+
program
|
|
15
|
+
.action(() => {
|
|
16
|
+
const dir = process.cwd();
|
|
17
|
+
const cleanupWatcher = startEmbeddedWatcher(dir);
|
|
18
|
+
const { unmount } = render(React.createElement(Game));
|
|
19
|
+
function cleanup() {
|
|
20
|
+
cleanupWatcher();
|
|
21
|
+
unmount();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
process.on("SIGINT", cleanup);
|
|
25
|
+
process.on("SIGTERM", cleanup);
|
|
26
|
+
});
|
|
27
|
+
// Watch command — headless watcher (no TUI)
|
|
28
|
+
program
|
|
29
|
+
.command("watch [path]")
|
|
30
|
+
.description("Start a headless watcher (no game UI) for an additional project directory")
|
|
31
|
+
.action((targetPath) => {
|
|
32
|
+
const dir = targetPath ?? process.cwd();
|
|
33
|
+
startWatcher(dir);
|
|
34
|
+
});
|
|
35
|
+
// Reset command
|
|
36
|
+
program
|
|
37
|
+
.command("reset")
|
|
38
|
+
.description("Reset all game progress")
|
|
39
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
40
|
+
.action(async (options) => {
|
|
41
|
+
const state = loadState();
|
|
42
|
+
if (!state) {
|
|
43
|
+
console.log("No game data to reset.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!options.force) {
|
|
47
|
+
const readline = await import("node:readline");
|
|
48
|
+
const rl = readline.createInterface({
|
|
49
|
+
input: process.stdin,
|
|
50
|
+
output: process.stdout,
|
|
51
|
+
});
|
|
52
|
+
const answer = await new Promise((resolve) => {
|
|
53
|
+
rl.question(`Are you sure you want to reset? You'll lose $${state.money.toLocaleString()} and ${state.tokens.toLocaleString()} tokens. (y/N) `, resolve);
|
|
54
|
+
});
|
|
55
|
+
rl.close();
|
|
56
|
+
if (answer.toLowerCase() !== "y") {
|
|
57
|
+
console.log("Reset cancelled.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
resetState();
|
|
62
|
+
console.log("Game progress has been reset.");
|
|
63
|
+
});
|
|
64
|
+
program.parse();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useStdout } from "ink";
|
|
4
|
+
import { loadState, loadRecentEvents, } from "../state.js";
|
|
5
|
+
import { getLevelMultiplier } from "../economy.js";
|
|
6
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
7
|
+
function formatNumber(n) {
|
|
8
|
+
return n.toLocaleString("en-US");
|
|
9
|
+
}
|
|
10
|
+
function formatMoney(n) {
|
|
11
|
+
if (n >= 1_000_000)
|
|
12
|
+
return `$${(n / 1_000_000).toFixed(2)}M`;
|
|
13
|
+
if (n >= 10_000)
|
|
14
|
+
return `$${(n / 1_000).toFixed(1)}K`;
|
|
15
|
+
return `$${formatNumber(n)}`;
|
|
16
|
+
}
|
|
17
|
+
function xpForNextLevel(level) {
|
|
18
|
+
return (level + 1) * 500;
|
|
19
|
+
}
|
|
20
|
+
function timeAgo(timestamp) {
|
|
21
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
22
|
+
if (seconds < 5)
|
|
23
|
+
return "now";
|
|
24
|
+
if (seconds < 60)
|
|
25
|
+
return `${seconds}s`;
|
|
26
|
+
const minutes = Math.floor(seconds / 60);
|
|
27
|
+
if (minutes < 60)
|
|
28
|
+
return `${minutes}m`;
|
|
29
|
+
const hours = Math.floor(minutes / 60);
|
|
30
|
+
return `${hours}h`;
|
|
31
|
+
}
|
|
32
|
+
// ── ASCII Art ────────────────────────────────────────────
|
|
33
|
+
const LOGO_LINES = [
|
|
34
|
+
" ____ _ _ ____ ____ _ ____ ",
|
|
35
|
+
"| __ )| | | |/ ___| / ___| / \\ | _ \\ ",
|
|
36
|
+
"| _ \\| | | | | _ | | / _ \\ | |_) |",
|
|
37
|
+
"| |_) | |_| | |_| | | |___ / ___ \\| __/ ",
|
|
38
|
+
"|____/ \\___/ \\____| \\____/_/ \\_\\_| ",
|
|
39
|
+
];
|
|
40
|
+
const LOGO_COLORS = [
|
|
41
|
+
"green",
|
|
42
|
+
"green",
|
|
43
|
+
"cyan",
|
|
44
|
+
"cyan",
|
|
45
|
+
"yellow",
|
|
46
|
+
];
|
|
47
|
+
const BUG_ART = [
|
|
48
|
+
" _ ",
|
|
49
|
+
" / \\ ",
|
|
50
|
+
" / _ \\ ",
|
|
51
|
+
" | |_| | ",
|
|
52
|
+
" \\___/ BUG! ",
|
|
53
|
+
];
|
|
54
|
+
const MINI_BUG = "~(o.o)~";
|
|
55
|
+
// ── Animated Components ──────────────────────────────────
|
|
56
|
+
const PULSE_FRAMES = ["*", "o", "O", "o"];
|
|
57
|
+
const SPINNER_FRAMES = [" ", ". ", ".. ", "..."];
|
|
58
|
+
function useAnimationFrame(fps = 4) {
|
|
59
|
+
const [frame, setFrame] = useState(0);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const interval = setInterval(() => {
|
|
62
|
+
setFrame((f) => f + 1);
|
|
63
|
+
}, 1000 / fps);
|
|
64
|
+
return () => clearInterval(interval);
|
|
65
|
+
}, [fps]);
|
|
66
|
+
return frame;
|
|
67
|
+
}
|
|
68
|
+
// ── Progress Bar ─────────────────────────────────────────
|
|
69
|
+
function ProgressBar({ current, max, width = 20, filledColor = "green", emptyColor, }) {
|
|
70
|
+
const ratio = Math.min(current / max, 1);
|
|
71
|
+
const filled = Math.round(ratio * width);
|
|
72
|
+
const empty = width - filled;
|
|
73
|
+
const percent = Math.round(ratio * 100);
|
|
74
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "white", children: "[" }), _jsx(Text, { color: filledColor, children: "█".repeat(filled) }), _jsx(Text, { dimColor: true, children: "░".repeat(empty) }), _jsx(Text, { color: "white", children: "]" }), _jsxs(Text, { dimColor: true, children: [" ", percent, "%"] })] }));
|
|
75
|
+
}
|
|
76
|
+
// ── Stat Box ─────────────────────────────────────────────
|
|
77
|
+
function StatBox({ label, value, color = "white", }) {
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [_jsx(Text, { dimColor: true, children: label }), _jsx(Text, { bold: true, color: color, children: value })] }));
|
|
79
|
+
}
|
|
80
|
+
// ── Event Line ───────────────────────────────────────────
|
|
81
|
+
function EventLine({ event, showTime = false, }) {
|
|
82
|
+
const iconMap = {
|
|
83
|
+
token_gain: " +",
|
|
84
|
+
bug_found: " !",
|
|
85
|
+
bug_sold: " $",
|
|
86
|
+
level_up: " *",
|
|
87
|
+
};
|
|
88
|
+
const colorMap = {
|
|
89
|
+
token_gain: "green",
|
|
90
|
+
bug_found: "red",
|
|
91
|
+
bug_sold: "yellow",
|
|
92
|
+
level_up: "cyan",
|
|
93
|
+
};
|
|
94
|
+
const icon = iconMap[event.type] ?? " ?";
|
|
95
|
+
const color = colorMap[event.type] ?? "white";
|
|
96
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, children: event.description ?? event.type }), showTime && _jsxs(Text, { dimColor: true, children: [" ", timeAgo(event.timestamp)] })] }));
|
|
97
|
+
}
|
|
98
|
+
// ── Welcome Screen ───────────────────────────────────────
|
|
99
|
+
function WelcomeScreen() {
|
|
100
|
+
const frame = useAnimationFrame(2);
|
|
101
|
+
const bugFrame = frame % 2 === 0 ? MINI_BUG : "~(O.O)~";
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { flexDirection: "column", marginBottom: 1, children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: line }, i))) }), _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: bugFrame }), _jsx(Text, { children: " Watching for changes... Start coding!" })] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Your AI coding activity will appear here." }), _jsx(Text, { dimColor: true, children: "Watch extra projects: npx bugcap watch ~/other-project" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Turn your AI coding into a terminal idle game" }) })] }));
|
|
103
|
+
}
|
|
104
|
+
// ── Compact View ─────────────────────────────────────────
|
|
105
|
+
function CompactView({ state, isOnline, }) {
|
|
106
|
+
const frame = useAnimationFrame(2);
|
|
107
|
+
const pulse = isOnline ? PULSE_FRAMES[frame % PULSE_FRAMES.length] : "x";
|
|
108
|
+
const pulseColor = isOnline ? "green" : "red";
|
|
109
|
+
return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: pulseColor, children: ["[", pulse, "]"] }), _jsxs(Text, { bold: true, color: "green", children: [" ", "BUG CAP", " "] }), _jsx(Text, { bold: true, color: "yellow", children: formatMoney(state.money) }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: "cyan", children: ["Lv", state.level] }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { children: ["T:", formatNumber(state.tokens)] }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: "red", children: ["B:", state.bugsSold] })] }) }));
|
|
110
|
+
}
|
|
111
|
+
// ── Normal View ──────────────────────────────────────────
|
|
112
|
+
function NormalView({ state, events, isOnline, }) {
|
|
113
|
+
const frame = useAnimationFrame(4);
|
|
114
|
+
const multiplier = getLevelMultiplier(state.level);
|
|
115
|
+
const xpNeeded = xpForNextLevel(state.level);
|
|
116
|
+
const xpProgress = Math.min(state.xp, xpNeeded);
|
|
117
|
+
// Animated watcher indicator
|
|
118
|
+
const pulse = isOnline ? PULSE_FRAMES[frame % PULSE_FRAMES.length] : "x";
|
|
119
|
+
const pulseColor = isOnline ? "green" : "red";
|
|
120
|
+
const statusText = isOnline ? "LIVE" : "OFFLINE";
|
|
121
|
+
// Money flash effect: alternate bold on recent change
|
|
122
|
+
const recentMoney = events.some((e) => e.type === "bug_sold" && Date.now() - e.timestamp < 3000);
|
|
123
|
+
const moneyFlash = recentMoney && frame % 2 === 0;
|
|
124
|
+
// Activity dots
|
|
125
|
+
const dots = isOnline ? SPINNER_FRAMES[frame % SPINNER_FRAMES.length] : "";
|
|
126
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { flexDirection: "row", justifyContent: "space-between", children: _jsx(Box, { children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: i === 0 ? line : "\n" + line }, i))) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: pulseColor, bold: true, children: ["[", pulse, "]"] }), _jsxs(Text, { color: pulseColor, bold: true, children: [" ", statusText] }), _jsx(Text, { dimColor: true, children: dots })] }) }), _jsx(Box, { borderStyle: "round", borderColor: moneyFlash ? "green" : "yellow", paddingX: 2, marginTop: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Bug Bounty" }), _jsxs(Text, { bold: true, color: moneyFlash ? "green" : "yellow", children: [" ", "$", formatNumber(state.money)] })] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(StatBox, { label: "Level", value: `${state.level} (${multiplier.toFixed(2)}x)`, color: "cyan" }), _jsx(StatBox, { label: "Bugs Sold", value: formatNumber(state.bugsSold), color: "red" }), _jsx(StatBox, { label: "Tokens", value: formatNumber(state.tokens), color: "green" })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "XP " }), _jsxs(Text, { children: [formatNumber(xpProgress), "/", formatNumber(xpNeeded)] })] }), _jsx(ProgressBar, { current: xpProgress, max: xpNeeded, width: 30, filledColor: "cyan" })] }), events.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: [" ", "Recent Activity", " "] }), events.map((event, i) => (_jsx(EventLine, { event: event, showTime: i === events.length - 1 }, i)))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [MINI_BUG, " Keep coding to earn more!"] }) })] }));
|
|
127
|
+
}
|
|
128
|
+
// ── Main Component ───────────────────────────────────────
|
|
129
|
+
export default function Game() {
|
|
130
|
+
const [state, setState] = useState(null);
|
|
131
|
+
const [events, setEvents] = useState([]);
|
|
132
|
+
const [initialized, setInitialized] = useState(false);
|
|
133
|
+
const { stdout } = useStdout();
|
|
134
|
+
const columns = stdout?.columns ?? 80;
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
function poll() {
|
|
137
|
+
const s = loadState();
|
|
138
|
+
setState(s);
|
|
139
|
+
if (s) {
|
|
140
|
+
setEvents(loadRecentEvents(8));
|
|
141
|
+
}
|
|
142
|
+
if (!initialized)
|
|
143
|
+
setInitialized(true);
|
|
144
|
+
}
|
|
145
|
+
poll();
|
|
146
|
+
const interval = setInterval(poll, 1000);
|
|
147
|
+
return () => clearInterval(interval);
|
|
148
|
+
}, [initialized]);
|
|
149
|
+
if (!initialized) {
|
|
150
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "cyan", children: "Loading..." }) }));
|
|
151
|
+
}
|
|
152
|
+
if (!state) {
|
|
153
|
+
return _jsx(WelcomeScreen, {});
|
|
154
|
+
}
|
|
155
|
+
const isOnline = isWatcherOnline(state);
|
|
156
|
+
if (columns < 60) {
|
|
157
|
+
return _jsx(CompactView, { state: state, isOnline: isOnline });
|
|
158
|
+
}
|
|
159
|
+
return _jsx(NormalView, { state: state, events: events, isOnline: isOnline });
|
|
160
|
+
}
|
|
161
|
+
function isWatcherOnline(state) {
|
|
162
|
+
if (state.watcherPid) {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(state.watcherPid, 0);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
package/dist/economy.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { appendEvent } from "./state.js";
|
|
2
|
+
// Tokens earned per line changed
|
|
3
|
+
const TOKENS_PER_LINE = 5;
|
|
4
|
+
// Base bug chance per batch of changes (percentage)
|
|
5
|
+
const BUG_BASE_CHANCE = 15;
|
|
6
|
+
// Extra bug chance per 50 lines changed
|
|
7
|
+
const BUG_CHANCE_PER_50_LINES = 10;
|
|
8
|
+
// Base price for selling a bug
|
|
9
|
+
const BUG_BASE_PRICE = 25;
|
|
10
|
+
// XP gained per token earned
|
|
11
|
+
const XP_PER_TOKEN = 0.1;
|
|
12
|
+
// XP required for each level: level N requires N * 500 XP
|
|
13
|
+
function xpForLevel(level) {
|
|
14
|
+
return level * 500;
|
|
15
|
+
}
|
|
16
|
+
export function getLevelMultiplier(level) {
|
|
17
|
+
return 1 + (level - 1) * 0.25;
|
|
18
|
+
}
|
|
19
|
+
export function processFileChange(state, linesChanged) {
|
|
20
|
+
const events = [];
|
|
21
|
+
// Calculate tokens
|
|
22
|
+
const tokens = Math.max(1, Math.round(linesChanged * TOKENS_PER_LINE));
|
|
23
|
+
state.tokens += tokens;
|
|
24
|
+
const tokenEvent = {
|
|
25
|
+
type: "token_gain",
|
|
26
|
+
value: tokens,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
description: `${linesChanged} lines changed — +${tokens} tokens`,
|
|
29
|
+
};
|
|
30
|
+
events.push(tokenEvent);
|
|
31
|
+
appendEvent(tokenEvent);
|
|
32
|
+
// XP gain
|
|
33
|
+
state.xp += Math.round(tokens * XP_PER_TOKEN);
|
|
34
|
+
// Check level up
|
|
35
|
+
while (state.xp >= xpForLevel(state.level + 1)) {
|
|
36
|
+
state.level++;
|
|
37
|
+
const levelEvent = {
|
|
38
|
+
type: "level_up",
|
|
39
|
+
value: state.level,
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
description: `Level up! Now level ${state.level}`,
|
|
42
|
+
};
|
|
43
|
+
events.push(levelEvent);
|
|
44
|
+
appendEvent(levelEvent);
|
|
45
|
+
}
|
|
46
|
+
// Bug generation — chance increases with more lines changed
|
|
47
|
+
const bugChance = BUG_BASE_CHANCE + Math.floor(linesChanged / 50) * BUG_CHANCE_PER_50_LINES;
|
|
48
|
+
if (Math.random() * 100 < bugChance) {
|
|
49
|
+
state.bugs++;
|
|
50
|
+
const bugEvent = {
|
|
51
|
+
type: "bug_found",
|
|
52
|
+
value: 1,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
description: "Bug detected — queued for sale",
|
|
55
|
+
};
|
|
56
|
+
events.push(bugEvent);
|
|
57
|
+
appendEvent(bugEvent);
|
|
58
|
+
}
|
|
59
|
+
// Auto-sell bugs
|
|
60
|
+
while (state.bugs > 0) {
|
|
61
|
+
const multiplier = getLevelMultiplier(state.level);
|
|
62
|
+
const price = Math.round(BUG_BASE_PRICE * multiplier);
|
|
63
|
+
state.bugs--;
|
|
64
|
+
state.bugsSold++;
|
|
65
|
+
state.money += price;
|
|
66
|
+
const sellEvent = {
|
|
67
|
+
type: "bug_sold",
|
|
68
|
+
value: price,
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
description: `Bug sold — +$${price}`,
|
|
71
|
+
};
|
|
72
|
+
events.push(sellEvent);
|
|
73
|
+
appendEvent(sellEvent);
|
|
74
|
+
}
|
|
75
|
+
state.lastEventAt = Date.now();
|
|
76
|
+
return events;
|
|
77
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface GameState {
|
|
2
|
+
tokens: number;
|
|
3
|
+
bugs: number;
|
|
4
|
+
bugsSold: number;
|
|
5
|
+
money: number;
|
|
6
|
+
xp: number;
|
|
7
|
+
level: number;
|
|
8
|
+
lastEventAt: number;
|
|
9
|
+
watcherPid: number | null;
|
|
10
|
+
}
|
|
11
|
+
export interface GameEvent {
|
|
12
|
+
type: "token_gain" | "bug_found" | "bug_sold" | "level_up";
|
|
13
|
+
value: number;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function defaultState(): GameState;
|
|
18
|
+
export declare function loadState(): GameState | null;
|
|
19
|
+
export declare function saveState(state: GameState): void;
|
|
20
|
+
export declare function appendEvent(event: GameEvent): void;
|
|
21
|
+
export declare function loadRecentEvents(count?: number): GameEvent[];
|
|
22
|
+
export declare function resetState(): void;
|
|
23
|
+
export declare function getStatePath(): string;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
const BUGCAP_DIR = path.join(os.homedir(), ".bugcap");
|
|
5
|
+
const STATE_FILE = path.join(BUGCAP_DIR, "state.json");
|
|
6
|
+
const EVENTS_FILE = path.join(BUGCAP_DIR, "events.jsonl");
|
|
7
|
+
const MAX_EVENTS_SIZE = 5 * 1024 * 1024; // 5MB
|
|
8
|
+
export function defaultState() {
|
|
9
|
+
return {
|
|
10
|
+
tokens: 0,
|
|
11
|
+
bugs: 0,
|
|
12
|
+
bugsSold: 0,
|
|
13
|
+
money: 0,
|
|
14
|
+
xp: 0,
|
|
15
|
+
level: 1,
|
|
16
|
+
lastEventAt: 0,
|
|
17
|
+
watcherPid: null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function ensureDir() {
|
|
21
|
+
if (!fs.existsSync(BUGCAP_DIR)) {
|
|
22
|
+
fs.mkdirSync(BUGCAP_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function loadState() {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(STATE_FILE))
|
|
28
|
+
return null;
|
|
29
|
+
const data = fs.readFileSync(STATE_FILE, "utf-8");
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function saveState(state) {
|
|
37
|
+
ensureDir();
|
|
38
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
39
|
+
}
|
|
40
|
+
export function appendEvent(event) {
|
|
41
|
+
ensureDir();
|
|
42
|
+
// Rotate if too large
|
|
43
|
+
try {
|
|
44
|
+
if (fs.existsSync(EVENTS_FILE)) {
|
|
45
|
+
const stats = fs.statSync(EVENTS_FILE);
|
|
46
|
+
if (stats.size >= MAX_EVENTS_SIZE) {
|
|
47
|
+
// Keep last half of the file
|
|
48
|
+
const content = fs.readFileSync(EVENTS_FILE, "utf-8");
|
|
49
|
+
const lines = content.trim().split("\n");
|
|
50
|
+
const half = lines.slice(Math.floor(lines.length / 2));
|
|
51
|
+
fs.writeFileSync(EVENTS_FILE, half.join("\n") + "\n");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// ignore rotation errors
|
|
57
|
+
}
|
|
58
|
+
fs.appendFileSync(EVENTS_FILE, JSON.stringify(event) + "\n");
|
|
59
|
+
}
|
|
60
|
+
export function loadRecentEvents(count = 10) {
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(EVENTS_FILE))
|
|
63
|
+
return [];
|
|
64
|
+
const content = fs.readFileSync(EVENTS_FILE, "utf-8");
|
|
65
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
66
|
+
return lines
|
|
67
|
+
.slice(-count)
|
|
68
|
+
.map((line) => JSON.parse(line));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function resetState() {
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(STATE_FILE))
|
|
77
|
+
fs.unlinkSync(STATE_FILE);
|
|
78
|
+
if (fs.existsSync(EVENTS_FILE))
|
|
79
|
+
fs.unlinkSync(EVENTS_FILE);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function getStatePath() {
|
|
86
|
+
return STATE_FILE;
|
|
87
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Headless watcher with console output — used by `bugcap watch` */
|
|
2
|
+
export declare function startWatcher(targetDir: string): void;
|
|
3
|
+
/** Embedded watcher (no console output) — used by default `bugcap` command */
|
|
4
|
+
export declare function startEmbeddedWatcher(targetDir: string): () => void;
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { watch } from "chokidar";
|
|
4
|
+
import { loadState, saveState, defaultState } from "./state.js";
|
|
5
|
+
import { processFileChange } from "./economy.js";
|
|
6
|
+
const IGNORED_DIRS = [
|
|
7
|
+
".git",
|
|
8
|
+
"node_modules",
|
|
9
|
+
"vendor",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
".next",
|
|
13
|
+
"__pycache__",
|
|
14
|
+
".venv",
|
|
15
|
+
"target",
|
|
16
|
+
];
|
|
17
|
+
// Track file sizes to estimate lines changed
|
|
18
|
+
const fileSizes = new Map();
|
|
19
|
+
function getFileLineCount(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
22
|
+
return content.split("\n").length;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function loadGitignorePatterns(dir) {
|
|
29
|
+
const gitignorePath = path.join(dir, ".gitignore");
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(gitignorePath))
|
|
32
|
+
return [];
|
|
33
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
34
|
+
return content
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Debounce changes per file
|
|
44
|
+
const pendingChanges = new Map();
|
|
45
|
+
const DEBOUNCE_MS = 500;
|
|
46
|
+
function createWatcher(targetDir) {
|
|
47
|
+
const resolvedDir = path.resolve(targetDir);
|
|
48
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
49
|
+
throw new Error(`Directory "${resolvedDir}" does not exist.`);
|
|
50
|
+
}
|
|
51
|
+
// Load or create state
|
|
52
|
+
let state = loadState() ?? defaultState();
|
|
53
|
+
state.watcherPid = process.pid;
|
|
54
|
+
saveState(state);
|
|
55
|
+
// Build ignore patterns
|
|
56
|
+
const gitignorePatterns = loadGitignorePatterns(resolvedDir);
|
|
57
|
+
const ignored = [
|
|
58
|
+
...IGNORED_DIRS.map((d) => `**/${d}/**`),
|
|
59
|
+
...gitignorePatterns,
|
|
60
|
+
];
|
|
61
|
+
const watcher = watch(resolvedDir, {
|
|
62
|
+
ignored,
|
|
63
|
+
persistent: true,
|
|
64
|
+
ignoreInitial: true,
|
|
65
|
+
awaitWriteFinish: {
|
|
66
|
+
stabilityThreshold: 300,
|
|
67
|
+
pollInterval: 100,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
function handleChange(filePath) {
|
|
71
|
+
const existing = pendingChanges.get(filePath);
|
|
72
|
+
if (existing)
|
|
73
|
+
clearTimeout(existing);
|
|
74
|
+
pendingChanges.set(filePath, setTimeout(() => {
|
|
75
|
+
pendingChanges.delete(filePath);
|
|
76
|
+
const currentLines = getFileLineCount(filePath);
|
|
77
|
+
const previousLines = fileSizes.get(filePath) ?? 0;
|
|
78
|
+
const linesChanged = Math.abs(currentLines - previousLines);
|
|
79
|
+
fileSizes.set(filePath, currentLines);
|
|
80
|
+
if (linesChanged === 0 && previousLines > 0)
|
|
81
|
+
return;
|
|
82
|
+
const effectiveLines = Math.max(1, linesChanged);
|
|
83
|
+
state = loadState() ?? state;
|
|
84
|
+
processFileChange(state, effectiveLines);
|
|
85
|
+
saveState(state);
|
|
86
|
+
}, DEBOUNCE_MS));
|
|
87
|
+
}
|
|
88
|
+
return { watcher, handleChange };
|
|
89
|
+
}
|
|
90
|
+
/** Headless watcher with console output — used by `bugcap watch` */
|
|
91
|
+
export function startWatcher(targetDir) {
|
|
92
|
+
const resolvedDir = path.resolve(targetDir);
|
|
93
|
+
let state;
|
|
94
|
+
const { watcher, handleChange } = createWatcher(resolvedDir);
|
|
95
|
+
state = loadState();
|
|
96
|
+
let totalTokens = state?.tokens ?? 0;
|
|
97
|
+
let totalBugs = state?.bugsSold ?? 0;
|
|
98
|
+
let eventCount = 0;
|
|
99
|
+
console.log(`Bug Capitalist — watching ${resolvedDir}`);
|
|
100
|
+
console.log("Capturing file changes... (Ctrl+C to stop)\n");
|
|
101
|
+
console.log("Open another terminal and run: npx bugcap");
|
|
102
|
+
console.log("to see your score and game progress.\n");
|
|
103
|
+
function updateStatusLine() {
|
|
104
|
+
const s = loadState();
|
|
105
|
+
if (s) {
|
|
106
|
+
totalTokens = s.tokens;
|
|
107
|
+
totalBugs = s.bugsSold;
|
|
108
|
+
}
|
|
109
|
+
process.stdout.write(`\rTokens: ${totalTokens.toLocaleString()} | Bugs sold: ${totalBugs} | Events: ${eventCount} `);
|
|
110
|
+
}
|
|
111
|
+
updateStatusLine();
|
|
112
|
+
// Wrap handleChange to also update the status line
|
|
113
|
+
function handleChangeWithStatus(filePath) {
|
|
114
|
+
handleChange(filePath);
|
|
115
|
+
// Update status after debounce settles
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
eventCount++;
|
|
118
|
+
updateStatusLine();
|
|
119
|
+
}, DEBOUNCE_MS + 100);
|
|
120
|
+
}
|
|
121
|
+
watcher.on("add", handleChangeWithStatus);
|
|
122
|
+
watcher.on("change", handleChangeWithStatus);
|
|
123
|
+
watcher.on("unlink", (filePath) => {
|
|
124
|
+
const previousLines = fileSizes.get(filePath) ?? 0;
|
|
125
|
+
fileSizes.delete(filePath);
|
|
126
|
+
if (previousLines > 0) {
|
|
127
|
+
const s = loadState() ?? defaultState();
|
|
128
|
+
processFileChange(s, previousLines);
|
|
129
|
+
saveState(s);
|
|
130
|
+
eventCount++;
|
|
131
|
+
updateStatusLine();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
function cleanup() {
|
|
135
|
+
console.log("\n\nWatcher stopped. Your progress has been saved.");
|
|
136
|
+
const s = loadState();
|
|
137
|
+
if (s) {
|
|
138
|
+
s.watcherPid = null;
|
|
139
|
+
saveState(s);
|
|
140
|
+
}
|
|
141
|
+
watcher.close();
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
process.on("SIGINT", cleanup);
|
|
145
|
+
process.on("SIGTERM", cleanup);
|
|
146
|
+
}
|
|
147
|
+
/** Embedded watcher (no console output) — used by default `bugcap` command */
|
|
148
|
+
export function startEmbeddedWatcher(targetDir) {
|
|
149
|
+
const { watcher, handleChange } = createWatcher(targetDir);
|
|
150
|
+
watcher.on("add", handleChange);
|
|
151
|
+
watcher.on("change", handleChange);
|
|
152
|
+
watcher.on("unlink", (filePath) => {
|
|
153
|
+
const previousLines = fileSizes.get(filePath) ?? 0;
|
|
154
|
+
fileSizes.delete(filePath);
|
|
155
|
+
if (previousLines > 0) {
|
|
156
|
+
let state = loadState() ?? defaultState();
|
|
157
|
+
processFileChange(state, previousLines);
|
|
158
|
+
saveState(state);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// Return cleanup function
|
|
162
|
+
return () => {
|
|
163
|
+
const s = loadState();
|
|
164
|
+
if (s) {
|
|
165
|
+
s.watcherPid = null;
|
|
166
|
+
saveState(s);
|
|
167
|
+
}
|
|
168
|
+
watcher.close();
|
|
169
|
+
};
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bugcap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn your AI coding into a terminal idle game",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bugcap": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"start": "node dist/cli.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["cli", "terminal", "idle-game", "ai", "developer-tools"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"chokidar": "^4.0.3",
|
|
25
|
+
"commander": "^13.1.0",
|
|
26
|
+
"ink": "^5.1.0",
|
|
27
|
+
"react": "^18.3.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.13.10",
|
|
31
|
+
"@types/react": "^18.3.18",
|
|
32
|
+
"typescript": "^5.8.2"
|
|
33
|
+
}
|
|
34
|
+
}
|