Forge Card Enrichment — Full Implementation (Issue #80) (#81)

* feat(issue-80): Phase 1 — add services/forge_card_loader.py

* feat(issue-80): Phase 2 — add 5 Forge fields to Card dataclass

* feat(issue-80): Phase 3+4 — wire Forge into enrich_card/score_card

* feat(issue-80): Phase 5 — add tests/sim/test_forge_enrichment.py

* fix(issue-80): resolve PR #81 blocking issues — import paths, indentation, partial Forge fallback

- Move forge_card_loader.py from services/ into src/commander_ai_lab/sim/
  so the import in rules.py uses a proper intra-package path instead of
  crossing package boundaries via services.forge_card_loader
- Fix indentation of Forge enrichment comment in models.py (8→4 spaces)
- Fix enrich_card() early return on partial Forge data: re-check
  has_real_data after _apply_forge_data so cards with incomplete Forge
  entries still fall through to known-cards / default heuristics
- Fix flawed test assertion in test_etb_via_forge_mode_equals_text_detection:
  oracle text "draw a card" was adding a +3 spell_draw bonus, making the
  score comparison invalid; replaced with side-effect-free ETB text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <claude-code@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-03-23 09:37:05 -04:00
1 parent 2eae7dcbd5
commit eee773b6f9
4 files changed
+392 -4

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
"""Tests for Forge card enrichment -- parser, enrich_card(), and score_card()."""
from __future__ import annotations
import textwrap
from pathlib import Path
from unittest.mock import patch
import pytest
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card, score_card
FORGE_BASIC = textwrap.dedent("""\
Name:Elvish Mystic
ManaCost:G
Types:Creature Elf Druid
PT:1/1
Oracle:{T}: Add {G}.
""")
FORGE_ETB = textwrap.dedent("""\
Name:Llanowar Elves
ManaCost:G
Types:Creature Elf Druid
PT:1/1
T:Mode$ChangesZone $ Origin$Graveyard $ Destination$Battlefield
""")
FORGE_KEYWORDS = textwrap.dedent("""\
Name:Serra Angel
ManaCost:3 W W
Types:Creature Angel
PT:4/4
K:Flying
K:Vigilance
""")
FORGE_STATIC = textwrap.dedent("""\
Name:Glorious Anthem
ManaCost:1 W W
Types:Enchantment
S:Mode$Continuous $ Affected$Creature.YouCtrl
""")
FORGE_REPLACEMENT = textwrap.dedent("""\
Name:Leyline of the Void
ManaCost:2 B B
Types:Enchantment
R:Mode$ReplaceEvent $ Event$MovedToGraveyard
""")
# -- Parser unit tests --
class TestParseForgeFile:
def _parse(self, txt: str, tmp_path: Path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "card.txt"
fpath.write_text(txt, encoding="utf-8")
return _parse_forge_file(fpath)
def test_basic_fields(self, tmp_path):
d = self._parse(FORGE_BASIC, tmp_path)
assert d.name == "Elvish Mystic"
assert d.cmc == 1
assert d.pt == "1/1"
assert "{T}: Add {G}" in d.oracle_text
def test_keywords_parsed(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert "Flying" in d.keywords and "Vigilance" in d.keywords
def test_cmc_generic_plus_colored(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert d.cmc == 5 # 3 + W + W
def test_etb_trigger_mode(self, tmp_path):
d = self._parse(FORGE_ETB, tmp_path)
assert "ChangesZone" in d.trigger_modes
def test_static_flag(self, tmp_path):
d = self._parse(FORGE_STATIC, tmp_path)
assert d.has_static_ability is True
assert d.has_replacement_effect is False
def test_replacement_flag(self, tmp_path):
d = self._parse(FORGE_REPLACEMENT, tmp_path)
assert d.has_replacement_effect is True
def test_empty_file_returns_none(self, tmp_path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "empty.txt"
fpath.write_text("", encoding="utf-8")
assert _parse_forge_file(fpath) is None
# -- enrich_card() integration tests --
class TestEnrichCardForge:
def _forge(self, **kwargs):
from commander_ai_lab.sim.forge_card_loader import ForgeCardData
base = dict(
name="X", mana_cost="2 G", cmc=3, type_line="Creature Elf",
oracle_text="", pt="2/2", keywords=["Flying"],
trigger_modes=[], has_replacement_effect=False,
has_static_ability=False,
)
base.update(kwargs)
return ForgeCardData(**base)
def test_forge_applied_to_empty_card(self):
card = Card(name="X")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=self._forge()):
r = enrich_card(card)
assert r.forge_enriched and r.cmc == 3 and r.power == "2"
assert "Flying" in r.keywords
def test_skipped_when_has_real_data(self):
card = Card(name="Sol Ring", cmc=1, type_line="Artifact")
with patch("commander_ai_lab.sim.rules.lookup_forge_card") as m:
enrich_card(card)
m.assert_not_called()
def test_miss_falls_through_to_known_cards(self):
card = Card(name="Sol Ring")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=None):
r = enrich_card(card)
assert r.type_line == "Artifact" and r.is_ramp is True
def test_oracle_flags_from_forge_text(self):
card = Card(name="X")
fd = self._forge(oracle_text="Destroy target creature.", keywords=[])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert r.is_removal is True
def test_no_duplicate_keywords(self):
card = Card(name="X", keywords=["Flying"])
fd = self._forge(keywords=["Flying", "Haste"])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert sum(1 for k in r.keywords if k.lower() == "flying") == 1
assert any(k.lower() == "haste" for k in r.keywords)
# -- score_card() trigger detection tests --
class TestScoreCardForge:
def test_etb_via_forge_mode_equals_text_detection(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["ChangesZone"])
text = Card(name="B", type_line="Creature", cmc=3, pt="3/3",
oracle_text="When this enters the battlefield, gain 1 life.")
none = Card(name="C", type_line="Creature", cmc=3, pt="3/3")
sf, st, sn = score_card(forge), score_card(text), score_card(none)
assert sf > sn and abs(sf - st) < 0.01
def test_attack_trig_via_forge_mode(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["Attacks"])
none = Card(name="B", type_line="Creature", cmc=3, pt="3/3")
assert score_card(forge) > score_card(none)