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

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