Compare commits

...
Author SHA1 Message Date
Claude AgentandClaude Opus 4.6 faf33f488f fix: correct indentation of last_err in DeepSeekBrain.choose_action()
`last_err = None` was at module-level indentation (4 spaces) instead of
method-body indentation (8 spaces), causing an IndentationError on import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 23:53:51 +00:00
KoalaTrapLord 62463dfa4a fix: correct indentation in deepseek_brain.py (choose_action + _parse_action) 2026-03-23 19:34:14 -04:00
KoalaTrapLord 7e0aba78ba fix: add ML logging support to MultiThreadBatchRunner 2026-03-23 19:01:43 -04:00
KoalaTrapLord 7036c9568f fix: enable ML decision logging in multi-thread batch path 2026-03-23 18:58:14 -04:00
KoalaTrapLord b37968e3c2 fix: always pass --ml-log flag to Forge Java batch runner 2026-03-23 17:22:58 -04:00
KoalaTrapLord d424ccb74c feat(86): add combat priority checkpoints with CombatState and split engine methods (#96) 2026-03-23 16:52:19 -04:00
KoalaTrapLord 1cf2439a5a fix(#95): correct validator defaults to deepseek-r1:8b and 300s timeout
_get_validator_brain() defaulted VALIDATOR_MODEL to deepseek-r1:14b
and VALIDATOR_TIMEOUT to 120.0, conflicting with the brain config.
Changed to deepseek-r1:8b and 300.0 to match. Fixes issue #95 item 4.
2026-03-23 14:31:59 -04:00
KoalaTrapLord f3bd188a8b fix(#95): implement retry loop and bump max_tokens/max_retries defaults
max_retries was configured but never used — choose_action() made
exactly one attempt. Now wraps _call_llm() in a retry loop.
Bumped max_retries default to 2 and max_tokens to 4096 (R1 needs
room for <think> blocks + JSON). Fixes issue #95 item 3.
2026-03-23 14:29:22 -04:00
KoalaTrapLord c1ad9e953c fix(#95): replace regex JSON extraction with outermost-brace approach
_parse_action() used r'\{[^{}]*\}' regex which fails on nested
JSON (e.g. oracle text with braces). Now uses text.find/rfind to
extract the outermost { } block, handling nested structures.
Fixes issue #95 item 2.
2026-03-23 14:24:57 -04:00
KoalaTrapLord 25e064d265 fix(#95): prevent check_connection from overwriting configured model
check_connection() was auto-overwriting self.config.model with the
first model returned by /v1/models, causing deepseek-r1:8b to be
replaced by deepseek-r1:14b (or whatever Ollama listed first).

Now logs available models without changing the configured model.
Fixes issue #95 item 1.
2026-03-23 10:37:39 -04:00
2b6d4dcdb1 fix: deduplicate LLM duplicate cards in _check_ownership (#78) (#79)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:42:33 -04:00
eee773b6f9 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>
2026-03-23 09:37:05 -04:00
2eae7dcbd5 fix(ci): remove frontend job that references non-existent directory (#94)
The CI workflow had a `frontend` job that tried to run npm ci/build/test
in a `frontend/` directory which does not exist in this repo. The actual
UI lives in `ui/` as static HTML/CSS/JS (no Node.js toolchain). This
caused every CI run to fail on an otherwise valid codebase.

Co-authored-by: Claude Agent <claude-agent@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:54:16 -04:00
0f8d1382e0 feat: explicit turn-phase structure with Phase enum and main2 support (#86) (#93)
Refactor the headless engine to walk through all 12 MTG turn phases in
order (untap → upkeep → draw → main1 → begin_combat → declare_attackers
→ declare_blockers → combat_damage → end_combat → main2 → end_step →
cleanup) instead of the previous flat per-player block.

Key changes:
- Add Phase str enum and PHASE_ORDER/SORCERY_PHASES constants to models
- Track current_phase and active_player_index on SimState
- Sync sim.turn with the loop turn counter (fixes latent summoning-
  sickness bug where turn_played was always compared against 0)
- Add second main phase (main2) with shared 2-spell budget across
  main1 + main2
- _play_spells now accepts max_spells param and returns count played
- Unify turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES to derive
  from the canonical Phase enum in models.py
- 33 regression tests covering phase enum, phase progression, spell
  budget, game log structure, backward compat, and turn manager
  constant consistency

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:46:09 -04:00
KoalaTrapLord b72d5fba83 fix(#92): bump READ_TIMEOUT_MS from 5s to 300s in PolicyServerClient.java 2026-03-22 23:44:47 -04:00
KoalaTrapLord 5d34d07bb8 fix(#92): bump validator_brain.py timeouts from 60s to 300s 2026-03-22 23:43:30 -04:00
KoalaTrapLord 5e9685ba70 fix(#92): read BRAIN_TIMEOUT env for ai_decision_timeout + add import os in turn_manager.py 2026-03-22 23:42:28 -04:00
KoalaTrapLord 49fc817187 fix(#92): bump BRAIN_TIMEOUT fallback from 120s to 300s in forge_runner.py 2026-03-22 23:40:28 -04:00
KoalaTrapLord c2b3d937a4 fix(#92): read BRAIN_TIMEOUT env for request_timeout in deepseek_brain.py 2026-03-22 23:39:33 -04:00
5e9c568bcc feat: implement London mulligan and opening-hand validation (#86) (#91) (#91)
Add London mulligan rule to the headless game engine:

- GameEngine now accepts mulligan_rule param ("london" default, "none" for legacy)
- AI heuristic: mulligan 0-1 land or 6-7 land hands; relax after 2+ mulligans
- After keeping, bottom N lowest-scored cards (London mulligan rule)
- Cap at MAX_MULLIGANS=4 to prevent infinite loops on degenerate decks
- Track mulligans and opening_hand_lands in PlayerStats
- Serialize both fields in to_dict() for batch-result schema compatibility
- 28 targeted regression tests covering unit helpers, integration,
  N-player, commander interaction, serialization, and legacy mode

Co-authored-by: Claude Agent <claude-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:28:01 -04:00
KoalaTrapLordandClaude Code 85aa15445b feat: route direct-damage spells through centralized deal_damage() (#86) (#90)
Add centralized deal_damage() method to GameEngine that handles life
reduction, stat tracking, commander damage (both per-seat and per-card),
and elimination checks. Route both burn spells and combat damage through
this unified path, building on the dual-dict commander damage model
merged in PR #89.

- Add is_direct_damage / direct_damage_amount fields to Card
- Add burn spell detection via known-card table + oracle regex fallback
- Route burn spells in _play_spells() through deal_damage()
- Refactor _resolve_combat() to route per-attacker damage through deal_damage()
- Add 38 regression tests covering deal_damage(), burn detection, spell routing,
  combat routing, and full-game integration

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-22 22:27:37 -04:00
KoalaTrapLordandClaude e265b1e15b feat: commander damage source identity and per-card tracking (#86) (#89)
Track commander damage at combat-damage time by checking `is_commander`
on each attacking creature.  Damage from commander creatures is now
recorded in both a per-seat aggregate (`commander_damage_received`) and
a per-card breakdown (`commander_damage_by_card`), enabling correct
21-damage lethal checks for partner/multi-commander games.

Changes:
- engine._resolve_combat: identify commander attackers, accumulate their
  damage (including trample overflow and double strike), write to both
  tracking dicts, and check commander-damage elimination after combat.
- models.Player: add commander_damage_received, commander_damage_by_card,
  and is_dead_to_commander_damage() so the headless engine can track
  commander damage without the full CommanderGameState wrapper.
- game_state.CommanderPlayer: replace the separate
  commander_damage_received field with a property delegating to
  base.Player to avoid split-brain state between engine and wrapper.
- game_state.CommanderGameState.deal_commander_damage: accept optional
  commander_name for per-card tracking.
- 22 new regression tests covering unblocked, blocked, trample, double
  strike, lifelink, partner, cumulative, and lethal scenarios.

Partial fix for Issue #86 item 2.

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 22:04:19 -04:00
KoalaTrapLord ae8d14c19e chore: point deepseek brain at deepseek-r1:8b on localhost 2026-03-22 21:49:45 -04:00
KoalaTrapLord f090a36f79 chore: switch deck builder default model to qwen2.5:7b 2026-03-22 21:40:26 -04:00
24 changed files with 3359 additions and 151 deletions

No files matched your search

-20
View File
@@ -33,26 +33,6 @@ jobs:
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
+25
View File
@@ -256,6 +256,19 @@ class DeckGeneratorV3:
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary safety-net deduplication
seen = {}
deduped = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
@@ -284,15 +297,26 @@ class DeckGeneratorV3:
"""
Cross-reference generated cards against the collection DB.
Sets owned/scryfall_id/owned_qty and real price.
Deduplicates cards by name (case-insensitive) — if the LLM
returns the same card twice, the counts are merged.
"""
conn = self.db_conn_factory()
enriched = []
seen_names: dict[str, int] = {} # name_lower → index in enriched
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
@@ -318,6 +342,7 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
+2 -2
View File
@@ -56,9 +56,9 @@ def _get_validator_brain() -> ValidatorBrain | None:
if _validator_brain is None:
cfg = ValidatorConfig(
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:8b"),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "120.0")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
)
_validator_brain = ValidatorBrain(cfg)
return _validator_brain
+2 -1
View File
@@ -204,6 +204,7 @@ def build_java_command(
cmd += ['--aiSimplified']
if ai_think_time_ms > 0:
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
cmd += ['--ml-log']
if max_queue_depth > 0:
cmd += ['--maxQueueDepth', str(max_queue_depth)]
return cmd
@@ -316,7 +317,7 @@ def _get_deepseek_brain():
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "120.0")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
# Ollama exposes an OpenAI-compatible endpoint
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "gpt-oss:20b"
DEFAULT_MODEL = "qwen2.5:7b"
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
+34 -28
View File
@@ -10,7 +10,7 @@ action describing what to do this turn (play land, cast spell, attack,
hold/pass, use removal, use board wipe, cast ramp, cast commander).
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
ollama run gpt-oss:20b
ollama run deepseek-r1:8b
Also supports:
- LM Studio (OpenAI-compatible API) — set api_base to LM Studio URL
@@ -58,28 +58,28 @@ class DeepSeekConfig:
Example — LM Studio::
config = DeepSeekConfig(
api_base="http://192.168.0.240:11434",
api_base="http://localhost:11434",
model="deepseek-r1-distill-qwen-8b",
max_tokens=2048, # R1 needs room for <think> blocks
)
Example — Ollama (default)::
config = DeepSeekConfig() # points at 192.168.0.240:11434 / gpt-oss:20b
config = DeepSeekConfig() # points at 192.168.0.240:11434 / deepseek-r1:8b
"""
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
api_base: str = "http://192.168.0.240:11434"
api_base: str = "http://localhost:11434"
# Model name as registered in Ollama
model: str = "gpt-oss:20b"
model: str = "deepseek-r1:8b"
# Generation parameters
# GPT-OSS 20B does not emit <think> blocks so 1024 tokens is ample
temperature: float = 0.3
max_tokens: int = 1024
max_tokens: int = 4096
top_p: float = 0.9
# Timeouts
request_timeout: float = 30.0 # Ollama local inference; raise vs LM Studio
request_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # Ollama local inference; raise vs LM Studio
fallback_on_timeout: bool = True
# Caching
@@ -91,7 +91,7 @@ class DeepSeekConfig:
log_dir: str = ""
# Retry
max_retries: int = 1
max_retries: int = 2
# ══════════════════════════════════════════════════════════════
@@ -543,9 +543,8 @@ class DeepSeekBrain:
if models:
model_id = models[0].get("id", self.config.model)
if model_id:
self.config.model = model_id
logger.info("Available model: %s (keeping configured: %s)", model_id, self.config.model)
logger.info("Auto-detected model: %s", model_id)
logger.info("LLM brain connected to %s", self.config.api_base)
return True
except Exception as e:
logger.warning("LLM brain connection failed: %s", e)
@@ -591,25 +590,31 @@ class DeepSeekBrain:
if not self._connected:
return self._fallback_action(snapshot, t_start, "not_connected")
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
last_err = None
for attempt in range(1, self.config.max_retries + 1):
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
if self.config.cache_enabled:
self._put_cache(cache_key, result)
if self.config.cache_enabled:
self._put_cache(cache_key, result)
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
self._total_latency_ms += result["latency_ms"]
return result
self._total_latency_ms += result["latency_ms"]
return result
except Exception as e:
logger.warning("LLM call failed (turn %d): %s", turn + 1, e)
if self.config.fallback_on_timeout:
return self._fallback_action(snapshot, t_start, str(e))
raise
except Exception as e:
last_err = e
logger.warning("LLM call failed (turn %d, attempt %d/%d): %s", turn + 1, attempt, self.config.max_retries, e)
if attempt < self.config.max_retries:
continue
if self.config.fallback_on_timeout:
return self._fallback_action(snapshot, t_start, str(last_err))
raise last_err
def _call_llm(self, snapshot: dict) -> dict:
"""Send game state to LLM and parse response."""
@@ -654,9 +659,10 @@ class DeepSeekBrain:
text = re.sub(r'\s*```$', '', text)
text = text.strip()
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if json_match:
text = json_match.group(0)
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
try:
parsed = json.loads(text)
+495 -56
View File
@@ -28,10 +28,14 @@ from typing import Optional
from commander_ai_lab.sim.models import (
Card,
CombatState,
GameResult,
Phase,
PHASE_ORDER,
Player,
PlayerResult,
PlayerStats,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import (
@@ -60,6 +64,9 @@ class GameEngine:
``weights`` explicitly to override (e.g. for A/B testing).
"""
# London mulligan: max times a player may mulligan before forced keep
MAX_MULLIGANS = 4
def __init__(
self,
max_turns: int = 25,
@@ -67,6 +74,7 @@ class GameEngine:
weights: Optional[dict] = None,
record_log: bool = False,
ml_log: bool = False,
mulligan_rule: str = "london",
):
self.max_turns = max_turns
self.starting_life = starting_life
@@ -75,6 +83,7 @@ class GameEngine:
self.record_log = record_log
self.ml_log = ml_log
self.ml_decisions: list[dict] = []
self.mulligan_rule = mulligan_rule
# ──────────────────────────────────────────────────────────
# Public API
@@ -115,6 +124,7 @@ class GameEngine:
final_turn = 0
for turn in range(sim.max_turns):
sim.turn = turn
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
@@ -122,59 +132,106 @@ class GameEngine:
if p.eliminated:
continue
p.stats.turns_alive += 1
sim.active_player_index = pi
phase_events: list[str] = []
# ── Untap (correct MTG order: untap is first) ──
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # clear damage each turn (MTG rule 514.2)
# ── Draw ──
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
# ── Land drop ──
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# ── Available mana ──
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
# Track spell/damage counts for ML logging
spells_before = p.stats.spells_cast
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
played_spell = p.stats.spells_cast > spells_before
damage_before_turn = p.stats.damage_dealt
spells_budget = 2 # shared across main1 + main2
# ── Track board size ──
# ── Walk each phase in order ──
for phase in PHASE_ORDER:
sim.current_phase = phase
if phase == Phase.UNTAP:
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # MTG rule 514.2
elif phase == Phase.UPKEEP:
pass # placeholder — triggers would fire here
elif phase == Phase.DRAW:
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
elif phase == Phase.MAIN1:
# Land drop (only in main1, per MTG convention)
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# Play spells (shared budget across main1 + main2)
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.BEGIN_COMBAT:
pass # placeholder — "beginning of combat" triggers
elif phase == Phase.DECLARE_ATTACKERS:
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
elif phase == Phase.DECLARE_BLOCKERS:
pass # blocking is handled inside _resolve_combat
elif phase == Phase.COMBAT_DAMAGE:
pass # damage is handled inside _resolve_combat
elif phase == Phase.END_COMBAT:
pass # placeholder — "end of combat" triggers
elif phase == Phase.MAIN2:
# Second main phase: use remaining spell budget
if spells_budget > 0:
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.END_STEP:
pass # placeholder — "at end of turn" triggers
elif phase == Phase.CLEANUP:
pass # damage clearing already happened in untap for simplicity
# ── Post-phase bookkeeping ──
played_spell = p.stats.spells_cast > spells_before
attacked = p.stats.damage_dealt > damage_before_turn
# Track board size
board_size = len(sim.get_battlefield(pi))
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Combat ──
damage_before = p.stats.damage_dealt
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
attacked = p.stats.damage_dealt > damage_before
# ── Record ML decision snapshot ──
if self.ml_log and pre_snapshot is not None:
available_mana = self._count_untapped_lands(sim, pi)
if attacked:
action_type = "attack"
elif played_spell:
@@ -282,6 +339,42 @@ class GameEngine:
# Internal helpers
# ──────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────
# Opening-hand helpers
# ──────────────────────────────────────────────────────────
@staticmethod
def _count_lands_in_hand(hand: list[Card]) -> int:
"""Count lands in a hand of cards."""
return sum(1 for c in hand if c.is_land())
@staticmethod
def _should_mulligan(hand: list[Card], mulligan_number: int) -> bool:
"""Heuristic: mulligan if opening hand has 0-1 lands or 6-7 lands.
Becomes more lenient with each successive mulligan — after 2+
mulligans, accept 1-land hands to avoid going too low on cards.
"""
lands = GameEngine._count_lands_in_hand(hand)
total = len(hand)
if mulligan_number >= 2:
# Desperate: keep anything with 1-5 lands
return lands < 1 or lands >= total
# Standard: keep 2-5 lands in a 7-card hand
return lands <= 1 or lands >= 6
@staticmethod
def _pick_bottom_cards(hand: list[Card], count: int, weights: dict) -> list[Card]:
"""Choose the worst *count* cards to put on the bottom of library.
Uses the AI scoring function so the least valuable cards are
bottomed — mirrors a real player's London mulligan decision.
"""
if count <= 0 or count >= len(hand):
return []
scored = sorted(hand, key=lambda c: score_card(c, weights))
return scored[:count]
def _create_state(
self,
decks: list[list[Card]],
@@ -290,6 +383,11 @@ class GameEngine:
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players).
Implements the London mulligan rule: each player draws 7, decides
whether to keep, and if not shuffles back and redraws. After
keeping, they put N cards on the bottom of their library where N
is the number of times they mulliganed.
If commander_names is provided, the matching card is flagged
is_commander=True and placed in the player's command_zone
instead of the library/hand.
@@ -308,16 +406,46 @@ class GameEngine:
commander_card = c
else:
remaining.append(c)
random.shuffle(remaining)
hand = remaining[:7]
# ── London mulligan loop ──
mulligan_count = 0
if self.mulligan_rule == "london":
for attempt in range(self.MAX_MULLIGANS):
random.shuffle(remaining)
hand = remaining[:7]
if not self._should_mulligan(hand, attempt):
break
mulligan_count += 1
else:
# Exhausted all mulligans — forced keep with last draw
random.shuffle(remaining)
hand = remaining[:7]
else:
# No mulligan (legacy behavior)
random.shuffle(remaining)
hand = remaining[:7]
library = remaining[7:]
# ── Bottom N cards for London mulligan ──
if mulligan_count > 0:
bottom = self._pick_bottom_cards(hand, mulligan_count, self.weights)
for card in bottom:
hand.remove(card)
library.insert(0, card) # bottom of library
opening_lands = self._count_lands_in_hand(hand)
player = Player(
name=name,
life=self.starting_life,
owner_id=idx,
library=library,
hand=hand,
stats=PlayerStats(cards_drawn=7),
stats=PlayerStats(
cards_drawn=7,
mulligans=mulligan_count,
opening_hand_lands=opening_lands,
),
)
if commander_card:
player.command_zone.append(commander_card)
@@ -354,6 +482,57 @@ class GameEngine:
else:
owner.graveyard.append(card)
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def deal_damage(
sim: SimState,
amount: int,
target_seat: int,
source_card: Card | None = None,
source_seat: int | None = None,
is_combat: bool = False,
events: list | None = None,
label: str = "",
) -> int:
"""Apply *amount* damage to a player through the centralized path.
Handles life reduction, stat tracking, commander damage tracking
(both per-seat aggregate and per-card breakdown from PR #89),
and elimination checks. Returns the actual damage dealt.
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
if source_seat is not None:
sim.players[source_seat].stats.damage_dealt += amount
# Commander damage tracking — dual-dict model (PR #89):
# commander_damage_received[seat] = aggregate per-seat total
# commander_damage_by_card[(seat, card_name)] = per-card breakdown
if source_card and source_card.is_commander and source_seat is not None:
target.commander_damage_received[source_seat] = (
target.commander_damage_received.get(source_seat, 0) + amount
)
key = (source_seat, source_card.name)
target.commander_damage_by_card[key] = (
target.commander_damage_by_card.get(key, 0) + amount
)
if events is not None and label:
events.append(f"{label} ({target.name} now at {target.life} life)")
if target.life <= 0 or target.is_dead_to_commander_damage():
target.eliminated = True
return amount
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
"""Count untapped lands for a player."""
return sum(
@@ -362,12 +541,15 @@ class GameEngine:
if not c.tapped and c.is_land()
)
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
"""Play up to 2 spells from hand, prioritized by AI score."""
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None, max_spells: int = 2) -> int:
"""Play up to *max_spells* spells from hand, prioritized by AI score.
Returns the number of spells actually played this call.
"""
p = sim.players[pi]
w = self.weights
played = 0
while played < 2:
while played < max_spells:
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
@@ -437,6 +619,20 @@ class GameEngine:
p.graveyard.append(card)
if events is not None:
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
elif card.is_direct_damage and card.direct_damage_amount > 0:
# Direct-damage spell (burn): route through centralized deal_damage()
target_seat = self._select_attack_target(sim, pi)
if target_seat != -1:
target_name = sim.players[target_seat].name
self.deal_damage(
sim, card.direct_damage_amount, target_seat,
source_card=card, source_seat=pi,
events=events,
label=f"{card.name} deals {card.direct_damage_amount} damage to {target_name}",
)
elif events is not None:
events.append(f"Cast {card.name} (burn, no targets)")
p.graveyard.append(card)
else:
sim.add_to_battlefield(pi, card)
if card.is_creature():
@@ -482,6 +678,7 @@ class GameEngine:
f"for {total_cost} mana (tax={tax})"
)
available_mana = self._count_untapped_lands(sim, pi)
return played
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
@@ -559,6 +756,8 @@ class GameEngine:
total_damage = 0
used_blockers: set[int] = set()
combat_details: list[str] = []
# Per-attacker damage to route through deal_damage() individually
attacker_damage: list[tuple[Card, int]] = []
for atk in attackers:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
@@ -607,12 +806,12 @@ class GameEngine:
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.has_keyword("double strike"):
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
total_damage += a_pow
else:
total_damage += trample_over
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
attacker_damage.append((atk, ds_dmg))
total_damage += ds_dmg
if atk.has_keyword("lifelink"):
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
if atk.has_keyword("double strike"):
@@ -620,21 +819,261 @@ class GameEngine:
p.life += damage_dealt
if not blocked:
if atk.has_keyword("double strike"):
total_damage += a_pow * 2
atk_dmg = a_pow * 2
if atk.has_keyword("lifelink"):
p.life += a_pow * 2
else:
total_damage += a_pow
atk_dmg = a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
opp.stats.damage_received += total_damage
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage() for
# centralized life/stats/commander tracking
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if events is not None and total_damage > 0:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
# Compute commander damage annotation from the per-card dict
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.extend(combat_details)
if opp.life <= 0:
opp.eliminated = True
# ──────────────────────────────────────────────────────────
# Split combat methods for turn_manager priority windows
# (Issue #86 Item 1). The original _resolve_combat() above
# remains as the backward-compatible atomic path for run_n().
# ──────────────────────────────────────────────────────────
def assign_attackers(
self, sim: SimState, pi: int, events: list | None = None,
) -> Optional[CombatState]:
"""Phase 1 of split combat: choose attackers, populate CombatState.
Returns a CombatState with attackers set and tapped, or None if
no valid attack is possible. Does NOT deal any damage.
"""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return None
opp = sim.players[opp_idx]
w = self.weights
my_creatures = [
c
for c in sim.get_battlefield(pi)
if (
c.is_creature()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
)
]
if not my_creatures:
return None
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
has_flying = atk.has_keyword("flying")
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if not attackers:
return None
if events is not None:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# Build and store CombatState
combat = CombatState(
defending_seat=opp_idx,
attackers={atk.id: opp_idx for atk in attackers},
active=True,
)
sim.combat = combat
return combat
def assign_blockers(
self, sim: SimState, pi: int, combat: CombatState,
events: list | None = None,
) -> None:
"""Phase 2 of split combat: assign blockers into CombatState.
Populates combat.blockers and combat.player_damage.
Does NOT apply any damage to players or creatures yet.
"""
opp_idx = combat.defending_seat
opp_blockers = [
c for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
used_blockers: set[int] = set()
for atk_id in combat.attackers:
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue
a_pow = atk.get_power()
has_flying = atk.has_keyword("flying")
valid_blockers = [
b for b in opp_blockers
if b.id not in used_blockers
and (not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
]
blocker = None
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
valid_blockers.sort(key=lambda b: -b.get_toughness())
blocker = valid_blockers[0]
used_blockers.add(valid_blockers[1].id)
else:
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
opp = sim.players[opp_idx]
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
if blocker:
used_blockers.add(blocker.id)
combat.blockers[atk_id] = [blocker.id]
if events is not None:
events.append(f"{atk.name} blocked by {blocker.name}")
else:
# Unblocked — full damage goes to player
combat.player_damage[atk_id] = a_pow
def resolve_combat_damage(
self, sim: SimState, pi: int, combat: CombatState,
first_strike_only: bool = False,
events: list | None = None,
) -> None:
"""Phase 3 of split combat: apply damage from CombatState.
When first_strike_only=True, only processes creatures with
first_strike or double_strike. When False, processes all
remaining (skipping first-strikers if already resolved).
"""
opp_idx = combat.defending_seat
p = sim.players[pi]
total_damage = 0
attacker_damage: list[tuple[Card, int]] = []
combat_details: list[str] = []
for atk_id, def_seat in list(combat.attackers.items()):
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue # removed by instant before damage
is_fs = atk.has_keyword("first_strike") or atk.has_keyword("double_strike")
if first_strike_only and not is_fs:
continue
if not first_strike_only and is_fs and combat.first_strike_resolved:
if not atk.has_keyword("double_strike"):
continue # already dealt damage in FS step
a_pow = atk.get_power()
a_tou = atk.get_toughness()
blocker_ids = combat.blockers.get(atk_id, [])
if blocker_ids:
blocker = next((c for bf in sim.battlefields for c in bf if c.id == blocker_ids[0]), None)
if blocker is None:
# Blocker removed — attacker is still blocked but deals no combat damage to player
continue
b_pow = blocker.get_power()
blocker.damage_marked += a_pow
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
self._send_to_graveyard(sim, blocker, opp_idx)
if events is not None:
combat_details.append(f" {blocker.name} dies")
atk.damage_marked += b_pow
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
self._send_to_graveyard(sim, atk, pi)
if events is not None:
combat_details.append(f" {atk.name} dies")
# Trample over
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness()) + trample_over
else:
# Unblocked
atk_dmg = a_pow * 2 if atk.has_keyword("double_strike") else a_pow
if atk.has_keyword("lifelink"):
p.life += atk_dmg
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage()
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if first_strike_only:
combat.first_strike_resolved = True
if events is not None and total_damage > 0:
opp = sim.players[opp_idx]
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.extend(combat_details)
def _build_result(
self,
@@ -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())
+79 -13
View File
@@ -15,7 +15,7 @@ import json
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.models import Card, Player, SimState
from commander_ai_lab.sim.models import Card, CombatState, Phase, Player, SimState
# ── Mana Pool ────────────────────────────────────────────────
@@ -95,9 +95,6 @@ class CommanderPlayer:
# Whether this player has drawn their card this turn
drawn_this_turn: bool = False
# Commander damage received from each opponent (keyed by seat index)
commander_damage_received: dict[int, int] = field(default_factory=dict)
# ── Convenience pass-throughs to base Player ─────────────
@property
@@ -136,13 +133,18 @@ class CommanderPlayer:
def library(self) -> list[Card]:
return self.base.library
@property
def commander_damage_received(self) -> dict[int, int]:
"""Delegate to base Player so headless engine writes are visible."""
return self.base.commander_damage_received
def commander_tax(self) -> int:
"""Additional mana cost (2 per cast from command zone)."""
return self.commander_cast_count * 2
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single opponent has dealt 21+ commander damage."""
return any(v >= 21 for v in self.commander_damage_received.values())
"""Returns True if any single commander has dealt 21+ damage."""
return self.base.is_dead_to_commander_damage()
def to_dict(self) -> dict:
return {
@@ -194,7 +196,7 @@ class CommanderGameState:
# Phase tracking
# Phases: untap, upkeep, draw, main1, combat_begin, combat_declare_attackers,
# combat_declare_blockers, combat_damage, combat_end, main2, end, cleanup
current_phase: str = "main1"
current_phase: str | Phase = Phase.MAIN1
# Turn number (mirrors sim_state.turn)
turn: int = 0
@@ -230,13 +232,65 @@ class CommanderGameState:
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for a seat. Stub — returns pass-only."""
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
"""Return legal moves for a seat.
During DECLARE_BLOCKERS phase, emits block moves for defending
creatures against each attacker in sim_state.combat.
Otherwise returns pass-only (stub for remaining rules engine).
"""
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
# Emit block moves during declare_blockers phase
if (
combat
and combat.active
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
and seat != self.active_player_seat
):
move_id = 1000
my_creatures = [
c for c in self.sim_state.get_battlefield(seat)
if c.is_creature() and not c.tapped
]
for atk_id in combat.attackers:
atk = next(
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
None,
)
if atk is None:
continue
for blocker in my_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move. Stub — no-op until rules engine is wired."""
pass
"""Apply a chosen move.
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
"""
# Find the move dict
all_moves = self.get_legal_moves(seat)
move = next((m for m in all_moves if m["id"] == move_id), None)
if move is None:
return
if move.get("category") == "block":
combat = self.sim_state.combat
if combat and combat.active:
atk_id = move["attacker_id"]
blocker_id = move["blocker_id"]
if atk_id not in combat.blockers:
combat.blockers[atk_id] = []
combat.blockers[atk_id].append(blocker_id)
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
@@ -247,13 +301,25 @@ class CommanderGameState:
return p.commander_damage_received.get(from_seat, 0)
return 0
def deal_commander_damage(self, from_seat: int, to_seat: int, amount: int) -> None:
"""Record commander damage and reduce life total."""
def deal_commander_damage(
self, from_seat: int, to_seat: int, amount: int,
commander_name: str | None = None,
) -> None:
"""Record commander damage and reduce life total.
If *commander_name* is provided the per-card breakdown is also
updated (needed for partner-correct 21-damage checks).
"""
if to_seat < len(self.commander_players):
p = self.commander_players[to_seat]
p.commander_damage_received[from_seat] = (
p.commander_damage_received.get(from_seat, 0) + amount
)
if commander_name is not None:
key = (from_seat, commander_name)
p.base.commander_damage_by_card[key] = (
p.base.commander_damage_by_card.get(key, 0) + amount
)
p.life -= amount
# ── Serialization ────────────────────────────────────────
+100
View File
@@ -11,12 +11,73 @@ SimState.battlefields is per-player: battlefields[seat_index] holds that player'
from __future__ import annotations
import copy
import enum
import json
import re
from dataclasses import dataclass, field
from typing import Optional
# ── Turn Phases ──────────────────────────────────────────────
class Phase(str, enum.Enum):
"""MTG turn phases in order.
Using ``str`` as a mixin allows direct comparison with plain strings
and clean JSON serialization (``phase.value`` is a string).
"""
UNTAP = "untap"
UPKEEP = "upkeep"
DRAW = "draw"
MAIN1 = "main1"
BEGIN_COMBAT = "begin_combat"
DECLARE_ATTACKERS = "declare_attackers"
DECLARE_BLOCKERS = "declare_blockers"
COMBAT_DAMAGE = "combat_damage"
END_COMBAT = "end_combat"
MAIN2 = "main2"
END_STEP = "end_step"
CLEANUP = "cleanup"
# Ordered tuple for iteration
PHASE_ORDER: tuple[Phase, ...] = tuple(Phase)
# Phases where sorcery-speed actions (land drops, casting creatures) are legal
SORCERY_PHASES: frozenset[Phase] = frozenset({Phase.MAIN1, Phase.MAIN2})
# ── Combat State ───────────────────────────────────────────
@dataclass
class CombatState:
"""Transient combat state shared across combat sub-phases.
Created at BEGIN_COMBAT, cleared to None at END_COMBAT.
Allows priority windows between attacker declaration, blocker
declaration, and damage resolution (Issue #86 Item 1).
"""
# Seat index of the defending player (multiplayer: chosen target)
defending_seat: int = -1
# attacker card_id -> defending seat
attackers: dict[int, int] = field(default_factory=dict)
# attacker card_id -> list of blocker card_ids
blockers: dict[int, list[int]] = field(default_factory=dict)
# Damage routed to the defending player per-attacker (for unblocked / trample)
player_damage: dict[int, int] = field(default_factory=dict)
# Whether the first-strike damage sub-step has already resolved
first_strike_resolved: bool = False
# Whether combat is currently active
active: bool = False
@dataclass
class Card:
"""A single card in the simulation."""
@@ -37,6 +98,15 @@ class Card:
is_removal: bool = False
is_board_wipe: bool = False
is_commander: bool = False
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
@@ -100,6 +170,8 @@ class PlayerStats:
"""Per-game statistics tracked for a player."""
cards_drawn: int = 7 # starting hand
mulligans: int = 0
opening_hand_lands: int = 0
lands_played: int = 0
spells_cast: int = 0
creatures_played: int = 0
@@ -115,6 +187,8 @@ class PlayerStats:
def to_dict(self) -> dict:
return {
"cardsDrawn": self.cards_drawn,
"mulligans": self.mulligans,
"openingHandLands": self.opening_hand_lands,
"landsPlayed": self.lands_played,
"spellsCast": self.spells_cast,
"creaturesPlayed": self.creatures_played,
@@ -152,8 +226,29 @@ class Player:
# p.commander_tax[name] = tax + 2
commander_tax: dict = field(default_factory=dict)
# Commander damage received from each opponent, keyed by seat index.
# This is the aggregate per-seat total (used by GUI matrix and prompt
# builder). For games where a player controls partner commanders
# the per-card breakdown lives in commander_damage_by_card.
commander_damage_received: dict[int, int] = field(default_factory=dict)
# Per-commander breakdown: {(seat, card_name): damage}.
# In MTG rules, lethal commander damage (21+) is tracked per individual
# commander card, not per player. This dict captures that.
commander_damage_by_card: dict[tuple[int, str], int] = field(default_factory=dict)
stats: PlayerStats = field(default_factory=PlayerStats)
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single commander has dealt 21+ damage.
Checks per-card tracking first (partner-correct); falls back to
per-seat totals for backward compatibility with older game states.
"""
if self.commander_damage_by_card:
return any(v >= 21 for v in self.commander_damage_by_card.values())
return any(v >= 21 for v in self.commander_damage_received.values())
@dataclass
class SimState:
@@ -169,6 +264,11 @@ class SimState:
turn: int = 0
max_turns: int = 25
next_card_id: int = 90000
current_phase: Phase = Phase.MAIN1
active_player_index: int = 0
# Combat state (transient, set during combat phases)
combat: Optional[CombatState] = None
# ── Battlefield helpers ──────────────────────────────────
+132 -4
View File
@@ -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:
@@ -208,6 +264,10 @@ def _apply_oracle_flags(card: Card) -> None:
]):
card.is_removal = True
# Direct-damage spells (burn): "deals N damage to target/any target"
if not card.is_direct_damage:
_detect_direct_damage(card, oracle)
# Bug 17 fix: evaluate ramp independently — cards can be both removal and ramp
# (e.g. Nature's Claim, Beast Within, Abrupt Decay)
if not card.is_ramp:
@@ -224,6 +284,64 @@ def _apply_oracle_flags(card: Card) -> None:
card.is_ramp = True
# ── Direct-damage (burn spell) detection ──────────────────────
# Regex: "deals N damage to target|any target" (not "each creature" — that's a wipe)
_DIRECT_DAMAGE_RE = re.compile(
r"deals?\s+(\d+)\s+damage\s+to\s+(?:target|any\s+target)",
re.IGNORECASE,
)
# Well-known burn spells with fixed damage amounts for reliable detection
_KNOWN_BURN: dict[str, int] = {
"lightning bolt": 3,
"shock": 2,
"lightning strike": 3,
"lava spike": 3,
"rift bolt": 3,
"searing blaze": 3,
"chain lightning": 3,
"flame rift": 4,
"exquisite firecraft": 4,
"lightning helix": 3,
"warleader's helix": 4,
"boros charm": 4,
"searing spear": 3,
"incinerate": 3,
"magma jet": 2,
"searing blood": 2,
"firebolt": 2,
"galvanic blast": 2,
"skullcrack": 3,
"flames of the firebrand": 3,
"stoke the flames": 4,
}
def _detect_direct_damage(card: Card, oracle: str) -> None:
"""Detect direct-damage (burn) spells and set card flags."""
# Check known-burn table first (most reliable)
name_lower = (card.name or "").lower().strip()
if name_lower in _KNOWN_BURN:
card.is_direct_damage = True
card.direct_damage_amount = _KNOWN_BURN[name_lower]
return
# Fall back to regex detection from oracle text
if not oracle:
return
# Skip board wipes and each-creature effects
if card.is_board_wipe:
return
if "each creature" in oracle or "all creature" in oracle:
return
m = _DIRECT_DAMAGE_RE.search(oracle)
if m:
card.is_direct_damage = True
card.direct_damage_amount = int(m.group(1))
# ── Multi-opponent scaling factors ────────────────────────────
# Keywords / effects that become more valuable in larger pods.
# Each entry maps a weight key → the extra bonus *per additional
@@ -318,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
+69 -21
View File
@@ -46,38 +46,29 @@ from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional, Set
from commander_ai_lab.sim.models import Phase, PHASE_ORDER, SORCERY_PHASES, CombatState
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.threat_assessor import assess_threats, ThreatScore
logger = logging.getLogger("turn_manager")
# Commander turn phases in order
PHASES = [
"untap",
"upkeep",
"draw",
"main1",
"begin_combat",
"declare_attackers",
"declare_blockers",
"combat_damage",
"end_combat",
"main2",
"end_step",
"cleanup",
]
# Re-export for backward compatibility; canonical source is models.py
PHASES = [p.value for p in PHASE_ORDER]
# Phases where a player can take an action (play land, cast spell, etc.)
ACTION_PHASES = {"main1", "main2"}
ACTION_PHASES = {p.value for p in SORCERY_PHASES}
# Phases where instant-speed responses can happen
RESPONSE_PHASES = {"upkeep", "begin_combat", "end_combat", "end_step"}
RESPONSE_PHASES = {
Phase.UPKEEP.value,
Phase.END_STEP.value,
}
@dataclass
@@ -98,7 +89,7 @@ class GameEvent:
class TurnManagerConfig:
"""Tunable parameters for the turn manager."""
max_turns: int = 40
ai_decision_timeout: float = 30.0 # seconds before fallback
ai_decision_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # seconds before fallback
ai_narration_enabled: bool = True
ai_decision_delay: float = 0.0 # cosmetic pause (set > 0 for UI readability)
priority_pass_limit: int = 8 # max APNAP passes per phase before auto-advance
@@ -362,7 +353,13 @@ class CommanderTurnManager:
await self._apnap_priority_window(active_seat, turn_num, phase)
async def _phase_combat(self, active_seat: int, turn_num: int) -> None:
"""Declare attackers phase."""
"""Full combat sequence with priority windows at each sub-phase (Issue #86)."""
sim = self.gs.sim_state
# 1. Beginning of combat — priority window BEFORE attackers chosen
await self._apnap_priority_window(active_seat, turn_num, "begin_combat")
# 2. Declare attackers
legal_moves = self.get_legal_moves(active_seat)
attack_moves = [m for m in legal_moves if m.get("category") == "attack"]
if not attack_moves:
@@ -391,9 +388,44 @@ class CommanderTurnManager:
narration=narration,
))
# Defending players get priority to declare blockers
# 3. After-attackers priority window
await self._apnap_priority_window(active_seat, turn_num, "declare_attackers")
# 4. Declare blockers — each defending player in APNAP order
for defender_seat in [s for s in range(len(self.gs.players))
if s != active_seat
and not self.gs.players[s].eliminated]:
block_moves = [m for m in self.get_legal_moves(defender_seat)
if m.get("category") == "block"]
if block_moves:
block_id = await self._get_action(defender_seat, block_moves, turn_num)
if block_id is not None:
self.gs.apply_move(defender_seat, block_id)
# 5. After-blockers priority window
await self._apnap_priority_window(active_seat, turn_num, "declare_blockers")
# 6. First-strike damage sub-step (if applicable)
if self._has_first_strikers():
# Resolve first-strike damage via engine split method
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=True)
await self._apnap_priority_window(active_seat, turn_num, "first_strike_damage")
# 7. Normal combat damage
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=False)
# 8. End of combat priority window
await self._apnap_priority_window(active_seat, turn_num, "end_combat")
# 9. Clear combat state
sim.combat = None
async def _phase_cleanup(self, active_seat: int) -> None:
"""Discard to hand size (7), remove end-of-turn effects."""
player = self.gs.players[active_seat]
@@ -406,6 +438,22 @@ class CommanderTurnManager:
player.name, discarded.name,
)
def _has_first_strikers(self) -> bool:
"""Check if any creature in active combat has first_strike or double_strike."""
sim = self.gs.sim_state
combat = sim.combat
if not combat or not combat.active:
return False
all_combat_ids = (
list(combat.attackers.keys()) +
[bid for blist in combat.blockers.values() for bid in blist]
)
for card_id in all_combat_ids:
card = next((c for bf in sim.battlefields for c in bf if c.id == card_id), None)
if card and (card.has_keyword("first_strike") or card.has_keyword("double_strike")):
return True
return False
# ── Priority Passing (APNAP) ──────────────────────────────────────────────
async def _apnap_priority_window(
+2 -2
View File
@@ -58,7 +58,7 @@ class ValidatorConfig:
model: str = "deepseek-r1:14b"
temperature: float = 0.0
max_tokens: int = 2048
request_timeout: float = 60.0
request_timeout: float = 300.0
enabled: bool = True
@classmethod
@@ -70,7 +70,7 @@ class ValidatorConfig:
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
temperature=float(os.environ.get("VALIDATOR_TEMPERATURE", "0.0")),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
enabled=(enabled_str == "true"),
)
@@ -43,6 +43,11 @@ public class MultiThreadBatchRunner {
private boolean useSimplifiedAi = false;
private int aiThinkTimeMs = -1;
// ── ML Decision Logging ────────────────────────────────────
private boolean mlLogEnabled = false;
private String mlOutputDir = "results";
private String mlBatchId;
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
@@ -93,6 +98,16 @@ public class MultiThreadBatchRunner {
this.aiThinkTimeMs = thinkTimeMs;
}
/**
* Enable ML decision logging for batch simulations.
* Each thread gets its own log file with a unique batch ID suffix.
*/
public void enableMlLogging(String outputDir, String batchId) {
this.mlLogEnabled = true;
this.mlOutputDir = outputDir;
this.mlBatchId = batchId;
}
/**
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
*
@@ -193,6 +208,11 @@ public class MultiThreadBatchRunner {
// Issue #4: Forward AI optimization settings
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
// Forward ML decision logging to per-thread runner
if (mlLogEnabled) {
String threadBatchId = mlBatchId + "-t" + threadId;
runner.enableMlLogging(mlOutputDir, threadBatchId);
}
// Issue #5: Per-thread progress callback that aggregates across all threads
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
@@ -218,6 +238,7 @@ public class MultiThreadBatchRunner {
for (int i = 0; i < results.size(); i++) {
results.get(i).gameIndex = startIndex + i;
}
if (mlLogEnabled) { runner.closeMlLogger(); }
return results;
}));
@@ -344,6 +344,12 @@ public class LabCli implements Callable<Integer> {
mtRunner.setAiOptimization(aiSimplified, aiThinkTimeMs);
if (maxQueueDepth > 0) {
mtRunner.setMaxQueueDepth(maxQueueDepth);
}
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
mtRunner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — data will be saved to results/");
}
games = mtRunner.runBatch(numGames, masterSeed);
} else {
@@ -33,7 +33,7 @@ public class PolicyServerClient {
private static final Gson GSON = new GsonBuilder().create();
private static final int CONNECT_TIMEOUT_MS = 2000;
private static final int READ_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 300_000;
private final String baseUrl;
private volatile boolean available = false;
+539
View File
@@ -0,0 +1,539 @@
"""Regression tests for direct-damage spell routing (Issue #86).
Tests that burn spells and similar direct-damage effects are routed
through the centralized deal_damage() path, correctly update
life totals / stats, and integrate with commander damage tracking.
Adapted to work with the dual-dict commander damage model from PR #89:
- commander_damage_received[seat] = per-seat aggregate
- commander_damage_by_card[(seat, card_name)] = per-card breakdown
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
"""Build a creature Card with optional oracle text."""
c = Card(
name=name,
type_line="Creature",
cmc=cmc,
pt=pt,
power=pt.split("/")[0],
toughness=pt.split("/")[1],
oracle_text=oracle_text,
**kwargs,
)
enrich_card(c)
return c
def _make_land(name="Forest") -> Card:
c = Card(name=name)
enrich_card(c)
return c
def _make_burn(name="Lightning Bolt", cmc=1, oracle_text="Lightning Bolt deals 3 damage to any target.") -> Card:
c = Card(name=name, type_line="Instant", cmc=cmc, oracle_text=oracle_text)
enrich_card(c)
return c
def _setup_two_player(
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
p0_battlefield=None, p1_battlefield=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 2-player SimState with controlled hands/battlefields."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i, (hand, life, bf) in enumerate([
(p0_hand or [], p0_life, p0_battlefield or []),
(p1_hand or [], p1_life, p1_battlefield or []),
]):
p = Player(
name=f"Player {i}",
life=life,
owner_id=i,
hand=list(hand),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(2)
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
def _setup_four_player(
hands=None, lives=None, battlefields=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 4-player SimState."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
hands = hands or [[] for _ in range(4)]
lives = lives or [40, 40, 40, 40]
battlefields = battlefields or [[] for _ in range(4)]
for i in range(4):
p = Player(
name=f"Player {i}",
life=lives[i],
owner_id=i,
hand=list(hands[i]),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(4)
for seat, bf_cards in enumerate(battlefields):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
# ══════════════════════════════════════════════════════════════
# deal_damage() centralized method tests
# ══════════════════════════════════════════════════════════════
class TestDealDamage:
"""Tests for GameEngine.deal_damage() static method."""
def test_basic_damage_reduces_life(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert dealt == 5
assert sim.players[1].life == 15
def test_zero_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 0, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_negative_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, -3, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_damage_updates_stats(self):
engine, sim = _setup_two_player()
engine.deal_damage(sim, 7, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 7
assert sim.players[1].stats.damage_received == 7
def test_damage_eliminates_at_zero(self):
engine, sim = _setup_two_player(p1_life=3)
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_damage_eliminates_below_zero(self):
engine, sim = _setup_two_player(p1_life=2)
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert sim.players[1].life == -3
assert sim.players[1].eliminated is True
def test_damage_to_eliminated_player_is_noop(self):
engine, sim = _setup_two_player(p1_life=0)
sim.players[1].eliminated = True
dealt = engine.deal_damage(sim, 10, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 0
def test_events_logged(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, source_seat=0,
events=events, label="Bolt deals 3")
assert len(events) == 1
assert "Bolt deals 3" in events[0]
assert "Player 1 now at 17 life" in events[0]
def test_no_label_means_no_event(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, events=events)
assert len(events) == 0
# ══════════════════════════════════════════════════════════════
# Commander damage tracking through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCommanderDamageTracking:
"""Tests for commander damage tracked by deal_damage() using
the dual-dict model: per-seat aggregate + per-card breakdown."""
def test_commander_damage_tracked_both_dicts(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0, is_combat=True)
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_commander_damage_accumulates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_received[0] == 8
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 8
def test_commander_21_damage_eliminates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="7/7", is_commander=True)
for _ in range(3):
engine.deal_damage(sim, 7, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 21
assert sim.players[1].eliminated is True
def test_partner_commanders_tracked_separately(self):
engine, sim = _setup_two_player()
cmd_a = _make_creature("Thrasios", pt="1/3", is_commander=True)
cmd_b = _make_creature("Tymna", pt="2/2", is_commander=True)
# 15 from Thrasios, 15 from Tymna — neither hits 21 alone
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_a, source_seat=0)
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_b, source_seat=0)
# Per-card: each under 21
assert sim.players[1].commander_damage_by_card[(0, "Thrasios")] == 15
assert sim.players[1].commander_damage_by_card[(0, "Tymna")] == 15
# Per-seat aggregate: 30 total
assert sim.players[1].commander_damage_received[0] == 30
# Life is 10, but not eliminated by commander damage (no single source >= 21)
assert sim.players[1].life == 10
assert sim.players[1].is_dead_to_commander_damage() is False
def test_non_commander_damage_not_tracked(self):
engine, sim = _setup_two_player()
regular = _make_creature("Bear", pt="2/2")
engine.deal_damage(sim, 2, target_seat=1, source_card=regular, source_seat=0)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_is_dead_to_commander_damage(self):
p = Player(name="Test", life=40)
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 20
p.commander_damage_received[0] = 20
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 21
p.commander_damage_received[0] = 21
assert p.is_dead_to_commander_damage() is True
def test_commander_damage_eliminates_despite_high_life(self):
"""A player at 100 life should still die to 21 commander damage."""
engine, sim = _setup_two_player(p1_life=100)
cmd = _make_creature("Voltron", pt="21/21", is_commander=True)
engine.deal_damage(sim, 21, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].life == 79
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Direct-damage spell detection (rules.py)
# ══════════════════════════════════════════════════════════════
class TestDirectDamageDetection:
"""Tests for is_direct_damage detection in enrich_card/rules."""
def test_lightning_bolt_detected(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1,
oracle_text="Lightning Bolt deals 3 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_shock_detected(self):
c = Card(name="Shock", type_line="Instant", cmc=1,
oracle_text="Shock deals 2 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 2
def test_known_burn_by_name(self):
"""Known burn spells should be detected even without oracle text."""
c = Card(name="Lightning Bolt")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_chain_lightning_detected(self):
c = Card(name="Chain Lightning")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_boros_charm_detected(self):
c = Card(name="Boros Charm")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 4
def test_oracle_text_detection(self):
"""An unknown card with direct-damage oracle text should be detected."""
c = Card(
name="Custom Burn Spell",
type_line="Instant",
cmc=2,
oracle_text="Custom Burn Spell deals 5 damage to target creature or player.",
)
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 5
def test_board_wipe_not_flagged(self):
"""Board wipes should not be flagged as direct damage."""
c = Card(name="Blasphemous Act", type_line="Sorcery", cmc=9,
oracle_text="Blasphemous Act deals 13 damage to each creature.")
enrich_card(c)
assert c.is_board_wipe is True
assert c.is_direct_damage is False
def test_creature_not_flagged(self):
"""A vanilla creature should not be flagged as direct damage."""
c = _make_creature("Grizzly Bears", pt="2/2")
assert c.is_direct_damage is False
def test_removal_spell_not_flagged_as_direct_damage(self):
"""Pure removal (destroy target) should not become direct damage."""
c = Card(name="Murder", type_line="Instant", cmc=3,
oracle_text="Destroy target creature.")
enrich_card(c)
assert c.is_removal is True
assert c.is_direct_damage is False
# ══════════════════════════════════════════════════════════════
# Direct-damage routing through _play_spells()
# ══════════════════════════════════════════════════════════════
class TestDirectDamageSpellRouting:
"""Tests that burn spells resolve through deal_damage() during gameplay."""
def test_burn_spell_reduces_opponent_life(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
# Lightning Bolt: 3 damage
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_burn_spell_goes_to_graveyard(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
# Bolt should be in graveyard
assert any(c.name == "Lightning Bolt" for c in sim.players[0].graveyard)
def test_burn_spell_eliminates_low_life_opponent(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=2,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == -1
assert sim.players[1].eliminated is True
def test_burn_targets_weakest_opponent(self):
"""In multiplayer, burn should target the weakest opponent."""
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_four_player(
hands=[[bolt], [], [], []],
lives=[40, 30, 10, 20],
battlefields=[lands, [], [], []],
)
engine._play_spells(sim, 0, available_mana=3)
# Player 2 (life=10) is weakest, should be targeted
assert sim.players[2].life == 7
def test_burn_events_logged(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
burn_events = [e for e in events if "Lightning Bolt" in e and "damage" in e]
assert len(burn_events) >= 1
def test_burn_spell_taps_lands(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
tapped_lands = [c for c in sim.get_battlefield(0) if c.is_land() and c.tapped]
assert len(tapped_lands) == 1 # CMC 1
def test_shock_deals_2(self):
shock = _make_burn("Shock", cmc=1, oracle_text="Shock deals 2 damage to any target.")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[shock],
p0_battlefield=lands,
p1_life=20,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == 18
# ══════════════════════════════════════════════════════════════
# Combat damage routing through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCombatDamageRouting:
"""Tests that combat damage routes through deal_damage()."""
def test_unblocked_creature_deals_damage(self):
bear = _make_creature("Bear", pt="2/2")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=20,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 18
assert sim.players[0].stats.damage_dealt == 2
assert sim.players[1].stats.damage_received == 2
def test_commander_combat_damage_tracked(self):
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 36
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_non_commander_combat_damage_not_tracked_as_commander(self):
bear = _make_creature("Bear", pt="3/3")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_commander_lethal_at_21(self):
"""A commander dealing 21 damage should eliminate even at high life."""
cmd = _make_creature("Voltron", pt="7/7", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=100,
)
# Simulate 3 combat rounds
for t in range(3):
if sim.players[1].eliminated:
break
# Reset attacker for next combat
cmd.tapped = False
cmd.turn_played = -1
engine._resolve_combat(sim, 0, turn=t + 1)
assert sim.players[1].commander_damage_by_card[(0, "Voltron")] == 21
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Full-game integration with burn spells
# ══════════════════════════════════════════════════════════════
class TestFullGameWithBurn:
"""Integration tests running full games with burn spells in decks."""
def test_game_with_burn_completes(self):
"""A game with burn spells in decks should complete without error."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(8)]
+ [Card(name="Shock") for _ in range(8)]
+ [Card(name="Forest") for _ in range(24)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
)
deck_b = (
[Card(name="Forest") for _ in range(24)]
+ [Card(name="Serra Angel") for _ in range(10)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
+ [Card(name="Murder") for _ in range(6)]
)
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25, record_log=True)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Midrange",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_burn_damage_appears_in_stats(self):
"""Burn spell damage should be reflected in player stats."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(15)]
+ [Card(name="Mountain") for _ in range(45)]
)
deck_b = [Card(name="Forest") for _ in range(60)]
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Lands",
)
# Player A should have dealt some damage from burn spells
pa_stats = result.player_a_stats
assert pa_stats is not None
assert pa_stats.damage_dealt > 0
+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)
+315
View File
@@ -0,0 +1,315 @@
"""Tests for London mulligan and opening-hand validation (#86)."""
import random
import pytest
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.models import Card, PlayerStats
from commander_ai_lab.sim.rules import enrich_card
# ── Helpers ───────────────────────────────────────────────────
def _land(name: str = "Forest") -> Card:
return enrich_card(Card(name=name))
def _spell(name: str = "Grizzly Bears") -> Card:
return enrich_card(Card(name=name))
def _make_deck(lands: int = 24, spells: int = 36) -> list[Card]:
"""Build a deck with an exact land/spell split."""
deck: list[Card] = []
for _ in range(lands):
deck.append(_land())
for _ in range(spells):
deck.append(_spell())
return deck
def _all_lands_deck(size: int = 60) -> list[Card]:
"""Deck of nothing but lands — forces 7-land opening hand."""
return [_land() for _ in range(size)]
def _no_lands_deck(size: int = 60) -> list[Card]:
"""Deck with zero lands — forces 0-land opening hand."""
return [_spell(f"Spell_{i}") for i in range(size)]
# ── PlayerStats fields ────────────────────────────────────────
class TestPlayerStatsMultiganFields:
def test_default_mulligans_zero(self):
stats = PlayerStats()
assert stats.mulligans == 0
def test_default_opening_hand_lands_zero(self):
stats = PlayerStats()
assert stats.opening_hand_lands == 0
def test_to_dict_includes_mulligan_keys(self):
stats = PlayerStats(mulligans=2, opening_hand_lands=3)
d = stats.to_dict()
assert d["mulligans"] == 2
assert d["openingHandLands"] == 3
# ── _should_mulligan heuristic ────────────────────────────────
class TestShouldMulligan:
def test_zero_lands_mulligans(self):
hand = [_spell() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_one_land_mulligans(self):
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_two_lands_keeps(self):
hand = [_land(), _land()] + [_spell() for _ in range(5)]
assert GameEngine._should_mulligan(hand, 0) is False
def test_five_lands_keeps(self):
hand = [_land() for _ in range(5)] + [_spell(), _spell()]
assert GameEngine._should_mulligan(hand, 0) is False
def test_six_lands_mulligans(self):
hand = [_land() for _ in range(6)] + [_spell()]
assert GameEngine._should_mulligan(hand, 0) is True
def test_seven_lands_mulligans(self):
hand = [_land() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_desperate_keeps_one_land(self):
"""After 2+ mulligans, accept a 1-land hand."""
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 2) is False
# ── _count_lands_in_hand ──────────────────────────────────────
class TestCountLandsInHand:
def test_empty_hand(self):
assert GameEngine._count_lands_in_hand([]) == 0
def test_mixed_hand(self):
hand = [_land(), _spell(), _land(), _spell()]
assert GameEngine._count_lands_in_hand(hand) == 2
def test_all_lands(self):
hand = [_land() for _ in range(7)]
assert GameEngine._count_lands_in_hand(hand) == 7
# ── _pick_bottom_cards ────────────────────────────────────────
class TestPickBottomCards:
def test_returns_correct_count(self):
hand = [_land(), _land(), _spell(), _spell(), _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 2, {})
assert len(bottom) == 2
def test_zero_count_returns_empty(self):
hand = [_land(), _spell()]
assert GameEngine._pick_bottom_cards(hand, 0, {}) == []
def test_count_gte_hand_returns_empty(self):
hand = [_land()]
assert GameEngine._pick_bottom_cards(hand, 1, {}) == []
def test_bottoms_lowest_scored_cards(self):
"""The card with the worst AI score should be picked to bottom."""
good = enrich_card(Card(name="Serra Angel", type_line="Creature", cmc=5, pt="4/4"))
bad = enrich_card(Card(name="Forest"))
hand = [good, bad, _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 1, {})
# Lands score very low compared to creatures
assert bottom[0].name == "Forest"
# ── London mulligan integration ───────────────────────────────
class TestLondonMulliganIntegration:
def test_good_hand_no_mulligan(self):
"""A well-balanced deck should usually keep on the first try."""
random.seed(42)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have kept without mulligan
any_kept = any(
p.stats.mulligans == 0 for p in result.players
)
# With a 24-land deck, most seeds give keepable hands
assert result.turns > 0 # game still works
def test_all_lands_deck_triggers_mulligan(self):
"""A 60-land deck should always mulligan (7 lands in hand)."""
random.seed(99)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_lands],
deck_b=[c.clone() for c in deck_normal],
)
# Player A has an all-land deck; must have mulliganed at least once
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_no_lands_deck_triggers_mulligan(self):
"""A 0-land deck should always mulligan (0 lands in hand)."""
random.seed(77)
deck_empty = _no_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_normal],
)
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_mulligan_caps_at_max(self):
"""Even a terrible deck can mulligan at most MAX_MULLIGANS times."""
random.seed(11)
deck_empty = _no_lands_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_empty],
)
for p in result.players:
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
def test_hand_size_after_mulligan(self):
"""After N mulligans, opening hand should be 7 - N cards."""
random.seed(55)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
# We need to test the internal state, so use _create_state directly
sim = engine._create_state(
decks=[[c.clone() for c in deck_lands], [c.clone() for c in deck_normal]],
names=["LandPlayer", "NormalPlayer"],
)
for p in sim.players:
expected = 7 - p.stats.mulligans
assert len(p.hand) == expected, (
f"{p.name}: hand={len(p.hand)}, mulligans={p.stats.mulligans}, "
f"expected={expected}"
)
def test_opening_hand_lands_tracked(self):
"""opening_hand_lands should reflect final kept hand."""
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["P1", "P2"],
)
for p in sim.players:
actual_lands = sum(1 for c in p.hand if c.is_land())
assert p.stats.opening_hand_lands == actual_lands
def test_total_cards_preserved(self):
"""Mulligan must not create or destroy cards."""
random.seed(33)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
for _ in range(10):
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["A", "B"],
)
for p in sim.players:
total = len(p.hand) + len(p.library) + len(p.command_zone)
assert total == 60, f"Card count mismatch: {total}"
# ── mulligan_rule="none" (legacy) ─────────────────────────────
class TestNoMulliganMode:
def test_no_mulligan_always_keeps_seven(self):
"""With mulligan_rule='none', always get 7 cards, 0 mulligans."""
random.seed(42)
deck = _all_lands_deck()
engine = GameEngine(mulligan_rule="none")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
)
p = sim.players[0]
assert len(p.hand) == 7
assert p.stats.mulligans == 0
# ── N-player mulligan ─────────────────────────────────────────
class TestNPlayerMulligan:
def test_four_player_mulligan(self):
"""Each player in a 4-player game resolves mulligans independently."""
random.seed(42)
decks = [_make_deck() for _ in range(4)]
engine = GameEngine(mulligan_rule="london")
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert len(result.players) == 4
for p in result.players:
assert p.stats.mulligans >= 0
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
# ── Commander + mulligan interaction ──────────────────────────
class TestCommanderMulligan:
def test_commander_stays_in_command_zone_through_mulligans(self):
"""Commander should remain in command zone regardless of mulligans."""
random.seed(42)
# Build a deck with a known commander
deck = _make_deck(lands=24, spells=35)
commander = Card(name="Kenrith, the Returned King", type_line="Creature", cmc=5, pt="5/5")
deck.append(commander)
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
commander_names=["Kenrith, the Returned King"],
)
p = sim.players[0]
assert len(p.command_zone) == 1
assert p.command_zone[0].name == "Kenrith, the Returned King"
assert p.command_zone[0].is_commander is True
# Commander should not appear in hand or library
assert all(not c.is_commander for c in p.hand)
assert all(not c.is_commander for c in p.library)
# ── Serialization round-trip ──────────────────────────────────
class TestMulliganSerialization:
def test_result_dict_includes_mulligans(self):
"""to_dict() output should include mulligan stats."""
import json
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
# Verify JSON-serializable
json.dumps(d)
for pr in d["playerResults"]:
assert "mulligans" in pr["stats"]
assert "openingHandLands" in pr["stats"]
+515
View File
@@ -0,0 +1,515 @@
"""Regression tests for explicit turn-structure and phase progression.
Validates that the headless engine walks through all MTG phases in order,
tracks the current phase on SimState, supports a shared spell budget across
main1+main2, and preserves all prior behavior (combat, elimination, logging).
"""
import random
import pytest
from commander_ai_lab.sim.models import (
Card,
Phase,
PHASE_ORDER,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_deck(seed: int, size: int = 60) -> list[Card]:
"""Deterministic test deck."""
rng = random.Random(seed)
pool = [
Card(name="Forest"),
Card(name="Sol Ring"),
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
]
for c in pool:
enrich_card(c)
deck = [pool[i % len(pool)].clone() for i in range(size)]
rng.shuffle(deck)
return deck
def _land_heavy_deck(size: int = 60) -> list[Card]:
"""Deck with many lands (ensures mana for spells in both main phases)."""
cards = []
for i in range(size):
if i < 30:
c = Card(name="Forest")
elif i < 40:
c = Card(name="Grizzly Bears")
elif i < 50:
c = Card(name="Serra Angel")
else:
c = Card(name="Sol Ring")
enrich_card(c)
cards.append(c)
random.Random(42).shuffle(cards)
return cards
# ══════════════════════════════════════════════════════════════
# Phase Enum Tests
# ══════════════════════════════════════════════════════════════
class TestPhaseEnum:
"""Tests for the Phase enum and related constants."""
def test_phase_count(self):
"""There should be exactly 12 phases in MTG turn structure."""
assert len(Phase) == 12
def test_phase_order_matches_mtg(self):
"""Phases must follow the MTG turn order."""
expected = [
"untap", "upkeep", "draw", "main1",
"begin_combat", "declare_attackers", "declare_blockers",
"combat_damage", "end_combat",
"main2", "end_step", "cleanup",
]
assert [p.value for p in PHASE_ORDER] == expected
def test_phase_is_str_subclass(self):
"""Phase enum values should compare directly with plain strings."""
assert Phase.MAIN1 == "main1"
assert Phase.CLEANUP == "cleanup"
def test_sorcery_phases(self):
"""Only main1 and main2 are sorcery-speed phases."""
assert SORCERY_PHASES == frozenset({Phase.MAIN1, Phase.MAIN2})
def test_phase_iteration(self):
"""PHASE_ORDER is iterable and matches Phase members."""
assert list(PHASE_ORDER) == list(Phase)
# ══════════════════════════════════════════════════════════════
# SimState Phase Tracking
# ══════════════════════════════════════════════════════════════
class TestSimStatePhaseTracking:
"""Tests that SimState tracks phase and active player index."""
def test_default_phase(self):
"""SimState defaults to MAIN1."""
sim = SimState()
assert sim.current_phase == Phase.MAIN1
def test_default_active_player(self):
"""SimState defaults to player 0 as active."""
sim = SimState()
assert sim.active_player_index == 0
def test_phase_updates_during_game(self):
"""After a game, current_phase should have been set (not stuck at default)."""
engine = GameEngine(max_turns=1, mulligan_rule="none")
deck_a = _make_deck(seed=10)
deck_b = _make_deck(seed=11)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Game completed successfully
assert result.turns >= 1
# ══════════════════════════════════════════════════════════════
# Phase Progression in Engine
# ══════════════════════════════════════════════════════════════
class TestPhaseProgression:
"""Tests that the engine walks through phases correctly."""
def test_game_completes_with_phase_structure(self):
"""Game should complete without errors using the new phase loop."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(1)],
deck_b=[c.clone() for c in _make_deck(2)],
name_a="Alice",
name_b="Bob",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_four_player_game_with_phases(self):
"""4-player game should complete with the new phase loop."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i) for i in range(4)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert result is not None
assert result.turns > 0
assert len(result.players) == 4
def test_untap_happens_each_turn(self):
"""Creatures tapped for combat are untapped at start of next turn."""
engine = GameEngine(max_turns=5, record_log=True, mulligan_rule="none")
# Build deck with creatures that will attack
deck = []
for i in range(60):
if i < 25:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Game should complete — untap logic is working
assert result.turns > 0
def test_draw_happens_after_untap(self):
"""Players draw one card each turn after the first."""
engine = GameEngine(max_turns=3, mulligan_rule="none")
deck_a = _make_deck(seed=20, size=60)
deck_b = _make_deck(seed=21, size=60)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Both players should have drawn cards (starting 7 + draws)
for pr in result.players:
assert pr.stats.cards_drawn >= 7
def test_land_drop_in_main1(self):
"""Land drops should still happen (in main1)."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _make_deck(seed=30)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have played lands
total_lands = sum(pr.stats.lands_played for pr in result.players)
assert total_lands > 0
def test_combat_in_declare_attackers_phase(self):
"""Combat damage should still resolve correctly."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Use creature-heavy deck so combat is guaranteed
deck = []
for i in range(60):
if i < 24:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
random.Random(40).shuffle(deck)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
total_damage = sum(pr.stats.damage_dealt for pr in result.players)
# With creature-heavy decks over 25 turns, combat must happen
assert total_damage > 0
# ══════════════════════════════════════════════════════════════
# Spell Budget Across Main Phases
# ══════════════════════════════════════════════════════════════
class TestSpellBudget:
"""Tests that the 2-spell budget is shared across main1 and main2."""
def test_max_two_spells_per_turn(self):
"""No player should cast more than 2 non-commander spells per turn."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _land_heavy_deck()
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Both players played spells
for pr in result.players:
# spells_cast across all turns; with 5 turns max 10 spells each
assert pr.stats.spells_cast <= 10 # 2 per turn * 5 turns
def test_spells_can_split_across_main_phases(self):
"""Engine supports casting in main2 if budget allows.
We verify by checking that _play_spells returns 0 when budget is 0.
"""
engine = GameEngine()
# Create a minimal sim state with one player
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# With no cards in hand, should play 0 spells
result = engine._play_spells(sim, 0, available_mana=5, max_spells=2)
assert result == 0
def test_play_spells_respects_max_spells_zero(self):
"""_play_spells with max_spells=0 plays nothing."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[Card(name="Grizzly Bears", cmc=2, type_line="Creature")],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# Add 5 untapped lands
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=0)
assert result == 0
# Card should still be in hand
assert len(p.hand) == 1
def test_play_spells_respects_max_spells_one(self):
"""_play_spells with max_spells=1 plays at most one spell."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
bear1 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
bear2 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
enrich_card(bear1)
enrich_card(bear2)
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[bear1, bear2],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=1)
assert result == 1
assert len(p.hand) == 1 # one spell remaining
# ══════════════════════════════════════════════════════════════
# Game Log with Phase Structure
# ══════════════════════════════════════════════════════════════
class TestGameLogPhases:
"""Tests that the game log still works with the new phase structure."""
def test_game_log_has_turns(self):
"""record_log should produce a log with turn entries."""
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=50)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
assert result.game_log is not None
assert len(result.game_log) > 0
def test_game_log_turn_structure(self):
"""Each log entry should have turn number and phases list."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=51)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
assert "turn" in entry
assert "phases" in entry
assert isinstance(entry["phases"], list)
def test_game_log_phase_entries(self):
"""Phase entries should have player and events."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=52)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
for phase in entry["phases"]:
assert "player" in phase
assert "playerId" in phase
assert "events" in phase
# ══════════════════════════════════════════════════════════════
# Backward Compatibility
# ══════════════════════════════════════════════════════════════
class TestBackwardCompat:
"""Ensure existing behavior is preserved."""
def test_two_player_still_works(self):
"""The 2-player run() entry point should still work."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(60)],
deck_b=[c.clone() for c in _make_deck(61)],
)
assert result.winner_seat in (-1, 0, 1)
def test_n_player_still_works(self):
"""The N-player run_n() entry point should still work."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i + 70) for i in range(3)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["X", "Y", "Z"],
)
assert result.winner_seat in range(3)
assert len(result.players) == 3
def test_mulligan_rule_still_works(self):
"""London mulligan should still function with the phase refactor."""
engine = GameEngine(max_turns=5, mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(80)],
deck_b=[c.clone() for c in _make_deck(81)],
)
assert result.turns > 0
def test_no_mulligan_rule_still_works(self):
"""Legacy mulligan='none' should still function."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(90)],
deck_b=[c.clone() for c in _make_deck(91)],
)
assert result.turns > 0
def test_commander_damage_still_tracked(self):
"""Commander damage tracking should still work through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
commander = Card(name="TestCommander", is_commander=True)
GameEngine.deal_damage(
sim, 5, target_seat=1,
source_card=commander, source_seat=0,
is_combat=True,
)
assert sim.players[1].commander_damage_received[0] == 5
assert sim.players[1].commander_damage_by_card[(0, "TestCommander")] == 5
assert sim.players[1].life == 35
def test_direct_damage_still_works(self):
"""Burn spells should still route through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
burn = Card(name="Lightning Bolt", is_direct_damage=True, direct_damage_amount=3)
events: list[str] = []
GameEngine.deal_damage(
sim, 3, target_seat=1,
source_card=burn, source_seat=0,
events=events,
label="Lightning Bolt deals 3",
)
assert sim.players[1].life == 37
assert len(events) == 1
def test_elimination_still_works(self):
"""Player elimination should still trigger when life <= 0."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Build aggro-heavy decks
deck = []
for i in range(60):
if i < 20:
deck.append(Card(name="Mountain"))
else:
c = Card(name="Lightning Bolt")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should be eliminated (or game timed out)
assert result.turns > 0
def test_ml_log_still_works(self):
"""ML logging should still capture decisions."""
engine = GameEngine(max_turns=3, ml_log=True, mulligan_rule="none")
deck = _make_deck(seed=100)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
decisions = engine.flush_ml_decisions()
assert isinstance(decisions, list)
# With turns happening, there should be some decisions
if result.turns > 0:
assert len(decisions) > 0
def test_result_serializable(self):
"""Game result should still serialize cleanly."""
import json
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=110)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
json.dumps(d) # should not raise
# ══════════════════════════════════════════════════════════════
# Turn Manager Phase Constants
# ══════════════════════════════════════════════════════════════
class TestTurnManagerPhaseCompat:
"""Ensure turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES are consistent."""
def test_turn_manager_phases_match_model(self):
"""turn_manager.PHASES should match the canonical PHASE_ORDER."""
from commander_ai_lab.sim.turn_manager import PHASES as TM_PHASES
assert TM_PHASES == [p.value for p in PHASE_ORDER]
def test_turn_manager_action_phases(self):
"""ACTION_PHASES should be main1 and main2."""
from commander_ai_lab.sim.turn_manager import ACTION_PHASES
assert ACTION_PHASES == {"main1", "main2"}
def test_turn_manager_response_phases(self):
"""RESPONSE_PHASES should be upkeep, begin_combat, end_combat, end_step."""
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert RESPONSE_PHASES == {"upkeep", "begin_combat", "end_combat", "end_step"}
+134
View File
@@ -0,0 +1,134 @@
"""
tests/test_check_ownership_dedup.py
====================================
Unit tests for duplicate-card deduplication in
DeckGeneratorV3._check_ownership().
Uses an in-memory SQLite database so no real collection DB is required.
"""
from __future__ import annotations
import logging
import sqlite3
from unittest.mock import MagicMock
import pytest
from coach.services.deck_generator import DeckGeneratorV3
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_conn() -> sqlite3.Connection:
"""Return a seeded in-memory DB matching the collection_entries schema."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
scryfall_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
type_line TEXT DEFAULT '',
cmc REAL DEFAULT 0
)
""")
cards = [
("Cultivate", "cult-001", 2, 1.50, "Sorcery", 3),
("Sol Ring", "sol-001", 1, 5.00, "Artifact", 0),
("Kodama's Reach", "kr-001", 1, 0.80, "Sorcery", 3),
]
conn.executemany(
"INSERT INTO collection_entries (name, scryfall_id, quantity, tcg_price, type_line, cmc) "
"VALUES (?, ?, ?, ?, ?, ?)",
cards,
)
conn.commit()
return conn
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
return DeckGeneratorV3(
pplx_client=MagicMock(),
db_conn_factory=lambda: conn,
)
# ── Tests ────────────────────────────────────────────────────────────────────
class TestCheckOwnershipDedup:
def setup_method(self):
self.conn = _make_conn()
self.gen = _make_generator(self.conn)
def test_duplicate_cards_merged(self):
"""Duplicate card names should be merged into a single entry with summed count."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
names = [c.name for c in result]
# No duplicate names (case-insensitive)
assert len(names) == len(set(n.lower() for n in names)), (
f"Duplicate names found in output: {names}"
)
# Only 2 unique cards
assert len(result) == 2
# Merged count should be 2
cultivate = next(c for c in result if c.name.lower() == "cultivate")
assert cultivate.count == 2
def test_case_insensitive_dedup(self):
"""Deduplication should be case-insensitive."""
cards = [
{"name": "cultivate", "count": 1, "category": "Ramp"},
{"name": "CULTIVATE", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].count == 3
def test_warning_logged_on_duplicate(self, caplog):
"""A WARNING log should be emitted when a duplicate is merged."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
with caplog.at_level(logging.WARNING, logger="coach.deckgen"):
self.gen._check_ownership(cards)
assert any("Duplicate" in msg or "merged" in msg for msg in caplog.messages), (
f"Expected WARNING about duplicate/merged, got: {caplog.messages}"
)
def test_no_duplicates_passes_through(self):
"""When there are no duplicates, all cards pass through normally."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Kodama's Reach", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 3
assert all(c.count == 1 for c in result)
def test_empty_name_skipped(self):
"""Cards with empty names should be silently skipped."""
cards = [
{"name": "", "count": 1},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].name == "Sol Ring"
+160
View File
@@ -0,0 +1,160 @@
"""Tests for combat priority checkpoints (Issue #86 Item 1).
Validates CombatState lifecycle, split combat methods,
priority windows, first-strike ordering, and instant-speed
interaction during combat.
"""
import pytest
from commander_ai_lab.sim.models import (
Card, CombatState, Phase, Player, PlayerStats, SimState,
)
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────────
def _make_creature(name, power, toughness, card_id, owner=0, keywords=None, is_commander=False):
"""Build a minimal creature Card for testing."""
return Card(
name=name,
type_line="Creature",
power=str(power),
toughness=str(toughness),
pt=f"{power}/{toughness}",
id=card_id,
owner_id=owner,
keywords=keywords or [],
is_commander=is_commander,
turn_played=-1,
)
def _two_player_sim(p0_creatures, p1_creatures, p0_life=40, p1_life=40):
"""Build a minimal 2-player SimState with creatures on the battlefield."""
sim = SimState(
players=[
Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()),
Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()),
],
battlefields=[list(p0_creatures), list(p1_creatures)],
turn=2,
)
return sim
# ── Tests ───────────────────────────────────────────────────────
class TestCombatState:
"""CombatState dataclass lifecycle."""
def test_combat_state_defaults(self):
cs = CombatState()
assert cs.defending_seat == -1
assert cs.attackers == {}
assert cs.blockers == {}
assert cs.player_damage == {}
assert cs.first_strike_resolved is False
assert cs.active is False
def test_combat_state_on_simstate(self):
sim = SimState()
assert sim.combat is None
sim.combat = CombatState(active=True)
assert sim.combat.active is True
sim.combat = None
assert sim.combat is None
class TestAssignAttackers:
"""Engine.assign_attackers() populates CombatState."""
def test_assigns_and_taps(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [])
engine = GameEngine()
combat = engine.assign_attackers(sim, 0)
assert combat is not None
assert combat.active is True
assert 100 in combat.attackers
assert atk.tapped is True
assert sim.combat is combat
def test_no_attack_returns_none(self):
sim = _two_player_sim([], []) # no creatures
engine = GameEngine()
assert engine.assign_attackers(sim, 0) is None
assert sim.combat is None
class TestResolveCombatDamage:
"""Engine.resolve_combat_damage() applies damage from CombatState."""
def test_unblocked_attacker_deals_damage(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 17 # 20 - 3
def test_attacker_removed_before_damage_deals_nothing(self):
"""Simulates instant-speed removal of attacker after declare attackers."""
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
# Simulate instant-speed removal: remove attacker before damage
sim.remove_from_battlefield(100)
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 20 # no damage dealt
def test_first_strike_only_skips_non_fs(self):
fs = _make_creature("Knight", 2, 2, 200, keywords=["first_strike"])
normal = _make_creature("Bear", 3, 3, 201)
sim = _two_player_sim([fs, normal], [], p1_life=20)
combat = CombatState(
defending_seat=1,
attackers={200: 1, 201: 1},
active=True,
)
sim.combat = combat
engine = GameEngine()
# First-strike step: only the knight deals damage
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=True)
assert sim.players[1].life == 18 # 20 - 2 (knight only)
assert combat.first_strike_resolved is True
# Normal damage step: bear deals damage, knight skipped
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=False)
assert sim.players[1].life == 15 # 18 - 3 (bear only)
def test_combat_state_cleared_after_end_combat(self):
"""Verifies sim.combat is None after combat cleanup."""
sim = SimState()
sim.combat = CombatState(active=True)
# Simulate end-of-combat cleanup
sim.combat = None
assert sim.combat is None
def test_commander_damage_attributed(self):
"""Commander attacker routes damage through commander_damage_by_card."""
cmd = _make_creature("Atraxa", 4, 4, 300, is_commander=True)
sim = _two_player_sim([cmd], [], p1_life=40)
combat = CombatState(defending_seat=1, attackers={300: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 36 # 40 - 4
assert sim.players[1].commander_damage_by_card.get((0, "Atraxa"), 0) == 4
class TestResponsePhasesCleanup:
"""Verify BEGIN_COMBAT and END_COMBAT removed from RESPONSE_PHASES."""
def test_no_duplicate_apnap_begin_end_combat(self):
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert "begin_combat" not in RESPONSE_PHASES
assert "end_combat" not in RESPONSE_PHASES
assert "upkeep" in RESPONSE_PHASES
assert "end_step" in RESPONSE_PHASES
+410
View File
@@ -0,0 +1,410 @@
"""
Commander Damage Regression Tests
==================================
Run with: pytest tests/test_commander_damage.py -v
Covers:
- Commander source identity during combat
- Trample overflow counts as commander damage
- Double strike commander damage
- Partner / multi-commander per-card tracking
- 21-damage lethal check (per-card, not per-seat)
- Commander damage through CommanderGameState helper
- Non-commander creatures do NOT generate commander damage
- Lifelink + commander damage interaction
"""
from __future__ import annotations
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
)
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(
name: str,
power: str = "3",
toughness: str = "3",
keywords: list[str] | None = None,
is_commander: bool = False,
) -> Card:
c = Card(
name=name,
type_line="Legendary Creature" if is_commander else "Creature",
cmc=3,
power=power,
toughness=toughness,
keywords=keywords or [],
is_commander=is_commander,
)
return c
def _make_land(name: str = "Mountain") -> Card:
return Card(name=name, type_line="Basic Land", cmc=0)
def _setup_combat(
attacker_creatures: list[Card],
blocker_creatures: list[Card] | None = None,
attacker_life: int = 40,
defender_life: int = 40,
) -> tuple[GameEngine, SimState, int, int]:
"""Set up a 2-player SimState ready for _resolve_combat.
Returns (engine, sim, attacker_seat, defender_seat).
Attacker is seat 0, defender is seat 1.
All creatures are pre-placed on battlefield, untapped, played on a previous turn.
"""
engine = GameEngine(max_turns=25, starting_life=40, record_log=True)
sim = SimState(max_turns=25)
sim.turn = 5 # so creatures played earlier pass summoning sickness check
p0 = Player(name="Attacker", life=attacker_life, owner_id=0, stats=PlayerStats())
p1 = Player(name="Defender", life=defender_life, owner_id=1, stats=PlayerStats())
sim.players = [p0, p1]
sim.init_battlefields(2)
card_id = 90000
for c in attacker_creatures:
c.owner_id = 0
c.tapped = False
c.turn_played = 1 # played on an earlier turn
c.id = card_id
card_id += 1
sim.add_to_battlefield(0, c)
for c in (blocker_creatures or []):
c.owner_id = 1
c.tapped = False
c.turn_played = 1
c.id = card_id
card_id += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = card_id
return engine, sim, 0, 1
# ── Tests: Basic commander damage tracking ───────────────────
class TestCommanderDamageBasic:
"""Commander creatures attacking unblocked should generate
commander damage tracked on the defending player."""
def test_unblocked_commander_deals_commander_damage(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Life reduced
assert opp.life == 35
# Per-seat aggregate
assert opp.commander_damage_received[pi] == 5
# Per-card breakdown
assert opp.commander_damage_by_card[(pi, "Krenko")] == 5
def test_unblocked_non_commander_no_commander_damage(self):
creature = _make_creature("Goblin", "4", "4", is_commander=False)
engine, sim, pi, oi = _setup_combat([creature])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
assert opp.life == 36
assert opp.commander_damage_received == {}
assert opp.commander_damage_by_card == {}
def test_mixed_attackers_only_commander_tracked(self):
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
grunt = _make_creature("Goblin", "2", "2", is_commander=False)
engine, sim, pi, oi = _setup_combat([cmd, grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 3 + 2 = 5
assert opp.life == 35
# Only commander damage tracked
assert opp.commander_damage_received[pi] == 3
assert opp.commander_damage_by_card[(pi, "Krenko")] == 3
def test_cumulative_commander_damage_across_combats(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
# First combat
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 5
# Reset tapped state for second combat
cmd.tapped = False
sim.turn += 1
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 10
assert sim.players[oi].commander_damage_by_card[(pi, "Krenko")] == 10
# ── Tests: Commander damage lethal (21+) ─────────────────────
class TestCommanderDamageLethal:
def test_21_damage_eliminates(self):
cmd = _make_creature("Krenko", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd], defender_life=100)
# Deal 7 per combat, need 3 combats = 21
for _ in range(3):
cmd.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert opp.commander_damage_received[pi] == 21
assert opp.eliminated
def test_20_damage_not_lethal(self):
"""20 commander damage should NOT eliminate."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 20}
p.commander_damage_by_card = {(0, "Krenko"): 20}
assert not p.is_dead_to_commander_damage()
def test_21_per_card_not_per_seat(self):
"""With partners, 21 must come from a SINGLE commander, not combined."""
p = Player(name="Test", life=40)
# Two partner commanders from seat 0, 11 each = 22 total but neither is 21
p.commander_damage_by_card = {
(0, "Brallin"): 11,
(0, "Shabraz"): 11,
}
p.commander_damage_received = {0: 22}
assert not p.is_dead_to_commander_damage()
def test_21_from_one_partner_is_lethal(self):
"""If one partner reaches 21, that's lethal even if the other has 0."""
p = Player(name="Test", life=40)
p.commander_damage_by_card = {
(0, "Brallin"): 21,
(0, "Shabraz"): 3,
}
p.commander_damage_received = {0: 24}
assert p.is_dead_to_commander_damage()
def test_backward_compat_no_by_card(self):
"""If commander_damage_by_card is empty, fall back to per-seat check."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 21}
# No by_card data (older game state)
assert p.is_dead_to_commander_damage()
# ── Tests: Trample commander damage ──────────────────────────
class TestCommanderDamageTrample:
def test_trample_overflow_is_commander_damage(self):
"""When a commander with trample is chump-blocked, overflow to
the player should count as commander damage."""
cmd = _make_creature("Ghalta", "12", "12", keywords=["trample"], is_commander=True)
# Defender life low enough that AI decides to chump-block
# (opp.life <= a_pow * 2 ⇒ 20 <= 24)
blocker = _make_creature("Bear", "2", "2")
engine, sim, pi, oi = _setup_combat([cmd], [blocker], defender_life=20)
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Bear (2/2) chump-blocks Ghalta (12/12 trample).
# Trample overflow = 12 - 2 = 10 damage to player.
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Ghalta")] == 10
assert opp.life == 10
def test_fully_blocked_commander_no_player_damage(self):
"""Commander fully absorbed by a survivable blocker — no commander
damage to the defending player."""
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
# Blocker toughness > attacker power so AI selects it
blocker = _make_creature("Wall", "0", "5")
engine, sim, pi, oi = _setup_combat([cmd], [blocker])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# No damage to player (absorbed by blocker, no trample)
assert opp.life == 40
assert opp.commander_damage_received == {}
# ── Tests: Double strike commander damage ────────────────────
class TestCommanderDamageDoubleStrike:
def test_unblocked_double_strike_commander(self):
cmd = _make_creature("Rafiq", "5", "5", keywords=["double strike"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Double strike unblocked = power * 2
assert opp.life == 30
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Rafiq")] == 10
# ── Tests: Partner / multi-commander ─────────────────────────
class TestPartnerCommanderDamage:
def test_two_partner_commanders_tracked_separately(self):
cmd_a = _make_creature("Brallin", "4", "4", is_commander=True)
cmd_b = _make_creature("Shabraz", "3", "3", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 4 + 3 = 7
assert opp.life == 33
# Aggregate per-seat
assert opp.commander_damage_received[pi] == 7
# Per-card individual
assert opp.commander_damage_by_card[(pi, "Brallin")] == 4
assert opp.commander_damage_by_card[(pi, "Shabraz")] == 3
def test_partner_21_check_per_card(self):
"""Even with partners, only 21 from ONE commander kills."""
cmd_a = _make_creature("Brallin", "7", "7", is_commander=True)
cmd_b = _make_creature("Shabraz", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b], defender_life=100)
# 3 combats: 7*3=21 for Brallin, 7*3=21 for Shabraz
# After 2 combats: 14 each — not lethal
for _ in range(2):
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert not opp.eliminated # 14 from each, neither is 21
# 3rd combat pushes both to 21
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
assert opp.eliminated
# ── Tests: CommanderGameState integration ────────────────────
class TestCommanderGameStateDamage:
def test_deal_commander_damage_with_name(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10, commander_name="Krenko")
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card[(0, "Krenko")] == 10
assert cp.life == 30
def test_deal_commander_damage_without_name_backward_compat(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10)
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card == {}
assert cp.life == 30
def test_commander_player_property_delegates_to_base(self):
"""CommanderPlayer.commander_damage_received should be the same
object as base.commander_damage_received."""
p = Player(name="Test", life=40)
cp = CommanderPlayer(base=p)
# Write through base
p.commander_damage_received[0] = 15
# Read through wrapper
assert cp.commander_damage_received[0] == 15
# Write through wrapper
cp.commander_damage_received[1] = 7
assert p.commander_damage_received[1] == 7
# ── Tests: Event log annotation ──────────────────────────────
class TestCommanderDamageEvents:
def test_event_log_includes_cmdr_annotation(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
# Should mention commander damage in the event text
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "5 cmdr" in damage_event[0]
def test_no_cmdr_annotation_for_non_commanders(self):
grunt = _make_creature("Goblin", "3", "3", is_commander=False)
engine, sim, pi, oi = _setup_combat([grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "cmdr" not in damage_event[0]
# ── Tests: Lifelink + commander interaction ──────────────────
class TestCommanderDamageLifelink:
def test_commander_with_lifelink_heals_and_tracks(self):
cmd = _make_creature("Oloro", "5", "5", keywords=["lifelink"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
attacker = sim.players[pi]
opp = sim.players[oi]
# Commander damage tracked
assert opp.commander_damage_received[pi] == 5
# Lifelink healed attacker
assert attacker.life == 45
# Defender took damage
assert opp.life == 35
# ── Tests: Player model ─────────────────────────────────────
class TestPlayerCommanderDamageModel:
def test_fresh_player_has_empty_tracking(self):
p = Player(name="Test")
assert p.commander_damage_received == {}
assert p.commander_damage_by_card == {}
assert not p.is_dead_to_commander_damage()
def test_is_dead_prefers_by_card(self):
"""When by_card data exists, per-seat aggregate is ignored for lethal check."""
p = Player(name="Test")
p.commander_damage_received = {0: 25} # aggregate says lethal
p.commander_damage_by_card = {
(0, "CmdA"): 12,
(0, "CmdB"): 13,
}
# Per-card: neither commander has 21 → NOT lethal
assert not p.is_dead_to_commander_damage()
+2 -2
View File
@@ -92,9 +92,9 @@ class TestCommanderPlayer:
def test_commander_damage_death(self):
cp = CommanderPlayer(base=Player(name="Test"))
cp.commander_damage_received = {0: 20, 1: 5}
cp.base.commander_damage_received = {0: 20, 1: 5}
assert not cp.is_dead_to_commander_damage()
cp.commander_damage_received[0] = 21
cp.base.commander_damage_received[0] = 21
assert cp.is_dead_to_commander_damage()
def test_to_dict_json_serializable(self):