mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -8,7 +8,7 @@ test.py
|
||||||
main.spec
|
main.spec
|
||||||
!requirements.txt
|
!requirements.txt
|
||||||
__pycache__/
|
__pycache__/
|
||||||
build/
|
#build/
|
||||||
csv_files/
|
csv_files/
|
||||||
dist/
|
dist/
|
||||||
logs/
|
logs/
|
||||||
|
|
29
DOCKER.md
29
DOCKER.md
|
@ -17,6 +17,35 @@ docker run -it --rm `
|
||||||
-v "${PWD}/logs:/app/logs" `
|
-v "${PWD}/logs:/app/logs" `
|
||||||
-v "${PWD}/csv_files:/app/csv_files" `
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||||
|
## Web UI (new)
|
||||||
|
|
||||||
|
The web UI runs the same deckbuilding logic behind a browser-based interface.
|
||||||
|
|
||||||
|
### PowerShell (recommended)
|
||||||
|
```powershell
|
||||||
|
docker compose build web
|
||||||
|
docker compose up --no-deps web
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:8080
|
||||||
|
|
||||||
|
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
|
||||||
|
|
||||||
|
### From Docker Hub (PowerShell)
|
||||||
|
If you prefer not to build locally, pull `mwisnowski/mtg-python-deckbuilder:latest` and run uvicorn:
|
||||||
|
```powershell
|
||||||
|
docker run --rm `
|
||||||
|
-p 8080:8080 `
|
||||||
|
-v "${PWD}/deck_files:/app/deck_files" `
|
||||||
|
-v "${PWD}/logs:/app/logs" `
|
||||||
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
|
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||||
|
-v "${PWD}/config:/app/config" `
|
||||||
|
mwisnowski/mtg-python-deckbuilder:latest `
|
||||||
|
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
-v "${PWD}/config:/app/config" `
|
-v "${PWD}/config:/app/config" `
|
||||||
mwisnowski/mtg-python-deckbuilder:latest
|
mwisnowski/mtg-python-deckbuilder:latest
|
||||||
```
|
```
|
||||||
|
|
|
@ -228,6 +228,49 @@ class DeckBuilder(
|
||||||
if hasattr(super(), 'add_spells_phase'):
|
if hasattr(super(), 'add_spells_phase'):
|
||||||
return super().add_spells_phase()
|
return super().add_spells_phase()
|
||||||
raise NotImplementedError("Spell addition phase not implemented.")
|
raise NotImplementedError("Spell addition phase not implemented.")
|
||||||
|
# ---------------------------
|
||||||
|
# Lightweight confirmations (CLI pauses; web auto-continues)
|
||||||
|
# ---------------------------
|
||||||
|
def _pause(self, message: str = "Press Enter to continue...") -> None:
|
||||||
|
try:
|
||||||
|
_ = self.input_func(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def confirm_primary_theme(self) -> None:
|
||||||
|
if getattr(self, 'primary_tag', None):
|
||||||
|
self.output_func(f"Primary Theme: {self.primary_tag}")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_secondary_theme(self) -> None:
|
||||||
|
if getattr(self, 'secondary_tag', None):
|
||||||
|
self.output_func(f"Secondary Theme: {self.secondary_tag}")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_tertiary_theme(self) -> None:
|
||||||
|
if getattr(self, 'tertiary_tag', None):
|
||||||
|
self.output_func(f"Tertiary Theme: {self.tertiary_tag}")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_ramp_spells(self) -> None:
|
||||||
|
self.output_func("Confirm Ramp")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_removal_spells(self) -> None:
|
||||||
|
self.output_func("Confirm Removal")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_wipes_spells(self) -> None:
|
||||||
|
self.output_func("Confirm Board Wipes")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_card_advantage_spells(self) -> None:
|
||||||
|
self.output_func("Confirm Card Advantage")
|
||||||
|
self._pause()
|
||||||
|
|
||||||
|
def confirm_protection_spells(self) -> None:
|
||||||
|
self.output_func("Confirm Protection")
|
||||||
|
self._pause()
|
||||||
# Commander core selection state
|
# Commander core selection state
|
||||||
commander_name: str = ""
|
commander_name: str = ""
|
||||||
commander_row: Optional[pd.Series] = None
|
commander_row: Optional[pd.Series] = None
|
||||||
|
@ -1201,6 +1244,7 @@ class DeckBuilder(
|
||||||
'ramp': bc.DEFAULT_RAMP_COUNT,
|
'ramp': bc.DEFAULT_RAMP_COUNT,
|
||||||
'lands': bc.DEFAULT_LAND_COUNT,
|
'lands': bc.DEFAULT_LAND_COUNT,
|
||||||
'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT,
|
'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT,
|
||||||
|
'fetch_lands': getattr(bc, 'FETCH_LAND_DEFAULT_COUNT', 3),
|
||||||
'creatures': bc.DEFAULT_CREATURE_COUNT,
|
'creatures': bc.DEFAULT_CREATURE_COUNT,
|
||||||
'removal': bc.DEFAULT_REMOVAL_COUNT,
|
'removal': bc.DEFAULT_REMOVAL_COUNT,
|
||||||
'wipes': bc.DEFAULT_WIPES_COUNT,
|
'wipes': bc.DEFAULT_WIPES_COUNT,
|
||||||
|
@ -1248,6 +1292,7 @@ class DeckBuilder(
|
||||||
('ramp', 'Ramp Pieces'),
|
('ramp', 'Ramp Pieces'),
|
||||||
('lands', 'Total Lands'),
|
('lands', 'Total Lands'),
|
||||||
('basic_lands', 'Minimum Basic Lands'),
|
('basic_lands', 'Minimum Basic Lands'),
|
||||||
|
('fetch_lands', 'Fetch Lands'),
|
||||||
('creatures', 'Creatures'),
|
('creatures', 'Creatures'),
|
||||||
('removal', 'Spot Removal'),
|
('removal', 'Spot Removal'),
|
||||||
('wipes', 'Board Wipes'),
|
('wipes', 'Board Wipes'),
|
||||||
|
@ -1270,6 +1315,7 @@ class DeckBuilder(
|
||||||
('ramp', 'Ramp'),
|
('ramp', 'Ramp'),
|
||||||
('lands', 'Total Lands'),
|
('lands', 'Total Lands'),
|
||||||
('basic_lands', 'Basic Lands (Min)'),
|
('basic_lands', 'Basic Lands (Min)'),
|
||||||
|
('fetch_lands', 'Fetch Lands'),
|
||||||
('creatures', 'Creatures'),
|
('creatures', 'Creatures'),
|
||||||
('removal', 'Spot Removal'),
|
('removal', 'Spot Removal'),
|
||||||
('wipes', 'Board Wipes'),
|
('wipes', 'Board Wipes'),
|
||||||
|
|
|
@ -376,6 +376,7 @@ DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
||||||
'ramp': 'Enter desired number of ramp pieces (default: 8):',
|
'ramp': 'Enter desired number of ramp pieces (default: 8):',
|
||||||
'lands': 'Enter desired number of total lands (default: 35):',
|
'lands': 'Enter desired number of total lands (default: 35):',
|
||||||
'basic_lands': 'Enter minimum number of basic lands (default: 15):',
|
'basic_lands': 'Enter minimum number of basic lands (default: 15):',
|
||||||
|
'fetch_lands': 'Enter desired number of fetch lands (default: 3):',
|
||||||
'creatures': 'Enter desired number of creatures (default: 25):',
|
'creatures': 'Enter desired number of creatures (default: 25):',
|
||||||
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
||||||
'wipes': 'Enter desired number of board wipes (default: 2):',
|
'wipes': 'Enter desired number of board wipes (default: 2):',
|
||||||
|
|
|
@ -24,11 +24,56 @@ class CommanderSelectionMixin:
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Commander Selection
|
# Commander Selection
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
def _normalize_commander_query(self, s: str) -> str:
|
||||||
|
"""Return a nicely capitalized search string (e.g., "inti, seneschal of the sun"
|
||||||
|
-> "Inti, Seneschal of the Sun"). Keeps small words lowercase unless at a segment start,
|
||||||
|
and capitalizes parts around hyphens/apostrophes.
|
||||||
|
"""
|
||||||
|
if not isinstance(s, str):
|
||||||
|
return str(s)
|
||||||
|
s = s.strip()
|
||||||
|
if not s:
|
||||||
|
return s
|
||||||
|
small = {
|
||||||
|
'a','an','and','as','at','but','by','for','in','of','on','or','the','to','vs','v','with','from','into','over','per'
|
||||||
|
}
|
||||||
|
# Consider a new segment after these punctuation marks
|
||||||
|
segment_breakers = {':',';','-','–','—','/','\\','(', '[', '{', '"', "'", ',', '.'}
|
||||||
|
out_words: list[str] = []
|
||||||
|
start_of_segment = True
|
||||||
|
for raw in s.lower().split():
|
||||||
|
word = raw
|
||||||
|
# If preceding token ended with a breaker, reset segment
|
||||||
|
if out_words:
|
||||||
|
prev = out_words[-1]
|
||||||
|
if prev and prev[-1] in segment_breakers:
|
||||||
|
start_of_segment = True
|
||||||
|
def cap_subparts(token: str) -> str:
|
||||||
|
# Capitalize around hyphens and apostrophes
|
||||||
|
def cap_piece(piece: str) -> str:
|
||||||
|
return piece[:1].upper() + piece[1:] if piece else piece
|
||||||
|
parts = [cap_piece(p) for p in token.split("'")]
|
||||||
|
token2 = "'".join(parts)
|
||||||
|
parts2 = [cap_piece(p) for p in token2.split('-')]
|
||||||
|
return '-'.join(parts2)
|
||||||
|
if start_of_segment or word not in small:
|
||||||
|
fixed = cap_subparts(word)
|
||||||
|
else:
|
||||||
|
fixed = word
|
||||||
|
out_words.append(fixed)
|
||||||
|
# Next word is not start unless current ends with breaker
|
||||||
|
start_of_segment = word[-1:] in segment_breakers
|
||||||
|
# Post-process to ensure first character is capitalized if needed
|
||||||
|
if out_words:
|
||||||
|
out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
|
||||||
|
return ' '.join(out_words)
|
||||||
|
|
||||||
def choose_commander(self) -> str: # type: ignore[override]
|
def choose_commander(self) -> str: # type: ignore[override]
|
||||||
df = self.load_commander_data()
|
df = self.load_commander_data()
|
||||||
names = df["name"].tolist()
|
names = df["name"].tolist()
|
||||||
while True:
|
while True:
|
||||||
query = self.input_func("Enter commander name: ").strip()
|
query = self.input_func("Enter commander name: ").strip()
|
||||||
|
query = self._normalize_commander_query(query)
|
||||||
if not query:
|
if not query:
|
||||||
self.output_func("No input provided. Try again.")
|
self.output_func("No input provided. Try again.")
|
||||||
continue
|
continue
|
||||||
|
@ -66,7 +111,7 @@ class CommanderSelectionMixin:
|
||||||
else:
|
else:
|
||||||
self.output_func("Invalid index.")
|
self.output_func("Invalid index.")
|
||||||
continue
|
continue
|
||||||
query = choice # treat as new query
|
query = self._normalize_commander_query(choice) # treat as new (normalized) query
|
||||||
|
|
||||||
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
|
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
|
||||||
row = df[df["name"] == name].iloc[0]
|
row = df[df["name"] == name].iloc[0]
|
||||||
|
|
|
@ -144,8 +144,17 @@ class LandFetchMixin:
|
||||||
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
|
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
|
||||||
|
|
||||||
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
|
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
|
||||||
"""Public wrapper to add fetch lands. Optional requested_count to bypass prompt."""
|
"""Public wrapper to add fetch lands.
|
||||||
self.add_fetch_lands(requested_count=requested_count)
|
|
||||||
|
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
|
||||||
|
"""
|
||||||
|
desired = requested_count
|
||||||
|
try:
|
||||||
|
if desired is None and getattr(self, 'ideal_counts', None) and 'fetch_lands' in self.ideal_counts:
|
||||||
|
desired = int(self.ideal_counts['fetch_lands'])
|
||||||
|
except Exception:
|
||||||
|
desired = requested_count
|
||||||
|
self.add_fetch_lands(requested_count=desired)
|
||||||
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
|
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
|
@ -190,3 +190,197 @@ class CreatureAdditionMixin:
|
||||||
"""
|
"""
|
||||||
"""Public method for orchestration: delegates to add_creatures."""
|
"""Public method for orchestration: delegates to add_creatures."""
|
||||||
return self.add_creatures()
|
return self.add_creatures()
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Per-theme creature sub-stages (for web UI staged confirms)
|
||||||
|
# ---------------------------
|
||||||
|
def _theme_weights(self, themes_ordered: List[tuple[str, str]]) -> Dict[str, float]:
|
||||||
|
n_themes = len(themes_ordered)
|
||||||
|
if n_themes == 1:
|
||||||
|
base_map = {'primary': 1.0}
|
||||||
|
elif n_themes == 2:
|
||||||
|
base_map = {'primary': 0.6, 'secondary': 0.4}
|
||||||
|
else:
|
||||||
|
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
|
||||||
|
weights: Dict[str, float] = {}
|
||||||
|
boosted_roles: set[str] = set()
|
||||||
|
if n_themes > 1:
|
||||||
|
for role, tag in themes_ordered:
|
||||||
|
w = base_map.get(role, 0.0)
|
||||||
|
lt = tag.lower()
|
||||||
|
if 'kindred' in lt or 'tribal' in lt:
|
||||||
|
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
|
||||||
|
w *= mult
|
||||||
|
boosted_roles.add(role)
|
||||||
|
weights[role] = w
|
||||||
|
total = sum(weights.values())
|
||||||
|
if total > 1.0:
|
||||||
|
for r in list(weights):
|
||||||
|
weights[r] /= total
|
||||||
|
else:
|
||||||
|
rem = 1.0 - total
|
||||||
|
base_sum_unboosted = sum(base_map[r] for r,_t in themes_ordered if r not in boosted_roles)
|
||||||
|
if rem > 1e-6 and base_sum_unboosted > 0:
|
||||||
|
for r,_t in themes_ordered:
|
||||||
|
if r not in boosted_roles:
|
||||||
|
weights[r] += rem * (base_map[r] / base_sum_unboosted)
|
||||||
|
else:
|
||||||
|
weights['primary'] = 1.0
|
||||||
|
return weights
|
||||||
|
|
||||||
|
def _creature_count_in_library(self) -> int:
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for _n, entry in getattr(self, 'card_library', {}).items():
|
||||||
|
if str(entry.get('Role') or '').strip() == 'creature':
|
||||||
|
total += int(entry.get('Count', 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _prepare_creature_pool(self):
|
||||||
|
df = getattr(self, '_combined_cards_df', None)
|
||||||
|
if df is None or df.empty or 'type' not in df.columns:
|
||||||
|
return None
|
||||||
|
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
|
||||||
|
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
|
||||||
|
if commander_name and 'name' in creature_df.columns:
|
||||||
|
creature_df = creature_df[creature_df['name'] != commander_name]
|
||||||
|
if creature_df.empty:
|
||||||
|
return None
|
||||||
|
if '_parsedThemeTags' not in creature_df.columns:
|
||||||
|
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
||||||
|
creature_df['_normTags'] = creature_df['_parsedThemeTags']
|
||||||
|
selected_tags_lower: List[str] = []
|
||||||
|
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||||
|
if t:
|
||||||
|
selected_tags_lower.append(t.lower())
|
||||||
|
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
|
||||||
|
return creature_df
|
||||||
|
|
||||||
|
def _add_creatures_for_role(self, role: str):
|
||||||
|
"""Add creatures for a single theme role ('primary'|'secondary'|'tertiary')."""
|
||||||
|
df = getattr(self, '_combined_cards_df', None)
|
||||||
|
if df is None or df.empty:
|
||||||
|
self.output_func("Card pool not loaded; cannot add creatures.")
|
||||||
|
return
|
||||||
|
tag = getattr(self, f'{role}_tag', None)
|
||||||
|
if not tag:
|
||||||
|
return
|
||||||
|
themes_ordered: List[tuple[str, str]] = []
|
||||||
|
if getattr(self, 'primary_tag', None):
|
||||||
|
themes_ordered.append(('primary', self.primary_tag))
|
||||||
|
if getattr(self, 'secondary_tag', None):
|
||||||
|
themes_ordered.append(('secondary', self.secondary_tag))
|
||||||
|
if getattr(self, 'tertiary_tag', None):
|
||||||
|
themes_ordered.append(('tertiary', self.tertiary_tag))
|
||||||
|
weights = self._theme_weights(themes_ordered)
|
||||||
|
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||||
|
current_added = self._creature_count_in_library()
|
||||||
|
remaining = max(0, desired_total - current_added)
|
||||||
|
if remaining <= 0:
|
||||||
|
return
|
||||||
|
w = float(weights.get(role, 0.0))
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
import math as _math
|
||||||
|
target = int(_math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
|
||||||
|
target = min(target, remaining)
|
||||||
|
if target <= 0:
|
||||||
|
return
|
||||||
|
creature_df = self._prepare_creature_pool()
|
||||||
|
if creature_df is None:
|
||||||
|
self.output_func("No creature rows in dataset; skipping.")
|
||||||
|
return
|
||||||
|
tnorm = str(tag).lower()
|
||||||
|
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
||||||
|
if subset.empty:
|
||||||
|
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
||||||
|
return
|
||||||
|
if 'edhrecRank' in subset.columns:
|
||||||
|
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||||
|
elif 'manaValue' in subset.columns:
|
||||||
|
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||||
|
base_top = 30
|
||||||
|
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||||
|
pool = subset.head(top_n).copy()
|
||||||
|
# Exclude any names already chosen
|
||||||
|
existing_names = set(getattr(self, 'card_library', {}).keys())
|
||||||
|
pool = pool[~pool['name'].isin(existing_names)]
|
||||||
|
if pool.empty:
|
||||||
|
return
|
||||||
|
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||||||
|
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
|
||||||
|
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||||
|
added = 0
|
||||||
|
for nm in chosen:
|
||||||
|
row = pool[pool['name']==nm].iloc[0]
|
||||||
|
self.add_card(
|
||||||
|
nm,
|
||||||
|
card_type=row.get('type','Creature'),
|
||||||
|
mana_cost=row.get('manaCost',''),
|
||||||
|
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||||
|
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||||
|
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||||
|
role='creature',
|
||||||
|
sub_role=role,
|
||||||
|
added_by='creature_add',
|
||||||
|
trigger_tag=tag,
|
||||||
|
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
if added >= target:
|
||||||
|
break
|
||||||
|
self.output_func(f"Added {added} creatures for {role} theme '{tag}' (target {target}).")
|
||||||
|
|
||||||
|
def _add_creatures_fill(self):
|
||||||
|
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||||
|
current_added = self._creature_count_in_library()
|
||||||
|
need = max(0, desired_total - current_added)
|
||||||
|
if need <= 0:
|
||||||
|
return
|
||||||
|
creature_df = self._prepare_creature_pool()
|
||||||
|
if creature_df is None:
|
||||||
|
return
|
||||||
|
multi_pool = creature_df[~creature_df['name'].isin(set(getattr(self, 'card_library', {}).keys()))].copy()
|
||||||
|
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||||
|
if multi_pool.empty:
|
||||||
|
return
|
||||||
|
if 'edhrecRank' in multi_pool.columns:
|
||||||
|
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||||
|
elif 'manaValue' in multi_pool.columns:
|
||||||
|
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||||
|
fill = multi_pool['name'].tolist()[:need]
|
||||||
|
added = 0
|
||||||
|
for nm in fill:
|
||||||
|
row = multi_pool[multi_pool['name']==nm].iloc[0]
|
||||||
|
self.add_card(
|
||||||
|
nm,
|
||||||
|
card_type=row.get('type','Creature'),
|
||||||
|
mana_cost=row.get('manaCost',''),
|
||||||
|
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||||
|
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||||
|
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||||
|
role='creature',
|
||||||
|
sub_role='fill',
|
||||||
|
added_by='creature_fill',
|
||||||
|
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
if added >= need:
|
||||||
|
break
|
||||||
|
if added:
|
||||||
|
self.output_func(f"Fill pass added {added} extra creatures (shortfall compensation).")
|
||||||
|
|
||||||
|
# Public stage entry points (web orchestrator looks for these)
|
||||||
|
def add_creatures_primary_phase(self):
|
||||||
|
return self._add_creatures_for_role('primary')
|
||||||
|
|
||||||
|
def add_creatures_secondary_phase(self):
|
||||||
|
return self._add_creatures_for_role('secondary')
|
||||||
|
|
||||||
|
def add_creatures_tertiary_phase(self):
|
||||||
|
return self._add_creatures_for_role('tertiary')
|
||||||
|
|
||||||
|
def add_creatures_fill_phase(self):
|
||||||
|
return self._add_creatures_fill()
|
||||||
|
|
|
@ -61,7 +61,7 @@ class ColorBalanceMixin:
|
||||||
self,
|
self,
|
||||||
pip_weights: Optional[Dict[str, float]] = None,
|
pip_weights: Optional[Dict[str, float]] = None,
|
||||||
color_shortfall_threshold: float = 0.15,
|
color_shortfall_threshold: float = 0.15,
|
||||||
perform_swaps: bool = True,
|
perform_swaps: bool = False,
|
||||||
max_swaps: int = 5,
|
max_swaps: int = 5,
|
||||||
rebalance_basics: bool = True
|
rebalance_basics: bool = True
|
||||||
):
|
):
|
||||||
|
@ -93,16 +93,18 @@ class ColorBalanceMixin:
|
||||||
self.output_func(" Deficits (need more sources):")
|
self.output_func(" Deficits (need more sources):")
|
||||||
for c, pip_share, s_share, gap in deficits:
|
for c, pip_share, s_share, gap in deficits:
|
||||||
self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)")
|
self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)")
|
||||||
if not perform_swaps or not deficits:
|
# We'll conditionally perform swaps; but even when skipping swaps we continue to basic rebalance.
|
||||||
|
do_swaps = bool(perform_swaps and deficits)
|
||||||
|
if not do_swaps:
|
||||||
self.output_func(" (No land swaps performed.)")
|
self.output_func(" (No land swaps performed.)")
|
||||||
return
|
|
||||||
|
|
||||||
|
swaps_done: List[tuple[str,str,str]] = []
|
||||||
|
if do_swaps:
|
||||||
df = getattr(self, '_combined_cards_df', None)
|
df = getattr(self, '_combined_cards_df', None)
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
||||||
return
|
else:
|
||||||
deficits.sort(key=lambda x: x[3], reverse=True)
|
deficits.sort(key=lambda x: x[3], reverse=True)
|
||||||
swaps_done: List[tuple[str,str,str]] = []
|
|
||||||
overages: Dict[str,float] = {}
|
overages: Dict[str,float] = {}
|
||||||
for c in ['W','U','B','R','G']:
|
for c in ['W','U','B','R','G']:
|
||||||
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
||||||
|
@ -152,6 +154,10 @@ class ColorBalanceMixin:
|
||||||
self.output_func(" Updated Source Shares:")
|
self.output_func(" Updated Source Shares:")
|
||||||
for c in ['W','U','B','R','G']:
|
for c in ['W','U','B','R','G']:
|
||||||
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
|
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
|
||||||
|
elif do_swaps:
|
||||||
|
self.output_func(" (No viable swaps executed.)")
|
||||||
|
|
||||||
|
# Always consider basic-land rebalance when requested
|
||||||
if rebalance_basics:
|
if rebalance_basics:
|
||||||
try:
|
try:
|
||||||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||||
|
@ -199,5 +205,3 @@ class ColorBalanceMixin:
|
||||||
self.output_func(f" {nm}: {old} -> {new}")
|
self.output_func(f" {nm}: {old} -> {new}")
|
||||||
except Exception as e: # pragma: no cover (defensive)
|
except Exception as e: # pragma: no cover (defensive)
|
||||||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||||||
else:
|
|
||||||
self.output_func(" (No viable swaps executed.)")
|
|
||||||
|
|
|
@ -108,6 +108,192 @@ class ReportingMixin:
|
||||||
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
|
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
|
||||||
pct = (c / total_cards * 100) if total_cards else 0.0
|
pct = (c / total_cards * 100) if total_cards else 0.0
|
||||||
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
|
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Structured deck summary for UI (types, pips, sources, curve)
|
||||||
|
# ---------------------------
|
||||||
|
def build_deck_summary(self) -> dict:
|
||||||
|
"""Return a structured summary of the finished deck for UI rendering.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
{
|
||||||
|
'type_breakdown': {
|
||||||
|
'counts': { type: count, ... },
|
||||||
|
'order': [sorted types by precedence],
|
||||||
|
'cards': { type: [ {name, count}, ... ] },
|
||||||
|
'total': int
|
||||||
|
},
|
||||||
|
'pip_distribution': {
|
||||||
|
'counts': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n },
|
||||||
|
'weights': { 'W': 0-1, ... }, # normalized weights (may not sum exactly to 1 due to rounding)
|
||||||
|
},
|
||||||
|
'mana_generation': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n, 'total_sources': n },
|
||||||
|
'mana_curve': { '0': n, '1': n, '2': n, '3': n, '4': n, '5': n, '6+': n, 'total_spells': n }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Build lookup to enrich type and mana values
|
||||||
|
full_df = getattr(self, '_full_cards_df', None)
|
||||||
|
combined_df = getattr(self, '_combined_cards_df', None)
|
||||||
|
snapshot = full_df if full_df is not None else combined_df
|
||||||
|
row_lookup: Dict[str, any] = {}
|
||||||
|
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
|
||||||
|
for _, r in snapshot.iterrows(): # type: ignore[attr-defined]
|
||||||
|
nm = str(r.get('name'))
|
||||||
|
if nm and nm not in row_lookup:
|
||||||
|
row_lookup[nm] = r
|
||||||
|
|
||||||
|
# Category classification (reuse export logic)
|
||||||
|
precedence_order = [
|
||||||
|
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||||
|
]
|
||||||
|
precedence_index = {k: i for i, k in enumerate(precedence_order)}
|
||||||
|
commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
|
|
||||||
|
def classify(primary_type_line: str, card_name: str) -> str:
|
||||||
|
if commander_name and card_name == commander_name:
|
||||||
|
return 'Commander'
|
||||||
|
tl = (primary_type_line or '').lower()
|
||||||
|
if 'battle' in tl:
|
||||||
|
return 'Battle'
|
||||||
|
if 'planeswalker' in tl:
|
||||||
|
return 'Planeswalker'
|
||||||
|
if 'creature' in tl:
|
||||||
|
return 'Creature'
|
||||||
|
if 'instant' in tl:
|
||||||
|
return 'Instant'
|
||||||
|
if 'sorcery' in tl:
|
||||||
|
return 'Sorcery'
|
||||||
|
if 'artifact' in tl:
|
||||||
|
return 'Artifact'
|
||||||
|
if 'enchantment' in tl:
|
||||||
|
return 'Enchantment'
|
||||||
|
if 'land' in tl:
|
||||||
|
return 'Land'
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
# Type breakdown (counts and per-type card lists)
|
||||||
|
type_counts: Dict[str, int] = {}
|
||||||
|
type_cards: Dict[str, list] = {}
|
||||||
|
total_cards = 0
|
||||||
|
for name, info in self.card_library.items():
|
||||||
|
# Exclude commander from type breakdown per UI preference
|
||||||
|
if commander_name and name == commander_name:
|
||||||
|
continue
|
||||||
|
cnt = int(info.get('Count', 1))
|
||||||
|
base_type = info.get('Card Type') or info.get('Type', '')
|
||||||
|
if not base_type:
|
||||||
|
row = row_lookup.get(name)
|
||||||
|
if row is not None:
|
||||||
|
base_type = row.get('type', row.get('type_line', '')) or ''
|
||||||
|
category = classify(base_type, name)
|
||||||
|
type_counts[category] = type_counts.get(category, 0) + cnt
|
||||||
|
total_cards += cnt
|
||||||
|
type_cards.setdefault(category, []).append({
|
||||||
|
'name': name,
|
||||||
|
'count': cnt,
|
||||||
|
'role': info.get('Role', '') or '',
|
||||||
|
'tags': list(info.get('Tags', []) or []),
|
||||||
|
})
|
||||||
|
# Sort cards within each type by name
|
||||||
|
for cat, lst in type_cards.items():
|
||||||
|
lst.sort(key=lambda x: (x['name'].lower(), -int(x['count'])))
|
||||||
|
type_order = sorted(type_counts.keys(), key=lambda k: precedence_index.get(k, 999))
|
||||||
|
|
||||||
|
# Pip distribution (counts and weights) for non-land spells only
|
||||||
|
pip_counts = {c: 0 for c in ('W','U','B','R','G')}
|
||||||
|
import re as _re_local
|
||||||
|
total_pips = 0.0
|
||||||
|
for name, info in self.card_library.items():
|
||||||
|
ctype = str(info.get('Card Type', ''))
|
||||||
|
if 'land' in ctype.lower():
|
||||||
|
continue
|
||||||
|
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
|
||||||
|
if not isinstance(mana_cost, str):
|
||||||
|
continue
|
||||||
|
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
|
||||||
|
sym = match.upper()
|
||||||
|
if len(sym) == 1 and sym in pip_counts:
|
||||||
|
pip_counts[sym] += 1
|
||||||
|
total_pips += 1
|
||||||
|
elif '/' in sym:
|
||||||
|
parts = [p for p in sym.split('/') if p in pip_counts]
|
||||||
|
if parts:
|
||||||
|
weight_each = 1 / len(parts)
|
||||||
|
for p in parts:
|
||||||
|
pip_counts[p] += weight_each
|
||||||
|
total_pips += weight_each
|
||||||
|
if total_pips <= 0:
|
||||||
|
# Fallback to even distribution across color identity
|
||||||
|
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
|
||||||
|
if colors:
|
||||||
|
share = 1 / len(colors)
|
||||||
|
for c in colors:
|
||||||
|
pip_counts[c] = share
|
||||||
|
total_pips = 1.0
|
||||||
|
pip_weights = {c: (pip_counts[c] / total_pips if total_pips else 0.0) for c in pip_counts}
|
||||||
|
|
||||||
|
# Mana generation from lands (color sources)
|
||||||
|
try:
|
||||||
|
from deck_builder import builder_utils as _bu
|
||||||
|
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
|
||||||
|
except Exception:
|
||||||
|
matrix = {}
|
||||||
|
source_counts = {c: 0 for c in ('W','U','B','R','G')}
|
||||||
|
for name, flags in matrix.items():
|
||||||
|
copies = int(self.card_library.get(name, {}).get('Count', 1))
|
||||||
|
for c in source_counts:
|
||||||
|
if int(flags.get(c, 0)):
|
||||||
|
source_counts[c] += copies
|
||||||
|
total_sources = sum(source_counts.values())
|
||||||
|
|
||||||
|
# Mana curve (non-land spells)
|
||||||
|
curve_bins = ['0','1','2','3','4','5','6+']
|
||||||
|
curve_counts = {b: 0 for b in curve_bins}
|
||||||
|
curve_cards: Dict[str, list] = {b: [] for b in curve_bins}
|
||||||
|
total_spells = 0
|
||||||
|
for name, info in self.card_library.items():
|
||||||
|
ctype = str(info.get('Card Type', ''))
|
||||||
|
if 'land' in ctype.lower():
|
||||||
|
continue
|
||||||
|
cnt = int(info.get('Count', 1))
|
||||||
|
mv = info.get('Mana Value')
|
||||||
|
if mv in (None, ''):
|
||||||
|
row = row_lookup.get(name)
|
||||||
|
if row is not None:
|
||||||
|
mv = row.get('manaValue', row.get('cmc', None))
|
||||||
|
try:
|
||||||
|
val = float(mv) if mv not in (None, '') else 0.0
|
||||||
|
except Exception:
|
||||||
|
val = 0.0
|
||||||
|
bucket = '6+' if val >= 6 else str(int(val))
|
||||||
|
if bucket not in curve_counts:
|
||||||
|
bucket = '6+'
|
||||||
|
curve_counts[bucket] += cnt
|
||||||
|
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||||
|
total_spells += cnt
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type_breakdown': {
|
||||||
|
'counts': type_counts,
|
||||||
|
'order': type_order,
|
||||||
|
'cards': type_cards,
|
||||||
|
'total': total_cards,
|
||||||
|
},
|
||||||
|
'pip_distribution': {
|
||||||
|
'counts': pip_counts,
|
||||||
|
'weights': pip_weights,
|
||||||
|
},
|
||||||
|
'mana_generation': {
|
||||||
|
**source_counts,
|
||||||
|
'total_sources': total_sources,
|
||||||
|
},
|
||||||
|
'mana_curve': {
|
||||||
|
**curve_counts,
|
||||||
|
'total_spells': total_spells,
|
||||||
|
'cards': curve_cards,
|
||||||
|
},
|
||||||
|
'colors': list(getattr(self, 'color_identity', []) or []),
|
||||||
|
}
|
||||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
"""Export current decklist to CSV (enriched).
|
"""Export current decklist to CSV (enriched).
|
||||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||||
|
@ -276,6 +462,7 @@ class ReportingMixin:
|
||||||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||||||
owned_flag
|
owned_flag
|
||||||
]))
|
]))
|
||||||
|
|
||||||
# Now sort (category precedence, then alphabetical name)
|
# Now sort (category precedence, then alphabetical name)
|
||||||
rows.sort(key=lambda x: x[0])
|
rows.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import os
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from deck_builder.builder import DeckBuilder
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
command_name: str = "",
|
command_name: str = "",
|
||||||
|
@ -47,14 +48,27 @@ def run(
|
||||||
scripted_inputs.append("0") # stop at primary
|
scripted_inputs.append("0") # stop at primary
|
||||||
# Bracket (meta power / style) selection; default to 3 if not provided
|
# Bracket (meta power / style) selection; default to 3 if not provided
|
||||||
scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3))
|
scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3))
|
||||||
# Ideal count prompts (press Enter for defaults)
|
# Ideal count prompts (press Enter for defaults). Include fetch_lands if present.
|
||||||
for _ in range(8):
|
ideal_keys = {
|
||||||
|
"ramp",
|
||||||
|
"lands",
|
||||||
|
"basic_lands",
|
||||||
|
"fetch_lands",
|
||||||
|
"creatures",
|
||||||
|
"removal",
|
||||||
|
"wipes",
|
||||||
|
"card_advantage",
|
||||||
|
"protection",
|
||||||
|
}
|
||||||
|
for key in bc.DECK_COMPOSITION_PROMPTS.keys():
|
||||||
|
if key in ideal_keys:
|
||||||
scripted_inputs.append("")
|
scripted_inputs.append("")
|
||||||
|
|
||||||
def scripted_input(prompt: str) -> str:
|
def scripted_input(prompt: str) -> str:
|
||||||
if scripted_inputs:
|
if scripted_inputs:
|
||||||
return scripted_inputs.pop(0)
|
return scripted_inputs.pop(0)
|
||||||
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
|
# Fallback to auto-accept defaults for any unexpected prompts
|
||||||
|
return ""
|
||||||
|
|
||||||
builder = DeckBuilder(input_func=scripted_input)
|
builder = DeckBuilder(input_func=scripted_input)
|
||||||
# Mark this run as headless so builder can adjust exports and logging
|
# Mark this run as headless so builder can adjust exports and logging
|
||||||
|
|
1
code/web/__init__.py
Normal file
1
code/web/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Web package marker
|
118
code/web/app.py
Normal file
118
code/web/app.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
# Resolve template/static dirs relative to this file
|
||||||
|
_THIS_DIR = Path(__file__).resolve().parent
|
||||||
|
_TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||||
|
_STATIC_DIR = _THIS_DIR / "static"
|
||||||
|
|
||||||
|
app = FastAPI(title="MTG Deckbuilder Web UI")
|
||||||
|
|
||||||
|
# Mount static if present
|
||||||
|
if _STATIC_DIR.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
# Jinja templates
|
||||||
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||||
|
|
||||||
|
# Global template flags (env-driven)
|
||||||
|
def _as_bool(val: str | None, default: bool = False) -> bool:
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
||||||
|
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||||
|
|
||||||
|
# Expose as Jinja globals so all templates can reference without passing per-view
|
||||||
|
templates.env.globals.update({
|
||||||
|
"show_logs": SHOW_LOGS,
|
||||||
|
"show_setup": SHOW_SETUP,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
|
||||||
|
|
||||||
|
|
||||||
|
# Simple health check
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Lightweight setup/tagging status endpoint
|
||||||
|
@app.get("/status/setup")
|
||||||
|
async def setup_status():
|
||||||
|
try:
|
||||||
|
p = Path("csv_files/.setup_status.json")
|
||||||
|
if p.exists():
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
data = _json.load(f)
|
||||||
|
# Attach a small log tail if available
|
||||||
|
try:
|
||||||
|
log_path = Path('logs/deck_builder.log')
|
||||||
|
if log_path.exists():
|
||||||
|
tail_lines = []
|
||||||
|
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
|
||||||
|
# Read last ~100 lines efficiently
|
||||||
|
from collections import deque
|
||||||
|
tail = deque(lf, maxlen=100)
|
||||||
|
tail_lines = list(tail)
|
||||||
|
# Reduce noise: keep lines related to setup/tagging; fallback to last 30 if too few remain
|
||||||
|
try:
|
||||||
|
lowered = [ln for ln in tail_lines]
|
||||||
|
keywords = ["setup", "tag", "color", "csv", "initial setup", "tagging", "load_dataframe"]
|
||||||
|
filtered = [ln for ln in lowered if any(kw in ln.lower() for kw in keywords)]
|
||||||
|
if len(filtered) >= 5:
|
||||||
|
use_lines = filtered[-60:]
|
||||||
|
else:
|
||||||
|
use_lines = tail_lines[-30:]
|
||||||
|
data["log_tail"] = "".join(use_lines).strip()
|
||||||
|
except Exception:
|
||||||
|
data["log_tail"] = "".join(tail_lines).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return JSONResponse(data)
|
||||||
|
return JSONResponse({"running": False, "phase": "idle"})
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"running": False, "phase": "error"})
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
from .routes import build as build_routes # noqa: E402
|
||||||
|
from .routes import configs as config_routes # noqa: E402
|
||||||
|
from .routes import decks as decks_routes # noqa: E402
|
||||||
|
from .routes import setup as setup_routes # noqa: E402
|
||||||
|
app.include_router(build_routes.router)
|
||||||
|
app.include_router(config_routes.router)
|
||||||
|
app.include_router(decks_routes.router)
|
||||||
|
app.include_router(setup_routes.router)
|
||||||
|
|
||||||
|
# Lightweight file download endpoint for exports
|
||||||
|
@app.get("/files")
|
||||||
|
async def get_file(path: str):
|
||||||
|
try:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists() or not p.is_file():
|
||||||
|
return PlainTextResponse("File not found", status_code=404)
|
||||||
|
# Only allow returning files within the workspace directory for safety
|
||||||
|
# (best-effort: require relative to current working directory)
|
||||||
|
try:
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
if cwd not in p.resolve().parents and p.resolve() != cwd:
|
||||||
|
# Still allow if under deck_files or config
|
||||||
|
allowed = any(seg in ("deck_files", "config", "logs") for seg in p.parts)
|
||||||
|
if not allowed:
|
||||||
|
return PlainTextResponse("Access denied", status_code=403)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return FileResponse(path)
|
||||||
|
except Exception:
|
||||||
|
return PlainTextResponse("Error serving file", status_code=500)
|
1
code/web/routes/__init__.py
Normal file
1
code/web/routes/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Routes package marker
|
507
code/web/routes/build.py
Normal file
507
code/web/routes/build.py
Normal file
|
@ -0,0 +1,507 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from ..app import templates
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
from ..services.tasks import get_session, new_sid
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/build")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def build_index(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/index.html",
|
||||||
|
{"request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", [])},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/step1", response_class=HTMLResponse)
|
||||||
|
async def build_step1(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step1", response_class=HTMLResponse)
|
||||||
|
async def build_step1_search(request: Request, query: str = Form(""), auto: str | None = Form(None)) -> HTMLResponse:
|
||||||
|
query = (query or "").strip()
|
||||||
|
auto_enabled = True if (auto == "1") else False
|
||||||
|
candidates = []
|
||||||
|
if query:
|
||||||
|
candidates = orch.commander_candidates(query, limit=10)
|
||||||
|
# Optional auto-select at a stricter threshold
|
||||||
|
if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98:
|
||||||
|
top_name = candidates[0][0]
|
||||||
|
res = orch.commander_select(top_name)
|
||||||
|
if res.get("ok"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step2.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": res,
|
||||||
|
"tags": orch.tags_for_commander(res["name"]),
|
||||||
|
"brackets": orch.bracket_options(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse("build/_step1.html", {"request": request, "query": query, "candidates": candidates, "auto": auto_enabled})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step1/inspect", response_class=HTMLResponse)
|
||||||
|
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||||
|
info = orch.commander_inspect(name)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step1.html",
|
||||||
|
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step1/confirm", response_class=HTMLResponse)
|
||||||
|
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||||
|
res = orch.commander_select(name)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
|
||||||
|
# Proceed to step2 placeholder
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step2.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": res,
|
||||||
|
"tags": orch.tags_for_commander(res["name"]),
|
||||||
|
"brackets": orch.bracket_options(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/step2", response_class=HTMLResponse)
|
||||||
|
async def build_step2_get(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
commander = sess.get("commander")
|
||||||
|
if not commander:
|
||||||
|
# Fallback to step1 if no commander in session
|
||||||
|
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||||
|
tags = orch.tags_for_commander(commander)
|
||||||
|
selected = sess.get("tags", [])
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step2.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": {"name": commander},
|
||||||
|
"tags": tags,
|
||||||
|
"brackets": orch.bracket_options(),
|
||||||
|
"primary_tag": selected[0] if len(selected) > 0 else "",
|
||||||
|
"secondary_tag": selected[1] if len(selected) > 1 else "",
|
||||||
|
"tertiary_tag": selected[2] if len(selected) > 2 else "",
|
||||||
|
"selected_bracket": sess.get("bracket"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step2", response_class=HTMLResponse)
|
||||||
|
async def build_step2_submit(
|
||||||
|
request: Request,
|
||||||
|
commander: str = Form(...),
|
||||||
|
primary_tag: str | None = Form(None),
|
||||||
|
secondary_tag: str | None = Form(None),
|
||||||
|
tertiary_tag: str | None = Form(None),
|
||||||
|
bracket: int = Form(...),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
# Validate primary tag selection if tags are available
|
||||||
|
available_tags = orch.tags_for_commander(commander)
|
||||||
|
if available_tags and not (primary_tag and primary_tag.strip()):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step2.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": {"name": commander},
|
||||||
|
"tags": available_tags,
|
||||||
|
"brackets": orch.bracket_options(),
|
||||||
|
"error": "Please choose a primary theme.",
|
||||||
|
"primary_tag": primary_tag or "",
|
||||||
|
"secondary_tag": secondary_tag or "",
|
||||||
|
"tertiary_tag": tertiary_tag or "",
|
||||||
|
"selected_bracket": int(bracket) if bracket is not None else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save selection to session (basic MVP; real build will use this later)
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
sess["commander"] = commander
|
||||||
|
sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||||
|
sess["bracket"] = int(bracket)
|
||||||
|
# Proceed to Step 3 placeholder for now
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step3.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": commander,
|
||||||
|
"tags": sess["tags"],
|
||||||
|
"bracket": sess["bracket"],
|
||||||
|
"defaults": orch.ideal_defaults(),
|
||||||
|
"labels": orch.ideal_labels(),
|
||||||
|
"values": orch.ideal_defaults(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step3", response_class=HTMLResponse)
|
||||||
|
async def build_step3_submit(
|
||||||
|
request: Request,
|
||||||
|
ramp: int = Form(...),
|
||||||
|
lands: int = Form(...),
|
||||||
|
basic_lands: int = Form(...),
|
||||||
|
creatures: int = Form(...),
|
||||||
|
removal: int = Form(...),
|
||||||
|
wipes: int = Form(...),
|
||||||
|
card_advantage: int = Form(...),
|
||||||
|
protection: int = Form(...),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
labels = orch.ideal_labels()
|
||||||
|
submitted = {
|
||||||
|
"ramp": ramp,
|
||||||
|
"lands": lands,
|
||||||
|
"basic_lands": basic_lands,
|
||||||
|
"creatures": creatures,
|
||||||
|
"removal": removal,
|
||||||
|
"wipes": wipes,
|
||||||
|
"card_advantage": card_advantage,
|
||||||
|
"protection": protection,
|
||||||
|
}
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
for k, v in submitted.items():
|
||||||
|
try:
|
||||||
|
iv = int(v)
|
||||||
|
except Exception:
|
||||||
|
errors.append(f"{labels.get(k, k)} must be a number.")
|
||||||
|
continue
|
||||||
|
if iv < 0:
|
||||||
|
errors.append(f"{labels.get(k, k)} cannot be negative.")
|
||||||
|
submitted[k] = iv
|
||||||
|
# Cross-field validation: basic lands should not exceed total lands
|
||||||
|
if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int):
|
||||||
|
if submitted["basic_lands"] > submitted["lands"]:
|
||||||
|
errors.append("Basic Lands cannot exceed Total Lands.")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step3.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"defaults": orch.ideal_defaults(),
|
||||||
|
"labels": labels,
|
||||||
|
"values": submitted,
|
||||||
|
"error": " ".join(errors),
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to session
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
sess["ideals"] = submitted
|
||||||
|
|
||||||
|
# Proceed to review (Step 4)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step4.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"labels": labels,
|
||||||
|
"values": submitted,
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/step3", response_class=HTMLResponse)
|
||||||
|
async def build_step3_get(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
defaults = orch.ideal_defaults()
|
||||||
|
values = sess.get("ideals") or defaults
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step3.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"defaults": defaults,
|
||||||
|
"labels": orch.ideal_labels(),
|
||||||
|
"values": values,
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/step4", response_class=HTMLResponse)
|
||||||
|
async def build_step4_get(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
labels = orch.ideal_labels()
|
||||||
|
values = sess.get("ideals") or orch.ideal_defaults()
|
||||||
|
commander = sess.get("commander")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_step4.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"labels": labels,
|
||||||
|
"values": values,
|
||||||
|
"commander": commander,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/step5", response_class=HTMLResponse)
|
||||||
|
async def build_step5_get(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step5.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||||
|
"status": None,
|
||||||
|
"stage_label": None,
|
||||||
|
"log": None,
|
||||||
|
"added_cards": [],
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@router.post("/step5/continue", response_class=HTMLResponse)
|
||||||
|
async def build_step5_continue(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
# Validate commander; redirect to step1 if missing
|
||||||
|
if not sess.get("commander"):
|
||||||
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
# Ensure build context exists; if not, start it first
|
||||||
|
if not sess.get("build_ctx"):
|
||||||
|
opts = orch.bracket_options()
|
||||||
|
default_bracket = (opts[0]["level"] if opts else 1)
|
||||||
|
bracket_val = sess.get("bracket")
|
||||||
|
try:
|
||||||
|
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||||
|
except Exception:
|
||||||
|
safe_bracket = int(default_bracket)
|
||||||
|
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||||
|
sess["build_ctx"] = orch.start_build_ctx(
|
||||||
|
commander=sess.get("commander"),
|
||||||
|
tags=sess.get("tags", []),
|
||||||
|
bracket=safe_bracket,
|
||||||
|
ideals=ideals_val,
|
||||||
|
)
|
||||||
|
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||||
|
status = "Build complete" if res.get("done") else "Stage complete"
|
||||||
|
stage_label = res.get("label")
|
||||||
|
log = res.get("log_delta", "")
|
||||||
|
added_cards = res.get("added_cards", [])
|
||||||
|
# Progress & downloads
|
||||||
|
i = res.get("idx")
|
||||||
|
n = res.get("total")
|
||||||
|
csv_path = res.get("csv_path") if res.get("done") else None
|
||||||
|
txt_path = res.get("txt_path") if res.get("done") else None
|
||||||
|
summary = res.get("summary") if res.get("done") else None
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step5.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||||
|
"status": status,
|
||||||
|
"stage_label": stage_label,
|
||||||
|
"log": log,
|
||||||
|
"added_cards": added_cards,
|
||||||
|
"i": i,
|
||||||
|
"n": n,
|
||||||
|
"csv_path": csv_path,
|
||||||
|
"txt_path": txt_path,
|
||||||
|
"summary": summary,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@router.post("/step5/rerun", response_class=HTMLResponse)
|
||||||
|
async def build_step5_rerun(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
if not sess.get("commander"):
|
||||||
|
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."})
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
# Rerun requires an existing context; if missing, create it and run first stage as rerun
|
||||||
|
if not sess.get("build_ctx"):
|
||||||
|
opts = orch.bracket_options()
|
||||||
|
default_bracket = (opts[0]["level"] if opts else 1)
|
||||||
|
bracket_val = sess.get("bracket")
|
||||||
|
try:
|
||||||
|
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||||
|
except Exception:
|
||||||
|
safe_bracket = int(default_bracket)
|
||||||
|
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||||
|
sess["build_ctx"] = orch.start_build_ctx(
|
||||||
|
commander=sess.get("commander"),
|
||||||
|
tags=sess.get("tags", []),
|
||||||
|
bracket=safe_bracket,
|
||||||
|
ideals=ideals_val,
|
||||||
|
)
|
||||||
|
res = orch.run_stage(sess["build_ctx"], rerun=True)
|
||||||
|
status = "Stage rerun complete" if not res.get("done") else "Build complete"
|
||||||
|
stage_label = res.get("label")
|
||||||
|
log = res.get("log_delta", "")
|
||||||
|
added_cards = res.get("added_cards", [])
|
||||||
|
i = res.get("idx")
|
||||||
|
n = res.get("total")
|
||||||
|
csv_path = res.get("csv_path") if res.get("done") else None
|
||||||
|
txt_path = res.get("txt_path") if res.get("done") else None
|
||||||
|
summary = res.get("summary") if res.get("done") else None
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step5.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": sess.get("commander"),
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||||
|
"status": status,
|
||||||
|
"stage_label": stage_label,
|
||||||
|
"log": log,
|
||||||
|
"added_cards": added_cards,
|
||||||
|
"i": i,
|
||||||
|
"n": n,
|
||||||
|
"csv_path": csv_path,
|
||||||
|
"txt_path": txt_path,
|
||||||
|
"summary": summary,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step5/start", response_class=HTMLResponse)
|
||||||
|
async def build_step5_start(request: Request) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
# Validate commander exists before starting
|
||||||
|
commander = sess.get("commander")
|
||||||
|
if not commander:
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step1.html",
|
||||||
|
{"request": request, "candidates": [], "error": "Please select a commander first."},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
try:
|
||||||
|
# Initialize step-by-step build context and run first stage
|
||||||
|
opts = orch.bracket_options()
|
||||||
|
default_bracket = (opts[0]["level"] if opts else 1)
|
||||||
|
bracket_val = sess.get("bracket")
|
||||||
|
try:
|
||||||
|
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||||
|
except Exception:
|
||||||
|
safe_bracket = int(default_bracket)
|
||||||
|
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||||
|
sess["build_ctx"] = orch.start_build_ctx(
|
||||||
|
commander=commander,
|
||||||
|
tags=sess.get("tags", []),
|
||||||
|
bracket=safe_bracket,
|
||||||
|
ideals=ideals_val,
|
||||||
|
)
|
||||||
|
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||||
|
status = "Stage complete" if not res.get("done") else "Build complete"
|
||||||
|
stage_label = res.get("label")
|
||||||
|
log = res.get("log_delta", "")
|
||||||
|
added_cards = res.get("added_cards", [])
|
||||||
|
i = res.get("idx")
|
||||||
|
n = res.get("total")
|
||||||
|
csv_path = res.get("csv_path") if res.get("done") else None
|
||||||
|
txt_path = res.get("txt_path") if res.get("done") else None
|
||||||
|
summary = res.get("summary") if res.get("done") else None
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step5.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": commander,
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||||
|
"status": status,
|
||||||
|
"stage_label": stage_label,
|
||||||
|
"log": log,
|
||||||
|
"added_cards": added_cards,
|
||||||
|
"i": i,
|
||||||
|
"n": n,
|
||||||
|
"csv_path": csv_path,
|
||||||
|
"txt_path": txt_path,
|
||||||
|
"summary": summary,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
# Surface a friendly error on the step 5 screen
|
||||||
|
resp = templates.TemplateResponse(
|
||||||
|
"build/_step5.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"commander": commander,
|
||||||
|
"tags": sess.get("tags", []),
|
||||||
|
"bracket": sess.get("bracket"),
|
||||||
|
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||||
|
"status": "Error",
|
||||||
|
"stage_label": None,
|
||||||
|
"log": f"Failed to start build: {e}",
|
||||||
|
"added_cards": [],
|
||||||
|
"i": None,
|
||||||
|
"n": None,
|
||||||
|
"csv_path": None,
|
||||||
|
"txt_path": None,
|
||||||
|
"summary": None,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@router.get("/step5/start", response_class=HTMLResponse)
|
||||||
|
async def build_step5_start_get(request: Request) -> HTMLResponse:
|
||||||
|
# Allow GET as a fallback to start the build (delegates to POST handler)
|
||||||
|
return await build_step5_start(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/banner", response_class=HTMLResponse)
|
||||||
|
async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse:
|
||||||
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
|
sess = get_session(sid)
|
||||||
|
commander = sess.get("commander")
|
||||||
|
tags = sess.get("tags", [])
|
||||||
|
# Render only the inner text for the subtitle
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"build/_banner_subtitle.html",
|
||||||
|
{"request": request, "commander": commander, "tags": tags, "step": step, "i": i, "n": n},
|
||||||
|
)
|
179
code/web/routes/configs.py
Normal file
179
code/web/routes/configs.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, UploadFile, File
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from ..app import templates
|
||||||
|
from ..services import orchestrator as orch
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/configs")
|
||||||
|
|
||||||
|
|
||||||
|
def _config_dir() -> Path:
|
||||||
|
# Prefer explicit env var if provided, else default to ./config
|
||||||
|
p = os.getenv("DECK_CONFIG")
|
||||||
|
if p:
|
||||||
|
# If env points to a file, use its parent dir; else treat as dir
|
||||||
|
pp = Path(p)
|
||||||
|
return (pp.parent if pp.suffix else pp).resolve()
|
||||||
|
return (Path.cwd() / "config").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_configs() -> list[dict]:
|
||||||
|
d = _config_dir()
|
||||||
|
try:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
items: list[dict] = []
|
||||||
|
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
|
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||||
|
try:
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
meta["commander"] = data.get("commander")
|
||||||
|
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
|
||||||
|
meta["tags"] = tags
|
||||||
|
meta["bracket_level"] = data.get("bracket_level")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
items.append(meta)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def configs_index(request: Request) -> HTMLResponse:
|
||||||
|
items = _list_configs()
|
||||||
|
# Load example deck.json from the config directory, if present
|
||||||
|
example_json = None
|
||||||
|
example_name = "deck.json"
|
||||||
|
try:
|
||||||
|
example_path = _config_dir() / example_name
|
||||||
|
if example_path.exists() and example_path.is_file():
|
||||||
|
example_json = example_path.read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
example_json = None
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view", response_class=HTMLResponse)
|
||||||
|
async def configs_view(request: Request, name: str) -> HTMLResponse:
|
||||||
|
base = _config_dir()
|
||||||
|
p = (base / name).resolve()
|
||||||
|
# Safety: ensure the resolved path is within config dir
|
||||||
|
try:
|
||||||
|
if base not in p.parents and p != base:
|
||||||
|
raise ValueError("Access denied")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/view.html",
|
||||||
|
{"request": request, "path": str(p), "name": p.name, "data": data},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_class=HTMLResponse)
|
||||||
|
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||||
|
base = _config_dir()
|
||||||
|
p = (base / name).resolve()
|
||||||
|
try:
|
||||||
|
if base not in p.parents and p != base:
|
||||||
|
raise ValueError("Access denied")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
commander = cfg.get("commander", "")
|
||||||
|
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
|
||||||
|
bracket = int(cfg.get("bracket_level") or 0)
|
||||||
|
ideals = cfg.get("ideal_counts", {}) or {}
|
||||||
|
|
||||||
|
# Run build headlessly with orchestrator
|
||||||
|
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/run_result.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"ok": False,
|
||||||
|
"error": res.get("error") or "Build failed",
|
||||||
|
"log": res.get("log", ""),
|
||||||
|
"cfg_name": p.name,
|
||||||
|
"commander": commander,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/run_result.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"ok": True,
|
||||||
|
"log": res.get("log", ""),
|
||||||
|
"csv_path": res.get("csv_path"),
|
||||||
|
"txt_path": res.get("txt_path"),
|
||||||
|
"summary": res.get("summary"),
|
||||||
|
"cfg_name": p.name,
|
||||||
|
"commander": commander,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_class=HTMLResponse)
|
||||||
|
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||||
|
# Optional helper: allow uploading a JSON config
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
data = json.loads(content.decode("utf-8"))
|
||||||
|
# Minimal validation
|
||||||
|
if not data.get("commander"):
|
||||||
|
raise ValueError("Missing 'commander'")
|
||||||
|
except Exception as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
|
||||||
|
)
|
||||||
|
# Save to config dir with original filename (or unique)
|
||||||
|
d = _config_dir()
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
fname = file.filename or "config.json"
|
||||||
|
out = d / fname
|
||||||
|
i = 1
|
||||||
|
while out.exists():
|
||||||
|
stem = out.stem
|
||||||
|
out = d / f"{stem}_{i}.json"
|
||||||
|
i += 1
|
||||||
|
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"configs/index.html",
|
||||||
|
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
|
||||||
|
)
|
267
code/web/routes/decks.py
Normal file
267
code/web/routes/decks.py
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pathlib import Path
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from ..app import templates
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/decks")
|
||||||
|
|
||||||
|
|
||||||
|
def _deck_dir() -> Path:
|
||||||
|
# Prefer explicit env var if provided, else default to ./deck_files
|
||||||
|
p = os.getenv("DECK_EXPORTS")
|
||||||
|
if p:
|
||||||
|
return Path(p).resolve()
|
||||||
|
return (Path.cwd() / "deck_files").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_decks() -> list[dict]:
|
||||||
|
d = _deck_dir()
|
||||||
|
try:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
items: list[dict] = []
|
||||||
|
# Prefer CSV entries and pair with matching TXT if present
|
||||||
|
for p in sorted(d.glob("*.csv"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
|
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||||
|
stem = p.stem
|
||||||
|
txt = p.with_suffix('.txt')
|
||||||
|
if txt.exists():
|
||||||
|
meta["txt_name"] = txt.name
|
||||||
|
meta["txt_path"] = str(txt)
|
||||||
|
# Prefer sidecar summary meta if present
|
||||||
|
sidecar = p.with_suffix('.summary.json')
|
||||||
|
if sidecar.exists():
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||||
|
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
||||||
|
meta["commander"] = _m.get('commander') or meta.get("commander")
|
||||||
|
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
||||||
|
if not meta.get("commander"):
|
||||||
|
parts = stem.split('_')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
meta["commander"] = parts[0]
|
||||||
|
meta["tags"] = parts[1:-1]
|
||||||
|
else:
|
||||||
|
meta["commander"] = stem
|
||||||
|
meta["tags"] = []
|
||||||
|
items.append(meta)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_within(base: Path, target: Path) -> bool:
|
||||||
|
try:
|
||||||
|
base_r = base.resolve()
|
||||||
|
targ_r = target.resolve()
|
||||||
|
return (base_r == targ_r) or (base_r in targ_r.parents)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, int], Dict[str, List[dict]]]:
|
||||||
|
"""Parse CSV export to reconstruct minimal summary pieces.
|
||||||
|
|
||||||
|
Returns: (meta, type_counts, curve_counts, type_cards)
|
||||||
|
meta: { 'commander': str, 'colors': [..] }
|
||||||
|
"""
|
||||||
|
headers = []
|
||||||
|
type_counts: Dict[str, int] = {}
|
||||||
|
type_cards: Dict[str, List[dict]] = {}
|
||||||
|
curve_bins = ['0','1','2','3','4','5','6+']
|
||||||
|
curve_counts: Dict[str, int] = {b: 0 for b in curve_bins}
|
||||||
|
curve_cards: Dict[str, List[dict]] = {b: [] for b in curve_bins}
|
||||||
|
meta: dict = {"commander": "", "colors": []}
|
||||||
|
commander_seen = False
|
||||||
|
# Infer commander from filename stem (pattern Commander_Themes_YYYYMMDD)
|
||||||
|
stem_parts = csv_path.stem.split('_')
|
||||||
|
inferred_commander = stem_parts[0] if stem_parts else ''
|
||||||
|
|
||||||
|
def classify_mv(raw) -> str:
|
||||||
|
try:
|
||||||
|
v = float(raw)
|
||||||
|
except Exception:
|
||||||
|
v = 0.0
|
||||||
|
return '6+' if v >= 6 else str(int(v))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with csv_path.open('r', encoding='utf-8') as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
headers = next(reader, [])
|
||||||
|
# Expected columns include: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, ..., Tags, Text, Owned
|
||||||
|
name_idx = headers.index('Name') if 'Name' in headers else 0
|
||||||
|
count_idx = headers.index('Count') if 'Count' in headers else 1
|
||||||
|
type_idx = headers.index('Type') if 'Type' in headers else 2
|
||||||
|
mv_idx = headers.index('ManaValue') if 'ManaValue' in headers else (headers.index('Mana Value') if 'Mana Value' in headers else -1)
|
||||||
|
role_idx = headers.index('Role') if 'Role' in headers else -1
|
||||||
|
tags_idx = headers.index('Tags') if 'Tags' in headers else -1
|
||||||
|
colors_idx = headers.index('Colors') if 'Colors' in headers else -1
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name = row[name_idx]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cnt = int(float(row[count_idx])) if row[count_idx] else 1
|
||||||
|
except Exception:
|
||||||
|
cnt = 1
|
||||||
|
type_line = row[type_idx] if type_idx >= 0 and type_idx < len(row) else ''
|
||||||
|
role = (row[role_idx] if role_idx >= 0 and role_idx < len(row) else '')
|
||||||
|
tags = (row[tags_idx] if tags_idx >= 0 and tags_idx < len(row) else '')
|
||||||
|
tags_list = [t.strip() for t in tags.split(';') if t.strip()]
|
||||||
|
|
||||||
|
# Commander detection: prefer filename inference; else best-effort via type line containing 'Commander'
|
||||||
|
is_commander = (inferred_commander and name == inferred_commander)
|
||||||
|
if not is_commander:
|
||||||
|
is_commander = isinstance(type_line, str) and ('commander' in type_line.lower())
|
||||||
|
if is_commander and not commander_seen:
|
||||||
|
meta['commander'] = name
|
||||||
|
commander_seen = True
|
||||||
|
|
||||||
|
# Map type_line to broad category
|
||||||
|
tl = (type_line or '').lower()
|
||||||
|
if 'battle' in tl:
|
||||||
|
cat = 'Battle'
|
||||||
|
elif 'planeswalker' in tl:
|
||||||
|
cat = 'Planeswalker'
|
||||||
|
elif 'creature' in tl:
|
||||||
|
cat = 'Creature'
|
||||||
|
elif 'instant' in tl:
|
||||||
|
cat = 'Instant'
|
||||||
|
elif 'sorcery' in tl:
|
||||||
|
cat = 'Sorcery'
|
||||||
|
elif 'artifact' in tl:
|
||||||
|
cat = 'Artifact'
|
||||||
|
elif 'enchantment' in tl:
|
||||||
|
cat = 'Enchantment'
|
||||||
|
elif 'land' in tl:
|
||||||
|
cat = 'Land'
|
||||||
|
else:
|
||||||
|
cat = 'Other'
|
||||||
|
|
||||||
|
# Type counts/cards (exclude commander entry from distribution)
|
||||||
|
if not is_commander:
|
||||||
|
type_counts[cat] = type_counts.get(cat, 0) + cnt
|
||||||
|
type_cards.setdefault(cat, []).append({
|
||||||
|
'name': name,
|
||||||
|
'count': cnt,
|
||||||
|
'role': role,
|
||||||
|
'tags': tags_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Curve
|
||||||
|
if mv_idx >= 0 and mv_idx < len(row):
|
||||||
|
bucket = classify_mv(row[mv_idx])
|
||||||
|
if bucket not in curve_counts:
|
||||||
|
bucket = '6+'
|
||||||
|
curve_counts[bucket] += cnt
|
||||||
|
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||||
|
|
||||||
|
# Colors (from Colors col for commander/overall)
|
||||||
|
if is_commander and colors_idx >= 0 and colors_idx < len(row):
|
||||||
|
cid = row[colors_idx] or ''
|
||||||
|
if isinstance(cid, str):
|
||||||
|
meta['colors'] = list(cid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Precedence ordering
|
||||||
|
precedence_order = [
|
||||||
|
'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||||
|
]
|
||||||
|
prec_index = {k: i for i, k in enumerate(precedence_order)}
|
||||||
|
type_order = sorted(type_counts.keys(), key=lambda k: prec_index.get(k, 999))
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'type_breakdown': {
|
||||||
|
'counts': type_counts,
|
||||||
|
'order': type_order,
|
||||||
|
'cards': type_cards,
|
||||||
|
'total': sum(type_counts.values()),
|
||||||
|
},
|
||||||
|
'pip_distribution': {
|
||||||
|
# Not recoverable from CSV without mana symbols; leave zeros
|
||||||
|
'counts': {c: 0 for c in ('W','U','B','R','G')},
|
||||||
|
'weights': {c: 0 for c in ('W','U','B','R','G')},
|
||||||
|
},
|
||||||
|
'mana_generation': {
|
||||||
|
# Not recoverable from CSV alone
|
||||||
|
'W': 0, 'U': 0, 'B': 0, 'R': 0, 'G': 0, 'total_sources': 0,
|
||||||
|
},
|
||||||
|
'mana_curve': {
|
||||||
|
**curve_counts,
|
||||||
|
'total_spells': sum(curve_counts.values()),
|
||||||
|
'cards': curve_cards,
|
||||||
|
},
|
||||||
|
'colors': meta.get('colors', []),
|
||||||
|
}
|
||||||
|
return summary, type_counts, curve_counts, type_cards
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def decks_index(request: Request) -> HTMLResponse:
|
||||||
|
items = _list_decks()
|
||||||
|
return templates.TemplateResponse("decks/index.html", {"request": request, "items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view", response_class=HTMLResponse)
|
||||||
|
async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
|
base = _deck_dir()
|
||||||
|
p = (base / name).resolve()
|
||||||
|
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||||
|
return templates.TemplateResponse("decks/index.html", {"request": request, "items": _list_decks(), "error": "Deck not found."})
|
||||||
|
|
||||||
|
# Try to load sidecar summary JSON first
|
||||||
|
summary = None
|
||||||
|
commander_name = ''
|
||||||
|
tags: List[str] = []
|
||||||
|
sidecar = p.with_suffix('.summary.json')
|
||||||
|
if sidecar.exists():
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
summary = payload.get('summary')
|
||||||
|
meta = payload.get('meta', {})
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
commander_name = meta.get('commander') or ''
|
||||||
|
_tags = meta.get('tags') or []
|
||||||
|
if isinstance(_tags, list):
|
||||||
|
tags = [str(t) for t in _tags]
|
||||||
|
except Exception:
|
||||||
|
summary = None
|
||||||
|
if not summary:
|
||||||
|
# Reconstruct minimal summary from CSV
|
||||||
|
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
||||||
|
stem = p.stem
|
||||||
|
txt_path = p.with_suffix('.txt')
|
||||||
|
# If missing still, infer from filename stem
|
||||||
|
if not commander_name:
|
||||||
|
parts = stem.split('_')
|
||||||
|
commander_name = parts[0] if parts else ''
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"request": request,
|
||||||
|
"name": p.name,
|
||||||
|
"csv_path": str(p),
|
||||||
|
"txt_path": str(txt_path) if txt_path.exists() else None,
|
||||||
|
"summary": summary,
|
||||||
|
"commander": commander_name,
|
||||||
|
"tags": tags,
|
||||||
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse("decks/view.html", ctx)
|
11
code/web/routes/home.py
Normal file
11
code/web/routes/home.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from ..app import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse("home.html", {"request": request})
|
105
code/web/routes/setup.py
Normal file
105
code/web/routes/setup.py
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi import Body
|
||||||
|
from pathlib import Path
|
||||||
|
import json as _json
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from ..app import templates
|
||||||
|
from ..services.orchestrator import _ensure_setup_ready # type: ignore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/setup")
|
||||||
|
|
||||||
|
|
||||||
|
def _kickoff_setup_async(force: bool = False):
|
||||||
|
def runner():
|
||||||
|
try:
|
||||||
|
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
t = threading.Thread(target=runner, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/running", response_class=HTMLResponse)
|
||||||
|
async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override]
|
||||||
|
# Optionally start the setup/tagging in the background if requested
|
||||||
|
try:
|
||||||
|
if start and int(start) != 0:
|
||||||
|
# honor optional force flag from query
|
||||||
|
f = False
|
||||||
|
try:
|
||||||
|
if force is not None:
|
||||||
|
f = bool(force)
|
||||||
|
else:
|
||||||
|
q_force = request.query_params.get('force')
|
||||||
|
if q_force is not None:
|
||||||
|
f = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
except Exception:
|
||||||
|
f = False
|
||||||
|
_kickoff_setup_async(force=f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return templates.TemplateResponse("setup/running.html", {"request": request, "next_url": next})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def setup_start(request: Request, force: bool = Body(False)): # accept JSON body {"force": true}
|
||||||
|
try:
|
||||||
|
# Allow query string override as well (?force=1)
|
||||||
|
try:
|
||||||
|
q_force = request.query_params.get('force')
|
||||||
|
if q_force is not None:
|
||||||
|
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Write immediate status so UI reflects the start
|
||||||
|
try:
|
||||||
|
p = Path("csv_files")
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||||
|
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||||
|
_json.dump(status, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_kickoff_setup_async(force=bool(force))
|
||||||
|
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/start")
|
||||||
|
async def setup_start_get(request: Request):
|
||||||
|
"""GET alias to start setup/tagging via query string (?force=1).
|
||||||
|
|
||||||
|
Useful as a fallback from clients that cannot POST JSON.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine force from query params
|
||||||
|
force = False
|
||||||
|
try:
|
||||||
|
q_force = request.query_params.get('force')
|
||||||
|
if q_force is not None:
|
||||||
|
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Write immediate status so UI reflects the start
|
||||||
|
try:
|
||||||
|
p = Path("csv_files")
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||||
|
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||||
|
_json.dump(status, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_kickoff_setup_async(force=bool(force))
|
||||||
|
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def setup_index(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse("setup/index.html", {"request": request})
|
1
code/web/services/__init__.py
Normal file
1
code/web/services/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Services package marker
|
865
code/web/services/orchestrator.py
Normal file
865
code/web/services/orchestrator.py
Normal file
|
@ -0,0 +1,865 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
import copy
|
||||||
|
from deck_builder.builder import DeckBuilder
|
||||||
|
from deck_builder.phases.phase0_core import BRACKET_DEFINITIONS
|
||||||
|
from deck_builder import builder_constants as bc
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def commander_names() -> List[str]:
|
||||||
|
tmp = DeckBuilder()
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
return df["name"].astype(str).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, List[str]]]:
|
||||||
|
# Normalize query similar to CLI to reduce case sensitivity surprises
|
||||||
|
tmp = DeckBuilder()
|
||||||
|
try:
|
||||||
|
if hasattr(tmp, '_normalize_commander_query'):
|
||||||
|
query = tmp._normalize_commander_query(query) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
# Light fallback: basic title case
|
||||||
|
query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
# Filter to plausible commanders: Legendary Creature, or text explicitly allows being a commander.
|
||||||
|
try:
|
||||||
|
cols = set(df.columns.astype(str))
|
||||||
|
has_type = ('type' in cols) or ('type_line' in cols)
|
||||||
|
has_text = ('text' in cols) or ('oracleText' in cols)
|
||||||
|
if has_type or has_text:
|
||||||
|
def _is_commander_row(_r) -> bool:
|
||||||
|
try:
|
||||||
|
tline = str(_r.get('type', _r.get('type_line', '')) or '').lower()
|
||||||
|
textv = str(_r.get('text', _r.get('oracleText', '')) or '').lower()
|
||||||
|
if 'legendary' in tline and 'creature' in tline:
|
||||||
|
return True
|
||||||
|
if 'legendary' in tline and 'planeswalker' in tline and 'can be your commander' in textv:
|
||||||
|
return True
|
||||||
|
if 'can be your commander' in textv:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
df_comm = df[df.apply(_is_commander_row, axis=1)]
|
||||||
|
if not df_comm.empty:
|
||||||
|
df = df_comm
|
||||||
|
# else: keep df as-is when columns not present
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
names = df["name"].astype(str).tolist()
|
||||||
|
# Reuse existing scoring helpers through the DeckBuilder API
|
||||||
|
scored_raw = tmp._gather_candidates(query, names)
|
||||||
|
# Consider a wider pool for re-ranking so exact substrings bubble up
|
||||||
|
pool = scored_raw[: max(limit * 5, 50)]
|
||||||
|
# Force-include any names that contain the raw query as a substring (case-insensitive)
|
||||||
|
# to avoid missing obvious matches like 'Inti, Seneschal of the Sun' for 'inti'.
|
||||||
|
try:
|
||||||
|
q_raw = (query or "").strip().lower()
|
||||||
|
if q_raw:
|
||||||
|
have = {n for (n, _s) in pool}
|
||||||
|
# Map original scores for reuse
|
||||||
|
base_scores = {n: int(s) for (n, s) in scored_raw}
|
||||||
|
for n in names:
|
||||||
|
nl = str(n).lower()
|
||||||
|
if q_raw in nl and n not in have:
|
||||||
|
# Assign a reasonable base score if not present; favor prefixes
|
||||||
|
approx = base_scores.get(n, 90 if nl.startswith(q_raw) else 80)
|
||||||
|
pool.append((n, approx))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Attach color identity for each candidate
|
||||||
|
try:
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
except Exception:
|
||||||
|
df = None
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
tokens = [t for t in re.split(r"[\s,]+", q) if t]
|
||||||
|
def _color_list_for(name: str) -> List[str]:
|
||||||
|
colors: List[str] = []
|
||||||
|
try:
|
||||||
|
if df is not None:
|
||||||
|
row = df[df["name"].astype(str) == str(name)]
|
||||||
|
if not row.empty:
|
||||||
|
ci = row.iloc[0].get("colorIdentity")
|
||||||
|
if isinstance(ci, list):
|
||||||
|
colors = [str(c).upper() for c in ci if str(c).strip()]
|
||||||
|
elif isinstance(ci, str) and ci.strip():
|
||||||
|
parts = [p.strip().upper() for p in ci.replace('[', '').replace(']', '').replace("'", '').split(',') if p.strip()]
|
||||||
|
colors = parts if parts else list(ci)
|
||||||
|
if not colors:
|
||||||
|
colors = ["C"]
|
||||||
|
except Exception:
|
||||||
|
colors = ["C"]
|
||||||
|
return colors
|
||||||
|
|
||||||
|
rescored: List[Tuple[str, int, List[str], int, int, int]] = [] # (name, orig_score, colors, rank_score, pos, exact_first_word)
|
||||||
|
for name, score in pool:
|
||||||
|
colors: List[str] = []
|
||||||
|
colors = _color_list_for(name)
|
||||||
|
nl = str(name).lower()
|
||||||
|
bonus = 0
|
||||||
|
pos = nl.find(q) if q else -1
|
||||||
|
# Extract first word (letters only) for exact first-word preference
|
||||||
|
try:
|
||||||
|
m_first = re.match(r"^[a-z0-9']+", nl)
|
||||||
|
first_word = m_first.group(0) if m_first else ""
|
||||||
|
except Exception:
|
||||||
|
first_word = nl.split(" ", 1)[0] if nl else ""
|
||||||
|
exact_first = 1 if (q and first_word == q) else 0
|
||||||
|
# Base heuristics
|
||||||
|
if q:
|
||||||
|
if nl == q:
|
||||||
|
bonus += 100
|
||||||
|
if nl.startswith(q):
|
||||||
|
bonus += 60
|
||||||
|
if re.search(r"\b" + re.escape(q), nl):
|
||||||
|
bonus += 40
|
||||||
|
if q in nl:
|
||||||
|
bonus += 30
|
||||||
|
# Strongly prefer exact first-word equality over general prefix
|
||||||
|
if exact_first:
|
||||||
|
bonus += 140
|
||||||
|
# Multi-token bonuses
|
||||||
|
if tokens:
|
||||||
|
present = sum(1 for t in tokens if t in nl)
|
||||||
|
all_present = 1 if all(t in nl for t in tokens) else 0
|
||||||
|
bonus += present * 10 + all_present * 40
|
||||||
|
# Extra if first token is a prefix
|
||||||
|
if nl.startswith(tokens[0]):
|
||||||
|
bonus += 15
|
||||||
|
# Favor shorter names slightly and earlier positions
|
||||||
|
bonus += max(0, 20 - len(nl))
|
||||||
|
if pos >= 0:
|
||||||
|
bonus += max(0, 20 - pos)
|
||||||
|
rank_score = int(score) + bonus
|
||||||
|
rescored.append((name, int(score), colors, rank_score, pos if pos >= 0 else 10**6, exact_first))
|
||||||
|
|
||||||
|
# Sort: exact first-word matches first, then by rank score desc, then earliest position, then original score desc, then name asc
|
||||||
|
rescored.sort(key=lambda x: (-x[5], -x[3], x[4], -x[1], x[0]))
|
||||||
|
top = rescored[:limit]
|
||||||
|
return [(name, orig_score, colors) for (name, orig_score, colors, _r, _p, _e) in top]
|
||||||
|
|
||||||
|
|
||||||
|
def commander_inspect(name: str) -> Dict[str, Any]:
|
||||||
|
tmp = DeckBuilder()
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
row = df[df["name"] == name]
|
||||||
|
if row.empty:
|
||||||
|
return {"ok": False, "error": "Commander not found"}
|
||||||
|
pretty = tmp._format_commander_pretty(row.iloc[0])
|
||||||
|
return {"ok": True, "pretty": pretty}
|
||||||
|
|
||||||
|
|
||||||
|
def commander_select(name: str) -> Dict[str, Any]:
|
||||||
|
tmp = DeckBuilder()
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
# Try exact match, then normalized match
|
||||||
|
row = df[df["name"] == name]
|
||||||
|
if row.empty:
|
||||||
|
try:
|
||||||
|
if hasattr(tmp, '_normalize_commander_query'):
|
||||||
|
name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')])
|
||||||
|
row = df[df["name"] == name2]
|
||||||
|
except Exception:
|
||||||
|
row = df[df["name"] == name]
|
||||||
|
if row.empty:
|
||||||
|
return {"ok": False, "error": "Commander not found"}
|
||||||
|
tmp._apply_commander_selection(row.iloc[0])
|
||||||
|
# Derive tags and a quick preview of bracket choices
|
||||||
|
tags = list(dict.fromkeys(tmp.commander_tags)) if hasattr(tmp, "commander_tags") else []
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"name": name,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tags_for_commander(name: str) -> List[str]:
|
||||||
|
tmp = DeckBuilder()
|
||||||
|
df = tmp.load_commander_data()
|
||||||
|
row = df[df["name"] == name]
|
||||||
|
if row.empty:
|
||||||
|
return []
|
||||||
|
raw = row.iloc[0].get("themeTags", [])
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return list(dict.fromkeys([str(t).strip() for t in raw if str(t).strip()]))
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
parts = [p.strip().strip("'\"") for p in raw.split(',')]
|
||||||
|
return [p for p in parts if p]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def bracket_options() -> List[Dict[str, Any]]:
|
||||||
|
return [{"level": b.level, "name": b.name, "desc": b.short_desc} for b in BRACKET_DEFINITIONS]
|
||||||
|
|
||||||
|
|
||||||
|
def ideal_defaults() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ramp": getattr(bc, 'DEFAULT_RAMP_COUNT', 10),
|
||||||
|
"lands": getattr(bc, 'DEFAULT_LAND_COUNT', 35),
|
||||||
|
"basic_lands": getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20),
|
||||||
|
"fetch_lands": getattr(bc, 'FETCH_LAND_DEFAULT_COUNT', 3),
|
||||||
|
"creatures": getattr(bc, 'DEFAULT_CREATURE_COUNT', 28),
|
||||||
|
"removal": getattr(bc, 'DEFAULT_REMOVAL_COUNT', 10),
|
||||||
|
"wipes": getattr(bc, 'DEFAULT_WIPES_COUNT', 2),
|
||||||
|
"card_advantage": getattr(bc, 'DEFAULT_CARD_ADVANTAGE_COUNT', 8),
|
||||||
|
"protection": getattr(bc, 'DEFAULT_PROTECTION_COUNT', 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ideal_labels() -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
'ramp': 'Ramp',
|
||||||
|
'lands': 'Total Lands',
|
||||||
|
'basic_lands': 'Basic Lands (Min)',
|
||||||
|
'fetch_lands': 'Fetch Lands',
|
||||||
|
'creatures': 'Creatures',
|
||||||
|
'removal': 'Spot Removal',
|
||||||
|
'wipes': 'Board Wipes',
|
||||||
|
'card_advantage': 'Card Advantage',
|
||||||
|
'protection': 'Protection',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_setup_ready(out, force: bool = False) -> None:
|
||||||
|
"""Ensure card CSVs exist and tagging has completed; bootstrap if needed.
|
||||||
|
|
||||||
|
Mirrors the CLI behavior used in build_deck_full: if csv_files/cards.csv is
|
||||||
|
missing, too old, or the tagging flag is absent, run initial setup and tagging.
|
||||||
|
"""
|
||||||
|
def _write_status(payload: dict) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs('csv_files', exist_ok=True)
|
||||||
|
# Preserve started_at if present
|
||||||
|
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||||
|
existing = {}
|
||||||
|
try:
|
||||||
|
if os.path.exists(status_path):
|
||||||
|
with open(status_path, 'r', encoding='utf-8') as _rf:
|
||||||
|
existing = json.load(_rf) or {}
|
||||||
|
except Exception:
|
||||||
|
existing = {}
|
||||||
|
# Merge and keep started_at unless explicitly overridden
|
||||||
|
merged = {**existing, **payload}
|
||||||
|
if 'started_at' not in merged and existing.get('started_at'):
|
||||||
|
merged['started_at'] = existing.get('started_at')
|
||||||
|
merged['updated'] = _dt.now().isoformat(timespec='seconds')
|
||||||
|
with open(status_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(merged, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
cards_path = os.path.join('csv_files', 'cards.csv')
|
||||||
|
flag_path = os.path.join('csv_files', '.tagging_complete.json')
|
||||||
|
refresh_needed = bool(force)
|
||||||
|
if force:
|
||||||
|
_write_status({"running": True, "phase": "setup", "message": "Forcing full setup and tagging...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||||
|
|
||||||
|
if not os.path.exists(cards_path):
|
||||||
|
out("cards.csv not found. Running initial setup and tagging...")
|
||||||
|
_write_status({"running": True, "phase": "setup", "message": "Preparing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||||
|
refresh_needed = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
age_seconds = time.time() - os.path.getmtime(cards_path)
|
||||||
|
if age_seconds > 7 * 24 * 60 * 60 and not force:
|
||||||
|
out("cards.csv is older than 7 days. Refreshing data (setup + tagging)...")
|
||||||
|
_write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||||
|
refresh_needed = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not os.path.exists(flag_path):
|
||||||
|
out("Tagging completion flag not found. Performing full tagging...")
|
||||||
|
if not refresh_needed:
|
||||||
|
_write_status({"running": True, "phase": "tagging", "message": "Applying tags to card database...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
|
||||||
|
refresh_needed = True
|
||||||
|
|
||||||
|
if refresh_needed:
|
||||||
|
try:
|
||||||
|
from file_setup.setup import initial_setup # type: ignore
|
||||||
|
# Always run initial_setup when forced or when cards are missing/stale
|
||||||
|
initial_setup()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Initial setup failed: {e}")
|
||||||
|
_write_status({"running": False, "phase": "error", "message": f"Initial setup failed: {e}"})
|
||||||
|
return
|
||||||
|
# Tagging with granular color progress
|
||||||
|
try:
|
||||||
|
from tagging import tagger as _tagger # type: ignore
|
||||||
|
from settings import COLORS as _COLORS # type: ignore
|
||||||
|
colors = list(_COLORS)
|
||||||
|
total = len(colors)
|
||||||
|
_write_status({
|
||||||
|
"running": True,
|
||||||
|
"phase": "tagging",
|
||||||
|
"message": "Tagging cards (this may take a while)...",
|
||||||
|
"color": None,
|
||||||
|
"percent": 0,
|
||||||
|
"color_idx": 0,
|
||||||
|
"color_total": total,
|
||||||
|
"tagging_started_at": _dt.now().isoformat(timespec='seconds')
|
||||||
|
})
|
||||||
|
for idx, _color in enumerate(colors, start=1):
|
||||||
|
try:
|
||||||
|
pct = int((idx - 1) * 100 / max(1, total))
|
||||||
|
# Estimate ETA based on average time per completed color
|
||||||
|
eta_s = None
|
||||||
|
try:
|
||||||
|
from datetime import datetime as __dt
|
||||||
|
ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore
|
||||||
|
elapsed = max(0.0, (_dt.now() - ts).total_seconds())
|
||||||
|
completed = max(0, idx - 1)
|
||||||
|
if completed > 0:
|
||||||
|
avg = elapsed / completed
|
||||||
|
remaining = max(0, total - completed)
|
||||||
|
eta_s = int(avg * remaining)
|
||||||
|
except Exception:
|
||||||
|
eta_s = None
|
||||||
|
payload = {
|
||||||
|
"running": True,
|
||||||
|
"phase": "tagging",
|
||||||
|
"message": f"Tagging {_color}...",
|
||||||
|
"color": _color,
|
||||||
|
"percent": pct,
|
||||||
|
"color_idx": idx,
|
||||||
|
"color_total": total,
|
||||||
|
}
|
||||||
|
if eta_s is not None:
|
||||||
|
payload["eta_seconds"] = eta_s
|
||||||
|
_write_status(payload)
|
||||||
|
_tagger.load_dataframe(_color)
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Tagging {_color} failed: {e}")
|
||||||
|
_write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color})
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Tagging failed to start: {e}")
|
||||||
|
_write_status({"running": False, "phase": "error", "message": f"Tagging failed to start: {e}"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.makedirs('csv_files', exist_ok=True)
|
||||||
|
with open(flag_path, 'w', encoding='utf-8') as _fh:
|
||||||
|
json.dump({'tagged_at': _dt.now().isoformat(timespec='seconds')}, _fh)
|
||||||
|
# Final status with percent 100 and timing info
|
||||||
|
finished_dt = _dt.now()
|
||||||
|
finished = finished_dt.isoformat(timespec='seconds')
|
||||||
|
# Compute duration_seconds if started_at exists
|
||||||
|
duration_s = None
|
||||||
|
try:
|
||||||
|
from datetime import datetime as __dt
|
||||||
|
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||||
|
with open(status_path, 'r', encoding='utf-8') as _rf:
|
||||||
|
_st = json.load(_rf) or {}
|
||||||
|
if _st.get('started_at'):
|
||||||
|
start_dt = __dt.fromisoformat(_st['started_at'])
|
||||||
|
duration_s = int(max(0.0, (finished_dt - start_dt).total_seconds()))
|
||||||
|
except Exception:
|
||||||
|
duration_s = None
|
||||||
|
payload = {"running": False, "phase": "done", "message": "Setup complete", "color": None, "percent": 100, "finished_at": finished}
|
||||||
|
if duration_s is not None:
|
||||||
|
payload["duration_seconds"] = duration_s
|
||||||
|
_write_status(payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
# Non-fatal; downstream loads will still attempt and surface errors in logs
|
||||||
|
_write_status({"running": False, "phase": "error", "message": "Setup check failed"})
|
||||||
|
|
||||||
|
|
||||||
|
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]:
|
||||||
|
"""Run the deck build end-to-end with provided selections and capture logs.
|
||||||
|
|
||||||
|
Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] }
|
||||||
|
"""
|
||||||
|
logs: List[str] = []
|
||||||
|
|
||||||
|
def out(msg: str) -> None:
|
||||||
|
try:
|
||||||
|
logs.append(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Provide a no-op input function so any leftover prompts auto-accept defaults
|
||||||
|
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True)
|
||||||
|
# Ensure setup/tagging present for web headless run
|
||||||
|
_ensure_setup_ready(out)
|
||||||
|
# Commander selection
|
||||||
|
df = b.load_commander_data()
|
||||||
|
row = df[df["name"].astype(str) == str(commander)]
|
||||||
|
if row.empty:
|
||||||
|
return {"ok": False, "error": f"Commander not found: {commander}", "log": "\n".join(logs)}
|
||||||
|
b._apply_commander_selection(row.iloc[0])
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
b.selected_tags = list(tags or [])
|
||||||
|
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
||||||
|
b.secondary_tag = b.selected_tags[1] if len(b.selected_tags) > 1 else None
|
||||||
|
b.tertiary_tag = b.selected_tags[2] if len(b.selected_tags) > 2 else None
|
||||||
|
try:
|
||||||
|
b._update_commander_dict_with_selected_tags()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Bracket
|
||||||
|
bd = next((x for x in BRACKET_DEFINITIONS if int(getattr(x, 'level', 0)) == int(bracket)), None)
|
||||||
|
if bd is None:
|
||||||
|
return {"ok": False, "error": f"Invalid bracket level: {bracket}", "log": "\n".join(logs)}
|
||||||
|
b.bracket_definition = bd
|
||||||
|
b.bracket_level = bd.level
|
||||||
|
b.bracket_name = bd.name
|
||||||
|
b.bracket_limits = dict(getattr(bd, 'limits', {}))
|
||||||
|
|
||||||
|
# Ideal counts
|
||||||
|
b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()}
|
||||||
|
|
||||||
|
# Load data and run phases
|
||||||
|
try:
|
||||||
|
b.determine_color_identity()
|
||||||
|
b.setup_dataframes()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Failed to load color identity/card pool: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
b._run_land_build_steps()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Land build failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'add_creatures_phase'):
|
||||||
|
b.add_creatures_phase()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Creature phase failed: {e}")
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'add_spells_phase'):
|
||||||
|
b.add_spells_phase()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Spell phase failed: {e}")
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'post_spell_land_adjust'):
|
||||||
|
b.post_spell_land_adjust()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Post-spell land adjust failed: {e}")
|
||||||
|
|
||||||
|
# Reporting/exports
|
||||||
|
csv_path = None
|
||||||
|
txt_path = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'run_reporting_phase'):
|
||||||
|
b.run_reporting_phase()
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Reporting phase failed: {e}")
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'export_decklist_csv'):
|
||||||
|
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
|
except Exception as e:
|
||||||
|
out(f"CSV export failed: {e}")
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'export_decklist_text'):
|
||||||
|
# Try to mirror build_deck_full behavior by displaying the contents
|
||||||
|
import os as _os
|
||||||
|
base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "")
|
||||||
|
txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
b._display_txt_contents(txt_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
out(f"Text export failed: {e}")
|
||||||
|
|
||||||
|
# Build structured summary for UI
|
||||||
|
summary = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'build_deck_summary'):
|
||||||
|
summary = b.build_deck_summary() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
summary = None
|
||||||
|
# Write sidecar summary JSON next to CSV (if available)
|
||||||
|
try:
|
||||||
|
if summary and csv_path:
|
||||||
|
import os as _os
|
||||||
|
import json as _json
|
||||||
|
base, _ = _os.path.splitext(csv_path)
|
||||||
|
sidecar = base + '.summary.json'
|
||||||
|
meta = {
|
||||||
|
"commander": getattr(b, 'commander_name', '') or getattr(b, 'commander', ''),
|
||||||
|
"tags": list(getattr(b, 'selected_tags', []) or []) or [t for t in [getattr(b, 'primary_tag', None), getattr(b, 'secondary_tag', None), getattr(b, 'tertiary_tag', None)] if t],
|
||||||
|
"bracket_level": getattr(b, 'bracket_level', None),
|
||||||
|
"csv": csv_path,
|
||||||
|
"txt": txt_path,
|
||||||
|
}
|
||||||
|
payload = {"meta": meta, "summary": summary}
|
||||||
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "log": "\n".join(logs), "csv_path": csv_path, "txt_path": txt_path, "summary": summary}
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"Build failed: {e}")
|
||||||
|
return {"ok": False, "error": str(e), "log": "\n".join(logs)}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# Step-by-step build session
|
||||||
|
# -----------------
|
||||||
|
def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
||||||
|
stages: List[Dict[str, Any]] = []
|
||||||
|
# Web UI: skip theme confirmation stages (CLI-only pauses)
|
||||||
|
# Land steps 1..8 (if present)
|
||||||
|
for i in range(1, 9):
|
||||||
|
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}"})
|
||||||
|
# Creatures split into theme sub-stages for web confirm
|
||||||
|
if getattr(b, 'primary_tag', None) and hasattr(b, 'add_creatures_primary_phase'):
|
||||||
|
stages.append({"key": "creatures_primary", "label": "Creatures: Primary", "runner_name": "add_creatures_primary_phase"})
|
||||||
|
if getattr(b, 'secondary_tag', None) and hasattr(b, 'add_creatures_secondary_phase'):
|
||||||
|
stages.append({"key": "creatures_secondary", "label": "Creatures: Secondary", "runner_name": "add_creatures_secondary_phase"})
|
||||||
|
if getattr(b, 'tertiary_tag', None) and hasattr(b, 'add_creatures_tertiary_phase'):
|
||||||
|
stages.append({"key": "creatures_tertiary", "label": "Creatures: Tertiary", "runner_name": "add_creatures_tertiary_phase"})
|
||||||
|
if hasattr(b, 'add_creatures_fill_phase'):
|
||||||
|
stages.append({"key": "creatures_fill", "label": "Creatures: Fill", "runner_name": "add_creatures_fill_phase"})
|
||||||
|
# Spells: prefer granular categories when available; otherwise fall back to bulk
|
||||||
|
spell_categories: List[Tuple[str, str, str]] = [
|
||||||
|
("ramp", "Confirm Ramp", "add_ramp"),
|
||||||
|
("removal", "Confirm Removal", "add_removal"),
|
||||||
|
("wipes", "Confirm Board Wipes", "add_board_wipes"),
|
||||||
|
("card_advantage", "Confirm Card Advantage", "add_card_advantage"),
|
||||||
|
("protection", "Confirm Protection", "add_protection"),
|
||||||
|
]
|
||||||
|
any_granular = any(callable(getattr(b, rn, None)) for _key, _label, rn in spell_categories)
|
||||||
|
if any_granular:
|
||||||
|
for key, label, runner in spell_categories:
|
||||||
|
if callable(getattr(b, runner, None)):
|
||||||
|
# Web UI: omit confirm stages; show only the action stage
|
||||||
|
label_action = label.replace("Confirm ", "")
|
||||||
|
stages.append({"key": f"spells_{key}", "label": label_action, "runner_name": runner})
|
||||||
|
# Ensure we include the theme filler step to top up to 100 cards
|
||||||
|
if callable(getattr(b, 'fill_remaining_theme_spells', None)):
|
||||||
|
stages.append({"key": "spells_fill", "label": "Theme Spell Fill", "runner_name": "fill_remaining_theme_spells"})
|
||||||
|
elif hasattr(b, 'add_spells_phase'):
|
||||||
|
stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"})
|
||||||
|
# Post-adjust
|
||||||
|
if hasattr(b, 'post_spell_land_adjust'):
|
||||||
|
stages.append({"key": "post_adjust", "label": "Post-Spell Land Adjust", "runner_name": "post_spell_land_adjust"})
|
||||||
|
# Reporting
|
||||||
|
if hasattr(b, 'run_reporting_phase'):
|
||||||
|
stages.append({"key": "reporting", "label": "Reporting", "runner_name": "run_reporting_phase"})
|
||||||
|
# Export is not a separate stage here; we will auto-export at the final continue.
|
||||||
|
return stages
|
||||||
|
|
||||||
|
|
||||||
|
def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]:
|
||||||
|
logs: List[str] = []
|
||||||
|
|
||||||
|
def out(msg: str) -> None:
|
||||||
|
logs.append(msg)
|
||||||
|
|
||||||
|
# Provide a no-op input function so staged web builds never block on input
|
||||||
|
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True)
|
||||||
|
# Ensure setup/tagging present before staged build
|
||||||
|
_ensure_setup_ready(out)
|
||||||
|
# Commander selection
|
||||||
|
df = b.load_commander_data()
|
||||||
|
row = df[df["name"].astype(str) == str(commander)]
|
||||||
|
if row.empty:
|
||||||
|
raise ValueError(f"Commander not found: {commander}")
|
||||||
|
b._apply_commander_selection(row.iloc[0])
|
||||||
|
# Tags
|
||||||
|
b.selected_tags = list(tags or [])
|
||||||
|
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
||||||
|
b.secondary_tag = b.selected_tags[1] if len(b.selected_tags) > 1 else None
|
||||||
|
b.tertiary_tag = b.selected_tags[2] if len(b.selected_tags) > 2 else None
|
||||||
|
try:
|
||||||
|
b._update_commander_dict_with_selected_tags()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Bracket
|
||||||
|
bd = next((x for x in BRACKET_DEFINITIONS if int(getattr(x, 'level', 0)) == int(bracket)), None)
|
||||||
|
if bd is None:
|
||||||
|
raise ValueError(f"Invalid bracket level: {bracket}")
|
||||||
|
b.bracket_definition = bd
|
||||||
|
b.bracket_level = bd.level
|
||||||
|
b.bracket_name = bd.name
|
||||||
|
b.bracket_limits = dict(getattr(bd, 'limits', {}))
|
||||||
|
# Ideals
|
||||||
|
b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()}
|
||||||
|
# Data load
|
||||||
|
b.determine_color_identity()
|
||||||
|
b.setup_dataframes()
|
||||||
|
# Stages
|
||||||
|
stages = _make_stages(b)
|
||||||
|
ctx = {
|
||||||
|
"builder": b,
|
||||||
|
"logs": logs,
|
||||||
|
"stages": stages,
|
||||||
|
"idx": 0,
|
||||||
|
"last_log_idx": 0,
|
||||||
|
"csv_path": None,
|
||||||
|
"txt_path": None,
|
||||||
|
"snapshot": None,
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_builder(b: DeckBuilder) -> Dict[str, Any]:
|
||||||
|
"""Capture mutable state needed to rerun a stage."""
|
||||||
|
snap: Dict[str, Any] = {}
|
||||||
|
# Core collections
|
||||||
|
snap["card_library"] = copy.deepcopy(getattr(b, 'card_library', {}))
|
||||||
|
snap["tag_counts"] = copy.deepcopy(getattr(b, 'tag_counts', {}))
|
||||||
|
snap["_card_name_tags_index"] = copy.deepcopy(getattr(b, '_card_name_tags_index', {}))
|
||||||
|
snap["suggested_lands_queue"] = copy.deepcopy(getattr(b, 'suggested_lands_queue', []))
|
||||||
|
# Caches and pools
|
||||||
|
try:
|
||||||
|
if getattr(b, '_combined_cards_df', None) is not None:
|
||||||
|
snap["_combined_cards_df"] = b._combined_cards_df.copy(deep=True)
|
||||||
|
except Exception:
|
||||||
|
snap["_combined_cards_df"] = None
|
||||||
|
try:
|
||||||
|
if getattr(b, '_full_cards_df', None) is not None:
|
||||||
|
snap["_full_cards_df"] = b._full_cards_df.copy(deep=True)
|
||||||
|
except Exception:
|
||||||
|
snap["_full_cards_df"] = None
|
||||||
|
snap["_color_source_matrix_baseline"] = copy.deepcopy(getattr(b, '_color_source_matrix_baseline', None))
|
||||||
|
snap["_color_source_matrix_cache"] = copy.deepcopy(getattr(b, '_color_source_matrix_cache', None))
|
||||||
|
snap["_color_source_cache_dirty"] = getattr(b, '_color_source_cache_dirty', True)
|
||||||
|
snap["_spell_pip_weights_cache"] = copy.deepcopy(getattr(b, '_spell_pip_weights_cache', None))
|
||||||
|
snap["_spell_pip_cache_dirty"] = getattr(b, '_spell_pip_cache_dirty', True)
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
|
||||||
|
b.card_library = copy.deepcopy(snap.get("card_library", {}))
|
||||||
|
b.tag_counts = copy.deepcopy(snap.get("tag_counts", {}))
|
||||||
|
b._card_name_tags_index = copy.deepcopy(snap.get("_card_name_tags_index", {}))
|
||||||
|
b.suggested_lands_queue = copy.deepcopy(snap.get("suggested_lands_queue", []))
|
||||||
|
if "_combined_cards_df" in snap:
|
||||||
|
b._combined_cards_df = snap["_combined_cards_df"]
|
||||||
|
if "_full_cards_df" in snap:
|
||||||
|
b._full_cards_df = snap["_full_cards_df"]
|
||||||
|
b._color_source_matrix_baseline = copy.deepcopy(snap.get("_color_source_matrix_baseline", None))
|
||||||
|
b._color_source_matrix_cache = copy.deepcopy(snap.get("_color_source_matrix_cache", None))
|
||||||
|
b._color_source_cache_dirty = bool(snap.get("_color_source_cache_dirty", True))
|
||||||
|
b._spell_pip_weights_cache = copy.deepcopy(snap.get("_spell_pip_weights_cache", None))
|
||||||
|
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
|
||||||
|
|
||||||
|
|
||||||
|
def run_stage(ctx: Dict[str, Any], rerun: bool = False) -> Dict[str, Any]:
|
||||||
|
b: DeckBuilder = ctx["builder"]
|
||||||
|
stages: List[Dict[str, Any]] = ctx["stages"]
|
||||||
|
logs: List[str] = ctx["logs"]
|
||||||
|
|
||||||
|
# If all stages done, finalize exports (interactive/manual build)
|
||||||
|
if ctx["idx"] >= len(stages):
|
||||||
|
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
||||||
|
try:
|
||||||
|
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"CSV export failed: {e}")
|
||||||
|
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
|
||||||
|
try:
|
||||||
|
import os as _os
|
||||||
|
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
|
||||||
|
ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
||||||
|
# Export the run configuration JSON for manual builds
|
||||||
|
try:
|
||||||
|
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"Text export failed: {e}")
|
||||||
|
# Build structured summary for UI
|
||||||
|
summary = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'build_deck_summary'):
|
||||||
|
summary = b.build_deck_summary() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
summary = None
|
||||||
|
# Write sidecar summary JSON next to CSV (if available)
|
||||||
|
try:
|
||||||
|
if summary and ctx.get("csv_path"):
|
||||||
|
import os as _os
|
||||||
|
import json as _json
|
||||||
|
csv_path = ctx.get("csv_path")
|
||||||
|
base, _ = _os.path.splitext(csv_path)
|
||||||
|
sidecar = base + '.summary.json'
|
||||||
|
meta = {
|
||||||
|
"commander": getattr(b, 'commander_name', '') or getattr(b, 'commander', ''),
|
||||||
|
"tags": list(getattr(b, 'selected_tags', []) or []) or [t for t in [getattr(b, 'primary_tag', None), getattr(b, 'secondary_tag', None), getattr(b, 'tertiary_tag', None)] if t],
|
||||||
|
"bracket_level": getattr(b, 'bracket_level', None),
|
||||||
|
"csv": ctx.get("csv_path"),
|
||||||
|
"txt": ctx.get("txt_path"),
|
||||||
|
}
|
||||||
|
payload = {"meta": meta, "summary": summary}
|
||||||
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"done": True,
|
||||||
|
"label": "Complete",
|
||||||
|
"log_delta": "",
|
||||||
|
"idx": len(stages),
|
||||||
|
"total": len(stages),
|
||||||
|
"csv_path": ctx.get("csv_path"),
|
||||||
|
"txt_path": ctx.get("txt_path"),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine which stage index to run (rerun last visible, else current)
|
||||||
|
if rerun:
|
||||||
|
i = max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1)
|
||||||
|
else:
|
||||||
|
i = ctx["idx"]
|
||||||
|
|
||||||
|
# Iterate forward until we find a stage that adds cards, skipping no-ops
|
||||||
|
while i < len(stages):
|
||||||
|
stage = stages[i]
|
||||||
|
label = stage["label"]
|
||||||
|
runner_name = stage["runner_name"]
|
||||||
|
|
||||||
|
# Take snapshot before executing; for rerun, restore first if we have one
|
||||||
|
if rerun and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
|
||||||
|
_restore_builder(b, ctx["snapshot"]) # restore to pre-stage state
|
||||||
|
snap_before = _snapshot_builder(b)
|
||||||
|
|
||||||
|
# Run the stage and capture logs delta
|
||||||
|
start_log = len(logs)
|
||||||
|
fn = getattr(b, runner_name, None)
|
||||||
|
if callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"Stage '{label}' failed: {e}")
|
||||||
|
else:
|
||||||
|
logs.append(f"Runner not available: {runner_name}")
|
||||||
|
delta_log = "\n".join(logs[start_log:])
|
||||||
|
|
||||||
|
# Compute added cards based on snapshot
|
||||||
|
try:
|
||||||
|
prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {}
|
||||||
|
added_cards: list[dict] = []
|
||||||
|
for name, entry in b.card_library.items():
|
||||||
|
try:
|
||||||
|
prev_entry = prev_lib.get(name)
|
||||||
|
prev_count = int(prev_entry.get('Count', 0)) if isinstance(prev_entry, dict) else 0
|
||||||
|
new_count = int(entry.get('Count', 1))
|
||||||
|
delta_count = max(0, new_count - prev_count)
|
||||||
|
if delta_count <= 0:
|
||||||
|
continue
|
||||||
|
role = str(entry.get('Role') or '').strip()
|
||||||
|
sub_role = str(entry.get('SubRole') or '').strip()
|
||||||
|
added_by = str(entry.get('AddedBy') or '').strip()
|
||||||
|
trig = str(entry.get('TriggerTag') or '').strip()
|
||||||
|
parts: list[str] = []
|
||||||
|
if role:
|
||||||
|
parts.append(role)
|
||||||
|
if sub_role:
|
||||||
|
parts.append(sub_role)
|
||||||
|
if added_by:
|
||||||
|
parts.append(f"by {added_by}")
|
||||||
|
if trig:
|
||||||
|
parts.append(f"tag: {trig}")
|
||||||
|
reason = " • ".join(parts)
|
||||||
|
added_cards.append({
|
||||||
|
"name": name,
|
||||||
|
"count": delta_count,
|
||||||
|
"reason": reason,
|
||||||
|
"role": role,
|
||||||
|
"sub_role": sub_role,
|
||||||
|
"trigger_tag": trig,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
added_cards.sort(key=lambda x: (x.get('reason') or '', x['name']))
|
||||||
|
except Exception:
|
||||||
|
added_cards = []
|
||||||
|
|
||||||
|
# If this stage added cards, present it and advance idx
|
||||||
|
if added_cards:
|
||||||
|
ctx["snapshot"] = snap_before # snapshot for rerun
|
||||||
|
ctx["idx"] = i + 1
|
||||||
|
ctx["last_visible_idx"] = i + 1
|
||||||
|
return {
|
||||||
|
"done": False,
|
||||||
|
"label": label,
|
||||||
|
"log_delta": delta_log,
|
||||||
|
"added_cards": added_cards,
|
||||||
|
"idx": i + 1,
|
||||||
|
"total": len(stages),
|
||||||
|
}
|
||||||
|
|
||||||
|
# No cards added: skip showing this stage and advance to next
|
||||||
|
i += 1
|
||||||
|
# Continue loop to auto-advance
|
||||||
|
|
||||||
|
# If we reached here, all remaining stages were no-ops; finalize exports
|
||||||
|
ctx["idx"] = len(stages)
|
||||||
|
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
||||||
|
try:
|
||||||
|
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"CSV export failed: {e}")
|
||||||
|
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
|
||||||
|
try:
|
||||||
|
import os as _os
|
||||||
|
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
|
||||||
|
ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
||||||
|
# Export the run configuration JSON for manual builds
|
||||||
|
try:
|
||||||
|
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logs.append(f"Text export failed: {e}")
|
||||||
|
# Build structured summary for UI
|
||||||
|
summary = None
|
||||||
|
try:
|
||||||
|
if hasattr(b, 'build_deck_summary'):
|
||||||
|
summary = b.build_deck_summary() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
summary = None
|
||||||
|
# Write sidecar summary JSON next to CSV (if available)
|
||||||
|
try:
|
||||||
|
if summary and ctx.get("csv_path"):
|
||||||
|
import os as _os
|
||||||
|
import json as _json
|
||||||
|
csv_path = ctx.get("csv_path")
|
||||||
|
base, _ = _os.path.splitext(csv_path)
|
||||||
|
sidecar = base + '.summary.json'
|
||||||
|
meta = {
|
||||||
|
"commander": getattr(b, 'commander_name', '') or getattr(b, 'commander', ''),
|
||||||
|
"tags": list(getattr(b, 'selected_tags', []) or []) or [t for t in [getattr(b, 'primary_tag', None), getattr(b, 'secondary_tag', None), getattr(b, 'tertiary_tag', None)] if t],
|
||||||
|
"bracket_level": getattr(b, 'bracket_level', None),
|
||||||
|
"csv": ctx.get("csv_path"),
|
||||||
|
"txt": ctx.get("txt_path"),
|
||||||
|
}
|
||||||
|
payload = {"meta": meta, "summary": summary}
|
||||||
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"done": True,
|
||||||
|
"label": "Complete",
|
||||||
|
"log_delta": "",
|
||||||
|
"idx": len(stages),
|
||||||
|
"total": len(stages),
|
||||||
|
"csv_path": ctx.get("csv_path"),
|
||||||
|
"txt_path": ctx.get("txt_path"),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
48
code/web/services/tasks.py
Normal file
48
code/web/services/tasks.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
# Extremely simple in-memory session/task store for MVP
|
||||||
|
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_TTL_SECONDS = 60 * 60 * 8 # 8 hours
|
||||||
|
|
||||||
|
|
||||||
|
def new_sid() -> str:
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def touch_session(sid: str) -> Dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
s = _SESSIONS.get(sid)
|
||||||
|
if not s:
|
||||||
|
s = {"created": now, "updated": now}
|
||||||
|
_SESSIONS[sid] = s
|
||||||
|
else:
|
||||||
|
s["updated"] = now
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(sid: Optional[str]) -> Dict[str, Any]:
|
||||||
|
if not sid:
|
||||||
|
sid = new_sid()
|
||||||
|
return touch_session(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_value(sid: str, key: str, value: Any) -> None:
|
||||||
|
touch_session(sid)[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
|
||||||
|
return touch_session(sid).get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> None:
|
||||||
|
now = time.time()
|
||||||
|
expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS]
|
||||||
|
for sid in expired:
|
||||||
|
try:
|
||||||
|
del _SESSIONS[sid]
|
||||||
|
except Exception:
|
||||||
|
pass
|
119
code/web/static/styles.css
Normal file
119
code/web/static/styles.css
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
/* Base */
|
||||||
|
:root{
|
||||||
|
/* MTG color palette (approx from provided values) */
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
--green-main: rgb(0,115,62);
|
||||||
|
--green-light: rgb(196,211,202);
|
||||||
|
--blue-main: rgb(14,104,171);
|
||||||
|
--blue-light: rgb(179,206,234);
|
||||||
|
--red-main: rgb(211,32,42);
|
||||||
|
--red-light: rgb(235,159,130);
|
||||||
|
--white-main: rgb(249,250,244);
|
||||||
|
--white-light: rgb(248,231,185);
|
||||||
|
--black-main: rgb(21,11,0);
|
||||||
|
--black-light: rgb(166,159,157);
|
||||||
|
--bg: #0f0f10;
|
||||||
|
--panel: #1a1b1e;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #b6b8bd;
|
||||||
|
--border: #2a2b2f;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{height:100%}
|
||||||
|
body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); }
|
||||||
|
/* Top banner */
|
||||||
|
.top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); }
|
||||||
|
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
|
||||||
|
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||||
|
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.banner-status.busy{ color:#fbbf24; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout{ display:grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: calc(100vh - 52px); }
|
||||||
|
.sidebar{ background: var(--panel); border-right: 1px solid var(--border); padding: 1rem; position:sticky; top:0; align-self:start; height:100vh; overflow:auto; width: var(--sidebar-w); }
|
||||||
|
.content{ padding: 1.25rem 1.5rem; }
|
||||||
|
|
||||||
|
.brand h1{ display:none; }
|
||||||
|
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
|
||||||
|
.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
|
||||||
|
.dot.green{ background: var(--green-main); }
|
||||||
|
.dot.blue{ background: var(--blue-main); }
|
||||||
|
.dot.red{ background: var(--red-main); }
|
||||||
|
.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
|
||||||
|
.dot.black{ background: var(--black-light); }
|
||||||
|
|
||||||
|
.nav{ display:flex; flex-direction:column; gap:.35rem; }
|
||||||
|
.nav a{ color: var(--text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
||||||
|
.nav a:hover{ background: #202227; border-color: var(--border); }
|
||||||
|
|
||||||
|
/* Simple two-column layout for inspect panel */
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
||||||
|
.two-col .grow { min-width: 0; }
|
||||||
|
.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: #111; }
|
||||||
|
@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Left-rail variant puts the image first */
|
||||||
|
.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
|
||||||
|
.card-preview.card-sm{ max-width:200px; }
|
||||||
|
|
||||||
|
/* Buttons, inputs */
|
||||||
|
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
|
||||||
|
button:hover{ filter:brightness(1.05); }
|
||||||
|
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
|
||||||
|
select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
|
||||||
|
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
|
||||||
|
small, .muted{ color: var(--muted); }
|
||||||
|
|
||||||
|
/* Banner */
|
||||||
|
.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
|
||||||
|
.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
|
||||||
|
.banner .subtitle{ color: var(--muted); font-size:.95rem; }
|
||||||
|
|
||||||
|
/* Home actions */
|
||||||
|
.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
|
||||||
|
.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background:#0f1115; padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
|
||||||
|
.action-button:hover{ border-color:#3a3c42; background:#12151b; }
|
||||||
|
.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
|
||||||
|
|
||||||
|
/* Card grid for added cards (responsive, compact tiles) */
|
||||||
|
.card-grid{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top:.5rem;
|
||||||
|
justify-content: start; /* pack as many as possible per row */
|
||||||
|
}
|
||||||
|
.card-tile{
|
||||||
|
width:170px;
|
||||||
|
background:#0f1115;
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:6px;
|
||||||
|
padding:.25rem .25rem .4rem;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
|
||||||
|
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
|
||||||
|
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
|
||||||
|
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
||||||
|
|
||||||
|
/* Step 1 candidate grid (200px-wide scaled images) */
|
||||||
|
.candidate-grid{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap:.75rem;
|
||||||
|
}
|
||||||
|
.candidate-tile{
|
||||||
|
background:#0f1115;
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:8px;
|
||||||
|
padding:.4rem;
|
||||||
|
}
|
||||||
|
.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
|
||||||
|
.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background:#111; display:block; margin:0 auto; }
|
||||||
|
.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
|
||||||
|
.candidate-tile .name{ font-weight:600; font-size:.95rem; }
|
||||||
|
.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
|
||||||
|
|
||||||
|
/* Deck summary: highlight game changers */
|
||||||
|
.game-changer { color: var(--green-main); }
|
||||||
|
.stack-card.game-changer { outline: 2px solid var(--green-main); }
|
3
code/web/static/vendor/htmx-1.9.12.min.js
vendored
Normal file
3
code/web/static/vendor/htmx-1.9.12.min.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/* Local fallback for HTMX 1.9.12. If the CDN fails, base.html will load this file. */
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t():("function"==typeof define&&define.amd?define(t):t())}(0,function(){/* placeholder minimal shim to avoid runtime errors if CDN blocked; swaps won't work until user refreshes with network */
|
||||||
|
window.htmx=window.htmx||{version:"1.9.12",onLoad:function(){},find:function(){return null},trigger:function(){},config:{},logAll:function(){}};});
|
152
code/web/templates/base.html
Normal file
152
code/web/templates/base.html
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MTG Deckbuilder</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top-banner">
|
||||||
|
<div class="top-inner">
|
||||||
|
<h1>MTG Deckbuilder</h1>
|
||||||
|
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="mana-dots" aria-hidden="true">
|
||||||
|
<span class="dot green"></span>
|
||||||
|
<span class="dot blue"></span>
|
||||||
|
<span class="dot red"></span>
|
||||||
|
<span class="dot white"></span>
|
||||||
|
<span class="dot black"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/build">Build</a>
|
||||||
|
<a href="/configs">Build from JSON</a>
|
||||||
|
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||||
|
<a href="/decks">Finished Decks</a>
|
||||||
|
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main class="content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||||
|
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||||
|
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background:#0f1115; }
|
||||||
|
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||||
|
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||||
|
.card-meta .line + .line { margin-top:.35rem; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Setup/Tagging status poller
|
||||||
|
var statusEl;
|
||||||
|
function ensureStatusEl(){
|
||||||
|
if (!statusEl) statusEl = document.getElementById('banner-status');
|
||||||
|
return statusEl;
|
||||||
|
}
|
||||||
|
function renderSetupStatus(data){
|
||||||
|
var el = ensureStatusEl(); if (!el) return;
|
||||||
|
if (data && data.running) {
|
||||||
|
var msg = (data.message || 'Preparing data...');
|
||||||
|
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||||
|
el.classList.add('busy');
|
||||||
|
} else if (data && data.phase === 'done') {
|
||||||
|
el.innerHTML = '<span class="muted">Setup complete.</span>';
|
||||||
|
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
|
||||||
|
} else if (data && data.phase === 'error') {
|
||||||
|
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||||
|
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||||
|
} else {
|
||||||
|
if (!el.innerHTML.trim()) el.innerHTML = '';
|
||||||
|
el.classList.remove('busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pollStatus(){
|
||||||
|
try {
|
||||||
|
fetch('/status/setup', { cache: 'no-store' })
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(renderSetupStatus)
|
||||||
|
.catch(function(){ /* noop */ });
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
setInterval(pollStatus, 3000);
|
||||||
|
pollStatus();
|
||||||
|
|
||||||
|
function ensureCard() {
|
||||||
|
var pop = document.getElementById('card-hover');
|
||||||
|
if (!pop) {
|
||||||
|
pop = document.createElement('div');
|
||||||
|
pop.id = 'card-hover';
|
||||||
|
pop.className = 'card-hover';
|
||||||
|
var inner = document.createElement('div');
|
||||||
|
inner.className = 'card-hover-inner';
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.alt = 'Card preview';
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'card-meta';
|
||||||
|
inner.appendChild(img);
|
||||||
|
inner.appendChild(meta);
|
||||||
|
pop.appendChild(inner);
|
||||||
|
document.body.appendChild(pop);
|
||||||
|
}
|
||||||
|
return pop;
|
||||||
|
}
|
||||||
|
var cardPop = ensureCard();
|
||||||
|
function positionCard(e) {
|
||||||
|
var x = e.clientX + 16, y = e.clientY + 16;
|
||||||
|
cardPop.style.display = 'block';
|
||||||
|
cardPop.style.left = x + 'px';
|
||||||
|
cardPop.style.top = y + 'px';
|
||||||
|
var rect = cardPop.getBoundingClientRect();
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
|
||||||
|
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
|
||||||
|
}
|
||||||
|
function attachCardHover() {
|
||||||
|
document.querySelectorAll('[data-card-name]').forEach(function(el) {
|
||||||
|
if (el.__cardHoverBound) return; // avoid duplicate bindings
|
||||||
|
el.__cardHoverBound = true;
|
||||||
|
el.addEventListener('mouseenter', function(e) {
|
||||||
|
var img = cardPop.querySelector('img');
|
||||||
|
var meta = cardPop.querySelector('.card-meta');
|
||||||
|
var q = encodeURIComponent(el.getAttribute('data-card-name'));
|
||||||
|
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=normal';
|
||||||
|
var role = el.getAttribute('data-role') || '';
|
||||||
|
var tags = el.getAttribute('data-tags') || '';
|
||||||
|
if (role || tags) {
|
||||||
|
var html = '';
|
||||||
|
if (role) {
|
||||||
|
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
||||||
|
}
|
||||||
|
if (tags) {
|
||||||
|
html += '<div class="line"><span class="label">Themes</span>' + tags.replace(/</g,'<') + '</div>';
|
||||||
|
}
|
||||||
|
meta.innerHTML = html;
|
||||||
|
meta.style.display = '';
|
||||||
|
} else {
|
||||||
|
meta.style.display = 'none';
|
||||||
|
meta.innerHTML = '';
|
||||||
|
}
|
||||||
|
positionCard(e);
|
||||||
|
});
|
||||||
|
el.addEventListener('mousemove', positionCard);
|
||||||
|
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attachCardHover();
|
||||||
|
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1
code/web/templates/build/_banner_subtitle.html
Normal file
1
code/web/templates/build/_banner_subtitle.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<div id="banner-status" hx-swap-oob="true">{% if step %}<span class="muted">{{ step }}{% if i is not none and n is not none %} ({{ i }}/{{ n }}){% endif %}</span>: {% endif %}{% if commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} - {{ tags|join(', ') }}{% endif %}</div>
|
214
code/web/templates/build/_step1.html
Normal file
214
code/web/templates/build/_step1.html
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
<section>
|
||||||
|
<h3>Step 1: Choose a Commander</h3>
|
||||||
|
|
||||||
|
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<label>Search by name</label>
|
||||||
|
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" />
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
<label style="margin-left:.5rem; font-weight:normal;">
|
||||||
|
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<div class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
|
||||||
|
Tip: Press Enter to select the highlighted result, or use Up/Down to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if candidates %}
|
||||||
|
<h4>Top matches</h4>
|
||||||
|
<div class="candidate-grid" id="candidate-grid">
|
||||||
|
{% for name, score, colors in candidates %}
|
||||||
|
<div class="candidate-tile" data-card-name="{{ name }}">
|
||||||
|
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="name" value="{{ name }}" />
|
||||||
|
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
|
||||||
|
alt="{{ name }}" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name"><span class="name-text">{{ name }}</span></div>
|
||||||
|
<div class="score">
|
||||||
|
match {{ score }}%
|
||||||
|
{% if colors %}
|
||||||
|
<span class="colors" style="margin-left:.25rem;">
|
||||||
|
{% for c in colors %}
|
||||||
|
<span class="chip chip-{{ c|lower }}" title="{{ c }}">{{ c }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<form hx-post="/build/step1/inspect" hx-target="#wizard" hx-swap="innerHTML" style="margin-top:.25rem;">
|
||||||
|
<input type="hidden" name="name" value="{{ name }}" />
|
||||||
|
<button type="submit">Inspect</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if inspect and inspect.ok %}
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" />
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
<h4>Theme Tags</h4>
|
||||||
|
{% if tags and tags|length > 0 %}
|
||||||
|
<ul>
|
||||||
|
{% for t in tags %}
|
||||||
|
<li>{{ t }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No theme tags found for this commander.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:.75rem;">
|
||||||
|
<form style="display:inline" hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="name" value="{{ selected }}" />
|
||||||
|
<button>Use this commander</button>
|
||||||
|
</form>
|
||||||
|
<form style="display:inline" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="query" value="" />
|
||||||
|
<button>Back to search</button>
|
||||||
|
</form>
|
||||||
|
<form action="/build" method="get" style="display:inline; margin-left:.5rem;">
|
||||||
|
<button type="submit">Start over</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div hx-get="/build/banner?step=Choose%20Commander&i=1&n=5" hx-trigger="load"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif inspect and not inspect.ok %}
|
||||||
|
<div style="color:#a00">{{ inspect.error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div style="color:#a00">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var input = document.getElementById('cmdr-search');
|
||||||
|
var form = document.getElementById('cmdr-search-form');
|
||||||
|
var grid = document.getElementById('candidate-grid');
|
||||||
|
if (!input || !form) return;
|
||||||
|
// Debounce live search
|
||||||
|
var t = null;
|
||||||
|
function submit(){
|
||||||
|
if (!form) return;
|
||||||
|
// Trigger the HTMX post without clicking submit
|
||||||
|
if (window.htmx) { window.htmx.trigger(form, 'submit'); }
|
||||||
|
else { form.submit(); }
|
||||||
|
}
|
||||||
|
input.addEventListener('input', function(){
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
t = setTimeout(submit, 250);
|
||||||
|
});
|
||||||
|
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if (!grid || !grid.children || grid.children.length === 0) return;
|
||||||
|
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
|
||||||
|
// Ensure something is selected by default
|
||||||
|
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
|
||||||
|
if (idx < 0 && tiles.length > 0) {
|
||||||
|
tiles[0].classList.add('active');
|
||||||
|
idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine columns via first row's offsetTop count
|
||||||
|
var cols = 1;
|
||||||
|
if (tiles.length > 1) {
|
||||||
|
var firstTop = tiles[0].offsetTop;
|
||||||
|
cols = tiles.findIndex(function(el, i){ return i>0 && el.offsetTop !== firstTop; });
|
||||||
|
if (cols === -1 || cols === 0) cols = tiles.length; // single row fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(newIdx) {
|
||||||
|
// Clamp to bounds; wrapping handled by callers
|
||||||
|
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
|
||||||
|
tiles.forEach(function(el){ el.classList.remove('active'); });
|
||||||
|
tiles[newIdx].classList.add('active');
|
||||||
|
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
return newIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
var total = tiles.length;
|
||||||
|
var rows = Math.ceil(total / cols);
|
||||||
|
var row = Math.floor(idx / cols);
|
||||||
|
var col = idx % cols;
|
||||||
|
var newRow = row + 1;
|
||||||
|
if (newRow >= rows) newRow = 0; // wrap to first row
|
||||||
|
var newIdx = newRow * cols + col;
|
||||||
|
if (newIdx >= total) newIdx = total - 1; // clamp to last tile if last row shorter
|
||||||
|
idx = setActive(newIdx);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
var totalU = tiles.length;
|
||||||
|
var rowsU = Math.ceil(totalU / cols);
|
||||||
|
var rowU = Math.floor(idx / cols);
|
||||||
|
var colU = idx % cols;
|
||||||
|
var newRowU = rowU - 1;
|
||||||
|
if (newRowU < 0) newRowU = rowsU - 1; // wrap to last row
|
||||||
|
var newIdxU = newRowU * cols + colU;
|
||||||
|
if (newIdxU >= totalU) newIdxU = totalU - 1;
|
||||||
|
idx = setActive(newIdxU);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
var totalR = tiles.length;
|
||||||
|
idx = setActive((idx + 1) % totalR);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
var totalL = tiles.length;
|
||||||
|
idx = setActive((idx - 1 + totalL) % totalL);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
var active = tiles[idx];
|
||||||
|
if (!active) return;
|
||||||
|
var formSel = active.querySelector('form[hx-post="/build/step1/confirm"]');
|
||||||
|
if (formSel) {
|
||||||
|
if (window.htmx) { window.htmx.trigger(formSel, 'submit'); }
|
||||||
|
else if (formSel.submit) { formSel.submit(); }
|
||||||
|
else {
|
||||||
|
var btn = active.querySelector('button');
|
||||||
|
if (btn) btn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Highlight matched text
|
||||||
|
try {
|
||||||
|
var q = (input.value || '').trim().toLowerCase();
|
||||||
|
if (q && grid) {
|
||||||
|
grid.querySelectorAll('.name-text').forEach(function(el){
|
||||||
|
var txt = el.textContent || '';
|
||||||
|
var low = txt.toLowerCase();
|
||||||
|
var i = low.indexOf(q);
|
||||||
|
if (i >= 0) {
|
||||||
|
el.innerHTML = txt.substring(0, i) + '<mark>' + txt.substring(i, i+q.length) + '</mark>' + txt.substring(i+q.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.candidate-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:.75rem; }
|
||||||
|
.candidate-tile { border:1px solid var(--border); border-radius:8px; background:#0f1115; padding:.5rem; }
|
||||||
|
.candidate-tile.active { outline:2px solid #3b82f6; }
|
||||||
|
.img-btn { display:block; width:100%; background:transparent; border:0; padding:0; cursor:pointer; }
|
||||||
|
.img-btn img { width:100%; height:auto; border-radius:6px; }
|
||||||
|
.chip { display:inline-block; padding:0 .35rem; border-radius:999px; font-size:.75rem; line-height:1.4; border:1px solid var(--border); background:#151821; margin-left:.15rem; }
|
||||||
|
.chip-w { background:#fdf4d6; color:#6b4f00; border-color:#e9d8a6; }
|
||||||
|
.chip-u { background:#dbeafe; color:#1e40af; border-color:#93c5fd; }
|
||||||
|
.chip-b { background:#e5e7eb; color:#111827; border-color:#9ca3af; }
|
||||||
|
.chip-g { background:#dcfce7; color:#065f46; border-color:#86efac; }
|
||||||
|
.chip-r { background:#fee2e2; color:#991b1b; border-color:#fecaca; }
|
||||||
|
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
|
||||||
|
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||||
|
.candidate-tile { cursor: pointer; }
|
||||||
|
</style>
|
77
code/web/templates/build/_step2.html
Normal file
77
code/web/templates/build/_step2.html
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<section>
|
||||||
|
<h3>Step 2: Tags & Bracket</h3>
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" />
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
|
||||||
|
|
||||||
|
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||||
|
{% if error %}
|
||||||
|
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Theme Tags</legend>
|
||||||
|
{% if tags %}
|
||||||
|
<label>Primary
|
||||||
|
<select name="primary_tag">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
{% for t in tags %}
|
||||||
|
<option value="{{ t }}" {% if t == primary_tag %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Secondary
|
||||||
|
<select name="secondary_tag">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
{% for t in tags %}
|
||||||
|
<option value="{{ t }}" {% if t == secondary_tag %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tertiary
|
||||||
|
<select name="tertiary_tag">
|
||||||
|
<option value="">-- none --</option>
|
||||||
|
{% for t in tags %}
|
||||||
|
<option value="{{ t }}" {% if t == tertiary_tag %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p>No theme tags available for this commander.</p>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Budget/Power Bracket</legend>
|
||||||
|
<div style="display:grid; gap:.5rem;">
|
||||||
|
{% for b in brackets %}
|
||||||
|
<label style="display:flex; gap:.5rem; align-items:flex-start;">
|
||||||
|
<input type="radio" name="bracket" value="{{ b.level }}" {% if (selected_bracket is defined and selected_bracket == b.level) or (selected_bracket is not defined and loop.first) %}checked{% endif %} />
|
||||||
|
<span><strong>{{ b.name }}</strong> — <small>{{ b.desc }}</small></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.35rem; font-size:.9em;">
|
||||||
|
Note: This guides deck creation and relaxes/raises constraints, but it is not a guarantee the final deck strictly fits that bracket.
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<button type="submit">Continue to Ideals</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top:.5rem;">
|
||||||
|
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Start over</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
44
code/web/templates/build/_step3.html
Normal file
44
code/web/templates/build/_step3.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<section>
|
||||||
|
<h3>Step 3: Ideal Counts</h3>
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Card Type Targets</legend>
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem;">
|
||||||
|
{% for key, label in labels.items() %}
|
||||||
|
<label>
|
||||||
|
{{ label }}
|
||||||
|
<input type="number" name="{{ key }}" min="0" value="{{ (values or defaults)[key] }}" />
|
||||||
|
<small class="muted">Default: {{ defaults[key] }}</small>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||||
|
<button type="submit">Continue to Review</button>
|
||||||
|
<button type="button" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:.5rem;">
|
||||||
|
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Start over</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
28
code/web/templates/build/_step4.html
Normal file
28
code/web/templates/build/_step4.html
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<section>
|
||||||
|
<h3>Step 4: Review</h3>
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
|
||||||
|
<h4>Chosen Ideals</h4>
|
||||||
|
<ul>
|
||||||
|
{% for key, label in labels.items() %}
|
||||||
|
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||||
|
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Build Deck</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
|
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Start over</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
111
code/web/templates/build/_step5.html
Normal file
111
code/web/templates/build/_step5.html
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
<section>
|
||||||
|
<h3>Step 5: Build</h3>
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview">
|
||||||
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
|
||||||
|
</a>
|
||||||
|
{% if status and status.startswith('Build complete') %}
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
|
{% if csv_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||||
|
<button type="submit">Download CSV</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if txt_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||||
|
<button type="submit">Download TXT</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
|
||||||
|
|
||||||
|
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||||
|
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||||
|
<p>Bracket: {{ bracket }}</p>
|
||||||
|
|
||||||
|
{% if i and n %}
|
||||||
|
<div class="muted" style="margin:.25rem 0 .5rem 0;">Stage {{ i }}/{{ n }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status %}
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Controls moved back above the cards as requested -->
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||||
|
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem;">
|
||||||
|
<button type="submit">Start Build</button>
|
||||||
|
</form>
|
||||||
|
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||||
|
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
|
||||||
|
</form>
|
||||||
|
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||||
|
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if added_cards %}
|
||||||
|
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||||
|
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||||
|
{% set groups = added_cards|groupby('sub_role') %}
|
||||||
|
{% for g in groups %}
|
||||||
|
{% set role = g.grouper %}
|
||||||
|
{% if role %}
|
||||||
|
{% set heading = 'Theme: ' + role.title() %}
|
||||||
|
{% else %}
|
||||||
|
{% set heading = 'Additional Picks' %}
|
||||||
|
{% endif %}
|
||||||
|
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for c in g.list %}
|
||||||
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||||
|
</a>
|
||||||
|
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
|
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for c in added_cards %}
|
||||||
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||||
|
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||||
|
</a>
|
||||||
|
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
|
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_logs and log %}
|
||||||
|
<details style="margin-top:1rem;">
|
||||||
|
<summary>Show logs</summary>
|
||||||
|
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- controls now above -->
|
||||||
|
|
||||||
|
{% if status and status.startswith('Build complete') %}
|
||||||
|
{% if summary %}
|
||||||
|
{% include "partials/deck_summary.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
10
code/web/templates/build/index.html
Normal file
10
code/web/templates/build/index.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Build a Deck</h2>
|
||||||
|
<div id="wizard">
|
||||||
|
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||||
|
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
|
||||||
|
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
93
code/web/templates/configs/index.html
Normal file
93
code/web/templates/configs/index.html
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Build from JSON</h2>
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;">
|
||||||
|
<p class="muted" style="max-width: 70ch; margin:0;">
|
||||||
|
Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
|
||||||
|
</div>
|
||||||
|
<pre style="margin-top:.35rem; background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
{% if notice %}<div class="notice">{{ notice }}</div>{% endif %}
|
||||||
|
<div class="config-actions" style="margin-bottom:1rem; display:flex; gap:12px; align-items:center;">
|
||||||
|
<form hx-post="/configs/upload" hx-target="#config-list" hx-swap="outerHTML" enctype="multipart/form-data">
|
||||||
|
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload JSON</button>
|
||||||
|
<input id="upload-json" type="file" name="file" accept="application/json" style="display:none" onchange="this.form.requestSubmit();">
|
||||||
|
</form>
|
||||||
|
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:#0f1115; color:#e5e7eb;" />
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Drag and drop upload support
|
||||||
|
var form = document.querySelector('.config-actions form[enctype="multipart/form-data"]');
|
||||||
|
var fileInput = document.getElementById('upload-json');
|
||||||
|
if (form && fileInput) {
|
||||||
|
form.addEventListener('dragover', function(e){ e.preventDefault(); form.style.outline = '2px dashed #334155'; });
|
||||||
|
form.addEventListener('dragleave', function(){ form.style.outline = ''; });
|
||||||
|
form.addEventListener('drop', function(e){
|
||||||
|
e.preventDefault(); form.style.outline = '';
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
||||||
|
fileInput.files = e.dataTransfer.files;
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Client-side filter of config list
|
||||||
|
var filter = document.getElementById('config-filter');
|
||||||
|
var list = document.getElementById('config-list');
|
||||||
|
function applyFilter() {
|
||||||
|
if (!list) return;
|
||||||
|
var q = (filter.value || '').toLowerCase().trim();
|
||||||
|
var items = list.querySelectorAll('li');
|
||||||
|
items.forEach(function(li){
|
||||||
|
var txt = (li.textContent || '').toLowerCase();
|
||||||
|
li.style.display = (!q || txt.indexOf(q) !== -1) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filter) {
|
||||||
|
filter.addEventListener('input', applyFilter);
|
||||||
|
}
|
||||||
|
document.addEventListener('htmx:afterSwap', function(e){ if (e && e.target && e.target.id === 'config-list') applyFilter(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<div id="config-list">
|
||||||
|
{% if not items %}
|
||||||
|
<p>No configs found in /config. Export a run config from a build, or upload one here.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="file-list" style="list-style: none; margin: 0; padding: 0;">
|
||||||
|
{% for it in items %}
|
||||||
|
<li>
|
||||||
|
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<strong>
|
||||||
|
{% if it.commander %}
|
||||||
|
<span data-card-name="{{ it.commander }}" data-tags="{{ (it.tags|join(', ')) if it.tags else '' }}">{{ it.commander }}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ it.name }}
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
{% if it.tags %}<span style="color:#64748b;">[{{ ', '.join(it.tags) }}]</span>{% endif %}
|
||||||
|
{% if it.bracket_level is not none %}<span class="badge">Bracket {{ it.bracket_level }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="display:flex; gap:8px;">
|
||||||
|
<form method="get" action="/configs/view" style="display:inline;">
|
||||||
|
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||||
|
<button type="submit" class="btn">View</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/configs/run" style="display:inline;">
|
||||||
|
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||||
|
<button type="submit">Run</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
56
code/web/templates/configs/run_result.html
Normal file
56
code/web/templates/configs/run_result.html
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Build from JSON: {{ cfg_name }}</h2>
|
||||||
|
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
|
||||||
|
{% if commander %}
|
||||||
|
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="two-col two-col-left-rail">
|
||||||
|
<aside class="card-preview">
|
||||||
|
{% if commander %}
|
||||||
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" />
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
|
{% if ok and csv_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||||
|
<button type="submit">Download CSV</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if ok and txt_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||||
|
<button type="submit">Download TXT</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" action="/configs" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Back to Build from JSON</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="grow">
|
||||||
|
{% if not ok %}
|
||||||
|
<div class="error">Build failed: {{ error }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if summary %}
|
||||||
|
{% include "partials/deck_summary.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_logs %}
|
||||||
|
<details style="margin-top:1rem;">
|
||||||
|
<summary>Show logs</summary>
|
||||||
|
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
24
code/web/templates/configs/run_summary.html
Normal file
24
code/web/templates/configs/run_summary.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Run from Config: {{ cfg_name }}</h2>
|
||||||
|
{% if not ok %}
|
||||||
|
<div class="error">Build failed: {{ error }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="notice">Build completed.</div>
|
||||||
|
<div style="margin:.5rem 0;">
|
||||||
|
{% if csv_path %}<a class="btn" href="/files?path={{ csv_path | urlencode }}">Download CSV</a>{% endif %}
|
||||||
|
{% if txt_path %}<a class="btn" href="/files?path={{ txt_path | urlencode }}">Download TXT</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if summary %}
|
||||||
|
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||||
|
<h4>Deck Summary</h4>
|
||||||
|
{# Reuse the same inner sections as the build summary page by including its markup #}
|
||||||
|
{% set __summary = summary %}
|
||||||
|
{% set summary = __summary %}
|
||||||
|
{% include "build/_step5.html" ignore missing %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No summary data available.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<p style="margin-top:1rem;"><a href="/configs">Back to Configs</a></p>
|
||||||
|
{% endblock %}
|
22
code/web/templates/configs/view.html
Normal file
22
code/web/templates/configs/view.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Build from JSON: {{ name }}</h2>
|
||||||
|
<p class="muted" style="max-width: 70ch;">Review the configuration details below, then run a non-interactive build to produce deck exports and a summary.</p>
|
||||||
|
<details open>
|
||||||
|
<summary>Overview</summary>
|
||||||
|
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
|
||||||
|
<div>Commander</div><div>{{ data.commander }}</div>
|
||||||
|
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
|
||||||
|
<div>Bracket</div><div>{{ data.bracket_level }}</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details style="margin-top:1rem;" open>
|
||||||
|
<summary>Ideal Counts</summary>
|
||||||
|
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
|
||||||
|
</details>
|
||||||
|
<form method="post" action="/configs/run" style="margin-top:1rem;">
|
||||||
|
<input type="hidden" name="name" value="{{ name }}" />
|
||||||
|
<button type="submit">Run Headless</button>
|
||||||
|
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
55
code/web/templates/decks/index.html
Normal file
55
code/web/templates/decks/index.html
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Finished Decks</h2>
|
||||||
|
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||||
|
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div id="deck-list" style="list-style:none; padding:0; margin:0; display:block;">
|
||||||
|
{% for it in items %}
|
||||||
|
<div class="panel" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" style="margin:0 0 .5rem 0;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||||
|
</div>
|
||||||
|
{% if it.tags and it.tags|length %}
|
||||||
|
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:.35rem;">
|
||||||
|
<form action="/decks/view" method="get" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||||
|
<button type="submit">Open</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No exports yet. Run a build to create one.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var input = document.getElementById('deck-filter');
|
||||||
|
if (!input) return;
|
||||||
|
input.addEventListener('input', function(){
|
||||||
|
var q = (input.value || '').toLowerCase();
|
||||||
|
document.querySelectorAll('#deck-list .panel').forEach(function(row){
|
||||||
|
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||||
|
row.style.display = hay.indexOf(q) >= 0 ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
40
code/web/templates/decks/view.html
Normal file
40
code/web/templates/decks/view.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Finished Deck</h2>
|
||||||
|
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
||||||
|
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
|
||||||
|
<div>
|
||||||
|
{% if commander %}
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
|
||||||
|
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
|
{% if csv_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||||
|
<button type="submit">Download CSV</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if txt_path %}
|
||||||
|
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||||
|
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||||
|
<button type="submit">Download TXT</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||||
|
<button type="submit">Back to Finished Decks</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if summary %}
|
||||||
|
{% include "partials/deck_summary.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No summary available.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
12
code/web/templates/home.html
Normal file
12
code/web/templates/home.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<a class="action-button primary" href="/build">Build a Deck</a>
|
||||||
|
<a class="action-button" href="/configs">Run a JSON Config</a>
|
||||||
|
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||||
|
<a class="action-button" href="/decks">Finished Decks</a>
|
||||||
|
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
396
code/web/templates/partials/deck_summary.html
Normal file
396
code/web/templates/partials/deck_summary.html
Normal file
|
@ -0,0 +1,396 @@
|
||||||
|
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||||
|
<h4>Deck Summary</h4>
|
||||||
|
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0;">
|
||||||
|
Legend: <span class="game-changer" style="font-weight:600;">Game Changer</span>
|
||||||
|
<span class="muted" style="opacity:.8;">(green highlight)</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||||
|
<section style="margin-top:.5rem;">
|
||||||
|
<h5>Card Types</h5>
|
||||||
|
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||||
|
<span class="muted">View:</span>
|
||||||
|
<div class="seg" role="tablist" aria-label="Type view">
|
||||||
|
<button type="button" class="seg-btn" data-view="list" aria-selected="true">List</button>
|
||||||
|
<button type="button" class="seg-btn" data-view="thumbs">Thumbnails</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set tb = summary.type_breakdown %}
|
||||||
|
{% if tb and tb.counts %}
|
||||||
|
<style>
|
||||||
|
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||||
|
.seg-btn { background:#12161c; color:#e5e7eb; border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
|
||||||
|
.seg-btn[aria-selected="true"] { background:#1f2937; }
|
||||||
|
.typeview { margin-top:.25rem; }
|
||||||
|
.typeview.hidden { display:none; }
|
||||||
|
.stack-wrap { --card-w: 160px; --card-h: 224px; --cols: 9; --overlap: .5; overflow: visible; padding: 6px 0 calc(var(--card-h) * (1 - var(--overlap))) 0; }
|
||||||
|
.stack-grid { display: grid; grid-template-columns: repeat(var(--cols), var(--card-w)); grid-auto-rows: calc(var(--card-h) * var(--overlap)); column-gap: 10px; }
|
||||||
|
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
|
||||||
|
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
|
||||||
|
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||||
|
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||||
|
</style>
|
||||||
|
<div id="typeview-list" class="typeview">
|
||||||
|
{% for t in tb.order %}
|
||||||
|
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||||
|
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||||
|
</div>
|
||||||
|
{% set clist = tb.cards.get(t, []) %}
|
||||||
|
{% if clist %}
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0;">
|
||||||
|
{% for c in clist %}
|
||||||
|
<div class="{% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||||
|
{% set cnt = c.count if c.count else 1 %}
|
||||||
|
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||||
|
{{ cnt }}x {{ c.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div id="typeview-thumbs" class="typeview hidden">
|
||||||
|
{% for t in tb.order %}
|
||||||
|
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||||
|
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||||
|
</div>
|
||||||
|
{% set clist = tb.cards.get(t, []) %}
|
||||||
|
{% if clist %}
|
||||||
|
<div class="stack-wrap">
|
||||||
|
<div class="stack-grid">
|
||||||
|
{% for c in clist %}
|
||||||
|
{% set cnt = c.count if c.count else 1 %}
|
||||||
|
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" />
|
||||||
|
<div class="count-badge">{{ cnt }}x</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No type data available.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
|
||||||
|
var thumbsBtn = document.querySelector('.seg-btn[data-view="thumbs"]');
|
||||||
|
var listView = document.getElementById('typeview-list');
|
||||||
|
var thumbsView = document.getElementById('typeview-thumbs');
|
||||||
|
|
||||||
|
function recalcThumbCols() {
|
||||||
|
if (thumbsView.classList.contains('hidden')) return;
|
||||||
|
var wraps = thumbsView.querySelectorAll('.stack-wrap');
|
||||||
|
wraps.forEach(function(sw){
|
||||||
|
var grid = sw.querySelector('.stack-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var gridStyles = window.getComputedStyle(grid);
|
||||||
|
var gap = parseFloat(gridStyles.columnGap) || 10;
|
||||||
|
var swStyles = window.getComputedStyle(sw);
|
||||||
|
var cardW = parseFloat(swStyles.getPropertyValue('--card-w')) || 160;
|
||||||
|
var width = sw.clientWidth;
|
||||||
|
if (!width || width < cardW) {
|
||||||
|
sw.style.setProperty('--cols', '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cols = Math.max(1, Math.floor((width + gap) / (cardW + gap)));
|
||||||
|
sw.style.setProperty('--cols', String(cols));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, ms); }; }
|
||||||
|
var debouncedRecalc = debounce(recalcThumbCols, 100);
|
||||||
|
window.addEventListener('resize', debouncedRecalc);
|
||||||
|
document.addEventListener('htmx:afterSwap', debouncedRecalc);
|
||||||
|
|
||||||
|
function applyMode(mode){
|
||||||
|
var isList = (mode !== 'thumbs');
|
||||||
|
listView.classList.toggle('hidden', !isList);
|
||||||
|
thumbsView.classList.toggle('hidden', isList);
|
||||||
|
if (listBtn) listBtn.setAttribute('aria-selected', isList ? 'true' : 'false');
|
||||||
|
if (thumbsBtn) thumbsBtn.setAttribute('aria-selected', isList ? 'false' : 'true');
|
||||||
|
try { localStorage.setItem('summaryTypeView', mode); } catch(e) {}
|
||||||
|
if (!isList) recalcThumbCols();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listBtn && thumbsBtn) {
|
||||||
|
listBtn.addEventListener('click', function(){ applyMode('list'); });
|
||||||
|
thumbsBtn.addEventListener('click', function(){ applyMode('thumbs'); });
|
||||||
|
}
|
||||||
|
var initial = 'list';
|
||||||
|
try { initial = localStorage.getItem('summaryTypeView') || 'list'; } catch(e) {}
|
||||||
|
applyMode(initial);
|
||||||
|
if (initial === 'thumbs') recalcThumbCols();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Mana Pip Distribution (vertical bars; only deck colors) -->
|
||||||
|
<section style="margin-top:1rem;">
|
||||||
|
<h5>Mana Pip Distribution (non-lands)</h5>
|
||||||
|
{% set pd = summary.pip_distribution %}
|
||||||
|
{% set deck_colors = summary.colors or [] %}
|
||||||
|
{% if pd %}
|
||||||
|
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||||
|
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||||
|
{% for color in colors %}
|
||||||
|
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||||
|
{% set pct = (w * 100) | int %}
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||||
|
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||||
|
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||||
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||||
|
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||||
|
{% set h = (pct * 1.0) | int %}
|
||||||
|
{% set bar_h = (h if h>2 else 2) %}
|
||||||
|
{% set y = 118 - bar_h %}
|
||||||
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||||
|
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||||
|
</svg>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No pip data.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Mana Generation (color sources from lands, vertical bars; only deck colors) -->
|
||||||
|
<section style="margin-top:1rem;">
|
||||||
|
<h5>Mana Generation (Color Sources)</h5>
|
||||||
|
{% set mg = summary.mana_generation %}
|
||||||
|
{% set deck_colors = summary.colors or [] %}
|
||||||
|
{% if mg %}
|
||||||
|
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||||
|
{% set ns = namespace(max_src=0) %}
|
||||||
|
{% for color in colors %}
|
||||||
|
{% set val = mg.get(color, 0) %}
|
||||||
|
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||||
|
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||||
|
{% for color in colors %}
|
||||||
|
{% set val = mg.get(color, 0) %}
|
||||||
|
{% set pct = (val * 100 / denom) | int %}
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||||
|
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||||
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||||
|
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||||
|
{% set bar_h = (pct if pct>2 else 2) %}
|
||||||
|
{% set y = 118 - bar_h %}
|
||||||
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||||
|
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||||
|
</svg>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No mana source data.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Mana Curve (vertical bars) -->
|
||||||
|
<section style="margin-top:1rem;">
|
||||||
|
<h5>Mana Curve (non-lands)</h5>
|
||||||
|
{% set mc = summary.mana_curve %}
|
||||||
|
{% if mc %}
|
||||||
|
{% set ts = mc.total_spells or 0 %}
|
||||||
|
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||||
|
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||||
|
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||||
|
{% set val = mc.get(label, 0) %}
|
||||||
|
{% set pct = (val * 100 / denom) | int %}
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||||
|
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||||
|
{% set parts = [] %}
|
||||||
|
{% for c in cards %}
|
||||||
|
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set cards_line = parts|join(' • ') %}
|
||||||
|
{% set pct_f = (100.0 * (val / denom)) %}
|
||||||
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||||
|
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||||
|
{% set bar_h = (pct if pct>2 else 2) %}
|
||||||
|
{% set y = 118 - bar_h %}
|
||||||
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||||
|
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||||
|
</svg>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No curve data.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||||
|
<section style="margin-top:1rem;">
|
||||||
|
<h5>Test Hand</h5>
|
||||||
|
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
||||||
|
<button type="button" id="btn-new-hand">New Hand</button>
|
||||||
|
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
|
||||||
|
<div class="stack-grid" id="test-hand-grid"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var GC_SET = (function(){
|
||||||
|
try {
|
||||||
|
var els = document.querySelectorAll('#typeview-list .game-changer [data-card-name], #typeview-thumbs .game-changer [data-card-name]');
|
||||||
|
var s = new Set();
|
||||||
|
els.forEach(function(el){ var n = el.getAttribute('data-card-name'); if(n) s.add(n); });
|
||||||
|
return s;
|
||||||
|
} catch(e) { return new Set(); }
|
||||||
|
})();
|
||||||
|
var BASE_BASICS = ["Plains","Island","Swamp","Mountain","Forest","Wastes"];
|
||||||
|
function isBasicLand(name){
|
||||||
|
if (!name) return false;
|
||||||
|
if (BASE_BASICS.indexOf(name) >= 0) return true;
|
||||||
|
if (name.startsWith('Snow-Covered ')) {
|
||||||
|
var base = name.substring('Snow-Covered '.length);
|
||||||
|
return BASE_BASICS.indexOf(base) >= 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function collectDeck(){
|
||||||
|
var deck = [];
|
||||||
|
document.querySelectorAll('#typeview-list span[data-card-name]').forEach(function(el){
|
||||||
|
var name = el.getAttribute('data-card-name');
|
||||||
|
var cnt = parseInt(el.getAttribute('data-count') || '1', 10);
|
||||||
|
if (name) deck.push({ name: name, count: (isFinite(cnt) && cnt>0 ? cnt : 1) });
|
||||||
|
});
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
function buildPool(deck){
|
||||||
|
var pool = [];
|
||||||
|
deck.forEach(function(it){
|
||||||
|
var n = Math.max(1, parseInt(it.count || 1, 10));
|
||||||
|
for (var i=0;i<n;i++){ pool.push(it.name); }
|
||||||
|
});
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
function drawHand(deck){
|
||||||
|
var pool = buildPool(deck);
|
||||||
|
if (!pool.length) return [];
|
||||||
|
var picked = {};
|
||||||
|
var hand = [];
|
||||||
|
var attempts = 0;
|
||||||
|
while (hand.length < 7 && attempts < 500) {
|
||||||
|
attempts++;
|
||||||
|
var idx = Math.floor(Math.random() * pool.length);
|
||||||
|
var name = pool[idx];
|
||||||
|
if (!name) continue;
|
||||||
|
var allowDup = isBasicLand(name);
|
||||||
|
if (!allowDup && picked[name]) continue;
|
||||||
|
hand.push(name);
|
||||||
|
if (!allowDup) picked[name] = true;
|
||||||
|
pool.splice(idx, 1);
|
||||||
|
if (!pool.length) break;
|
||||||
|
}
|
||||||
|
return hand;
|
||||||
|
}
|
||||||
|
function compress(hand){
|
||||||
|
var map = {};
|
||||||
|
hand.forEach(function(n){ map[n] = (map[n]||0) + 1; });
|
||||||
|
var out = [];
|
||||||
|
Object.keys(map).forEach(function(n){ out.push({name:n, count: map[n]}); });
|
||||||
|
out.sort(function(a,b){ return hand.indexOf(a.name) - hand.indexOf(b.name); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function render(hand){
|
||||||
|
var grid = document.getElementById('test-hand-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = '';
|
||||||
|
var unique = compress(hand);
|
||||||
|
unique.forEach(function(it){
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'stack-card';
|
||||||
|
if (GC_SET && GC_SET.has(it.name)) {
|
||||||
|
div.className += ' game-changer';
|
||||||
|
}
|
||||||
|
div.innerHTML = (
|
||||||
|
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(it.name) + '&format=image&version=normal" alt="' + it.name + '" data-card-name="' + it.name + '" />' +
|
||||||
|
'<div class="count-badge">' + it.count + 'x</div>'
|
||||||
|
);
|
||||||
|
grid.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
|
||||||
|
var btn = document.getElementById('btn-new-hand');
|
||||||
|
if (btn) btn.addEventListener('click', newHand);
|
||||||
|
newHand();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
<style>
|
||||||
|
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
function ensureTip() {
|
||||||
|
var tip = document.getElementById('chart-tooltip');
|
||||||
|
if (!tip) {
|
||||||
|
tip = document.createElement('div');
|
||||||
|
tip.id = 'chart-tooltip';
|
||||||
|
tip.className = 'chart-tooltip';
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
}
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
var tip = ensureTip();
|
||||||
|
function position(e) {
|
||||||
|
tip.style.display = 'block';
|
||||||
|
var x = e.clientX + 12, y = e.clientY + 12;
|
||||||
|
tip.style.left = x + 'px';
|
||||||
|
tip.style.top = y + 'px';
|
||||||
|
var rect = tip.getBoundingClientRect();
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
|
||||||
|
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
|
||||||
|
}
|
||||||
|
function compose(el) {
|
||||||
|
var t = el.getAttribute('data-type');
|
||||||
|
if (t === 'pips') {
|
||||||
|
return el.dataset.color + ': ' + el.dataset.count + ' (' + el.dataset.pct + '%)';
|
||||||
|
}
|
||||||
|
if (t === 'sources') {
|
||||||
|
return el.dataset.color + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)';
|
||||||
|
}
|
||||||
|
if (t === 'curve') {
|
||||||
|
var cards = (el.dataset.cards || '').split(' • ').join('\n');
|
||||||
|
return el.dataset.label + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)' + (cards ? '\n' + cards : '');
|
||||||
|
}
|
||||||
|
return el.getAttribute('aria-label') || '';
|
||||||
|
}
|
||||||
|
function attach() {
|
||||||
|
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||||
|
el.addEventListener('mouseenter', function(e) {
|
||||||
|
tip.textContent = compose(el);
|
||||||
|
position(e);
|
||||||
|
});
|
||||||
|
el.addEventListener('mousemove', position);
|
||||||
|
el.addEventListener('mouseleave', function() { tip.style.display = 'none'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attach();
|
||||||
|
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||||
|
})();
|
||||||
|
</script>
|
162
code/web/templates/setup/index.html
Normal file
162
code/web/templates/setup/index.html
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
<h2>Setup / Tagging</h2>
|
||||||
|
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
|
||||||
|
|
||||||
|
<details open style="margin-top:.5rem;">
|
||||||
|
<summary>Current Status</summary>
|
||||||
|
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
|
||||||
|
<div class="muted">Status:</div>
|
||||||
|
<div id="setup-status-line" style="margin-top:.25rem;">Checking…</div>
|
||||||
|
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||||
|
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||||
|
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||||
|
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||||
|
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
|
||||||
|
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
|
||||||
|
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
|
||||||
|
<input type="checkbox" id="chk-force" checked /> Force run
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<form method="get" action="/setup/running?start=1&force=1">
|
||||||
|
<button type="submit">Open Progress Page</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
function update(data){
|
||||||
|
var line = document.getElementById('setup-status-line');
|
||||||
|
var colorEl = document.getElementById('setup-color-line');
|
||||||
|
var logEl = document.getElementById('setup-log-tail');
|
||||||
|
var progEl = document.getElementById('setup-progress-line');
|
||||||
|
var timeEl = document.getElementById('setup-time-line');
|
||||||
|
var bar = document.getElementById('setup-progress-bar');
|
||||||
|
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||||
|
var logWrap = document.getElementById('setup-log-wrap');
|
||||||
|
var logSummary = document.getElementById('setup-log-summary');
|
||||||
|
if (!line) return;
|
||||||
|
if (data && data.running) {
|
||||||
|
line.textContent = (data.message || 'Working…');
|
||||||
|
if (typeof data.percent === 'number') {
|
||||||
|
progEl.style.display = '';
|
||||||
|
var p = Math.max(0, Math.min(100, data.percent));
|
||||||
|
progEl.textContent = 'Progress: ' + p + '%';
|
||||||
|
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||||
|
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||||
|
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||||
|
}
|
||||||
|
if (typeof data.eta_seconds === 'number') {
|
||||||
|
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||||
|
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progEl.style.display = 'none';
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.started_at) {
|
||||||
|
timeEl.style.display = '';
|
||||||
|
timeEl.textContent = 'Started: ' + data.started_at;
|
||||||
|
} else {
|
||||||
|
timeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.color) {
|
||||||
|
colorEl.style.display = '';
|
||||||
|
colorEl.textContent = 'Current color: ' + data.color;
|
||||||
|
} else {
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.log_tail) {
|
||||||
|
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||||
|
if (logWrap) logWrap.style.display = '';
|
||||||
|
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||||
|
logEl.textContent = data.log_tail;
|
||||||
|
} else {
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else if (data && data.phase === 'done') {
|
||||||
|
line.textContent = 'Setup complete.';
|
||||||
|
if (typeof data.percent === 'number') {
|
||||||
|
progEl.style.display = '';
|
||||||
|
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||||
|
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||||
|
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||||
|
} else {
|
||||||
|
progEl.style.display = 'none';
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.started_at || data.finished_at) {
|
||||||
|
timeEl.style.display = '';
|
||||||
|
var t = [];
|
||||||
|
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||||
|
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||||
|
timeEl.textContent = t.join(' • ');
|
||||||
|
} else {
|
||||||
|
timeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
} else if (data && data.phase === 'error') {
|
||||||
|
line.textContent = (data.message || 'Setup error.');
|
||||||
|
if (data.color) {
|
||||||
|
colorEl.style.display = '';
|
||||||
|
colorEl.textContent = 'While working on: ' + data.color;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.textContent = 'Idle';
|
||||||
|
progEl.style.display = 'none';
|
||||||
|
timeEl.style.display = 'none';
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function poll(){
|
||||||
|
fetch('/status/setup', { cache: 'no-store' })
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(update)
|
||||||
|
.catch(function(){});
|
||||||
|
}
|
||||||
|
function rapidPoll(times, delay){
|
||||||
|
var i = 0;
|
||||||
|
function tick(){
|
||||||
|
poll();
|
||||||
|
i++;
|
||||||
|
if (i < times) setTimeout(tick, delay);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
window.startSetup = function(){
|
||||||
|
var btn = document.getElementById('btn-start-setup');
|
||||||
|
var line = document.getElementById('setup-status-line');
|
||||||
|
var force = document.getElementById('chk-force') && document.getElementById('chk-force').checked;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (line) line.textContent = 'Starting setup/tagging…';
|
||||||
|
// First try POST with JSON body
|
||||||
|
fetch('/setup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) })
|
||||||
|
.then(function(r){ if (!r.ok) throw new Error('POST failed'); return r.json().catch(function(){ return {}; }); })
|
||||||
|
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||||
|
.catch(function(){
|
||||||
|
// Fallback to GET if POST fails (proxy/middleware issues)
|
||||||
|
var url = '/setup/start' + (force ? '?force=1' : '');
|
||||||
|
fetch(url, { method: 'GET', cache: 'no-store' })
|
||||||
|
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||||
|
.catch(function(){});
|
||||||
|
})
|
||||||
|
.finally(function(){ if (btn) btn.disabled = false; });
|
||||||
|
};
|
||||||
|
setInterval(poll, 3000);
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
134
code/web/templates/setup/running.html
Normal file
134
code/web/templates/setup/running.html
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
<h2>Preparing Card Database</h2>
|
||||||
|
<p class="muted">Initial setup and tagging may take several minutes on first run.</p>
|
||||||
|
|
||||||
|
<div id="setup-status" style="margin-top:1rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;" data-next-url="{{ next_url or '' }}">
|
||||||
|
<div class="muted">Status:</div>
|
||||||
|
<div id="setup-status-line" style="margin-top:.25rem;">Starting…</div>
|
||||||
|
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||||
|
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||||
|
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||||
|
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||||
|
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||||
|
<form method="get" action="/setup">
|
||||||
|
<button type="submit">Back to Setup</button>
|
||||||
|
</form>
|
||||||
|
{% if next_url %}
|
||||||
|
<form method="get" action="{{ next_url }}">
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var container = document.getElementById('setup-status');
|
||||||
|
var nextUrl = (container && container.dataset.nextUrl) ? container.dataset.nextUrl : null;
|
||||||
|
if (nextUrl === '') nextUrl = null;
|
||||||
|
function update(data){
|
||||||
|
var line = document.getElementById('setup-status-line');
|
||||||
|
var colorEl = document.getElementById('setup-color-line');
|
||||||
|
var logEl = document.getElementById('setup-log-tail');
|
||||||
|
var progEl = document.getElementById('setup-progress-line');
|
||||||
|
var timeEl = document.getElementById('setup-time-line');
|
||||||
|
var bar = document.getElementById('setup-progress-bar');
|
||||||
|
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||||
|
var logWrap = document.getElementById('setup-log-wrap');
|
||||||
|
var logSummary = document.getElementById('setup-log-summary');
|
||||||
|
if (!line) return;
|
||||||
|
if (data && data.running) {
|
||||||
|
line.textContent = (data.message || 'Working…');
|
||||||
|
if (typeof data.percent === 'number') {
|
||||||
|
progEl.style.display = '';
|
||||||
|
var p = Math.max(0, Math.min(100, data.percent));
|
||||||
|
progEl.textContent = 'Progress: ' + p + '%';
|
||||||
|
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||||
|
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||||
|
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||||
|
}
|
||||||
|
if (typeof data.eta_seconds === 'number') {
|
||||||
|
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||||
|
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progEl.style.display = 'none';
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.started_at) {
|
||||||
|
timeEl.style.display = '';
|
||||||
|
timeEl.textContent = 'Started: ' + data.started_at;
|
||||||
|
} else {
|
||||||
|
timeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.color) {
|
||||||
|
colorEl.style.display = '';
|
||||||
|
colorEl.textContent = 'Current color: ' + data.color;
|
||||||
|
} else {
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.log_tail) {
|
||||||
|
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||||
|
if (logWrap) logWrap.style.display = '';
|
||||||
|
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||||
|
logEl.textContent = data.log_tail;
|
||||||
|
} else {
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else if (data && data.phase === 'done') {
|
||||||
|
line.textContent = 'Setup complete.';
|
||||||
|
if (typeof data.percent === 'number') {
|
||||||
|
progEl.style.display = '';
|
||||||
|
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||||
|
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||||
|
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||||
|
} else {
|
||||||
|
progEl.style.display = 'none';
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (data.started_at || data.finished_at) {
|
||||||
|
timeEl.style.display = '';
|
||||||
|
var t = [];
|
||||||
|
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||||
|
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||||
|
timeEl.textContent = t.join(' • ');
|
||||||
|
} else {
|
||||||
|
timeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
if (nextUrl) {
|
||||||
|
setTimeout(function(){ window.location.href = nextUrl; }, 1200);
|
||||||
|
}
|
||||||
|
} else if (data && data.phase === 'error') {
|
||||||
|
line.textContent = (data.message || 'Setup error.');
|
||||||
|
if (data.color) {
|
||||||
|
colorEl.style.display = '';
|
||||||
|
colorEl.textContent = 'While working on: ' + data.color;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.textContent = 'Idle';
|
||||||
|
colorEl.style.display = 'none';
|
||||||
|
if (logWrap) logWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function poll(){
|
||||||
|
fetch('/status/setup', { cache: 'no-store' })
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(update)
|
||||||
|
.catch(function(){});
|
||||||
|
}
|
||||||
|
setInterval(poll, 3000);
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -22,3 +22,22 @@ services:
|
||||||
# - DECK_COMMANDER=Pantlaza
|
# - DECK_COMMANDER=Pantlaza
|
||||||
# Ensure proper cleanup
|
# Ensure proper cleanup
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: mtg-deckbuilder-web
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- TERM=xterm-256color
|
||||||
|
- DEBIAN_FRONTEND=noninteractive
|
||||||
|
volumes:
|
||||||
|
- ${PWD}/deck_files:/app/deck_files
|
||||||
|
- ${PWD}/logs:/app/logs
|
||||||
|
- ${PWD}/csv_files:/app/csv_files
|
||||||
|
- ${PWD}/config:/app/config
|
||||||
|
- ${PWD}/owned_cards:/app/owned_cards
|
||||||
|
working_dir: /app
|
||||||
|
command: ["bash", "-lc", "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"]
|
||||||
|
restart: "no"
|
||||||
|
|
|
@ -5,4 +5,10 @@ tqdm>=4.66.0
|
||||||
# Optional pretty output in reports; app falls back gracefully if missing
|
# Optional pretty output in reports; app falls back gracefully if missing
|
||||||
prettytable>=3.9.0
|
prettytable>=3.9.0
|
||||||
|
|
||||||
|
# Web UI stack (FastAPI + Jinja + HTMX served via CDN)
|
||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.28.0
|
||||||
|
Jinja2>=3.1.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
|
||||||
# Development dependencies are in requirements-dev.txt
|
# Development dependencies are in requirements-dev.txt
|
Loading…
Add table
Add a link
Reference in a new issue