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:
matt 2025-08-29 09:19:03 -07:00
parent be672ac5d2
commit 341a216ed3
20 changed files with 1271 additions and 21 deletions

View file

@ -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']
}

View file

@ -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.

View file

@ -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