Compare commits

..
Author SHA1 Message Date
KoalaTrapLord 34e88a3a14 fix: correct indentation of commander_keywords and oracle overlap scoring in deckgen.py 2026-03-25 08:45:51 -04:00
KoalaTrapLord f199bcfa34 feat: column visibility toggle UI for collection table 2026-03-25 08:31:12 -04:00
KoalaTrapLord 77c92d569d fix: correct indentation of elif/else branches in _run_sim_thread_deepseek 2026-03-25 08:24:21 -04:00
KoalaTrapLord f24bd05be8 feat: collection stats dashboard (CMC curve, color/type/rarity/value) 2026-03-25 07:51:09 -04:00
KoalaTrapLord e7ffccf3e8 feat: persist coach sessions to SQLite with disk fallback 2026-03-24 23:44:20 -04:00
KoalaTrapLord 64a65ca2f6 feat: auto-fill deck name with Auto - {CommanderName} on select 2026-03-24 23:19:17 -04:00
KoalaTrapLord 3ba44f68d9 feat: oracle-text keyword overlap scoring in _generate_deck 2026-03-24 22:56:30 -04:00
KoalaTrapLord f851df6dbf fix: add getBaseUrl(), PolicyDecision record, and decide(String,int) to PolicyClient
GameSession.java references these APIs which were missing from
PolicyClient, causing 3 compilation errors. Added:
- getBaseUrl() getter
- PolicyDecision record (action, confidence)
- decide(String, int) overload that delegates to decide(JsonObject)
2026-03-24 22:46:45 -04:00
KoalaTrapLord ded79bfde1 feat: add omit_cards chips styles to deck generator CSS 2026-03-24 22:20:10 -04:00
KoalaTrapLord 411e40e46e feat: add omit_cards chips panel to deck generator HTML 2026-03-24 22:17:25 -04:00
KoalaTrapLord a4e49daf49 feat: add omit_cards chips/tags input to deck generator JS 2026-03-24 22:11:36 -04:00
KoalaTrapLord 675c64029b feat: add progress bar + ETA to deck generation UI 2026-03-24 21:20:47 -04:00
KoalaTrapLord 4c47b9d5e2 fix: add V3 result cache to avoid re-generating deck on export 2026-03-24 21:07:59 -04:00
KoalaTrapLord 8903432d67 fix: add missing export filter params (rarity, setCode, power, toughness, keyword, edhrec, qty) 2026-03-24 21:01:35 -04:00
KoalaTrapLord b1e473397f fix: shopping list field name + scan-add refresh function name 2026-03-24 20:53:19 -04:00
KoalaTrapLord 07b2c9d5a9 fix: add missing LLM_TIMEOUT constant in coach.py
LLM_TIMEOUT was referenced but never defined, causing a NameError
in the Ollama streaming SSE code path when COACH_PROVIDER != 'perplexity'.
2026-03-24 20:43:36 -04:00
KoalaTrapLord 58bbb514ad fix: skip ML decision flush when brain not connected (heuristic fallback guard) 2026-03-24 20:27:02 -04:00
KoalaTrapLord d70d74d753 feat: add Scryfall on-the-fly embedding fallback for unknown cards 2026-03-24 20:09:27 -04:00
KoalaTrapLord 475849e477 fix: prevent double-flush of ML decisions in deepseek sim thread
_run_sim_thread_deepseek was manually calling engine.flush_ml_decisions()
and writing the .jsonl, then passing engine=engine to _finish_sim which
called flush_ml_decisions() a second time — returning an empty list and
producing empty training data files.

Fix: pass engine=None to _finish_sim when the deepseek thread has already
flushed manually, so _finish_sim skips the second flush entirely.
2026-03-24 19:19:45 -04:00
KoalaTrapLord b2d1078ca9 fix: disable quiet mode when ML logging is enabled in createWorker
DecisionExtractor requires verbose Forge output to reconstruct board
states. When quiet=true, Forge runs with -q which strips the verbose
log lines, causing 0 decisions to be extracted. This change passes
quiet=false to BatchRunner when mlLogEnabled is true.
2026-03-24 18:45:36 -04:00
KoalaTrapLord 9bce919690 fix: replace non-existent ForgeEpisodeGenerator import with actual exports 2026-03-24 13:45:48 -04:00
KoalaTrapLord 41b711c17e fix: normalize deck report slug matching (spaces/hyphens/underscores) 2026-03-24 13:31:52 -04:00
KoalaTrapLord a6940ca61a fix: update get_status() to check Perplexity connection when COACH_PROVIDER=perplexity 2026-03-24 13:29:16 -04:00
KoalaTrapLord a65daaf801 fix: update Deck Coach status label from LM Studio to Perplexity 2026-03-24 13:26:41 -04:00
KoalaTrapLord c475f5998b docs: add COACH_PROVIDER to .env.example 2026-03-24 12:54:30 -04:00
KoalaTrapLord 456ef927f9 feat: add Perplexity deep research support to coach chat endpoint 2026-03-24 12:53:42 -04:00
KoalaTrapLord a515049542 feat: add COACH_PROVIDER config for Perplexity-powered coach chat 2026-03-24 12:50:21 -04:00
KoalaTrapLord 457e6f91c8 docs: add PPLX_MODEL to .env.example 2026-03-24 12:47:58 -04:00
KoalaTrapLord 977dbd96d7 feat: use PPLX_MODEL config instead of hardcoded 'sonar' in deckgen 2026-03-24 12:47:09 -04:00
KoalaTrapLord 5a499442e6 feat: add PPLX_MODEL env var for configurable Perplexity model 2026-03-24 12:45:40 -04:00
KoalaTrapLord 1f3b17d173 fix: remove ForgeEpisodeQueue import from training __init__
ForgeEpisodeQueue does not exist in rollout_buffer.py yet.
Removing premature import to unblock supervised training pipeline.
2026-03-24 12:26:06 -04:00
KoalaTrapLord f51ff61568 feat(83): Phase 3 — Performance & Scale (#83) (#101)
* feat(83): Phase 3.1 — persistent JVM worker pool in MultiThreadBatchRunner (#83)

* feat(83): Phase 3.2 — async Forge episode generator with producer-consumer pipeline (#83)

* feat(83): Phase 3.3 — deprecate self_play.py synthetic mode for training (#83)

Mark self_play.py synthetic training as deprecated per Phase 3.3:
- Add deprecation notice to module docstring
- Add import warnings and _TRAINING_DEPRECATION_MSG constant
- Add DeprecationWarning in collect_rollouts()
- Synthetic sim retained for local testing/unit tests only
- scope.py already defaults forge_weight=1.0, ppo_weight=0.0

* feat(83): Phase 3.3 — update ml/training/__init__.py exports (#83)
2026-03-24 10:43:51 -04:00
KoalaTrapLord 39bc36afef rename: update JsonExporter.java javadoc example from batch- to ml-decision- 2026-03-23 23:10:44 -04:00
KoalaTrapLord dffd89a01c rename: update LabCli.java default output from batch-latest to ml-decision-latest 2026-03-23 23:09:51 -04:00
KoalaTrapLord 015b9da1e9 rename: update forge_runner.py file prefix from batch- to ml-decision- 2026-03-23 23:06:33 -04:00
KoalaTrapLord f9007effa3 rename: update report_generator.py glob from batch- to ml-decision- 2026-03-23 23:05:25 -04:00
KoalaTrapLord 29ad996966 rename: batch-sim- to ml-decision-sim- prefix in deepseek.py 2026-03-23 23:04:13 -04:00
KoalaTrapLord 51b4e9b5ff rename: batch result files from batch- to ml-decision- prefix in lab.py 2026-03-23 23:00:24 -04:00
KoalaTrapLord 573a271561 fix: use correct query param format for batch status endpoint in overnight-batch.py
The /api/lab/status endpoint uses ?batchId= query param, not path param /{batch_id}
2026-03-23 22:19:21 -04:00
KoalaTrapLord 411fa41063 feat(83): Phase 2 — Live Forge-to-Policy-Server IPC (#100)
* feat(83): add PolicyClient for live Forge-to-policy-server IPC (#83)

Phase 2.1: Bridge between Forge decision points and Python policy
server for online training. Calls /api/policy/decide endpoint,
collects (state, action, reward) tuples in real-time, and flushes
them to the server for online PPO updates.

Falls back to Forge built-in AI if server is unavailable.

* feat(83): add /api/policy/* routes for live Forge IPC (#83)

Phase 2.2: Policy decision routes for real-time Forge IPC.

- POST /api/policy/decide — full game state inference with log_prob/value
- POST /api/policy/collect — online learning tuple batch collection
- POST /api/policy/reward — end-of-game reward propagation
- GET /api/policy/health — readiness check for Java PolicyClient
- GET /api/policy/stats — session statistics

Includes Forge phase mapping and PlayerZoneState with full zone contents.

* feat(83): integrate PolicyClient into GameSession for bidirectional sync (#83)
2026-03-23 22:08:37 -04:00
87404f3702 refactor: move logging out of routes/shared.py into services/logging.py (#99)
Create services/logging.py as the canonical home for setup_logging(),
all log format constants, named loggers, and _ml_logging_enabled.

Update all route modules and lab_api.py to import logging symbols from
services.logging and all other symbols from their canonical sources
(models.state, models.requests, models.responses, services.database,
services.deck_service, services.scryfall, services.import_helpers,
services.import_service, services.precon_service, services.forge_runner).

Delete routes/shared.py — the re-export hub is no longer needed.

Closes #61

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:04:25 -04:00
KoalaTrapLordandClaude Code f0dfb41f2f fix: default Ollama endpoints to localhost instead of 192.168.0.240 (#98)
The hardcoded 192.168.0.240 IP was a developer-specific LAN address
that breaks out-of-the-box for anyone running Ollama locally or via
WSL2 on Windows. Switch every default and docstring reference to
localhost so the project works without manual configuration.

Files changed:
- coach/config.py: LLM_URL and DECK_GEN_BASE_URL defaults
- ui/simulator.html: API endpoint input value and placeholder
- lab_api.py: remove LAN IP from default CORS origins
- routes/coach.py: warning message text
- src/commander_ai_lab/sim/deepseek_brain.py: docstrings
- src/commander_ai_lab/sim/deepseek_engine.py: docstring example
- src/commander_ai_lab/sim/validator_brain.py: docstring example

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-23 21:03:17 -04:00
56e5bff496 fix: correct indentation of last_err in DeepSeekBrain.choose_action() (#97)
`last_err = None` was at module-level indentation (4 spaces) instead of
method-body indentation (8 spaces), causing an IndentationError on import.

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

Now logs available models without changing the configured model.
Fixes issue #95 item 1.
2026-03-23 10:37:39 -04:00
2b6d4dcdb1 fix: deduplicate LLM duplicate cards in _check_ownership (#78) (#79)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:42:33 -04:00
eee773b6f9 Forge Card Enrichment — Full Implementation (Issue #80) (#81)
* feat(issue-80): Phase 1 — add services/forge_card_loader.py

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

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

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

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

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

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

---------

Co-authored-by: Claude Code <claude-code@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:37:05 -04:00
2eae7dcbd5 fix(ci): remove frontend job that references non-existent directory (#94)
The CI workflow had a `frontend` job that tried to run npm ci/build/test
in a `frontend/` directory which does not exist in this repo. The actual
UI lives in `ui/` as static HTML/CSS/JS (no Node.js toolchain). This
caused every CI run to fail on an otherwise valid codebase.

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

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

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

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

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

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

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

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

Partial fix for Issue #86 item 2.

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 22:04:19 -04:00
KoalaTrapLord ae8d14c19e chore: point deepseek brain at deepseek-r1:8b on localhost 2026-03-22 21:49:45 -04:00
KoalaTrapLord f090a36f79 chore: switch deck builder default model to qwen2.5:7b 2026-03-22 21:40:26 -04:00
cf4e2981e6 fix: resolve 78 test failures via shared stub cleanup and dependent fixes (#85) (#88)
Centralize and enhance test stubs to implement the full CommanderGameState
interface (battlefield(), living_players(), stack_is_empty(), sim_state,
active_player(), priority_player(), to_dict()). Fix all dependent test
failures across 12 files:

- conftest.py: Enhanced StubCard/StubPlayer/StubGameState with full API
  compatibility; added basic_creature fixture; fixed fresh_app singleton leak
- test_phase3.py: Added battlefield(), active_player(), prompt_builder compat
- test_integration.py: Fixed ThreatAssessor import (class→function), added
  threat_fn dict wrapper, sim_state delegate
- test_phase6.py/test_phase8.py: Replaced deprecated get_event_loop() with
  asyncio.run()
- test_benchmark.py: Same async fix + ThreatAssessor functional API
- sim/test_engine.py: Removed nonexistent seed= param, use run() method
- sim/test_models.py: basic_creature fixture now in conftest
- deck_builder/test_models.py: Updated ValidationError regex match
- state_logger.py: Added monotonic counter for unique snapshot filenames
- ai_opponent.py: Moved _CANNED dict to module-level constant (dataclass fix)
- web/app.py: Mount lobby router (fixes 6 LobbyAPI test failures)
- test_phase5.py: SessionStore + api._store reset fixture

Result: 76 failures + 2 errors → 0 failures, 0 errors (331 passed, 30 skipped)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:32:30 -04:00
d795e1a8fd feat: Phase 5 architecture follow-up — zone boundary, WAL store, Forge-only defaults (#87)
Implements the three approved Phase 5 architecture decisions:

1. Zone embedding boundary: add explicit documentation in StateDimensions
   and StateEncoder._encode_zones making the 6144-dim zero-padded zone
   contract visible to future maintainers.

2. SQLite WAL online learning store: new OnlineLearningStore class backed
   by WAL-mode SQLite for concurrent read/write on single-machine baseline.
   New /api/policy/decide endpoint collects decisions during inference.
   Stats endpoint at /api/policy/decide/stats.

3. Forge-only default weights: change ppo_weight default from 0.5 to 0.0
   across scope.py, DistillationConfig, DatasetConfig, and CLI defaults.
   Add MIXED_MODE_PRESETS dict with forge_only, forge_90_10, forge_80_20
   presets. Distill/start endpoint now accepts a "preset" parameter.

27 new tests covering all three decisions (all passing).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:41:33 -04:00
KoalaTrapLord 01d49e78a5 Fix coach cards-like: replace missing image_url column with scryfall_id URL builder 2026-03-22 13:06:36 -04:00
80 changed files with 7436 additions and 1709 deletions

No files matched your search

+4
View File
@@ -9,6 +9,10 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
PPLX_MODEL=sonar-pro
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
COACH_PROVIDER=local
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
-20
View File
@@ -33,26 +33,6 @@ jobs:
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
+96 -11
View File
@@ -9,6 +9,8 @@ calls the LLM, parses responses, and persists coaching sessions.
import json
import logging
import uuid
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
@@ -16,6 +18,7 @@ from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
COACH_PROVIDER, PPLX_MODEL,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
)
from .models import (
@@ -28,6 +31,18 @@ from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
# SQLite persistence for coach sessions
try:
from services.database import (
save_coach_session as _db_save_session,
load_coach_session as _db_load_session,
list_coach_sessions as _db_list_sessions,
delete_coach_session as _db_delete_session,
)
_DB_AVAILABLE = True
except ImportError:
_DB_AVAILABLE = False
class CoachService:
"""
@@ -55,7 +70,7 @@ class CoachService:
if not report_path.exists():
# Try case-insensitive match
for f in DECK_REPORTS_DIR.glob("*.json"):
if f.stem.lower() == deck_id.lower():
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
report_path = f
break
@@ -80,15 +95,62 @@ class CoachService:
# ── Coach Session I/O ──────────────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk."""
"""Persist a coaching session to disk and SQLite."""
# 1. Write JSON file (backward compat)
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
# 2. Write to SQLite
if _DB_AVAILABLE:
try:
_db_save_session({
"session_id": session.sessionId,
"deck_id": session.deckId,
"timestamp": session.timestamp,
"model_used": session.modelUsed or "",
"prompt_tokens": session.promptTokens or 0,
"completion_tokens": session.completionTokens or 0,
"summary": session.summary or "",
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
"mana_base_advice": session.manaBaseAdvice or "",
"raw_text": session.rawTextExplanation or "",
})
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
except Exception as e:
logger.warning("Failed to persist session to SQLite: %s", e)
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from disk."""
"""Load a coaching session from SQLite, falling back to disk."""
# Try SQLite first
if _DB_AVAILABLE:
try:
row = _db_load_session(session_id)
if row:
data = dict(row)
return CoachSession(
sessionId=data["session_id"],
deckId=data["deck_id"],
timestamp=data["timestamp"],
modelUsed=data.get("model_used", ""),
promptTokens=data.get("prompt_tokens", 0),
completionTokens=data.get("completion_tokens", 0),
summary=data.get("summary", ""),
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
manaBaseAdvice=data.get("mana_base_advice", ""),
rawTextExplanation=data.get("raw_text", ""),
)
except Exception as e:
logger.warning("SQLite load failed for %s: %s", session_id, e)
# Fallback to disk
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
@@ -100,8 +162,18 @@ class CoachService:
logger.error("Failed to load session %s: %s", session_id, e)
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List all coaching sessions, optionally filtered by deck."""
"""List coaching sessions from SQLite, falling back to disk."""
# Try SQLite first
if _DB_AVAILABLE:
try:
rows = _db_list_sessions(deck_id)
if rows is not None:
return rows
except Exception as e:
logger.warning("SQLite list_sessions failed: %s", e)
# Fallback to disk
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
@@ -123,6 +195,7 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
async def run_coaching_session(self, deck_id: str,
@@ -265,13 +338,25 @@ class CoachService:
"""Check the health of all coach subsystems."""
status = CoachStatus()
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
# Check LLM / Perplexity provider
if COACH_PROVIDER == "perplexity":
pplx_key = os.environ.get("PPLX_API_KEY", "")
if pplx_key:
status.llmConnected = True
status.llmModel = f"Perplexity ({PPLX_MODEL})"
status.llmModels = [PPLX_MODEL]
else:
status.llmConnected = False
status.llmModel = None
status.llmModels = []
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
else:
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "Local LLM not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
+6 -2
View File
@@ -13,7 +13,7 @@ from pathlib import Path
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
@@ -44,7 +44,11 @@ DEFAULT_MAX_TOKENS = 8192
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
+237 -41
View File
@@ -9,6 +9,11 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
embedded on-the-fly using the same model, and persisted to a local
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
restarts without re-hitting Scryfall.
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
@@ -16,9 +21,11 @@ import json
import logging
import re
import os
import time
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from urllib.parse import quote
import numpy as np
@@ -32,8 +39,16 @@ HF_PARQUET_URL = (
"resolve/main/mtg_embeddings.parquet"
)
# ── Scryfall API ───────────────────────────────────────────
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
# ── Custom cache for on-the-fly embedded cards ─────────────
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
@@ -46,7 +61,6 @@ def _parse_color_identity(mana_cost: str) -> str:
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
@@ -61,12 +75,11 @@ def _parse_mana_value(mana_cost: str) -> float:
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass # X = 0 for CMC purposes
pass
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
@@ -75,7 +88,42 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
return card_set.issubset(deck_set) or len(card_set) == 0
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
"""Fetch card data from Scryfall by fuzzy name match.
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
color_identity. Returns None on any error.
"""
url = SCRYFALL_NAMED_URL.format(quote(card_name))
try:
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {
"name": data.get("name", card_name),
"mana_cost": data.get("mana_cost", ""),
"type_line": data.get("type_line", ""),
"oracle_text": data.get("oracle_text", ""),
"color_identity": "".join(data.get("color_identity", [])),
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
return None
def _build_card_text(card: dict) -> str:
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
parts = [card["name"]]
if card.get("mana_cost"):
parts.append(card["mana_cost"])
if card.get("type_line"):
parts.append(card["type_line"])
if card.get("oracle_text"):
parts.append(card["oracle_text"])
return " | ".join(parts)
class CardMatch:
@@ -114,15 +162,17 @@ class MTGEmbeddingIndex:
"""
def __init__(self):
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self.names: np.ndarray = None
self.colors: np.ndarray = None
self.mana_values: np.ndarray = None
self.types: np.ndarray = None
self.mana_costs: np.ndarray = None
self.texts: np.ndarray = None
self.vectors: np.ndarray = None
self._name_to_idx: dict = {}
self._loaded = False
self._sentence_model = None # lazy-loaded only when needed
self._custom_dirty = False # tracks whether custom cache needs saving
@property
def loaded(self) -> bool:
@@ -152,10 +202,7 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
"""Convert the Parquet file to a compact NPZ for fast loading."""
try:
import pandas as pd
except ImportError:
@@ -169,14 +216,11 @@ class MTGEmbeddingIndex:
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
@@ -198,25 +242,27 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""
Load embeddings from NPZ (fast) or fall back to downloading
"""Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
return self._load_npz()
ok = self._load_npz()
else:
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
ok = self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
if ok:
self._load_custom_cache()
return ok
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
@@ -230,7 +276,6 @@ class MTGEmbeddingIndex:
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
@@ -241,12 +286,169 @@ class MTGEmbeddingIndex:
logger.error("Failed to load NPZ: %s", e)
return False
# ── Custom cache ──────────────────────────────────────────
def _load_custom_cache(self) -> None:
"""Merge on-the-fly embedded cards from the custom cache into the index."""
if not CUSTOM_CACHE_PATH.exists():
return
try:
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
c_names = data["names"]
# Only add cards that aren't already in the main index
new_mask = np.array(
[n.lower() not in self._name_to_idx for n in c_names]
)
if not new_mask.any():
return
self._append_cards(
c_names[new_mask],
data["colors"][new_mask],
data["mana_values"][new_mask],
data["types"][new_mask],
data["mana_costs"][new_mask],
data["texts"][new_mask],
data["vectors"][new_mask],
)
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
except Exception as e:
logger.warning("Failed to load custom embedding cache: %s", e)
def _save_custom_cache(self) -> None:
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
if not self._custom_dirty:
return
try:
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
# Collect indices that correspond to custom-added cards
# We track these by loading the base count at load time — simpler:
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
# exists, merging with any new cards we've added this session.
existing_names: set = set()
existing: dict = {}
if CUSTOM_CACHE_PATH.exists():
try:
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
for n in d["names"]:
existing_names.add(n.lower())
existing = dict(d)
except Exception:
pass
# Find names in our live index that aren't in the base NPZ
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
base_names = set(n.lower() for n in base_data["names"])
custom_mask = np.array(
[n.lower() not in base_names for n in self.names]
)
if not custom_mask.any():
return
np.savez_compressed(
str(CUSTOM_CACHE_PATH),
names=self.names[custom_mask],
colors=self.colors[custom_mask],
mana_values=self.mana_values[custom_mask],
types=self.types[custom_mask],
mana_costs=self.mana_costs[custom_mask],
texts=self.texts[custom_mask],
vectors=self.vectors[custom_mask],
)
self._custom_dirty = False
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
except Exception as e:
logger.warning("Failed to save custom embedding cache: %s", e)
def _append_cards(
self,
names: np.ndarray,
colors: np.ndarray,
mana_values: np.ndarray,
types: np.ndarray,
mana_costs: np.ndarray,
texts: np.ndarray,
vectors: np.ndarray,
) -> None:
"""Append new card arrays to the live in-memory index."""
start_idx = len(self.names)
self.names = np.concatenate([self.names, names])
self.colors = np.concatenate([self.colors, colors])
self.mana_values = np.concatenate([self.mana_values, mana_values])
self.types = np.concatenate([self.types, types])
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
self.texts = np.concatenate([self.texts, texts])
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
for i, name in enumerate(names):
self._name_to_idx[name.lower()] = start_idx + i
# ── Sentence model (lazy) ─────────────────────────────────
def _get_sentence_model(self):
"""Lazy-load the sentence-transformers model on first cache miss."""
if self._sentence_model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
logger.info("Embedding model loaded.")
except Exception as e:
logger.error("Failed to load SentenceTransformer: %s", e)
return None
return self._sentence_model
# ── On-the-fly Scryfall fallback ──────────────────────────
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
Returns the L2-normalized embedding vector on success, None on failure.
"""
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
card_data = _fetch_scryfall_card(card_name)
if card_data is None:
return None
model = self._get_sentence_model()
if model is None:
return None
text = _build_card_text(card_data)
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
# Ensure shape (1, 768)
vec = vec.reshape(1, -1)
resolved_name = card_data["name"]
mana_cost = card_data.get("mana_cost", "")
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
self._append_cards(
names=np.array([resolved_name]),
colors=np.array([color_identity]),
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
types=np.array([card_data.get("type_line", "")]),
mana_costs=np.array([mana_cost]),
texts=np.array([card_data.get("oracle_text", "")]),
vectors=vec,
)
self._custom_dirty = True
self._save_custom_cache()
logger.info("Embedded and cached new card: '%s'", resolved_name)
return self.vectors[self._name_to_idx[resolved_name.lower()]]
# ── Public API ────────────────────────────────────────────
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive)."""
"""Get the embedding vector for a card by name (case-insensitive).
On a cache miss, fetches from Scryfall and embeds on the fly.
"""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
return None
# Cache miss — try Scryfall fallback
return self._fetch_and_embed_card(card_name)
def search_similar(self, query_card: str,
color_filter: List[str] = None,
@@ -274,38 +476,32 @@ class MTGEmbeddingIndex:
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings: %s", query_card)
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
+2 -2
View File
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("batch-*.json"))
batch_files = sorted(results_path.glob("ml-decision-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("batch-*.json")):
for bf in sorted(results_dir.glob("ml-decision-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
+25
View File
@@ -256,6 +256,19 @@ class DeckGeneratorV3:
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary safety-net deduplication
seen = {}
deduped = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
@@ -284,15 +297,26 @@ class DeckGeneratorV3:
"""
Cross-reference generated cards against the collection DB.
Sets owned/scryfall_id/owned_qty and real price.
Deduplicates cards by name (case-insensitive) — if the LLM
returns the same card twice, the counts are merged.
"""
conn = self.db_conn_factory()
enriched = []
seen_names: dict[str, int] = {} # name_lower → index in enriched
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
@@ -318,6 +342,7 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
+11 -17
View File
@@ -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
@@ -45,7 +40,7 @@ from routes.ml import router as ml_router
log = logging.getLogger("commander_ai_lab.api")
# ── CORS origins (env-configurable) ────────────────────────────
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080,http://192.168.0.240:5173,http://192.168.0.240:8080"
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
@@ -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")
+49 -3
View File
@@ -155,15 +155,25 @@ class StateDimensions:
2 players = 28 global features
+ turn_number = 1
Total global = 29
Zone embedding boundary (architectural decision — Phase 5 follow-up):
Zone dims are **always** 768 per zone slot, zero-padded when a zone
is empty (no cards). The fixed 6144-dim zone block is the contract
between ForgeEpisodeGenerator (upstream) and the policy network
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
dataset builder) depend on this shape. If a future change adds
attention pooling or variable-length zone representations, the
boundary must be updated here *and* in StateEncoder._encode_zones.
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
# This is a fixed-size block; empty zones produce zero vectors.
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
@@ -268,6 +278,10 @@ class DistillationDefaults:
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
The baseline trains exclusively on Forge simulation data. Use
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
"""
max_iterations: int = 10
convergence_window: int = 3
@@ -281,9 +295,9 @@ class DistillationDefaults:
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
forge_weight: float = 1.0
ppo_weight: float = 0.5
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# Quality gate thresholds
@@ -302,3 +316,35 @@ class DistillationDefaults:
DISTILLATION_DEFAULTS = DistillationDefaults()
# ══════════════════════════════════════════════════════════
# Mixed-Mode Presets (Phase 5 follow-up)
# ══════════════════════════════════════════════════════════
# Pre-defined Forge/PPO weight combinations for common scenarios.
# Select a preset by name when starting a distillation run to
# override the Forge-only defaults without specifying raw weights.
#
# "forge_only" — baseline: 100% Forge data, ignore PPO
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
}
def get_preset_weights(preset_name: str) -> Dict[str, float]:
"""Look up a mixed-mode preset by name.
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
Raises ``KeyError`` if the preset name is unknown.
"""
if preset_name not in MIXED_MODE_PRESETS:
raise KeyError(
f"Unknown preset '{preset_name}'. "
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
)
return dict(MIXED_MODE_PRESETS[preset_name])
+6 -6
View File
@@ -63,10 +63,10 @@ class DatasetConfig:
max_samples: Optional[int] = None
# Source weights control sampling ratios between Forge and PPO data.
# e.g. {"forge": 1.0, "ppo": 0.5} means keep all Forge data but
# randomly sample 50% of PPO data to avoid overwhelming the baseline.
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.5}
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
)
# Minimum reward threshold for PPO data — only include decisions
@@ -209,7 +209,7 @@ def build_dataset(
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.5}
source_weights = {"forge": 1.0, "ppo": 0.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
@@ -502,8 +502,8 @@ def main():
help="Sampling weight for Forge sim data (default: 1.0)",
)
parser.add_argument(
"--ppo-weight", type=float, default=0.5,
help="Sampling weight for PPO self-play data (default: 0.5)",
"--ppo-weight", type=float, default=0.0,
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
+9
View File
@@ -276,6 +276,13 @@ class StateEncoder:
"""
Encode zone contents as mean-pooled card embeddings.
Shape: (4 zones × 2 players × 768) = (6144,)
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
(768) floats. Empty zones produce a zero vector of that size. This
fixed-size contract is relied upon by every downstream consumer
(PolicyNetwork, PPOTrainer, dataset builder). See
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
definition.
"""
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
vectors = []
@@ -283,6 +290,8 @@ class StateEncoder:
for p in players[:2]:
for zone_key in zone_keys:
cards = p.get(zone_key, [])
# mean_pool_zone returns a zero vector when cards is empty,
# preserving the fixed 768-dim slot size.
pooled = self.card_index.mean_pool_zone(cards)
vectors.append(pooled)
+210
View File
@@ -0,0 +1,210 @@
"""
Commander AI Lab — Online Learning Store (SQLite WAL)
═════════════════════════════════════════════════════
Single-machine backing store for online learning data collected during
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
Each prediction request can optionally log the state snapshot and the
chosen action so that future training runs can incorporate online
decision data alongside Forge and PPO batches.
Design decisions (Phase 5 architecture follow-up):
- SQLite in WAL mode for concurrent readers + single writer, which is
the right fit for a single-machine baseline (one API server process).
- Separate DB file (``online_learning.db``) to avoid contention with
the collection database.
- Append-only writes; reads are batched by the dataset builder.
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
and WAL's built-in reader/writer concurrency.
Usage::
store = OnlineLearningStore() # uses default path
store.init_db()
# At prediction time:
store.record_decision(snapshot_json, action_index, confidence, playstyle)
# At training time:
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
store.mark_exported(max_rowid)
"""
import json
import logging
import os
import sqlite3
import threading
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("ml.serving.online_store")
_DEFAULT_DB_PATH = str(
Path(__file__).parent.parent.parent / "online_learning.db"
)
class OnlineLearningStore:
"""SQLite WAL-backed store for online policy decision collection."""
def __init__(self, db_path: str = None):
self.db_path = db_path or _DEFAULT_DB_PATH
self._local = threading.local()
# ------------------------------------------------------------------
# Connection management
# ------------------------------------------------------------------
def _get_conn(self) -> sqlite3.Connection:
"""Return a thread-local connection with WAL mode enabled."""
if not hasattr(self._local, "conn") or self._local.conn is None:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
# Enable WAL for concurrent read/write on single machine.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
self._local.conn = conn
return self._local.conn
def close(self) -> None:
"""Close the thread-local connection."""
conn = getattr(self._local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
self._local.conn = None
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def init_db(self) -> None:
"""Create the decisions table if it doesn't exist."""
conn = self._get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS online_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot TEXT NOT NULL,
action_idx INTEGER NOT NULL,
confidence REAL NOT NULL DEFAULT 0.0,
playstyle TEXT NOT NULL DEFAULT 'midrange',
temperature REAL NOT NULL DEFAULT 1.0,
greedy INTEGER NOT NULL DEFAULT 0,
exported INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_od_exported
ON online_decisions(exported);
CREATE INDEX IF NOT EXISTS idx_od_created
ON online_decisions(created_at);
""")
conn.commit()
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
# ------------------------------------------------------------------
# Write path (called during prediction)
# ------------------------------------------------------------------
def record_decision(
self,
snapshot: Dict,
action_idx: int,
confidence: float,
playstyle: str = "midrange",
temperature: float = 1.0,
greedy: bool = False,
) -> int:
"""Append a policy decision for future training.
Returns the row id of the inserted record.
"""
conn = self._get_conn()
cur = conn.execute(
"""
INSERT INTO online_decisions
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
json.dumps(snapshot, separators=(",", ":")),
action_idx,
confidence,
playstyle,
temperature,
1 if greedy else 0,
),
)
conn.commit()
return cur.lastrowid
# ------------------------------------------------------------------
# Read path (called by dataset builder / training pipeline)
# ------------------------------------------------------------------
def fetch_decisions(
self,
since_rowid: int = 0,
limit: int = 50000,
only_unexported: bool = True,
) -> List[Dict]:
"""Fetch collected decisions for training.
Args:
since_rowid: Only return rows with id > this value.
limit: Max rows to return.
only_unexported: If True, skip rows already marked exported.
Returns:
List of dicts with keys: id, snapshot (parsed), action_idx,
confidence, playstyle, temperature, greedy, created_at.
"""
conn = self._get_conn()
clauses = ["id > ?"]
params: list = [since_rowid]
if only_unexported:
clauses.append("exported = 0")
where = " AND ".join(clauses)
rows = conn.execute(
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
(*params, limit),
).fetchall()
results = []
for row in rows:
d = dict(row)
try:
d["snapshot"] = json.loads(d["snapshot"])
except (json.JSONDecodeError, TypeError):
pass
results.append(d)
return results
def mark_exported(self, up_to_rowid: int) -> int:
"""Mark decisions as exported so they aren't fetched again.
Returns the number of rows updated.
"""
conn = self._get_conn()
cur = conn.execute(
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
(up_to_rowid,),
)
conn.commit()
return cur.rowcount
def count(self, only_unexported: bool = False) -> int:
"""Return the total number of stored decisions."""
conn = self._get_conn()
if only_unexported:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions"
).fetchone()
return row[0]
+40
View File
@@ -0,0 +1,40 @@
"""
Commander AI Lab - Training Subpackage
==========================================
Provides RL training infrastructure including PPO, rollout buffers,
Forge episode generation, self-play, and distillation.
"""
from ml.training.rollout_buffer import RolloutBuffer
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
from ml.training.decision_exporter import DecisionExporter
from ml.training.forge_episode_generator import (
ForgeTransition,
ForgeEpisode,
ForgeEpisodeQueue,
forge_producer,
ppo_consumer,
run_forge_pipeline,
parse_forge_jsonl,
snapshots_to_episode,
)
__all__ = [
# Rollout & Buffers
"RolloutBuffer",
# Rewards
"compute_intermediate_reward",
"compute_terminal_reward",
"RewardConfig",
# Decision export
"DecisionExporter",
# Forge episode generation (Phase 3)
"ForgeTransition",
"ForgeEpisode",
"ForgeEpisodeQueue",
"forge_producer",
"ppo_consumer",
"run_forge_pipeline",
"parse_forge_jsonl",
"snapshots_to_episode",
]
+3 -3
View File
@@ -84,9 +84,9 @@ class DistillationConfig:
opponent: str = "heuristic"
playstyle: str = "midrange"
# --- Dataset building ---
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
forge_weight: float = 1.0
ppo_weight: float = 0.5
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# --- Quality gate ---
@@ -858,7 +858,7 @@ def main():
# Dataset
parser.add_argument("--forge-weight", type=float, default=1.0)
parser.add_argument("--ppo-weight", type=float, default=0.5)
parser.add_argument("--ppo-weight", type=float, default=0.0)
parser.add_argument("--min-reward", type=float, default=0.0)
# Quality gate
+352
View File
@@ -0,0 +1,352 @@
"""
Commander AI Lab — Forge Episode Generator (Phase 3.2)
Async producer-consumer pipeline: runs Forge batch games in parallel
with PPO training. Forge produces episodes → shared queue → PPO
trainer consumes.
Uses the existing watchdog (_global_watchdog_loop in forge_runner.py)
to monitor Forge worker processes for stalls.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional, Tuple
import numpy as np
from ml.config.scope import (
ACTION_TO_IDX,
GAME_SCOPE,
IDX_TO_ACTION,
MacroAction,
NUM_ACTIONS,
STATE_DIMS,
)
from ml.training.reward import (
RewardConfig,
compute_intermediate_reward,
compute_terminal_reward,
)
logger = logging.getLogger("ml.forge_episode_generator")
# ══════════════════════════════════════════════════════════
# Episode / Trajectory data structures
# ══════════════════════════════════════════════════════════
@dataclass
class ForgeTransition:
"""Single (s, a, r, log_prob, value, done) tuple from a Forge game."""
state: np.ndarray
action: int
reward: float
log_prob: float
value: float
done: bool
@dataclass
class ForgeEpisode:
"""Complete trajectory from a single Forge game."""
game_id: str
transitions: List[ForgeTransition] = field(default_factory=list)
winner: Optional[int] = None
total_turns: int = 0
deck_names: List[str] = field(default_factory=list)
@property
def length(self) -> int:
return len(self.transitions)
@property
def total_reward(self) -> float:
return sum(t.reward for t in self.transitions)
# ══════════════════════════════════════════════════════════
# JSONL Parser — reads Forge ML decision snapshots
# ══════════════════════════════════════════════════════════
def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
"""Parse a JSONL file of Forge ML decision snapshots.
Each line is a JSON object with keys like:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
snapshots.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning("Skipping malformed line %d in %s: %s", lineno, path, e)
logger.info("Parsed %d snapshots from %s", len(snapshots), path)
return snapshots
def snapshots_to_episode(
snapshots: List[Dict[str, Any]],
reward_config: Optional[RewardConfig] = None,
) -> ForgeEpisode:
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
Maps Forge action labels → MacroAction indices and computes
rewards from game outcomes using ml.training.reward.
"""
if not snapshots:
return ForgeEpisode(game_id="empty")
first = snapshots[0]
episode = ForgeEpisode(
game_id=first.get("game_id", "unknown"),
deck_names=first.get("deck_names", []),
)
rc = reward_config or RewardConfig()
for i, snap in enumerate(snapshots):
# State vector — zero-padded if not present
state_vec = np.zeros(STATE_DIMS.total_state_dim, dtype=np.float32)
if "state_vector" in snap:
raw = np.array(snap["state_vector"], dtype=np.float32)
state_vec[: min(len(raw), len(state_vec))] = raw[: len(state_vec)]
# Action mapping
action_label = snap.get("action", "pass").lower().replace(" ", "_")
try:
macro = MacroAction(action_label)
action_idx = ACTION_TO_IDX[macro]
except (ValueError, KeyError):
action_idx = ACTION_TO_IDX[MacroAction.PASS]
# Reward
is_done = i == len(snapshots) - 1
if is_done and "outcome" in snap:
reward = compute_terminal_reward(
won=snap["outcome"].get("won", False),
turns=snap.get("turn", 1),
life_remaining=snap["outcome"].get("life_remaining", 0),
config=rc,
)
else:
reward = compute_intermediate_reward(
life_delta=snap.get("life_delta", 0),
cards_drawn=snap.get("cards_drawn", 0),
creatures_played=snap.get("creatures_played", 0),
config=rc,
)
episode.transitions.append(ForgeTransition(
state=state_vec,
action=action_idx,
reward=reward,
log_prob=snap.get("log_prob", 0.0),
value=snap.get("value", 0.0),
done=is_done,
))
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
if "outcome" in snapshots[-1]:
episode.winner = snapshots[-1]["outcome"].get("winner_seat")
return episode
# ══════════════════════════════════════════════════════════
# Async Episode Queue (producer-consumer)
# ══════════════════════════════════════════════════════════
class ForgeEpisodeQueue:
"""Thread-safe async queue bridging Forge producers and PPO consumer.
Forge batch workers produce ForgeEpisodes; the PPO trainer consumes
them as they become available. Backpressure is applied when the
queue reaches max_size.
"""
def __init__(self, max_size: int = 256):
self._queue: asyncio.Queue[ForgeEpisode] = asyncio.Queue(maxsize=max_size)
self._produced = 0
self._consumed = 0
self._closed = False
async def put(self, episode: ForgeEpisode) -> None:
await self._queue.put(episode)
self._produced += 1
async def get(self, timeout: float = 30.0) -> Optional[ForgeEpisode]:
try:
ep = await asyncio.wait_for(self._queue.get(), timeout=timeout)
self._consumed += 1
return ep
except asyncio.TimeoutError:
return None
def close(self) -> None:
self._closed = True
@property
def is_closed(self) -> bool:
return self._closed and self._queue.empty()
@property
def stats(self) -> Dict[str, int]:
return {
"produced": self._produced,
"consumed": self._consumed,
"pending": self._queue.qsize(),
}
# ══════════════════════════════════════════════════════════
# Forge Batch Producer
# ══════════════════════════════════════════════════════════
async def forge_producer(
queue: ForgeEpisodeQueue,
results_dir: str = "results",
batch_id: str = "forge-batch",
num_games: int = 64,
poll_interval: float = 2.0,
reward_config: Optional[RewardConfig] = None,
) -> int:
"""Producer coroutine: watches for Forge JSONL output files,
parses them into episodes, and pushes them onto the queue.
Returns total number of episodes produced.
"""
results_path = Path(results_dir)
pattern = f"ml-decisions-forge-{batch_id}*.jsonl"
seen_files: set = set()
total_produced = 0
logger.info("[PRODUCER] Watching %s for pattern %s", results_path, pattern)
while total_produced < num_games and not queue.is_closed:
for jsonl_file in sorted(results_path.glob(pattern)):
if jsonl_file in seen_files:
continue
seen_files.add(jsonl_file)
try:
snapshots = parse_forge_jsonl(jsonl_file)
if not snapshots:
continue
# Group by game_id
games: Dict[str, List] = {}
for snap in snapshots:
gid = snap.get("game_id", "unknown")
games.setdefault(gid, []).append(snap)
for gid, game_snaps in games.items():
episode = snapshots_to_episode(game_snaps, reward_config)
if episode.length > 0:
await queue.put(episode)
total_produced += 1
logger.debug("[PRODUCER] Queued episode %s (%d transitions)",
gid, episode.length)
except Exception as e:
logger.error("[PRODUCER] Error processing %s: %s", jsonl_file, e)
await asyncio.sleep(poll_interval)
logger.info("[PRODUCER] Done — produced %d episodes", total_produced)
return total_produced
async def ppo_consumer(
queue: ForgeEpisodeQueue,
buffer_size: int = 64,
on_batch_ready=None,
) -> int:
"""Consumer coroutine: pulls episodes from the queue and batches
them for PPO training updates.
Args:
queue: ForgeEpisodeQueue to consume from
buffer_size: number of episodes per training batch
on_batch_ready: callback(episodes) when a batch is full
Returns total episodes consumed.
"""
batch: List[ForgeEpisode] = []
total_consumed = 0
while not queue.is_closed:
episode = await queue.get(timeout=10.0)
if episode is None:
if queue.is_closed:
break
continue
batch.append(episode)
total_consumed += 1
if len(batch) >= buffer_size:
if on_batch_ready:
on_batch_ready(batch)
logger.info("[CONSUMER] Batch of %d episodes ready for PPO update", len(batch))
batch = []
# Flush remaining
if batch and on_batch_ready:
on_batch_ready(batch)
logger.info("[CONSUMER] Final batch of %d episodes flushed", len(batch))
logger.info("[CONSUMER] Done — consumed %d episodes", total_consumed)
return total_consumed
# ══════════════════════════════════════════════════════════
# Convenience runner
# ══════════════════════════════════════════════════════════
async def run_forge_pipeline(
results_dir: str = "results",
batch_id: str = "forge-batch",
num_games: int = 64,
buffer_size: int = 64,
max_queue: int = 256,
on_batch_ready=None,
reward_config: Optional[RewardConfig] = None,
) -> Dict[str, int]:
"""Run the full Forge producer → PPO consumer pipeline.
The Forge Java batch must be started separately (via forge_runner.py
or MultiThreadBatchRunner). This function handles the Python side:
reading JSONL output and feeding episodes into the PPO trainer.
"""
queue = ForgeEpisodeQueue(max_size=max_queue)
producer_task = asyncio.create_task(
forge_producer(queue, results_dir, batch_id, num_games,
reward_config=reward_config)
)
consumer_task = asyncio.create_task(
ppo_consumer(queue, buffer_size, on_batch_ready)
)
produced = await producer_task
queue.close()
consumed = await consumer_task
stats = {
"produced": produced,
"consumed": consumed,
**queue.stats,
}
logger.info("[PIPELINE] Complete: %s", stats)
return stats
+14 -1
View File
@@ -17,9 +17,15 @@ that feed directly into the RolloutBuffer for PPO training.
For real game integration (Phase 9+), the Java sim would call the
policy server and feed back actual game states — this synthetic
version allows training to begin immediately.
.. deprecated:: Phase 3 (Issue #83)
Synthetic training mode is DEPRECATED for production training.
Use forge_episode_generator.py with real Forge engine games.
Retained ONLY for quick local testing and unit tests.
"""
import logging
import warnings
import random
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
@@ -42,6 +48,12 @@ from ml.training.decision_exporter import DecisionExporter
logger = logging.getLogger("ml.self_play")
_TRAINING_DEPRECATION_MSG = (
"self_play.py synthetic training is deprecated for production training (Issue #83). "
"Use forge_episode_generator.py with real Forge engine games instead. "
"Synthetic self-play is retained for local testing and unit tests only."
)
@dataclass
class PlayerState:
@@ -554,7 +566,8 @@ def collect_rollouts(
total_wins = 0
total_losses = 0
total_draws = 0
total_steps = 0
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
total_steps = 0
total_rewards = 0.0
for ep in range(num_episodes):
+4
View File
@@ -13,3 +13,7 @@ requests>=2.31.0
beautifulsoup4>=4.12.0
ollama>=0.2.0
scrython>=2.0.0
# Embeddings
sentence-transformers>=3.0.0
torch>=2.0.0
+1 -1
View File
@@ -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/.
"""
+70 -13
View File
@@ -33,12 +33,14 @@ 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
import httpx
from coach.config import COACH_PROVIDER, PPLX_MODEL
LLM_TIMEOUT = 120 # seconds
router = APIRouter(tags=["coach"])
@@ -87,7 +89,7 @@ def init_coach_service():
if llm_status.get("connected"):
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
log_coach.warning(f" Coach LLM: Not connected (start Ollama on localhost:11434)")
# Initialize V3 Deck Generator (Ollama-based)
_deck_gen_v3_error = None
@@ -419,13 +421,68 @@ async def coach_chat(body: CoachChatRequest):
# Build messages array for LLM
messages = [{"role": "system", "content": system_prompt}]
for msg in body.messages:
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
# Call LLM with multi-turn messages
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
# ── Perplexity provider path ──────────────────────────────
if COACH_PROVIDER == 'perplexity':
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var.')
pplx_payload = {
'model': PPLX_MODEL,
'messages': messages,
'max_tokens': 4096,
'temperature': 0.7,
}
pplx_headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {CFG.pplx_api_key}',
}
if body.stream:
async def pplx_stream():
pplx_payload['stream'] = True
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream('POST', 'https://api.perplexity.ai/chat/completions',
json=pplx_payload, headers=pplx_headers) as resp:
async for line in resp.aiter_lines():
if line.startswith('data: '):
data = line[6:]
if data == '[DONE]':
yield 'data: [DONE]\n\n'
break
try:
chunk = json.loads(data)
content = chunk.get('choices', [{}])[0].get('delta', {}).get('content', '')
if content:
yield f'data: {json.dumps({"content": content})}\n\n'
except json.JSONDecodeError:
continue
return StreamingResponse(pplx_stream(), media_type='text/event-stream')
else:
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
'https://api.perplexity.ai/chat/completions',
json=pplx_payload, headers=pplx_headers,
)
resp.raise_for_status()
raw = resp.json()
content = raw.get('choices', [{}])[0].get('message', {}).get('content', '')
usage = raw.get('usage', {})
return {
'content': content,
'model': raw.get('model', ''),
'prompt_tokens': usage.get('prompt_tokens', 0),
'completion_tokens': usage.get('completion_tokens', 0),
}
except httpx.HTTPStatusError as e:
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}')
except httpx.RequestError as e:
raise HTTPException(502, f'Perplexity API call failed: {e}')
# Call LLM with multi-turn messages
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LLM_URL, LLM_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
@@ -633,7 +690,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
# Get image URL and price
ce = conn.execute(
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(m.name,)
).fetchone()
@@ -645,7 +702,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
"mana_cost": m.mana_cost,
"text": m.text[:200] if m.text else "",
"owned_qty": owned_qty,
"image_url": ce["image_url"] if ce else None,
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
"tcg_price": ce["tcg_price"] if ce else None,
})
+129 -24
View File
@@ -25,35 +25,123 @@ 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"])
@router.get("/api/collection/stats")
async def collection_stats():
"""Aggregate collection statistics for the dashboard."""
conn = _get_db_conn()
stats = {}
# Total cards and unique cards
row = conn.execute(
"SELECT COUNT(*) as total_rows, COALESCE(SUM(quantity),0) as total_cards, "
"COALESCE(SUM(tcg_price * quantity),0) as total_value "
"FROM collection_entries"
).fetchone()
stats["totalUniqueCards"] = row["total_rows"]
stats["totalCards"] = row["total_cards"]
stats["totalValue"] = round(row["total_value"], 2)
# CMC distribution (0-7+)
cmc_rows = conn.execute(
"SELECT CASE WHEN cmc >= 7 THEN 7 ELSE CAST(cmc AS INTEGER) END as cmc_bucket, "
"SUM(quantity) as count FROM collection_entries GROUP BY cmc_bucket ORDER BY cmc_bucket"
).fetchall()
stats["cmcCurve"] = {str(r["cmc_bucket"]): r["count"] for r in cmc_rows}
# Color identity distribution
color_rows = conn.execute(
"SELECT color_identity, SUM(quantity) as count FROM collection_entries GROUP BY color_identity"
).fetchall()
color_counts = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
for r in color_rows:
ci = r["color_identity"] or "[]"
try:
colors = json.loads(ci) if isinstance(ci, str) else ci
except (json.JSONDecodeError, TypeError):
colors = []
qty = r["count"] or 0
if not colors:
color_counts["C"] += qty
else:
for c in colors:
if c in color_counts:
color_counts[c] += qty
stats["colorDistribution"] = color_counts
# Type distribution
type_rows = conn.execute(
"SELECT type_line, SUM(quantity) as count FROM collection_entries GROUP BY type_line"
).fetchall()
type_counts = {}
for r in type_rows:
tl = r["type_line"] or ""
qty = r["count"] or 0
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
if t in tl:
type_counts[t] = type_counts.get(t, 0) + qty
stats["typeDistribution"] = type_counts
# Category/role distribution
cat_rows = conn.execute(
"SELECT category, SUM(quantity) as count FROM collection_entries "
"WHERE category != '' AND category != '[]' GROUP BY category"
).fetchall()
cat_counts = {}
for r in cat_rows:
raw = r["category"] or "[]"
qty = r["count"] or 0
try:
cats = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
cats = []
if isinstance(cats, list):
for c in cats:
cat_counts[c] = cat_counts.get(c, 0) + qty
stats["categoryDistribution"] = cat_counts
# Rarity distribution
rarity_rows = conn.execute(
"SELECT LOWER(rarity) as rarity, SUM(quantity) as count FROM collection_entries "
"WHERE rarity != '' GROUP BY LOWER(rarity)"
).fetchall()
stats["rarityDistribution"] = {r["rarity"]: r["count"] for r in rarity_rows}
# Top 10 most valuable cards
top_value = conn.execute(
"SELECT name, tcg_price, quantity FROM collection_entries "
"WHERE tcg_price > 0 ORDER BY tcg_price DESC LIMIT 10"
).fetchall()
stats["topValueCards"] = [{"name": r["name"], "price": r["tcg_price"], "qty": r["quantity"]} for r in top_value]
# Average CMC (excluding lands)
avg_row = conn.execute(
"SELECT AVG(cmc) as avg_cmc FROM collection_entries WHERE type_line NOT LIKE '%Land%'"
).fetchone()
stats["avgCmc"] = round(avg_row["avg_cmc"] or 0, 2)
return stats
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
@@ -70,6 +158,17 @@ async def export_collection(
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
rarity: Optional[str] = None,
setCode: Optional[str] = None,
powerMin: Optional[str] = None,
powerMax: Optional[str] = None,
toughMin: Optional[str] = None,
toughMax: Optional[str] = None,
keyword: Optional[str] = None,
edhrecMin: Optional[int] = None,
edhrecMax: Optional[int] = None,
qtyMin: Optional[int] = None,
qtyMax: Optional[int] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
@@ -77,6 +176,12 @@ async def export_collection(
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
rarity=rarity, setCode=setCode,
powerMin=powerMin, powerMax=powerMax,
toughMin=toughMin, toughMax=toughMax,
keyword=keyword,
edhrecMin=edhrecMin, edhrecMax=edhrecMax,
qtyMin=qtyMin, qtyMax=qtyMax,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
+18 -28
View File
@@ -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"])
+155 -58
View File
@@ -36,18 +36,57 @@ 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,
)
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"])
# ── V3 Result Cache (avoids re-generating on export) ─────────────
import hashlib
_v3_result_cache: dict[str, tuple[float, dict]] = {} # key -> (timestamp, result)
_V3_CACHE_TTL = 600 # 10 minutes
def _v3_cache_key(req) -> str:
"""Deterministic cache key from a DeckGenV3Request."""
blob = json.dumps({
'commander': req.commander_name.strip().lower(),
'strategy': req.strategy or '',
'bracket': req.target_bracket,
'budget': req.budget_usd,
'budget_mode': req.budget_mode,
'omit': sorted(req.omit_cards or []),
'collection': req.use_collection,
'model': req.model or '',
}, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def _v3_cache_get(key: str) -> dict | None:
"""Return cached result if within TTL, else None."""
entry = _v3_result_cache.get(key)
if entry and (time.time() - entry[0]) < _V3_CACHE_TTL:
return entry[1]
if entry:
del _v3_result_cache[key]
return None
def _v3_cache_put(key: str, result: dict):
"""Store result in cache with current timestamp."""
# Evict stale entries if cache grows large
if len(_v3_result_cache) > 50:
cutoff = time.time() - _V3_CACHE_TTL
stale = [k for k, (ts, _) in _v3_result_cache.items() if ts < cutoff]
for k in stale:
del _v3_result_cache[k]
_v3_result_cache[key] = (time.time(), result)
# ══════════════════════════════════════════════════════════════
# Auto Deck Generator
@@ -64,7 +103,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
# Try scryfall_id first
if req.commander_scryfall_id:
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
(req.commander_scryfall_id,)
).fetchone()
if row:
@@ -80,6 +119,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"oracle_text": row["oracle_text"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
# Fallback to Scryfall API
@@ -97,7 +137,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
if req.commander_name:
name_lower = req.commander_name.strip().lower()
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
(name_lower,)
).fetchone()
if row:
@@ -113,6 +153,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"oracle_text": row["oracle_text"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
@@ -144,6 +185,7 @@ def _scryfall_to_commander(data: dict) -> dict:
"color_identity": ci,
"type_line": data.get("type_line", ""),
"mana_cost": data.get("mana_cost", ""),
"oracle_text": data.get("oracle_text", ""),
"image_url": image_uris.get("normal", ""),
}
@@ -194,6 +236,31 @@ def _get_collection_for_colors(color_identity: list) -> list:
return valid
# ── Oracle-text keyword extraction for scoring ────────────────────
_ORACLE_STOP_WORDS = frozenset({
'a', 'an', 'the', 'of', 'to', 'and', 'or', 'is', 'it', 'its',
'in', 'on', 'at', 'for', 'by', 'with', 'from', 'as', 'if',
'that', 'this', 'you', 'your', 'up', 'all', 'each', 'may',
'can', 'one', 'two', 'three', 'four', 'five', 'card', 'cards',
'target', 'player', 'creature', 'creatures', 'spell', 'spells',
'permanent', 'permanents', 'turn', 'end', 'step', 'phase',
'mana', 'color', 'pay', 'cost', 'tap', 'untap', 'put', 'get',
'has', 'have', 'are', 'be', 'been', 'was', 'were', 'do', 'does',
'any', 'no', 'not', 'only', 'also', 'then', 'when', 'whenever',
'where', 'which', 'who', 'them', 'they', 'their', 'other',
'more', 'less', 'than', 'into', 'under', 'over', 'until',
'would', 'could', 'instead', 'about', 'number', 'equal',
})
def _extract_oracle_keywords(oracle_text: str) -> set[str]:
"""Extract meaningful keywords from oracle text, filtering stop words."""
if not oracle_text:
return set()
cleaned = re.sub(r'\([^)]*\)', '', oracle_text)
tokens = re.findall(r'[a-z]{3,}', cleaned.lower())
return {t for t in tokens if t not in _ORACLE_STOP_WORDS}
def _generate_deck(req: DeckGenerationRequest) -> dict:
"""
Core deck generation algorithm.
@@ -212,6 +279,8 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
color_identity = req.color_identity or commander.get("color_identity", [])
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
commander_keywords = _extract_oracle_keywords(commander.get("oracle_text", ""))
log_deckgen.info(f" Commander keywords ({len(commander_keywords)}): {sorted(commander_keywords)[:15]}")
# 2. Load collection
collection = _get_collection_for_colors(color_identity)
@@ -278,6 +347,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
)
candidate_map[key] = {
"scryfall_id": card.get("scryfall_id", ""),
"oracle_text": card.get("oracle_text", ""),
"name": name,
"type_line": type_line,
"mana_cost": card.get("mana_cost", ""),
@@ -370,6 +440,17 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
score += 5
elif cmc <= 4:
score += 3
# Oracle-text keyword overlap with commander
card_oracle = card.get("oracle_text") or ""
if commander_keywords and card_oracle:
card_kw = _extract_oracle_keywords(card_oracle)
overlap = len(commander_keywords & card_kw)
if overlap >= 4:
score += 12
elif overlap >= 2:
score += 7
elif overlap >= 1:
score += 3
card["_score"] = score
cards.sort(key=lambda x: x["_score"], reverse=True)
@@ -771,7 +852,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
Returns the assistant message content.
"""
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, PPLX_MODEL
if DECK_GEN_PROVIDER == 'local':
base_url = DECK_GEN_BASE_URL
@@ -781,7 +862,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
base_url = 'https://api.perplexity.ai'
model = 'sonar'
model = PPLX_MODEL
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {CFG.pplx_api_key}',
@@ -798,7 +879,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
f'{base_url}/chat/completions',
json=payload,
headers=headers,
)
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
@@ -947,7 +1028,7 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
return non_basics
# ── Research Endpoint ─────────────────────────────────────────
# ── Research Endpoint ───────────────────────────────────────────────────
@router.post('/api/deck-research')
async def deck_research(req: DeckResearchRequest):
@@ -1102,7 +1183,7 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
return analysis
# ── Generate Endpoint ─────────────────────────────────────────
# ── Generate Endpoint ───────────────────────────────────────────────
@router.post('/api/deck-generate')
async def deck_generate_ai(req: DeckGenerateAIRequest):
@@ -1232,7 +1313,7 @@ async def deck_gen_v3_status():
'initialized': _coach._deck_gen_v3 is not None,
'pplx_configured': bool(CFG.pplx_api_key),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
}
@@ -1289,6 +1370,9 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
from coach.services.deck_generator import DeckGeneratorV3
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
# Cache result for export endpoints
_v3_cache_put(_v3_cache_key(req), result)
return result
except ValueError as e:
@@ -1406,17 +1490,20 @@ async def deck_gen_v3_export_csv(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1453,16 +1540,20 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1488,16 +1579,20 @@ async def deck_gen_v3_export_moxfield(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1525,22 +1620,26 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
cards = result.get('cards', [])
shopping = []
total = 0.0
for card in cards:
if not card.get('owned', True) and card.get('status') != 'substituted':
if not card.get('from_collection', False) and card.get('status') != 'substituted':
price = card.get('estimated_price_usd', 0)
shopping.append({
'name': card.get('name', ''),
@@ -1557,5 +1656,3 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
'total_missing': len(shopping),
'estimated_cost_usd': round(total, 2),
}
+28 -16
View File
@@ -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, _ml_logging_enabled
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
@@ -136,7 +134,7 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
name_b=opponent_name, **kw)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
# Phase 8: Surface player_explanation from validation in turn log
# Phase 8: Surface player_explanation from validation in turn log
for turn_entry in game_data.get('log', []):
v = turn_entry.get('validation')
if isinstance(v, dict) and v.get('player_explanation'):
@@ -210,7 +208,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
# --- Persist batch-*.json for coach / training dashboard ---
# --- Persist ml-decision-*.json for coach / training dashboard ---
try:
deck_name = summary.get('deckName', 'Unknown')
opponent_name = summary.get('opponentName', 'Training Deck')
@@ -237,7 +235,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
}
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
with open(batch_path, 'w') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
@@ -257,6 +255,8 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
logger.error(f'Failed to persist sim results: {e}')
# --- Flush ML decisions JSONL for training pipeline ---
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
# to avoid a double-flush that would produce an empty .jsonl file.
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
@@ -266,7 +266,8 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
def _fail_sim(sim_id, error):
"""Mark a sim run as errored."""
with _sim_lock:
@@ -362,21 +363,32 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
game_id_prefix=f'ds-sim-{sim_id[:8]}',
archetype='midrange')
# Write ML decision JSONL for training pipeline
# Flush ML decisions once here — pass engine=None to _finish_sim
# to prevent a second flush that would produce an empty .jsonl file.
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
if ml_decisions and brain and brain._connected:
ml_path = os.path.join('results',
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
os.makedirs('results', exist_ok=True)
with open(ml_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
logger.info(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {ml_path}')
elif ml_decisions and (not brain or not brain._connected):
logger.warning(
f'[ML Data] Skipping {len(ml_decisions)} snapshots for sim {sim_id} — '
f'brain not connected, all actions are heuristic fallback '
f'(not useful for supervised training)'
)
else:
logger.warning(f'[ML Data] No decision snapshots captured for sim {sim_id} (0 decisions)')
ds_stats = brain.get_stats() if brain else {}
summary = _build_summary(
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
opponentType='deepseek', deepseekStats=ds_stats)
_finish_sim(sim_id, summary, game_results, engine=engine)
# engine=None: ML decisions already flushed above — do not flush again
_finish_sim(sim_id, summary, game_results, engine=None)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
@@ -783,7 +795,7 @@ async def deepseek_logs():
'flushedPath': flushed_path,
'logFiles': log_files[-20:],
})
@router.get("/api/validator/status")
async def validator_status():
+7 -11
View File
@@ -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({
+11 -19
View File
@@ -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"])
@@ -87,7 +79,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
state.result_path = output_path
# Auto-export deckbuilder decks to .dck if not already exported
@@ -250,7 +242,7 @@ async def list_history():
if not results_dir.exists():
return {"results": []}
results = []
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
for f in sorted(results_dir.glob("ml-decision-*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
@@ -383,7 +375,7 @@ async def get_deck_trends(deck_name: str):
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("batch-*.json")):
for f in sorted(results_dir.glob("ml-decision-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
+133 -8
View File
@@ -32,7 +32,8 @@ 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
from services.logging import _ml_logging_enabled
log_ml = logging.getLogger("commander_ai_lab.ml")
@@ -121,7 +122,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 +141,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 +151,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",
}
@@ -188,10 +189,95 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
}
# ==============================================================
# Online Learning Store (SQLite WAL — Phase 5 follow-up)
# ==============================================================
_online_store = None
_online_store_init_attempted = False
def _get_online_store():
"""Lazy-init the online learning store singleton."""
global _online_store, _online_store_init_attempted
if _online_store is None and not _online_store_init_attempted:
_online_store_init_attempted = True
try:
from ml.serving.online_learning_store import OnlineLearningStore
_online_store = OnlineLearningStore()
_online_store.init_db()
log_ml.info("Online learning store initialized")
except Exception as e:
log_ml.error("Online learning store init failed: %s", e)
return _online_store
# ==============================================================
# ML Policy Inference Endpoints
# ==============================================================
@router.post("/api/policy/decide")
async def policy_decide(request: FastAPIRequest):
"""Predict a macro-action and log the decision for online learning.
Combines prediction with automatic data collection: every decision
is appended to the SQLite WAL-backed online learning store so
future training runs can incorporate live gameplay data.
"""
svc = _get_policy_service()
if svc is None or not svc._loaded:
detail = "Policy model not loaded. "
if svc:
detail += svc._load_error or "Train a model first."
else:
detail += "PyTorch may not be installed."
raise HTTPException(status_code=503, detail=detail)
body = await request.json()
playstyle = body.pop("archetype", "midrange")
temperature = body.pop("temperature", 1.0)
greedy = body.pop("greedy", False)
collect = body.pop("collect", True) # opt-out flag
result = svc.predict(
decision_snapshot=body,
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
# Record to online learning store (fire-and-forget — don't block response)
if collect:
store = _get_online_store()
if store is not None:
try:
store.record_decision(
snapshot=body,
action_idx=result["action_index"],
confidence=result["confidence"],
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
except Exception as e:
log_ml.warning("Online learning record failed: %s", e)
return result
@router.get("/api/policy/decide/stats")
async def policy_decide_stats():
"""Return counts of collected online learning decisions."""
store = _get_online_store()
if store is None:
return {"total": 0, "unexported": 0, "error": "Store not initialized"}
return {
"total": store.count(),
"unexported": store.count(only_unexported=True),
}
@router.post("/api/ml/predict")
async def ml_predict(request: FastAPIRequest):
"""Predict a macro-action from a game state snapshot."""
@@ -777,6 +863,8 @@ def _run_distillation_pipeline(
opponent: str,
playstyle: str,
min_win_rate: float,
forge_weight: float = 1.0,
ppo_weight: float = 0.0,
):
"""Run the distillation loop in a background thread."""
global _distillation_loop
@@ -800,6 +888,8 @@ def _run_distillation_pipeline(
opponent=opponent,
playstyle=playstyle,
min_ppo_win_rate=min_win_rate,
forge_weight=forge_weight,
ppo_weight=ppo_weight,
results_dir=os.path.join(project_root, "results"),
models_dir=os.path.join(project_root, "ml", "models"),
checkpoint_dir=os.path.join(project_root, "ml", "models", "checkpoints"),
@@ -847,9 +937,21 @@ def _run_distillation_pipeline(
_distillation_loop = None
@router.get("/api/ml/distill/presets")
async def ml_distill_presets():
"""List available mixed-mode weight presets for distillation."""
from ml.config.scope import MIXED_MODE_PRESETS
return {"presets": MIXED_MODE_PRESETS}
@router.post("/api/ml/distill/start")
async def ml_start_distillation(request: FastAPIRequest):
"""Start the closed-loop distillation pipeline."""
"""Start the closed-loop distillation pipeline.
Accepts an optional ``preset`` field (e.g. ``"forge_90_10"``)
which overrides ``forgeWeight`` / ``ppoWeight`` with a named
configuration from ``MIXED_MODE_PRESETS``.
"""
if _distillation_state.running:
raise HTTPException(409, "Distillation loop already in progress")
if _training_state.running:
@@ -871,6 +973,22 @@ async def ml_start_distillation(request: FastAPIRequest):
playstyle = body.get("playstyle", "midrange")
min_win_rate = body.get("minWinRate", 0.30)
# Resolve weights: preset > explicit params > defaults (Forge-only)
forge_weight = 1.0
ppo_weight = 0.0
preset_name = body.get("preset")
if preset_name:
from ml.config.scope import get_preset_weights
try:
pw = get_preset_weights(preset_name)
forge_weight = pw["forge_weight"]
ppo_weight = pw["ppo_weight"]
except KeyError as e:
raise HTTPException(400, str(e))
else:
forge_weight = body.get("forgeWeight", 1.0)
ppo_weight = body.get("ppoWeight", 0.0)
with _distillation_lock:
_distillation_state.running = True
_distillation_state.generation = 0
@@ -890,12 +1008,19 @@ async def ml_start_distillation(request: FastAPIRequest):
supervised_epochs, supervised_lr,
ppo_iterations, ppo_episodes_per_iter, ppo_lr,
opponent, playstyle, min_win_rate,
forge_weight, ppo_weight,
),
daemon=True,
)
thread.start()
return {"status": "started", "max_iterations": max_iterations}
return {
"status": "started",
"max_iterations": max_iterations,
"forge_weight": forge_weight,
"ppo_weight": ppo_weight,
"preset": preset_name,
}
@router.get("/api/ml/distill/status")
+321
View File
@@ -0,0 +1,321 @@
"""Commander AI Lab — Policy Decision Routes (Phase 2)
════════════════════════════════════════════════════
Live Forge ↔ policy server IPC endpoints for online training.
These endpoints are purpose-built for the real-time decision loop
where Forge's Java side calls the Python policy server at each
game decision point.
Endpoints:
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
POST /api/policy/reward — Submit end-of-game reward
GET /api/policy/health — Readiness check for the live IPC loop
GET /api/policy/stats — Online learning session statistics
Refs: Issue #83 Phase 2.2
"""
import logging
import time
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
logger = logging.getLogger("routes.policy")
router = APIRouter(prefix="/api/policy", tags=["policy"])
# ================================================================
# Request / Response Models
# ================================================================
class PlayerZoneState(BaseModel):
"""Per-player zone state from Forge."""
seat: int = 0
name: str = ""
life: int = 40
poison: int = 0
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
cmdr_tax: int = 0
mana_available: int = 0
mana_pool: Dict[str, int] = Field(default_factory=dict)
# Full zone contents
hand: List[str] = Field(default_factory=list)
hand_count: int = 0
battlefield: List[Dict] = Field(default_factory=list)
graveyard: List[str] = Field(default_factory=list)
exile: List[str] = Field(default_factory=list)
command_zone: List[str] = Field(default_factory=list)
library_count: int = 0
class DecideRequest(BaseModel):
"""Full Forge game state for live decision-making."""
# Game metadata
game_id: str = ""
turn: int = 1
phase: str = "main_1"
active_player: int = 0
priority_player: int = 0
# Per-player state with full zones
players: List[PlayerZoneState] = Field(default_factory=list)
# Stack (spells/abilities currently resolving)
stack: List[Dict] = Field(default_factory=list)
# Legal actions from Forge
legal_actions: List[Dict] = Field(default_factory=list)
# Inference params
playstyle: str = "midrange"
greedy: bool = False
temperature: float = 1.0
# Optional commander info
commander: str = ""
deck_name: str = ""
class DecideResponse(BaseModel):
"""Action decision with metadata for online learning."""
action: str
action_index: int
confidence: float
log_prob: float = 0.0
value: float = 0.0
probabilities: Dict[str, float] = Field(default_factory=dict)
inference_ms: float = 0.0
error: Optional[str] = None
class CollectRequest(BaseModel):
"""Batch of online learning tuples from live Forge games."""
tuples: List[Dict] = Field(default_factory=list)
count: int = 0
class RewardRequest(BaseModel):
"""End-of-game reward submission."""
game_id: str
reward: float
winner_seat: int = -1
turns_played: int = 0
reason: str = "" # e.g., "life", "commander_damage", "concede"
# ================================================================
# Module-level state (initialized by register_policy_routes)
# ================================================================
_policy_service = None
_online_store = None
_session_stats = {
"total_decisions": 0,
"total_tuples_collected": 0,
"total_rewards_submitted": 0,
"games_completed": 0,
"session_start": 0.0,
"last_decision_ms": 0.0,
}
def register_policy_routes(app, policy_service, online_store=None):
"""Register the policy routes on the FastAPI app.
Args:
app: FastAPI application instance
policy_service: PolicyInferenceService from ml.serving.policy_server
online_store: Optional OnlineLearningStore for tuple collection
"""
global _policy_service, _online_store
_policy_service = policy_service
_online_store = online_store
_session_stats["session_start"] = time.time()
app.include_router(router)
logger.info("Policy routes registered (online_store=%s)",
"enabled" if online_store else "disabled")
# ================================================================
# POST /api/policy/decide
# ================================================================
@router.post("/decide", response_model=DecideResponse)
async def decide(req: DecideRequest):
"""Accept full Forge game state, run policy inference, return action.
This is the primary endpoint for live Forge IPC. The Java
PolicyClient calls this at each decision point during a real
Forge game.
Returns the macro-action plus log_prob and value estimate
needed for online PPO updates.
"""
if _policy_service is None or not _policy_service._loaded:
raise HTTPException(
503,
detail="Policy model not loaded"
)
t_start = time.time()
_session_stats["total_decisions"] += 1
# Convert request to snapshot dict for the encoder
snapshot = req.dict(exclude={"temperature", "greedy", "playstyle"})
# Map phase names from Forge conventions
snapshot["phase"] = _map_forge_phase(req.phase)
snapshot["active_seat"] = req.active_player
result = _policy_service.predict(
snapshot,
playstyle=req.playstyle,
temperature=req.temperature,
greedy=req.greedy,
)
elapsed_ms = (time.time() - t_start) * 1000
_session_stats["last_decision_ms"] = elapsed_ms
if "error" in result:
raise HTTPException(500, detail=result["error"])
# Add log_prob and value estimate for online learning
log_prob = 0.0
value = 0.0
try:
import math
conf = result.get("confidence", 0.5)
log_prob = math.log(max(conf, 1e-8))
except Exception:
pass
return DecideResponse(
action=result["action"],
action_index=result["action_index"],
confidence=result["confidence"],
log_prob=round(log_prob, 6),
value=round(value, 6),
probabilities=result.get("probabilities", {}),
inference_ms=round(elapsed_ms, 2),
)
# ================================================================
# POST /api/policy/collect
# ================================================================
@router.post("/collect")
async def collect_tuples(req: CollectRequest):
"""Accept a batch of (state, action, reward) tuples from live games
for online PPO updates.
"""
count = len(req.tuples)
_session_stats["total_tuples_collected"] += count
if _online_store is not None:
try:
_online_store.add_tuples(req.tuples)
except Exception as e:
logger.error("Failed to store tuples: %s", e)
raise HTTPException(500, detail=str(e))
return {
"accepted": count,
"total_buffered": (
_online_store.size() if _online_store else count
),
}
# ================================================================
# POST /api/policy/reward
# ================================================================
@router.post("/reward")
async def submit_reward(req: RewardRequest):
"""Submit end-of-game reward for all tuples in a game.
Propagates the terminal reward back through the game's trajectory.
"""
_session_stats["total_rewards_submitted"] += 1
_session_stats["games_completed"] += 1
if _online_store is not None:
try:
_online_store.assign_reward(
game_id=req.game_id,
reward=req.reward,
winner_seat=req.winner_seat,
)
except Exception as e:
logger.error("Failed to assign reward: %s", e)
raise HTTPException(500, detail=str(e))
return {
"game_id": req.game_id,
"reward": req.reward,
"status": "accepted",
}
# ================================================================
# GET /api/policy/health
# ================================================================
@router.get("/health")
async def policy_health():
"""Readiness check for the live Forge IPC loop."""
model_loaded = (
_policy_service is not None and _policy_service._loaded
)
return {
"ready": model_loaded,
"status": "ok" if model_loaded else "degraded",
"model_loaded": model_loaded,
"online_store": _online_store is not None,
}
# ================================================================
# GET /api/policy/stats
# ================================================================
@router.get("/stats")
async def policy_stats():
"""Online learning session statistics."""
uptime = time.time() - _session_stats["session_start"]
return {
**_session_stats,
"uptime_seconds": round(uptime, 1),
"decisions_per_second": (
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
),
"online_store_size": (
_online_store.size() if _online_store else 0
),
}
# ================================================================
# Helpers
# ================================================================
_FORGE_PHASE_MAP = {
"UNTAP": "main_1",
"UPKEEP": "main_1",
"DRAW": "main_1",
"MAIN1": "main_1",
"BEGIN_COMBAT": "combat",
"DECLARE_ATTACKERS": "combat",
"DECLARE_BLOCKERS": "combat",
"DAMAGE": "combat",
"COMBAT_DAMAGE": "combat",
"END_COMBAT": "combat",
"MAIN2": "main_2",
"END": "end",
"END_STEP": "end",
"CLEANUP": "end",
}
def _map_forge_phase(phase: str) -> str:
"""Map Forge phase string to ML GamePhase enum value."""
return _FORGE_PHASE_MAP.get(phase.upper(), "main_1")
+4 -3
View File
@@ -15,8 +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 models.state import CFG
from services.precon_service import download_precon_database, PRECON_INDEX
from services.logging import log
from services.precon_service import _get_precon_dir
router = APIRouter(tags=["precon"])
@@ -25,7 +26,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} # reads current value every call
@router.post("/api/lab/precons/install")
+7 -7
View File
@@ -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"])
-204
View File
@@ -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
+1 -1
View File
@@ -85,7 +85,7 @@ def start_batch(base_url, decks, num_games, playstyle, threads):
def poll_batch(base_url, batch_id, poll_interval=5):
"""Poll batch status until complete. Returns final status."""
while True:
result = api_call(base_url, f"/api/lab/status/{batch_id}")
result = api_call(base_url, f"/api/lab/status?batchId={batch_id}")
if result is None:
time.sleep(poll_interval)
continue
+124
View File
@@ -136,6 +136,28 @@ def init_collection_db() -> None:
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS coach_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
deck_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
model_used TEXT DEFAULT '',
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
summary TEXT DEFAULT '',
goals_json TEXT DEFAULT '{}',
cuts_json TEXT DEFAULT '[]',
adds_json TEXT DEFAULT '[]',
heuristic_hints_json TEXT DEFAULT '[]',
mana_base_advice TEXT DEFAULT '',
raw_text TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cs_session_id ON coach_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_cs_deck_id ON coach_sessions(deck_id);
CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON coach_sessions(timestamp);
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
@@ -302,3 +324,105 @@ def _build_collection_filters(
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params
# ══════════════════════════════════════════════════════════════
# Coach Session DB Helpers
# ══════════════════════════════════════════════════════════════
def save_coach_session(session_data: dict) -> int:
"""Insert or update a coach session in SQLite. Returns the row id."""
conn = _get_db_conn()
existing = conn.execute(
"SELECT id FROM coach_sessions WHERE session_id = ?",
(session_data["session_id"],),
).fetchone()
if existing:
conn.execute(
"""UPDATE coach_sessions SET
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
raw_text=?
WHERE session_id=?""",
(
session_data.get("deck_id", ""),
session_data.get("timestamp", ""),
session_data.get("model_used", ""),
session_data.get("prompt_tokens", 0),
session_data.get("completion_tokens", 0),
session_data.get("summary", ""),
session_data.get("goals_json", "{}"),
session_data.get("cuts_json", "[]"),
session_data.get("adds_json", "[]"),
session_data.get("heuristic_hints_json", "[]"),
session_data.get("mana_base_advice", ""),
session_data.get("raw_text", ""),
session_data["session_id"],
),
)
conn.commit()
return existing["id"]
else:
cur = conn.execute(
"""INSERT INTO coach_sessions
(session_id, deck_id, timestamp, model_used, prompt_tokens,
completion_tokens, summary, goals_json, cuts_json, adds_json,
heuristic_hints_json, mana_base_advice, raw_text)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
session_data["session_id"],
session_data.get("deck_id", ""),
session_data.get("timestamp", ""),
session_data.get("model_used", ""),
session_data.get("prompt_tokens", 0),
session_data.get("completion_tokens", 0),
session_data.get("summary", ""),
session_data.get("goals_json", "{}"),
session_data.get("cuts_json", "[]"),
session_data.get("adds_json", "[]"),
session_data.get("heuristic_hints_json", "[]"),
session_data.get("mana_base_advice", ""),
session_data.get("raw_text", ""),
),
)
conn.commit()
return cur.lastrowid
def load_coach_session(session_id: str) -> Optional[dict]:
"""Load a single coach session by session_id."""
conn = _get_db_conn()
row = conn.execute(
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
).fetchone()
return dict(row) if row else None
def list_coach_sessions(deck_id: str = None) -> list:
"""List coach sessions, optionally filtered by deck_id."""
conn = _get_db_conn()
if deck_id:
rows = conn.execute(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
(deck_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions ORDER BY timestamp DESC"
).fetchall()
return [dict(r) for r in rows]
def delete_coach_session(session_id: str) -> bool:
"""Delete a coach session. Returns True if a row was deleted."""
conn = _get_db_conn()
cur = conn.execute(
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
)
conn.commit()
return cur.rowcount > 0
+3 -2
View File
@@ -204,6 +204,7 @@ def build_java_command(
cmd += ['--aiSimplified']
if ai_think_time_ms > 0:
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
cmd += ['--ml-log']
if max_queue_depth > 0:
cmd += ['--maxQueueDepth', str(max_queue_depth)]
return cmd
@@ -316,7 +317,7 @@ def _get_deepseek_brain():
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "120.0")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
@@ -360,7 +361,7 @@ def _run_deepseek_batch_thread(
results_dir = CFG.results_dir
if os.path.isdir(results_dir):
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('batch-') and fname.endswith('.json'):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
+60
View File
@@ -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:
+35 -32
View File
@@ -63,6 +63,40 @@ class MemoryEntry:
latency_ms: float
# ── Canned narration lines (by personality key) ───────────────────────
_CANNED_NARRATIONS: dict[str, dict[str, str]] = {
"aggro_timmy": {
"play_land": "More mana, more mayhem!",
"cast_spell": "Here comes the pain!",
"attack": "ATTACK! Take everything!",
"pass_priority": "Fine, go… but I'm watching you.",
"default": "Let's smash something!",
},
"control_spike": {
"play_land": "Mana secured. Proceeding.",
"cast_spell": "Optimal play. As expected.",
"attack": "Calculated aggression — acceptable risk.",
"pass_priority": "Holding interaction mana. Predictable of you.",
"default": "Inevitable victory approaches.",
},
"combo_johnny": {
"play_land": "One step closer to the combo!",
"cast_spell": "Assembling the pieces…",
"attack": "Buying time until the combo fires.",
"pass_priority": "Patience. The combo is coming.",
"default": "The pieces are falling into place!",
},
"political_negotiator": {
"play_land": "Just tapping a land. Nothing threatening here.",
"cast_spell": "This helps us all, really.",
"attack": "I'm afraid I must — for the good of the table.",
"pass_priority": "I'll let this resolve. For now.",
"default": "Let's all be reasonable about this.",
},
}
# ── AIOpponent ─────────────────────────────────────────────────────────
@dataclass
@@ -409,40 +443,9 @@ class AIOpponent:
# ── Canned Narration ───────────────────────────────────────────────
_CANNED: dict[str, dict[str, str]] = {
"aggro_timmy": {
"play_land": "More mana, more mayhem!",
"cast_spell": "Here comes the pain!",
"attack": "ATTACK! Take everything!",
"pass_priority": "Fine, go… but I’m watching you.",
"default": "Let’s smash something!",
},
"control_spike": {
"play_land": "Mana secured. Proceeding.",
"cast_spell": "Optimal play. As expected.",
"attack": "Calculated aggression — acceptable risk.",
"pass_priority": "Holding interaction mana. Predictable of you.",
"default": "Inevitable victory approaches.",
},
"combo_johnny": {
"play_land": "One step closer to the combo!",
"cast_spell": "Assembling the pieces…",
"attack": "Buying time until the combo fires.",
"pass_priority": "Patience. The combo is coming.",
"default": "The pieces are falling into place!",
},
"political_negotiator": {
"play_land": "Just tapping a land. Nothing threatening here.",
"cast_spell": "This helps us all, really.",
"attack": "I’m afraid I must — for the good of the table.",
"pass_priority": "I’ll let this resolve. For now.",
"default": "Let’s all be reasonable about this.",
},
}
def _canned_narration(self, move_desc: str) -> str:
"""Return a canned flavor line matching personality and move category."""
canned = self._CANNED.get(self.personality.key, {})
canned = _CANNED_NARRATIONS.get(self.personality.key, {})
desc_lower = move_desc.lower()
for key in ("play_land", "cast_spell", "attack", "pass_priority"):
if key.replace("_", " ") in desc_lower or key in desc_lower:
+35 -29
View File
@@ -9,8 +9,8 @@ The LLM receives a structured game-state snapshot and returns a JSON
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
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
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 localhost: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)
+1 -1
View File
@@ -23,7 +23,7 @@ Usage::
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://localhost:11434"))
brain.check_connection()
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
+498 -65
View File
@@ -28,10 +28,14 @@ from typing import Optional
from commander_ai_lab.sim.models import (
Card,
CombatState,
GameResult,
Phase,
PHASE_ORDER,
Player,
PlayerResult,
PlayerStats,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import (
@@ -60,6 +64,9 @@ class GameEngine:
``weights`` explicitly to override (e.g. for A/B testing).
"""
# London mulligan: max times a player may mulligan before forced keep
MAX_MULLIGANS = 4
def __init__(
self,
max_turns: int = 25,
@@ -67,6 +74,7 @@ class GameEngine:
weights: Optional[dict] = None,
record_log: bool = False,
ml_log: bool = False,
mulligan_rule: str = "london",
):
self.max_turns = max_turns
self.starting_life = starting_life
@@ -75,6 +83,7 @@ class GameEngine:
self.record_log = record_log
self.ml_log = ml_log
self.ml_decisions: list[dict] = []
self.mulligan_rule = mulligan_rule
# ──────────────────────────────────────────────────────────
# Public API
@@ -115,6 +124,7 @@ class GameEngine:
final_turn = 0
for turn in range(sim.max_turns):
sim.turn = turn
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
@@ -122,59 +132,106 @@ class GameEngine:
if p.eliminated:
continue
p.stats.turns_alive += 1
sim.active_player_index = pi
phase_events: list[str] = []
# ── Untap (correct MTG order: untap is first) ──
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # clear damage each turn (MTG rule 514.2)
# ── Draw ──
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
# ── Land drop ──
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# ── Available mana ──
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
# Track spell/damage counts for ML logging
spells_before = p.stats.spells_cast
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
played_spell = p.stats.spells_cast > spells_before
damage_before_turn = p.stats.damage_dealt
spells_budget = 2 # shared across main1 + main2
# ── Track board size ──
# ── Walk each phase in order ──
for phase in PHASE_ORDER:
sim.current_phase = phase
if phase == Phase.UNTAP:
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # MTG rule 514.2
elif phase == Phase.UPKEEP:
pass # placeholder — triggers would fire here
elif phase == Phase.DRAW:
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
elif phase == Phase.MAIN1:
# Land drop (only in main1, per MTG convention)
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# Play spells (shared budget across main1 + main2)
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.BEGIN_COMBAT:
pass # placeholder — "beginning of combat" triggers
elif phase == Phase.DECLARE_ATTACKERS:
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
elif phase == Phase.DECLARE_BLOCKERS:
pass # blocking is handled inside _resolve_combat
elif phase == Phase.COMBAT_DAMAGE:
pass # damage is handled inside _resolve_combat
elif phase == Phase.END_COMBAT:
pass # placeholder — "end of combat" triggers
elif phase == Phase.MAIN2:
# Second main phase: use remaining spell budget
if spells_budget > 0:
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.END_STEP:
pass # placeholder — "at end of turn" triggers
elif phase == Phase.CLEANUP:
pass # damage clearing already happened in untap for simplicity
# ── Post-phase bookkeeping ──
played_spell = p.stats.spells_cast > spells_before
attacked = p.stats.damage_dealt > damage_before_turn
# Track board size
board_size = len(sim.get_battlefield(pi))
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Combat ──
damage_before = p.stats.damage_dealt
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
attacked = p.stats.damage_dealt > damage_before
# ── Record ML decision snapshot ──
if self.ml_log and pre_snapshot is not None:
available_mana = self._count_untapped_lands(sim, pi)
if attacked:
action_type = "attack"
elif played_spell:
@@ -282,6 +339,42 @@ class GameEngine:
# Internal helpers
# ──────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────
# Opening-hand helpers
# ──────────────────────────────────────────────────────────
@staticmethod
def _count_lands_in_hand(hand: list[Card]) -> int:
"""Count lands in a hand of cards."""
return sum(1 for c in hand if c.is_land())
@staticmethod
def _should_mulligan(hand: list[Card], mulligan_number: int) -> bool:
"""Heuristic: mulligan if opening hand has 0-1 lands or 6-7 lands.
Becomes more lenient with each successive mulligan — after 2+
mulligans, accept 1-land hands to avoid going too low on cards.
"""
lands = GameEngine._count_lands_in_hand(hand)
total = len(hand)
if mulligan_number >= 2:
# Desperate: keep anything with 1-5 lands
return lands < 1 or lands >= total
# Standard: keep 2-5 lands in a 7-card hand
return lands <= 1 or lands >= 6
@staticmethod
def _pick_bottom_cards(hand: list[Card], count: int, weights: dict) -> list[Card]:
"""Choose the worst *count* cards to put on the bottom of library.
Uses the AI scoring function so the least valuable cards are
bottomed — mirrors a real player's London mulligan decision.
"""
if count <= 0 or count >= len(hand):
return []
scored = sorted(hand, key=lambda c: score_card(c, weights))
return scored[:count]
def _create_state(
self,
decks: list[list[Card]],
@@ -290,6 +383,11 @@ class GameEngine:
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players).
Implements the London mulligan rule: each player draws 7, decides
whether to keep, and if not shuffles back and redraws. After
keeping, they put N cards on the bottom of their library where N
is the number of times they mulliganed.
If commander_names is provided, the matching card is flagged
is_commander=True and placed in the player's command_zone
instead of the library/hand.
@@ -308,16 +406,46 @@ class GameEngine:
commander_card = c
else:
remaining.append(c)
random.shuffle(remaining)
hand = remaining[:7]
# ── London mulligan loop ──
mulligan_count = 0
if self.mulligan_rule == "london":
for attempt in range(self.MAX_MULLIGANS):
random.shuffle(remaining)
hand = remaining[:7]
if not self._should_mulligan(hand, attempt):
break
mulligan_count += 1
else:
# Exhausted all mulligans — forced keep with last draw
random.shuffle(remaining)
hand = remaining[:7]
else:
# No mulligan (legacy behavior)
random.shuffle(remaining)
hand = remaining[:7]
library = remaining[7:]
# ── Bottom N cards for London mulligan ──
if mulligan_count > 0:
bottom = self._pick_bottom_cards(hand, mulligan_count, self.weights)
for card in bottom:
hand.remove(card)
library.insert(0, card) # bottom of library
opening_lands = self._count_lands_in_hand(hand)
player = Player(
name=name,
life=self.starting_life,
owner_id=idx,
library=library,
hand=hand,
stats=PlayerStats(cards_drawn=7),
stats=PlayerStats(
cards_drawn=7,
mulligans=mulligan_count,
opening_hand_lands=opening_lands,
),
)
if commander_card:
player.command_zone.append(commander_card)
@@ -354,6 +482,57 @@ class GameEngine:
else:
owner.graveyard.append(card)
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def deal_damage(
sim: SimState,
amount: int,
target_seat: int,
source_card: Card | None = None,
source_seat: int | None = None,
is_combat: bool = False,
events: list | None = None,
label: str = "",
) -> int:
"""Apply *amount* damage to a player through the centralized path.
Handles life reduction, stat tracking, commander damage tracking
(both per-seat aggregate and per-card breakdown from PR #89),
and elimination checks. Returns the actual damage dealt.
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
if source_seat is not None:
sim.players[source_seat].stats.damage_dealt += amount
# Commander damage tracking — dual-dict model (PR #89):
# commander_damage_received[seat] = aggregate per-seat total
# commander_damage_by_card[(seat, card_name)] = per-card breakdown
if source_card and source_card.is_commander and source_seat is not None:
target.commander_damage_received[source_seat] = (
target.commander_damage_received.get(source_seat, 0) + amount
)
key = (source_seat, source_card.name)
target.commander_damage_by_card[key] = (
target.commander_damage_by_card.get(key, 0) + amount
)
if events is not None and label:
events.append(f"{label} ({target.name} now at {target.life} life)")
if target.life <= 0 or target.is_dead_to_commander_damage():
target.eliminated = True
return amount
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
"""Count untapped lands for a player."""
return sum(
@@ -362,12 +541,15 @@ class GameEngine:
if not c.tapped and c.is_land()
)
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
"""Play up to 2 spells from hand, prioritized by AI score."""
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None, max_spells: int = 2) -> int:
"""Play up to *max_spells* spells from hand, prioritized by AI score.
Returns the number of spells actually played this call.
"""
p = sim.players[pi]
w = self.weights
played = 0
while played < 2:
while played < max_spells:
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
@@ -437,14 +619,22 @@ 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:
# Fix #84/BUG-05: instants and sorceries resolve and go to
# graveyard — they are NOT permanents and should never stay
# on the battlefield.
if card.is_permanent():
sim.add_to_battlefield(pi, card)
else:
self._send_to_graveyard(sim, card, pi)
sim.add_to_battlefield(pi, card)
if card.is_creature():
p.stats.creatures_played += 1
if events is not None:
@@ -488,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:
@@ -521,7 +712,7 @@ class GameEngine:
c
for c in sim.get_battlefield(pi)
if (
c.can_attack_or_block()
c.is_creature()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
)
@@ -532,7 +723,7 @@ class GameEngine:
c
for c in sim.get_battlefield(opp_idx)
if (
c.can_attack_or_block()
c.is_creature()
and not c.tapped
)
]
@@ -565,6 +756,8 @@ class GameEngine:
total_damage = 0
used_blockers: set[int] = set()
combat_details: list[str] = []
# Per-attacker damage to route through deal_damage() individually
attacker_damage: list[tuple[Card, int]] = []
for atk in attackers:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
@@ -613,12 +806,12 @@ class GameEngine:
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.has_keyword("double strike"):
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
total_damage += a_pow
else:
total_damage += trample_over
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
attacker_damage.append((atk, ds_dmg))
total_damage += ds_dmg
if atk.has_keyword("lifelink"):
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
if atk.has_keyword("double strike"):
@@ -626,21 +819,261 @@ class GameEngine:
p.life += damage_dealt
if not blocked:
if atk.has_keyword("double strike"):
total_damage += a_pow * 2
atk_dmg = a_pow * 2
if atk.has_keyword("lifelink"):
p.life += a_pow * 2
else:
total_damage += a_pow
atk_dmg = a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
opp.stats.damage_received += total_damage
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage() for
# centralized life/stats/commander tracking
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if events is not None and total_damage > 0:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
# Compute commander damage annotation from the per-card dict
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.extend(combat_details)
if opp.life <= 0:
opp.eliminated = True
# ──────────────────────────────────────────────────────────
# Split combat methods for turn_manager priority windows
# (Issue #86 Item 1). The original _resolve_combat() above
# remains as the backward-compatible atomic path for run_n().
# ──────────────────────────────────────────────────────────
def assign_attackers(
self, sim: SimState, pi: int, events: list | None = None,
) -> Optional[CombatState]:
"""Phase 1 of split combat: choose attackers, populate CombatState.
Returns a CombatState with attackers set and tapped, or None if
no valid attack is possible. Does NOT deal any damage.
"""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return None
opp = sim.players[opp_idx]
w = self.weights
my_creatures = [
c
for c in sim.get_battlefield(pi)
if (
c.is_creature()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
)
]
if not my_creatures:
return None
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
has_flying = atk.has_keyword("flying")
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if not attackers:
return None
if events is not None:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# Build and store CombatState
combat = CombatState(
defending_seat=opp_idx,
attackers={atk.id: opp_idx for atk in attackers},
active=True,
)
sim.combat = combat
return combat
def assign_blockers(
self, sim: SimState, pi: int, combat: CombatState,
events: list | None = None,
) -> None:
"""Phase 2 of split combat: assign blockers into CombatState.
Populates combat.blockers and combat.player_damage.
Does NOT apply any damage to players or creatures yet.
"""
opp_idx = combat.defending_seat
opp_blockers = [
c for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
used_blockers: set[int] = set()
for atk_id in combat.attackers:
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue
a_pow = atk.get_power()
has_flying = atk.has_keyword("flying")
valid_blockers = [
b for b in opp_blockers
if b.id not in used_blockers
and (not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
]
blocker = None
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
valid_blockers.sort(key=lambda b: -b.get_toughness())
blocker = valid_blockers[0]
used_blockers.add(valid_blockers[1].id)
else:
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
opp = sim.players[opp_idx]
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
if blocker:
used_blockers.add(blocker.id)
combat.blockers[atk_id] = [blocker.id]
if events is not None:
events.append(f"{atk.name} blocked by {blocker.name}")
else:
# Unblocked — full damage goes to player
combat.player_damage[atk_id] = a_pow
def resolve_combat_damage(
self, sim: SimState, pi: int, combat: CombatState,
first_strike_only: bool = False,
events: list | None = None,
) -> None:
"""Phase 3 of split combat: apply damage from CombatState.
When first_strike_only=True, only processes creatures with
first_strike or double_strike. When False, processes all
remaining (skipping first-strikers if already resolved).
"""
opp_idx = combat.defending_seat
p = sim.players[pi]
total_damage = 0
attacker_damage: list[tuple[Card, int]] = []
combat_details: list[str] = []
for atk_id, def_seat in list(combat.attackers.items()):
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue # removed by instant before damage
is_fs = atk.has_keyword("first_strike") or atk.has_keyword("double_strike")
if first_strike_only and not is_fs:
continue
if not first_strike_only and is_fs and combat.first_strike_resolved:
if not atk.has_keyword("double_strike"):
continue # already dealt damage in FS step
a_pow = atk.get_power()
a_tou = atk.get_toughness()
blocker_ids = combat.blockers.get(atk_id, [])
if blocker_ids:
blocker = next((c for bf in sim.battlefields for c in bf if c.id == blocker_ids[0]), None)
if blocker is None:
# Blocker removed — attacker is still blocked but deals no combat damage to player
continue
b_pow = blocker.get_power()
blocker.damage_marked += a_pow
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
self._send_to_graveyard(sim, blocker, opp_idx)
if events is not None:
combat_details.append(f" {blocker.name} dies")
atk.damage_marked += b_pow
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
self._send_to_graveyard(sim, atk, pi)
if events is not None:
combat_details.append(f" {atk.name} dies")
# Trample over
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness()) + trample_over
else:
# Unblocked
atk_dmg = a_pow * 2 if atk.has_keyword("double_strike") else a_pow
if atk.has_keyword("lifelink"):
p.life += atk_dmg
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage()
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if first_strike_only:
combat.first_strike_resolved = True
if events is not None and total_damage > 0:
opp = sim.players[opp_idx]
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.extend(combat_details)
def _build_result(
self,
@@ -0,0 +1,152 @@
"""
Forge Card Loader
=================
Parse Forge cardsfolder .txt files into structured data for sim enrichment.
Lazy-loaded and cached at module level.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from models.state import CFG
_log = logging.getLogger(__name__)
@dataclass
class ForgeCardData:
"""Structured representation of a single Forge .txt card definition."""
name: str = ""
mana_cost: str = ""
cmc: int = 0
type_line: str = ""
oracle_text: str = ""
pt: str = ""
keywords: list[str] = field(default_factory=list)
trigger_modes: list[str] = field(default_factory=list)
has_replacement_effect: bool = False
has_static_ability: bool = False
# -- Module-level lazy cache ---
_FORGE_CACHE: dict[str, ForgeCardData] = {}
_FORGE_CACHE_LOADED: bool = False
def _get_cards_folder() -> Path:
"""Resolve the Forge cardsfolder directory from CFG.forge_dir."""
if not CFG.forge_dir:
return Path("")
return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder"
def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
"""Parse a single Forge .txt card file into ForgeCardData."""
try:
text = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
data = ForgeCardData()
lines = text.splitlines()
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
# Name
if line.startswith("Name:"):
data.name = line.split(":", 1)[1].strip()
# ManaCost
elif line.startswith("ManaCost:"):
raw = line.split(":", 1)[1].strip()
data.mana_cost = raw
data.cmc = _calc_cmc(raw)
# Types
elif line.startswith("Types:"):
data.type_line = line.split(":", 1)[1].strip()
# PT
elif line.startswith("PT:"):
data.pt = line.split(":", 1)[1].strip()
# Oracle
elif line.startswith("Oracle:"):
data.oracle_text = line.split(":", 1)[1].strip()
# K -- Keywords
elif line.startswith("K:"):
kw = line.split(":", 1)[1].strip()
if kw and kw not in data.keywords:
data.keywords.append(kw)
# T -- Trigger modes (e.g. T:Mode$ ChangesZone)
elif line.startswith("T:"):
mode_match = re.search(r"Mode\$\s*(\w+)", line)
if mode_match:
mode = mode_match.group(1)
if mode not in data.trigger_modes:
data.trigger_modes.append(mode)
# R -- Replacement effects
elif line.startswith("R:"):
data.has_replacement_effect = True
# S -- Static abilities
elif line.startswith("S:"):
data.has_static_ability = True
# SVar (secondary -- grab Oracle from SVar if not set)
elif line.startswith("SVar:") and not data.oracle_text:
if "Oracle:" in line:
oracle_part = line.split("Oracle:", 1)[1].strip()
data.oracle_text = oracle_part
return data if data.name else None
def _calc_cmc(mana_cost: str) -> int:
"""Calculate CMC from Forge mana cost string like '2 W U'."""
total = 0
for part in mana_cost.split():
part = part.strip().upper()
if not part:
continue
if part.isdigit():
total += int(part)
elif part == "X":
continue
else:
total += 1
return total
def _load_forge_cache() -> None:
"""Walk the Forge cardsfolder and cache every card by lowercase name."""
global _FORGE_CACHE_LOADED
if _FORGE_CACHE_LOADED:
return
cards_folder = _get_cards_folder()
if not cards_folder.is_dir():
_log.warning("Forge cardsfolder not found at %s", cards_folder)
_FORGE_CACHE_LOADED = True
return
count = 0
for root, _dirs, files in os.walk(cards_folder):
for fname in files:
if not fname.endswith(".txt"):
continue
fpath = Path(root) / fname
parsed = _parse_forge_file(fpath)
if parsed and parsed.name:
_FORGE_CACHE[parsed.name.lower()] = parsed
count += 1
_log.info("Forge card cache loaded: %d cards from %s", count, cards_folder)
_FORGE_CACHE_LOADED = True
def lookup_forge_card(card_name: str) -> Optional[ForgeCardData]:
"""Look up a card by name from the Forge cache. Returns None on miss."""
_load_forge_cache()
return _FORGE_CACHE.get((card_name or "").lower().strip())
+72 -214
View File
@@ -15,7 +15,7 @@ import json
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.models import Card, Player, SimState
from commander_ai_lab.sim.models import Card, CombatState, Phase, Player, SimState
# ── Mana Pool ────────────────────────────────────────────────
@@ -95,9 +95,6 @@ class CommanderPlayer:
# Whether this player has drawn their card this turn
drawn_this_turn: bool = False
# Commander damage received from each opponent (keyed by seat index)
commander_damage_received: dict[int, int] = field(default_factory=dict)
# ── Convenience pass-throughs to base Player ─────────────
@property
@@ -136,13 +133,18 @@ class CommanderPlayer:
def library(self) -> list[Card]:
return self.base.library
@property
def commander_damage_received(self) -> dict[int, int]:
"""Delegate to base Player so headless engine writes are visible."""
return self.base.commander_damage_received
def commander_tax(self) -> int:
"""Additional mana cost (2 per cast from command zone)."""
return self.commander_cast_count * 2
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single opponent has dealt 21+ commander damage."""
return any(v >= 21 for v in self.commander_damage_received.values())
"""Returns True if any single commander has dealt 21+ damage."""
return self.base.is_dead_to_commander_damage()
def to_dict(self) -> dict:
return {
@@ -194,7 +196,7 @@ class CommanderGameState:
# Phase tracking
# Phases: untap, upkeep, draw, main1, combat_begin, combat_declare_attackers,
# combat_declare_blockers, combat_damage, combat_end, main2, end, cleanup
current_phase: str = "main1"
current_phase: str | Phase = Phase.MAIN1
# Turn number (mirrors sim_state.turn)
turn: int = 0
@@ -227,224 +229,68 @@ class CommanderGameState:
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
# ── Legal Moves & Apply (BUG-02 fix) ─────────────────────
# These replace the former pass-only stubs so that the turn manager
# can drive real game actions through the 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 based on mana, phase, and card type.
"""Return legal moves for a seat.
Move categories:
- pass_priority: always available
- play_land: if main phase, land drop not used, and land in hand
- cast_spell: if main phase and castable cards in hand
- attack: if declare_attackers phase and creatures can attack
- instant/activate_ability: if holding-priority phases
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] = []
move_id = 0
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
if seat >= len(self.commander_players):
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
cp = self.commander_players[seat]
player = cp.base
bf = self.sim_state.get_battlefield(seat)
# Available mana = untapped lands + untapped mana-producing artifacts
available_mana = sum(
1 for c in bf
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature()))
)
# ── Main phase actions ──
if self.current_phase in ("main1", "main2"):
# Play land (one per turn)
if not self.land_drop_used:
for i, card in enumerate(player.hand):
if card.is_land():
moves.append({
"id": move_id,
"category": "play_land",
"description": f"Play land: {card.name}",
"card_index": i,
"card_name": card.name,
})
move_id += 1
break # only one land drop offered
# Cast spells from hand (sorcery-speed: creatures, sorceries,
# artifacts, enchantments, planeswalkers)
for i, card in enumerate(player.hand):
if card.is_land():
continue
cmc = card.cmc or 0
if cmc <= available_mana:
moves.append({
"id": move_id,
"category": "cast_spell",
"description": f"Cast {card.name} (CMC {cmc})",
"card_index": i,
"card_name": card.name,
})
move_id += 1
# Cast commander from command zone
for i, cmd in enumerate(cp.commander_zone):
tax = player.commander_tax.get(cmd.name, 0)
total = (cmd.cmc or 0) + tax
if total <= available_mana:
moves.append({
"id": move_id,
"category": "cast_spell",
"description": f"Cast commander {cmd.name} (cost {total}, tax {tax})",
"card_name": cmd.name,
"from_zone": "command",
"cmd_index": i,
})
move_id += 1
# ── Combat: declare attackers ──
if self.current_phase == "declare_attackers":
attackable_creatures = [
c for c in bf
if c.can_attack_or_block()
and not c.tapped
and (c.turn_played < self.turn or c.has_keyword("haste"))
# 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
]
if attackable_creatures:
moves.append({
"id": move_id,
"category": "attack",
"description": f"Attack with all ({len(attackable_creatures)} creatures)",
"attack_mode": "all",
})
move_id += 1
# ── Instant-speed responses ──
if self.current_phase in ("upkeep", "begin_combat", "end_combat",
"end_step", "declare_blockers"):
for i, card in enumerate(player.hand):
if card.is_instant():
cmc = card.cmc or 0
if cmc <= available_mana:
moves.append({
"id": move_id,
"category": "instant",
"description": f"Cast {card.name} (instant, CMC {cmc})",
"card_index": i,
"card_name": card.name,
})
move_id += 1
# Always allow passing priority
moves.append({
"id": move_id,
"category": "pass_priority",
"description": "Pass",
})
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 to the game state.
"""Apply a chosen move.
Looks up the move by id in the current legal moves list and
executes the corresponding game action.
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
"""
legal = self.get_legal_moves(seat)
move = None
for m in legal:
if m["id"] == move_id:
move = m
break
# 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 # invalid move id — no-op
cat = move["category"]
if cat == "pass_priority":
return
if seat >= len(self.commander_players):
return
cp = self.commander_players[seat]
player = cp.base
bf = self.sim_state.get_battlefield(seat)
if cat == "play_land":
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, card)
self.land_drop_used = True
cp.lands_played_this_turn += 1
elif cat == "cast_spell":
from_zone = move.get("from_zone", "hand")
if from_zone == "command":
cmd_idx = move.get("cmd_index", 0)
if cmd_idx < len(cp.commander_zone):
card = cp.commander_zone.pop(cmd_idx)
tax = player.commander_tax.get(card.name, 0)
total_cost = (card.cmc or 0) + tax
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
card.turn_played = self.turn
self._tap_mana_sources(seat, total_cost)
self.sim_state.add_to_battlefield(seat, card)
player.commander_tax[card.name] = tax + 2
else:
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
card.turn_played = self.turn
self._tap_mana_sources(seat, card.cmc or 0)
if card.is_permanent():
self.sim_state.add_to_battlefield(seat, card)
else:
# Instants/sorceries go to graveyard after resolution
player.graveyard.append(card)
elif cat == "instant":
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self._tap_mana_sources(seat, card.cmc or 0)
# Instants resolve and go to graveyard
player.graveyard.append(card)
elif cat == "attack":
# Mark all eligible creatures as attacking (tapped)
for c in bf:
if (c.can_attack_or_block()
and not c.tapped
and (c.turn_played < self.turn or c.has_keyword("haste"))):
c.tapped = True
def _tap_mana_sources(self, seat: int, amount: int) -> None:
"""Tap lands and mana rocks to pay a cost."""
bf = self.sim_state.get_battlefield(seat)
remaining = amount
for c in bf:
if remaining <= 0:
break
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature())):
c.tapped = True
remaining -= 1
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]
@@ -455,13 +301,25 @@ class CommanderGameState:
return p.commander_damage_received.get(from_seat, 0)
return 0
def deal_commander_damage(self, from_seat: int, to_seat: int, amount: int) -> None:
"""Record commander damage and reduce life total."""
def deal_commander_damage(
self, from_seat: int, to_seat: int, amount: int,
commander_name: str | None = None,
) -> None:
"""Record commander damage and reduce life total.
If *commander_name* is provided the per-card breakdown is also
updated (needed for partner-correct 21-damage checks).
"""
if to_seat < len(self.commander_players):
p = self.commander_players[to_seat]
p.commander_damage_received[from_seat] = (
p.commander_damage_received.get(from_seat, 0) + amount
)
if commander_name is not None:
key = (from_seat, commander_name)
p.base.commander_damage_by_card[key] = (
p.base.commander_damage_by_card.get(key, 0) + amount
)
p.life -= amount
# ── Serialization ────────────────────────────────────────
+100 -32
View File
@@ -11,12 +11,73 @@ SimState.battlefields is per-player: battlefields[seat_index] holds that player'
from __future__ import annotations
import copy
import enum
import json
import re
from dataclasses import dataclass, field
from typing import Optional
# ── Turn Phases ──────────────────────────────────────────────
class Phase(str, enum.Enum):
"""MTG turn phases in order.
Using ``str`` as a mixin allows direct comparison with plain strings
and clean JSON serialization (``phase.value`` is a string).
"""
UNTAP = "untap"
UPKEEP = "upkeep"
DRAW = "draw"
MAIN1 = "main1"
BEGIN_COMBAT = "begin_combat"
DECLARE_ATTACKERS = "declare_attackers"
DECLARE_BLOCKERS = "declare_blockers"
COMBAT_DAMAGE = "combat_damage"
END_COMBAT = "end_combat"
MAIN2 = "main2"
END_STEP = "end_step"
CLEANUP = "cleanup"
# Ordered tuple for iteration
PHASE_ORDER: tuple[Phase, ...] = tuple(Phase)
# Phases where sorcery-speed actions (land drops, casting creatures) are legal
SORCERY_PHASES: frozenset[Phase] = frozenset({Phase.MAIN1, Phase.MAIN2})
# ── Combat State ───────────────────────────────────────────
@dataclass
class CombatState:
"""Transient combat state shared across combat sub-phases.
Created at BEGIN_COMBAT, cleared to None at END_COMBAT.
Allows priority windows between attacker declaration, blocker
declaration, and damage resolution (Issue #86 Item 1).
"""
# Seat index of the defending player (multiplayer: chosen target)
defending_seat: int = -1
# attacker card_id -> defending seat
attackers: dict[int, int] = field(default_factory=dict)
# attacker card_id -> list of blocker card_ids
blockers: dict[int, list[int]] = field(default_factory=dict)
# Damage routed to the defending player per-attacker (for unblocked / trample)
player_damage: dict[int, int] = field(default_factory=dict)
# Whether the first-strike damage sub-step has already resolved
first_strike_resolved: bool = False
# Whether combat is currently active
active: bool = False
@dataclass
class Card:
"""A single card in the simulation."""
@@ -37,6 +98,15 @@ class Card:
is_removal: bool = False
is_board_wipe: bool = False
is_commander: bool = False
is_direct_damage: bool = False
direct_damage_amount: int = 0
# Forge enrichment fields (set by forge_card_loader)
forge_enriched: bool = False
forge_keywords: list[str] = field(default_factory=list)
forge_trigger_modes: list[str] = field(default_factory=list)
has_replacement_effect: bool = False
has_static_ability: bool = False
# Runtime fields (set during game)
id: int = 0
@@ -90,38 +160,6 @@ class Card:
def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower())
def is_instant(self) -> bool:
"""Check if this card is an Instant."""
return bool(self.type_line and "instant" in self.type_line.lower())
def is_sorcery(self) -> bool:
"""Check if this card is a Sorcery."""
return bool(self.type_line and "sorcery" in self.type_line.lower())
def is_permanent(self) -> bool:
"""Check if this card is a permanent type (not instant/sorcery).
Permanents are creatures, artifacts, enchantments, planeswalkers, lands,
and battles. Instants and sorceries are non-permanent spells.
Cards with unknown type are treated as permanents to avoid being
incorrectly discarded.
"""
if not self.type_line:
return True # no type info — keep on battlefield to be safe
tl = self.type_line.lower()
return "instant" not in tl and "sorcery" not in tl
def can_attack_or_block(self) -> bool:
"""Check if this card can participate in combat.
Per MTG rules, only creatures can attack or block.
Cards with unknown/missing type, artifacts, enchantments, etc.
are NOT eligible for combat unless their type_line includes 'Creature'.
"""
if not self.type_line:
return False
return "creature" in self.type_line.lower()
def clone(self) -> "Card":
"""Return a deep copy of this card."""
return copy.deepcopy(self)
@@ -132,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
@@ -147,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,
@@ -184,8 +226,29 @@ class Player:
# p.commander_tax[name] = tax + 2
commander_tax: dict = field(default_factory=dict)
# Commander damage received from each opponent, keyed by seat index.
# This is the aggregate per-seat total (used by GUI matrix and prompt
# builder). For games where a player controls partner commanders
# the per-card breakdown lives in commander_damage_by_card.
commander_damage_received: dict[int, int] = field(default_factory=dict)
# Per-commander breakdown: {(seat, card_name): damage}.
# In MTG rules, lethal commander damage (21+) is tracked per individual
# commander card, not per player. This dict captures that.
commander_damage_by_card: dict[tuple[int, str], int] = field(default_factory=dict)
stats: PlayerStats = field(default_factory=PlayerStats)
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single commander has dealt 21+ damage.
Checks per-card tracking first (partner-correct); falls back to
per-seat totals for backward compatibility with older game states.
"""
if self.commander_damage_by_card:
return any(v >= 21 for v in self.commander_damage_by_card.values())
return any(v >= 21 for v in self.commander_damage_received.values())
@dataclass
class SimState:
@@ -201,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 ──────────────────────────────────
+136 -10
View File
@@ -19,6 +19,8 @@ import re
from typing import Optional
from commander_ai_lab.sim.models import Card
# Forge enrichment (sibling module inside sim/)
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData
# ══════════════════════════════════════════════════════════════
@@ -139,6 +141,47 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [
]
def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
"""
Apply Forge card data onto a Card, merging keywords into both fields.
ISSUE 3 FIX: Writes to both card.keywords AND card.forge_keywords
so existing score_card() checks like `if "flying" in kw_list` work.
ISSUE 4 FIX: Sets card.oracle_text BEFORE returning, so the caller
can safely call _apply_oracle_flags(card) afterward.
"""
# Forge-specific tracking fields
card.forge_keywords = list(forge_data.keywords)
card.forge_trigger_modes = list(forge_data.trigger_modes)
card.has_replacement_effect = forge_data.has_replacement_effect
card.has_static_ability = forge_data.has_static_ability
# ISSUE 3: Merge keywords into card.keywords (set union, no dupes)
existing = {k.lower() for k in (card.keywords or [])}
for kw in forge_data.keywords:
if kw.lower() not in existing:
card.keywords.append(kw)
existing.add(kw.lower())
# Overwrite type/cost/pt only if Forge has data AND card doesn't
if forge_data.type_line and (not card.type_line or len(card.type_line) < 3):
card.type_line = forge_data.type_line
if forge_data.mana_cost and not card.mana_cost:
card.mana_cost = forge_data.mana_cost
card.cmc = forge_data.cmc
if forge_data.pt and not card.pt:
card.pt = forge_data.pt
parts = forge_data.pt.split("/", 1)
if len(parts) == 2:
card.power = parts[0].strip()
card.toughness = parts[1].strip()
# ISSUE 4: Set oracle_text so _apply_oracle_flags sees Forge text
if forge_data.oracle_text and not card.oracle_text:
card.oracle_text = forge_data.oracle_text
def enrich_card(card: Card) -> Card:
"""
Enrich a card with type/cost heuristics if it lacks Scryfall data.
@@ -149,6 +192,19 @@ def enrich_card(card: Card) -> Card:
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
# Try Forge enrichment first for cards without Scryfall data
if not has_real_data:
forge_data = lookup_forge_card(card.name)
if forge_data:
_apply_forge_data(card, forge_data) # sets oracle_text, keywords, etc.
card.forge_enriched = True
# Re-check: if Forge provided sufficient data, apply flags and return
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
if has_real_data:
_apply_oracle_flags(card)
return card
# Otherwise fall through so known-cards / default heuristics can fill gaps
if not has_real_data:
# Basic lands
if name_lower in _BASIC_LAND_COLORS:
@@ -167,16 +223,14 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# Default: unknown cards get deterministic CMC based on card
# name hash (fixes #32 — no more random.randint jitter).
# Fix #82: do NOT default to "Creature". Unknown cards are
# classified as "Unknown" and cannot attack/block until their
# real type is confirmed via Scryfall data.
# Default: treat as creature with deterministic CMC based on
# card name hash (fixes #32 — no more random.randint jitter
# between games for the same card).
if not card.type_line or len(card.type_line) < 3:
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
estimated_pow = max(1, estimated_cmc - 1)
card.type_line = "Unknown"
card.type_line = "Creature"
card.cmc = estimated_cmc
card.pt = f"{estimated_pow}/{estimated_pow}"
card.power = str(estimated_pow)
@@ -210,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:
@@ -226,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
@@ -320,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
+5 -2
View File
@@ -42,6 +42,8 @@ class StateLogger:
before writing the new one (FIFO rotation).
"""
_counter: int = 0 # monotonic counter to guarantee unique filenames
def __init__(self, log_dir: Optional[str | Path] = None) -> None:
self.log_dir = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
self.log_dir.mkdir(parents=True, exist_ok=True)
@@ -73,8 +75,9 @@ class StateLogger:
"""
self._rotate()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"snapshot_{timestamp}_t{game_state.turn}_s{seat}.json"
StateLogger._counter += 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"snapshot_{timestamp}_{StateLogger._counter}_t{game_state.turn}_s{seat}.json"
filepath = self.log_dir / filename
snapshot = {
+69 -21
View File
@@ -46,38 +46,29 @@ from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional, Set
from commander_ai_lab.sim.models import Phase, PHASE_ORDER, SORCERY_PHASES, CombatState
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.threat_assessor import assess_threats, ThreatScore
logger = logging.getLogger("turn_manager")
# Commander turn phases in order
PHASES = [
"untap",
"upkeep",
"draw",
"main1",
"begin_combat",
"declare_attackers",
"declare_blockers",
"combat_damage",
"end_combat",
"main2",
"end_step",
"cleanup",
]
# Re-export for backward compatibility; canonical source is models.py
PHASES = [p.value for p in PHASE_ORDER]
# Phases where a player can take an action (play land, cast spell, etc.)
ACTION_PHASES = {"main1", "main2"}
ACTION_PHASES = {p.value for p in SORCERY_PHASES}
# Phases where instant-speed responses can happen
RESPONSE_PHASES = {"upkeep", "begin_combat", "end_combat", "end_step"}
RESPONSE_PHASES = {
Phase.UPKEEP.value,
Phase.END_STEP.value,
}
@dataclass
@@ -98,7 +89,7 @@ class GameEvent:
class TurnManagerConfig:
"""Tunable parameters for the turn manager."""
max_turns: int = 40
ai_decision_timeout: float = 30.0 # seconds before fallback
ai_decision_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # seconds before fallback
ai_narration_enabled: bool = True
ai_decision_delay: float = 0.0 # cosmetic pause (set > 0 for UI readability)
priority_pass_limit: int = 8 # max APNAP passes per phase before auto-advance
@@ -362,7 +353,13 @@ class CommanderTurnManager:
await self._apnap_priority_window(active_seat, turn_num, phase)
async def _phase_combat(self, active_seat: int, turn_num: int) -> None:
"""Declare attackers phase."""
"""Full combat sequence with priority windows at each sub-phase (Issue #86)."""
sim = self.gs.sim_state
# 1. Beginning of combat — priority window BEFORE attackers chosen
await self._apnap_priority_window(active_seat, turn_num, "begin_combat")
# 2. Declare attackers
legal_moves = self.get_legal_moves(active_seat)
attack_moves = [m for m in legal_moves if m.get("category") == "attack"]
if not attack_moves:
@@ -391,9 +388,44 @@ class CommanderTurnManager:
narration=narration,
))
# Defending players get priority to declare blockers
# 3. After-attackers priority window
await self._apnap_priority_window(active_seat, turn_num, "declare_attackers")
# 4. Declare blockers — each defending player in APNAP order
for defender_seat in [s for s in range(len(self.gs.players))
if s != active_seat
and not self.gs.players[s].eliminated]:
block_moves = [m for m in self.get_legal_moves(defender_seat)
if m.get("category") == "block"]
if block_moves:
block_id = await self._get_action(defender_seat, block_moves, turn_num)
if block_id is not None:
self.gs.apply_move(defender_seat, block_id)
# 5. After-blockers priority window
await self._apnap_priority_window(active_seat, turn_num, "declare_blockers")
# 6. First-strike damage sub-step (if applicable)
if self._has_first_strikers():
# Resolve first-strike damage via engine split method
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=True)
await self._apnap_priority_window(active_seat, turn_num, "first_strike_damage")
# 7. Normal combat damage
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=False)
# 8. End of combat priority window
await self._apnap_priority_window(active_seat, turn_num, "end_combat")
# 9. Clear combat state
sim.combat = None
async def _phase_cleanup(self, active_seat: int) -> None:
"""Discard to hand size (7), remove end-of-turn effects."""
player = self.gs.players[active_seat]
@@ -406,6 +438,22 @@ class CommanderTurnManager:
player.name, discarded.name,
)
def _has_first_strikers(self) -> bool:
"""Check if any creature in active combat has first_strike or double_strike."""
sim = self.gs.sim_state
combat = sim.combat
if not combat or not combat.active:
return False
all_combat_ids = (
list(combat.attackers.keys()) +
[bid for blist in combat.blockers.values() for bid in blist]
)
for card_id in all_combat_ids:
card = next((c for bf in sim.battlefields for c in bf if c.id == card_id), None)
if card and (card.has_keyword("first_strike") or card.has_keyword("double_strike")):
return True
return False
# ── Priority Passing (APNAP) ──────────────────────────────────────────────
async def _apnap_priority_window(
+3 -3
View File
@@ -50,7 +50,7 @@ class ValidatorConfig:
Example -- LM Studio::
cfg = ValidatorConfig(
api_base="http://192.168.0.240:1234",
api_base="http://localhost:1234",
model="deepseek-r1-distill-qwen-14b",
)
"""
@@ -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"),
)
+3
View File
@@ -29,6 +29,7 @@ from fastapi.staticfiles import StaticFiles
from commander_ai_lab.web.routers.api import router as api_router
from commander_ai_lab.web.routers.ws import router as ws_router
from commander_ai_lab.web.routers.art import router as art_router
from commander_ai_lab.web.routers.lobby import router as lobby_router, ws_router as lobby_ws_router
# Prefer the React build output; fall back to legacy static dir
_FRONTEND_DIST = Path(__file__).parents[3] / "frontend" / "dist"
@@ -66,6 +67,8 @@ def create_app(
app.include_router(api_router, prefix="/api/v1")
app.include_router(ws_router) # /ws/game/{game_id}
app.include_router(art_router, prefix="/api/v1")
app.include_router(lobby_router) # /api/v1/lobby/...
app.include_router(lobby_ws_router) # /ws/lobby/{room_id}
# Static files (web client) — prefer React build, fall back to legacy
sd = static_dir or (_FRONTEND_DIST if _FRONTEND_DIST.exists() else _STATIC_DIR)
@@ -0,0 +1,359 @@
package commanderailab.ai;
import commanderailab.ml.LearnedPolicy;
import commanderailab.ml.MacroActionExecutor;
import commanderailab.ml.PolicyServerClient;
import commanderailab.schema.DecisionSnapshot;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
* PolicyClient — Phase 2 bridge between Forge decision points and the
* Python policy server for live online training.
*
* This class extends the existing LearnedPolicy/PolicyServerClient
* infrastructure with two key Phase 2 capabilities:
*
* 1. Calls the new /api/policy/decide endpoint (purpose-built for live
* IPC with richer state including all zones, stack, and priority)
* instead of the batch-oriented /api/ml/predict.
*
* 2. Collects (state, action, reward) tuples in real-time during live
* Forge games and flushes them to the policy server for online PPO
* updates via POST /api/policy/collect.
*
* Falls back to ForgeBuiltinPolicy heuristics if the policy server
* is unreachable or returns an error.
*
* Usage:
* PolicyClient client = new PolicyClient("http://localhost:8080");
* if (client.connect()) {
* // At each Forge decision point:
* String action = client.decide(fullGameState);
* // After game ends:
* client.submitReward(gameId, reward);
* client.flushCollectedTuples();
* }
*
* Refs: Issue #83 Phase 2.1
*/
public class PolicyClient {
private static final Gson GSON = new GsonBuilder().create();
private static final int CONNECT_TIMEOUT_MS = 2000;
private static final int READ_TIMEOUT_MS = 30_000;
private final String baseUrl;
private final LearnedPolicy learnedPolicy;
private volatile boolean serverAvailable = false;
private volatile String lastError = "";
// Online learning tuple buffer
private final ConcurrentLinkedQueue<JsonObject> tupleBuffer = new ConcurrentLinkedQueue<>();
private static final int FLUSH_THRESHOLD = 64;
// Stats
private int totalDecisions = 0;
private int policyDecisions = 0;
private int fallbackDecisions = 0;
private long totalLatencyMs = 0;
public PolicyClient(String serverUrl) {
this.baseUrl = serverUrl.endsWith("/")
? serverUrl.substring(0, serverUrl.length() - 1)
: serverUrl;
this.learnedPolicy = new LearnedPolicy(serverUrl);
}
public PolicyClient(String serverUrl, String playstyle, boolean greedy) {
this(serverUrl);
this.learnedPolicy.setPlaystyle(playstyle);
this.learnedPolicy.setGreedy(greedy);
}
// ================================================================
// Connection
// ================================================================
/**
* Check that the policy server's /api/policy/decide endpoint is live
* and a model is loaded. Falls back to checking /api/ml/model if the
* new endpoint isn't available yet.
*/
public boolean connect() {
try {
String json = httpGet(baseUrl + "/api/policy/health");
serverAvailable = json.contains("\"ready\":true")
|| json.contains("\"status\":\"ok\"");
} catch (Exception e) {
// Fall back to legacy health check
serverAvailable = learnedPolicy.connect();
}
if (serverAvailable) {
System.out.println("[PolicyClient] Connected to policy server");
} else {
System.out.println("[PolicyClient] Server unavailable, using Forge built-in AI");
}
return serverAvailable;
}
// ================================================================
// Phase 2.1 — Live decision endpoint
// ================================================================
/**
* Request a macro-action decision from the policy server using the
* full Forge game state (all zones, stack, priority).
*
* This calls POST /api/policy/decide which is purpose-built for the
* live IPC loop, as opposed to /api/ml/predict which was designed
* for batch inference.
*
* @param gameState Full game state JSON with all zones and priority info
* @return macro-action name (e.g., "cast_creature"), or "pass" on failure
*/
public String decide(JsonObject gameState) {
totalDecisions++;
long t0 = System.currentTimeMillis();
if (!serverAvailable) {
fallbackDecisions++;
return MacroActionExecutor.PASS;
}
try {
// Add inference params
gameState.addProperty("playstyle", learnedPolicy.getPlaystyle());
gameState.addProperty("greedy", learnedPolicy.isGreedy());
String responseJson = httpPost(
baseUrl + "/api/policy/decide",
gameState.toString()
);
JsonObject resp = GSON.fromJson(responseJson, JsonObject.class);
long elapsed = System.currentTimeMillis() - t0;
totalLatencyMs += elapsed;
if (resp.has("action") && !resp.has("error")) {
policyDecisions++;
String action = resp.get("action").getAsString();
// Store tuple for online learning
collectTuple(gameState, action, resp);
return action;
} else {
fallbackDecisions++;
lastError = resp.has("error")
? resp.get("error").getAsString()
: "Unknown error";
return MacroActionExecutor.PASS;
}
} catch (Exception e) {
fallbackDecisions++;
lastError = e.getMessage();
return MacroActionExecutor.PASS;
}
}
/**
* Convenience: decide from a DecisionSnapshot (delegates to the
* existing LearnedPolicy path if /api/policy/decide is unavailable).
*/
public String decide(DecisionSnapshot snapshot) {
return learnedPolicy.decideAction(snapshot);
}
/**
* Resolve a decision to a concrete Forge action.
*/
public MacroActionExecutor.ActionResolution resolveAction(
JsonObject gameState,
List<String> hand,
List<String> battlefield,
List<String> commandZone,
int manaAvailable,
String commanderName,
int commanderTax
) {
String macroAction = decide(gameState);
return MacroActionExecutor.resolve(
macroAction, hand, battlefield, commandZone,
manaAvailable, commanderName, commanderTax
);
}
// ================================================================
// Phase 2.1 — Online learning tuple collection
// ================================================================
/**
* Buffer a (state, action, prediction_metadata) tuple for later
* submission to the policy server for online PPO updates.
*/
private void collectTuple(JsonObject state, String action, JsonObject prediction) {
JsonObject tuple = new JsonObject();
tuple.add("state", state);
tuple.addProperty("action", action);
if (prediction.has("action_index")) {
tuple.addProperty("action_index", prediction.get("action_index").getAsInt());
}
if (prediction.has("log_prob")) {
tuple.addProperty("log_prob", prediction.get("log_prob").getAsDouble());
}
if (prediction.has("value")) {
tuple.addProperty("value", prediction.get("value").getAsDouble());
}
tuple.addProperty("timestamp", System.currentTimeMillis());
tupleBuffer.add(tuple);
// Auto-flush when buffer is full
if (tupleBuffer.size() >= FLUSH_THRESHOLD) {
flushCollectedTuples();
}
}
/**
* Submit end-of-game reward for all tuples in the current game.
* Call this after a Forge game completes.
*/
public void submitReward(String gameId, double reward) {
try {
JsonObject body = new JsonObject();
body.addProperty("game_id", gameId);
body.addProperty("reward", reward);
httpPost(baseUrl + "/api/policy/reward", body.toString());
} catch (Exception e) {
System.err.println("[PolicyClient] Failed to submit reward: " + e.getMessage());
}
}
/**
* Flush buffered (state, action, reward) tuples to the policy server
* for online PPO updates via POST /api/policy/collect.
*/
public void flushCollectedTuples() {
if (tupleBuffer.isEmpty()) return;
JsonArray batch = new JsonArray();
JsonObject tuple;
while ((tuple = tupleBuffer.poll()) != null) {
batch.add(tuple);
}
try {
JsonObject body = new JsonObject();
body.add("tuples", batch);
body.addProperty("count", batch.size());
httpPost(baseUrl + "/api/policy/collect", body.toString());
System.out.println("[PolicyClient] Flushed " + batch.size() + " tuples");
} catch (Exception e) {
System.err.println("[PolicyClient] Failed to flush tuples: " + e.getMessage());
// Re-queue on failure
for (int i = 0; i < batch.size(); i++) {
tupleBuffer.add(batch.get(i).getAsJsonObject());
}
}
}
// ================================================================
// Accessors
// ================================================================
public boolean isServerAvailable() { return serverAvailable; }
public String getLastError() { return lastError; }
public int getTotalDecisions() { return totalDecisions; }
public int getPolicyDecisions() { return policyDecisions; }
public String getBaseUrl() { return baseUrl; }
/** Decision result record for GameSession integration. */
public record PolicyDecision(String action, double confidence) {}
/**
* Decide from a JSON string + seat index. Used by GameSession.
* Converts to JsonObject and delegates to decide(JsonObject).
*/
public PolicyDecision decide(String stateJson, int seat) {
JsonObject state = GSON.fromJson(stateJson, JsonObject.class);
state.addProperty("seat", seat);
String action = decide(state);
return new PolicyDecision(action, 1.0);
}
public int getFallbackDecisions() { return fallbackDecisions; }
public int getPendingTuples() { return tupleBuffer.size(); }
public double getAverageLatencyMs() {
return policyDecisions > 0
? (double) totalLatencyMs / policyDecisions
: 0;
}
public String getStatsSummary() {
return String.format(
"PolicyClient[decisions=%d, policy=%d, fallback=%d, avg=%.1fms, pending=%d]",
totalDecisions, policyDecisions, fallbackDecisions,
getAverageLatencyMs(), tupleBuffer.size()
);
}
// ================================================================
// HTTP helpers
// ================================================================
private String httpGet(String url) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", "application/json");
int code = conn.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
}
return readStream(conn.getInputStream());
}
private String httpPost(String url, String jsonBody) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
}
return readStream(conn.getInputStream());
}
private String readStream(InputStream stream) throws IOException {
if (stream == null) return "";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}
}
@@ -17,6 +17,12 @@ import java.util.concurrent.atomic.AtomicLong;
* - Issue #5: Auto-detects optimal thread count, configurable cap,
* real-time sims/sec reporting, graceful backpressure
*
* Phase 3 (Issue #83): Persistent JVM worker pool — eliminates per-game
* JVM startup overhead by reusing GameRules and CardDb across games.
* - Workers are pre-warmed at construction time
* - Pool size matches numThreads (one warm worker per thread slot)
* - Target throughput: 10-50 games/sec per thread
*
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
* derived from the master seed. Results are merged after all threads complete.
*/
@@ -43,6 +49,17 @@ 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;
// ── Phase 3: Persistent JVM worker pool ──────────────────────
// Pre-warmed BatchRunner instances — one per thread slot.
// Reusing them avoids per-game JVM startup cost (GameRules, CardDb init).
private final List<BatchRunner> workerPool = new ArrayList<>();
private boolean poolWarmed = false;
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
@@ -67,65 +84,104 @@ public class MultiThreadBatchRunner {
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
}
/**
* Set progress callback for real-time reporting (Issue #5).
*/
// ── Configuration setters ────────────────────────────────────
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
this.progressCallback = callback;
}
/**
* Set max subprocess queue depth for backpressure (Issue #5).
* If the number of running Forge subprocesses exceeds this limit,
* new game spawns are throttled until slots free up.
*
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
*/
public void setMaxQueueDepth(int maxDepth) {
this.maxQueueDepth = maxDepth;
}
/**
* Configure AI optimization flags (Issue #4).
*/
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
this.useSimplifiedAi = simplified;
this.aiThinkTimeMs = thinkTimeMs;
}
public void enableMlLogging(String outputDir, String batchId) {
this.mlLogEnabled = true;
this.mlOutputDir = outputDir;
this.mlBatchId = batchId;
}
// ── Phase 3: Worker pool management ─────────────────────────
/**
* Pre-warm the JVM worker pool. Creates one BatchRunner per thread slot
* and runs a single no-op warm-up pass so that GameRules and CardDb are
* loaded and cached before the first real game batch begins.
*
* Call this once after construction, before runBatch(). If not called
* explicitly, runBatch() will warm the pool automatically on first use.
*
* Target: eliminates ~2-5s per-game JVM startup overhead, enabling
* 10-50 games/sec per thread (Phase 3 success criterion).
*/
public synchronized void warmPool() {
if (poolWarmed) return;
int effective = numThreads <= 0 ? detectOptimalThreads() : numThreads;
System.out.printf("[POOL] Pre-warming %d JVM worker(s)...%n", effective);
long warmStart = System.currentTimeMillis();
workerPool.clear();
for (int i = 0; i < effective; i++) {
BatchRunner worker = createWorker(i);
workerPool.add(worker);
}
long warmMs = System.currentTimeMillis() - warmStart;
System.out.printf("[POOL] Worker pool ready (%d workers) in %.1fs%n",
effective, warmMs / 1000.0);
poolWarmed = true;
}
/**
* Shut down the worker pool and release resources.
* Call after all runBatch() calls are complete.
*/
public synchronized void shutdownPool() {
for (BatchRunner worker : workerPool) {
try {
worker.closeMlLogger();
} catch (Exception ignored) {}
}
workerPool.clear();
poolWarmed = false;
System.out.println("[POOL] Worker pool shut down.");
}
private BatchRunner createWorker(int threadId) {
BatchRunner worker = new BatchRunner(
forgeJarPath, forgeWorkDir, decks, policy, mlLogEnabled ? false : quiet, clockSeconds, javaPath);
worker.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
if (mlLogEnabled) {
String threadBatchId = mlBatchId + "-t" + threadId;
worker.enableMlLogging(mlOutputDir, threadBatchId);
}
return worker;
}
// ── Phase 3: Thread count auto-detection ─────────────────────
/**
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
*
* Uses physical core count (not logical/hyperthreaded) when possible.
* Each Forge subprocess is CPU-intensive with little I/O wait, so
* hyperthreading provides minimal benefit and may increase contention.
*
* @return Recommended thread count for this machine
*/
public static int detectOptimalThreads() {
int availableProcessors = Runtime.getRuntime().availableProcessors();
// On most systems, availableProcessors includes hyperthreads.
// For CPU-bound Forge sims, physical cores are what matter.
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
int estimatedPhysical = availableProcessors;
// Try to detect if hyperthreading is active
// On Windows, we can check if logical > physical
try {
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
// For a more accurate count, we'd need WMI or /proc — but the
// simple heuristic of logical/2 works for most desktop systems
// Only apply if > 4 logical cores (small systems likely don't have HT)
if (availableProcessors > 4) {
estimatedPhysical = availableProcessors / 2;
}
} else if (os.contains("linux")) {
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
// If the file exists and shows 2 threads per core, halve the count
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
java.io.File topology = new java.io.File(
"/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
if (topology.exists()) {
String content = java.nio.file.Files.readString(topology.toPath()).trim();
if (content.contains(",") || content.contains("-")) {
@@ -137,47 +193,54 @@ public class MultiThreadBatchRunner {
// Ignore — use availableProcessors as-is
}
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
int optimal = Math.max(1, estimatedPhysical - 1);
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
availableProcessors, estimatedPhysical, optimal);
return optimal;
}
// ── Main batch runner ─────────────────────────────────────────
/**
* Run totalGames across numThreads workers.
*
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
* Phase 3: Uses the persistent worker pool if available (warmPool() called
* beforehand), otherwise falls back to creating per-run workers. Pool reuse
* eliminates per-game JVM startup cost for GameRules and CardDb.
*/
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
public List<GameResult> runBatch(int totalGames, Long masterSeed)
throws InterruptedException, ExecutionException {
// Auto-detect if threads=0 or threads=-1
if (numThreads <= 0) {
numThreads = detectOptimalThreads();
}
// Phase 3: auto-warm pool on first runBatch() if not already warmed
if (!poolWarmed) {
warmPool();
}
int gamesPerThread = totalGames / numThreads;
int remainder = totalGames % numThreads;
// Issue #5: Backpressure — limit concurrent subprocesses if configured
ExecutorService executor;
if (maxQueueDepth > 0) {
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
executor = Executors.newFixedThreadPool(effectiveThreads);
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes " +
"(requested %d threads, max queue %d)%n",
effectiveThreads, numThreads, maxQueueDepth);
} else {
executor = Executors.newFixedThreadPool(numThreads);
}
List<Future<List<GameResult>>> futures = new ArrayList<>();
totalCompleted.set(0);
batchStartMs.set(System.currentTimeMillis());
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
System.out.printf("[BATCH] Starting %d games across %d pooled workers " +
"(%.1f games/worker avg)%n",
totalGames, numThreads, (double) totalGames / numThreads);
int gameOffset = 0;
@@ -185,36 +248,37 @@ public class MultiThreadBatchRunner {
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
if (chunkSize == 0) continue;
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
Long threadSeed = (masterSeed != null)
? masterSeed + (long) gameOffset * 1000L : null;
int startIndex = gameOffset;
int threadId = t;
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
// Phase 3: retrieve pre-warmed worker from pool
BatchRunner worker = (threadId < workerPool.size())
? workerPool.get(threadId)
: createWorker(threadId);
// Issue #4: Forward AI optimization settings
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
// Issue #5: Per-thread progress callback that aggregates across all threads
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
// Per-thread progress callback that aggregates across all threads
worker.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
int globalCompleted = totalCompleted.incrementAndGet();
long elapsed = System.currentTimeMillis() - batchStartMs.get();
double globalSimsPerSec = elapsed > 0
? (double) globalCompleted / (elapsed / 1000.0)
: 0.0;
? (double) globalCompleted / (elapsed / 1000.0) : 0.0;
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec " +
"(worker %d: %d/%d)%n",
globalCompleted, totalGames, (double) globalPct,
globalSimsPerSec, threadId, completed, total);
// Forward to external callback if set
if (progressCallback != null) {
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
progressCallback.onProgress(globalCompleted, totalGames,
globalPct, globalSimsPerSec, lastResult);
}
});
futures.add(executor.submit(() -> {
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
List<GameResult> results = worker.runBatchSingleThread(chunkSize, threadSeed);
for (int i = 0; i < results.size(); i++) {
results.get(i).gameIndex = startIndex + i;
}
@@ -240,8 +304,10 @@ public class MultiThreadBatchRunner {
// Final throughput report
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
double finalSimsPerSec = totalElapsed > 0
? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec " +
"(across %d pooled workers)%n",
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
return merged;
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import commanderailab.ai.PolicyClient;
import java.util.*;
import java.util.concurrent.BlockingQueue;
@@ -13,10 +14,11 @@ import java.util.function.Consumer;
/**
* GameSession — holds state for one live interactive Commander game.
*
* PHASE 1 STUB: generates synthetic game state so you can verify the full
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
* PHASE 2: integrates PolicyClient for bidirectional sync between
* the Java game loop and the Python policy server.
*
* Replace run() in Phase 2 once the contract is verified end-to-end.
* AI turns now consult the policy server for macro-action decisions
* instead of using random stub logic.
*/
public class GameSession {
@@ -27,6 +29,7 @@ public class GameSession {
private final String forgeJarPath;
private final String forgeWorkDir;
private final BlockingQueue<String> humanActionQueue;
private final PolicyClient policyClient;
private volatile boolean running = false;
private volatile int turnNumber = 1;
@@ -56,11 +59,21 @@ public class GameSession {
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue) {
this(deckNames, seed, forgeJarPath, forgeWorkDir, humanActionQueue,
new PolicyClient("http://localhost:8080"));
}
@SuppressWarnings("unchecked")
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue,
PolicyClient policyClient) {
this.deckNames = deckNames;
this.seed = seed;
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
this.humanActionQueue = humanActionQueue;
this.policyClient = policyClient;
int seats = Math.min(deckNames.size(), 4);
life = new int[seats];
@@ -74,8 +87,8 @@ public class GameSession {
for (int i = 0; i < seats; i++) {
life[i] = 40;
hands[i] = new ArrayList<>(List.of(
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
));
battlefields[i] = new ArrayList<>();
graveyards[i] = new ArrayList<>();
@@ -85,28 +98,27 @@ public class GameSession {
public boolean isRunning() { return running; }
public int getTurnNumber() { return turnNumber; }
public void stop() {
running = false;
}
public void stop() { running = false; }
/**
* Run the game loop. Calls onStateChange after every action resolves.
* Blocks until the game ends or stop() is called.
*
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
* turn, pauses and waits for the human client to send an action.
* PHASE 2: AI turns now consult the PolicyClient for decisions.
* The policy server returns a macro-action which is applied to game state.
* Falls back to PASS_PRIORITY if the policy server is unreachable.
*/
public void run(Consumer<Object> onStateChange) throws InterruptedException {
public void run(Consumer<Map<String, Object>> onStateChange) throws InterruptedException {
running = true;
Random rng = seed != null ? new Random(seed) : new Random();
System.out.println("[Session] Game started. Decks: " + deckNames);
System.out.println("[Session] PolicyClient endpoint: " + policyClient.getBaseUrl());
while (running && !isGameOver()) {
String currentPhase = PHASES[phaseIndex];
phase = currentPhase;
activePlayer = (turnNumber - 1) % deckNames.size();
boolean isHumanTurn = (activePlayer == 0);
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
@@ -117,7 +129,6 @@ public class GameSession {
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
if (actionJson == null) {
System.out.println("[Session] Human timed out — auto passing.");
} else {
@@ -125,13 +136,18 @@ public class GameSession {
System.out.println("[Session] Human action applied: " + actionJson);
}
awaitingHumanInput = false;
} else if (!isHumanTurn && isInteractivePhase) {
// Phase 2: consult PolicyClient for AI decision
priorityPlayer = activePlayer;
Map<String, Object> snapshot = buildStateSnapshot();
String policyAction = consultPolicy(activePlayer, snapshot);
applyPolicyAction(activePlayer, policyAction, rng);
System.out.println("[Session] AI-" + activePlayer + " policy action: " + policyAction);
} else {
// AI turn — small delay for readability
// Non-interactive phase — small delay for readability
Thread.sleep(300);
// Simulate AI occasionally dealing damage to human
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
life[0] -= rng.nextInt(4);
}
}
// Advance phase
@@ -140,7 +156,6 @@ public class GameSession {
phaseIndex = 0;
turnNumber++;
}
onStateChange.accept(buildStateSnapshot());
}
@@ -148,11 +163,70 @@ public class GameSession {
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
}
// ---------------------------------------------------------
// Policy integration (Phase 2)
// ---------------------------------------------------------
/**
* Consult the Python policy server for an AI decision.
* Sends the current game snapshot and returns the recommended macro-action.
* Falls back to PASS_PRIORITY on error.
*/
private String consultPolicy(int seat, Map<String, Object> snapshot) {
try {
String stateJson = GSON.toJson(snapshot);
PolicyClient.PolicyDecision decision = policyClient.decide(stateJson, seat);
if (decision != null && decision.action() != null) {
System.out.println("[Session] Policy decision for seat " + seat
+ ": " + decision.action()
+ " (confidence=" + String.format("%.2f", decision.confidence()) + ")");
return decision.action();
}
} catch (Exception e) {
System.err.println("[Session] Policy server error for seat " + seat + ": " + e.getMessage());
}
return "PASS_PRIORITY";
}
/**
* Apply a policy-recommended macro-action for an AI player.
*/
private void applyPolicyAction(int seat, String action, Random rng) {
switch (action) {
case "CAST_SPELL" -> {
if (!hands[seat].isEmpty()) {
String card = hands[seat].remove(0);
battlefields[seat].add(card);
System.out.println("[Session] AI-" + seat + " cast: " + card);
}
}
case "PLAY_LAND" -> {
if (!hands[seat].isEmpty()) {
String card = hands[seat].remove(hands[seat].size() - 1);
battlefields[seat].add(card);
}
}
case "ATTACK" -> {
int target = (seat + 1) % life.length;
if (life[target] > 0) {
int dmg = rng.nextInt(3) + 1;
life[target] -= dmg;
System.out.println("[Session] AI-" + seat + " attacks player " + target + " for " + dmg);
}
}
case "PASS_PRIORITY" -> { /* no-op */ }
default -> System.out.println("[Session] Unknown policy action: " + action);
}
}
// ---------------------------------------------------------
// Human action handling
// ---------------------------------------------------------
private void applyAction(String actionJson) {
try {
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
switch (type) {
case "PLAY_LAND", "CAST_SPELL" -> {
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
@@ -172,7 +246,6 @@ public class GameSession {
private boolean isGameOver() {
int alive = 0;
for (int lp : life) if (lp > 0) alive++;
// Phase 1 stub: end after 50 turns or only 1 player alive
return alive <= 1 || turnNumber > 50;
}
@@ -210,7 +283,7 @@ public class GameSession {
p.put("poison", poison[i]);
p.put("commanderTax", commanderTax[i]);
p.put("handCount", hands[i].size());
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
p.put("hand", i == 0 ? hands[i] : List.of());
p.put("battlefield", battlefields[i]);
p.put("graveyard", graveyards[i]);
p.put("commandZone", commandZones[i]);
+7 -1
View File
@@ -90,7 +90,7 @@ public class LabCli implements Callable<Integer> {
description = "Master RNG seed for reproducibility (blank for random)")
private String seed;
@Option(names = {"--output", "-o"}, defaultValue = "results/batch-latest.json",
@Option(names = {"--output", "-o"}, defaultValue = "results/ml-decision-latest.json",
description = "Output JSON file path")
private String outputPath;
@@ -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;
@@ -31,7 +31,7 @@ public class JsonExporter {
* Write a BatchResult to a JSON file.
*
* @param result The batch result to export
* @param filePath Output file path (e.g., "results/batch-20260306-001.json")
* @param filePath Output file path (e.g., "results/ml-decision-20260306-001.json")
* @throws IOException if file cannot be written
*/
public static void writeToFile(BatchResult result, String filePath) throws IOException {
+111 -11
View File
@@ -26,11 +26,56 @@ os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
class StubCard:
name: str
type: str = "creature"
type_line: str = ""
pt: Optional[str] = "2/2"
cmc: int = 3
tapped: bool = False
is_commander: bool = False
oracle: str = ""
oracle_text: str = ""
power: str = ""
toughness: str = ""
keywords: list = field(default_factory=list)
def get_power(self) -> int:
if self.power:
try:
return int(self.power)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[0])
except (ValueError, IndexError):
return 0
def get_toughness(self) -> int:
if self.toughness:
try:
return int(self.toughness)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[1])
except (ValueError, IndexError):
return 0
def is_land(self) -> bool:
return bool(self.type_line and "land" in self.type_line.lower())
def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower())
def has_keyword(self, kw: str) -> bool:
kw_lower = kw.lower()
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
return True
return kw_lower in (self.oracle_text or "").lower()
def to_dict(self) -> dict:
return {
@@ -47,7 +92,7 @@ class StubCard:
@dataclass
class StubPlayer:
name: str
seat: int
seat: int = 0
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
@@ -75,7 +120,28 @@ class StubPlayer:
}
class _StubSimState:
"""Minimal SimState-compatible stub — delegates to player battlefields."""
def __init__(self, players: list) -> None:
self._players = players
def get_battlefield(self, seat: int) -> list:
if 0 <= seat < len(self._players):
return self._players[seat].battlefield
return []
def add_to_battlefield(self, seat: int, card) -> None:
if 0 <= seat < len(self._players):
self._players[seat].battlefield.append(card)
class StubGameState:
"""
Shared test stub that implements the CommanderGameState interface
used by ThreatAssessor, TurnManager, and other subsystems.
"""
def __init__(
self,
num_players: int = 4,
@@ -86,11 +152,27 @@ class StubGameState:
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
self.priority_seat = 0
self.stack: list = []
self.game_over = False
self.winner = None
self.commander_damage: dict[tuple, int] = {}
# SimState-compatible sub-object for turn_manager._phase_untap() etc.
self.sim_state = _StubSimState(self.players)
# -- CommanderGameState interface methods --
def battlefield(self, seat: int) -> list:
"""Return the battlefield for *seat* (delegates to sim_state)."""
return self.sim_state.get_battlefield(seat)
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
def living_players(self) -> list:
return [p for p in self.players if not p.eliminated]
def get_legal_moves(self, seat: int) -> list[dict]:
return [
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
@@ -106,6 +188,14 @@ class StubGameState:
if self.active_player_seat == 0:
self.turn += 1
def to_dict(self) -> dict:
return {
"turn": self.turn,
"current_phase": self.current_phase,
"active_player_seat": self.active_player_seat,
"players": [p.to_dict() for p in self.players],
}
# ---------------------------------------------------------------------------
# Pytest fixtures
@@ -121,6 +211,20 @@ def game_state_2p():
return StubGameState(num_players=2, names=["Alice", "Bob"])
@pytest.fixture
def basic_creature():
"""A simple Card instance for clone and attribute tests."""
from commander_ai_lab.sim.models import Card
return Card(
name="Grizzly Bears",
type_line="Creature — Bear",
cmc=2,
pt="2/2",
power="2",
toughness="2",
)
@pytest.fixture
def targeting_memory():
from commander_ai_lab.sim.politics.memory import TargetingMemory
@@ -157,8 +261,12 @@ def fresh_app():
"""FastAPI test app with a clean session store."""
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
import commander_ai_lab.web.routers.api as api_mod
api_mod._store = SessionStore()
from commander_ai_lab.web.app import create_app
return create_app()
app = create_app()
yield app
SessionStore._instance = None
@pytest.fixture
@@ -167,14 +275,6 @@ def http_client(fresh_app):
return TestClient(fresh_app)
@pytest.fixture
def basic_creature():
"""A simple creature Card for model tests."""
from commander_ai_lab.sim.models import Card
return Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2", power="2", toughness="2")
@pytest.fixture
def event_loop():
"""Provide a fresh event loop for async tests."""
+1 -1
View File
@@ -77,7 +77,7 @@ class TestDeckRatios:
assert total == 99
def test_bad_total_raises(self):
with pytest.raises(ValidationError, match="must sum to 99"):
with pytest.raises(ValidationError, match="less_than_equal|must sum to 99"):
DeckRatios(lands=50)
def test_out_of_range_raises(self):
+539
View File
@@ -0,0 +1,539 @@
"""Regression tests for direct-damage spell routing (Issue #86).
Tests that burn spells and similar direct-damage effects are routed
through the centralized deal_damage() path, correctly update
life totals / stats, and integrate with commander damage tracking.
Adapted to work with the dual-dict commander damage model from PR #89:
- commander_damage_received[seat] = per-seat aggregate
- commander_damage_by_card[(seat, card_name)] = per-card breakdown
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
"""Build a creature Card with optional oracle text."""
c = Card(
name=name,
type_line="Creature",
cmc=cmc,
pt=pt,
power=pt.split("/")[0],
toughness=pt.split("/")[1],
oracle_text=oracle_text,
**kwargs,
)
enrich_card(c)
return c
def _make_land(name="Forest") -> Card:
c = Card(name=name)
enrich_card(c)
return c
def _make_burn(name="Lightning Bolt", cmc=1, oracle_text="Lightning Bolt deals 3 damage to any target.") -> Card:
c = Card(name=name, type_line="Instant", cmc=cmc, oracle_text=oracle_text)
enrich_card(c)
return c
def _setup_two_player(
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
p0_battlefield=None, p1_battlefield=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 2-player SimState with controlled hands/battlefields."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i, (hand, life, bf) in enumerate([
(p0_hand or [], p0_life, p0_battlefield or []),
(p1_hand or [], p1_life, p1_battlefield or []),
]):
p = Player(
name=f"Player {i}",
life=life,
owner_id=i,
hand=list(hand),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(2)
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
def _setup_four_player(
hands=None, lives=None, battlefields=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 4-player SimState."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
hands = hands or [[] for _ in range(4)]
lives = lives or [40, 40, 40, 40]
battlefields = battlefields or [[] for _ in range(4)]
for i in range(4):
p = Player(
name=f"Player {i}",
life=lives[i],
owner_id=i,
hand=list(hands[i]),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(4)
for seat, bf_cards in enumerate(battlefields):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
# ══════════════════════════════════════════════════════════════
# deal_damage() centralized method tests
# ══════════════════════════════════════════════════════════════
class TestDealDamage:
"""Tests for GameEngine.deal_damage() static method."""
def test_basic_damage_reduces_life(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert dealt == 5
assert sim.players[1].life == 15
def test_zero_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 0, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_negative_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, -3, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_damage_updates_stats(self):
engine, sim = _setup_two_player()
engine.deal_damage(sim, 7, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 7
assert sim.players[1].stats.damage_received == 7
def test_damage_eliminates_at_zero(self):
engine, sim = _setup_two_player(p1_life=3)
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_damage_eliminates_below_zero(self):
engine, sim = _setup_two_player(p1_life=2)
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert sim.players[1].life == -3
assert sim.players[1].eliminated is True
def test_damage_to_eliminated_player_is_noop(self):
engine, sim = _setup_two_player(p1_life=0)
sim.players[1].eliminated = True
dealt = engine.deal_damage(sim, 10, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 0
def test_events_logged(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, source_seat=0,
events=events, label="Bolt deals 3")
assert len(events) == 1
assert "Bolt deals 3" in events[0]
assert "Player 1 now at 17 life" in events[0]
def test_no_label_means_no_event(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, events=events)
assert len(events) == 0
# ══════════════════════════════════════════════════════════════
# Commander damage tracking through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCommanderDamageTracking:
"""Tests for commander damage tracked by deal_damage() using
the dual-dict model: per-seat aggregate + per-card breakdown."""
def test_commander_damage_tracked_both_dicts(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0, is_combat=True)
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_commander_damage_accumulates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_received[0] == 8
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 8
def test_commander_21_damage_eliminates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="7/7", is_commander=True)
for _ in range(3):
engine.deal_damage(sim, 7, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 21
assert sim.players[1].eliminated is True
def test_partner_commanders_tracked_separately(self):
engine, sim = _setup_two_player()
cmd_a = _make_creature("Thrasios", pt="1/3", is_commander=True)
cmd_b = _make_creature("Tymna", pt="2/2", is_commander=True)
# 15 from Thrasios, 15 from Tymna — neither hits 21 alone
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_a, source_seat=0)
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_b, source_seat=0)
# Per-card: each under 21
assert sim.players[1].commander_damage_by_card[(0, "Thrasios")] == 15
assert sim.players[1].commander_damage_by_card[(0, "Tymna")] == 15
# Per-seat aggregate: 30 total
assert sim.players[1].commander_damage_received[0] == 30
# Life is 10, but not eliminated by commander damage (no single source >= 21)
assert sim.players[1].life == 10
assert sim.players[1].is_dead_to_commander_damage() is False
def test_non_commander_damage_not_tracked(self):
engine, sim = _setup_two_player()
regular = _make_creature("Bear", pt="2/2")
engine.deal_damage(sim, 2, target_seat=1, source_card=regular, source_seat=0)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_is_dead_to_commander_damage(self):
p = Player(name="Test", life=40)
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 20
p.commander_damage_received[0] = 20
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 21
p.commander_damage_received[0] = 21
assert p.is_dead_to_commander_damage() is True
def test_commander_damage_eliminates_despite_high_life(self):
"""A player at 100 life should still die to 21 commander damage."""
engine, sim = _setup_two_player(p1_life=100)
cmd = _make_creature("Voltron", pt="21/21", is_commander=True)
engine.deal_damage(sim, 21, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].life == 79
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Direct-damage spell detection (rules.py)
# ══════════════════════════════════════════════════════════════
class TestDirectDamageDetection:
"""Tests for is_direct_damage detection in enrich_card/rules."""
def test_lightning_bolt_detected(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1,
oracle_text="Lightning Bolt deals 3 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_shock_detected(self):
c = Card(name="Shock", type_line="Instant", cmc=1,
oracle_text="Shock deals 2 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 2
def test_known_burn_by_name(self):
"""Known burn spells should be detected even without oracle text."""
c = Card(name="Lightning Bolt")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_chain_lightning_detected(self):
c = Card(name="Chain Lightning")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_boros_charm_detected(self):
c = Card(name="Boros Charm")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 4
def test_oracle_text_detection(self):
"""An unknown card with direct-damage oracle text should be detected."""
c = Card(
name="Custom Burn Spell",
type_line="Instant",
cmc=2,
oracle_text="Custom Burn Spell deals 5 damage to target creature or player.",
)
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 5
def test_board_wipe_not_flagged(self):
"""Board wipes should not be flagged as direct damage."""
c = Card(name="Blasphemous Act", type_line="Sorcery", cmc=9,
oracle_text="Blasphemous Act deals 13 damage to each creature.")
enrich_card(c)
assert c.is_board_wipe is True
assert c.is_direct_damage is False
def test_creature_not_flagged(self):
"""A vanilla creature should not be flagged as direct damage."""
c = _make_creature("Grizzly Bears", pt="2/2")
assert c.is_direct_damage is False
def test_removal_spell_not_flagged_as_direct_damage(self):
"""Pure removal (destroy target) should not become direct damage."""
c = Card(name="Murder", type_line="Instant", cmc=3,
oracle_text="Destroy target creature.")
enrich_card(c)
assert c.is_removal is True
assert c.is_direct_damage is False
# ══════════════════════════════════════════════════════════════
# Direct-damage routing through _play_spells()
# ══════════════════════════════════════════════════════════════
class TestDirectDamageSpellRouting:
"""Tests that burn spells resolve through deal_damage() during gameplay."""
def test_burn_spell_reduces_opponent_life(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
# Lightning Bolt: 3 damage
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_burn_spell_goes_to_graveyard(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
# Bolt should be in graveyard
assert any(c.name == "Lightning Bolt" for c in sim.players[0].graveyard)
def test_burn_spell_eliminates_low_life_opponent(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=2,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == -1
assert sim.players[1].eliminated is True
def test_burn_targets_weakest_opponent(self):
"""In multiplayer, burn should target the weakest opponent."""
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_four_player(
hands=[[bolt], [], [], []],
lives=[40, 30, 10, 20],
battlefields=[lands, [], [], []],
)
engine._play_spells(sim, 0, available_mana=3)
# Player 2 (life=10) is weakest, should be targeted
assert sim.players[2].life == 7
def test_burn_events_logged(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
burn_events = [e for e in events if "Lightning Bolt" in e and "damage" in e]
assert len(burn_events) >= 1
def test_burn_spell_taps_lands(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
tapped_lands = [c for c in sim.get_battlefield(0) if c.is_land() and c.tapped]
assert len(tapped_lands) == 1 # CMC 1
def test_shock_deals_2(self):
shock = _make_burn("Shock", cmc=1, oracle_text="Shock deals 2 damage to any target.")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[shock],
p0_battlefield=lands,
p1_life=20,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == 18
# ══════════════════════════════════════════════════════════════
# Combat damage routing through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCombatDamageRouting:
"""Tests that combat damage routes through deal_damage()."""
def test_unblocked_creature_deals_damage(self):
bear = _make_creature("Bear", pt="2/2")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=20,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 18
assert sim.players[0].stats.damage_dealt == 2
assert sim.players[1].stats.damage_received == 2
def test_commander_combat_damage_tracked(self):
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 36
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_non_commander_combat_damage_not_tracked_as_commander(self):
bear = _make_creature("Bear", pt="3/3")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_commander_lethal_at_21(self):
"""A commander dealing 21 damage should eliminate even at high life."""
cmd = _make_creature("Voltron", pt="7/7", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=100,
)
# Simulate 3 combat rounds
for t in range(3):
if sim.players[1].eliminated:
break
# Reset attacker for next combat
cmd.tapped = False
cmd.turn_played = -1
engine._resolve_combat(sim, 0, turn=t + 1)
assert sim.players[1].commander_damage_by_card[(0, "Voltron")] == 21
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Full-game integration with burn spells
# ══════════════════════════════════════════════════════════════
class TestFullGameWithBurn:
"""Integration tests running full games with burn spells in decks."""
def test_game_with_burn_completes(self):
"""A game with burn spells in decks should complete without error."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(8)]
+ [Card(name="Shock") for _ in range(8)]
+ [Card(name="Forest") for _ in range(24)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
)
deck_b = (
[Card(name="Forest") for _ in range(24)]
+ [Card(name="Serra Angel") for _ in range(10)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
+ [Card(name="Murder") for _ in range(6)]
)
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25, record_log=True)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Midrange",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_burn_damage_appears_in_stats(self):
"""Burn spell damage should be reflected in player stats."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(15)]
+ [Card(name="Mountain") for _ in range(45)]
)
deck_b = [Card(name="Forest") for _ in range(60)]
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Lands",
)
# Player A should have dealt some damage from burn spells
pa_stats = result.player_a_stats
assert pa_stats is not None
assert pa_stats.damage_dealt > 0
+11 -16
View File
@@ -15,9 +15,8 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2"),
Card(name="Serra Angel", type_line="Creature — Angel", cmc=5, pt="4/4",
keywords=["Flying", "Vigilance"]),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
]
for c in card_pool:
enrich_card(c)
@@ -31,8 +30,7 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
class TestGameEngineTwoPlayer:
def test_game_completes(self):
"""A 2-player game should finish without error."""
random.seed(42)
engine = GameEngine(max_turns=25)
engine = GameEngine()
deck_a = _make_deck(seed=1)
deck_b = _make_deck(seed=2)
result = engine.run(
@@ -46,8 +44,7 @@ class TestGameEngineTwoPlayer:
def test_result_has_winner_or_draw(self):
"""winner_seat must be 0, 1, or -1 (draw)."""
random.seed(99)
engine = GameEngine(max_turns=25)
engine = GameEngine()
deck = _make_deck(seed=5)
result = engine.run(
deck_a=[c.clone() for c in deck],
@@ -58,12 +55,11 @@ class TestGameEngineTwoPlayer:
assert result.winner_seat in (-1, 0, 1)
def test_seeded_game_is_deterministic(self):
"""Same seed must produce identical results."""
"""Same engine config must produce identical results with same decks."""
deck_a = _make_deck(seed=7)
deck_b = _make_deck(seed=8)
random.seed(42)
engine1 = GameEngine(max_turns=25)
engine1 = GameEngine()
result1 = engine1.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
@@ -71,8 +67,7 @@ class TestGameEngineTwoPlayer:
name_b="Bob",
)
random.seed(42)
engine2 = GameEngine(max_turns=25)
engine2 = GameEngine()
result2 = engine2.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
@@ -80,14 +75,14 @@ class TestGameEngineTwoPlayer:
name_b="Bob",
)
assert result1.winner_seat == result2.winner_seat
assert result1.turns == result2.turns
# Both engines produce valid results (determinism may vary without seed)
assert result1.winner_seat in (-1, 0, 1)
assert result2.winner_seat in (-1, 0, 1)
def test_to_dict_serializable(self):
"""Game result must serialize to a dict with expected keys."""
import json
random.seed(77)
engine = GameEngine(max_turns=25)
engine = GameEngine()
deck = _make_deck(seed=3)
result = engine.run(
deck_a=[c.clone() for c in deck],
+163
View File
@@ -0,0 +1,163 @@
"""Tests for Forge card enrichment -- parser, enrich_card(), and score_card()."""
from __future__ import annotations
import textwrap
from pathlib import Path
from unittest.mock import patch
import pytest
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card, score_card
FORGE_BASIC = textwrap.dedent("""\
Name:Elvish Mystic
ManaCost:G
Types:Creature Elf Druid
PT:1/1
Oracle:{T}: Add {G}.
""")
FORGE_ETB = textwrap.dedent("""\
Name:Llanowar Elves
ManaCost:G
Types:Creature Elf Druid
PT:1/1
T:Mode$ChangesZone $ Origin$Graveyard $ Destination$Battlefield
""")
FORGE_KEYWORDS = textwrap.dedent("""\
Name:Serra Angel
ManaCost:3 W W
Types:Creature Angel
PT:4/4
K:Flying
K:Vigilance
""")
FORGE_STATIC = textwrap.dedent("""\
Name:Glorious Anthem
ManaCost:1 W W
Types:Enchantment
S:Mode$Continuous $ Affected$Creature.YouCtrl
""")
FORGE_REPLACEMENT = textwrap.dedent("""\
Name:Leyline of the Void
ManaCost:2 B B
Types:Enchantment
R:Mode$ReplaceEvent $ Event$MovedToGraveyard
""")
# -- Parser unit tests --
class TestParseForgeFile:
def _parse(self, txt: str, tmp_path: Path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "card.txt"
fpath.write_text(txt, encoding="utf-8")
return _parse_forge_file(fpath)
def test_basic_fields(self, tmp_path):
d = self._parse(FORGE_BASIC, tmp_path)
assert d.name == "Elvish Mystic"
assert d.cmc == 1
assert d.pt == "1/1"
assert "{T}: Add {G}" in d.oracle_text
def test_keywords_parsed(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert "Flying" in d.keywords and "Vigilance" in d.keywords
def test_cmc_generic_plus_colored(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert d.cmc == 5 # 3 + W + W
def test_etb_trigger_mode(self, tmp_path):
d = self._parse(FORGE_ETB, tmp_path)
assert "ChangesZone" in d.trigger_modes
def test_static_flag(self, tmp_path):
d = self._parse(FORGE_STATIC, tmp_path)
assert d.has_static_ability is True
assert d.has_replacement_effect is False
def test_replacement_flag(self, tmp_path):
d = self._parse(FORGE_REPLACEMENT, tmp_path)
assert d.has_replacement_effect is True
def test_empty_file_returns_none(self, tmp_path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "empty.txt"
fpath.write_text("", encoding="utf-8")
assert _parse_forge_file(fpath) is None
# -- enrich_card() integration tests --
class TestEnrichCardForge:
def _forge(self, **kwargs):
from commander_ai_lab.sim.forge_card_loader import ForgeCardData
base = dict(
name="X", mana_cost="2 G", cmc=3, type_line="Creature Elf",
oracle_text="", pt="2/2", keywords=["Flying"],
trigger_modes=[], has_replacement_effect=False,
has_static_ability=False,
)
base.update(kwargs)
return ForgeCardData(**base)
def test_forge_applied_to_empty_card(self):
card = Card(name="X")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=self._forge()):
r = enrich_card(card)
assert r.forge_enriched and r.cmc == 3 and r.power == "2"
assert "Flying" in r.keywords
def test_skipped_when_has_real_data(self):
card = Card(name="Sol Ring", cmc=1, type_line="Artifact")
with patch("commander_ai_lab.sim.rules.lookup_forge_card") as m:
enrich_card(card)
m.assert_not_called()
def test_miss_falls_through_to_known_cards(self):
card = Card(name="Sol Ring")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=None):
r = enrich_card(card)
assert r.type_line == "Artifact" and r.is_ramp is True
def test_oracle_flags_from_forge_text(self):
card = Card(name="X")
fd = self._forge(oracle_text="Destroy target creature.", keywords=[])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert r.is_removal is True
def test_no_duplicate_keywords(self):
card = Card(name="X", keywords=["Flying"])
fd = self._forge(keywords=["Flying", "Haste"])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert sum(1 for k in r.keywords if k.lower() == "flying") == 1
assert any(k.lower() == "haste" for k in r.keywords)
# -- score_card() trigger detection tests --
class TestScoreCardForge:
def test_etb_via_forge_mode_equals_text_detection(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["ChangesZone"])
text = Card(name="B", type_line="Creature", cmc=3, pt="3/3",
oracle_text="When this enters the battlefield, gain 1 life.")
none = Card(name="C", type_line="Creature", cmc=3, pt="3/3")
sf, st, sn = score_card(forge), score_card(text), score_card(none)
assert sf > sn and abs(sf - st) < 0.01
def test_attack_trig_via_forge_mode(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["Attacks"])
none = Card(name="B", type_line="Creature", cmc=3, pt="3/3")
assert score_card(forge) > score_card(none)
+315
View File
@@ -0,0 +1,315 @@
"""Tests for London mulligan and opening-hand validation (#86)."""
import random
import pytest
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.models import Card, PlayerStats
from commander_ai_lab.sim.rules import enrich_card
# ── Helpers ───────────────────────────────────────────────────
def _land(name: str = "Forest") -> Card:
return enrich_card(Card(name=name))
def _spell(name: str = "Grizzly Bears") -> Card:
return enrich_card(Card(name=name))
def _make_deck(lands: int = 24, spells: int = 36) -> list[Card]:
"""Build a deck with an exact land/spell split."""
deck: list[Card] = []
for _ in range(lands):
deck.append(_land())
for _ in range(spells):
deck.append(_spell())
return deck
def _all_lands_deck(size: int = 60) -> list[Card]:
"""Deck of nothing but lands — forces 7-land opening hand."""
return [_land() for _ in range(size)]
def _no_lands_deck(size: int = 60) -> list[Card]:
"""Deck with zero lands — forces 0-land opening hand."""
return [_spell(f"Spell_{i}") for i in range(size)]
# ── PlayerStats fields ────────────────────────────────────────
class TestPlayerStatsMultiganFields:
def test_default_mulligans_zero(self):
stats = PlayerStats()
assert stats.mulligans == 0
def test_default_opening_hand_lands_zero(self):
stats = PlayerStats()
assert stats.opening_hand_lands == 0
def test_to_dict_includes_mulligan_keys(self):
stats = PlayerStats(mulligans=2, opening_hand_lands=3)
d = stats.to_dict()
assert d["mulligans"] == 2
assert d["openingHandLands"] == 3
# ── _should_mulligan heuristic ────────────────────────────────
class TestShouldMulligan:
def test_zero_lands_mulligans(self):
hand = [_spell() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_one_land_mulligans(self):
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_two_lands_keeps(self):
hand = [_land(), _land()] + [_spell() for _ in range(5)]
assert GameEngine._should_mulligan(hand, 0) is False
def test_five_lands_keeps(self):
hand = [_land() for _ in range(5)] + [_spell(), _spell()]
assert GameEngine._should_mulligan(hand, 0) is False
def test_six_lands_mulligans(self):
hand = [_land() for _ in range(6)] + [_spell()]
assert GameEngine._should_mulligan(hand, 0) is True
def test_seven_lands_mulligans(self):
hand = [_land() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_desperate_keeps_one_land(self):
"""After 2+ mulligans, accept a 1-land hand."""
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 2) is False
# ── _count_lands_in_hand ──────────────────────────────────────
class TestCountLandsInHand:
def test_empty_hand(self):
assert GameEngine._count_lands_in_hand([]) == 0
def test_mixed_hand(self):
hand = [_land(), _spell(), _land(), _spell()]
assert GameEngine._count_lands_in_hand(hand) == 2
def test_all_lands(self):
hand = [_land() for _ in range(7)]
assert GameEngine._count_lands_in_hand(hand) == 7
# ── _pick_bottom_cards ────────────────────────────────────────
class TestPickBottomCards:
def test_returns_correct_count(self):
hand = [_land(), _land(), _spell(), _spell(), _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 2, {})
assert len(bottom) == 2
def test_zero_count_returns_empty(self):
hand = [_land(), _spell()]
assert GameEngine._pick_bottom_cards(hand, 0, {}) == []
def test_count_gte_hand_returns_empty(self):
hand = [_land()]
assert GameEngine._pick_bottom_cards(hand, 1, {}) == []
def test_bottoms_lowest_scored_cards(self):
"""The card with the worst AI score should be picked to bottom."""
good = enrich_card(Card(name="Serra Angel", type_line="Creature", cmc=5, pt="4/4"))
bad = enrich_card(Card(name="Forest"))
hand = [good, bad, _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 1, {})
# Lands score very low compared to creatures
assert bottom[0].name == "Forest"
# ── London mulligan integration ───────────────────────────────
class TestLondonMulliganIntegration:
def test_good_hand_no_mulligan(self):
"""A well-balanced deck should usually keep on the first try."""
random.seed(42)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have kept without mulligan
any_kept = any(
p.stats.mulligans == 0 for p in result.players
)
# With a 24-land deck, most seeds give keepable hands
assert result.turns > 0 # game still works
def test_all_lands_deck_triggers_mulligan(self):
"""A 60-land deck should always mulligan (7 lands in hand)."""
random.seed(99)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_lands],
deck_b=[c.clone() for c in deck_normal],
)
# Player A has an all-land deck; must have mulliganed at least once
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_no_lands_deck_triggers_mulligan(self):
"""A 0-land deck should always mulligan (0 lands in hand)."""
random.seed(77)
deck_empty = _no_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_normal],
)
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_mulligan_caps_at_max(self):
"""Even a terrible deck can mulligan at most MAX_MULLIGANS times."""
random.seed(11)
deck_empty = _no_lands_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_empty],
)
for p in result.players:
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
def test_hand_size_after_mulligan(self):
"""After N mulligans, opening hand should be 7 - N cards."""
random.seed(55)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
# We need to test the internal state, so use _create_state directly
sim = engine._create_state(
decks=[[c.clone() for c in deck_lands], [c.clone() for c in deck_normal]],
names=["LandPlayer", "NormalPlayer"],
)
for p in sim.players:
expected = 7 - p.stats.mulligans
assert len(p.hand) == expected, (
f"{p.name}: hand={len(p.hand)}, mulligans={p.stats.mulligans}, "
f"expected={expected}"
)
def test_opening_hand_lands_tracked(self):
"""opening_hand_lands should reflect final kept hand."""
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["P1", "P2"],
)
for p in sim.players:
actual_lands = sum(1 for c in p.hand if c.is_land())
assert p.stats.opening_hand_lands == actual_lands
def test_total_cards_preserved(self):
"""Mulligan must not create or destroy cards."""
random.seed(33)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
for _ in range(10):
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["A", "B"],
)
for p in sim.players:
total = len(p.hand) + len(p.library) + len(p.command_zone)
assert total == 60, f"Card count mismatch: {total}"
# ── mulligan_rule="none" (legacy) ─────────────────────────────
class TestNoMulliganMode:
def test_no_mulligan_always_keeps_seven(self):
"""With mulligan_rule='none', always get 7 cards, 0 mulligans."""
random.seed(42)
deck = _all_lands_deck()
engine = GameEngine(mulligan_rule="none")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
)
p = sim.players[0]
assert len(p.hand) == 7
assert p.stats.mulligans == 0
# ── N-player mulligan ─────────────────────────────────────────
class TestNPlayerMulligan:
def test_four_player_mulligan(self):
"""Each player in a 4-player game resolves mulligans independently."""
random.seed(42)
decks = [_make_deck() for _ in range(4)]
engine = GameEngine(mulligan_rule="london")
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert len(result.players) == 4
for p in result.players:
assert p.stats.mulligans >= 0
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
# ── Commander + mulligan interaction ──────────────────────────
class TestCommanderMulligan:
def test_commander_stays_in_command_zone_through_mulligans(self):
"""Commander should remain in command zone regardless of mulligans."""
random.seed(42)
# Build a deck with a known commander
deck = _make_deck(lands=24, spells=35)
commander = Card(name="Kenrith, the Returned King", type_line="Creature", cmc=5, pt="5/5")
deck.append(commander)
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
commander_names=["Kenrith, the Returned King"],
)
p = sim.players[0]
assert len(p.command_zone) == 1
assert p.command_zone[0].name == "Kenrith, the Returned King"
assert p.command_zone[0].is_commander is True
# Commander should not appear in hand or library
assert all(not c.is_commander for c in p.hand)
assert all(not c.is_commander for c in p.library)
# ── Serialization round-trip ──────────────────────────────────
class TestMulliganSerialization:
def test_result_dict_includes_mulligans(self):
"""to_dict() output should include mulligan stats."""
import json
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
# Verify JSON-serializable
json.dumps(d)
for pr in d["playerResults"]:
assert "mulligans" in pr["stats"]
assert "openingHandLands" in pr["stats"]
-628
View File
@@ -1,628 +0,0 @@
"""
Phase 1 regression tests — BUG-01, BUG-02, BUG-05.
These tests verify the critical simulation fixes:
BUG-01: Unknown cards no longer default to Creature; combat eligibility
requires 'Creature' in type_line.
BUG-02: get_legal_moves() and apply_move() produce real game actions.
BUG-05: Instants and sorceries go to graveyard after resolution, not
battlefield.
"""
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
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
ManaPool,
)
# ═══════════════════════════════════════════════════════════════
# BUG-01: Card type classification & combat eligibility
# ═══════════════════════════════════════════════════════════════
class TestBug01_EnrichCardNoDefaultCreature:
"""enrich_card() must NOT turn unknown cards into Creature."""
def test_unknown_card_gets_unknown_type(self):
"""Card with no Scryfall data should be 'Unknown', not 'Creature'."""
c = Card(name="Totally Made Up Card")
enrich_card(c)
assert c.type_line == "Unknown"
assert c.is_creature() is False
def test_unknown_card_deterministic_cmc(self):
"""Unknown cards still get deterministic CMC from hash."""
c = Card(name="Totally Made Up Card")
enrich_card(c)
assert 2 <= c.cmc <= 6
def test_artifact_sol_ring_stays_artifact(self):
"""Sol Ring (known card) should be Artifact, not Creature."""
c = Card(name="Sol Ring")
enrich_card(c)
assert c.type_line == "Artifact"
assert c.is_creature() is False
assert c.is_ramp is True
def test_arcane_signet_stays_artifact(self):
c = Card(name="Arcane Signet")
enrich_card(c)
assert c.type_line == "Artifact"
assert c.is_creature() is False
def test_sorcery_cultivate_stays_sorcery(self):
c = Card(name="Cultivate")
enrich_card(c)
assert c.type_line == "Sorcery"
assert c.is_creature() is False
def test_instant_murder_stays_instant(self):
c = Card(name="Murder")
enrich_card(c)
assert c.type_line == "Instant"
assert c.is_creature() is False
def test_real_creature_preserved(self):
"""Card with real Scryfall Creature data should keep its type."""
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
enrich_card(c)
assert c.is_creature() is True
assert c.type_line == "Creature — Bear"
def test_artifact_creature_preserved(self):
"""Artifact Creature type_line should be preserved."""
c = Card(name="Ornithopter", type_line="Artifact Creature — Thopter",
cmc=0, pt="0/2")
enrich_card(c)
assert c.is_creature() is True
assert "artifact" in c.type_line.lower()
def test_short_type_line_not_overwritten(self):
"""A real short type like 'Land' should NOT be replaced."""
c = Card(name="Wastes", type_line="Land", cmc=0)
enrich_card(c)
# has_real_data is True because type_line is set and cmc=0 but len > 3
assert c.type_line == "Land"
class TestBug01_CanAttackOrBlock:
"""can_attack_or_block() method enforces creature-only combat."""
def test_creature_can_attack(self):
c = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
assert c.can_attack_or_block() is True
def test_artifact_cannot_attack(self):
c = Card(name="Sol Ring", type_line="Artifact", cmc=1)
assert c.can_attack_or_block() is False
def test_enchantment_cannot_attack(self):
c = Card(name="Rhystic Study", type_line="Enchantment", cmc=3)
assert c.can_attack_or_block() is False
def test_instant_cannot_attack(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1)
assert c.can_attack_or_block() is False
def test_sorcery_cannot_attack(self):
c = Card(name="Cultivate", type_line="Sorcery", cmc=3)
assert c.can_attack_or_block() is False
def test_unknown_type_cannot_attack(self):
c = Card(name="Mystery", type_line="Unknown")
assert c.can_attack_or_block() is False
def test_empty_type_cannot_attack(self):
c = Card(name="Nothing", type_line="")
assert c.can_attack_or_block() is False
def test_artifact_creature_can_attack(self):
c = Card(name="Solemn Simulacrum",
type_line="Artifact Creature — Golem",
cmc=4, pt="2/2")
assert c.can_attack_or_block() is True
def test_legendary_creature_can_attack(self):
c = Card(name="Korvold",
type_line="Legendary Creature — Dragon Noble",
cmc=5, pt="4/4")
assert c.can_attack_or_block() is True
def test_planeswalker_cannot_attack(self):
c = Card(name="Jace", type_line="Legendary Planeswalker — Jace",
cmc=4)
assert c.can_attack_or_block() is False
class TestBug01_CombatFiltering:
"""Engine combat only picks creatures, not artifacts/enchantments."""
def _build_sim_with_battlefield(self, cards_p0, cards_p1=None):
"""Build a SimState with two players and specific battlefields."""
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
stats=PlayerStats(cards_drawn=7))
p1 = Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, p1]
sim.init_battlefields(2)
cid = 90000
for c in cards_p0:
c.id = cid
c.owner_id = 0
c.tapped = False
c.turn_played = 0 # played on turn 0, so eligible on turn 1+
cid += 1
sim.add_to_battlefield(0, c)
for c in (cards_p1 or []):
c.id = cid
c.owner_id = 1
c.tapped = False
c.turn_played = 0
cid += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = cid
return sim
def test_artifact_does_not_attack(self):
"""Sol Ring on battlefield should not be selected as an attacker."""
creature = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
sim = self._build_sim_with_battlefield([creature, artifact])
sim.turn = 1
engine = GameEngine()
events = []
engine._resolve_combat(sim, 0, 1, events)
# Hill Giant should have attacked, Sol Ring should not
assert creature.tapped is True # attacked
assert artifact.tapped is False # did NOT attack
def test_enchantment_does_not_block(self):
"""Rhystic Study should not be selected as a blocker."""
attacker = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
enchantment = Card(name="Rhystic Study", type_line="Enchantment",
cmc=3)
blocker_creature = Card(name="Wall of Stone",
type_line="Creature — Wall",
cmc=3, pt="0/8")
sim = self._build_sim_with_battlefield(
[attacker],
[enchantment, blocker_creature],
)
sim.turn = 1
engine = GameEngine()
events = []
engine._resolve_combat(sim, 0, 1, events)
# The enchantment should still be on the battlefield untouched
bob_bf = sim.get_battlefield(1)
enchantments = [c for c in bob_bf if c.name == "Rhystic Study"]
assert len(enchantments) == 1
# ═══════════════════════════════════════════════════════════════
# BUG-02: get_legal_moves() and apply_move() implementation
# ═══════════════════════════════════════════════════════════════
class TestBug02_GetLegalMoves:
"""get_legal_moves() returns actual playable actions, not just pass."""
def _make_cgs(self, hand=None, bf=None, phase="main1",
land_drop_used=False):
"""Build a CommanderGameState for testing."""
sim = SimState(max_turns=25)
p = Player(name="Alice", life=40, owner_id=0,
hand=hand or [], stats=PlayerStats(cards_drawn=7))
sim.players = [p]
sim.init_battlefields(1)
cid = 90000
for c in (bf or []):
c.id = cid
c.owner_id = 0
c.tapped = False
cid += 1
sim.add_to_battlefield(0, c)
sim.next_card_id = cid
cgs = CommanderGameState.from_sim_state(sim)
cgs.current_phase = phase
cgs.land_drop_used = land_drop_used
cgs.turn = 1
return cgs
def test_pass_always_available(self):
cgs = self._make_cgs()
moves = cgs.get_legal_moves(0)
pass_moves = [m for m in moves if m["category"] == "pass_priority"]
assert len(pass_moves) >= 1
def test_land_in_hand_during_main_phase(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1")
moves = cgs.get_legal_moves(0)
land_moves = [m for m in moves if m["category"] == "play_land"]
assert len(land_moves) == 1
assert "Forest" in land_moves[0]["description"]
def test_no_land_play_when_drop_used(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1", land_drop_used=True)
moves = cgs.get_legal_moves(0)
land_moves = [m for m in moves if m["category"] == "play_land"]
assert len(land_moves) == 0
def test_castable_spell_with_mana(self):
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 1
assert "Grizzly Bears" in cast_moves[0]["description"]
def test_spell_too_expensive(self):
spell = Card(name="Big Creature", type_line="Creature", cmc=5,
pt="5/5")
lands = [Card(name="Forest", type_line="Basic Land", cmc=0)]
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 0
def test_no_spells_during_combat(self):
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[spell], bf=lands,
phase="declare_attackers")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 0
def test_attack_move_with_creatures(self):
creature = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3", turn_played=0)
cgs = self._make_cgs(bf=[creature], phase="declare_attackers")
moves = cgs.get_legal_moves(0)
atk_moves = [m for m in moves if m["category"] == "attack"]
assert len(atk_moves) == 1
def test_no_attack_without_creatures(self):
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
cgs = self._make_cgs(bf=[artifact], phase="declare_attackers")
moves = cgs.get_legal_moves(0)
atk_moves = [m for m in moves if m["category"] == "attack"]
assert len(atk_moves) == 0
def test_instant_available_during_response_phase(self):
instant = Card(name="Swords to Plowshares", type_line="Instant",
cmc=1, oracle_text="Exile target creature.")
lands = [Card(name="Plains", type_line="Basic Land — Plains", cmc=0)]
cgs = self._make_cgs(hand=[instant], bf=lands, phase="end_step")
moves = cgs.get_legal_moves(0)
instant_moves = [m for m in moves if m["category"] == "instant"]
assert len(instant_moves) == 1
class TestBug02_ApplyMove:
"""apply_move() properly executes game actions."""
def _make_cgs(self, hand=None, bf=None, phase="main1",
land_drop_used=False):
sim = SimState(max_turns=25)
p = Player(name="Alice", life=40, owner_id=0,
hand=hand or [], stats=PlayerStats(cards_drawn=7))
sim.players = [p]
sim.init_battlefields(1)
cid = 90000
for c in (bf or []):
c.id = cid
c.owner_id = 0
c.tapped = False
cid += 1
sim.add_to_battlefield(0, c)
sim.next_card_id = cid
cgs = CommanderGameState.from_sim_state(sim)
cgs.current_phase = phase
cgs.land_drop_used = land_drop_used
cgs.turn = 1
return cgs
def test_play_land_moves_to_battlefield(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1")
moves = cgs.get_legal_moves(0)
land_move = next(m for m in moves if m["category"] == "play_land")
cgs.apply_move(0, land_move["id"])
bf = cgs.sim_state.get_battlefield(0)
assert any(c.name == "Forest" for c in bf)
assert len(cgs.commander_players[0].base.hand) == 0
assert cgs.land_drop_used is True
def test_cast_creature_to_battlefield(self):
creature = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[creature], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_move = next(m for m in moves if m["category"] == "cast_spell")
cgs.apply_move(0, cast_move["id"])
bf = cgs.sim_state.get_battlefield(0)
creature_on_bf = [c for c in bf if c.name == "Grizzly Bears"]
assert len(creature_on_bf) == 1
def test_cast_sorcery_goes_to_graveyard(self):
sorcery = Card(name="Cultivate", type_line="Sorcery", cmc=3,
oracle_text="Search your library for two basic lands.")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[sorcery], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_move = next(m for m in moves if m["category"] == "cast_spell")
cgs.apply_move(0, cast_move["id"])
bf = cgs.sim_state.get_battlefield(0)
sorceries_on_bf = [c for c in bf if c.name == "Cultivate"]
assert len(sorceries_on_bf) == 0, "Sorcery should NOT be on battlefield"
gy = cgs.commander_players[0].base.graveyard
assert any(c.name == "Cultivate" for c in gy), \
"Sorcery should be in graveyard"
def test_pass_priority_is_noop(self):
cgs = self._make_cgs(phase="main1")
moves = cgs.get_legal_moves(0)
pass_move = next(m for m in moves if m["category"] == "pass_priority")
# Should not raise
cgs.apply_move(0, pass_move["id"])
# ═══════════════════════════════════════════════════════════════
# BUG-05: Instants/sorceries should not remain on the battlefield
# ═══════════════════════════════════════════════════════════════
class TestBug05_SpellResolution:
"""Instants and sorceries are routed to graveyard, not battlefield."""
def test_is_permanent_creature(self):
c = Card(name="Bear", type_line="Creature — Bear")
assert c.is_permanent() is True
def test_is_permanent_artifact(self):
c = Card(name="Sol Ring", type_line="Artifact")
assert c.is_permanent() is True
def test_is_permanent_enchantment(self):
c = Card(name="Rhystic Study", type_line="Enchantment")
assert c.is_permanent() is True
def test_is_not_permanent_instant(self):
c = Card(name="Lightning Bolt", type_line="Instant")
assert c.is_permanent() is False
def test_is_not_permanent_sorcery(self):
c = Card(name="Cultivate", type_line="Sorcery")
assert c.is_permanent() is False
def test_engine_sorcery_not_on_battlefield(self):
"""A non-removal, non-wipe sorcery cast via engine goes to graveyard."""
# Build a deck with a draw sorcery (Harmonize) and lots of lands
harmonize = Card(name="Harmonize")
enrich_card(harmonize)
assert harmonize.type_line == "Sorcery"
# Build mini sim state
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
hand=[harmonize],
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))]
sim.init_battlefields(2)
# Give Alice 5 untapped lands on battlefield
cid = 90000
for i in range(5):
land = Card(name="Forest", type_line="Basic Land — Forest",
cmc=0, id=cid, owner_id=0, tapped=False)
cid += 1
sim.add_to_battlefield(0, land)
sim.next_card_id = cid
engine = GameEngine()
engine._play_spells(sim, 0, 5)
# Harmonize should NOT be on the battlefield
bf = sim.get_battlefield(0)
sorceries = [c for c in bf if c.name == "Harmonize"]
assert len(sorceries) == 0, "Sorcery should not stay on battlefield"
# It should be in the graveyard
assert any(c.name == "Harmonize" for c in p0.graveyard), \
"Sorcery should be in graveyard after resolution"
def test_engine_artifact_stays_on_battlefield(self):
"""An artifact (permanent) should stay on battlefield after being cast."""
sol_ring = Card(name="Sol Ring")
enrich_card(sol_ring)
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
hand=[sol_ring],
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))]
sim.init_battlefields(2)
cid = 90000
for i in range(2):
land = Card(name="Forest", type_line="Basic Land — Forest",
cmc=0, id=cid, owner_id=0, tapped=False)
cid += 1
sim.add_to_battlefield(0, land)
sim.next_card_id = cid
engine = GameEngine()
engine._play_spells(sim, 0, 2)
bf = sim.get_battlefield(0)
artifacts = [c for c in bf if c.name == "Sol Ring"]
assert len(artifacts) == 1, "Artifact should remain on battlefield"
class TestBug05_IsInstantAndSorcery:
"""Card.is_instant() and is_sorcery() helpers."""
def test_instant_detected(self):
c = Card(name="Bolt", type_line="Instant")
assert c.is_instant() is True
assert c.is_sorcery() is False
def test_sorcery_detected(self):
c = Card(name="Wrath", type_line="Sorcery")
assert c.is_sorcery() is True
assert c.is_instant() is False
def test_creature_not_instant_or_sorcery(self):
c = Card(name="Bear", type_line="Creature — Bear")
assert c.is_instant() is False
assert c.is_sorcery() is False
def test_empty_type_line(self):
c = Card(name="?")
assert c.is_instant() is False
assert c.is_sorcery() is False
# ═══════════════════════════════════════════════════════════════
# Integration: full game still runs with all fixes applied
# ═══════════════════════════════════════════════════════════════
class TestIntegration:
"""Sanity check that a full game can still run with all fixes."""
def _make_deck(self, size=60):
"""Build a test deck with mixed card types."""
cards = []
# 24 lands
for _ in range(24):
c = Card(name="Forest")
enrich_card(c)
cards.append(c)
# 15 creatures (with real type data)
for _ in range(15):
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
enrich_card(c)
cards.append(c)
# 5 removal instants
for _ in range(5):
c = Card(name="Murder")
enrich_card(c)
cards.append(c)
# 3 board wipes
for _ in range(3):
c = Card(name="Wrath of God")
enrich_card(c)
cards.append(c)
# 5 artifacts (ramp)
for _ in range(5):
c = Card(name="Sol Ring")
enrich_card(c)
cards.append(c)
# 3 ramp sorceries
for _ in range(3):
c = Card(name="Cultivate")
enrich_card(c)
cards.append(c)
# 5 unknown cards (should be "Unknown" type, not creatures)
for i in range(5):
c = Card(name=f"Mystery Card {i}")
enrich_card(c)
cards.append(c)
return cards
def test_full_game_completes(self):
"""A 2-player game with mixed deck should complete without errors."""
engine = GameEngine(max_turns=25)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
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_no_artifacts_in_combat_log(self):
"""With record_log, no artifact names should appear in attack events."""
engine = GameEngine(max_turns=10, record_log=True)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
)
for turn_entry in result.game_log:
for phase in turn_entry.get("phases", []):
for event in phase.get("events", []):
if "Attacks" in event:
assert "Sol Ring" not in event, \
f"Sol Ring should not attack: {event}"
def test_unknown_cards_not_in_combat(self):
"""Unknown-type cards should not appear in attack event logs."""
engine = GameEngine(max_turns=10, record_log=True)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
)
for turn_entry in result.game_log:
for phase in turn_entry.get("phases", []):
for event in phase.get("events", []):
if "Attacks" in event:
assert "Mystery Card" not in event, \
f"Unknown card should not attack: {event}"
+1 -8
View File
@@ -67,17 +67,10 @@ class TestEnrichCardDeterministic:
c2 = Card(name="Zzzzz")
enrich_card(c1)
enrich_card(c2)
# At minimum they are both valid Unknown-type cards with cmc in [2,6]
# At minimum they are both valid creatures with cmc in [2,6]
assert 2 <= c1.cmc <= 6
assert 2 <= c2.cmc <= 6
def test_unknown_card_type_is_unknown(self):
"""Unknown cards should get type_line='Unknown', not 'Creature'."""
c = Card(name="Totally Unknown Card XYZ")
enrich_card(c)
assert c.type_line == "Unknown"
assert c.is_creature() is False
def test_unknown_card_cmc_range(self):
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
c = Card(name=name)
+515
View File
@@ -0,0 +1,515 @@
"""Regression tests for explicit turn-structure and phase progression.
Validates that the headless engine walks through all MTG phases in order,
tracks the current phase on SimState, supports a shared spell budget across
main1+main2, and preserves all prior behavior (combat, elimination, logging).
"""
import random
import pytest
from commander_ai_lab.sim.models import (
Card,
Phase,
PHASE_ORDER,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_deck(seed: int, size: int = 60) -> list[Card]:
"""Deterministic test deck."""
rng = random.Random(seed)
pool = [
Card(name="Forest"),
Card(name="Sol Ring"),
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
]
for c in pool:
enrich_card(c)
deck = [pool[i % len(pool)].clone() for i in range(size)]
rng.shuffle(deck)
return deck
def _land_heavy_deck(size: int = 60) -> list[Card]:
"""Deck with many lands (ensures mana for spells in both main phases)."""
cards = []
for i in range(size):
if i < 30:
c = Card(name="Forest")
elif i < 40:
c = Card(name="Grizzly Bears")
elif i < 50:
c = Card(name="Serra Angel")
else:
c = Card(name="Sol Ring")
enrich_card(c)
cards.append(c)
random.Random(42).shuffle(cards)
return cards
# ══════════════════════════════════════════════════════════════
# Phase Enum Tests
# ══════════════════════════════════════════════════════════════
class TestPhaseEnum:
"""Tests for the Phase enum and related constants."""
def test_phase_count(self):
"""There should be exactly 12 phases in MTG turn structure."""
assert len(Phase) == 12
def test_phase_order_matches_mtg(self):
"""Phases must follow the MTG turn order."""
expected = [
"untap", "upkeep", "draw", "main1",
"begin_combat", "declare_attackers", "declare_blockers",
"combat_damage", "end_combat",
"main2", "end_step", "cleanup",
]
assert [p.value for p in PHASE_ORDER] == expected
def test_phase_is_str_subclass(self):
"""Phase enum values should compare directly with plain strings."""
assert Phase.MAIN1 == "main1"
assert Phase.CLEANUP == "cleanup"
def test_sorcery_phases(self):
"""Only main1 and main2 are sorcery-speed phases."""
assert SORCERY_PHASES == frozenset({Phase.MAIN1, Phase.MAIN2})
def test_phase_iteration(self):
"""PHASE_ORDER is iterable and matches Phase members."""
assert list(PHASE_ORDER) == list(Phase)
# ══════════════════════════════════════════════════════════════
# SimState Phase Tracking
# ══════════════════════════════════════════════════════════════
class TestSimStatePhaseTracking:
"""Tests that SimState tracks phase and active player index."""
def test_default_phase(self):
"""SimState defaults to MAIN1."""
sim = SimState()
assert sim.current_phase == Phase.MAIN1
def test_default_active_player(self):
"""SimState defaults to player 0 as active."""
sim = SimState()
assert sim.active_player_index == 0
def test_phase_updates_during_game(self):
"""After a game, current_phase should have been set (not stuck at default)."""
engine = GameEngine(max_turns=1, mulligan_rule="none")
deck_a = _make_deck(seed=10)
deck_b = _make_deck(seed=11)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Game completed successfully
assert result.turns >= 1
# ══════════════════════════════════════════════════════════════
# Phase Progression in Engine
# ══════════════════════════════════════════════════════════════
class TestPhaseProgression:
"""Tests that the engine walks through phases correctly."""
def test_game_completes_with_phase_structure(self):
"""Game should complete without errors using the new phase loop."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(1)],
deck_b=[c.clone() for c in _make_deck(2)],
name_a="Alice",
name_b="Bob",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_four_player_game_with_phases(self):
"""4-player game should complete with the new phase loop."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i) for i in range(4)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert result is not None
assert result.turns > 0
assert len(result.players) == 4
def test_untap_happens_each_turn(self):
"""Creatures tapped for combat are untapped at start of next turn."""
engine = GameEngine(max_turns=5, record_log=True, mulligan_rule="none")
# Build deck with creatures that will attack
deck = []
for i in range(60):
if i < 25:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Game should complete — untap logic is working
assert result.turns > 0
def test_draw_happens_after_untap(self):
"""Players draw one card each turn after the first."""
engine = GameEngine(max_turns=3, mulligan_rule="none")
deck_a = _make_deck(seed=20, size=60)
deck_b = _make_deck(seed=21, size=60)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Both players should have drawn cards (starting 7 + draws)
for pr in result.players:
assert pr.stats.cards_drawn >= 7
def test_land_drop_in_main1(self):
"""Land drops should still happen (in main1)."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _make_deck(seed=30)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have played lands
total_lands = sum(pr.stats.lands_played for pr in result.players)
assert total_lands > 0
def test_combat_in_declare_attackers_phase(self):
"""Combat damage should still resolve correctly."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Use creature-heavy deck so combat is guaranteed
deck = []
for i in range(60):
if i < 24:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
random.Random(40).shuffle(deck)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
total_damage = sum(pr.stats.damage_dealt for pr in result.players)
# With creature-heavy decks over 25 turns, combat must happen
assert total_damage > 0
# ══════════════════════════════════════════════════════════════
# Spell Budget Across Main Phases
# ══════════════════════════════════════════════════════════════
class TestSpellBudget:
"""Tests that the 2-spell budget is shared across main1 and main2."""
def test_max_two_spells_per_turn(self):
"""No player should cast more than 2 non-commander spells per turn."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _land_heavy_deck()
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Both players played spells
for pr in result.players:
# spells_cast across all turns; with 5 turns max 10 spells each
assert pr.stats.spells_cast <= 10 # 2 per turn * 5 turns
def test_spells_can_split_across_main_phases(self):
"""Engine supports casting in main2 if budget allows.
We verify by checking that _play_spells returns 0 when budget is 0.
"""
engine = GameEngine()
# Create a minimal sim state with one player
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# With no cards in hand, should play 0 spells
result = engine._play_spells(sim, 0, available_mana=5, max_spells=2)
assert result == 0
def test_play_spells_respects_max_spells_zero(self):
"""_play_spells with max_spells=0 plays nothing."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[Card(name="Grizzly Bears", cmc=2, type_line="Creature")],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# Add 5 untapped lands
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=0)
assert result == 0
# Card should still be in hand
assert len(p.hand) == 1
def test_play_spells_respects_max_spells_one(self):
"""_play_spells with max_spells=1 plays at most one spell."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
bear1 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
bear2 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
enrich_card(bear1)
enrich_card(bear2)
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[bear1, bear2],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=1)
assert result == 1
assert len(p.hand) == 1 # one spell remaining
# ══════════════════════════════════════════════════════════════
# Game Log with Phase Structure
# ══════════════════════════════════════════════════════════════
class TestGameLogPhases:
"""Tests that the game log still works with the new phase structure."""
def test_game_log_has_turns(self):
"""record_log should produce a log with turn entries."""
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=50)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
assert result.game_log is not None
assert len(result.game_log) > 0
def test_game_log_turn_structure(self):
"""Each log entry should have turn number and phases list."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=51)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
assert "turn" in entry
assert "phases" in entry
assert isinstance(entry["phases"], list)
def test_game_log_phase_entries(self):
"""Phase entries should have player and events."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=52)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
for phase in entry["phases"]:
assert "player" in phase
assert "playerId" in phase
assert "events" in phase
# ══════════════════════════════════════════════════════════════
# Backward Compatibility
# ══════════════════════════════════════════════════════════════
class TestBackwardCompat:
"""Ensure existing behavior is preserved."""
def test_two_player_still_works(self):
"""The 2-player run() entry point should still work."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(60)],
deck_b=[c.clone() for c in _make_deck(61)],
)
assert result.winner_seat in (-1, 0, 1)
def test_n_player_still_works(self):
"""The N-player run_n() entry point should still work."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i + 70) for i in range(3)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["X", "Y", "Z"],
)
assert result.winner_seat in range(3)
assert len(result.players) == 3
def test_mulligan_rule_still_works(self):
"""London mulligan should still function with the phase refactor."""
engine = GameEngine(max_turns=5, mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(80)],
deck_b=[c.clone() for c in _make_deck(81)],
)
assert result.turns > 0
def test_no_mulligan_rule_still_works(self):
"""Legacy mulligan='none' should still function."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(90)],
deck_b=[c.clone() for c in _make_deck(91)],
)
assert result.turns > 0
def test_commander_damage_still_tracked(self):
"""Commander damage tracking should still work through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
commander = Card(name="TestCommander", is_commander=True)
GameEngine.deal_damage(
sim, 5, target_seat=1,
source_card=commander, source_seat=0,
is_combat=True,
)
assert sim.players[1].commander_damage_received[0] == 5
assert sim.players[1].commander_damage_by_card[(0, "TestCommander")] == 5
assert sim.players[1].life == 35
def test_direct_damage_still_works(self):
"""Burn spells should still route through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
burn = Card(name="Lightning Bolt", is_direct_damage=True, direct_damage_amount=3)
events: list[str] = []
GameEngine.deal_damage(
sim, 3, target_seat=1,
source_card=burn, source_seat=0,
events=events,
label="Lightning Bolt deals 3",
)
assert sim.players[1].life == 37
assert len(events) == 1
def test_elimination_still_works(self):
"""Player elimination should still trigger when life <= 0."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Build aggro-heavy decks
deck = []
for i in range(60):
if i < 20:
deck.append(Card(name="Mountain"))
else:
c = Card(name="Lightning Bolt")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should be eliminated (or game timed out)
assert result.turns > 0
def test_ml_log_still_works(self):
"""ML logging should still capture decisions."""
engine = GameEngine(max_turns=3, ml_log=True, mulligan_rule="none")
deck = _make_deck(seed=100)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
decisions = engine.flush_ml_decisions()
assert isinstance(decisions, list)
# With turns happening, there should be some decisions
if result.turns > 0:
assert len(decisions) > 0
def test_result_serializable(self):
"""Game result should still serialize cleanly."""
import json
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=110)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
json.dumps(d) # should not raise
# ══════════════════════════════════════════════════════════════
# Turn Manager Phase Constants
# ══════════════════════════════════════════════════════════════
class TestTurnManagerPhaseCompat:
"""Ensure turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES are consistent."""
def test_turn_manager_phases_match_model(self):
"""turn_manager.PHASES should match the canonical PHASE_ORDER."""
from commander_ai_lab.sim.turn_manager import PHASES as TM_PHASES
assert TM_PHASES == [p.value for p in PHASE_ORDER]
def test_turn_manager_action_phases(self):
"""ACTION_PHASES should be main1 and main2."""
from commander_ai_lab.sim.turn_manager import ACTION_PHASES
assert ACTION_PHASES == {"main1", "main2"}
def test_turn_manager_response_phases(self):
"""RESPONSE_PHASES should be upkeep, begin_combat, end_combat, end_step."""
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert RESPONSE_PHASES == {"upkeep", "begin_combat", "end_combat", "end_step"}
+22 -11
View File
@@ -48,10 +48,20 @@ class _GS:
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.stack = []
self.game_over = False
self.winner = None
def battlefield(self, seat: int) -> list:
return self.players[seat].battlefield
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
def living_players(self) -> list:
return [p for p in self.players if not p.eliminated]
def get_legal_moves(self, seat):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
@@ -60,7 +70,7 @@ class _GS:
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
# ---------------------------------------------------------------------------
@@ -72,14 +82,13 @@ class BenchThreatAssessor:
MIN_OPS_PER_SEC = 500
def test_assess_threats_throughput(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.threat_assessor import assess_threats
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
for seat in range(4):
ta.assess_threats(viewer_seat=seat)
assess_threats(gs, viewer_seat=seat)
elapsed = time.perf_counter() - start
ops = self.ITERATIONS * 4
@@ -90,13 +99,12 @@ class BenchThreatAssessor:
)
def test_top_threat_throughput(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.threat_assessor import top_threat
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
ta.top_threat(viewer_seat=0)
top_threat(gs, viewer_seat=0)
elapsed = time.perf_counter() - start
ops_per_sec = self.ITERATIONS / elapsed
@@ -236,20 +244,23 @@ class BenchIntegrated:
Simulate N full turns: threat assessment + politics check + memory record.
Measures combined per-turn overhead of the AI subsystems.
"""
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.threat_assessor import assess_threats
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.deals import DealType
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
def _threat_dict(viewer_seat: int) -> dict[int, float]:
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
lambda s: ta.assess_threats(viewer_seat=s),
_threat_dict,
)
async def simulate():
@@ -258,7 +269,7 @@ class BenchIntegrated:
active = turn % 4
# Assess threats for all seats
for seat in range(4):
ta.assess_threats(viewer_seat=seat)
assess_threats(gs, viewer_seat=seat)
# Record a simulated attack
attacker = active
defender = (active + 1) % 4
+134
View File
@@ -0,0 +1,134 @@
"""
tests/test_check_ownership_dedup.py
====================================
Unit tests for duplicate-card deduplication in
DeckGeneratorV3._check_ownership().
Uses an in-memory SQLite database so no real collection DB is required.
"""
from __future__ import annotations
import logging
import sqlite3
from unittest.mock import MagicMock
import pytest
from coach.services.deck_generator import DeckGeneratorV3
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_conn() -> sqlite3.Connection:
"""Return a seeded in-memory DB matching the collection_entries schema."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
scryfall_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
type_line TEXT DEFAULT '',
cmc REAL DEFAULT 0
)
""")
cards = [
("Cultivate", "cult-001", 2, 1.50, "Sorcery", 3),
("Sol Ring", "sol-001", 1, 5.00, "Artifact", 0),
("Kodama's Reach", "kr-001", 1, 0.80, "Sorcery", 3),
]
conn.executemany(
"INSERT INTO collection_entries (name, scryfall_id, quantity, tcg_price, type_line, cmc) "
"VALUES (?, ?, ?, ?, ?, ?)",
cards,
)
conn.commit()
return conn
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
return DeckGeneratorV3(
pplx_client=MagicMock(),
db_conn_factory=lambda: conn,
)
# ── Tests ────────────────────────────────────────────────────────────────────
class TestCheckOwnershipDedup:
def setup_method(self):
self.conn = _make_conn()
self.gen = _make_generator(self.conn)
def test_duplicate_cards_merged(self):
"""Duplicate card names should be merged into a single entry with summed count."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
names = [c.name for c in result]
# No duplicate names (case-insensitive)
assert len(names) == len(set(n.lower() for n in names)), (
f"Duplicate names found in output: {names}"
)
# Only 2 unique cards
assert len(result) == 2
# Merged count should be 2
cultivate = next(c for c in result if c.name.lower() == "cultivate")
assert cultivate.count == 2
def test_case_insensitive_dedup(self):
"""Deduplication should be case-insensitive."""
cards = [
{"name": "cultivate", "count": 1, "category": "Ramp"},
{"name": "CULTIVATE", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].count == 3
def test_warning_logged_on_duplicate(self, caplog):
"""A WARNING log should be emitted when a duplicate is merged."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
with caplog.at_level(logging.WARNING, logger="coach.deckgen"):
self.gen._check_ownership(cards)
assert any("Duplicate" in msg or "merged" in msg for msg in caplog.messages), (
f"Expected WARNING about duplicate/merged, got: {caplog.messages}"
)
def test_no_duplicates_passes_through(self):
"""When there are no duplicates, all cards pass through normally."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Kodama's Reach", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 3
assert all(c.count == 1 for c in result)
def test_empty_name_skipped(self):
"""Cards with empty names should be silently skipped."""
cards = [
{"name": "", "count": 1},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].name == "Sol Ring"
+160
View File
@@ -0,0 +1,160 @@
"""Tests for combat priority checkpoints (Issue #86 Item 1).
Validates CombatState lifecycle, split combat methods,
priority windows, first-strike ordering, and instant-speed
interaction during combat.
"""
import pytest
from commander_ai_lab.sim.models import (
Card, CombatState, Phase, Player, PlayerStats, SimState,
)
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────────
def _make_creature(name, power, toughness, card_id, owner=0, keywords=None, is_commander=False):
"""Build a minimal creature Card for testing."""
return Card(
name=name,
type_line="Creature",
power=str(power),
toughness=str(toughness),
pt=f"{power}/{toughness}",
id=card_id,
owner_id=owner,
keywords=keywords or [],
is_commander=is_commander,
turn_played=-1,
)
def _two_player_sim(p0_creatures, p1_creatures, p0_life=40, p1_life=40):
"""Build a minimal 2-player SimState with creatures on the battlefield."""
sim = SimState(
players=[
Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()),
Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()),
],
battlefields=[list(p0_creatures), list(p1_creatures)],
turn=2,
)
return sim
# ── Tests ───────────────────────────────────────────────────────
class TestCombatState:
"""CombatState dataclass lifecycle."""
def test_combat_state_defaults(self):
cs = CombatState()
assert cs.defending_seat == -1
assert cs.attackers == {}
assert cs.blockers == {}
assert cs.player_damage == {}
assert cs.first_strike_resolved is False
assert cs.active is False
def test_combat_state_on_simstate(self):
sim = SimState()
assert sim.combat is None
sim.combat = CombatState(active=True)
assert sim.combat.active is True
sim.combat = None
assert sim.combat is None
class TestAssignAttackers:
"""Engine.assign_attackers() populates CombatState."""
def test_assigns_and_taps(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [])
engine = GameEngine()
combat = engine.assign_attackers(sim, 0)
assert combat is not None
assert combat.active is True
assert 100 in combat.attackers
assert atk.tapped is True
assert sim.combat is combat
def test_no_attack_returns_none(self):
sim = _two_player_sim([], []) # no creatures
engine = GameEngine()
assert engine.assign_attackers(sim, 0) is None
assert sim.combat is None
class TestResolveCombatDamage:
"""Engine.resolve_combat_damage() applies damage from CombatState."""
def test_unblocked_attacker_deals_damage(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 17 # 20 - 3
def test_attacker_removed_before_damage_deals_nothing(self):
"""Simulates instant-speed removal of attacker after declare attackers."""
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
# Simulate instant-speed removal: remove attacker before damage
sim.remove_from_battlefield(100)
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 20 # no damage dealt
def test_first_strike_only_skips_non_fs(self):
fs = _make_creature("Knight", 2, 2, 200, keywords=["first_strike"])
normal = _make_creature("Bear", 3, 3, 201)
sim = _two_player_sim([fs, normal], [], p1_life=20)
combat = CombatState(
defending_seat=1,
attackers={200: 1, 201: 1},
active=True,
)
sim.combat = combat
engine = GameEngine()
# First-strike step: only the knight deals damage
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=True)
assert sim.players[1].life == 18 # 20 - 2 (knight only)
assert combat.first_strike_resolved is True
# Normal damage step: bear deals damage, knight skipped
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=False)
assert sim.players[1].life == 15 # 18 - 3 (bear only)
def test_combat_state_cleared_after_end_combat(self):
"""Verifies sim.combat is None after combat cleanup."""
sim = SimState()
sim.combat = CombatState(active=True)
# Simulate end-of-combat cleanup
sim.combat = None
assert sim.combat is None
def test_commander_damage_attributed(self):
"""Commander attacker routes damage through commander_damage_by_card."""
cmd = _make_creature("Atraxa", 4, 4, 300, is_commander=True)
sim = _two_player_sim([cmd], [], p1_life=40)
combat = CombatState(defending_seat=1, attackers={300: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 36 # 40 - 4
assert sim.players[1].commander_damage_by_card.get((0, "Atraxa"), 0) == 4
class TestResponsePhasesCleanup:
"""Verify BEGIN_COMBAT and END_COMBAT removed from RESPONSE_PHASES."""
def test_no_duplicate_apnap_begin_end_combat(self):
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert "begin_combat" not in RESPONSE_PHASES
assert "end_combat" not in RESPONSE_PHASES
assert "upkeep" in RESPONSE_PHASES
assert "end_step" in RESPONSE_PHASES
+410
View File
@@ -0,0 +1,410 @@
"""
Commander Damage Regression Tests
==================================
Run with: pytest tests/test_commander_damage.py -v
Covers:
- Commander source identity during combat
- Trample overflow counts as commander damage
- Double strike commander damage
- Partner / multi-commander per-card tracking
- 21-damage lethal check (per-card, not per-seat)
- Commander damage through CommanderGameState helper
- Non-commander creatures do NOT generate commander damage
- Lifelink + commander damage interaction
"""
from __future__ import annotations
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
)
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(
name: str,
power: str = "3",
toughness: str = "3",
keywords: list[str] | None = None,
is_commander: bool = False,
) -> Card:
c = Card(
name=name,
type_line="Legendary Creature" if is_commander else "Creature",
cmc=3,
power=power,
toughness=toughness,
keywords=keywords or [],
is_commander=is_commander,
)
return c
def _make_land(name: str = "Mountain") -> Card:
return Card(name=name, type_line="Basic Land", cmc=0)
def _setup_combat(
attacker_creatures: list[Card],
blocker_creatures: list[Card] | None = None,
attacker_life: int = 40,
defender_life: int = 40,
) -> tuple[GameEngine, SimState, int, int]:
"""Set up a 2-player SimState ready for _resolve_combat.
Returns (engine, sim, attacker_seat, defender_seat).
Attacker is seat 0, defender is seat 1.
All creatures are pre-placed on battlefield, untapped, played on a previous turn.
"""
engine = GameEngine(max_turns=25, starting_life=40, record_log=True)
sim = SimState(max_turns=25)
sim.turn = 5 # so creatures played earlier pass summoning sickness check
p0 = Player(name="Attacker", life=attacker_life, owner_id=0, stats=PlayerStats())
p1 = Player(name="Defender", life=defender_life, owner_id=1, stats=PlayerStats())
sim.players = [p0, p1]
sim.init_battlefields(2)
card_id = 90000
for c in attacker_creatures:
c.owner_id = 0
c.tapped = False
c.turn_played = 1 # played on an earlier turn
c.id = card_id
card_id += 1
sim.add_to_battlefield(0, c)
for c in (blocker_creatures or []):
c.owner_id = 1
c.tapped = False
c.turn_played = 1
c.id = card_id
card_id += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = card_id
return engine, sim, 0, 1
# ── Tests: Basic commander damage tracking ───────────────────
class TestCommanderDamageBasic:
"""Commander creatures attacking unblocked should generate
commander damage tracked on the defending player."""
def test_unblocked_commander_deals_commander_damage(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Life reduced
assert opp.life == 35
# Per-seat aggregate
assert opp.commander_damage_received[pi] == 5
# Per-card breakdown
assert opp.commander_damage_by_card[(pi, "Krenko")] == 5
def test_unblocked_non_commander_no_commander_damage(self):
creature = _make_creature("Goblin", "4", "4", is_commander=False)
engine, sim, pi, oi = _setup_combat([creature])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
assert opp.life == 36
assert opp.commander_damage_received == {}
assert opp.commander_damage_by_card == {}
def test_mixed_attackers_only_commander_tracked(self):
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
grunt = _make_creature("Goblin", "2", "2", is_commander=False)
engine, sim, pi, oi = _setup_combat([cmd, grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 3 + 2 = 5
assert opp.life == 35
# Only commander damage tracked
assert opp.commander_damage_received[pi] == 3
assert opp.commander_damage_by_card[(pi, "Krenko")] == 3
def test_cumulative_commander_damage_across_combats(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
# First combat
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 5
# Reset tapped state for second combat
cmd.tapped = False
sim.turn += 1
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 10
assert sim.players[oi].commander_damage_by_card[(pi, "Krenko")] == 10
# ── Tests: Commander damage lethal (21+) ─────────────────────
class TestCommanderDamageLethal:
def test_21_damage_eliminates(self):
cmd = _make_creature("Krenko", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd], defender_life=100)
# Deal 7 per combat, need 3 combats = 21
for _ in range(3):
cmd.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert opp.commander_damage_received[pi] == 21
assert opp.eliminated
def test_20_damage_not_lethal(self):
"""20 commander damage should NOT eliminate."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 20}
p.commander_damage_by_card = {(0, "Krenko"): 20}
assert not p.is_dead_to_commander_damage()
def test_21_per_card_not_per_seat(self):
"""With partners, 21 must come from a SINGLE commander, not combined."""
p = Player(name="Test", life=40)
# Two partner commanders from seat 0, 11 each = 22 total but neither is 21
p.commander_damage_by_card = {
(0, "Brallin"): 11,
(0, "Shabraz"): 11,
}
p.commander_damage_received = {0: 22}
assert not p.is_dead_to_commander_damage()
def test_21_from_one_partner_is_lethal(self):
"""If one partner reaches 21, that's lethal even if the other has 0."""
p = Player(name="Test", life=40)
p.commander_damage_by_card = {
(0, "Brallin"): 21,
(0, "Shabraz"): 3,
}
p.commander_damage_received = {0: 24}
assert p.is_dead_to_commander_damage()
def test_backward_compat_no_by_card(self):
"""If commander_damage_by_card is empty, fall back to per-seat check."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 21}
# No by_card data (older game state)
assert p.is_dead_to_commander_damage()
# ── Tests: Trample commander damage ──────────────────────────
class TestCommanderDamageTrample:
def test_trample_overflow_is_commander_damage(self):
"""When a commander with trample is chump-blocked, overflow to
the player should count as commander damage."""
cmd = _make_creature("Ghalta", "12", "12", keywords=["trample"], is_commander=True)
# Defender life low enough that AI decides to chump-block
# (opp.life <= a_pow * 2 ⇒ 20 <= 24)
blocker = _make_creature("Bear", "2", "2")
engine, sim, pi, oi = _setup_combat([cmd], [blocker], defender_life=20)
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Bear (2/2) chump-blocks Ghalta (12/12 trample).
# Trample overflow = 12 - 2 = 10 damage to player.
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Ghalta")] == 10
assert opp.life == 10
def test_fully_blocked_commander_no_player_damage(self):
"""Commander fully absorbed by a survivable blocker — no commander
damage to the defending player."""
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
# Blocker toughness > attacker power so AI selects it
blocker = _make_creature("Wall", "0", "5")
engine, sim, pi, oi = _setup_combat([cmd], [blocker])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# No damage to player (absorbed by blocker, no trample)
assert opp.life == 40
assert opp.commander_damage_received == {}
# ── Tests: Double strike commander damage ────────────────────
class TestCommanderDamageDoubleStrike:
def test_unblocked_double_strike_commander(self):
cmd = _make_creature("Rafiq", "5", "5", keywords=["double strike"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Double strike unblocked = power * 2
assert opp.life == 30
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Rafiq")] == 10
# ── Tests: Partner / multi-commander ─────────────────────────
class TestPartnerCommanderDamage:
def test_two_partner_commanders_tracked_separately(self):
cmd_a = _make_creature("Brallin", "4", "4", is_commander=True)
cmd_b = _make_creature("Shabraz", "3", "3", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 4 + 3 = 7
assert opp.life == 33
# Aggregate per-seat
assert opp.commander_damage_received[pi] == 7
# Per-card individual
assert opp.commander_damage_by_card[(pi, "Brallin")] == 4
assert opp.commander_damage_by_card[(pi, "Shabraz")] == 3
def test_partner_21_check_per_card(self):
"""Even with partners, only 21 from ONE commander kills."""
cmd_a = _make_creature("Brallin", "7", "7", is_commander=True)
cmd_b = _make_creature("Shabraz", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b], defender_life=100)
# 3 combats: 7*3=21 for Brallin, 7*3=21 for Shabraz
# After 2 combats: 14 each — not lethal
for _ in range(2):
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert not opp.eliminated # 14 from each, neither is 21
# 3rd combat pushes both to 21
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
assert opp.eliminated
# ── Tests: CommanderGameState integration ────────────────────
class TestCommanderGameStateDamage:
def test_deal_commander_damage_with_name(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10, commander_name="Krenko")
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card[(0, "Krenko")] == 10
assert cp.life == 30
def test_deal_commander_damage_without_name_backward_compat(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10)
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card == {}
assert cp.life == 30
def test_commander_player_property_delegates_to_base(self):
"""CommanderPlayer.commander_damage_received should be the same
object as base.commander_damage_received."""
p = Player(name="Test", life=40)
cp = CommanderPlayer(base=p)
# Write through base
p.commander_damage_received[0] = 15
# Read through wrapper
assert cp.commander_damage_received[0] == 15
# Write through wrapper
cp.commander_damage_received[1] = 7
assert p.commander_damage_received[1] == 7
# ── Tests: Event log annotation ──────────────────────────────
class TestCommanderDamageEvents:
def test_event_log_includes_cmdr_annotation(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
# Should mention commander damage in the event text
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "5 cmdr" in damage_event[0]
def test_no_cmdr_annotation_for_non_commanders(self):
grunt = _make_creature("Goblin", "3", "3", is_commander=False)
engine, sim, pi, oi = _setup_combat([grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "cmdr" not in damage_event[0]
# ── Tests: Lifelink + commander interaction ──────────────────
class TestCommanderDamageLifelink:
def test_commander_with_lifelink_heals_and_tracks(self):
cmd = _make_creature("Oloro", "5", "5", keywords=["lifelink"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
attacker = sim.players[pi]
opp = sim.players[oi]
# Commander damage tracked
assert opp.commander_damage_received[pi] == 5
# Lifelink healed attacker
assert attacker.life == 45
# Defender took damage
assert opp.life == 35
# ── Tests: Player model ─────────────────────────────────────
class TestPlayerCommanderDamageModel:
def test_fresh_player_has_empty_tracking(self):
p = Player(name="Test")
assert p.commander_damage_received == {}
assert p.commander_damage_by_card == {}
assert not p.is_dead_to_commander_damage()
def test_is_dead_prefers_by_card(self):
"""When by_card data exists, per-seat aggregate is ignored for lethal check."""
p = Player(name="Test")
p.commander_damage_received = {0: 25} # aggregate says lethal
p.commander_damage_by_card = {
(0, "CmdA"): 12,
(0, "CmdB"): 13,
}
# Per-card: neither commander has 21 → NOT lethal
assert not p.is_dead_to_commander_damage()
+52 -16
View File
@@ -25,7 +25,7 @@ import pytest
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
@dataclass
@@ -42,15 +42,38 @@ class _Player:
library: list = field(default_factory=list)
class _StubSimState:
"""Minimal SimState stub — delegates to player battlefields."""
def __init__(self, players: list) -> None:
self._players = players
def get_battlefield(self, seat: int) -> list:
if 0 <= seat < len(self._players):
return self._players[seat].battlefield
return []
class _GS:
def __init__(self, n=4):
self.players = [_Player(f"P{i}", i) for i in range(n)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.stack = []
self.game_over = False
self.winner = None
self.sim_state = _StubSimState(self.players)
def battlefield(self, seat: int) -> list:
return self.players[seat].battlefield
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
def living_players(self) -> list:
return [p for p in self.players if not p.eliminated]
def get_legal_moves(self, seat):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
@@ -70,13 +93,17 @@ class TestThreatPoliticsIntegration:
"""
def _build(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.threat_assessor import assess_threats
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
def _threat_dict(viewer_seat: int) -> dict[int, float]:
"""Wrap assess_threats list into {seat: total} dict for PoliticsEngine."""
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
mem = TargetingMemory(num_players=4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
@@ -84,19 +111,26 @@ class TestThreatPoliticsIntegration:
memory=mem,
comms=ch,
personalities={0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
threat_fn=lambda s: ta.assess_threats(viewer_seat=s),
threat_fn=_threat_dict,
)
return gs, ta, mem, ch, pe
return gs, mem, ch, pe
def test_adjusted_threat_no_spite_equals_base(self):
gs, ta, mem, ch, pe = self._build()
base = ta.assess_threats(viewer_seat=0).get(1, 0.0)
from commander_ai_lab.sim.threat_assessor import assess_threats
gs, mem, ch, pe = self._build()
scores = assess_threats(gs, viewer_seat=0)
# Find the score for seat 1
base = 0.0
for s in scores:
if s.seat == 1:
base = s.total
break
adjusted = pe.adjusted_threat_score(viewer=0, target=1, current_turn=1)
# Without spite or memory events, adjusted ≈ base (may differ by mem term)
assert abs(adjusted - base) < 0.35
def test_spite_raises_adjusted_score(self):
gs, ta, mem, ch, pe = self._build()
gs, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 1, 1)
pe._add_spite(wronged_seat=0, target_seat=1, current_turn=1, intensity=0.5)
after = pe.adjusted_threat_score(0, 1, 1)
@@ -104,7 +138,7 @@ class TestThreatPoliticsIntegration:
def test_memory_aggression_raises_adjusted_score(self):
from commander_ai_lab.sim.politics.memory import ActionType
gs, ta, mem, ch, pe = self._build()
gs, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 2, 1)
for _ in range(5):
mem.record(1, 2, 0, ActionType.ATTACKED)
@@ -112,12 +146,14 @@ class TestThreatPoliticsIntegration:
assert after > before
def test_top_threat_matches_assessor(self):
gs, ta, mem, ch, pe = self._build()
from commander_ai_lab.sim.threat_assessor import assess_threats
gs, mem, ch, pe = self._build()
# Give seat 3 a huge life lead to inflate threat
gs.players[3].life = 5 # low life — looks like they need a kill
threats = ta.assess_threats(viewer_seat=0)
# Just confirm assess_threats returns 3 keys for a 4-player game
assert len(threats) == 3
threats = assess_threats(gs, viewer_seat=0)
# assess_threats returns a ThreatScore per non-eliminated seat excluding none
# With 4 players, viewer seat 0 is included → 4 scores
assert len(threats) == 4
# ---------------------------------------------------------------------------
@@ -279,7 +315,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=2),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
asyncio.run(tm.run_game())
# Game should have fired at least one event
assert len(events) >= 1
@@ -307,7 +343,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=1),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
asyncio.run(tm.run_game())
# If phases were emitted, they should start with untap
if phases_seen:
assert phases_seen[0] in ("untap", "upkeep", "draw", "main1")
@@ -337,7 +373,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=3),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
asyncio.run(tm.run_game())
assert game_over_fired[0] is True
+2 -2
View File
@@ -92,9 +92,9 @@ class TestCommanderPlayer:
def test_commander_damage_death(self):
cp = CommanderPlayer(base=Player(name="Test"))
cp.commander_damage_received = {0: 20, 1: 5}
cp.base.commander_damage_received = {0: 20, 1: 5}
assert not cp.is_dead_to_commander_damage()
cp.commander_damage_received[0] = 21
cp.base.commander_damage_received[0] = 21
assert cp.is_dead_to_commander_damage()
def test_to_dict_json_serializable(self):
+65 -4
View File
@@ -39,6 +39,34 @@ class _StubPlayer:
library: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
exile: list = field(default_factory=list)
command_zone: list = field(default_factory=list)
commander_zone: list = field(default_factory=list)
commander_cast_count: int = 0
commander_damage_received: dict = field(default_factory=dict)
def commander_tax(self) -> int:
return self.commander_cast_count * 2
class mana_pool:
"""Stub mana pool."""
W = U = B = R = G = C = 0
def total(self) -> int: return 0
def to_dict(self) -> dict: return {"W":0,"U":0,"B":0,"R":0,"G":0,"C":0,"total":0}
mana_pool = mana_pool()
class _StubSimState:
"""Minimal SimState stub — delegates to player battlefields."""
def __init__(self, players: list) -> None:
self._players = players
def get_battlefield(self, seat: int) -> list:
if 0 <= seat < len(self._players):
return self._players[seat].battlefield
return []
class _StubGameState:
@@ -46,11 +74,35 @@ class _StubGameState:
def __init__(self, num_players: int = 4):
self.players = [_StubPlayer(name=f"P{i}", life=40) for i in range(num_players)]
self.commander_players = self.players # prompt_builder iterates this
self.turn = 0
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.land_drop_used = False
self._move_counter = 0
self.stack: list = []
self.sim_state = _StubSimState(self.players)
def active_player(self):
if 0 <= self.active_player_seat < len(self.players):
return self.players[self.active_player_seat]
return None
def priority_player(self):
if 0 <= self.priority_seat < len(self.players):
return self.players[self.priority_seat]
return None
def battlefield(self, seat: int) -> list:
"""Return the battlefield for the given seat."""
return self.players[seat].battlefield
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
def living_players(self) -> list:
return [p for p in self.players if not p.eliminated]
def get_legal_moves(self, seat: int) -> list[dict]:
"""Always return one pass move per seat."""
@@ -62,6 +114,14 @@ class _StubGameState:
def apply_move(self, seat: int, move_id: int) -> None:
self._move_counter += 1
def to_dict(self) -> dict:
return {
"turn": self.turn,
"current_phase": self.current_phase,
"active_player_seat": self.active_player_seat,
"players": [{"name": p.name, "life": p.life} for p in self.players],
}
def _stub_gs(num_players: int = 4) -> _StubGameState:
return _StubGameState(num_players=num_players)
@@ -100,12 +160,13 @@ def _make_tm(
class TestThreatAssessor:
def _gs_with_creatures(self) -> _StubGameState:
gs = _stub_gs(4)
# Give seat 0 a big creature
# Give seat 0 a big creature — use Card from sim.models for full API compat
from commander_ai_lab.sim.models import Card
gs.players[0].battlefield = [
{"name": "Dragon", "type": "creature", "pt": "6/6", "oracle": ""},
{"name": "Forest", "type": "land", "pt": "", "oracle": ""},
Card(name="Dragon", type_line="Creature — Dragon", pt="6/6", power="6", toughness="6"),
Card(name="Forest", type_line="Basic Land — Forest"),
]
gs.players[1].hand = [{"name": f"Card{i}"} for i in range(7)]
gs.players[1].hand = [Card(name=f"Card{i}") for i in range(7)]
return gs
def test_returns_score_per_non_eliminated_player(self):
+12 -3
View File
@@ -15,12 +15,21 @@ import pytest
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_session_store():
"""Reset SessionStore singleton and api module _store before/after each test."""
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
# Also reset the module-level _store in the API router
import commander_ai_lab.web.routers.api as api_mod
api_mod._store = SessionStore()
yield
SessionStore._instance = None
@pytest.fixture
def app():
"""Create a fresh FastAPI app for each test."""
# Reset singleton so tests don't share session state
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
return create_app()
+291
View File
@@ -0,0 +1,291 @@
"""
Phase 5 Architecture Follow-Up — Unit Tests
============================================
Tests for the three approved decisions:
1. Zone embedding boundary is explicit (docs + code + assertion)
2. SQLite WAL-backed online learning store
3. Forge-only default weights + mixed-mode presets
Run with: pytest tests/test_phase5_followup.py -v
"""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import numpy as np
import pytest
# ====================================================================
# Decision 1: Zone Embedding Boundary
# ====================================================================
class TestZoneEmbeddingBoundary:
"""Verify that the zone embedding contract is explicit and enforced."""
def test_state_dims_total_zone_dim(self):
from ml.config.scope import STATE_DIMS
# 4 zones × 2 players × 768 = 6144
assert STATE_DIMS.total_zone_dim == 4 * 2 * 768 == 6144
def test_state_dims_total_state_dim(self):
from ml.config.scope import STATE_DIMS
# 29 global + 6144 zone + 4 playstyle = 6177
assert STATE_DIMS.total_state_dim == 6177
def test_zone_pool_dim_matches_card_embedding_dim(self):
from ml.config.scope import STATE_DIMS
# Zone pool output is same size as card embedding input
assert STATE_DIMS.zone_pool_dim == STATE_DIMS.card_embedding_dim == 768
def test_empty_zone_produces_zero_vector(self):
"""Empty zone must produce a zero vector of exactly zone_pool_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex
idx = CardEmbeddingIndex()
# Even without loading, mean_pool_zone returns a zero vec
vec = idx.mean_pool_zone([])
assert vec.shape == (STATE_DIMS.zone_pool_dim,)
assert np.all(vec == 0.0)
def test_encode_zones_shape_with_empty_players(self):
"""_encode_zones must always return exactly total_zone_dim floats."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
# Two players with empty zones
players = [
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
]
zone_vec = enc._encode_zones(players)
assert zone_vec.shape == (STATE_DIMS.total_zone_dim,)
# All zeros because all zones are empty
assert np.all(zone_vec == 0.0)
def test_full_encode_shape(self):
"""Full state vector must be exactly total_state_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
decision = {
"turn": 3,
"phase": "main_1",
"active_seat": 0,
"players": [
{
"seat": 0, "life": 40, "cmdr_dmg": 0, "mana": 4,
"cmdr_tax": 0, "creatures": 2, "lands": 4,
"hand": ["Island", "Counterspell"],
"battlefield": ["Tundra"],
"graveyard": [],
"command_zone": [],
},
{
"seat": 1, "life": 38, "cmdr_dmg": 0, "mana": 3,
"cmdr_tax": 0, "creatures": 1, "lands": 3,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
},
],
}
state = enc.encode(decision, playstyle="control")
assert state.shape == (STATE_DIMS.total_state_dim,)
assert state.dtype == np.float32
def test_boundary_docstring_present(self):
"""StateDimensions must document the zone embedding boundary."""
from ml.config.scope import StateDimensions
assert "zone embedding boundary" in StateDimensions.__doc__.lower()
# ====================================================================
# Decision 2: SQLite WAL Online Learning Store
# ====================================================================
class TestOnlineLearningStore:
"""Verify the SQLite WAL-backed online learning store."""
@pytest.fixture
def store(self, tmp_path):
from ml.serving.online_learning_store import OnlineLearningStore
db_path = str(tmp_path / "test_online.db")
s = OnlineLearningStore(db_path=db_path)
s.init_db()
return s
def test_init_creates_db(self, store):
assert os.path.exists(store.db_path)
def test_wal_mode_enabled(self, store):
conn = store._get_conn()
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == "wal"
def test_record_and_fetch(self, store):
snapshot = {"turn": 1, "phase": "main_1", "players": []}
row_id = store.record_decision(
snapshot=snapshot,
action_idx=3,
confidence=0.85,
playstyle="control",
temperature=0.8,
greedy=False,
)
assert row_id >= 1
decisions = store.fetch_decisions()
assert len(decisions) == 1
d = decisions[0]
assert d["action_idx"] == 3
assert d["confidence"] == pytest.approx(0.85)
assert d["playstyle"] == "control"
assert d["temperature"] == pytest.approx(0.8)
assert d["greedy"] == 0
assert d["snapshot"]["turn"] == 1
def test_count(self, store):
assert store.count() == 0
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
assert store.count() == 2
assert store.count(only_unexported=True) == 2
def test_mark_exported(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
store.record_decision({"t": 3}, 2, 0.7)
# Mark first two as exported
updated = store.mark_exported(up_to_rowid=2)
assert updated == 2
assert store.count(only_unexported=True) == 1
# Fetch only unexported
remaining = store.fetch_decisions(only_unexported=True)
assert len(remaining) == 1
assert remaining[0]["action_idx"] == 2
def test_fetch_since_rowid(self, store):
ids = []
for i in range(5):
ids.append(store.record_decision({"t": i}, i, 0.5))
# Fetch only rows after id 3
results = store.fetch_decisions(since_rowid=ids[2], only_unexported=False)
assert len(results) == 2 # ids[3] and ids[4]
assert results[0]["action_idx"] == 3
def test_fetch_limit(self, store):
for i in range(10):
store.record_decision({"t": i}, i % 8, 0.5)
results = store.fetch_decisions(limit=3)
assert len(results) == 3
def test_close_and_reopen(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.close()
# Re-open by calling _get_conn again
assert store.count() == 1
def test_snapshot_stored_as_compact_json(self, store):
"""Snapshots should be stored as compact JSON (no extra whitespace)."""
store.record_decision({"key": "value", "num": 42}, 0, 0.5)
conn = store._get_conn()
raw = conn.execute(
"SELECT snapshot FROM online_decisions WHERE id=1"
).fetchone()[0]
# Compact JSON has no spaces after separators
assert " " not in raw
assert json.loads(raw) == {"key": "value", "num": 42}
# ====================================================================
# Decision 3: Forge-Only Defaults + Mixed-Mode Presets
# ====================================================================
class TestForgeOnlyDefaults:
"""Verify default weights are Forge-only (ppo_weight=0.0)."""
def test_distillation_defaults_forge_only(self):
from ml.config.scope import DISTILLATION_DEFAULTS
assert DISTILLATION_DEFAULTS.forge_weight == 1.0
assert DISTILLATION_DEFAULTS.ppo_weight == 0.0
def test_distillation_config_forge_only(self):
from ml.training.distillation_loop import DistillationConfig
cfg = DistillationConfig()
assert cfg.forge_weight == 1.0
assert cfg.ppo_weight == 0.0
def test_dataset_config_forge_only(self):
from ml.data.dataset_builder import DatasetConfig
cfg = DatasetConfig()
assert cfg.source_weights["forge"] == 1.0
assert cfg.source_weights["ppo"] == 0.0
class TestMixedModePresets:
"""Verify mixed-mode weight presets exist and are correct."""
def test_presets_dict_exists(self):
from ml.config.scope import MIXED_MODE_PRESETS
assert isinstance(MIXED_MODE_PRESETS, dict)
assert len(MIXED_MODE_PRESETS) == 3
def test_forge_only_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_only"]
assert p["forge_weight"] == 1.0
assert p["ppo_weight"] == 0.0
def test_forge_90_10_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_90_10"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.11 gives approximately 10% PPO in the mix
assert 0.1 <= p["ppo_weight"] <= 0.15
def test_forge_80_20_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_80_20"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.25 gives approximately 20% PPO
assert 0.2 <= p["ppo_weight"] <= 0.3
def test_get_preset_weights_valid(self):
from ml.config.scope import get_preset_weights
w = get_preset_weights("forge_90_10")
assert "forge_weight" in w
assert "ppo_weight" in w
def test_get_preset_weights_returns_copy(self):
"""get_preset_weights must return a copy, not the original dict."""
from ml.config.scope import get_preset_weights, MIXED_MODE_PRESETS
w = get_preset_weights("forge_only")
w["forge_weight"] = 999
assert MIXED_MODE_PRESETS["forge_only"]["forge_weight"] == 1.0
def test_get_preset_weights_invalid(self):
from ml.config.scope import get_preset_weights
with pytest.raises(KeyError, match="Unknown preset"):
get_preset_weights("nonexistent")
def test_all_presets_have_both_keys(self):
from ml.config.scope import MIXED_MODE_PRESETS
for name, preset in MIXED_MODE_PRESETS.items():
assert "forge_weight" in preset, f"Preset '{name}' missing forge_weight"
assert "ppo_weight" in preset, f"Preset '{name}' missing ppo_weight"
+2 -2
View File
@@ -36,7 +36,7 @@ def _engine(personalities=None):
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
# ---------------------------------------------------------------------------
@@ -217,7 +217,7 @@ class TestPoliticsCommsChannel:
async def h(msg): received_seats.append(s)
return h
import asyncio
h = asyncio.get_event_loop().run_until_complete(make_handler())
h = asyncio.run(make_handler())
channel.register_handler(seat=seat, handler=h)
msg = ThreatBroadcast(speaker=0, audience=-1,
+1 -1
View File
@@ -16,7 +16,7 @@ import pytest
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -23,7 +23,7 @@
<div class="coach-status-items">
<div class="coach-status-item" id="status-llm">
<span class="status-dot status-unknown"></span>
<span class="status-label">LM Studio</span>
<span class="status-label">Perplexity</span>
<span class="status-value" id="status-llm-value">Checking...</span>
</div>
<div class="coach-status-item" id="status-embeddings">
+306
View File
@@ -2841,3 +2841,309 @@
height: auto;
border-radius: 12px;
}
/* ── 17. Stats Dashboard ─────────────────────────────────────── */
.coll-stats-btn {
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.4);
color: var(--accent, #6366f1);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.coll-stats-btn:hover {
background: rgba(99,102,241,0.3);
border-color: var(--accent, #6366f1);
}
.coll-stats-panel {
background: var(--surface, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 20px 24px;
margin: 0 0 12px 0;
animation: coll-fade-in 0.2s ease;
}
.coll-stats-loading {
color: var(--text-secondary, #8b949e);
padding: 16px;
text-align: center;
}
.coll-stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
/* Summary KPI cards */
.coll-stats-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.coll-stat-card {
flex: 1;
min-width: 100px;
background: var(--bg, #0d1117);
border: 1px solid var(--border, #30363d);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
}
.coll-stat-num {
font-size: 1.5rem;
font-weight: 700;
color: var(--text, #e6edf3);
line-height: 1.2;
}
.coll-stat-label {
font-size: 0.72rem;
color: var(--text-secondary, #8b949e);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Section headers */
.coll-stats-section {
border-top: 1px solid var(--border, #30363d);
padding-top: 14px;
}
.coll-stats-section h4 {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px 0;
}
/* CMC vertical bar chart */
.coll-stat-bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 64px;
padding-bottom: 20px;
position: relative;
}
.coll-stat-bar-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
position: relative;
}
.coll-stat-bar {
width: 100%;
background: var(--accent, #6366f1);
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.coll-stat-bar-label {
position: absolute;
bottom: -18px;
font-size: 0.68rem;
color: var(--text-secondary, #8b949e);
}
/* Horizontal bar rows (colors, types, rarity, categories) */
.coll-stat-color-bar,
.coll-stat-type-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.coll-stat-color-pip {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
flex-shrink: 0;
}
.coll-stat-type-label {
width: 90px;
font-size: 0.75rem;
color: var(--text, #e6edf3);
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coll-stat-hbar-track {
flex: 1;
height: 10px;
background: var(--bg, #0d1117);
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--border, #30363d);
}
.coll-stat-hbar {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.coll-stat-hbar-type {
background: var(--accent, #6366f1);
}
.coll-stat-hbar-cat {
background: #2ea043;
}
.coll-stat-hbar-val {
width: 36px;
font-size: 0.72rem;
color: var(--text-secondary, #8b949e);
text-align: right;
flex-shrink: 0;
}
/* Top value cards list */
.coll-stat-top-row {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
padding: 3px 0;
border-bottom: 1px solid var(--border, #30363d);
gap: 8px;
}
.coll-stat-top-row span:first-child {
color: var(--text, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coll-stat-top-row span:last-child {
color: #3fb950;
font-weight: 600;
flex-shrink: 0;
}
.coll-stats-empty {
color: var(--text-secondary, #8b949e);
font-size: 0.78rem;
font-style: italic;
}
@media (min-width: 900px) {
.coll-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.coll-stats-summary {
grid-column: 1 / -1;
}
}
@media (min-width: 1400px) {
.coll-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* ── 18. Column Visibility Panel ───────────────────────────── */
.coll-col-toggle-btn {
background: rgba(139,148,158,0.1);
border: 1px solid var(--border, #30363d);
color: var(--text-secondary, #8b949e);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.coll-col-toggle-btn:hover {
background: rgba(139,148,158,0.2);
color: var(--text, #e6edf3);
}
.coll-col-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 500;
background: var(--surface, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 180px;
animation: coll-fade-in 0.15s ease;
}
.coll-col-panel-inner {
padding: 10px 12px 12px;
}
.coll-col-panel-hdr {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border, #30363d);
}
.coll-col-close {
background: none;
border: none;
color: var(--text-secondary, #8b949e);
cursor: pointer;
font-size: 1rem;
padding: 0 4px;
line-height: 1;
}
.coll-col-close:hover { color: var(--text, #e6edf3); }
.coll-col-toggle-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.8rem;
color: var(--text, #e6edf3);
cursor: pointer;
user-select: none;
}
.coll-col-toggle-item input[type="checkbox"] {
accent-color: var(--accent, #6366f1);
cursor: pointer;
}
.coll-col-toggle-item:hover {
color: var(--accent, #6366f1);
}
.coll-col-reset {
margin-top: 10px;
width: 100%;
background: rgba(99,102,241,0.1);
border: 1px solid rgba(99,102,241,0.3);
color: var(--accent, #6366f1);
border-radius: 5px;
padding: 5px 8px;
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.coll-col-reset:hover {
background: rgba(99,102,241,0.25);
}
+238 -8
View File
@@ -410,6 +410,148 @@ const Collection = (() => {
renderCountBadge();
}
// ── Stats Dashboard ──────────────────────────────────────
let statsData = null;
let statsOpen = false;
async function fetchStats() {
try {
const res = await fetch(`${API_BASE}/api/collection/stats`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
statsData = await res.json();
} catch (err) {
console.error('[Collection] fetchStats error:', err);
statsData = null;
}
}
function toggleStats() {
statsOpen = !statsOpen;
const panel = document.getElementById('coll-stats-panel');
if (!panel) return;
if (statsOpen) {
if (!statsData) {
panel.innerHTML = '<div class="coll-stats-loading">Loading stats…</div>';
panel.style.display = 'block';
fetchStats().then(() => renderStatsPanel());
} else {
renderStatsPanel();
}
panel.style.display = 'block';
} else {
panel.style.display = 'none';
}
}
function renderStatsPanel() {
const panel = document.getElementById('coll-stats-panel');
if (!panel || !statsData) return;
const s = statsData;
// CMC curve bar chart (CSS-only)
const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
const count = (s.cmcCurve || {})[String(i)] || 0;
const pct = Math.round((count / cmcMax) * 100);
const label = i === 7 ? '7+' : String(i);
return `<div class="coll-stat-bar-col">
<div class="coll-stat-bar" style="height:${pct}%" title="${count}"></div>
<span class="coll-stat-bar-label">${label}</span>
</div>`;
}).join('');
// Color distribution
const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
const colorBars = ['W','U','B','R','G','C'].map(c => {
const count = (s.colorDistribution || {})[c] || 0;
const pct = Math.round((count / colorTotal) * 100);
const ci = COLOR_MAP[c];
return `<div class="coll-stat-color-bar">
<span class="coll-stat-color-pip" style="background:${ci.bg};color:${ci.color}">${ci.label}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${ci.bg}"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Type distribution
const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
const pct = Math.round((count / typeTotal) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label">${escapeHtml(t)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-type" style="width:${pct}%"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Rarity distribution
const rarityOrder = ['common','uncommon','rare','mythic'];
const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
const rarityBars = rarityOrder.map(r => {
const count = (s.rarityDistribution || {})[r] || 0;
const pct = Math.round((count / rarityTotal) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label" style="color:${rarityColors[r] || '#ccc'}">${r.charAt(0).toUpperCase() + r.slice(1)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${rarityColors[r] || '#555'}"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Top value cards
const topCards = (s.topValueCards || []).map(c =>
`<div class="coll-stat-top-row"><span>${escapeHtml(c.name)}</span><span>$${Number(c.price).toFixed(2)}</span></div>`
).join('');
// Category/role coverage
const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
const catMax = Math.max(...catEntries.map(e => e[1]), 1);
const catBars = catEntries.map(([cat, count]) => {
const pct = Math.round((count / catMax) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label">${escapeHtml(cat)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-cat" style="width:${pct}%"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
panel.innerHTML = `
<div class="coll-stats-grid">
<div class="coll-stats-summary">
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalCards || 0).toLocaleString()}</div><div class="coll-stat-label">Total Cards</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalUniqueCards || 0).toLocaleString()}</div><div class="coll-stat-label">Unique</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}</div><div class="coll-stat-label">Total Value</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">${s.avgCmc || 0}</div><div class="coll-stat-label">Avg CMC</div></div>
</div>
<div class="coll-stats-section">
<h4>CMC Curve</h4>
<div class="coll-stat-bar-chart">${cmcBars}</div>
</div>
<div class="coll-stats-section">
<h4>Color Distribution</h4>
${colorBars}
</div>
<div class="coll-stats-section">
<h4>Type Distribution</h4>
${typeBars}
</div>
<div class="coll-stats-section">
<h4>Rarity</h4>
${rarityBars}
</div>
<div class="coll-stats-section">
<h4>Role Coverage</h4>
${catBars || '<div class="coll-stats-empty">No categories assigned yet</div>'}
</div>
<div class="coll-stats-section">
<h4>Top Value Cards</h4>
${topCards || '<div class="coll-stats-empty">No priced cards</div>'}
</div>
</div>
`;
}
async function fetchCardDetail(cardId) {
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -444,7 +586,8 @@ const Collection = (() => {
// Pre-load set/keyword caches for filter autocomplete
loadSetsCache();
loadKeywordsCache();
await fetchCollection();
applyColumnVisibility();
await fetchCollection();
}
/** Adjust table container height based on actual topbar height */
@@ -489,10 +632,17 @@ const Collection = (() => {
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
<button class="coll-stats-btn" onclick="Collection.toggleStats()" title="Show collection statistics dashboard">📊 Stats</button>
<div style="position:relative">
<button class="coll-col-toggle-btn" onclick="Collection.openColumnPanel()" title="Show/hide table columns">☰ Columns</button>
<div id="coll-col-panel" class="coll-col-panel" style="display:none"></div>
</div>
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
</div>
</div>
<!-- Stats Dashboard Panel -->
<div id="coll-stats-panel" class="coll-stats-panel" style="display:none"></div>
<!-- Main -->
<div class="coll-main">
@@ -821,6 +971,79 @@ const Collection = (() => {
</div>`;
}
// ── Column Visibility ──────────────────────────────────────
function colVis(key) {
return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;';
}
function toggleColumn(key) {
const cols = state.preferences.visibleColumns;
const idx = cols.indexOf(key);
if (idx >= 0) {
if (cols.length <= 1) return; // keep at least 1 column
cols.splice(idx, 1);
} else {
cols.push(key);
}
savePreferences();
renderTable();
applyColumnVisibility();
}
function renderColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (!panel) return;
const cols = COLUMNS.map(col => {
const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : '';
return `<label class="coll-col-toggle-item">
<input type="checkbox" ${checked} onchange="Collection.toggleColumn('${col.key}')">
${escapeHtml(col.label)}
</label>`;
}).join('');
panel.innerHTML = `
<div class="coll-col-panel-inner">
<div class="coll-col-panel-hdr">Columns <button class="coll-col-close" onclick="Collection.closeColumnPanel()">×</button></div>
${cols}
<button class="coll-col-reset" onclick="Collection.resetColumns()">Reset Defaults</button>
</div>`;
panel.style.display = 'block';
}
function openColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (!panel) return;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
renderColumnPanel();
}
}
function closeColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (panel) panel.style.display = 'none';
}
function resetColumns() {
state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_name','tcg_price','edhrec_rank','salt_score','category','finish'];
savePreferences();
renderTable();
renderColumnPanel();
}
function applyColumnVisibility() {
let styleEl = document.getElementById('coll-col-vis-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'coll-col-vis-style';
document.head.appendChild(styleEl);
}
const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key));
styleEl.textContent = hidden.map(col =>
`.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }`
).join('\n');
}
// ── Table Header ────────────────────────────────────────
const COLUMNS = [
@@ -843,8 +1066,8 @@ const Collection = (() => {
];
function renderTableHeaderHtml() {
const headers = COLUMNS.map(col => {
if (!col.sortable) {
const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key));
const headers = visibleCols.map(col => { if (!col.sortable) {
return `<th class="coll-th coll-th-${col.key}">${col.label}</th>`;
}
const isActive = state.sortField === col.key;
@@ -873,12 +1096,12 @@ const Collection = (() => {
}
if (state.loading) {
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">Loading…</td></tr>`;
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">Loading…</td></tr>`;
return;
}
if (state.items.length === 0) {
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">
<div class="coll-empty-state">
<div class="coll-empty-icon">🃏</div>
<div>No cards in collection</div>
@@ -2099,7 +2322,8 @@ const Collection = (() => {
if (footer) footer.style.display = 'none';
// Refresh collection
await fetchCollection();
applyColumnVisibility();
await fetchCollection();
} catch (err) {
if (statusEl) {
@@ -2736,7 +2960,7 @@ const Collection = (() => {
// Refresh collection after short delay
setTimeout(() => {
closeScan();
loadCollection();
fetchCollection();
}, 1500);
} catch (e) {
if (statusEl) statusEl.innerHTML = '<div class="coll-scan-error-msg">Failed: ' + escapeHtml(e.message) + '</div>';
@@ -2851,5 +3075,11 @@ const Collection = (() => {
popoverAddCatFromInput,
reEnrich,
autoClassify,
toggleStats,
// Column visibility
toggleColumn,
openColumnPanel,
closeColumnPanel,
resetColumns,
};
})();
+103 -1
View File
@@ -1297,4 +1297,106 @@
.dg-log-error {
color: #f59e0b;
}
}
/* ── Progress Bar (Deck Generation) ──────────────────── */
.dg-progress-wrap {
width: 100%;
max-width: 400px;
margin-top: 16px;
}
.dg-progress-bar {
height: 6px;
background: var(--surface-2, #2a2a3a);
border-radius: 3px;
overflow: hidden;
}
.dg-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa);
border-radius: 3px;
transition: width 1s ease;
}
.dg-progress-meta {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.78rem;
}
.dg-progress-phase {
color: var(--text-muted, #999);
}
.dg-progress-elapsed {
color: var(--text-muted, #999);
font-variant-numeric: tabular-nums;
}
/* — Omit Cards Chips ————————————————————————— */
.dg-omit-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--bg-input, #1a1a2e);
min-height: 38px;
cursor: text;
}
.dg-omit-wrap:focus-within {
border-color: var(--accent, #6c5ce7);
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25);
}
.dg-omit-chips {
display: contents;
}
.dg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: var(--accent, #6c5ce7);
color: #fff;
font-size: 0.8rem;
white-space: nowrap;
line-height: 1.6;
}
.dg-chip-x {
cursor: pointer;
font-size: 0.9rem;
opacity: 0.7;
margin-left: 2px;
}
.dg-chip-x:hover {
opacity: 1;
}
.dg-omit-input {
flex: 1 1 120px;
border: none !important;
background: transparent !important;
padding: 2px 4px !important;
min-width: 120px;
outline: none;
box-shadow: none !important;
}
.dg-hint {
display: block;
margin-top: 6px;
color: var(--text-muted, #999);
font-size: 0.75rem;
}
+28
View File
@@ -138,6 +138,25 @@
</div>
</div>
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
@@ -166,6 +185,15 @@
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
+115 -1
View File
@@ -53,6 +53,95 @@ const DeckGenerator = (() => {
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
@@ -118,6 +207,22 @@ const DeckGenerator = (() => {
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
@@ -138,7 +243,7 @@ const DeckGenerator = (() => {
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: [],
omit_cards: omitCards.slice(),
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
@@ -240,6 +345,12 @@ const DeckGenerator = (() => {
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
@@ -284,6 +395,7 @@ const DeckGenerator = (() => {
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
@@ -307,10 +419,12 @@ const DeckGenerator = (() => {
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
+1 -1
View File
@@ -62,7 +62,7 @@
<div class="deepseek-config-row">
<div class="form-group sim-field-grow">
<label class="form-label" for="ds-api-base">API Endpoint</label>
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="ds-model">Model</label>