openbrawl 0.2.2
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/LICENSE +12 -0
- package/README.md +21 -0
- package/dist/buddy/battle.js +41 -0
- package/dist/buddy/parse.js +93 -0
- package/dist/buddy/prompts.js +43 -0
- package/dist/buddy/types.js +2 -0
- package/dist/cli.js +15 -0
- package/dist/game/combat.js +325 -0
- package/dist/game/rounds.js +70 -0
- package/dist/game/state.js +89 -0
- package/dist/game/types.js +1 -0
- package/dist/llm/claude.js +44 -0
- package/dist/llm/parse.js +108 -0
- package/dist/llm/prompts.js +114 -0
- package/dist/ui/App.js +256 -0
- package/dist/ui/BuddyBattle.js +199 -0
- package/dist/ui/CombatLog.js +90 -0
- package/dist/ui/DetailOverlay.js +10 -0
- package/dist/ui/StatCard.js +6 -0
- package/package.json +29 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/ui/BuddyBattle.tsx
|
|
3
|
+
import { useState, useCallback } from "react";
|
|
4
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
5
|
+
import { parseBuddyCard } from "../buddy/parse.js";
|
|
6
|
+
import { buildBuddyBattlePrompt } from "../buddy/prompts.js";
|
|
7
|
+
import { parseBattleRound } from "../buddy/battle.js";
|
|
8
|
+
import { callClaudeAsync } from "../llm/claude.js";
|
|
9
|
+
import { exec } from "child_process";
|
|
10
|
+
function BuddyStatLine({ buddy, color }) {
|
|
11
|
+
return (_jsx(Text, { color: color, children: buddy.stats.map((s) => `${s.name.slice(0, 3).toUpperCase()}:${s.value}`).join(" ") }));
|
|
12
|
+
}
|
|
13
|
+
function BuddyCardDisplay({ buddy, color, compact }) {
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: color, paddingX: 1, children: [buddy.rarity && _jsxs(Text, { color: color, dimColor: true, children: [buddy.rarity, buddy.species ? ` · ${buddy.species}` : ""] }), !compact && buddy.asciiArt && (_jsx(Text, { color: color === "cyan" ? "cyanBright" : "yellowBright", children: buddy.asciiArt })), _jsx(Text, { bold: true, color: color, children: buddy.name }), !compact && buddy.description && _jsxs(Text, { dimColor: true, italic: true, children: ["\"", buddy.description, "\""] }), _jsx(Box, { flexDirection: "column", children: buddy.stats.map((s, i) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: s.name.padEnd(12) }), _jsxs(Text, { color: color, children: ["█".repeat(Math.round(s.value / 10)), "░".repeat(10 - Math.round(s.value / 10))] }), _jsxs(Text, { children: [" ", s.value] })] }, i))) })] }));
|
|
15
|
+
}
|
|
16
|
+
export function BuddyBattle({ onQuit }) {
|
|
17
|
+
const { stdout } = useStdout();
|
|
18
|
+
const termHeight = stdout.rows ?? 40;
|
|
19
|
+
const [phase, setPhase] = useState("loading_player");
|
|
20
|
+
const [playerBuddy, setPlayerBuddy] = useState(null);
|
|
21
|
+
const [opponentBuddy, setOpponentBuddy] = useState(null);
|
|
22
|
+
const [rounds, setRounds] = useState([]);
|
|
23
|
+
const [currentRound, setCurrentRound] = useState(1);
|
|
24
|
+
const [currentBeat, setCurrentBeat] = useState(0);
|
|
25
|
+
const [currentBeats, setCurrentBeats] = useState([]);
|
|
26
|
+
const [playerScore, setPlayerScore] = useState(0);
|
|
27
|
+
const [opponentScore, setOpponentScore] = useState(0);
|
|
28
|
+
const [error, setError] = useState("");
|
|
29
|
+
const [loadingMsg, setLoadingMsg] = useState("");
|
|
30
|
+
const [victoryQuip, setVictoryQuip] = useState("");
|
|
31
|
+
const [matchWinner, setMatchWinner] = useState("");
|
|
32
|
+
const clearScreen = useCallback(() => {
|
|
33
|
+
stdout.write("\x1B[2J\x1B[H");
|
|
34
|
+
}, [stdout]);
|
|
35
|
+
function loadBuddyFromClipboard() {
|
|
36
|
+
setLoadingMsg("Reading clipboard...");
|
|
37
|
+
setError("");
|
|
38
|
+
exec(`pbpaste`, { encoding: "utf-8", timeout: 5000, maxBuffer: 1024 * 1024 }, (err, out) => {
|
|
39
|
+
if (err) {
|
|
40
|
+
setError("Could not read clipboard. Copy your buddy card and try again.");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const buddy = parseBuddyCard(out);
|
|
45
|
+
setError("");
|
|
46
|
+
if (phase === "loading_player") {
|
|
47
|
+
setPlayerBuddy(buddy);
|
|
48
|
+
clearScreen();
|
|
49
|
+
setPhase("loading_opponent");
|
|
50
|
+
}
|
|
51
|
+
else if (phase === "loading_opponent") {
|
|
52
|
+
setOpponentBuddy(buddy);
|
|
53
|
+
clearScreen();
|
|
54
|
+
setPhase("vs");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (parseErr) {
|
|
58
|
+
setError(`Parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}. Copy the full buddy card and try again.`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function startRound(roundNum, prevRounds, pScore, oScore) {
|
|
63
|
+
if (!playerBuddy || !opponentBuddy)
|
|
64
|
+
return;
|
|
65
|
+
setLoadingMsg(`Claude is narrating Round ${roundNum}...`);
|
|
66
|
+
clearScreen();
|
|
67
|
+
setPhase("waiting");
|
|
68
|
+
try {
|
|
69
|
+
const isMatchPoint = pScore === 1 || oScore === 1;
|
|
70
|
+
const prompt = buildBuddyBattlePrompt(playerBuddy, opponentBuddy, roundNum, prevRounds, isMatchPoint);
|
|
71
|
+
const raw = await callClaudeAsync(prompt.system, prompt.user);
|
|
72
|
+
const round = parseBattleRound(raw, roundNum, [playerBuddy.name, opponentBuddy.name]);
|
|
73
|
+
setCurrentBeats(round.beats);
|
|
74
|
+
setCurrentBeat(0);
|
|
75
|
+
const newRounds = [...prevRounds, round];
|
|
76
|
+
setRounds(newRounds);
|
|
77
|
+
const newPScore = round.winner === playerBuddy.name ? pScore + 1 : pScore;
|
|
78
|
+
const newOScore = round.winner === opponentBuddy.name ? oScore + 1 : oScore;
|
|
79
|
+
setPlayerScore(newPScore);
|
|
80
|
+
setOpponentScore(newOScore);
|
|
81
|
+
if (round.victoryQuip) {
|
|
82
|
+
setVictoryQuip(round.victoryQuip);
|
|
83
|
+
}
|
|
84
|
+
if (newPScore === 2 || newOScore === 2) {
|
|
85
|
+
setMatchWinner(round.winner);
|
|
86
|
+
}
|
|
87
|
+
clearScreen();
|
|
88
|
+
setPhase("battle");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
setError(`Battle error: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
clearScreen();
|
|
93
|
+
setPhase("vs");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
useInput((input, key) => {
|
|
97
|
+
if (input === "q" || input === "Q" || (key.escape && phase !== "loading_player" && phase !== "loading_opponent")) {
|
|
98
|
+
onQuit();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ((phase === "loading_player" || phase === "loading_opponent") && (input === "v" || input === "V")) {
|
|
102
|
+
loadBuddyFromClipboard();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (phase === "vs" && (input === " " || key.return)) {
|
|
106
|
+
startRound(1, [], 0, 0);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (phase === "battle" && (input === " " || key.return)) {
|
|
110
|
+
if (currentBeat < currentBeats.length - 1) {
|
|
111
|
+
setCurrentBeat((b) => b + 1);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// All beats revealed
|
|
115
|
+
if (matchWinner) {
|
|
116
|
+
clearScreen();
|
|
117
|
+
setPhase("victory");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
clearScreen();
|
|
121
|
+
setPhase("between_rounds");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (phase === "between_rounds" && (input === " " || key.return)) {
|
|
127
|
+
const nextRound = currentRound + 1;
|
|
128
|
+
setCurrentRound(nextRound);
|
|
129
|
+
startRound(nextRound, rounds, playerScore, opponentScore);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (phase === "victory") {
|
|
133
|
+
if (input === "r" || input === "R") {
|
|
134
|
+
// Rematch — same buddies
|
|
135
|
+
setRounds([]);
|
|
136
|
+
setCurrentRound(1);
|
|
137
|
+
setCurrentBeat(0);
|
|
138
|
+
setCurrentBeats([]);
|
|
139
|
+
setPlayerScore(0);
|
|
140
|
+
setOpponentScore(0);
|
|
141
|
+
setMatchWinner("");
|
|
142
|
+
setVictoryQuip("");
|
|
143
|
+
clearScreen();
|
|
144
|
+
setPhase("vs");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (input === "n" || input === "N") {
|
|
148
|
+
// New buddies
|
|
149
|
+
setPlayerBuddy(null);
|
|
150
|
+
setOpponentBuddy(null);
|
|
151
|
+
setRounds([]);
|
|
152
|
+
setCurrentRound(1);
|
|
153
|
+
setCurrentBeat(0);
|
|
154
|
+
setCurrentBeats([]);
|
|
155
|
+
setPlayerScore(0);
|
|
156
|
+
setOpponentScore(0);
|
|
157
|
+
setMatchWinner("");
|
|
158
|
+
setVictoryQuip("");
|
|
159
|
+
setError("");
|
|
160
|
+
clearScreen();
|
|
161
|
+
setPhase("loading_player");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
// ─── LOADING PLAYER BUDDY ────────────────
|
|
167
|
+
if (phase === "loading_player") {
|
|
168
|
+
return (_jsx(Box, { flexDirection: "column", height: termHeight, children: _jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "red", children: "Buddy" }), " ", _jsx(Text, { color: "cyan", children: "Battle" })] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Copy your buddy card to clipboard, then press ", _jsx(Text, { bold: true, color: "cyan", children: "V" }), " to load it."] }), _jsx(Text, { children: " " }), error && _jsx(Text, { color: "red", children: error }), _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { bold: true, children: "Q" }), " to go back"] })] }) }));
|
|
169
|
+
}
|
|
170
|
+
// ─── LOADING OPPONENT BUDDY ──────────────
|
|
171
|
+
if (phase === "loading_opponent") {
|
|
172
|
+
return (_jsx(Box, { flexDirection: "column", height: termHeight, children: _jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "red", children: "Buddy" }), " ", _jsx(Text, { color: "cyan", children: "Battle" })] }), _jsx(Text, { children: " " }), playerBuddy && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "cyan", children: ["Your buddy: ", _jsx(Text, { bold: true, children: playerBuddy.name }), " loaded!"] }) })), _jsxs(Text, { children: ["Copy your ", _jsx(Text, { bold: true, color: "red", children: "opponent's" }), " buddy card to clipboard, then press ", _jsx(Text, { bold: true, color: "cyan", children: "V" }), "."] }), _jsx(Text, { children: " " }), error && _jsx(Text, { color: "red", children: error }), _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { bold: true, children: "Q" }), " to go back"] })] }) }));
|
|
173
|
+
}
|
|
174
|
+
// ─── WAITING FOR CLAUDE ──────────────────
|
|
175
|
+
if (phase === "waiting") {
|
|
176
|
+
return (_jsxs(Box, { flexDirection: "column", height: termHeight, alignItems: "center", justifyContent: "center", children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "red", children: "Buddy" }), " ", _jsx(Text, { color: "cyan", children: "Battle" })] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["[...] ", loadingMsg] }), _jsx(Text, { dimColor: true, children: "(Calling Claude \u2014 this may take a few seconds)" })] }));
|
|
177
|
+
}
|
|
178
|
+
// ─── VS SCREEN ───────────────────────────
|
|
179
|
+
if (phase === "vs" && playerBuddy && opponentBuddy) {
|
|
180
|
+
return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "-- BUDDY BATTLE --" }), _jsx(Text, { children: " " })] }), _jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(Box, { width: "40%", children: _jsx(BuddyCardDisplay, { buddy: playerBuddy, color: "cyan" }) }), _jsx(Box, { alignItems: "center", children: _jsx(Text, { bold: true, color: "red", children: "VS" }) }), _jsx(Box, { width: "40%", children: _jsx(BuddyCardDisplay, { buddy: opponentBuddy, color: "red" }) })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { bold: true, color: "white", children: "SPACE" }), " to begin battle"] }) })] }));
|
|
181
|
+
}
|
|
182
|
+
// ─── BATTLE (beat-by-beat) ───────────────
|
|
183
|
+
if (phase === "battle" && playerBuddy && opponentBuddy) {
|
|
184
|
+
return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [playerBuddy.asciiArt && _jsx(Text, { color: "cyanBright", children: playerBuddy.asciiArt }), _jsx(Text, { bold: true, color: "cyan", children: playerBuddy.name }), _jsx(BuddyStatLine, { buddy: playerBuddy, color: "cyan" })] }), _jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", children: [_jsxs(Text, { bold: true, color: "red", children: ["ROUND ", currentRound, " of 3"] }), _jsxs(Text, { dimColor: true, children: [playerScore, " \u2014 ", opponentScore] })] }), _jsxs(Box, { flexDirection: "column", alignItems: "center", children: [opponentBuddy.asciiArt && _jsx(Text, { color: "yellowBright", children: opponentBuddy.asciiArt }), _jsx(Text, { bold: true, color: "red", children: opponentBuddy.name }), _jsx(BuddyStatLine, { buddy: opponentBuddy, color: "red" })] })] }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(stdout.columns ?? 80, 80)) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingY: 1, children: currentBeats.slice(0, currentBeat + 1).map((beat, i) => (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: beat }) }, i))) }), _jsx(Box, { children: currentBeat < currentBeats.length - 1 ? (_jsx(Text, { dimColor: true, italic: true, children: " Press SPACE for next beat..." })) : matchWinner ? (_jsx(Text, { dimColor: true, italic: true, children: " Press SPACE for victory screen..." })) : (_jsxs(Text, { dimColor: true, italic: true, children: [" Press SPACE for Round ", currentRound + 1, "..."] })) })] }));
|
|
185
|
+
}
|
|
186
|
+
// ─── BETWEEN ROUNDS ─────────────────────
|
|
187
|
+
if (phase === "between_rounds" && playerBuddy && opponentBuddy) {
|
|
188
|
+
const lastRound = rounds[rounds.length - 1];
|
|
189
|
+
return (_jsxs(Box, { flexDirection: "column", height: termHeight, alignItems: "center", justifyContent: "center", children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "red", children: "Buddy" }), " ", _jsx(Text, { color: "cyan", children: "Battle" })] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Round ", currentRound, " goes to ", _jsx(Text, { bold: true, color: lastRound?.winner === playerBuddy.name ? "cyan" : "red", children: lastRound?.winner }), "!"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: [playerBuddy.name, " ", _jsx(Text, { color: "cyan", children: playerScore }), " \u2014 ", _jsx(Text, { color: "red", children: opponentScore }), " ", opponentBuddy.name] }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { bold: true, color: "white", children: "SPACE" }), " for Round ", currentRound + 1] })] }));
|
|
190
|
+
}
|
|
191
|
+
// ─── VICTORY ─────────────────────────────
|
|
192
|
+
if (phase === "victory" && playerBuddy && opponentBuddy) {
|
|
193
|
+
const winner = matchWinner === playerBuddy.name ? playerBuddy : opponentBuddy;
|
|
194
|
+
const winnerColor = matchWinner === playerBuddy.name ? "cyan" : "red";
|
|
195
|
+
return (_jsxs(Box, { flexDirection: "column", height: termHeight, alignItems: "center", paddingY: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "-- VICTORY --" }), _jsx(Text, { children: " " }), winner.asciiArt && _jsx(Text, { color: winnerColor, children: winner.asciiArt }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, color: winnerColor, children: [winner.name, " wins ", playerScore, "-", opponentScore, "!"] }), victoryQuip && (_jsxs(Text, { dimColor: true, italic: true, children: ["\"", victoryQuip, "\""] })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "white", children: "R" }), _jsx(Text, { dimColor: true, children: " Rematch " }), _jsx(Text, { bold: true, color: "white", children: "N" }), _jsx(Text, { dimColor: true, children: " New Buddies " }), _jsx(Text, { bold: true, color: "white", children: "Q" }), _jsx(Text, { dimColor: true, children: " Quit" })] })] }));
|
|
196
|
+
}
|
|
197
|
+
// Fallback
|
|
198
|
+
return _jsx(Text, { children: "Loading..." });
|
|
199
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
function HPBar({ hp }) {
|
|
5
|
+
const pct = hp.maxHP > 0 ? hp.currentHP / hp.maxHP : 0;
|
|
6
|
+
const barWidth = 20;
|
|
7
|
+
const filled = Math.round(pct * barWidth);
|
|
8
|
+
const color = hp.currentHP <= 0 ? "red" : pct > 0.5 ? "green" : pct > 0.25 ? "yellow" : "red";
|
|
9
|
+
const bar = "#".repeat(filled) + ".".repeat(barWidth - filled);
|
|
10
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: hp.team === "player" ? "cyan" : "red", children: hp.name.padEnd(14) }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { dimColor: true, children: [" ", hp.currentHP <= 0 ? " DEAD" : `${String(hp.currentHP).padStart(3)}/${hp.maxHP}`] })] }));
|
|
11
|
+
}
|
|
12
|
+
function EventLine({ event }) {
|
|
13
|
+
const isAbility = !!event.abilityTriggered;
|
|
14
|
+
const isBlocked = event.damageFormula?.includes("chip");
|
|
15
|
+
if (event.attacker === "Arena") {
|
|
16
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: " ARENA" }), _jsx(Text, { dimColor: true, children: " deals " }), _jsx(Text, { color: "red", children: event.damage }), _jsx(Text, { dimColor: true, children: " to " }), _jsx(Text, { children: event.defender }), event.knockedOut && _jsx(Text, { color: "red", bold: true, children: " KO!" })] }));
|
|
17
|
+
}
|
|
18
|
+
if (isAbility && event.damage <= 0) {
|
|
19
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", children: [" * ", event.attacker] }), _jsx(Text, { dimColor: true, children: " triggers " }), _jsx(Text, { color: "yellow", bold: true, children: event.abilityTriggered }), event.defender !== event.attacker && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " on " }), _jsx(Text, { children: event.defender })] })), event.abilityEffect && _jsxs(Text, { dimColor: true, children: [" -- ", event.abilityEffect] })] }));
|
|
20
|
+
}
|
|
21
|
+
if (isAbility && event.damage > 0) {
|
|
22
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", children: [" * ", event.attacker] }), _jsx(Text, { dimColor: true, children: " triggers " }), _jsx(Text, { color: "yellow", bold: true, children: event.abilityTriggered }), _jsx(Text, { dimColor: true, children: " on " }), _jsx(Text, { children: event.defender }), _jsxs(Text, { color: "red", bold: true, children: [" -", event.damage] }), event.knockedOut && _jsx(Text, { color: "red", bold: true, children: " KO!" })] }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", children: [" x ", event.attacker] }), _jsx(Text, { dimColor: true, children: " attacks " }), _jsx(Text, { children: event.defender }), isBlocked ? (_jsx(Text, { color: "blue", children: " BLOCKED (1 chip)" })) : (_jsxs(Text, { color: "red", bold: true, children: [" -", event.damage] })), event.knockedOut && _jsx(Text, { color: "red", bold: true, children: " KO!" })] }));
|
|
25
|
+
}
|
|
26
|
+
function groupByTurn(events) {
|
|
27
|
+
const groups = [];
|
|
28
|
+
for (const e of events) {
|
|
29
|
+
const existing = groups.find((g) => g.turn === e.turn);
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.events.push(e);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
groups.push({ turn: e.turn, events: [e] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return groups;
|
|
38
|
+
}
|
|
39
|
+
// Count how many terminal lines a turn group takes (1 for header + 1 per event)
|
|
40
|
+
function turnLineCount(group) {
|
|
41
|
+
return 1 + group.events.length;
|
|
42
|
+
}
|
|
43
|
+
// Get the tail of turns that fit within maxLines
|
|
44
|
+
function tailTurns(turns, maxLines) {
|
|
45
|
+
let lines = 0;
|
|
46
|
+
const result = [];
|
|
47
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
48
|
+
const count = turnLineCount(turns[i]);
|
|
49
|
+
if (lines + count > maxLines)
|
|
50
|
+
break;
|
|
51
|
+
lines += count;
|
|
52
|
+
result.unshift(turns[i]);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
// Animated version — reveals one turn at a time, scrolling window
|
|
57
|
+
export function AnimatedCombatLog({ result, onComplete, maxLogLines, }) {
|
|
58
|
+
const turnGroups = groupByTurn(result.events);
|
|
59
|
+
const [visibleTurns, setVisibleTurns] = useState(0);
|
|
60
|
+
const [done, setDone] = useState(false);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (visibleTurns >= turnGroups.length) {
|
|
63
|
+
const t = setTimeout(() => setDone(true), 400);
|
|
64
|
+
return () => clearTimeout(t);
|
|
65
|
+
}
|
|
66
|
+
const delay = turnGroups.length > 15 ? 100 : turnGroups.length > 8 ? 200 : 350;
|
|
67
|
+
const t = setTimeout(() => setVisibleTurns((v) => v + 1), delay);
|
|
68
|
+
return () => clearTimeout(t);
|
|
69
|
+
}, [visibleTurns, turnGroups.length]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (done)
|
|
72
|
+
onComplete();
|
|
73
|
+
}, [done, onComplete]);
|
|
74
|
+
const allShown = turnGroups.slice(0, visibleTurns);
|
|
75
|
+
// Only display the tail that fits
|
|
76
|
+
const displayTurns = tailTurns(allShown, maxLogLines - 3); // -3 for header + footer + spacer
|
|
77
|
+
const lastEvent = allShown.length > 0
|
|
78
|
+
? allShown[allShown.length - 1].events[allShown[allShown.length - 1].events.length - 1]
|
|
79
|
+
: null;
|
|
80
|
+
const skippedTurns = allShown.length - displayTurns.length;
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "COMBATANTS" }), lastEvent?.hpAfter ? (lastEvent.hpAfter.filter((hp) => hp.maxHP > 0).map((hp) => (_jsx(HPBar, { hp: hp }, hp.name)))) : (_jsx(Text, { dimColor: true, children: "Preparing..." }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["COMBAT LOG -- turn ", Math.min(visibleTurns, turnGroups.length), "/", turnGroups.length, !done && _jsx(Text, { color: "yellow", children: " ..." })] }), skippedTurns > 0 && (_jsxs(Text, { dimColor: true, children: [" (", skippedTurns, " earlier turns)"] })), displayTurns.map((group) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, bold: true, children: [" TURN ", group.turn] }), group.events.map((e, i) => (_jsx(EventLine, { event: e }, i)))] }, group.turn))), done && (_jsx(_Fragment, { children: result.draw ? (_jsx(Text, { color: "yellow", bold: true, children: "=== DRAW ===" })) : result.playerWon ? (_jsxs(Text, { color: "cyan", bold: true, children: ["=== VICTORY === Survivors: ", result.playerTeamSurvivors.join(", ")] })) : (_jsxs(Text, { color: "red", bold: true, children: ["=== DEFEAT === Rival survivors: ", result.rivalTeamSurvivors.join(", ")] })) }))] })] }));
|
|
82
|
+
}
|
|
83
|
+
// Static version — for reviewing after battle, also windowed
|
|
84
|
+
export function CombatLog({ result, maxLogLines }) {
|
|
85
|
+
const turnGroups = groupByTurn(result.events);
|
|
86
|
+
const lastEvent = result.events[result.events.length - 1];
|
|
87
|
+
const displayTurns = tailTurns(turnGroups, maxLogLines - 3);
|
|
88
|
+
const skippedTurns = turnGroups.length - displayTurns.length;
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "FINAL STATE" }), lastEvent?.hpAfter?.filter((hp) => hp.maxHP > 0).map((hp) => (_jsx(HPBar, { hp: hp }, hp.name)))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["COMBAT LOG -- ", turnGroups.length, " turns, ", result.events.length, " actions"] }), skippedTurns > 0 && (_jsxs(Text, { dimColor: true, children: [" (", skippedTurns, " earlier turns)"] })), displayTurns.map((group) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, bold: true, children: [" TURN ", group.turn] }), group.events.map((e, i) => (_jsx(EventLine, { event: e }, i)))] }, group.turn))), result.draw ? (_jsx(Text, { color: "yellow", bold: true, children: "=== DRAW ===" })) : result.playerWon ? (_jsxs(Text, { color: "cyan", bold: true, children: ["=== VICTORY === Survivors: ", result.playerTeamSurvivors.join(", ")] })) : (_jsxs(Text, { color: "red", bold: true, children: ["=== DEFEAT === Rival survivors: ", result.rivalTeamSurvivors.join(", ")] }))] })] }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
export function DetailOverlay({ overlay, playerTeam, rivalTeam, }) {
|
|
5
|
+
const allCreatures = [
|
|
6
|
+
...playerTeam.map((c) => ({ ...c, side: "player" })),
|
|
7
|
+
...rivalTeam.map((c) => ({ ...c, side: "rival" })),
|
|
8
|
+
];
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, width: "100%", children: [_jsxs(Text, { bold: true, color: "yellow", children: [overlay === "stats" && "-- DETAILED STATS --", overlay === "abilities" && "-- ABILITIES --", overlay === "mutations" && "-- MUTATIONS --"] }), _jsx(Text, { dimColor: true, children: "ESC to close" }), _jsx(Text, { children: " " }), overlay === "stats" && allCreatures.map((c, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: c.side === "player" ? "cyan" : "red", children: [c.name, " ", c.side === "rival" ? "(rival)" : "(you)"] }), _jsxs(Text, { children: [_jsxs(Text, { color: "red", bold: true, children: ["ATK ", String(c.stats.ATK).padStart(3)] }), " ", _jsxs(Text, { dimColor: true, children: ["#".repeat(Math.min(c.stats.ATK, 30)), ".".repeat(Math.max(0, 30 - c.stats.ATK))] })] }), _jsxs(Text, { children: [_jsxs(Text, { color: "blue", bold: true, children: ["DEF ", String(c.stats.DEF).padStart(3)] }), " ", _jsxs(Text, { dimColor: true, children: ["#".repeat(Math.min(c.stats.DEF, 30)), ".".repeat(Math.max(0, 30 - c.stats.DEF))] })] }), _jsxs(Text, { children: [_jsxs(Text, { color: "green", bold: true, children: ["SPD ", String(c.stats.SPD).padStart(3)] }), " ", _jsxs(Text, { dimColor: true, children: ["#".repeat(Math.min(c.stats.SPD, 30)), ".".repeat(Math.max(0, 30 - c.stats.SPD))] })] }), _jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["HP ", String(c.stats.HP).padStart(3)] }), " ", _jsxs(Text, { dimColor: true, children: ["#".repeat(Math.min(c.stats.HP, 30)), ".".repeat(Math.max(0, 30 - c.stats.HP))] })] })] }, i))), overlay === "abilities" && allCreatures.map((c, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: c.side === "player" ? "cyan" : "red", children: [c.name, " ", c.side === "rival" ? "(rival)" : "(you)"] }), c.abilities.length === 0 ? (_jsx(Text, { dimColor: true, children: " No abilities yet" })) : (c.abilities.map((a, j) => (_jsxs(React.Fragment, { children: [_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", bold: true, children: a.name }), _jsxs(Text, { dimColor: true, children: [" - ", a.description] })] }), _jsxs(Text, { dimColor: true, children: [" ", a.trigger.replace(/_/g, " "), " | ", a.effect, " | ", a.value, "% | ", a.target] })] }, j))))] }, i))), overlay === "mutations" && allCreatures.map((c, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: c.side === "player" ? "cyan" : "red", children: [c.name, " ", c.side === "rival" ? "(rival)" : "(you)"] }), c.mutations.length === 0 ? (_jsx(Text, { dimColor: true, children: " No mutations yet" })) : (c.mutations.map((m, j) => (_jsxs(Text, { color: "magenta", children: [" + ", m] }, j))))] }, i)))] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function StatCard({ creature, isRival }) {
|
|
4
|
+
const color = isRival ? "red" : "cyan";
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: color, paddingX: 1, children: [_jsx(Text, { bold: true, color: color, children: creature.name }), _jsx(Text, { dimColor: true, italic: true, children: creature.flavorText }), _jsx(Text, { children: " " }), _jsxs(Box, { gap: 3, children: [_jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "ATK" }), " ", creature.stats.ATK] }), _jsxs(Text, { children: [_jsx(Text, { color: "blue", bold: true, children: "DEF" }), " ", creature.stats.DEF] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "SPD" }), " ", creature.stats.SPD] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "HP" }), " ", creature.stats.HP] })] }), creature.abilities.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "ABILITIES" }), creature.abilities.map((a, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: a.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", a.description] })] }, i)))] })), creature.mutations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "MUTATIONS" }), creature.mutations.slice(0, 3).map((m, i) => (_jsxs(Text, { color: "magenta", children: ["+ ", m] }, i))), creature.mutations.length > 3 && (_jsxs(Text, { dimColor: true, children: [" ...and ", creature.mutations.length - 3, " more"] }))] }))] }));
|
|
6
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openbrawl",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "AI-powered auto-battler in your terminal. Type a prompt. Forge a creature. Watch it fight.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"keywords": ["game", "auto-battler", "ai", "cli", "terminal", "tui", "claude", "ink"],
|
|
7
|
+
"homepage": "https://openbrawl.fun",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"openbrawl": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/cli.js",
|
|
15
|
+
"dev": "tsx src/cli.tsx"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ink": "^5.2.0",
|
|
19
|
+
"ink-text-input": "^6.0.0",
|
|
20
|
+
"ink-select-input": "^6.0.0",
|
|
21
|
+
"ink-spinner": "^5.0.0",
|
|
22
|
+
"react": "^18.3.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^18.3.0",
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.6.0"
|
|
28
|
+
}
|
|
29
|
+
}
|