mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-05 20:57:16 +02:00
feat: smart land bases — auto land count, mana profile, slot earmarking, and backfill (#63)
This commit is contained in:
parent
ac6c9f4daa
commit
0ab2183277
21 changed files with 1408 additions and 51 deletions
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -228,3 +228,20 @@ def batch_build_progress(request: Request, batch_id: str = Query(...)):
|
|||
# - POST /build/enforce/apply - Apply enforcement
|
||||
# - GET /build/enforcement - Full-page enforcement
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@router.get("/land-diagnostics")
|
||||
async def land_diagnostics(request: Request) -> JSONResponse:
|
||||
"""Return the smart-land analysis report for the active build session.
|
||||
|
||||
Reads _land_report_data produced by LandAnalysisMixin (Roadmap 14).
|
||||
Returns 204 when ENABLE_SMART_LANDS is off or no build is in session.
|
||||
"""
|
||||
sid = request.cookies.get("sid") or ""
|
||||
sess = get_session(sid)
|
||||
from ..services.land_optimization_service import LandOptimizationService
|
||||
svc = LandOptimizationService()
|
||||
report = svc.get_land_report(sess)
|
||||
if not report:
|
||||
return JSONResponse({}, status_code=204)
|
||||
return JSONResponse(svc.format_for_api(report))
|
||||
|
|
|
|||
|
|
@ -511,6 +511,7 @@ async def build_new_submit(
|
|||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"ideals_ui_mode": WEB_IDEALS_UI,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(suggested),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -538,6 +539,7 @@ async def build_new_submit(
|
|||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"ideals_ui_mode": WEB_IDEALS_UI,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(commander),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -645,6 +647,7 @@ async def build_new_submit(
|
|||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"ideals_ui_mode": WEB_IDEALS_UI,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(primary_commander_name),
|
||||
"tag_slot_html": tag_slot_html,
|
||||
|
|
@ -786,6 +789,7 @@ async def build_new_submit(
|
|||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||
"enable_budget_mode": ENABLE_BUDGET_MODE,
|
||||
"ideals_ui_mode": WEB_IDEALS_UI,
|
||||
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
"tag_slot_html": None,
|
||||
|
|
|
|||
61
code/web/services/land_optimization_service.py
Normal file
61
code/web/services/land_optimization_service.py
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -143,9 +143,48 @@
|
|||
|
||||
<!-- Land Summary -->
|
||||
{% set land = summary.land_summary if summary else None %}
|
||||
{% set lr = summary.land_report if summary else None %}
|
||||
{% if land %}
|
||||
<section class="summary-section-lg">
|
||||
<h5>Land Summary</h5>
|
||||
{% if lr and lr.profile %}
|
||||
{% set profile_labels = {'basics': 'Basics (minimal fixing)', 'mid': 'Balanced (moderate fixing)', 'fixing': 'Fixing-heavy (extensive duals/fetches)'} %}
|
||||
{% set speed_labels = {'fast': 'Fast', 'mid': 'Mid', 'slow': 'Slow'} %}
|
||||
<div class="notice" style="margin-bottom:.75rem; font-size:.85rem; line-height:1.5;">
|
||||
<strong>Smart Lands</strong> adjusted your land targets:
|
||||
<strong>{{ lr.land_target }} lands</strong> / <strong>{{ lr.basic_target }} basics</strong>
|
||||
— <strong>{{ profile_labels.get(lr.profile, lr.profile) }}</strong> profile,
|
||||
<strong>{{ speed_labels.get(lr.speed_category, lr.speed_category) }}</strong>-paced deck.
|
||||
<div class="muted" style="margin-top:.3rem; font-size:.8rem;">
|
||||
<strong>Why:</strong>
|
||||
{% set cc = lr.color_count | int %}
|
||||
{% if cc <= 1 %}
|
||||
{{ cc }}-color deck — single-color decks rarely need mana fixing; basics provide better consistency.
|
||||
{% elif cc >= 5 %}
|
||||
{{ cc }}-color deck — 5-color decks need extensive mana fixing to reliably cast spells.
|
||||
{% elif lr.profile == 'fixing' and lr.pip_was_deciding %}
|
||||
{{ cc }}-color deck with heavy color requirements in the card pool — many cards need multiple pips of the same color, making fixing lands critical.
|
||||
{% elif lr.profile == 'basics' and lr.pip_was_deciding %}
|
||||
{{ cc }}-color deck with light color requirements in the card pool — few demanding pip costs, so basics outperform fixing lands here.
|
||||
{% else %}
|
||||
{{ cc }}-color deck with moderate color requirements.
|
||||
{% endif %}
|
||||
{% set cmc = lr.commander_cmc %}
|
||||
{% set eff = lr.effective_cmc %}
|
||||
{% if cmc is not none %}
|
||||
Commander CMC {{ cmc | int if cmc % 1 == 0 else cmc | round(1) }}
|
||||
{%- if eff is not none and (eff - cmc) | abs >= 0.2 %} (effective {{ eff | round(1) }} weighted with pool average){%- endif -%}
|
||||
— {{ lr.speed_category }} deck speed.
|
||||
{% endif %}
|
||||
{% if lr.pip_was_deciding and lr.total_double_pips is defined %}
|
||||
Card pool contains {{ lr.total_double_pips }} double-pip and {{ lr.total_triple_pips }} triple-or-more-pip cards.
|
||||
{% endif %}
|
||||
{% if lr.budget_total is not none %}
|
||||
Budget constraint: ${{ lr.budget_total | round(0) | int }}.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted summary-type-heading mb-1">
|
||||
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue