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.
@@ -0,0 +1,89 @@
1
+ import { createInitialGameState, getCategoryForRound, getWordBudget, getStatBudget } from "./rounds.js";
2
+ function applyInterpretationToCreature(creature, interp) {
3
+ const newAbilities = [...creature.abilities, ...interp.abilities].slice(0, 3);
4
+ return {
5
+ ...creature,
6
+ name: interp.creatureName ?? creature.name,
7
+ stats: {
8
+ ATK: creature.stats.ATK + interp.statChanges.ATK,
9
+ DEF: creature.stats.DEF + interp.statChanges.DEF,
10
+ SPD: creature.stats.SPD + interp.statChanges.SPD,
11
+ HP: creature.stats.HP + interp.statChanges.HP,
12
+ },
13
+ currentHP: creature.currentHP + interp.statChanges.HP,
14
+ abilities: newAbilities,
15
+ mutations: [...creature.mutations, ...interp.mutations],
16
+ flavorText: interp.flavorText || creature.flavorText,
17
+ };
18
+ }
19
+ export class GameEngine {
20
+ state;
21
+ constructor(mode) {
22
+ this.state = createInitialGameState(mode);
23
+ }
24
+ applyInterpretation(interp, targetIndex) {
25
+ const newTeam = [...this.state.playerTeam];
26
+ newTeam[targetIndex] = applyInterpretationToCreature(newTeam[targetIndex], interp);
27
+ this.state = { ...this.state, playerTeam: newTeam };
28
+ }
29
+ applyRivalEvolution(interp, targetIndex) {
30
+ const newRivals = [...this.state.rivalTeam];
31
+ newRivals[targetIndex] = applyInterpretationToCreature(newRivals[targetIndex], interp);
32
+ this.state = { ...this.state, rivalTeam: newRivals };
33
+ }
34
+ addAlly(creature) {
35
+ this.state = { ...this.state, playerTeam: [...this.state.playerTeam, creature] };
36
+ }
37
+ addRivalAlly(creature) {
38
+ this.state = { ...this.state, rivalTeam: [...this.state.rivalTeam, creature] };
39
+ }
40
+ battleWon() {
41
+ this.state = { ...this.state, phase: "result" };
42
+ }
43
+ battleLost() {
44
+ const newLives = this.state.lives - 1;
45
+ if (newLives <= 0) {
46
+ this.state = { ...this.state, lives: 0, phase: "game_over" };
47
+ }
48
+ else {
49
+ this.state = { ...this.state, lives: newLives, phase: "result" };
50
+ }
51
+ }
52
+ battleDraw() {
53
+ this.state = { ...this.state, phase: "result" };
54
+ }
55
+ nextRound() {
56
+ const nextRound = this.state.round + 1;
57
+ if (nextRound > 10) {
58
+ this.state = { ...this.state, phase: "victory" };
59
+ return;
60
+ }
61
+ const refreshedPlayer = this.state.playerTeam.map((c) => ({ ...c, currentHP: c.stats.HP }));
62
+ const refreshedRival = this.state.rivalTeam.map((c) => ({ ...c, currentHP: c.stats.HP }));
63
+ this.state = {
64
+ ...this.state,
65
+ round: nextRound,
66
+ currentCategory: getCategoryForRound(nextRound),
67
+ wordBudget: getWordBudget(nextRound),
68
+ statBudget: getStatBudget(nextRound),
69
+ playerTeam: refreshedPlayer,
70
+ rivalTeam: refreshedRival,
71
+ phase: "prompting",
72
+ };
73
+ }
74
+ makeAllyFromInterpretation(interp) {
75
+ return {
76
+ name: interp.creatureName ?? "Ally",
77
+ stats: {
78
+ ATK: 1 + interp.statChanges.ATK,
79
+ DEF: 1 + interp.statChanges.DEF,
80
+ SPD: 1 + interp.statChanges.SPD,
81
+ HP: 10 + interp.statChanges.HP,
82
+ },
83
+ currentHP: 10 + interp.statChanges.HP,
84
+ abilities: interp.abilities,
85
+ mutations: interp.mutations,
86
+ flavorText: interp.flavorText,
87
+ };
88
+ }
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { exec } from "child_process";
2
+ let totalInputTokens = 0;
3
+ let totalOutputTokens = 0;
4
+ let totalCalls = 0;
5
+ function parseResult(result) {
6
+ try {
7
+ const parsed = JSON.parse(result.trim());
8
+ const text = parsed.result ?? parsed.text ?? result.trim();
9
+ const input = parsed.input_tokens ?? parsed.usage?.input_tokens ?? 0;
10
+ const output = parsed.output_tokens ?? parsed.usage?.output_tokens ?? 0;
11
+ totalInputTokens += input;
12
+ totalOutputTokens += output;
13
+ totalCalls++;
14
+ return text;
15
+ }
16
+ catch {
17
+ totalCalls++;
18
+ return result.trim();
19
+ }
20
+ }
21
+ export function callClaudeAsync(systemPrompt, userMessage) {
22
+ const fullPrompt = `${systemPrompt}\n\n${userMessage}`;
23
+ return new Promise((resolve, reject) => {
24
+ exec(`claude -p ${JSON.stringify(fullPrompt)} --model haiku --output-format json`, {
25
+ encoding: "utf-8",
26
+ timeout: 60000,
27
+ maxBuffer: 1024 * 1024,
28
+ }, (error, stdout) => {
29
+ if (error) {
30
+ reject(error);
31
+ return;
32
+ }
33
+ resolve(parseResult(stdout));
34
+ });
35
+ });
36
+ }
37
+ export function getTokenUsage() {
38
+ return {
39
+ inputTokens: totalInputTokens,
40
+ outputTokens: totalOutputTokens,
41
+ totalCalls,
42
+ estimatedCost: (totalInputTokens / 1_000_000) * 0.25 + (totalOutputTokens / 1_000_000) * 1.25,
43
+ };
44
+ }
@@ -0,0 +1,108 @@
1
+ const VALID_EFFECTS = ["damage", "heal", "buff", "debuff", "stun", "reflect"];
2
+ const VALID_TRIGGERS = ["on_attack", "on_hit", "on_defend", "start_of_turn", "on_death", "on_ally_death"];
3
+ const VALID_TARGETS = ["self", "enemy", "all_enemies", "random_ally", "all_allies"];
4
+ function normalizeEffect(raw) {
5
+ const s = String(raw ?? "").toLowerCase();
6
+ if (VALID_EFFECTS.includes(s))
7
+ return s;
8
+ // Map common LLM creative effects to known ones
9
+ if (s.includes("bleed") || s.includes("burn") || s.includes("poison") || s.includes("dot"))
10
+ return "damage";
11
+ if (s.includes("heal") || s.includes("regen") || s.includes("restore"))
12
+ return "heal";
13
+ if (s.includes("stun") || s.includes("freeze") || s.includes("paralyze"))
14
+ return "stun";
15
+ if (s.includes("buff") || s.includes("boost") || s.includes("empower"))
16
+ return "buff";
17
+ if (s.includes("debuff") || s.includes("weaken") || s.includes("slow"))
18
+ return "debuff";
19
+ if (s.includes("reflect") || s.includes("mirror") || s.includes("counter"))
20
+ return "reflect";
21
+ if (s.includes("spawn") || s.includes("summon"))
22
+ return "damage";
23
+ return "damage"; // default fallback
24
+ }
25
+ function normalizeTrigger(raw) {
26
+ const s = String(raw ?? "").toLowerCase();
27
+ if (VALID_TRIGGERS.includes(s))
28
+ return s;
29
+ return "on_attack";
30
+ }
31
+ function normalizeTarget(raw) {
32
+ const s = String(raw ?? "").toLowerCase();
33
+ if (VALID_TARGETS.includes(s))
34
+ return s;
35
+ return "enemy";
36
+ }
37
+ function extractJSON(raw) {
38
+ // Try direct parse first
39
+ try {
40
+ JSON.parse(raw.trim());
41
+ return raw.trim();
42
+ }
43
+ catch { }
44
+ // Try markdown code fence
45
+ const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
46
+ if (fenceMatch)
47
+ return fenceMatch[1].trim();
48
+ // Find first { ... } or [ ... ] in the text (handles prose-wrapped JSON)
49
+ const objMatch = raw.match(/\{[\s\S]*\}/);
50
+ if (objMatch)
51
+ return objMatch[0];
52
+ const arrMatch = raw.match(/\[[\s\S]*\]/);
53
+ if (arrMatch)
54
+ return arrMatch[0];
55
+ return raw.trim();
56
+ }
57
+ function clampStats(stats, budget) {
58
+ if (!budget)
59
+ return stats;
60
+ const total = stats.ATK + stats.DEF + stats.SPD + stats.HP;
61
+ if (total <= budget)
62
+ return stats;
63
+ const ratio = budget / total;
64
+ return {
65
+ ATK: Math.floor(stats.ATK * ratio),
66
+ DEF: Math.floor(stats.DEF * ratio),
67
+ SPD: Math.floor(stats.SPD * ratio),
68
+ HP: Math.floor(stats.HP * ratio),
69
+ };
70
+ }
71
+ export function parsePromptInterpretation(raw, statBudget) {
72
+ const json = JSON.parse(extractJSON(raw));
73
+ // Apply creativity bonus as multiplier to effective budget
74
+ const bonusMultiplier = 1 + (json.creativity_bonus ?? 0) * 0.1;
75
+ const effectiveBudget = statBudget ? Math.floor(statBudget * bonusMultiplier) : undefined;
76
+ const statChanges = clampStats({
77
+ ATK: json.stat_changes?.ATK ?? 0,
78
+ DEF: json.stat_changes?.DEF ?? 0,
79
+ SPD: json.stat_changes?.SPD ?? 0,
80
+ HP: json.stat_changes?.HP ?? 0,
81
+ }, effectiveBudget);
82
+ const abilities = (json.abilities ?? []).map((a) => ({
83
+ name: a.name ?? "Unknown",
84
+ description: a.description ?? "",
85
+ trigger: normalizeTrigger(a.trigger),
86
+ effect: normalizeEffect(a.effect),
87
+ value: a.value ?? 0,
88
+ target: normalizeTarget(a.target),
89
+ }));
90
+ return {
91
+ statChanges,
92
+ abilities,
93
+ mutations: json.mutations ?? [],
94
+ flavorText: json.flavor_text ?? "",
95
+ creativityBonus: json.creativity_bonus ?? 0,
96
+ creatureName: json.creature_name,
97
+ };
98
+ }
99
+ export function parseDraftOptions(raw) {
100
+ const json = JSON.parse(extractJSON(raw));
101
+ const arr = Array.isArray(json) ? json : [];
102
+ return arr.map((opt) => ({
103
+ rarity: opt.rarity ?? "common",
104
+ promptText: opt.prompt_text ?? "",
105
+ description: opt.description ?? "",
106
+ statHint: opt.stat_hint ?? undefined,
107
+ }));
108
+ }
@@ -0,0 +1,114 @@
1
+ function serializeCreature(c) {
2
+ const abilities = c.abilities.map((a) => ` - ${a.name}: ${a.description} (${a.trigger}, ${a.effect}, value: ${a.value}, target: ${a.target})`).join("\n");
3
+ const mutations = c.mutations.length > 0 ? `Mutations: ${c.mutations.join(", ")}` : "No mutations";
4
+ return `${c.name} — ATK:${c.stats.ATK} DEF:${c.stats.DEF} SPD:${c.stats.SPD} HP:${c.stats.HP}\n${mutations}\nAbilities:\n${abilities || " None"}`;
5
+ }
6
+ function serializeTeam(team) {
7
+ return team.map(serializeCreature).join("\n\n");
8
+ }
9
+ export function buildInterpretPrompt(category, statBudget, playerTeam, playerPrompt, round) {
10
+ const categoryLabel = category.replace("_", " ");
11
+ return {
12
+ system: `You are the Game Master for OpenBrawl, an AI auto-battler. The player typed a prompt to upgrade their creature. Interpret it creatively.
13
+
14
+ ROUND CATEGORY: ${categoryLabel}
15
+ STAT BUDGET: ${statBudget} points to distribute across ATK, DEF, SPD, HP.
16
+ CREATIVITY BONUS: Rate the prompt's creativity from 0-3:
17
+ - 0: Vague or generic ("a sword", "good armor")
18
+ - 1: Decent but plain ("a steel sword with a sharp edge")
19
+ - 2: Creative and specific ("a blade of frozen starlight that hums in the dark")
20
+ - 3: Exceptionally vivid and original ("a parasitic vine-sword that drinks the blood of enemies and blooms with each kill")
21
+ The creativity bonus MULTIPLIES the stat budget: 0 = base budget, 1 = +10%, 2 = +20%, 3 = +30%. So creative prompts are significantly more powerful.
22
+
23
+ CURRENT PLAYER TEAM:
24
+ ${serializeTeam(playerTeam)}
25
+
26
+ RULES:
27
+ - Distribute stats thematically based on the prompt. A "lightning blade" should favor ATK and SPD.
28
+ - Generate 0-1 new abilities using the trigger/effect/value/target format. Max 3 abilities per creature total.
29
+ - Be wildly creative with mutations. A prompt might cause the creature to sprout limbs, change form, merge with gear.
30
+ - For "recruit_ally" rounds, create a completely new creature (include "creature_name" in response).
31
+ - If the creature is still named "Box" (round 1), give it a unique name based on the prompt. Include "creature_name" in the response. The name should be short (1-2 words), evocative, and match the theme of the prompt. Examples: "Cinderfang", "Voidweaver", "Thornback".
32
+ - For "reshape_evolve" rounds, the prompt modifies existing stats/abilities — things can be replaced or merged.
33
+ - Keep abilities balanced: stun chances should be 10-25%, damage abilities scale with budget, heals should not exceed 30% of max HP.
34
+ - For "reshape evolve" rounds specifically: This is a TRANSFORMATION round. The player is reshaping their creature.
35
+ - Stat changes can be NEGATIVE (removing points from one stat to add to another). Total change should still be within budget but can include trade-offs like -3 DEF, +5 ATK.
36
+ - Existing abilities can be REPLACED, not just appended. If the prompt describes transforming an ability, return the new version.
37
+ - Be dramatic with mutations — this is an evolution moment.
38
+
39
+ Respond with ONLY valid JSON in this exact format:
40
+ {
41
+ "stat_changes": { "ATK": <number>, "DEF": <number>, "SPD": <number>, "HP": <number> },
42
+ "abilities": [{ "name": "<string>", "description": "<string>", "trigger": "<on_attack|on_hit|on_defend|start_of_turn|on_death|on_ally_death>", "effect": "<damage|heal|buff|debuff|stun|spawn|reflect>", "value": <number>, "target": "<self|enemy|all_enemies|random_ally|all_allies>" }],
43
+ "mutations": ["<string>"],
44
+ "flavor_text": "<string>",
45
+ "creativity_bonus": <0-3>,
46
+ "creature_name": "<string or null>"
47
+ }`,
48
+ user: `Player prompt: "${playerPrompt}"`,
49
+ };
50
+ }
51
+ export function buildRivalPrompt(category, statBudget, playerTeam, rivalTeam, personality) {
52
+ const categoryLabel = category.replace("_", " ");
53
+ return {
54
+ system: `You are the Game Master for OpenBrawl, an AI auto-battler. Generate the rival's evolution for this round.
55
+
56
+ ROUND CATEGORY: ${categoryLabel}
57
+ STAT BUDGET: ${statBudget} points to distribute.
58
+ RIVAL PERSONALITY: ${personality || "balanced and adaptive"}
59
+ Evolve the rival in a way that matches their personality.
60
+
61
+ CURRENT PLAYER TEAM:
62
+ ${serializeTeam(playerTeam)}
63
+
64
+ CURRENT RIVAL TEAM:
65
+ ${serializeTeam(rivalTeam)}
66
+
67
+ RULES:
68
+ - The rival should be challenging but beatable (~60% player win rate).
69
+ - React to the player's build. If they go heavy offense, develop some defense. If they have stun abilities, maybe add stun resistance.
70
+ - Be creative and thematic. Give the rival a coherent identity that evolves across rounds.
71
+ - For "recruit_ally" rounds, the rival also gets a new team member (include "creature_name").
72
+ - Keep abilities balanced: same rules as player abilities.
73
+
74
+ Respond with ONLY valid JSON in the same format as player interpretations:
75
+ {
76
+ "stat_changes": { "ATK": <number>, "DEF": <number>, "SPD": <number>, "HP": <number> },
77
+ "abilities": [{ "name": "<string>", "description": "<string>", "trigger": "<trigger>", "effect": "<effect>", "value": <number>, "target": "<target>" }],
78
+ "mutations": ["<string>"],
79
+ "flavor_text": "<string>",
80
+ "creativity_bonus": 0,
81
+ "creature_name": "<string or null>"
82
+ }`,
83
+ user: `Generate the rival's ${categoryLabel} evolution for this round.`,
84
+ };
85
+ }
86
+ export function buildDraftOptionsPrompt(category, playerTeam) {
87
+ const categoryLabel = category.replace("_", " ");
88
+ return {
89
+ system: `You are the Game Master for OpenBrawl, an AI auto-battler. Generate 3 draft options for the player to pick from.
90
+
91
+ ROUND CATEGORY: ${categoryLabel}
92
+
93
+ CURRENT PLAYER TEAM:
94
+ ${serializeTeam(playerTeam)}
95
+
96
+ Generate exactly 3 options with different rarity tiers. Use this distribution:
97
+ - At least 1 common or uncommon
98
+ - Rarity is randomized but weighted: common (40%), uncommon (30%), rare (20%), legendary (10%)
99
+
100
+ Each option is a short descriptive prompt that the player would have typed. Make them thematic and interesting. Legendaries should be dramatically creative.
101
+
102
+ Respond with ONLY valid JSON array:
103
+ [
104
+ { "rarity": "<common|uncommon|rare|legendary>", "prompt_text": "<string>", "description": "<1 sentence describing what this gives>", "stat_hint": "<e.g. +ATK +SPD or +DEF +HP>" }
105
+ ]`,
106
+ user: `Generate 3 ${categoryLabel} draft options.`,
107
+ };
108
+ }
109
+ export function buildNarratePrompt(events, playerWon) {
110
+ return {
111
+ system: `You are the combat narrator for OpenBrawl, an AI auto-battler. Write a short, dramatic play-by-play of the battle. Keep it to 3-6 sentences. Be vivid and exciting. End with the outcome.`,
112
+ user: `Battle events:\n${JSON.stringify(events, null, 2)}\n\nResult: ${playerWon ? "Player won" : "Rival won"}`,
113
+ };
114
+ }
package/dist/ui/App.js ADDED
@@ -0,0 +1,256 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text, useInput, useApp, useStdout } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import { GameEngine } from "../game/state.js";
6
+ import { resolveCombat } from "../game/combat.js";
7
+ import { callClaudeAsync, getTokenUsage } from "../llm/claude.js";
8
+ import { buildInterpretPrompt, buildRivalPrompt } from "../llm/prompts.js";
9
+ import { parsePromptInterpretation } from "../llm/parse.js";
10
+ import { StatCard } from "./StatCard.js";
11
+ import { CombatLog, AnimatedCombatLog } from "./CombatLog.js";
12
+ import { DetailOverlay } from "./DetailOverlay.js";
13
+ import { BuddyBattle } from "./BuddyBattle.js";
14
+ const CATEGORY_ICON = {
15
+ weapon: "ATK",
16
+ defense: "DEF",
17
+ special_ability: "ABL",
18
+ recruit_ally: "ALY",
19
+ reshape_evolve: "EVO",
20
+ };
21
+ export function App() {
22
+ const { exit } = useApp();
23
+ const { stdout } = useStdout();
24
+ const [screen, setScreen] = useState("title");
25
+ const [engine, setEngine] = useState(null);
26
+ const [prompt, setPrompt] = useState("");
27
+ const [lastInterp, setLastInterp] = useState(null);
28
+ const [combatResult, setCombatResult] = useState(null);
29
+ const [narrative, setNarrative] = useState("");
30
+ const [rivalTaunt, setRivalTaunt] = useState("");
31
+ const [error, setError] = useState("");
32
+ const [loadingMsg, setLoadingMsg] = useState("");
33
+ const TABS = ["stats", "abilities", "mutations", "history", "prompt"];
34
+ const [activeTab, setActiveTab] = useState("prompt");
35
+ const [history, setHistory] = useState([]);
36
+ const [historyIndex, setHistoryIndex] = useState(0);
37
+ // Clear screen and log to history on major screen transitions
38
+ const transition = useCallback((newScreen, label) => {
39
+ // Clear terminal
40
+ stdout.write("\x1B[2J\x1B[H");
41
+ setScreen(newScreen);
42
+ setHistory((prev) => [...prev, {
43
+ label,
44
+ screen: newScreen,
45
+ round: engine?.state.round ?? 0,
46
+ timestamp: Date.now(),
47
+ }]);
48
+ }, [stdout, engine]);
49
+ useInput((input, key) => {
50
+ // Tab navigation (available on round, result, battle_done screens)
51
+ if (screen === "round" || screen === "result" || screen === "battle_done") {
52
+ // ESC always returns to prompt tab
53
+ if (key.escape) {
54
+ setActiveTab("prompt");
55
+ return;
56
+ }
57
+ // TAB always cycles tabs (works even when typing)
58
+ if (key.tab) {
59
+ const idx = TABS.indexOf(activeTab);
60
+ setActiveTab(TABS[(idx + 1) % TABS.length]);
61
+ return;
62
+ }
63
+ // Arrow keys switch tabs ONLY when NOT on prompt tab
64
+ // (on prompt tab, arrows are for cursor movement in text input)
65
+ if (activeTab !== "prompt") {
66
+ if (key.rightArrow) {
67
+ const idx = TABS.indexOf(activeTab);
68
+ setActiveTab(TABS[(idx + 1) % TABS.length]);
69
+ return;
70
+ }
71
+ if (key.leftArrow) {
72
+ const idx = TABS.indexOf(activeTab);
73
+ setActiveTab(TABS[(idx - 1 + TABS.length) % TABS.length]);
74
+ return;
75
+ }
76
+ }
77
+ }
78
+ if (screen === "title") {
79
+ if (input === "f" || input === "F") {
80
+ const e = new GameEngine("forge");
81
+ setEngine(e);
82
+ transition("round", "Game Start — Round 1");
83
+ setActiveTab("prompt");
84
+ }
85
+ if (input === "b" || input === "B") {
86
+ stdout.write("\x1B[2J\x1B[H");
87
+ setScreen("buddy");
88
+ }
89
+ if (input === "q" || input === "Q" || key.escape) {
90
+ exit();
91
+ }
92
+ }
93
+ if (screen === "result") {
94
+ if (input === " " || key.return) {
95
+ startBattle();
96
+ }
97
+ }
98
+ if (screen === "battle_done") {
99
+ if (input === " " || key.return) {
100
+ if (!engine)
101
+ return;
102
+ if (engine.state.phase === "game_over" || engine.state.phase === "victory") {
103
+ transition("end", engine.state.phase === "victory" ? "Victory!" : "Game Over");
104
+ }
105
+ else {
106
+ engine.nextRound();
107
+ transition("round", `Round ${engine.state.round} — ${engine.state.currentCategory.replace(/_/g, " ")}`);
108
+ setActiveTab("prompt");
109
+ setLastInterp(null);
110
+ setCombatResult(null);
111
+ setNarrative("");
112
+ }
113
+ }
114
+ }
115
+ if (screen === "end") {
116
+ if (input === "r" || input === "R") {
117
+ const e = new GameEngine("forge");
118
+ setEngine(e);
119
+ transition("round", "New Game — Round 1");
120
+ setActiveTab("prompt");
121
+ setLastInterp(null);
122
+ setCombatResult(null);
123
+ }
124
+ if (input === "q" || input === "Q" || key.escape) {
125
+ exit();
126
+ }
127
+ }
128
+ });
129
+ async function handleSubmit(value) {
130
+ if (!engine || !value.trim())
131
+ return;
132
+ setPrompt("");
133
+ setError("");
134
+ setLoadingMsg("Forging your creature and rival simultaneously...");
135
+ transition("waiting", `Round ${engine.state.round} — Forging...`);
136
+ try {
137
+ const effectiveStatBudget = Math.floor(engine.state.statBudget * 1.2);
138
+ // Build both prompts
139
+ const playerPromptData = buildInterpretPrompt(engine.state.currentCategory, effectiveStatBudget, engine.state.playerTeam, value.trim(), engine.state.round);
140
+ const rivalPromptData = buildRivalPrompt(engine.state.currentCategory, engine.state.statBudget, engine.state.playerTeam, engine.state.rivalTeam, engine.state.rivalIdentity?.personality);
141
+ // Fire both claude -p calls in parallel
142
+ const [playerRaw, rivalRaw] = await Promise.all([
143
+ callClaudeAsync(playerPromptData.system, playerPromptData.user),
144
+ callClaudeAsync(rivalPromptData.system, rivalPromptData.user),
145
+ ]);
146
+ // Parse player result
147
+ const interp = parsePromptInterpretation(playerRaw, effectiveStatBudget);
148
+ setLastInterp(interp);
149
+ if (engine.state.currentCategory === "recruit_ally") {
150
+ engine.addAlly(engine.makeAllyFromInterpretation(interp));
151
+ }
152
+ else {
153
+ engine.applyInterpretation(interp, 0);
154
+ }
155
+ // Parse rival result
156
+ const rivalInterp = parsePromptInterpretation(rivalRaw, engine.state.statBudget);
157
+ if (engine.state.currentCategory === "recruit_ally") {
158
+ engine.addRivalAlly(engine.makeAllyFromInterpretation(rivalInterp));
159
+ }
160
+ else {
161
+ engine.applyRivalEvolution(rivalInterp, 0);
162
+ }
163
+ transition("result", `Round ${engine.state.round} — Prompt interpreted`);
164
+ }
165
+ catch (err) {
166
+ setError(String(err));
167
+ transition("round", `Round ${engine.state.round} — Error`);
168
+ }
169
+ }
170
+ function startBattle() {
171
+ if (!engine)
172
+ return;
173
+ // Combat is pure math — instant
174
+ const result = resolveCombat(engine.state.playerTeam, engine.state.rivalTeam);
175
+ setCombatResult(result);
176
+ if (result.draw) {
177
+ engine.battleDraw();
178
+ }
179
+ else if (result.playerWon) {
180
+ engine.battleWon();
181
+ }
182
+ else {
183
+ engine.battleLost();
184
+ }
185
+ const rivalName = engine.state.rivalIdentity?.name ?? "Rival";
186
+ const taunts = result.draw
187
+ ? [`${rivalName} nods. "Not bad..."`, `"We're evenly matched," ${rivalName} mutters.`]
188
+ : result.playerWon
189
+ ? [`${rivalName} staggers. "I'll remember that..."`, `"Lucky," ${rivalName} spits.`]
190
+ : [`${rivalName} grins. "Is that all?"`, `"Predictable," ${rivalName} laughs.`];
191
+ setRivalTaunt(taunts[Math.floor(Math.random() * taunts.length)]);
192
+ // Show animated battle screen first
193
+ transition("battle", `Round ${engine.state.round} — Battle`);
194
+ }
195
+ function onBattleAnimationComplete() {
196
+ if (!engine || !combatResult)
197
+ return;
198
+ const outcomeLabel = combatResult.draw ? "Draw" : combatResult.playerWon ? "Victory" : "Defeat";
199
+ transition("battle_done", `Round ${engine.state.round} — ${outcomeLabel}`);
200
+ }
201
+ // ─── BUDDY BATTLE ─────────────────────────
202
+ if (screen === "buddy") {
203
+ return _jsx(BuddyBattle, { onQuit: () => { stdout.write("\x1B[2J\x1B[H"); setScreen("title"); } });
204
+ }
205
+ // ─── TITLE SCREEN ─────────────────────────
206
+ if (screen === "title") {
207
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "white", children: "Open" }), _jsx(Text, { color: "red", children: "Brawl" })] }), _jsx(Text, { dimColor: true, children: "AI-powered auto-battler. Prompt your creature into existence." }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Press ", _jsx(Text, { color: "cyan", bold: true, children: "F" }), " to start Forge Mode"] }), _jsxs(Text, { children: ["Press ", _jsx(Text, { color: "yellow", bold: true, children: "B" }), " for Buddy Battle"] }), _jsxs(Text, { children: ["Press ", _jsx(Text, { dimColor: true, bold: true, children: "Q" }), " to quit"] })] }));
208
+ }
209
+ // ─── INITIALIZING ─────────────────────────
210
+ if (screen === "initializing") {
211
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "white", children: "Open" }), _jsx(Text, { color: "red", children: "Brawl" })] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["[...]", loadingMsg] }), _jsx(Text, { dimColor: true, children: "(Calling Claude \u2014 this may take a few seconds)" })] }));
212
+ }
213
+ if (!engine)
214
+ return _jsx(Text, { children: "Loading..." });
215
+ const s = engine.state;
216
+ const termHeight = stdout.rows ?? 40;
217
+ const tabBar = (_jsxs(Box, { gap: 1, children: [TABS.map((tab) => (_jsx(Text, { bold: activeTab === tab, color: activeTab === tab ? "cyan" : "gray", children: activeTab === tab ? `[${tab.toUpperCase()}]` : ` ${tab.toUpperCase()} ` }, tab))), _jsxs(Text, { dimColor: true, children: [" TAB", activeTab !== "prompt" ? "/←→" : "", " to switch \u00B7 ESC=prompt"] })] }));
218
+ // When a non-prompt tab is active, render it as a full-screen overlay
219
+ if (activeTab !== "prompt" && (screen === "round" || screen === "result" || screen === "battle_done")) {
220
+ const panelContent = activeTab === "history" ? (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, width: "100%", children: [_jsx(Text, { bold: true, color: "yellow", children: "HISTORY" }), _jsx(Text, { children: " " }), history.length === 0 ? (_jsx(Text, { dimColor: true, children: "No history yet" })) : (history.map((entry, i) => (_jsxs(Text, { color: i === history.length - 1 ? "white" : "gray", children: [_jsx(Text, { dimColor: true, children: new Date(entry.timestamp).toLocaleTimeString() }), " ", _jsx(Text, { children: entry.label })] }, i))))] })) : (_jsx(DetailOverlay, { overlay: activeTab, playerTeam: s.playerTeam, rivalTeam: s.rivalTeam }));
221
+ return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: panelContent }), tabBar] }));
222
+ }
223
+ const promptBar = (_jsxs(Box, { flexDirection: "column", children: [tabBar, screen === "round" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Prompt your ", s.currentCategory.replace(/_/g, " "), " (", prompt.trim().split(/\s+/).filter(Boolean).length, "/", s.wordBudget, " words):"] }), _jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [">", " "] }), _jsx(TextInput, { value: prompt, onChange: setPrompt, onSubmit: handleSubmit })] })] })), screen === "result" && (_jsxs(Text, { children: ["Press ", _jsx(Text, { color: "red", bold: true, children: "ENTER" }), " or ", _jsx(Text, { color: "red", bold: true, children: "SPACE" }), " to battle!"] })), screen === "battle_done" && (s.phase === "game_over" || s.phase === "victory" ? (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, children: "ENTER" }), " to see final results"] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, children: "ENTER" }), " for next round"] })))] }));
224
+ // ─── WAITING FOR CLAUDE ───────────────────
225
+ if (screen === "waiting") {
226
+ return (_jsxs(Box, { flexDirection: "column", height: termHeight, alignItems: "center", justifyContent: "center", children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "white", children: "Open" }), _jsx(Text, { color: "red", children: "Brawl" })] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["[...] ", loadingMsg] }), _jsx(Text, { dimColor: true, children: "(Two parallel claude -p calls running)" })] }));
227
+ }
228
+ // ─── BATTLE IN PROGRESS (animated) ────────
229
+ if (screen === "battle" && combatResult) {
230
+ return (_jsx(Box, { flexDirection: "column", height: termHeight, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { bold: true, color: "red", children: "--- BATTLE ---" }), _jsx(Text, { children: " " }), _jsx(AnimatedCombatLog, { result: combatResult, onComplete: onBattleAnimationComplete, maxLogLines: termHeight - 10 })] }) }));
231
+ }
232
+ // ─── RESULT (post-interpretation, pre-battle) ─────
233
+ if (screen === "result") {
234
+ return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { bold: true, children: ["Round ", s.round, " / 10 \u2014 ", s.currentCategory.replace(/_/g, " ").toUpperCase()] }), _jsx(Text, { children: " " }), lastInterp && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { italic: true, color: "gray", children: lastInterp.flavorText }), _jsx(Box, { gap: 2, marginTop: 1, children: Object.entries(lastInterp.statChanges).map(([stat, val]) => val !== 0 ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: stat }), " ", _jsx(Text, { color: val > 0 ? "green" : "red", bold: true, children: val > 0 ? `+${val}` : val })] }, stat)) : null) }), lastInterp.abilities.length > 0 && lastInterp.abilities.map((a, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "NEW" }), " ", _jsx(Text, { color: "yellow", children: a.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", a.description] })] }, i))), lastInterp.creativityBonus > 0 && (_jsxs(Text, { color: "yellow", bold: true, children: ["+", lastInterp.creativityBonus * 10, "% creativity bonus!"] }))] })), _jsxs(Box, { gap: 1, width: "100%", children: [_jsx(Box, { flexDirection: "column", width: "50%", children: s.playerTeam.map((c, i) => _jsx(StatCard, { creature: c }, i)) }), _jsx(Box, { flexDirection: "column", width: "50%", children: s.rivalTeam.map((c, i) => _jsx(StatCard, { creature: c, isRival: true }, `r${i}`)) })] })] }), promptBar] }));
235
+ }
236
+ // ─── BATTLE DONE ──────────────────────────
237
+ if (screen === "battle_done" && combatResult) {
238
+ return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [combatResult.draw ? (_jsx(Text, { color: "yellow", bold: true, children: "=== DRAW ===" })) : combatResult.playerWon ? (_jsx(Text, { color: "cyan", bold: true, children: "=== VICTORY ===" })) : (_jsx(Text, { color: "red", bold: true, children: "=== DEFEAT ===" })), _jsx(Text, { children: " " }), _jsx(CombatLog, { result: combatResult, maxLogLines: termHeight - 12 }), rivalTaunt && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", italic: true, children: rivalTaunt }) })), _jsx(Text, { children: " " }), !combatResult.playerWon && !combatResult.draw && (_jsxs(Text, { color: "red", children: ["Lives remaining: ", Array.from({ length: s.maxLives }).map((_, i) => i < s.lives ? "♥" : "·").join(" ")] }))] }), promptBar] }));
239
+ }
240
+ // ─── END SCREEN ───────────────────────────
241
+ if (screen === "end") {
242
+ const isVictory = s.phase === "victory";
243
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsx(Text, { bold: true, color: isVictory ? "cyan" : "red", children: isVictory ? "=== VICTORY ===" : "=== GAME OVER ===" }), _jsx(Text, { dimColor: true, children: isVictory ? "You survived all 10 rounds!" : `Defeated in round ${s.round}` }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, bold: true, children: "YOUR FINAL TEAM" }), _jsx(Box, { gap: 2, marginTop: 1, children: s.playerTeam.map((c, i) => _jsx(StatCard, { creature: c }, i)) }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["Rounds: ", s.round, " | Team size: ", s.playerTeam.length, " | Mutations: ", s.playerTeam.reduce((sum, c) => sum + c.mutations.length, 0)] }), _jsxs(Text, { dimColor: true, children: ["Token usage: ", getTokenUsage().totalCalls, " Claude calls | ", getTokenUsage().inputTokens.toLocaleString(), " in + ", getTokenUsage().outputTokens.toLocaleString(), " out | ~$", getTokenUsage().estimatedCost.toFixed(4)] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Press ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), " to play again, ", _jsx(Text, { dimColor: true, bold: true, children: "Q" }), " to quit"] })] }));
244
+ }
245
+ // ─── ROUND SCREEN (prompting) ─────────────
246
+ const catLabel = s.currentCategory.replace(/_/g, " ");
247
+ const catIcon = CATEGORY_ICON[s.currentCategory] ?? "???";
248
+ const wordCount = prompt.trim().split(/\s+/).filter(Boolean).length;
249
+ return (_jsxs(Box, { flexDirection: "column", height: termHeight, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: ["Round ", s.round, "/10 \u2014 ", catLabel.toUpperCase()] }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: [getTokenUsage().totalCalls, " calls \u00B7 ", getTokenUsage().inputTokens.toLocaleString(), "\u2191 ", getTokenUsage().outputTokens.toLocaleString(), "\u2193 \u00B7 $", getTokenUsage().estimatedCost.toFixed(4)] }), _jsx(Text, { color: "red", children: Array.from({ length: s.maxLives }).map((_, i) => i < s.lives ? "♥" : "·").join(" ") })] })] }), _jsx(Box, { gap: 1, marginY: 1, children: Array.from({ length: 10 }).map((_, i) => {
250
+ const r = i + 1;
251
+ const icon = CATEGORY_ICON[["weapon", "defense", "special_ability", "recruit_ally", "reshape_evolve"][(r - 1) % 5]] ?? "?";
252
+ const isCurrent = r === s.round;
253
+ const isPast = r < s.round;
254
+ return (_jsx(Text, { color: isCurrent ? "cyan" : isPast ? "gray" : "white", bold: isCurrent, children: isCurrent ? `[${r}:${icon}]` : `${r}:${icon}` }, r));
255
+ }) }), _jsxs(Box, { gap: 1, width: "100%", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsx(Text, { color: "cyan", bold: true, children: "YOUR TEAM" }), s.playerTeam.map((c, i) => _jsx(StatCard, { creature: c }, i))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsx(Text, { color: "red", bold: true, children: s.rivalIdentity ? `${s.rivalIdentity.name} ${s.rivalIdentity.title}` : "RIVAL" }), s.rivalTeam.map((c, i) => _jsx(StatCard, { creature: c, isRival: true }, i))] })] }), error && _jsx(Text, { color: "red", children: error })] }), promptBar] }));
256
+ }