feat(combos): add Combos & Synergies detection, chip-style UI with dual hover; JSON persistence and headless honoring; stage ordering; docs and tests; bump to v2.2.1

This commit is contained in:
mwisnowski 2025-09-01 16:55:24 -07:00
parent cc16c6f13a
commit 6c48fb3437
38 changed files with 2042 additions and 131 deletions

View file

@ -104,6 +104,9 @@
.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: var(--panel); }
.card-hover .dual {
display:flex; gap:12px; align-items:flex-start;
}
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
.card-meta li { margin:.1rem 0; }
@ -180,9 +183,15 @@
inner.className = 'card-hover-inner';
var img = document.createElement('img');
img.alt = 'Card preview';
var img2 = document.createElement('img');
img2.alt = 'Card preview'; img2.style.display = 'none';
var meta = document.createElement('div');
meta.className = 'card-meta';
inner.appendChild(img);
var dual = document.createElement('div');
dual.className = 'dual';
dual.appendChild(img);
dual.appendChild(img2);
inner.appendChild(dual);
inner.appendChild(meta);
pop.appendChild(inner);
document.body.appendChild(pop);
@ -259,12 +268,14 @@
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() {
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 img = cardPop.querySelector('.card-hover-inner img');
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
if (img2) img2.style.display = 'none';
var meta = cardPop.querySelector('.card-meta');
var name = el.getAttribute('data-card-name') || '';
var vi = 0; // always start at 'normal' on hover
@ -304,6 +315,33 @@
el.addEventListener('mousemove', positionCard);
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
});
// Dual-card hover for combo rows
document.querySelectorAll('[data-combo-names]').forEach(function(el){
if (el.__comboHoverBound) return; el.__comboHoverBound = true;
el.addEventListener('mouseenter', function(e){
var namesAttr = el.getAttribute('data-combo-names') || '';
var parts = namesAttr.split('||');
var a = (parts[0]||'').trim(); var b = (parts[1]||'').trim();
if (!a || !b) return;
var img = cardPop.querySelector('.card-hover-inner img');
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
var meta = cardPop.querySelector('.card-meta');
if (img2) img2.style.display = '';
var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false;
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);
function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} }
function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} }
img.addEventListener('error', err1, { once:false });
img2.addEventListener('error', err2, { once:false });
img.addEventListener('load', function on1(){ img.removeEventListener('load', on1); img.removeEventListener('error', err1); });
img2.addEventListener('load', function on2(){ img2.removeEventListener('load', on2); img2.removeEventListener('error', err2); });
meta.style.display = 'none'; meta.innerHTML = '';
positionCard(e);
});
el.addEventListener('mousemove', positionCard);
el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; });
});
}
attachCardHover();
bindAllCardImageRetries();

View file

@ -0,0 +1,28 @@
<div class="modal" id="combo-modal" role="dialog" aria-modal="true" aria-labelledby="combo-modal-title">
<div class="modal-content">
<h3 id="combo-modal-title" style="margin-top:0;">Combos & Synergies — Auto-complete plan</h3>
<p class="muted" style="margin:.25rem 0 .75rem 0;">You're prioritizing combos. Choose how many to aim for and the balance of early vs late-game pieces.</p>
<form hx-post="/build/combos/save" hx-target="#combo-modal" hx-swap="outerHTML" style="display:grid; gap:.75rem;">
<div>
<label for="combo_count"><strong>How many combos would you like?</strong></label>
<input id="combo_count" name="count" type="number" min="0" max="10" step="1" value="{{ count|default(2) }}" style="width:6rem; margin-left:.5rem;" />
</div>
<fieldset style="border:1px solid var(--border); padding:.5rem; border-radius:8px;">
<legend><strong>Balance of early-game vs late-game</strong></legend>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="early" {% if balance == 'early' %}checked{% endif %}/> Early-game focus (cheap, quick setups)
</label>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="late" {% if balance == 'late' %}checked{% endif %}/> Late-game focus (setup-dependent payoffs)
</label>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="mix" {% if balance == 'mix' or not balance %}checked{% endif %}/> Mix of both
</label>
</fieldset>
<div style="display:flex; gap:.5rem; justify-content:flex-end;">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn" hx-post="/build/combos/save" hx-vals='{"skip":"1"}' hx-target="#combo-modal" hx-swap="outerHTML">Dismiss</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,76 @@
<div class="panel" style="margin-top:1rem;">
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<h3 style="margin:0;">Combos & Synergies</h3>
{% if versions and (versions.combos or versions.synergies) %}
<span class="muted">lists v{{ versions.combos }}{% if versions.synergies %} / {{ versions.synergies }}{% endif %}</span>
{% endif %}
</div>
<section style="margin-top:.5rem;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected combos ({{ combos|length }})</div>
{% if combos and combos|length %}
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for c in combos %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
{% if c.cheap_early or c.setup_dependent %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="muted">None found.</div>
{% endif %}
</section>
<section style="margin-top:.5rem;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected synergies ({{ synergies|length }})</div>
{% if synergies and synergies|length %}
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for s in synergies %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
{% if s.tags %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="muted">None found.</div>
{% endif %}
</section>
{% if suggestions and suggestions|length %}
<div style="margin-top:.75rem;">
<h4 style="margin:0 0 .25rem 0;">Suggestions</h4>
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
{% for s in suggestions %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;">
{% if s.kind == 'add' %}Add <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (partner: <span data-card-name="{{ s.have }}">{{ s.have }}</span>)
{% elif s.kind == 'cut' %}Cut <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (pairs with <span data-card-name="{{ s.partner }}">{{ s.partner }}</span>)
{% else %}{{ s.kind|title }} <strong data-card-name="{{ s.name }}">{{ s.name }}</strong>{% endif %}
{% set badges = [] %}
{% if s.cheap_early %}{% set _ = badges.append('cheap/early') %}{% endif %}
{% if s.setup_dependent %}{% set _ = badges.append('setup-dependent') %}{% endif %}
{% if badges and badges|length %}
<span class="muted">{ {{ badges|join(', ') }} }</span>
{% endif %}
{% if s.tags and s.tags|length %}
<span class="muted">[{{ s.tags|join(', ') }}]</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>

View file

@ -49,6 +49,32 @@
</label>
</div>
</fieldset>
<fieldset>
<legend>Preferences</legend>
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
</label>
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<label>
<span>How many combos?</span>
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" style="width:6rem; margin-left:.5rem;" />
</label>
<div>
<div class="muted" style="font-size:12px; margin-bottom:.25rem;">Balance of early vs late-game</div>
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
<input type="radio" name="combo_balance" value="early" {% if form and form.combo_balance == 'early' %}checked{% endif %}/> Early
</label>
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
<input type="radio" name="combo_balance" value="late" {% if form and form.combo_balance == 'late' %}checked{% endif %}/> Late
</label>
<label style="display:inline-flex; align-items:center; gap:.25rem;">
<input type="radio" name="combo_balance" value="mix" {% if not form or (form and (not form.combo_balance or form.combo_balance == 'mix')) %}checked{% endif %}/> Mix
</label>
</div>
</div>
</div>
</fieldset>
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
@ -146,4 +172,18 @@
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
document.addEventListener('keydown', onKey);
})();
// Toggle combos config visibility based on checkbox
(function(){
try {
var form = document.querySelector('.modal form');
var chk = form && form.querySelector('#pref-combos-chk');
var box = form && form.querySelector('#pref-combos-config');
if (!chk || !box) return;
function sync(){ box.style.display = chk.checked ? 'block' : 'none'; }
chk.addEventListener('change', sync);
// Initial state
sync();
} catch(_){}
})();
</script>

View file

@ -26,7 +26,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -49,6 +49,9 @@
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
{% if prefer_combos %}
<span class="chip" title="Combos plan"><span class="dot" style="background: var(--orange-main);"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
{% endif %}
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
{% endif %}
@ -77,6 +80,10 @@
</div>
{% endif %}
{% if status and status.startswith('Build complete') %}
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
{% endif %}
{% if locked_cards is defined and locked_cards %}
<details id="locked-section" style="margin-top:.5rem;">
<summary>Locked cards (always kept)</summary>

View file

@ -39,7 +39,7 @@
{% if summary %}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
{% endif %}
{% endif %}

View file

@ -7,7 +7,7 @@
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label>Deck A
<select name="A" required>
<option value="">Choose…</option>
<option value="" data-mtime="0">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
@ -15,7 +15,7 @@
</label>
<label>Deck B
<select name="B" required>
<option value="">Choose…</option>
<option value="" data-mtime="0">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}

View file

@ -58,7 +58,7 @@
</div>
</div>
{% endif %}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
{% else %}
<div class="muted">No summary available.</div>
{% endif %}

View file

@ -20,6 +20,16 @@
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3>
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
<button class="btn" id="diag-combos-run">Detect</button>
<small class="muted">Runs in diagnostics mode only.</small>
</div>
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
</div>
{% if enable_pwa %}
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">PWA status</h3>
@ -86,6 +96,48 @@
});
}
}catch(_){ }
// Combos & synergies ad-hoc tester
try{
var runBtn = document.getElementById('diag-combos-run');
var ta = document.getElementById('diag-combos-input');
var out = document.getElementById('diag-combos-out');
function parseLines(){
var v = (ta && ta.value) || '';
return v.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean);
}
async function run(){
if (!ta || !out) return;
out.textContent = 'Running…';
try{
var resp = await fetch('/diagnostics/combos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: parseLines() })});
if (!resp.ok){ out.textContent = 'Error '+resp.status; return; }
var data = await resp.json();
var lines = [];
// Versions
try{
if (data.versions){
var vLine = 'List versions: ';
if (data.versions.combos) vLine += 'combos v'+ String(data.versions.combos);
if (data.versions.synergies) vLine += (data.versions.combos? ', ' : '') + 'synergies v'+ String(data.versions.synergies);
lines.push(vLine);
}
}catch(_){ }
lines.push('Combos: '+ data.counts.combos);
(data.combos||[]).forEach(function(c){
var badges = [];
if (c.cheap_early) badges.push('cheap/early');
if (c.setup_dependent) badges.push('setup-dependent');
var tagStr = (c.tags && c.tags.length? ' ['+c.tags.join(', ')+']' : '');
var badgeStr = badges.length ? ' {'+badges.join(', ')+'}' : '';
lines.push(' - '+c.a+' + '+c.b+ tagStr + badgeStr);
});
lines.push('Synergies: '+ data.counts.synergies);
(data.synergies||[]).forEach(function(s){ lines.push(' - '+s.a+' + '+s.b+(s.tags && s.tags.length? ' ['+s.tags.join(', ')+']':'')); });
out.textContent = lines.join('\n');
}catch(e){ out.textContent = 'Failed: '+ (e && e.message? e.message : 'Unknown error'); }
}
if (runBtn){ runBtn.addEventListener('click', run); }
}catch(_){ }
try{
var p = document.getElementById('pwaStatus');
if (p){

View file

@ -1,11 +1,60 @@
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
{% if versions and (versions.combos or versions.synergies) %}
<div class="muted" style="font-size:12px; margin:.1rem 0 .4rem 0;">Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span>Legend:</span>
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Not owned</span>
</div>
<!-- Detected Combos & Synergies (top) -->
{% if combos or synergies %}
<section style="margin-top:.25rem;">
<h5>Combos & Synergies</h5>
{% if combos %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Combos ({{ combos|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for c in combos %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
{% if c.cheap_early or c.setup_dependent %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if synergies %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Synergies ({{ synergies|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for s in synergies %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
{% if s.tags %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
<!-- Card Type Breakdown with names-only list and hover preview -->
<section style="margin-top:.5rem;">
<h5>Card Types</h5>
@ -99,6 +148,7 @@
<div class="muted">No type data available.</div>
{% endif %}
</section>
<script>
(function(){
var listBtn = document.querySelector('.seg-btn[data-view="list"]');