diff --git a/CLAUDE.md b/CLAUDE.md index 9711769..896410b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/balatrollm/strategies/aggressive/GAMESTATE.md.jinja b/src/balatrollm/strategies/aggressive/GAMESTATE.md.jinja index cf222dd..86207a5 100644 --- a/src/balatrollm/strategies/aggressive/GAMESTATE.md.jinja +++ b/src/balatrollm/strategies/aggressive/GAMESTATE.md.jinja @@ -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 @@ -222,9 +222,9 @@ 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 %} @@ -232,7 +232,7 @@ You have opened a booster pack. Choose a card or skip. ### 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 %} diff --git a/src/balatrollm/strategies/aggressive/STRATEGY.md.jinja b/src/balatrollm/strategies/aggressive/STRATEGY.md.jinja index 0087408..11aca45 100644 --- a/src/balatrollm/strategies/aggressive/STRATEGY.md.jinja +++ b/src/balatrollm/strategies/aggressive/STRATEGY.md.jinja @@ -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. diff --git a/src/balatrollm/strategies/aggressive/TOOLS.json b/src/balatrollm/strategies/aggressive/TOOLS.json index bed389c..eb40ee1 100644 --- a/src/balatrollm/strategies/aggressive/TOOLS.json +++ b/src/balatrollm/strategies/aggressive/TOOLS.json @@ -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": { diff --git a/src/balatrollm/strategies/conservative/GAMESTATE.md.jinja b/src/balatrollm/strategies/conservative/GAMESTATE.md.jinja index c60eec6..878d9d4 100644 --- a/src/balatrollm/strategies/conservative/GAMESTATE.md.jinja +++ b/src/balatrollm/strategies/conservative/GAMESTATE.md.jinja @@ -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 @@ -223,9 +223,9 @@ 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 %} @@ -233,7 +233,7 @@ You have opened a booster pack. Choose a card or skip. ### 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 %} diff --git a/src/balatrollm/strategies/conservative/STRATEGY.md.jinja b/src/balatrollm/strategies/conservative/STRATEGY.md.jinja index f3be2ab..1aa7edd 100644 --- a/src/balatrollm/strategies/conservative/STRATEGY.md.jinja +++ b/src/balatrollm/strategies/conservative/STRATEGY.md.jinja @@ -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+) diff --git a/src/balatrollm/strategies/conservative/TOOLS.json b/src/balatrollm/strategies/conservative/TOOLS.json index 52f2c2e..1078794 100644 --- a/src/balatrollm/strategies/conservative/TOOLS.json +++ b/src/balatrollm/strategies/conservative/TOOLS.json @@ -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": { diff --git a/src/balatrollm/strategies/default/GAMESTATE.md.jinja b/src/balatrollm/strategies/default/GAMESTATE.md.jinja index cf222dd..86207a5 100644 --- a/src/balatrollm/strategies/default/GAMESTATE.md.jinja +++ b/src/balatrollm/strategies/default/GAMESTATE.md.jinja @@ -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 @@ -222,9 +222,9 @@ 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 %} @@ -232,7 +232,7 @@ You have opened a booster pack. Choose a card or skip. ### 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 %} diff --git a/src/balatrollm/strategies/default/STRATEGY.md.jinja b/src/balatrollm/strategies/default/STRATEGY.md.jinja index 2dcdbcf..eb2c22c 100644 --- a/src/balatrollm/strategies/default/STRATEGY.md.jinja +++ b/src/balatrollm/strategies/default/STRATEGY.md.jinja @@ -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 diff --git a/src/balatrollm/strategies/default/TOOLS.json b/src/balatrollm/strategies/default/TOOLS.json index bed389c..eb40ee1 100644 --- a/src/balatrollm/strategies/default/TOOLS.json +++ b/src/balatrollm/strategies/default/TOOLS.json @@ -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": { diff --git a/tests/unit/test_strategy.py b/tests/unit/test_strategy.py index 0b335a6..41e25af 100644 --- a/tests/unit/test_strategy.py +++ b/tests/unit/test_strategy.py @@ -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.""" @@ -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."""