mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-23 19:10:13 +01:00
feat(web): Multi-Copy modal earlier; Multi-Copy stage before lands; bump version to 2.1.1; update CHANGELOG\n\n- Modal triggers after commander selection (Step 2)\n- Multi-Copy applied first in Step 5, lands next\n- Keep mc_summary/clamp/adjustments wiring intact\n- Tests green
This commit is contained in:
parent
be672ac5d2
commit
341a216ed3
20 changed files with 1271 additions and 21 deletions
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Dict, List, Final, Tuple, Union, Callable
|
||||
from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any
|
||||
from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -516,4 +516,206 @@ GAME_CHANGERS: Final[List[str]] = [
|
|||
'Tergrid, God of Fright', 'Thassa\'s Oracle', 'The One Ring', 'The Tabernacle at Pendrell Vale',
|
||||
'Underworld Breach', 'Urza, Lord High Artificer', 'Vampiric Tutor', 'Vorinclex, Voice of Hunger',
|
||||
'Winota, Joiner of Forces', 'Worldly Tutor', 'Yuriko, the Tiger\'s Shadow'
|
||||
]
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-copy archetype configuration (centralized source of truth)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each entry describes a supported multi-copy archetype eligible for the choose-one flow.
|
||||
# Fields:
|
||||
# - id: machine id
|
||||
# - name: card name
|
||||
# - color_identity: list[str] of required color letters (subset must be in commander CI)
|
||||
# - printed_cap: int | None (None means no printed cap)
|
||||
# - exclusive_group: str | None (at most one from the same group)
|
||||
# - triggers: { tags_any: list[str], tags_all: list[str] }
|
||||
# - default_count: int (default 25)
|
||||
# - rec_window: tuple[int,int] (recommendation window)
|
||||
# - thrumming_stone_synergy: bool
|
||||
# - type_hint: 'creature' | 'noncreature'
|
||||
MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
|
||||
'cid_timeless_artificer': {
|
||||
'id': 'cid_timeless_artificer',
|
||||
'name': 'Cid, Timeless Artificer',
|
||||
'color_identity': ['U','W'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['artificer kindred', 'hero kindred', 'artifacts matter'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'dragons_approach': {
|
||||
'id': 'dragons_approach',
|
||||
'name': "Dragon's Approach",
|
||||
'color_identity': ['R'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'noncreature'
|
||||
},
|
||||
'hare_apparent': {
|
||||
'id': 'hare_apparent',
|
||||
'name': 'Hare Apparent',
|
||||
'color_identity': ['W'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['rabbit kindred','tokens matter','aggro'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'slime_against_humanity': {
|
||||
'id': 'slime_against_humanity',
|
||||
'name': 'Slime Against Humanity',
|
||||
'color_identity': ['G'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'noncreature'
|
||||
},
|
||||
'relentless_rats': {
|
||||
'id': 'relentless_rats',
|
||||
'name': 'Relentless Rats',
|
||||
'color_identity': ['B'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': 'rats',
|
||||
'triggers': {
|
||||
'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'rat_colony': {
|
||||
'id': 'rat_colony',
|
||||
'name': 'Rat Colony',
|
||||
'color_identity': ['B'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': 'rats',
|
||||
'triggers': {
|
||||
'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'seven_dwarves': {
|
||||
'id': 'seven_dwarves',
|
||||
'name': 'Seven Dwarves',
|
||||
'color_identity': ['R'],
|
||||
'printed_cap': 7,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 7,
|
||||
'rec_window': (7,7),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'persistent_petitioners': {
|
||||
'id': 'persistent_petitioners',
|
||||
'name': 'Persistent Petitioners',
|
||||
'color_identity': ['U'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['mill','advisor kindred','control','defenders','walls','draw-go'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'shadowborn_apostle': {
|
||||
'id': 'shadowborn_apostle',
|
||||
'name': 'Shadowborn Apostle',
|
||||
'color_identity': ['B'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'nazgul': {
|
||||
'id': 'nazgul',
|
||||
'name': 'Nazgûl',
|
||||
'color_identity': ['B'],
|
||||
'printed_cap': 9,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 9,
|
||||
'rec_window': (9,9),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'tempest_hawk': {
|
||||
'id': 'tempest_hawk',
|
||||
'name': 'Tempest Hawk',
|
||||
'color_identity': ['W'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['bird kindred','aggro'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
'templar_knight': {
|
||||
'id': 'templar_knight',
|
||||
'name': 'Templar Knight',
|
||||
'color_identity': ['W'],
|
||||
'printed_cap': None,
|
||||
'exclusive_group': None,
|
||||
'triggers': {
|
||||
'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'],
|
||||
'tags_all': []
|
||||
},
|
||||
'default_count': 25,
|
||||
'rec_window': (20,30),
|
||||
'thrumming_stone_synergy': True,
|
||||
'type_hint': 'creature'
|
||||
},
|
||||
}
|
||||
|
||||
EXCLUSIVE_GROUPS: Final[dict[str, list[str]]] = {
|
||||
'rats': ['relentless_rats', 'rat_colony']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ __all__ = [
|
|||
'compute_spell_pip_weights',
|
||||
'parse_theme_tags',
|
||||
'normalize_theme_list',
|
||||
'detect_viable_multi_copy_archetypes',
|
||||
'prefer_owned_first',
|
||||
'compute_adjusted_target',
|
||||
'normalize_tag_cell',
|
||||
|
|
@ -476,6 +477,114 @@ def sort_by_priority(df, columns: list[str]):
|
|||
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
|
||||
|
||||
|
||||
def _normalize_tags_list(tags: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen = set()
|
||||
for t in tags or []:
|
||||
tt = str(t).strip().lower()
|
||||
if tt and tt not in seen:
|
||||
out.append(tt)
|
||||
seen.add(tt)
|
||||
return out
|
||||
|
||||
|
||||
def _color_subset_ok(required: list[str], commander_ci: list[str]) -> bool:
|
||||
if not required:
|
||||
return True
|
||||
ci = {c.upper() for c in commander_ci}
|
||||
need = {c.upper() for c in required}
|
||||
return need.issubset(ci)
|
||||
|
||||
|
||||
def detect_viable_multi_copy_archetypes(builder) -> list[dict]:
|
||||
"""Return ranked viable multi-copy archetypes for the given builder.
|
||||
|
||||
Output items: { id, name, printed_cap, type_hint, score, reasons }
|
||||
Never raises; returns [] on missing data.
|
||||
"""
|
||||
try:
|
||||
from . import builder_constants as bc
|
||||
except Exception:
|
||||
return []
|
||||
# Commander color identity and tags
|
||||
try:
|
||||
ci = list(getattr(builder, 'color_identity', []) or [])
|
||||
except Exception:
|
||||
ci = []
|
||||
# Gather tags from selected + commander summary
|
||||
tags: list[str] = []
|
||||
try:
|
||||
tags.extend([t for t in getattr(builder, 'selected_tags', []) or []])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cmd = getattr(builder, 'commander_dict', {}) or {}
|
||||
themes = cmd.get('Themes', [])
|
||||
if isinstance(themes, list):
|
||||
tags.extend(themes)
|
||||
except Exception:
|
||||
pass
|
||||
tags_norm = _normalize_tags_list(tags)
|
||||
out: list[dict] = []
|
||||
# Exclusivity prep: if multiple in same group qualify, we still compute score, suppression happens in consumer or by taking top one.
|
||||
for aid, meta in getattr(bc, 'MULTI_COPY_ARCHETYPES', {}).items():
|
||||
try:
|
||||
# Color gate
|
||||
if not _color_subset_ok(meta.get('color_identity', []), ci):
|
||||
continue
|
||||
# Tag triggers
|
||||
trig = meta.get('triggers', {}) or {}
|
||||
any_tags = _normalize_tags_list(trig.get('tags_any', []) or [])
|
||||
all_tags = _normalize_tags_list(trig.get('tags_all', []) or [])
|
||||
score = 0
|
||||
reasons: list[str] = []
|
||||
# +2 for color match baseline
|
||||
if meta.get('color_identity'):
|
||||
score += 2
|
||||
reasons.append('color identity fits')
|
||||
# +1 per matched any tag (cap small to avoid dwarfing)
|
||||
matches_any = [t for t in any_tags if t in tags_norm]
|
||||
if matches_any:
|
||||
bump = min(3, len(matches_any))
|
||||
score += bump
|
||||
reasons.append('tags: ' + ', '.join(matches_any[:3]))
|
||||
# +1 if all required tags matched
|
||||
if all_tags and all(t in tags_norm for t in all_tags):
|
||||
score += 1
|
||||
reasons.append('all required tags present')
|
||||
if score <= 0:
|
||||
continue
|
||||
out.append({
|
||||
'id': aid,
|
||||
'name': meta.get('name', aid),
|
||||
'printed_cap': meta.get('printed_cap'),
|
||||
'type_hint': meta.get('type_hint', 'noncreature'),
|
||||
'exclusive_group': meta.get('exclusive_group'),
|
||||
'default_count': meta.get('default_count', 25),
|
||||
'rec_window': meta.get('rec_window', (20,30)),
|
||||
'thrumming_stone_synergy': bool(meta.get('thrumming_stone_synergy', True)),
|
||||
'score': score,
|
||||
'reasons': reasons,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
# Suppress lower-scored siblings within the same exclusive group, keep the highest per group
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
rest: list[dict] = []
|
||||
for item in out:
|
||||
grp = item.get('exclusive_group')
|
||||
if grp:
|
||||
grouped.setdefault(grp, []).append(item)
|
||||
else:
|
||||
rest.append(item)
|
||||
kept: list[dict] = rest[:]
|
||||
for grp, items in grouped.items():
|
||||
items.sort(key=lambda d: d.get('score', 0), reverse=True)
|
||||
kept.append(items[0])
|
||||
kept.sort(key=lambda d: d.get('score', 0), reverse=True)
|
||||
return kept
|
||||
|
||||
|
||||
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
|
||||
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
|
||||
|
||||
|
|
|
|||
|
|
@ -335,9 +335,39 @@ class CreatureAdditionMixin:
|
|||
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))
|
||||
lib = getattr(self, 'card_library', {}) or {}
|
||||
for name, entry in lib.items():
|
||||
# Skip the commander from creature counts to preserve historical behavior
|
||||
try:
|
||||
if bool(entry.get('Commander')):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
is_creature = False
|
||||
# Prefer explicit Card Type recorded on the entry
|
||||
try:
|
||||
ctype = str(entry.get('Card Type') or '')
|
||||
if ctype:
|
||||
is_creature = ('creature' in ctype.lower())
|
||||
except Exception:
|
||||
is_creature = False
|
||||
# Fallback: look up type from the combined dataframe snapshot
|
||||
if not is_creature:
|
||||
try:
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is not None and not getattr(df, 'empty', True) and 'name' in df.columns:
|
||||
row = df[df['name'].astype(str).str.lower() == str(name).strip().lower()]
|
||||
if not row.empty:
|
||||
tline = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
|
||||
if 'creature' in tline.lower():
|
||||
is_creature = True
|
||||
except Exception:
|
||||
pass
|
||||
if is_creature:
|
||||
try:
|
||||
total += int(entry.get('Count', 1))
|
||||
except Exception:
|
||||
total += 1
|
||||
except Exception:
|
||||
pass
|
||||
return total
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue