feat: smart land bases — auto land count, mana profile, slot earmarking, and backfill (#63)

This commit is contained in:
mwisnowski 2026-03-25 18:05:28 -07:00 committed by GitHub
parent ac6c9f4daa
commit 0ab2183277
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1408 additions and 51 deletions

View file

@ -0,0 +1,61 @@
"""Land optimization service for surfacing smart-land diagnostics to the web layer.
Reads _land_report_data produced by LandAnalysisMixin (Roadmap 14) from the
active builder session and formats it for JSON API responses.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict
from code.web.services.base import BaseService
from code import logging_util
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
class LandOptimizationService(BaseService):
"""Thin service that extracts and formats land diagnostics from a build session."""
def __init__(self) -> None:
super().__init__()
def get_land_report(self, session: Dict[str, Any]) -> Dict[str, Any]:
"""Extract _land_report_data from the active builder in ``session``.
Args:
session: The dict returned by ``get_session(sid)``.
Returns:
A copy of ``_land_report_data``, or an empty dict if unavailable.
"""
ctx = session.get('build_ctx') or {}
builder = ctx.get('builder') if isinstance(ctx, dict) else None
if builder is None:
return {}
report = getattr(builder, '_land_report_data', None)
return dict(report) if report else {}
def format_for_api(self, report: Dict[str, Any]) -> Dict[str, Any]:
"""Return a JSON-serialisable copy of ``report``.
Converts any non-primitive values (numpy types, DataFrames, etc.) to
strings so the result can be passed straight to ``JSONResponse``.
Args:
report: Raw _land_report_data dict.
Returns:
A plain-dict copy safe for JSON serialisation.
"""
if not report:
return {}
try:
return json.loads(json.dumps(report, default=str))
except Exception as exc: # pragma: no cover
logger.warning('LandOptimizationService.format_for_api failed: %s', exc)
return {}

View file

@ -2075,8 +2075,8 @@ def _make_stages_legacy(b: DeckBuilder) -> List[Dict[str, Any]]:
if mc_selected:
stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"})
# Note: Combos auto-complete now runs late (near theme autofill), so we defer adding it here.
# Land steps 1..8 (if present)
for i in range(1, 9):
# Land steps 1..9 (if present; step 9 = backfill to target)
for i in range(1, 10):
fn = getattr(b, f"run_land_step{i}", None)
if callable(fn):
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
@ -2242,8 +2242,8 @@ def _make_stages_new(b: DeckBuilder) -> List[Dict[str, Any]]:
pass
stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"})
# 3) LANDS - Steps 1..8 (after spells so pip counts are known)
for i in range(1, 9):
# 3) LANDS - Steps 1..9 (after spells so pip counts are known; step 9 = backfill to target)
for i in range(1, 10):
fn = getattr(b, f"run_land_step{i}", None)
if callable(fn):
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
@ -2680,6 +2680,11 @@ def start_build_ctx(
b.apply_budget_pool_filter()
except Exception:
pass
# Smart land analysis — mirrors run_deck_build_step2() so web builds get profiles too
try:
b.run_land_analysis()
except Exception:
pass
stages = _make_stages(b)
ctx = {
"builder": b,