diff --git a/src/commander_ai_lab/sim/forge_card_loader.py b/src/commander_ai_lab/sim/forge_card_loader.py new file mode 100644 index 0000000..fcab984 --- /dev/null +++ b/src/commander_ai_lab/sim/forge_card_loader.py @@ -0,0 +1,152 @@ +""" +Forge Card Loader +================= +Parse Forge cardsfolder .txt files into structured data for sim enrichment. +Lazy-loaded and cached at module level. +""" +from __future__ import annotations + +import logging +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from models.state import CFG + +_log = logging.getLogger(__name__) + + +@dataclass +class ForgeCardData: + """Structured representation of a single Forge .txt card definition.""" + name: str = "" + mana_cost: str = "" + cmc: int = 0 + type_line: str = "" + oracle_text: str = "" + pt: str = "" + keywords: list[str] = field(default_factory=list) + trigger_modes: list[str] = field(default_factory=list) + has_replacement_effect: bool = False + has_static_ability: bool = False + + +# -- Module-level lazy cache --- +_FORGE_CACHE: dict[str, ForgeCardData] = {} +_FORGE_CACHE_LOADED: bool = False + + +def _get_cards_folder() -> Path: + """Resolve the Forge cardsfolder directory from CFG.forge_dir.""" + if not CFG.forge_dir: + return Path("") + return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder" + + +def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]: + """Parse a single Forge .txt card file into ForgeCardData.""" + try: + text = filepath.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + data = ForgeCardData() + lines = text.splitlines() + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + + # Name + if line.startswith("Name:"): + data.name = line.split(":", 1)[1].strip() + # ManaCost + elif line.startswith("ManaCost:"): + raw = line.split(":", 1)[1].strip() + data.mana_cost = raw + data.cmc = _calc_cmc(raw) + # Types + elif line.startswith("Types:"): + data.type_line = line.split(":", 1)[1].strip() + # PT + elif line.startswith("PT:"): + data.pt = line.split(":", 1)[1].strip() + # Oracle + elif line.startswith("Oracle:"): + data.oracle_text = line.split(":", 1)[1].strip() + # K -- Keywords + elif line.startswith("K:"): + kw = line.split(":", 1)[1].strip() + if kw and kw not in data.keywords: + data.keywords.append(kw) + # T -- Trigger modes (e.g. T:Mode$ ChangesZone) + elif line.startswith("T:"): + mode_match = re.search(r"Mode\$\s*(\w+)", line) + if mode_match: + mode = mode_match.group(1) + if mode not in data.trigger_modes: + data.trigger_modes.append(mode) + # R -- Replacement effects + elif line.startswith("R:"): + data.has_replacement_effect = True + # S -- Static abilities + elif line.startswith("S:"): + data.has_static_ability = True + # SVar (secondary -- grab Oracle from SVar if not set) + elif line.startswith("SVar:") and not data.oracle_text: + if "Oracle:" in line: + oracle_part = line.split("Oracle:", 1)[1].strip() + data.oracle_text = oracle_part + + return data if data.name else None + + +def _calc_cmc(mana_cost: str) -> int: + """Calculate CMC from Forge mana cost string like '2 W U'.""" + total = 0 + for part in mana_cost.split(): + part = part.strip().upper() + if not part: + continue + if part.isdigit(): + total += int(part) + elif part == "X": + continue + else: + total += 1 + return total + + +def _load_forge_cache() -> None: + """Walk the Forge cardsfolder and cache every card by lowercase name.""" + global _FORGE_CACHE_LOADED + if _FORGE_CACHE_LOADED: + return + + cards_folder = _get_cards_folder() + if not cards_folder.is_dir(): + _log.warning("Forge cardsfolder not found at %s", cards_folder) + _FORGE_CACHE_LOADED = True + return + + count = 0 + for root, _dirs, files in os.walk(cards_folder): + for fname in files: + if not fname.endswith(".txt"): + continue + fpath = Path(root) / fname + parsed = _parse_forge_file(fpath) + if parsed and parsed.name: + _FORGE_CACHE[parsed.name.lower()] = parsed + count += 1 + + _log.info("Forge card cache loaded: %d cards from %s", count, cards_folder) + _FORGE_CACHE_LOADED = True + + +def lookup_forge_card(card_name: str) -> Optional[ForgeCardData]: + """Look up a card by name from the Forge cache. Returns None on miss.""" + _load_forge_cache() + return _FORGE_CACHE.get((card_name or "").lower().strip()) diff --git a/src/commander_ai_lab/sim/models.py b/src/commander_ai_lab/sim/models.py index 783a68a..6b80614 100644 --- a/src/commander_ai_lab/sim/models.py +++ b/src/commander_ai_lab/sim/models.py @@ -71,6 +71,13 @@ class Card: is_direct_damage: bool = False direct_damage_amount: int = 0 + # Forge enrichment fields (set by forge_card_loader) + forge_enriched: bool = False + forge_keywords: list[str] = field(default_factory=list) + forge_trigger_modes: list[str] = field(default_factory=list) + has_replacement_effect: bool = False + has_static_ability: bool = False + # Runtime fields (set during game) id: int = 0 owner_id: int = -1 diff --git a/src/commander_ai_lab/sim/rules.py b/src/commander_ai_lab/sim/rules.py index e7f9afc..bcca82c 100644 --- a/src/commander_ai_lab/sim/rules.py +++ b/src/commander_ai_lab/sim/rules.py @@ -19,6 +19,8 @@ import re from typing import Optional from commander_ai_lab.sim.models import Card +# Forge enrichment (sibling module inside sim/) +from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData # ══════════════════════════════════════════════════════════════ @@ -139,6 +141,47 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [ ] + +def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None: + """ + Apply Forge card data onto a Card, merging keywords into both fields. + + ISSUE 3 FIX: Writes to both card.keywords AND card.forge_keywords + so existing score_card() checks like `if "flying" in kw_list` work. + + ISSUE 4 FIX: Sets card.oracle_text BEFORE returning, so the caller + can safely call _apply_oracle_flags(card) afterward. + """ + # Forge-specific tracking fields + card.forge_keywords = list(forge_data.keywords) + card.forge_trigger_modes = list(forge_data.trigger_modes) + card.has_replacement_effect = forge_data.has_replacement_effect + card.has_static_ability = forge_data.has_static_ability + + # ISSUE 3: Merge keywords into card.keywords (set union, no dupes) + existing = {k.lower() for k in (card.keywords or [])} + for kw in forge_data.keywords: + if kw.lower() not in existing: + card.keywords.append(kw) + existing.add(kw.lower()) + + # Overwrite type/cost/pt only if Forge has data AND card doesn't + if forge_data.type_line and (not card.type_line or len(card.type_line) < 3): + card.type_line = forge_data.type_line + if forge_data.mana_cost and not card.mana_cost: + card.mana_cost = forge_data.mana_cost + card.cmc = forge_data.cmc + if forge_data.pt and not card.pt: + card.pt = forge_data.pt + parts = forge_data.pt.split("/", 1) + if len(parts) == 2: + card.power = parts[0].strip() + card.toughness = parts[1].strip() + + # ISSUE 4: Set oracle_text so _apply_oracle_flags sees Forge text + if forge_data.oracle_text and not card.oracle_text: + card.oracle_text = forge_data.oracle_text + def enrich_card(card: Card) -> Card: """ Enrich a card with type/cost heuristics if it lacks Scryfall data. @@ -149,6 +192,19 @@ def enrich_card(card: Card) -> Card: has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5) + # Try Forge enrichment first for cards without Scryfall data + if not has_real_data: + forge_data = lookup_forge_card(card.name) + if forge_data: + _apply_forge_data(card, forge_data) # sets oracle_text, keywords, etc. + card.forge_enriched = True + # Re-check: if Forge provided sufficient data, apply flags and return + has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5) + if has_real_data: + _apply_oracle_flags(card) + return card + # Otherwise fall through so known-cards / default heuristics can fill gaps + if not has_real_data: # Basic lands if name_lower in _BASIC_LAND_COLORS: @@ -380,11 +436,21 @@ def score_card( score += w.get("kw_cascade", 3.0) if "defender" in text or "defender" in kw_list: score += w.get("kw_defender", -2.0) - - # ── Triggered ability bonuses ── - if "when" in text and "enters" in text: + # NEW + _has_etb = False + if card.forge_trigger_modes and "ChangesZone" in card.forge_trigger_modes: + _has_etb = True + elif "when" in text and "enters" in text: + _has_etb = True + if _has_etb: score += w.get("trig_etb", 2.0) + _MULTI_OPP_SCALING.get("trig_etb", 0.0) * extra_opps - if "whenever" in text and "attack" in text: + # NEW + _has_attack_trig = False + if card.forge_trigger_modes and "Attacks" in card.forge_trigger_modes: + _has_attack_trig = True + elif "whenever" in text and "attack" in text: + _has_attack_trig = True + if _has_attack_trig: score += w.get("trig_attack", 1.5) if "when" in text and "dies" in text: score += w.get("trig_dies", 1.0) + _MULTI_OPP_SCALING.get("trig_dies", 0.0) * extra_opps diff --git a/tests/sim/test_forge_enrichment.py b/tests/sim/test_forge_enrichment.py new file mode 100644 index 0000000..a8b467d --- /dev/null +++ b/tests/sim/test_forge_enrichment.py @@ -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)