Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd94435993 | ||
|
|
dff7ae2860 | ||
|
|
56e5bff496 | ||
|
|
62463dfa4a | ||
|
|
7e0aba78ba | ||
|
|
7036c9568f | ||
|
|
b37968e3c2 | ||
|
|
d424ccb74c | ||
|
|
1cf2439a5a | ||
|
|
f3bd188a8b | ||
|
|
c1ad9e953c | ||
|
|
25e064d265 | ||
|
|
2b6d4dcdb1 | ||
|
|
eee773b6f9 | ||
|
|
2eae7dcbd5 | ||
|
|
0f8d1382e0 | ||
|
|
b72d5fba83 | ||
|
|
5d34d07bb8 | ||
|
|
5e9685ba70 | ||
|
|
49fc817187 | ||
|
|
c2b3d937a4 | ||
|
|
5e9c568bcc | ||
|
|
85aa15445b | ||
|
|
e265b1e15b | ||
|
|
ae8d14c19e | ||
|
|
f090a36f79 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-16
@@ -22,15 +22,10 @@ except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
setup_logging,
|
||||
init_collection_db,
|
||||
download_precon_database,
|
||||
load_commander_meta,
|
||||
PRECON_INDEX,
|
||||
COMMANDER_META,
|
||||
)
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta
|
||||
from services.database import init_collection_db
|
||||
from services.logging import setup_logging
|
||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||
from routes.collection import router as collection_router
|
||||
from routes.deckbuilder import router as deckbuilder_router
|
||||
from routes.precon import router as precon_router
|
||||
@@ -80,15 +75,14 @@ async def health_check():
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||
import routes.shared as _shared
|
||||
# Ensure precon_dir is resolved even when started via uvicorn directly
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
_shared.init_collection_db()
|
||||
if not _shared.PRECON_INDEX:
|
||||
_shared.download_precon_database()
|
||||
if not _shared.COMMANDER_META:
|
||||
_shared.load_commander_meta()
|
||||
init_collection_db()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
@@ -288,7 +282,7 @@ def main():
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
|
||||
from routes.shared import get_java17
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
|
||||
log.info("Commander AI Lab — API Server v3.0.0")
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
routes package — API routers for Commander AI Lab.
|
||||
|
||||
Each module defines a FastAPI APIRouter with real endpoint implementations.
|
||||
All shared helpers, models, and state live in routes.shared.
|
||||
Shared helpers live in services/, models live in models/.
|
||||
"""
|
||||
+4
-6
@@ -33,12 +33,10 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
log_coach, log_deckgen,
|
||||
_get_db_conn,
|
||||
_compute_deck_analysis,
|
||||
)
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import _compute_deck_analysis
|
||||
from services.logging import log_coach, log_deckgen
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
|
||||
+15
-24
@@ -25,30 +25,21 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
_get_db_conn,
|
||||
_row_to_dict,
|
||||
_add_image_url,
|
||||
_build_collection_filters,
|
||||
_detect_card_roles,
|
||||
_classify_card_type,
|
||||
_enrich_from_scryfall,
|
||||
_fetch_scryfall_api,
|
||||
_parse_finish,
|
||||
_parse_text_line,
|
||||
_parse_csv_content,
|
||||
_auto_infer_mapping,
|
||||
_scryfall_cache,
|
||||
_scryfall_rate_limit,
|
||||
_snake_to_camel,
|
||||
VALID_SORT_FIELDS,
|
||||
_JSON_FIELDS,
|
||||
SCRYFALL_CACHE_DB_PATH,
|
||||
SCRYFALL_CACHE_TTL_SECONDS,
|
||||
_API_HEADERS,
|
||||
log_collect,
|
||||
log_cache,
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import (
|
||||
_get_db_conn, _row_to_dict, _add_image_url, _build_collection_filters,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS,
|
||||
)
|
||||
from services.deck_service import _classify_card_type
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _parse_csv_content, _auto_infer_mapping,
|
||||
)
|
||||
from services.logging import log_collect, log_cache
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_scryfall_cache, _scryfall_rate_limit,
|
||||
_enrich_from_scryfall, _fetch_scryfall_api,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["collection"])
|
||||
|
||||
+18
-28
@@ -36,35 +36,25 @@ from urllib.parse import quote
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
_get_db_conn,
|
||||
_row_to_dict,
|
||||
_add_image_url,
|
||||
_get_deck_or_404,
|
||||
_compute_deck_analysis,
|
||||
_check_ratio_limit,
|
||||
_classify_card_type,
|
||||
_detect_card_roles,
|
||||
_enrich_from_scryfall,
|
||||
_fetch_scryfall_api,
|
||||
_scryfall_rate_limit,
|
||||
_to_edhrec_slug,
|
||||
_edhrec_cache_get,
|
||||
_edhrec_cache_set,
|
||||
_fetch_edhrec_average,
|
||||
from models.state import CFG
|
||||
from models.requests import (
|
||||
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
|
||||
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
|
||||
)
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict, _add_image_url
|
||||
from services.deck_service import (
|
||||
_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_classify_card_type, _to_edhrec_slug, _TYPE_TARGETS,
|
||||
_save_profile_to_dck,
|
||||
_parse_text_decklist,
|
||||
_API_HEADERS,
|
||||
_TYPE_TARGETS,
|
||||
CreateDeckRequest,
|
||||
UpdateDeckRequest,
|
||||
AddDeckCardRequest,
|
||||
PatchDeckCardRequest,
|
||||
BulkAddRequest,
|
||||
BulkAddRecommendedRequest,
|
||||
log_collect,
|
||||
log_deckgen,
|
||||
)
|
||||
from services.import_helpers import (
|
||||
_fetch_edhrec_average, _parse_text_decklist,
|
||||
_edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
from services.logging import log_collect, log_deckgen
|
||||
from services.scryfall import (
|
||||
_API_HEADERS, _enrich_from_scryfall, _fetch_scryfall_api, _scryfall_rate_limit,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["deckbuilder"])
|
||||
|
||||
+7
-8
@@ -33,17 +33,16 @@ import httpx
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
|
||||
|
||||
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
log_deckgen, log_pplx,
|
||||
_get_db_conn,
|
||||
_scryfall_rate_limit, _API_HEADERS,
|
||||
from models.state import CFG
|
||||
from models.requests import (
|
||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
GeneratedDeckCard, DeckGenerationSourceConfig,
|
||||
DeckGenerationSourceConfig, DeckResearchRequest, DeckGenerateAIRequest,
|
||||
)
|
||||
from models.responses import GeneratedDeckCard
|
||||
from services.database import _get_db_conn
|
||||
from services.logging import log_deckgen, log_pplx
|
||||
from services.scryfall import _scryfall_rate_limit, _API_HEADERS
|
||||
from services.deck_service import _write_dck_file, _build_dck_lines
|
||||
|
||||
router = APIRouter(tags=["deckgen"])
|
||||
|
||||
+6
-8
@@ -28,12 +28,10 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG, log_sim,
|
||||
_get_db_conn,
|
||||
_get_deepseek_brain,
|
||||
_ml_logging_enabled,
|
||||
)
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.forge_runner import _get_deepseek_brain
|
||||
from services.logging import log_sim
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,9 +54,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
|
||||
|
||||
+7
-11
@@ -15,17 +15,13 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import routes.shared as _shared
|
||||
from routes.shared import (
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
_import_from_url,
|
||||
_parse_text_decklist,
|
||||
_save_profile_to_dck,
|
||||
from models.state import COMMANDER_META
|
||||
from models.requests import ImportUrlRequest, ImportTextRequest, MetaFetchRequest
|
||||
from services.import_helpers import (
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
_fetch_edhrec_average,
|
||||
log,
|
||||
)
|
||||
from services.logging import log
|
||||
|
||||
router = APIRouter(tags=["import"])
|
||||
|
||||
@@ -74,7 +70,7 @@ async def import_from_text(req: ImportTextRequest):
|
||||
async def list_meta_commanders():
|
||||
"""List available commanders in the meta mapping."""
|
||||
commanders = []
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
for name, entries in COMMANDER_META.items():
|
||||
entry = entries[0] if entries else {}
|
||||
commanders.append({
|
||||
"name": name,
|
||||
@@ -90,7 +86,7 @@ async def search_meta_commanders(q: str = ""):
|
||||
"""Search commanders by partial name match."""
|
||||
query = q.lower()
|
||||
matches = []
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
for name, entries in COMMANDER_META.items():
|
||||
if query in name.lower():
|
||||
entry = entries[0] if entries else {}
|
||||
matches.append({
|
||||
|
||||
+8
-16
@@ -31,23 +31,15 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
active_batches,
|
||||
BatchState,
|
||||
StartRequest,
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
AI_PROFILES,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
parse_dck_file,
|
||||
_get_db_conn,
|
||||
_load_deck_cards_by_name,
|
||||
log,
|
||||
log_batch,
|
||||
from models.state import CFG, active_batches, BatchState, AI_PROFILES
|
||||
from models.requests import StartRequest
|
||||
from models.responses import StartResponse, StatusResponse
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||
from services.forge_runner import (
|
||||
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
|
||||
)
|
||||
from services.logging import log, log_batch
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
|
||||
+6
-10
@@ -32,8 +32,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
|
||||
from routes.shared import CFG, _ml_logging_enabled
|
||||
|
||||
from models.state import CFG
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
router = APIRouter(tags=["ml"])
|
||||
@@ -93,9 +92,6 @@ _ppo_lock = threading.Lock()
|
||||
_tournament_state = _TournamentState()
|
||||
_tournament_lock = threading.Lock()
|
||||
|
||||
# module-level toggle (local copy; the shared one is for batch runs)
|
||||
_ml_logging_enabled_local = _ml_logging_enabled
|
||||
|
||||
|
||||
def _get_policy_service():
|
||||
"""Get or initialize the policy inference service (lazy loading)."""
|
||||
@@ -121,7 +117,7 @@ def _get_policy_service():
|
||||
@router.get("/api/ml/status")
|
||||
async def ml_status():
|
||||
"""Get ML decision logging status and available training data."""
|
||||
import routes.shared as _shared
|
||||
import services.logging as _logging_mod
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = lab_root / CFG.results_dir
|
||||
ml_files = []
|
||||
@@ -140,7 +136,7 @@ async def ml_status():
|
||||
})
|
||||
total_decisions = sum(f["decisions"] for f in ml_files)
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
|
||||
"training_files": ml_files,
|
||||
"total_decisions": total_decisions,
|
||||
"total_files": len(ml_files),
|
||||
@@ -150,10 +146,10 @@ async def ml_status():
|
||||
@router.post("/api/ml/toggle")
|
||||
async def ml_toggle(enable: bool = True):
|
||||
"""Enable or disable ML decision logging for future batch runs."""
|
||||
import routes.shared as _shared
|
||||
_shared._ml_logging_enabled = enable
|
||||
import services.logging as _logging_mod
|
||||
_logging_mod._ml_logging_enabled = enable
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
|
||||
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -15,9 +15,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import routes.shared as _shared # module reference, not value import
|
||||
from routes.shared import CFG, download_precon_database, log
|
||||
from services.precon_service import _get_precon_dir
|
||||
from models.state import CFG
|
||||
from services.precon_service import download_precon_database, PRECON_INDEX, _get_precon_dir
|
||||
from services.logging import log
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
|
||||
@@ -25,7 +25,7 @@ router = APIRouter(tags=["precon"])
|
||||
@router.get("/api/lab/precons")
|
||||
async def list_precons():
|
||||
"""List all available precon decks."""
|
||||
return {"precons": _shared.PRECON_INDEX} # reads current value every call
|
||||
return {"precons": PRECON_INDEX}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install")
|
||||
|
||||
+7
-7
@@ -22,13 +22,13 @@ from urllib.error import URLError
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
|
||||
from routes.shared import (
|
||||
CFG, log_scan, log_deckgen,
|
||||
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
|
||||
_scryfall_fuzzy_lookup, _detect_card_roles,
|
||||
_to_edhrec_slug, _API_HEADERS,
|
||||
_edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.import_helpers import _edhrec_cache_get, _edhrec_cache_set
|
||||
from services.logging import log_scan, log_deckgen
|
||||
from services.scryfall import _API_HEADERS, _enrich_from_scryfall, _scryfall_fuzzy_lookup
|
||||
|
||||
router = APIRouter(tags=["scanner"])
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
routes/shared.py
|
||||
================
|
||||
Single source of truth for everything every router needs.
|
||||
|
||||
Contains:
|
||||
- Config class + CFG singleton
|
||||
- Logging setup + all named loggers
|
||||
- FastAPI app instance
|
||||
- In-memory state (active_batches, COMMANDER_META, PRECON_INDEX, AI_PROFILES)
|
||||
- All Pydantic request/response models
|
||||
- SQLite DB helpers (_get_db_conn, init_collection_db)
|
||||
- Scryfall cache + enrichment helpers
|
||||
- Collection helpers (_detect_card_roles, _classify_card_type,
|
||||
_build_collection_filters, _parse_finish, _parse_csv_content,
|
||||
_parse_text_line, _auto_infer_mapping, _row_to_dict, _add_image_url,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS)
|
||||
- Deck helpers (_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_TYPE_TARGETS, _TYPE_PRIORITY, _classify_card_type)
|
||||
- Import helpers (_import_from_url, _fetch_archidekt_deck,
|
||||
_fetch_edhrec_average, _parse_text_decklist, _save_profile_to_dck,
|
||||
_to_edhrec_slug, _http_get, _API_HEADERS, _edhrec_cache_get,
|
||||
_edhrec_cache_set)
|
||||
- Precon helpers (PRECON_DIR, PRECON_INDEX, download_precon_database,
|
||||
load_precon_index, GITHUB_PRECON_URL, PRECON_CACHE_HOURS)
|
||||
- Sim helpers (build_java_command, get_java17, BatchState, parse_dck_file,
|
||||
AI_PROFILES, run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _load_deck_cards_by_name, _get_deepseek_brain)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Configuration (imported from models/state)
|
||||
from models.state import Config, CFG
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Logging Setup
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
if root_logger.handlers:
|
||||
return
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach")
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner")
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache")
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# In-Memory State (imported from models/state)
|
||||
from models.state import (
|
||||
BatchState, active_batches, COMMANDER_META,
|
||||
BUILTIN_COMMANDERS, load_commander_meta,
|
||||
)
|
||||
|
||||
|
||||
# Pydantic Models (imported from models/)
|
||||
from models.requests import (
|
||||
StartRequest, ImportUrlRequest, ImportTextRequest, MetaFetchRequest,
|
||||
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
|
||||
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig, DeckGenerationRequest,
|
||||
DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse, StatusResponse, DeckInfo, GeneratedDeckCard,
|
||||
)
|
||||
|
||||
|
||||
# Collection Database (imported from services/database.py)
|
||||
from services.database import (
|
||||
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
|
||||
_JSON_FIELDS, VALID_SORT_FIELDS,
|
||||
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
|
||||
)
|
||||
# Card Role Detection (imported from services/card_analysis.py)
|
||||
from services.card_analysis import _detect_card_roles
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Helpers (imported from services/deck_service.py)
|
||||
from services.deck_service import (
|
||||
_TYPE_PRIORITY, _TYPE_TARGETS, _classify_card_type, _get_deck_or_404,
|
||||
_compute_deck_analysis, _check_ratio_limit, _to_edhrec_slug,
|
||||
parse_dck_file, _save_profile_to_dck, _load_deck_cards_by_name,
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache (imported from services/scryfall.py)
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_ScryfallCache, _scryfall_cache, _scryfall_lock, _scryfall_last_call,
|
||||
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Import Helpers (extracted to services/import_helpers.py)
|
||||
from services.import_helpers import (
|
||||
_http_get, _fetch_archidekt_deck, _fetch_edhrec_average,
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
_edhrec_cache, _EDHREC_CACHE_TTL, _edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers (extracted to services/import_service.py)
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Precon Helpers (extracted to services/precon_service.py)
|
||||
from services.precon_service import (
|
||||
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
||||
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
|
||||
_get_precon_dir,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles (imported from models/state)
|
||||
from models.state import AI_PROFILES
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Java / Sim Helpers (extracted to services/forge_runner.py)
|
||||
from services.forge_runner import (
|
||||
_find_java17, get_java17, build_java_command,
|
||||
run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
||||
)
|
||||
|
||||
_ml_logging_enabled = True
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
services/logging.py
|
||||
====================
|
||||
Single source of truth for application logging configuration and named loggers.
|
||||
|
||||
Moved from routes/shared.py so that every layer (routes, services, CLI
|
||||
entry-points) can import logging without pulling in the full shared module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Format / rotation constants ───────────────────────────────────────────────
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
if root_logger.handlers:
|
||||
return
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
# ── Named loggers ────────────────────────────────────────────────────────────
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach")
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner")
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache")
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx")
|
||||
|
||||
# ── ML logging toggle ────────────────────────────────────────────────────────
|
||||
_ml_logging_enabled = True
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+499
-129
@@ -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)
|
||||
@@ -343,53 +471,6 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_seat: int,
|
||||
*,
|
||||
source_is_commander: bool = False,
|
||||
is_combat: bool = False,
|
||||
) -> int:
|
||||
"""Apply damage to a player through a single centralized path.
|
||||
|
||||
Handles:
|
||||
- Life total reduction
|
||||
- Commander damage tracking (when source_is_commander=True)
|
||||
- Per-player damage stats
|
||||
- Elimination by life<=0 or commander damage>=21
|
||||
|
||||
Returns the amount of damage actually applied (always ``amount``
|
||||
unless the target is already eliminated, in which case 0).
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
target = sim.players[target_seat]
|
||||
if target.eliminated:
|
||||
return 0
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
|
||||
if source_is_commander:
|
||||
prev = target.commander_damage_received.get(source_seat, 0)
|
||||
target.commander_damage_received[source_seat] = prev + amount
|
||||
|
||||
# Elimination checks: life<=0 OR 21+ commander damage from one source
|
||||
if target.life <= 0:
|
||||
target.eliminated = True
|
||||
elif any(v >= 21 for v in target.commander_damage_received.values()):
|
||||
target.eliminated = True
|
||||
|
||||
return amount
|
||||
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
@@ -401,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(
|
||||
@@ -409,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():
|
||||
@@ -484,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():
|
||||
@@ -529,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:
|
||||
@@ -603,13 +753,11 @@ class GameEngine:
|
||||
if events is not None and attackers:
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
|
||||
# Accumulate per-attacker player-damage buckets so we can route
|
||||
# commander vs non-commander damage through _apply_damage separately.
|
||||
commander_damage = 0 # damage dealt by commander creatures
|
||||
normal_damage = 0 # damage dealt by non-commander creatures
|
||||
total_damage = 0
|
||||
used_blockers: set[int] = set()
|
||||
combat_details: list[str] = []
|
||||
lifelink_total = 0 # lifelink healing for the attacker
|
||||
# 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()
|
||||
@@ -658,53 +806,275 @@ class GameEngine:
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
if atk.is_commander:
|
||||
commander_damage += trample_over
|
||||
else:
|
||||
normal_damage += trample_over
|
||||
attacker_damage.append((atk, trample_over))
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
|
||||
if atk.is_commander:
|
||||
commander_damage += ds_dmg
|
||||
else:
|
||||
normal_damage += ds_dmg
|
||||
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"):
|
||||
damage_dealt *= 2
|
||||
lifelink_total += damage_dealt
|
||||
p.life += damage_dealt
|
||||
if not blocked:
|
||||
if atk.has_keyword("double strike"):
|
||||
atk_dmg = a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
else:
|
||||
atk_dmg = a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += atk_dmg
|
||||
else:
|
||||
normal_damage += atk_dmg
|
||||
if atk.has_keyword("lifelink"):
|
||||
lifelink_total += atk_dmg
|
||||
|
||||
# Route all player damage through the centralized path.
|
||||
# Commander damage is applied first (tracks 21-damage rule).
|
||||
total_damage = 0
|
||||
if commander_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, commander_damage, opp_idx, pi,
|
||||
source_is_commander=True, is_combat=True,
|
||||
)
|
||||
if normal_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, normal_damage, opp_idx, pi,
|
||||
source_is_commander=False, is_combat=True,
|
||||
)
|
||||
# Apply lifelink healing
|
||||
if lifelink_total > 0:
|
||||
p.life += lifelink_total
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
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)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# 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,
|
||||
sim: SimState,
|
||||
|
||||
@@ -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())
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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,13 +226,29 @@ class Player:
|
||||
# p.commander_tax[name] = tax + 2
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
# Commander damage received from each opponent seat.
|
||||
# Keyed by opponent seat index; value is cumulative damage.
|
||||
# A player is eliminated when any single value reaches 21.
|
||||
# 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:
|
||||
@@ -174,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 ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
"""
|
||||
Regression tests for centralized commander damage tracking.
|
||||
============================================================
|
||||
Covers:
|
||||
- _apply_damage centralized path (life, stats, commander tracking, elimination)
|
||||
- Combat damage routing through _apply_damage (commander vs non-commander)
|
||||
- Commander damage 21-lethal elimination rule
|
||||
- Non-combat commander damage (direct _apply_damage calls)
|
||||
- Multi-seat / partner commander edge cases
|
||||
|
||||
Run with: pytest tests/sim/test_commander_damage.py -v
|
||||
"""
|
||||
|
||||
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_sim(num_players: int = 2, starting_life: int = 40) -> SimState:
|
||||
"""Create a minimal SimState with empty battlefields."""
|
||||
sim = SimState(max_turns=25)
|
||||
for i in range(num_players):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}",
|
||||
life=starting_life,
|
||||
owner_id=i,
|
||||
stats=PlayerStats(cards_drawn=0),
|
||||
))
|
||||
sim.init_battlefields(num_players)
|
||||
return sim
|
||||
|
||||
|
||||
def _creature(name: str, power: int, toughness: int, **kwargs) -> Card:
|
||||
"""Create a creature card with given stats."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
pt=f"{power}/{toughness}",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
**kwargs,
|
||||
)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _land(name: str = "Forest") -> Card:
|
||||
c = Card(name=name, type_line="Basic Land — Forest", cmc=0)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _place_on_battlefield(sim: SimState, seat: int, card: Card) -> Card:
|
||||
"""Place a card onto a seat's battlefield with a unique id."""
|
||||
card.owner_id = seat
|
||||
card.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
card.turn_played = -1 # played in a previous turn (not summoning sick)
|
||||
sim.add_to_battlefield(seat, card)
|
||||
return card
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _apply_damage unit tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestApplyDamage:
|
||||
"""Tests for the centralized _apply_damage static method."""
|
||||
|
||||
def test_basic_damage_reduces_life(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert applied == 5
|
||||
assert sim.players[1].life == 35
|
||||
assert sim.players[1].stats.damage_received == 5
|
||||
assert sim.players[0].stats.damage_dealt == 5
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 0, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, -3, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_damage_to_eliminated_player_is_noop(self):
|
||||
sim = _make_sim()
|
||||
sim.players[1].eliminated = True
|
||||
applied = GameEngine._apply_damage(sim, 10, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40 # unchanged
|
||||
|
||||
def test_lethal_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 40, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == 0
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_overkill_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 50, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == -10
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_non_commander_damage_does_not_track_commander_damage(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_commander_damage_tracks_per_seat(self):
|
||||
sim = _make_sim(num_players=3)
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 5, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 7, 1: 5}
|
||||
assert sim.players[2].life == 40 - 7 - 5
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 8, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 18
|
||||
|
||||
def test_21_commander_damage_eliminates(self):
|
||||
"""Commander damage rule: 21+ from a single source eliminates."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100 # high life — shouldn't save them
|
||||
GameEngine._apply_damage(
|
||||
sim, 21, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life == 79 # life was reduced but still positive
|
||||
|
||||
def test_incremental_commander_damage_to_21_eliminates(self):
|
||||
"""Gradual commander damage reaching 21 triggers elimination."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
for _ in range(3):
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_split_commander_damage_from_two_sources_no_elimination(self):
|
||||
"""20 commander damage from two different sources should NOT eliminate."""
|
||||
sim = _make_sim(num_players=3)
|
||||
sim.players[2].life = 100
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 10, 1: 10}
|
||||
assert sim.players[2].eliminated is False
|
||||
|
||||
def test_stats_update_on_both_players(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 12, target_seat=1, source_seat=0)
|
||||
assert sim.players[0].stats.damage_dealt == 12
|
||||
assert sim.players[1].stats.damage_received == 12
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Combat damage routing through _apply_damage
|
||||
# ===========================================================================
|
||||
|
||||
class TestCombatCommanderDamageRouting:
|
||||
"""Verify _resolve_combat routes commander creature damage correctly."""
|
||||
|
||||
def _setup_combat(self, *, commander_power=5, defender_life=40):
|
||||
"""Set up a 2-player sim with one commander attacker and no blockers."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2, starting_life=defender_life)
|
||||
# Attacker: a commander creature
|
||||
cmd = _creature("Prossh", commander_power, 5, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
# Defender: only lands (no blockers)
|
||||
for _ in range(3):
|
||||
_place_on_battlefield(sim, 1, _land())
|
||||
return engine, sim
|
||||
|
||||
def test_unblocked_commander_tracks_damage(self):
|
||||
engine, sim = self._setup_combat(commander_power=5)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 5
|
||||
assert sim.players[1].life == 35
|
||||
|
||||
def test_unblocked_noncommander_does_not_track(self):
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
# Non-commander attacker
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
assert sim.players[1].life == 38
|
||||
|
||||
def test_commander_combat_21_eliminates(self):
|
||||
"""Commander dealing 21+ combat damage in one hit should eliminate."""
|
||||
engine, sim = self._setup_combat(commander_power=21, defender_life=100)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
|
||||
def test_mixed_commander_and_noncommander_attackers(self):
|
||||
"""Commander + non-commander attack together; only commander damage tracked."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
cmd = _creature("Prossh", 3, 3, is_commander=True)
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Total damage: 3 + 2 = 5
|
||||
assert sim.players[1].life == 35
|
||||
# Only commander damage tracked
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 3
|
||||
|
||||
def test_commander_trample_over_blocker_tracks_damage(self):
|
||||
"""Trample damage from a commander going over a blocker should track."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
# Low life forces the AI to assign a blocker via the
|
||||
# "opp.life <= a_pow * 2" heuristic even when toughness < power.
|
||||
sim = _make_sim(num_players=2, starting_life=10)
|
||||
cmd = _creature("Prossh", 7, 7, is_commander=True, keywords=["trample"])
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
blocker = _creature("Wall", 0, 3)
|
||||
_place_on_battlefield(sim, 1, blocker)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Trample over: 7 - 3 = 4 damage to player (all from commander)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 4
|
||||
assert sim.players[1].life == 6
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Non-combat commander damage (direct _apply_damage usage)
|
||||
# ===========================================================================
|
||||
|
||||
class TestNonCombatCommanderDamage:
|
||||
"""
|
||||
Validates that _apply_damage correctly handles non-combat commander damage
|
||||
scenarios. These represent cases like commander abilities, ETB triggers,
|
||||
or spell effects that deal damage where the source is a commander.
|
||||
"""
|
||||
|
||||
def test_direct_commander_ability_damage(self):
|
||||
"""Simulates a commander dealing direct damage (e.g. Purphoros trigger)."""
|
||||
sim = _make_sim(num_players=4)
|
||||
# Commander at seat 0 deals 2 to each opponent via ability
|
||||
for target in [1, 2, 3]:
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=target, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
for target in [1, 2, 3]:
|
||||
assert sim.players[target].commander_damage_received[0] == 2
|
||||
assert sim.players[target].life == 38
|
||||
|
||||
def test_repeated_noncombat_commander_damage_reaches_21(self):
|
||||
"""Repeated non-combat commander triggers accumulate to lethal 21."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
# 11 triggers of 2 damage each = 22 total
|
||||
for _ in range(11):
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 22
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_noncombat_noncommander_spell_damage(self):
|
||||
"""Non-commander spell damage: reduces life, no commander tracking."""
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 3, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].life == 37
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_mixed_combat_and_noncombat_commander_damage(self):
|
||||
"""Both combat and non-combat commander damage should accumulate together."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
sim.players[1].life = 100
|
||||
|
||||
# Non-combat commander damage: 15
|
||||
GameEngine._apply_damage(
|
||||
sim, 15, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 15
|
||||
assert sim.players[1].eliminated is False
|
||||
|
||||
# Combat commander damage: 6 (pushes to 21)
|
||||
cmd = _creature("Prossh", 6, 6, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Full game integration
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullGameCommanderDamage:
|
||||
"""Integration test: run a full game with commanders and verify tracking."""
|
||||
|
||||
def _make_commander_deck(self, commander_name: str, seed: int = 42) -> list[Card]:
|
||||
"""Build a small test deck with a named commander."""
|
||||
import random
|
||||
rng = random.Random(seed)
|
||||
cards = []
|
||||
# Commander card
|
||||
cmd = Card(
|
||||
name=commander_name,
|
||||
type_line="Legendary Creature",
|
||||
cmc=5,
|
||||
pt="5/5",
|
||||
power="5",
|
||||
toughness="5",
|
||||
)
|
||||
cards.append(enrich_card(cmd))
|
||||
# Fill with lands and creatures
|
||||
for i in range(24):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Forest_{i}",
|
||||
type_line="Basic Land — Forest",
|
||||
cmc=0,
|
||||
)))
|
||||
for i in range(35):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Bear_{i}",
|
||||
type_line="Creature — Bear",
|
||||
cmc=2,
|
||||
pt="2/2",
|
||||
power="2",
|
||||
toughness="2",
|
||||
)))
|
||||
rng.shuffle(cards)
|
||||
return cards
|
||||
|
||||
def test_commander_damage_tracked_in_full_game(self):
|
||||
"""In a full game with commanders, commander_damage_received is populated."""
|
||||
engine = GameEngine(max_turns=15)
|
||||
deck_a = self._make_commander_deck("Prossh, Skyraider of Kher", seed=1)
|
||||
deck_b = self._make_commander_deck("Kaalia of the Vast", seed=2)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
commander_names=["Prossh, Skyraider of Kher", "Kaalia of the Vast"],
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
# The game completed without errors — commander damage tracking is wired
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user