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>
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>
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>
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>
Fixes UnboundLocalError crash when deck_row is None by returning an
empty list instead of falling through to a query with an unbound deck_id.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously _resolve_combat() used a binary power-vs-toughness kill check:
creatures only died if a single source had power >= their toughness in one
swing. This meant a 2/2 wouldn't die to two 1/1 tokens blocking it, and
repeated smaller hits across combats never accumulated.
Changes:
- models.py: add damage_marked field to Card (default 0)
- engine.py: accumulate damage via damage_marked += power instead of
binary a_pow >= toughness checks in _resolve_combat()
- engine.py: clear damage_marked = 0 in untap phase (MTG rule 514.2)
This enables multi-blocker lethality, incremental chip damage within a
combat step, and proper creature death-by-accumulated-damage interactions.
Script to run sequential batch sims across 10 precon decks with
rotating playstyles (midrange, aggro, control, combo). Generates
ML decision data for training.
- 210 unique 4-player matchups from 10 precon decks
- Cycles through playstyles each batch
- Polls for completion, handles errors gracefully
- Ensures ML logging is enabled before starting
- Prints summary with new decision count at end
Usage: python scripts/overnight-batch.py --hours 8
The distillation pipeline was looking for JSONL decision files in
data/results/ but they are stored in results/ (matching CFG.results_dir).
This caused all generations to fail at Step 1 with:
'No ml-decisions-*.jsonl files found in data/results'
Fixed:
- routes/ml.py: results_dir and history_dir now use 'results/' not 'data/results/'
- ml/training/distillation_loop.py: updated DistillationConfig defaults to match
- History endpoint paths updated to match
- showMetaPicker() now guards for no backend with clear message,
and lazy-loads commanders on first open (fixes race condition
with Promise.all where loadMetaCommanders ran before detectBackend)
- filterMetaCommanders() hits live /api/lab/meta/search endpoint
when backend is connected, falls back to local array filter
- Search input debounced at 250ms to avoid hammering API
- Add _hoverTimer and HOVER_DELAY (450ms) properties to CardPreview
- mouseenter starts a delay timer before showing the popup
- mousemove only repositions if popup is already visible
- mouseleave clears the timer and hides immediately
- hide() now also clears any pending timer
- Create ml/eval/elo_tracker.py:
- EloTracker class with standard ELO formula (K=32, start=1200)
- EloResult dataclass for tournament output
- EloHistory class for JSON persistence (data/elo_history.json)
- run_elo_tournament(): round-robin ELO-rated matches using
existing run_match() from tournament.py
- run_generation_tournament(): discovers gen-*/best_policy.pt
checkpoints and runs cross-generation ELO tournament
- CLI entry point: python -m ml.eval.elo_tracker
- Add ELO API endpoints to routes/ml.py:
- POST /api/ml/elo/tournament — start background ELO tournament
- GET /api/ml/elo/tournament/status — poll progress
- GET /api/ml/elo/history — load ELO history from disk
- Integrate ELO convergence in distillation_loop.py:
- New config fields: elo_convergence_enabled (default False),
elo_convergence_threshold (20.0), elo_tournament_episodes (30)
- New field: GenerationRecord.elo_rating
- _run_elo_mini_tournament() runs after quality gate pass
- _check_convergence() checks ELO delta when enabled
- Add ELO UI to distillation page:
- ELO Ratings panel with canvas multi-line chart showing per-model
ELO progression across generations
- Run Tournament button with progress indicator
- ELO leaderboard table with rank, rating, and visual bars
- Responsive chart resize handling
Closes#70
- Add distillation API endpoints to routes/ml.py:
- POST /api/ml/distill/start — start closed-loop distillation pipeline
- GET /api/ml/distill/status — poll loop status and generation data
- POST /api/ml/distill/stop — graceful stop after current generation
- GET /api/ml/distill/history — load persisted generation history
- Add 'Distill' nav entry in ui/nav.js with loop icon
- Create ui/distillation.html:
- Status overview cards (status, generation, best win rate, gate passes)
- Control panel with hyperparameter inputs for supervised, PPO, and
quality gate settings
- Start/Stop buttons with progress bar and phase badge
- Win rate trend chart (canvas-based, no dependencies)
- Data composition stacked bar chart (Forge vs PPO per generation)
- Generation history table with status, metrics, and gate results
- Create ui/distillation.js:
- Polling loop for real-time status updates during distillation
- Canvas chart rendering for win rate trends and data composition
- History table rendering with color-coded status tags
- Responsive resize handling for charts
- Create ui/distillation.css:
- Page-specific styles following training.css patterns
- Chart container, history table, and responsive grid styles
Closes#69
Implements the closed-loop distillation pipeline that automates:
1. Supervised train on current dataset (Forge + PPO data)
2. PPO self-play using supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check (win rate threshold)
5. If passed, merge into dataset and repeat
6. If failed, discard batch, adjust hyperparameters, retry
New files:
- ml/training/distillation_loop.py — DistillationLoop orchestrator,
DistillationConfig, GenerationRecord, CLI entry point
Updated files:
- ml/config/scope.py — DistillationDefaults dataclass with all
default hyperparameters for the pipeline
Features:
- Generation tagging: data and models tagged with gen-NNN
- Convergence detection: stops when win rate plateaus over N gens
- Automatic retry with LR reduction + entropy boost on gate failure
- Per-generation history saved to JSON for observability
- Full CLI with all tunable params
- progress_callback for UI integration (Phase 5 ready)
Closes#68
Phase 2 of closed-loop distillation pipeline.
- Scan both ml-decisions-sim-*.jsonl (Forge) and ml-decisions-ppo-*.jsonl (PPO)
- Add source_weights config for configurable sampling ratios
- Add min_reward_threshold filter for PPO data quality
- Tag each sample with source for downstream analysis
- New CLI args: --forge-weight, --ppo-weight, --min-reward
- NPZ output now includes 'sources' array