Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ Data collection and statistics. Output: `runs/v1.0.0/{strategy}/{vendor}/{model}

Template-based system using Jinja2. Templates render strategy guidance, game state, and action history into LLM prompts. At each decision point: render templates → send to LLM with tools → execute tool call. Custom strategies: create folder in `src/balatrollm/strategies/` with required template files.

**Gotcha — pack card modifiers:** BalatroBot puts enhancement/edition/seal on `card.modifier.*`, never on `card.value.*` (`value` is only `suit`/`rank`/`effect`). In `GAMESTATE.md.jinja` pack sections (`SMODS_BOOSTER_OPENED`), always use `card.modifier.enhancement` / `edition` / `seal` (same as hand/shop). Reading `card.value.enhancement` renders blank lines and hides Standard-pack modifiers from the LLM.

**Gotcha — pack skip is not a refund:** Buying a booster spends money immediately. In `SMODS_BOOSTER_OPENED`, skipping takes no card and does not refund the purchase. Strategy/GAMESTATE/TOOLS guidance must say the cost is already spent and prefer taking upside unless there is a skip-payoff joker (e.g. Red Card) or every option is actively harmful. Do not imply that skipping after purchase saves money.

### Relationship with BalatroBot

**BalatroBot** is a Lua mod that runs inside the Balatro game and exposes a JSON-RPC 2.0 HTTP API for programmatic control.
Expand Down
10 changes: 5 additions & 5 deletions src/balatrollm/strategies/aggressive/GAMESTATE.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ Here are the vouchers that can be redeemed:
{% if G.state == "SMODS_BOOSTER_OPENED" -%}
## Pack Selection

You have opened a booster pack. Choose a card or skip.
You have opened a booster pack. The purchase cost is already spent — skipping does not refund money. Prefer selecting a card with any upside unless you have a skip-payoff joker (e.g. Red Card) or every option is actively harmful.

### Available Cards in Pack

Expand All @@ -222,17 +222,17 @@ You have opened a booster pack. Choose a card or skip.
{% if card.value.min_highlighted %} - **Targets**: {{ card.value.min_highlighted }}-{{ card.value.max_highlighted }} hand cards{% endif %}
{% elif card.set in ["DEFAULT", "ENHANCED"] %}
- {{ loop.index0 }}: **{{ card.value.rank }} of {{ card.value.suit }}**
{% if card.set == "ENHANCED" %} - **Enhancement**: {{ card.value.enhancement }}{% endif %}
{% if card.value.edition %} - **Edition**: {{ card.value.edition }}{% endif %}
{% if card.value.seal %} - **Seal**: {{ card.value.seal }}{% endif %}
{% if card.modifier.edition %} - **{{ card.modifier.edition }} Edition**{% endif %}
{% if card.modifier.enhancement %} - **{{ card.modifier.enhancement }} Enhancement**{% endif %}
{% if card.modifier.seal %} - **{{ card.modifier.seal }} Seal**{% endif %}
{% endif %}
{%- endfor %}

{% if G.hand and G.hand.cards %}
### Your Hand (for targeting)

{% for card in G.hand.cards -%}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.set == "ENHANCED" %} ({{ card.value.enhancement }}){% endif %}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.modifier.enhancement %} ({{ card.modifier.enhancement }}){% endif %}
{% endfor %}
{% endif %}
{%- endif %}
Expand Down
5 changes: 5 additions & 0 deletions src/balatrollm/strategies/aggressive/STRATEGY.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ The shop sells: 2 random cards (usually Jokers), 1 Voucher, 2 Booster Packs.
- **Buffoon Packs**: Joker Cards to add to your run
- **Spectral Packs**: Spectral Cards for immediate use (HIGH RISK/HIGH REWARD)

**After buying a pack (sunk cost):**
- The purchase cost is **already spent**. Skipping does not refund money.
- Prefer taking any card with potential upside over skipping for "savings".
- Skip only if: (1) you have a skip-payoff joker (e.g. Red Card), or (2) every option is actively harmful (bad Spectral, unwanted deck dilution, forcing out a better joker).

## Jokers

Jokers are 150 special cards that modify scoring. Every run starts with 5 empty Joker slots. Jokers are ESSENTIAL for defeating higher antes.
Expand Down
2 changes: 1 addition & 1 deletion src/balatrollm/strategies/aggressive/TOOLS.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
"function": {
"name": "pack",
"strict": false,
"description": "Select a card from the opened booster pack, or skip without taking any card. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards.",
"description": "Select a card from the opened booster pack, or skip without taking any card. The pack cost is already spent — skipping does not refund money. Prefer taking a useful card unless you have a skip-payoff joker or every option is harmful. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards.",
"parameters": {
"type": "object",
"properties": {
Expand Down
10 changes: 5 additions & 5 deletions src/balatrollm/strategies/conservative/GAMESTATE.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Here are the vouchers that can be redeemed:
{% if G.state == "SMODS_BOOSTER_OPENED" -%}
## Pack Selection

You have opened a booster pack. Choose a card or skip.
You have opened a booster pack. The purchase cost is already spent — skipping does not refund money. Prefer selecting a card with any upside unless you have a skip-payoff joker (e.g. Red Card) or every option is actively harmful.

### Available Cards in Pack

Expand All @@ -223,17 +223,17 @@ You have opened a booster pack. Choose a card or skip.
{% if card.value.min_highlighted %} - **Targets**: {{ card.value.min_highlighted }}-{{ card.value.max_highlighted }} hand cards{% endif %}
{% elif card.set in ["DEFAULT", "ENHANCED"] %}
- {{ loop.index0 }}: **{{ card.value.rank }} of {{ card.value.suit }}**
{% if card.set == "ENHANCED" %} - **Enhancement**: {{ card.value.enhancement }}{% if card.value.enhancement == "GLASS" %} ⚠️ RISKY{% endif %}{% endif %}
{% if card.value.edition %} - **Edition**: {{ card.value.edition }}{% endif %}
{% if card.value.seal %} - **Seal**: {{ card.value.seal }}{% endif %}
{% if card.modifier.edition %} - **{{ card.modifier.edition }} Edition**{% endif %}
{% if card.modifier.enhancement %} - **{{ card.modifier.enhancement }} Enhancement**{% if card.modifier.enhancement == "GLASS" %} ⚠️ RISKY{% endif %}{% endif %}
{% if card.modifier.seal %} - **{{ card.modifier.seal }} Seal**{% endif %}
{% endif %}
{%- endfor %}

{% if G.hand and G.hand.cards %}
### Your Hand (for targeting)

{% for card in G.hand.cards -%}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.set == "ENHANCED" %} ({{ card.value.enhancement }}){% endif %}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.modifier.enhancement %} ({{ card.modifier.enhancement }}){% endif %}
{% endfor %}
{% endif %}
{%- endif %}
Expand Down
5 changes: 5 additions & 0 deletions src/balatrollm/strategies/conservative/STRATEGY.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ Each shop visit offers:

**Conservative Pack Priority**: Celestial > Arcana > Buffoon > Standard >>> Spectral

**After buying a pack (sunk cost):**
- The purchase cost is **already spent**. Skipping does not refund money.
- Prefer taking any card with potential upside over skipping for "savings".
- Skip only if: (1) you have a skip-payoff joker (e.g. Red Card), or (2) every option is actively harmful (bad Spectral, unwanted deck dilution, forcing out a better joker).

### Economy Rules

- **Interest**: Earn $1 per $5 held, maximum $5 interest (at $25+)
Expand Down
2 changes: 1 addition & 1 deletion src/balatrollm/strategies/conservative/TOOLS.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
"function": {
"name": "pack",
"strict": false,
"description": "Select a card from the opened booster pack, or skip without taking any card. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards. Avoid risky Spectral cards.",
"description": "Select a card from the opened booster pack, or skip without taking any card. The pack cost is already spent — skipping does not refund money. Prefer taking a useful card unless you have a skip-payoff joker or every option is harmful. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards. Avoid risky Spectral cards.",
"parameters": {
"type": "object",
"properties": {
Expand Down
10 changes: 5 additions & 5 deletions src/balatrollm/strategies/default/GAMESTATE.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ Here are the vouchers that can be redeemed:
{% if G.state == "SMODS_BOOSTER_OPENED" -%}
## Pack Selection

You have opened a booster pack. Choose a card or skip.
You have opened a booster pack. The purchase cost is already spent — skipping does not refund money. Prefer selecting a card with any upside unless you have a skip-payoff joker (e.g. Red Card) or every option is actively harmful.

### Available Cards in Pack

Expand All @@ -222,17 +222,17 @@ You have opened a booster pack. Choose a card or skip.
{% if card.value.min_highlighted %} - **Targets**: {{ card.value.min_highlighted }}-{{ card.value.max_highlighted }} hand cards{% endif %}
{% elif card.set in ["DEFAULT", "ENHANCED"] %}
- {{ loop.index0 }}: **{{ card.value.rank }} of {{ card.value.suit }}**
{% if card.set == "ENHANCED" %} - **Enhancement**: {{ card.value.enhancement }}{% endif %}
{% if card.value.edition %} - **Edition**: {{ card.value.edition }}{% endif %}
{% if card.value.seal %} - **Seal**: {{ card.value.seal }}{% endif %}
{% if card.modifier.edition %} - **{{ card.modifier.edition }} Edition**{% endif %}
{% if card.modifier.enhancement %} - **{{ card.modifier.enhancement }} Enhancement**{% endif %}
{% if card.modifier.seal %} - **{{ card.modifier.seal }} Seal**{% endif %}
{% endif %}
{%- endfor %}

{% if G.hand and G.hand.cards %}
### Your Hand (for targeting)

{% for card in G.hand.cards -%}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.set == "ENHANCED" %} ({{ card.value.enhancement }}){% endif %}
- {{ loop.index0 }}: {{ card.value.rank }} of {{ card.value.suit }}{% if card.modifier.enhancement %} ({{ card.modifier.enhancement }}){% endif %}
{% endfor %}
{% endif %}
{%- endif %}
Expand Down
6 changes: 5 additions & 1 deletion src/balatrollm/strategies/default/STRATEGY.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,13 @@ Booster packs provide valuable cards but use them wisely:
**Pack Selection Tips:**
- For Arcana/Spectral packs, cards are used **immediately**
- Some cards (like The Magician) require selecting target cards from your hand
- Consider skipping if no cards benefit your current strategy
- Buffoon/Standard packs add cards to your collection (not used immediately)

**After buying a pack (sunk cost):**
- The purchase cost is **already spent**. Skipping does not refund money.
- Prefer taking any card with potential upside over skipping for "savings".
- Skip only if: (1) you have a skip-payoff joker (e.g. Red Card), or (2) every option is actively harmful (bad Spectral, unwanted deck dilution, forcing out a better joker).

**Targeting for Tarot/Spectral:**
When a card requires targets:
- Check the card's effect for how many cards to select
Expand Down
2 changes: 1 addition & 1 deletion src/balatrollm/strategies/default/TOOLS.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
"function": {
"name": "pack",
"strict": false,
"description": "Select a card from the opened booster pack, or skip without taking any card. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards.",
"description": "Select a card from the opened booster pack, or skip without taking any card. The pack cost is already spent — skipping does not refund money. Prefer taking a useful card unless you have a skip-payoff joker or every option is harmful. For Arcana/Spectral packs, cards are used immediately and may require targeting hand cards.",
"parameters": {
"type": "object",
"properties": {
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/test_strategy.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
"""Unit tests for the StrategyManager module."""

from typing import Any

import pytest

from balatrollm.strategy import StrategyManager
from tests.unit.conftest import load_unit_fixture, load_unit_golden


def get_mock_pack_playing_card(
overrides: dict[str, object] | None = None,
) -> dict[str, Any]:
"""Factory for a pack playing card shaped like BalatroBot gamestate."""
base: dict[str, Any] = {
"id": 1,
"key": "D_6",
"set": "ENHANCED",
"label": "Glass Card",
"value": {
"rank": "6",
"suit": "D",
"effect": "+6 chips X2 Mult 1 in 4 chance to destroy card",
},
"modifier": {
"enhancement": "GLASS",
"edition": "FOIL",
"seal": "RED",
},
"state": {},
"cost": {"buy": 1, "sell": 1},
}
return {**base, **(overrides or {})}


def get_mock_pack_opened_gamestate(
overrides: dict[str, object] | None = None,
) -> dict[str, Any]:
"""Factory for SMODS_BOOSTER_OPENED gamestate with pack playing cards."""
base: dict[str, Any] = {
"state": "SMODS_BOOSTER_OPENED",
"money": 10,
"ante_num": 1,
"round_num": 1,
"deck": "RED",
"stake": "WHITE",
"seed": "AAAAAAA",
"won": False,
"round": {
"hands_left": 4,
"discards_left": 3,
"hands_played": 0,
"discards_used": 0,
"chips": 0,
"reroll_cost": 5,
},
"jokers": {"count": 0, "limit": 5, "cards": []},
"consumables": {"count": 0, "limit": 2, "cards": []},
"hand": {"count": 0, "limit": 8, "cards": [], "highlighted_limit": 5},
"shop": {"count": 0, "limit": 2, "cards": []},
"packs": {"count": 0, "limit": 2, "cards": []},
"vouchers": {"count": 0, "limit": 1, "cards": []},
"pack": {
"count": 1,
"limit": 3,
"cards": [get_mock_pack_playing_card()],
},
"hands": {},
"blinds": {
"small": {"name": "Small Blind", "score": 300, "status": "Upcoming"},
"big": {"name": "Big Blind", "score": 450, "status": "Upcoming"},
"boss": {"name": "The Hook", "score": 600, "status": "Upcoming"},
},
}
return {**base, **(overrides or {})}


class TestStrategyManagerInit:
"""Tests for StrategyManager initialization."""

Expand Down Expand Up @@ -184,6 +253,86 @@ def test_blinds_rendered_in_blind_select(self) -> None:
assert str(blind["score"]) in result, f"{blind_type} blind score not found"


class TestPackOpenSunkCostGuidance:
"""Opened packs already spent money; skip is not a refund."""

@pytest.mark.parametrize("strategy", ["default", "conservative", "aggressive"])
def test_strategy_teaches_pack_skip_does_not_refund(self, strategy: str) -> None:
"""Strategy text must state pack cost is sunk and skip does not refund."""
gamestate = get_mock_pack_opened_gamestate()
sm = StrategyManager(strategy)
result = sm.render_strategy(gamestate)

assert "already spent" in result.lower()
assert "does not refund" in result.lower()

@pytest.mark.parametrize("strategy", ["default", "conservative", "aggressive"])
def test_pack_opened_gamestate_teaches_sunk_cost(self, strategy: str) -> None:
"""Pack-open gamestate must remind the LLM money is already spent."""
gamestate = get_mock_pack_opened_gamestate()
sm = StrategyManager(strategy)
result = sm.render_gamestate(gamestate)

assert "already spent" in result.lower()
assert "does not refund" in result.lower()

@pytest.mark.parametrize("strategy", ["default", "conservative", "aggressive"])
def test_pack_tool_describes_skip_as_no_refund(self, strategy: str) -> None:
"""Pack tool description must say skipping does not refund the purchase."""
sm = StrategyManager(strategy)
tools = sm.get_tools("SMODS_BOOSTER_OPENED")
pack_tool = next(t for t in tools if t["function"]["name"] == "pack")
description = pack_tool["function"]["description"].lower()

assert "does not refund" in description


class TestRenderPackPlayingCardModifiers:
"""Pack playing cards must render modifier fields from card.modifier."""

@pytest.mark.parametrize("strategy", ["default", "conservative", "aggressive"])
def test_pack_card_renders_enhancement_edition_and_seal(
self, strategy: str
) -> None:
"""Opened Standard pack cards expose enhancement/edition/seal to the LLM."""
gamestate = get_mock_pack_opened_gamestate()
sm = StrategyManager(strategy)
result = sm.render_gamestate(gamestate)

assert "**GLASS Enhancement**" in result
assert "**FOIL Edition**" in result
assert "**RED Seal**" in result

@pytest.mark.parametrize("strategy", ["default", "conservative", "aggressive"])
def test_pack_hand_targeting_renders_enhancement(self, strategy: str) -> None:
"""Hand cards shown during pack targeting include enhancement."""
hand_card = get_mock_pack_playing_card(
{
"id": 2,
"key": "H_A",
"set": "ENHANCED",
"label": "Steel Card",
"value": {"rank": "A", "suit": "H", "effect": "X1.5 Mult"},
"modifier": {"enhancement": "STEEL"},
}
)
gamestate = get_mock_pack_opened_gamestate(
{
"hand": {
"count": 1,
"limit": 8,
"cards": [hand_card],
"highlighted_limit": 5,
}
}
)
sm = StrategyManager(strategy)
result = sm.render_gamestate(gamestate)

assert "Your Hand (for targeting)" in result
assert "(STEEL)" in result


class TestRenderMemoryProperties:
"""Property tests for memory template."""

Expand Down