mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
release: 2.2.6 – refresh bracket list JSONs; finalize brackets compliance docs and UI polish
This commit is contained in:
parent
4e03997923
commit
375349e56e
11 changed files with 734 additions and 16 deletions
|
@ -100,10 +100,19 @@ def _canonicalize(name: str | None) -> str:
|
|||
return s.casefold()
|
||||
|
||||
|
||||
def _status_for(count: int, limit: Optional[int]) -> str:
|
||||
def _status_for(count: int, limit: Optional[int], warn: Optional[int] = None) -> str:
|
||||
# Unlimited hard limit -> always PASS (no WARN semantics without a cap)
|
||||
if limit is None:
|
||||
return "PASS"
|
||||
return "PASS" if count <= int(limit) else "FAIL"
|
||||
if count > int(limit):
|
||||
return "FAIL"
|
||||
# Soft guidance: if warn threshold provided and met, surface WARN
|
||||
try:
|
||||
if warn is not None and int(warn) > 0 and count >= int(warn):
|
||||
return "WARN"
|
||||
except Exception:
|
||||
pass
|
||||
return "PASS"
|
||||
|
||||
|
||||
def evaluate_deck(
|
||||
|
@ -155,7 +164,13 @@ def evaluate_deck(
|
|||
flagged_names_disp = sorted({deck_canon_to_display.get(cn, cn) for cn in flagged_set})
|
||||
c = len(flagged_set)
|
||||
lim = limits.get(key)
|
||||
status = _status_for(c, lim)
|
||||
# Optional warn thresholds live alongside limits as "<key>_warn"
|
||||
try:
|
||||
warn_key = f"{key}_warn"
|
||||
warn_val = limits.get(warn_key)
|
||||
except Exception:
|
||||
warn_val = None
|
||||
status = _status_for(c, lim, warn=warn_val)
|
||||
cat: CategoryFinding = {
|
||||
"count": c,
|
||||
"limit": lim,
|
||||
|
@ -166,12 +181,27 @@ def evaluate_deck(
|
|||
categories[key] = cat
|
||||
if status == "FAIL":
|
||||
messages.append(f"{key.replace('_',' ').title()}: {c} exceeds limit {lim}")
|
||||
elif status == "WARN":
|
||||
try:
|
||||
if warn_val is not None:
|
||||
messages.append(f"{key.replace('_',' ').title()}: {c} present (discouraged for this bracket)")
|
||||
except Exception:
|
||||
pass
|
||||
# Conservative fallback: for low brackets (levels 1–2), tutors/extra-turns should WARN when present
|
||||
# even if a warn threshold was not provided in YAML.
|
||||
if status == "PASS" and level in (1, 2) and key in ("tutors_nonland", "extra_turns"):
|
||||
try:
|
||||
if (warn_val is None) and (lim is not None) and c > 0 and c <= int(lim):
|
||||
categories[key]["status"] = "WARN"
|
||||
messages.append(f"{key.replace('_',' ').title()}: {c} present (discouraged for this bracket)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Two-card combos detection
|
||||
combos = detect_combos(deck_cards.keys(), combos_path=combos_path)
|
||||
cheap_early_pairs = [p for p in combos if p.cheap_early]
|
||||
c_limit = limits.get("two_card_combos")
|
||||
combos_status = _status_for(len(cheap_early_pairs), c_limit)
|
||||
combos_status = _status_for(len(cheap_early_pairs), c_limit, warn=None)
|
||||
categories["two_card_combos"] = {
|
||||
"count": len(cheap_early_pairs),
|
||||
"limit": c_limit,
|
||||
|
|
|
@ -51,3 +51,33 @@ def test_two_card_combination_detection_respects_cheap_early():
|
|||
rep2 = evaluate_deck(deck, commander_name=None, bracket="optimized")
|
||||
assert rep2["categories"]["two_card_combos"]["limit"] is None
|
||||
assert rep2["overall"] == "PASS"
|
||||
|
||||
|
||||
def test_warn_thresholds_in_yaml_are_applied():
|
||||
# Exhibition: tutors_nonland_warn=1 -> WARN when a single tutor present (hard limit 3)
|
||||
deck1 = {
|
||||
# Use a non-"Game Changer" tutor to avoid hard fail in Exhibition
|
||||
"Solve the Equation": _mk_card(["Bracket:TutorNonland"]),
|
||||
"Cultivate": _mk_card([]),
|
||||
}
|
||||
rep1 = evaluate_deck(deck1, commander_name=None, bracket="exhibition")
|
||||
assert rep1["level"] == 1
|
||||
assert rep1["categories"]["tutors_nonland"]["status"] == "WARN"
|
||||
assert rep1["overall"] == "WARN"
|
||||
|
||||
# Core: extra_turns_warn=1 -> WARN at 1, PASS at 0, FAIL above hard limit 3
|
||||
deck2 = {
|
||||
"Time Warp": _mk_card(["Bracket:ExtraTurn"]),
|
||||
"Explore": _mk_card([]),
|
||||
}
|
||||
rep2 = evaluate_deck(deck2, commander_name=None, bracket="core")
|
||||
assert rep2["level"] == 2
|
||||
assert rep2["categories"]["extra_turns"]["limit"] == 3
|
||||
assert rep2["categories"]["extra_turns"]["status"] in {"WARN", "PASS"}
|
||||
# With two extra turns, still <= limit, but should at least WARN
|
||||
deck3 = {
|
||||
"Time Warp": _mk_card(["Bracket:ExtraTurn"]),
|
||||
"Temporal Manipulation": _mk_card(["Bracket:ExtraTurn"]),
|
||||
}
|
||||
rep3 = evaluate_deck(deck3, commander_name=None, bracket="core")
|
||||
assert rep3["categories"]["extra_turns"]["status"] == "WARN"
|
||||
|
|
|
@ -2273,12 +2273,12 @@ async def build_compliance_panel(request: Request) -> HTMLResponse:
|
|||
seen_lower: set[str] = set()
|
||||
for key, cat in cats.items():
|
||||
try:
|
||||
lim = cat.get('limit')
|
||||
cnt = int(cat.get('count', 0) or 0)
|
||||
if lim is None or cnt <= int(lim):
|
||||
status = str(cat.get('status') or '').upper()
|
||||
# Only surface tiles for WARN and FAIL
|
||||
if status not in {"WARN", "FAIL"}:
|
||||
continue
|
||||
# For two-card combos, split pairs into individual cards and skip commander
|
||||
if key == 'two_card_combos':
|
||||
if key == 'two_card_combos' and status == 'FAIL':
|
||||
# Prefer the structured combos list to ensure we only expand counted pairs
|
||||
pairs = []
|
||||
try:
|
||||
|
@ -2316,6 +2316,7 @@ async def build_compliance_panel(request: Request) -> HTMLResponse:
|
|||
'category': labels.get(key, key.replace('_',' ').title()),
|
||||
'role': role,
|
||||
'owned': (nm_l in owned_lower),
|
||||
'severity': status,
|
||||
})
|
||||
continue
|
||||
# Default handling for list/tag categories
|
||||
|
@ -2332,6 +2333,7 @@ async def build_compliance_panel(request: Request) -> HTMLResponse:
|
|||
'category': labels.get(key, key.replace('_',' ').title()),
|
||||
'role': role,
|
||||
'owned': (nm_l in owned_lower),
|
||||
'severity': status,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
{% if compliance %}
|
||||
<details id="compliance-panel" style="margin-top:.75rem;">
|
||||
{% set non_compliant = compliance.overall is defined and (compliance.overall|string|lower != 'pass') %}
|
||||
<details id="compliance-panel" style="margin-top:.75rem;" {% if non_compliant %}open{% endif %}>
|
||||
<summary>Bracket compliance</summary>
|
||||
<div class="muted" style="margin:.35rem 0;">Overall: <strong>{{ compliance.overall }}</strong> (Bracket: {{ compliance.bracket|title }}{{ ' #' ~ compliance.level if compliance.level is defined }})</div>
|
||||
{% set ov = compliance.overall|string|lower %}
|
||||
<div class="muted" style="margin:.35rem 0;">Overall:
|
||||
{% if ov == 'fail' %}
|
||||
<span class="chip" title="Overall bracket status"><span class="dot" style="background: var(--red-main);"></span> FAIL</span>
|
||||
{% elif ov == 'warn' %}
|
||||
<span class="chip" title="Overall bracket status"><span class="dot" style="background: var(--orange-main);"></span> WARN</span>
|
||||
{% else %}
|
||||
<span class="chip" title="Overall bracket status"><span class="dot" style="background: var(--green-main);"></span> PASS</span>
|
||||
{% endif %}
|
||||
(Bracket: {{ compliance.bracket|title }}{{ ' #' ~ compliance.level if compliance.level is defined }})
|
||||
</div>
|
||||
{% if compliance.messages and compliance.messages|length > 0 %}
|
||||
<ul style="margin:.25rem 0; padding-left:1.25rem;">
|
||||
{% for m in compliance.messages %}
|
||||
|
@ -15,7 +26,8 @@
|
|||
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
|
||||
<div class="card-grid">
|
||||
{% for f in flagged_meta %}
|
||||
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}">
|
||||
{% set sev = (f.severity or 'FAIL')|upper %}
|
||||
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>
|
||||
<a href="https://scryfall.com/search?q={{ f.name|urlencode }}" target="_blank" rel="noopener" class="img-btn" title="{{ f.name }}">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=large 672w"
|
||||
|
@ -23,7 +35,14 @@
|
|||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if f.owned else 'Not owned' }}" aria-label="{{ 'Owned' if f.owned else 'Not owned' }}">{% if f.owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ f.name }}</div>
|
||||
<div class="muted" style="text-align:center; font-size:12px;">{{ f.category }}{% if f.role %} • {{ f.role }}{% endif %}</div>
|
||||
<div class="muted" style="text-align:center; font-size:12px; display:flex; gap:.35rem; justify-content:center; align-items:center; flex-wrap:wrap;">
|
||||
<span>{{ f.category }}{% if f.role %} • {{ f.role }}{% endif %}</span>
|
||||
{% if sev == 'FAIL' %}
|
||||
<span class="chip" title="Severity: FAIL"><span class="dot" style="background: var(--red-main);"></span> FAIL</span>
|
||||
{% elif sev == 'WARN' %}
|
||||
<span class="chip" title="Severity: WARN"><span class="dot" style="background: var(--orange-main);"></span> WARN</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
{# Role-aware alternatives: pass the flagged name; server will infer role and exclude in-deck/locked #}
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ f.name }}"}' hx-target="#alts-flag-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest role-consistent replacements">Pick replacement…</button>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue