fix(ci): relax headless commander validation

This commit is contained in:
matt 2025-10-02 17:09:07 -07:00
parent 84749da214
commit e31c230e3b
4 changed files with 90 additions and 3 deletions

View file

@ -36,7 +36,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts. - Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts.
### Fixed ### Fixed
- _No changes yet._ - Headless runner commander validation now accepts fuzzy commander prefixes (e.g., "Krenko") so partial names still resolve to eligible entries during automated builds and CI runs.
### Removed ### Removed
- Preview performance GitHub Actions workflow (`.github/workflows/preview-perf-ci.yml`) retired after persistent cold-start failures; run the regression helper script manually as needed. - Preview performance GitHub Actions workflow (`.github/workflows/preview-perf-ci.yml`) retired after persistent cold-start failures; run the regression helper script manually as needed.

View file

@ -29,3 +29,4 @@
## Fixed ## Fixed
- Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures. - Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures.
- Headless runner commander validation now accepts fuzzy commander prefixes so automated builds using short commander names keep working.

View file

@ -3,13 +3,16 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import re
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from deck_builder.builder import DeckBuilder from deck_builder.builder import DeckBuilder
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from file_setup.setup import initial_setup from file_setup.setup import initial_setup
from tagging import tagger from tagging import tagger
from exceptions import CommanderValidationError
def _is_stale(file1: str, file2: str) -> bool: def _is_stale(file1: str, file2: str) -> bool:
"""Return True if file2 is missing or older than file1.""" """Return True if file2 is missing or older than file1."""
@ -67,6 +70,85 @@ def _headless_list_owned_files() -> List[str]:
return sorted(entries) return sorted(entries)
def _normalize_commander_name(value: Any) -> str:
return str(value or "").strip().casefold()
def _tokenize_commander_name(value: Any) -> List[str]:
normalized = _normalize_commander_name(value)
if not normalized:
return []
return [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
@lru_cache(maxsize=1)
def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]:
builder = DeckBuilder(
headless=True,
log_outputs=False,
output_func=lambda *_: None,
input_func=lambda *_: "",
)
df = builder.load_commander_data()
raw_names: List[str] = []
for column in ("name", "faceName"):
if column not in df.columns:
continue
series = df[column].dropna().astype(str)
raw_names.extend(series.tolist())
normalized = {
norm
for norm in (_normalize_commander_name(name) for name in raw_names)
if norm
}
ordered_raw = tuple(dict.fromkeys(raw_names))
return normalized, ordered_raw
def _validate_commander_available(command_name: str) -> None:
normalized = _normalize_commander_name(command_name)
if not normalized:
return
available, raw_names = _load_commander_name_lookup()
if normalized in available:
return
query_tokens = _tokenize_commander_name(command_name)
for candidate in raw_names:
candidate_norm = _normalize_commander_name(candidate)
if not candidate_norm:
continue
if candidate_norm.startswith(normalized):
return
candidate_tokens = _tokenize_commander_name(candidate)
if query_tokens and all(token in candidate_tokens for token in query_tokens):
return
try:
from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found]
except ImportError: # pragma: no cover
_lookup_commander_detail = None
info = _lookup_commander_detail(command_name) if _lookup_commander_detail else None
if info is not None:
primary_face = str(info.get("primary_face") or info.get("name") or "").strip()
eligible_faces = info.get("eligible_faces")
face_hint = ", ".join(str(face) for face in eligible_faces) if isinstance(eligible_faces, list) else ""
message = (
f"Commander '{command_name}' is no longer available because only a secondary face met commander eligibility."
)
if primary_face and _normalize_commander_name(primary_face) != normalized:
message += f" Try selecting the front face '{primary_face}' or choose a different commander."
elif face_hint:
message += f" The remaining eligible faces were: {face_hint}."
else:
message += " Choose a different commander whose front face is commander-legal."
raise CommanderValidationError(message, details={"commander": command_name, "reason": info})
raise CommanderValidationError(f"Commander not found: {command_name}", details={"commander": command_name})
@dataclass @dataclass
class RandomRunConfig: class RandomRunConfig:
"""Runtime options for the headless random build flow.""" """Runtime options for the headless random build flow."""
@ -113,6 +195,10 @@ def run(
seed: Optional[int | str] = None, seed: Optional[int | str] = None,
) -> DeckBuilder: ) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.""" """Run a scripted non-interactive deck build and return the DeckBuilder instance."""
trimmed_commander = (command_name or "").strip()
if trimmed_commander:
_validate_commander_available(trimmed_commander)
owned_prompt_inputs: List[str] = [] owned_prompt_inputs: List[str] = []
owned_files_available = _headless_list_owned_files() owned_files_available = _headless_list_owned_files()
if owned_files_available: if owned_files_available:

View file

@ -1,6 +1,6 @@
name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side
Shock,,,,R,{R},1,Instant,,Deal 2 damage to any target.,,,,[Burn],normal, Shock,,,,R,{R},1,Instant,,Deal 2 damage to any target.,,,,[Burn],normal,
Plains,,,,W,,0,Land,,{T}: Add {W}.,,,,[Land],normal,name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side Plains,,,,W,,0,Land,,{T}: Add {W}.,,,,[Land],normal,
Sol Ring,,1,Colorless,,{1},{1},Artifact,,{T}: Add {C}{C}.,,,Mana,Utility,normal, Sol Ring,,1,Colorless,,{1},{1},Artifact,,{T}: Add {C}{C}.,,,Mana,Utility,normal,
Llanowar Elves,,5000,G,G,{G},{1},Creature,Elf Druid,{T}: Add {G}.,1,1,Mana,Tribal;Ramp,normal, Llanowar Elves,,5000,G,G,{G},{1},Creature,Elf Druid,{T}: Add {G}.,1,1,Mana,Tribal;Ramp,normal,
Island,,9999,U,U,,,Land,,{T}: Add {U}.,,,Land,,normal, Island,,9999,U,U,,,Land,,{T}: Add {U}.,,,Land,,normal,

1 name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side name faceName edhrecRank colorIdentity colors manaCost manaValue type creatureTypes text power toughness keywords themeTags layout side
2 Shock,,,,R,{R},1,Instant,,Deal 2 damage to any target.,,,,[Burn],normal, Shock R {R} 1 Instant Deal 2 damage to any target. [Burn] normal
3 Plains,,,,W,,0,Land,,{T}: Add {W}.,,,,[Land],normal,name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side Plains W 0 Land {T}: Add {W}. [Land] normal
4 Sol Ring,,1,Colorless,,{1},{1},Artifact,,{T}: Add {C}{C}.,,,Mana,Utility,normal, Sol Ring 1 Colorless {1} {1} Artifact {T}: Add {C}{C}. Mana Utility normal
5 Llanowar Elves,,5000,G,G,{G},{1},Creature,Elf Druid,{T}: Add {G}.,1,1,Mana,Tribal;Ramp,normal, Llanowar Elves 5000 G G {G} {1} Creature Elf Druid {T}: Add {G}. 1 1 Mana Tribal;Ramp normal
6 Island,,9999,U,U,,,Land,,{T}: Add {U}.,,,Land,,normal, Island 9999 U U Land {T}: Add {U}. Land normal