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 ADDED
@@ -0,0 +1,12 @@
1
+ Copyright (c) 2026 Bad Cat Brewing LLC. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") may not be
4
+ copied, modified, merged, published, distributed, sublicensed, or sold, in
5
+ whole or in part, without the prior written permission of the copyright holder.
6
+
7
+ The Software is provided "as is", without warranty of any kind, express or
8
+ implied. In no event shall the copyright holder be liable for any claim,
9
+ damages, or other liability arising from the use of the Software.
10
+
11
+ Use of the Software via its published npm package (openbrawl) is permitted for
12
+ personal, non-commercial purposes only.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # OpenBrawl
2
+
3
+ AI-powered auto-battler in your terminal. Type a prompt. Forge a creature. Watch it fight.
4
+
5
+ Requires [Claude Code](https://claude.ai/claude-code) to be installed.
6
+
7
+ ## Play
8
+
9
+ ```bash
10
+ npx openbrawl
11
+ ```
12
+
13
+ ## Modes
14
+
15
+ **Forge Mode** - 10 rounds of prompt-driven creature evolution. Describe weapons, defenses, abilities, allies, and transformations. Claude interprets your prompts and generates stats, abilities, and mutations. Battle AI rivals after each round.
16
+
17
+ **Buddy Battle** - Pit two Anthropic buddy cards against each other in a best-of-3 narrative showdown. Copy a buddy card to your clipboard, press V to load it, and watch Claude narrate the fight beat by beat.
18
+
19
+ ## License
20
+
21
+ Proprietary. See [LICENSE](./LICENSE).
@@ -0,0 +1,41 @@
1
+ // src/buddy/battle.ts
2
+ function extractJSON(raw) {
3
+ try {
4
+ JSON.parse(raw.trim());
5
+ return raw.trim();
6
+ }
7
+ catch { }
8
+ const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
9
+ if (fenceMatch)
10
+ return fenceMatch[1].trim();
11
+ const objMatch = raw.match(/\{[\s\S]*\}/);
12
+ if (objMatch)
13
+ return objMatch[0];
14
+ return raw.trim();
15
+ }
16
+ export function parseBattleRound(raw, roundNumber, validNames) {
17
+ const json = JSON.parse(extractJSON(raw));
18
+ const beats = (json.beats ?? []).map((b) => String(b));
19
+ if (beats.length === 0) {
20
+ throw new Error("No beats in battle response");
21
+ }
22
+ let winner = String(json.winner ?? "");
23
+ // Validate winner is one of the two buddy names
24
+ if (!validNames.includes(winner)) {
25
+ // Try case-insensitive match
26
+ const match = validNames.find((n) => n.toLowerCase() === winner.toLowerCase());
27
+ if (match) {
28
+ winner = match;
29
+ }
30
+ else {
31
+ // Fallback: pick randomly
32
+ winner = validNames[Math.random() < 0.5 ? 0 : 1];
33
+ }
34
+ }
35
+ return {
36
+ roundNumber,
37
+ beats,
38
+ winner,
39
+ victoryQuip: json.victoryQuip ?? json.victory_quip ?? undefined,
40
+ };
41
+ }
@@ -0,0 +1,93 @@
1
+ // src/buddy/parse.ts
2
+ export function parseBuddyCard(raw) {
3
+ // Strip box-drawing characters from each line
4
+ const lines = raw.split("\n").map((line) => line.replace(/[│╭╮╰╯─┌┐└┘┬┴├┤┼║═╔╗╚╝╠╣╦╩╬]/g, "").trim()).filter((line) => line.length > 0);
5
+ // Parse rarity + species from line with star characters
6
+ let rarity = "";
7
+ let species = "";
8
+ const rarityLineIdx = lines.findIndex((l) => /[★☆✦✧⭐]/.test(l) || /^(COMMON|UNCOMMON|RARE|LEGENDARY)/i.test(l));
9
+ if (rarityLineIdx !== -1) {
10
+ const rarityLine = lines[rarityLineIdx];
11
+ const starMatch = rarityLine.match(/[★☆✦✧⭐]+/);
12
+ const words = rarityLine.replace(/[★☆✦✧⭐]+/, "").trim().split(/\s+/);
13
+ // Rarity label is typically the first ALL-CAPS word, species is the last
14
+ const capsWords = words.filter((w) => w === w.toUpperCase() && /^[A-Z]{2,}$/.test(w));
15
+ if (capsWords.length >= 2) {
16
+ rarity = `${starMatch?.[0] ?? ""} ${capsWords[0]}`.trim();
17
+ species = capsWords[capsWords.length - 1];
18
+ }
19
+ else if (capsWords.length === 1) {
20
+ rarity = `${starMatch?.[0] ?? ""} ${capsWords[0]}`.trim();
21
+ }
22
+ }
23
+ // Parse stats — lines matching: ALL_CAPS_NAME bar_chars number
24
+ const stats = [];
25
+ const statLineIndices = [];
26
+ lines.forEach((line, idx) => {
27
+ const statMatch = line.match(/^([A-Z][A-Z\s]+?)\s+[█░▓▒]+\s+(\d+)$/);
28
+ if (statMatch) {
29
+ stats.push({ name: statMatch[1].trim(), value: parseInt(statMatch[2], 10) });
30
+ statLineIndices.push(idx);
31
+ }
32
+ });
33
+ // Parse description — text between quotes (may span multiple lines)
34
+ const fullText = lines.join("\n");
35
+ const descMatch = fullText.match(/"([^"]+)"/s);
36
+ const description = descMatch ? descMatch[1].replace(/\s+/g, " ").trim() : "";
37
+ // Find the description line indices for exclusion
38
+ const descLineIndices = [];
39
+ if (description) {
40
+ let inDesc = false;
41
+ lines.forEach((line, idx) => {
42
+ if (line.includes('"') && !inDesc) {
43
+ inDesc = true;
44
+ descLineIndices.push(idx);
45
+ }
46
+ else if (inDesc) {
47
+ descLineIndices.push(idx);
48
+ if (line.includes('"'))
49
+ inDesc = false;
50
+ }
51
+ });
52
+ }
53
+ // Find name — standalone text line that isn't rarity, stats, or description
54
+ const excludedIndices = new Set([rarityLineIdx, ...statLineIndices, ...descLineIndices]);
55
+ let name = "";
56
+ let nameIdx = -1;
57
+ // Name comes after the art section, before description/stats
58
+ // Look for a short standalone text line (1-3 words, not ASCII art)
59
+ const firstStatIdx = statLineIndices.length > 0 ? Math.min(...statLineIndices) : lines.length;
60
+ const firstDescIdx = descLineIndices.length > 0 ? Math.min(...descLineIndices) : lines.length;
61
+ const nameSearchEnd = Math.min(firstStatIdx, firstDescIdx);
62
+ for (let i = 0; i < nameSearchEnd; i++) {
63
+ if (excludedIndices.has(i))
64
+ continue;
65
+ const line = lines[i];
66
+ // Name: short text, no special chars (not ASCII art)
67
+ if (line.length > 0 && line.length <= 30 && /^[A-Za-z][\w\s'-]*$/.test(line) && line.split(/\s+/).length <= 4) {
68
+ name = line;
69
+ nameIdx = i;
70
+ break;
71
+ }
72
+ }
73
+ // ASCII art — everything between rarity line and name line that isn't parsed as something else
74
+ const artStart = rarityLineIdx !== -1 ? rarityLineIdx + 1 : 0;
75
+ const artEnd = nameIdx !== -1 ? nameIdx : firstDescIdx;
76
+ const artLines = [];
77
+ for (let i = artStart; i < artEnd; i++) {
78
+ if (!excludedIndices.has(i)) {
79
+ artLines.push(lines[i]);
80
+ }
81
+ }
82
+ const asciiArt = artLines.join("\n");
83
+ if (!name && stats.length === 0) {
84
+ throw new Error("Could not parse buddy card — no name or stats found.");
85
+ }
86
+ if (stats.length === 0) {
87
+ throw new Error("Could not parse buddy card — no stats found.");
88
+ }
89
+ if (!name) {
90
+ name = "Unknown Buddy";
91
+ }
92
+ return { name, rarity, species, asciiArt, description, stats };
93
+ }
@@ -0,0 +1,43 @@
1
+ // src/buddy/prompts.ts
2
+ function serializeBuddy(buddy) {
3
+ const statsStr = buddy.stats.map((s) => ` ${s.name}: ${s.value}/100`).join("\n");
4
+ return `Name: ${buddy.name}
5
+ Rarity: ${buddy.rarity}
6
+ Species: ${buddy.species}
7
+ Description: "${buddy.description}"
8
+ Stats:
9
+ ${statsStr}`;
10
+ }
11
+ export function buildBuddyBattlePrompt(playerBuddy, opponentBuddy, roundNumber, priorRounds, isMatchPoint) {
12
+ const priorContext = priorRounds.length > 0
13
+ ? `\n\nPRIOR ROUNDS:\n${priorRounds.map((r) => `Round ${r.roundNumber}: ${r.winner} won.\n${r.beats.join("\n")}`).join("\n\n")}`
14
+ : "";
15
+ const system = `You are the narrator for an epic buddy battle — two collectible creatures with unique personalities and stats face off.
16
+
17
+ BUDDY 1 (Player):
18
+ ${serializeBuddy(playerBuddy)}
19
+
20
+ BUDDY 2 (Opponent):
21
+ ${serializeBuddy(opponentBuddy)}
22
+
23
+ RULES:
24
+ - These buddies do NOT use traditional ATK/DEF/SPD/HP stats. They have their own unique stats (like DEBUGGING, SNARK, CHAOS, MISCHIEF, etc.).
25
+ - You decide how each buddy's stats translate into combat actions. A buddy with high SNARK might win by demoralizing the opponent. High DEBUGGING might mean spotting weaknesses. High CHAOS means unpredictable attacks. Be creative.
26
+ - Always channel each buddy's personality and attitude throughout. Trash talk, passive-aggressive observations, and in-character quips are MANDATORY in every beat, not just at the end. The buddies' SNARK (or equivalent sass/attitude stat) should drip from the narration.
27
+ - Write the round as exactly 4-6 beats (short dramatic moments, 1-3 sentences each).
28
+ - The final beat MUST declare a winner for the round.
29
+ - Reference specific stats BY NAME and VALUE to justify what happens. Don't just narrate — explain WHY a stat matters in this moment.
30
+ - If this is round 2 or 3, account for prior rounds. Maybe the loser adapts, the winner gets cocky, or a new stat comes into play that wasn't relevant before.
31
+ - Keep it fun, dramatic, and varied — don't repeat narrative patterns across rounds.
32
+ - Battles should feel fair. Don't always give it to the buddy with higher total stats — creativity, personality, and stat matchups matter.
33
+ ${isMatchPoint ? "\n- This is the MATCH-DECIDING round. Include a \"victoryQuip\" field — the winner's in-character taunt for the victory screen. Make it memorable." : ""}
34
+ ${priorContext}
35
+
36
+ Respond with ONLY valid JSON:
37
+ {
38
+ "beats": ["beat 1 text", "beat 2 text", "...", "final beat declaring winner"],
39
+ "winner": "<exact buddy name>"${isMatchPoint ? ',\n "victoryQuip": "<winner\'s in-character victory taunt>"' : ""}
40
+ }`;
41
+ const user = `Narrate Round ${roundNumber} of 3. Fight!`;
42
+ return { system, user };
43
+ }
@@ -0,0 +1,2 @@
1
+ // src/buddy/types.ts
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./ui/App.js";
5
+ // Enter alternate screen buffer (like vim) — no scrollback, clean slate
6
+ process.stdout.write("\x1B[?1049h");
7
+ // Hide cursor
8
+ process.stdout.write("\x1B[?25l");
9
+ const { unmount, waitUntilExit } = render(_jsx(App, {}), { exitOnCtrlC: true });
10
+ waitUntilExit().then(() => {
11
+ // Show cursor
12
+ process.stdout.write("\x1B[?25h");
13
+ // Leave alternate screen buffer — restores original terminal content
14
+ process.stdout.write("\x1B[?1049l");
15
+ });
@@ -0,0 +1,325 @@
1
+ function cloneForCombat(creatures, team) {
2
+ return creatures.map((c) => ({
3
+ ...c,
4
+ stats: { ...c.stats },
5
+ abilities: c.abilities.map((a) => ({ ...a })),
6
+ currentHP: c.currentHP,
7
+ team,
8
+ activeBuffs: [],
9
+ }));
10
+ }
11
+ function pickTarget(attacker, allCombatants) {
12
+ const enemies = allCombatants.filter((c) => c.team !== attacker.team && c.currentHP > 0);
13
+ if (enemies.length === 0)
14
+ return null;
15
+ // Allies (non-first creatures) are the front line — target them first.
16
+ // Only attack the main creature (index 0 in their team) when all allies are down.
17
+ if (enemies.length > 1) {
18
+ return enemies[enemies.length - 1]; // last = most recently added ally
19
+ }
20
+ return enemies[0];
21
+ }
22
+ function snapshot(combatants) {
23
+ return combatants
24
+ .filter((c) => c.currentHP > 0 || c.stats.HP > 0)
25
+ .map((c) => ({
26
+ name: c.name,
27
+ currentHP: Math.max(0, c.currentHP),
28
+ maxHP: c.stats.HP,
29
+ team: c.team,
30
+ }));
31
+ }
32
+ function resolveAbility(ability, owner, allCombatants, events, turn) {
33
+ // All abilities use value as % proc chance (except heal which always fires but is capped)
34
+ const roll = Math.random() * 100;
35
+ if (ability.effect === "stun") {
36
+ if (roll < ability.value) {
37
+ const target = pickTarget(owner, allCombatants);
38
+ if (target) {
39
+ events.push({
40
+ turn,
41
+ attacker: owner.name,
42
+ attackerSPD: owner.stats.SPD,
43
+ defender: target.name,
44
+ damage: 0,
45
+ abilityTriggered: ability.name,
46
+ abilityEffect: `Stunned ${target.name} (${ability.value}% chance — rolled ${Math.floor(roll)})`,
47
+ hpAfter: snapshot(allCombatants),
48
+ });
49
+ }
50
+ }
51
+ return;
52
+ }
53
+ if (ability.effect === "heal") {
54
+ // Heal uses value as flat amount, always fires but capped at missing HP
55
+ const healAmount = Math.min(ability.value, owner.stats.HP - owner.currentHP);
56
+ if (healAmount > 0) {
57
+ owner.currentHP += healAmount;
58
+ events.push({
59
+ turn,
60
+ attacker: owner.name,
61
+ attackerSPD: owner.stats.SPD,
62
+ defender: owner.name,
63
+ damage: -healAmount,
64
+ abilityTriggered: ability.name,
65
+ abilityEffect: `Healed ${healAmount} HP`,
66
+ hpAfter: snapshot(allCombatants),
67
+ });
68
+ }
69
+ return;
70
+ }
71
+ if (ability.effect === "damage") {
72
+ // Proc chance: value as %, capped at 75%
73
+ const procChance = Math.min(ability.value, 75);
74
+ if (roll >= procChance)
75
+ return;
76
+ const targets = ability.target === "all_enemies"
77
+ ? allCombatants.filter((c) => c.team !== owner.team && c.currentHP > 0)
78
+ : [pickTarget(owner, allCombatants)].filter(Boolean);
79
+ // Damage: scales with owner's ATK. Ability damage = 50-100% of ATK (based on value)
80
+ // value 15 = 50% ATK, value 50+ = 100% ATK. Minimum 2 damage.
81
+ const atkMultiplier = Math.min(1.0, 0.5 + (ability.value / 100));
82
+ const dmg = Math.max(2, Math.round(owner.stats.ATK * atkMultiplier));
83
+ for (const target of targets) {
84
+ target.currentHP = Math.max(0, target.currentHP - dmg);
85
+ events.push({
86
+ turn,
87
+ attacker: owner.name,
88
+ attackerSPD: owner.stats.SPD,
89
+ defender: target.name,
90
+ damage: dmg,
91
+ damageFormula: `${ability.name}: ${dmg} ability dmg (${procChance}% chance — rolled ${Math.floor(roll)})`,
92
+ abilityTriggered: ability.name,
93
+ abilityEffect: `Dealt ${dmg} ability damage`,
94
+ knockedOut: target.currentHP <= 0 ? target.name : undefined,
95
+ hpAfter: snapshot(allCombatants),
96
+ });
97
+ }
98
+ return;
99
+ }
100
+ if (ability.effect === "reflect") {
101
+ if (roll >= ability.value)
102
+ return;
103
+ const reflectTarget = pickTarget(owner, allCombatants);
104
+ if (reflectTarget) {
105
+ const reflectDmg = Math.max(1, Math.round(owner.stats.DEF * 0.4));
106
+ reflectTarget.currentHP = Math.max(0, reflectTarget.currentHP - reflectDmg);
107
+ events.push({
108
+ turn,
109
+ attacker: owner.name,
110
+ attackerSPD: owner.stats.SPD,
111
+ defender: reflectTarget.name,
112
+ damage: reflectDmg,
113
+ damageFormula: `${ability.name}: ${reflectDmg} reflect dmg (40% of ${owner.stats.DEF} DEF, ${ability.value}% chance — rolled ${Math.floor(roll)})`,
114
+ abilityTriggered: ability.name,
115
+ abilityEffect: `Reflected ${reflectDmg} damage`,
116
+ knockedOut: reflectTarget.currentHP <= 0 ? reflectTarget.name : undefined,
117
+ hpAfter: snapshot(allCombatants),
118
+ });
119
+ }
120
+ return;
121
+ }
122
+ if (ability.effect === "buff") {
123
+ if (roll >= ability.value)
124
+ return;
125
+ const buffAmount = Math.max(1, Math.round(owner.stats.ATK * 0.3));
126
+ owner.stats.ATK += buffAmount;
127
+ owner.activeBuffs.push({ stat: "ATK", amount: buffAmount, turnsRemaining: 3, source: ability.name });
128
+ events.push({
129
+ turn,
130
+ attacker: owner.name,
131
+ attackerSPD: owner.stats.SPD,
132
+ defender: owner.name,
133
+ damage: 0,
134
+ abilityTriggered: ability.name,
135
+ abilityEffect: `ATK buffed by +${buffAmount} for 3 turns`,
136
+ hpAfter: snapshot(allCombatants),
137
+ });
138
+ return;
139
+ }
140
+ if (ability.effect === "debuff") {
141
+ if (roll >= ability.value)
142
+ return;
143
+ const target = pickTarget(owner, allCombatants);
144
+ if (target) {
145
+ const debuffAmount = Math.max(1, Math.round(target.stats.DEF * 0.3));
146
+ target.stats.DEF -= debuffAmount;
147
+ target.activeBuffs.push({ stat: "DEF", amount: -debuffAmount, turnsRemaining: 3, source: ability.name });
148
+ events.push({
149
+ turn,
150
+ attacker: owner.name,
151
+ attackerSPD: owner.stats.SPD,
152
+ defender: target.name,
153
+ damage: 0,
154
+ abilityTriggered: ability.name,
155
+ abilityEffect: `DEF reduced by ${debuffAmount} for 3 turns`,
156
+ hpAfter: snapshot(allCombatants),
157
+ });
158
+ }
159
+ return;
160
+ }
161
+ // Fallback: treat any unknown effect as a damage proc
162
+ const procChance = Math.min(ability.value, 75);
163
+ if (roll >= procChance)
164
+ return;
165
+ const target = pickTarget(owner, allCombatants);
166
+ if (target) {
167
+ const atkMult = Math.min(1.0, 0.5 + (ability.value / 100));
168
+ const dmg = Math.max(2, Math.round(owner.stats.ATK * atkMult));
169
+ target.currentHP = Math.max(0, target.currentHP - dmg);
170
+ events.push({
171
+ turn,
172
+ attacker: owner.name,
173
+ attackerSPD: owner.stats.SPD,
174
+ defender: target.name,
175
+ damage: dmg,
176
+ damageFormula: `${ability.name}: ${dmg} dmg (unknown effect "${ability.effect}" — treated as damage)`,
177
+ abilityTriggered: ability.name,
178
+ abilityEffect: `Dealt ${dmg} damage`,
179
+ knockedOut: target.currentHP <= 0 ? target.name : undefined,
180
+ hpAfter: snapshot(allCombatants),
181
+ });
182
+ }
183
+ }
184
+ function triggerAbilities(trigger, owner, allCombatants, events, turn) {
185
+ for (const ability of owner.abilities) {
186
+ if (ability.trigger === trigger) {
187
+ resolveAbility(ability, owner, allCombatants, events, turn);
188
+ }
189
+ }
190
+ }
191
+ const MAX_TICKS = 500;
192
+ const ATB_THRESHOLD = 100;
193
+ /**
194
+ * ATB (Active Time Battle) combat engine.
195
+ *
196
+ * Each tick, every creature accumulates their SPD into a gauge.
197
+ * When the gauge hits the threshold (100), they act and the gauge resets.
198
+ * This naturally interleaves attacks: SPD 3 vs SPD 1 produces
199
+ * A, A, B, A, A, B, A, ... (3:1 ratio, interleaved).
200
+ */
201
+ export function resolveCombat(playerTeam, rivalTeam) {
202
+ const combatants = [
203
+ ...cloneForCombat(playerTeam, "player"),
204
+ ...cloneForCombat(rivalTeam, "rival"),
205
+ ];
206
+ const events = [];
207
+ // ATB gauges — start at threshold so everyone acts on turn 1
208
+ const gauges = new Map();
209
+ for (const c of combatants) {
210
+ gauges.set(c, ATB_THRESHOLD);
211
+ }
212
+ let turnCounter = 0;
213
+ for (let tick = 0; tick < MAX_TICKS; tick++) {
214
+ const alive = combatants.filter((c) => c.currentHP > 0);
215
+ const playersAlive = alive.filter((c) => c.team === "player");
216
+ const rivalsAlive = alive.filter((c) => c.team === "rival");
217
+ if (playersAlive.length === 0 || rivalsAlive.length === 0)
218
+ break;
219
+ // Sudden death: after tick 300, all creatures take escalating damage
220
+ if (tick > 300) {
221
+ const suddenDeathDmg = tick - 300;
222
+ for (const c of alive) {
223
+ c.currentHP = Math.max(0, c.currentHP - suddenDeathDmg);
224
+ if (c.currentHP <= 0) {
225
+ turnCounter++;
226
+ events.push({
227
+ turn: turnCounter,
228
+ attacker: "Arena",
229
+ attackerSPD: 0,
230
+ defender: c.name,
231
+ damage: suddenDeathDmg,
232
+ damageFormula: `Sudden Death: ${suddenDeathDmg} arena damage (tick ${tick})`,
233
+ knockedOut: c.name,
234
+ hpAfter: snapshot(combatants),
235
+ });
236
+ }
237
+ }
238
+ const pStillAlive = combatants.filter((c) => c.team === "player" && c.currentHP > 0);
239
+ const rStillAlive = combatants.filter((c) => c.team === "rival" && c.currentHP > 0);
240
+ if (pStillAlive.length === 0 || rStillAlive.length === 0)
241
+ break;
242
+ }
243
+ // Accumulate SPD into gauges
244
+ for (const c of alive) {
245
+ gauges.set(c, (gauges.get(c) ?? 0) + Math.max(1, c.stats.SPD));
246
+ }
247
+ // Find all creatures whose gauge hit the threshold
248
+ const acting = [];
249
+ for (const c of alive) {
250
+ if ((gauges.get(c) ?? 0) >= ATB_THRESHOLD) {
251
+ acting.push(c);
252
+ // Carry over excess gauge (not a full reset — preserves fractional advantage)
253
+ gauges.set(c, (gauges.get(c) ?? 0) - ATB_THRESHOLD);
254
+ }
255
+ }
256
+ if (acting.length === 0)
257
+ continue;
258
+ // If multiple creatures act on the same tick, faster goes first
259
+ acting.sort((a, b) => b.stats.SPD - a.stats.SPD);
260
+ turnCounter++;
261
+ for (const creature of acting) {
262
+ if (creature.currentHP <= 0)
263
+ continue;
264
+ // Tick down active buffs/debuffs
265
+ creature.activeBuffs = creature.activeBuffs.filter((b) => {
266
+ b.turnsRemaining--;
267
+ if (b.turnsRemaining <= 0) {
268
+ creature.stats[b.stat] -= b.amount;
269
+ return false;
270
+ }
271
+ return true;
272
+ });
273
+ triggerAbilities("start_of_turn", creature, combatants, events, turnCounter);
274
+ const target = pickTarget(creature, combatants);
275
+ if (!target)
276
+ break;
277
+ const rawDamage = creature.stats.ATK - target.stats.DEF;
278
+ // Chip damage: even fully blocked attacks deal 1 to prevent stalemates
279
+ const damage = rawDamage > 0 ? rawDamage : 1;
280
+ const blocked = rawDamage <= 0;
281
+ target.currentHP = Math.max(0, target.currentHP - damage);
282
+ events.push({
283
+ turn: turnCounter,
284
+ attacker: creature.name,
285
+ attackerSPD: creature.stats.SPD,
286
+ defender: target.name,
287
+ damage,
288
+ damageFormula: blocked
289
+ ? `${creature.stats.ATK} ATK - ${target.stats.DEF} DEF = ${rawDamage} — mostly blocked! (1 chip dmg)`
290
+ : `${creature.stats.ATK} ATK - ${target.stats.DEF} DEF = ${damage}`,
291
+ knockedOut: target.currentHP <= 0 ? target.name : undefined,
292
+ hpAfter: snapshot(combatants),
293
+ });
294
+ // Attacker's on_attack abilities
295
+ triggerAbilities("on_attack", creature, combatants, events, turnCounter);
296
+ // Defender's on_hit and on_defend abilities (defensive reactions)
297
+ if (target.currentHP > 0) {
298
+ triggerAbilities("on_hit", target, combatants, events, turnCounter);
299
+ triggerAbilities("on_defend", target, combatants, events, turnCounter);
300
+ }
301
+ if (target.currentHP <= 0) {
302
+ triggerAbilities("on_death", target, combatants, events, turnCounter);
303
+ const allies = combatants.filter((c) => c.team === target.team && c.currentHP > 0 && c.name !== target.name);
304
+ for (const ally of allies) {
305
+ triggerAbilities("on_ally_death", ally, combatants, events, turnCounter);
306
+ }
307
+ }
308
+ const pAlive = combatants.filter((c) => c.team === "player" && c.currentHP > 0);
309
+ const rAlive = combatants.filter((c) => c.team === "rival" && c.currentHP > 0);
310
+ if (pAlive.length === 0 || rAlive.length === 0)
311
+ break;
312
+ }
313
+ }
314
+ const playerSurvivors = combatants.filter((c) => c.team === "player" && c.currentHP > 0);
315
+ const rivalSurvivors = combatants.filter((c) => c.team === "rival" && c.currentHP > 0);
316
+ const playerAlive = playerSurvivors.length > 0;
317
+ const rivalAlive = rivalSurvivors.length > 0;
318
+ return {
319
+ events,
320
+ playerWon: playerAlive && !rivalAlive,
321
+ draw: (!playerAlive && !rivalAlive) || (playerAlive && rivalAlive),
322
+ playerTeamSurvivors: playerSurvivors.map((c) => c.name),
323
+ rivalTeamSurvivors: rivalSurvivors.map((c) => c.name),
324
+ };
325
+ }
@@ -0,0 +1,70 @@
1
+ const CATEGORY_CYCLE = [
2
+ "weapon",
3
+ "defense",
4
+ "special_ability",
5
+ "recruit_ally",
6
+ "reshape_evolve",
7
+ ];
8
+ const BASE_WORD_BUDGET = 10;
9
+ const WORD_BUDGET_GROWTH = 3;
10
+ const BASE_STAT_BUDGET = 10;
11
+ const STAT_BUDGET_GROWTH = 4;
12
+ export function getCategoryForRound(round) {
13
+ return CATEGORY_CYCLE[(round - 1) % CATEGORY_CYCLE.length];
14
+ }
15
+ export function getWordBudget(round) {
16
+ return BASE_WORD_BUDGET + (round - 1) * WORD_BUDGET_GROWTH;
17
+ }
18
+ export function getStatBudget(round) {
19
+ return BASE_STAT_BUDGET + (round - 1) * STAT_BUDGET_GROWTH;
20
+ }
21
+ const RIVAL_NAMES = [
22
+ "Vex", "Kron", "Nyx", "Zeph", "Raze", "Glim", "Thane", "Dusk", "Blight", "Shard",
23
+ "Echo", "Flux", "Grimm", "Hex", "Jolt", "Lurk", "Maw", "Null", "Prism", "Rust",
24
+ ];
25
+ const RIVAL_TITLES = [
26
+ "the Relentless", "the Cunning", "the Unyielding", "the Shadow", "the Devourer",
27
+ "the Architect", "the Storm", "the Patient", "the Hollow", "the Burning",
28
+ "the Mirror", "the Thorn", "the Void Walker", "the Iron Will", "the Silent",
29
+ ];
30
+ const RIVAL_PERSONALITIES = [
31
+ "methodical and calculating — always builds counters",
32
+ "aggressive and reckless — all offense, no defense",
33
+ "defensive and patient — waits for you to overcommit",
34
+ "chaotic and unpredictable — wild ability combinations",
35
+ "balanced and adaptive — mirrors your strategy",
36
+ ];
37
+ export function generateRivalIdentity() {
38
+ const name = RIVAL_NAMES[Math.floor(Math.random() * RIVAL_NAMES.length)];
39
+ const title = RIVAL_TITLES[Math.floor(Math.random() * RIVAL_TITLES.length)];
40
+ const personality = RIVAL_PERSONALITIES[Math.floor(Math.random() * RIVAL_PERSONALITIES.length)];
41
+ return { name, title, personality };
42
+ }
43
+ function createStarterBox(name) {
44
+ return {
45
+ name,
46
+ stats: { ATK: 3, DEF: 2, SPD: 3, HP: 20 },
47
+ currentHP: 20,
48
+ abilities: [],
49
+ mutations: [],
50
+ flavorText: "A featureless box. It can do nothing... yet.",
51
+ };
52
+ }
53
+ export function createInitialGameState(mode, withRival = true) {
54
+ const rivalIdentity = withRival ? generateRivalIdentity() : { name: "Rival Box", title: "the Unknown", personality: "balanced and adaptive" };
55
+ return {
56
+ mode,
57
+ round: 1,
58
+ lives: 5,
59
+ maxLives: 5,
60
+ reforges: 2,
61
+ maxReforges: 2,
62
+ playerTeam: [createStarterBox("Box")],
63
+ rivalTeam: [createStarterBox(rivalIdentity.name)],
64
+ currentCategory: getCategoryForRound(1),
65
+ wordBudget: getWordBudget(1),
66
+ statBudget: getStatBudget(1),
67
+ phase: "prompting",
68
+ rivalIdentity,
69
+ };
70
+ }