feat(calculadora): cost split breakdown + plan por cristales mode
CI / Build Native (push) Has been cancelled

- Add costoCristales/costoOre/costoDwarven fields to calc.js results and
  RunItem/RunIn/RunSummary/HistoryStats records (no DB migration needed —
  items stored as JSON in TEXT column).
- Show 4 explicit columns (C. Cristales / C. Ore / C. Fee / Costo total)
  in all calculator tables: Cristales mode, Presupuesto (simple/maximizado/
  mezcla), and Objetivo. Totales globales now includes a 3-segment
  distribution bar with %.
- Historial: 3 new stats cards + global distribution bar; per-run modal
  shows breakdown + bar; per-item table has 3 sub-columns; CSV export
  extended. CSS: cost-split-bar, cost-split-segment-{cristales,ore,fee},
  cost-legend-dot.
- New 'Plan por cristales' mode (4th): single-grade + quantity input with
  greedy max-ROI/cristal algorithm and shared cost split columns.
  Verificado end-to-end en navegador.
This commit is contained in:
2026-08-20 19:15:42 -04:00
parent 34e524616d
commit d73f946cb2
9 changed files with 637 additions and 100 deletions
@@ -41,6 +41,8 @@
<strong>Presupuesto:</strong> Dado un presupuesto, qué craftear.
&nbsp;|&nbsp;
<strong>Objetivo:</strong> Quiero X shots, cuánto cuesta.
&nbsp;|&nbsp;
<strong>Plan cristales:</strong> Tengo N cristales, dame el mejor plan.
</p>
</div>
<div class="flex-row gap-1">
@@ -56,6 +58,10 @@
@click="modo = 'objetivo'"
:class="modo === 'objetivo' ? 'primary' : 'outline secondary'"
style="font-size:0.75rem;padding:0.3rem 0.6rem">Objetivo</button>
<button type="button"
@click="modo = 'cristales-presupuesto'"
:class="modo === 'cristales-presupuesto' ? 'primary' : 'outline secondary'"
style="font-size:0.75rem;padding:0.3rem 0.6rem">Plan cristales</button>
</div>
</div>
@@ -92,19 +98,22 @@
</header>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cristales disp.</th>
<th class="cell-label text-right">Cristales usados</th>
<th class="cell-label text-right" x-text="t.tipo === 'Soulshot' ? 'Soul Ore nec.' : 'Spirit Ore nec.'"></th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<thead>
<tr>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cristales disp.</th>
<th class="cell-label text-right">Cristales usados</th>
<th class="cell-label text-right" x-text="t.tipo === 'Soulshot' ? 'Soul Ore nec.' : 'Spirit Ore nec.'"></th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven por crafteo">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="f in t.formulas" :key="f.id">
<tr :class="rowClass(t.tipo, f.grado)">
@@ -120,7 +129,10 @@
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.oreNecesario)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.crafteosPosibles)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.shotsObtenidos)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoDwarven)"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.valorVenta)"></td>
<td class="text-mono text-right text-bold"
:class="gananciaClass(calculo(f.tipo, f.grado)?.ganancia)"
@@ -136,7 +148,10 @@
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.oreNecesario)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.crafteosPosibles)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.shotsObtenidos)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.costoTotal)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.costoCristales)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.costoOre)"></td>
<td class="cell-label text-right text-mono text-muted" x-text="fmtAdena(t.totales.costoDwarven)"></td>
<td class="cell-label text-right text-mono text-bold" x-text="fmtAdena(t.totales.costoTotal)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.valorVenta)"></td>
<td class="cell-label text-right text-mono" :class="gananciaClass(t.totales.ganancia)"
x-text="fmtAdena(t.totales.ganancia)"></td>
@@ -153,7 +168,7 @@
</template>
</div>
<article x-show="modo === 'cristales'">
<article x-show="modo === 'cristales'">
<header><h2>Totales globales</h2></header>
<div class="grid-8 text-center">
<div>
@@ -170,12 +185,26 @@
</div>
<div>
<p class="text-xs text-muted mb-2">Shots obtenidos</p>
<p class="text-lg text-mono text-bold" x-text="totalesGlobal().shotsObtenidos"></p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().shotsObtenidos)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2" title="Costo en cristales">C. Cristales</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().costoCristales)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2" title="Costo en ore">C. Ore</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().costoOre)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2" title="Fee Dwarven por crafteo">C. Fee</p>
<p class="text-lg text-mono text-bold text-muted" x-text="fmtAdena(totalesGlobal().costoDwarven)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Costo total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().costoTotal)"></p>
</div>
</div>
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap;align-items:center">
<div>
<p class="text-xs text-muted mb-2">Valor venta</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().valorVenta)"></p>
@@ -187,6 +216,19 @@
x-text="fmtAdena(totalesGlobal().ganancia)"></p>
</div>
</div>
<div class="mt-4" x-show="totalesGlobal().costoTotal > 0">
<p class="text-xs text-muted mb-2">Distribución del costo</p>
<div class="cost-split-bar" style="width:100%;max-width:32rem">
<div class="cost-split-segment-cristales" :style="'width:' + pctC() + '%'" :title="'Cristales: ' + fmtAdena(totalesGlobal().costoCristales) + ' (' + pctC().toFixed(1) + '%)'"></div>
<div class="cost-split-segment-ore" :style="'width:' + pctO() + '%'" :title="'Ore: ' + fmtAdena(totalesGlobal().costoOre) + ' (' + pctO().toFixed(1) + '%)'"></div>
<div class="cost-split-segment-fee" :style="'width:' + pctF() + '%'" :title="'Fee: ' + fmtAdena(totalesGlobal().costoDwarven) + ' (' + pctF().toFixed(1) + '%)'"></div>
</div>
<div class="flex-row gap-3 mt-2" style="font-size:0.75rem;flex-wrap:wrap">
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-cristales"></span> Cristales <span x-text="pctC().toFixed(1) + '%'"></span></span>
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctO().toFixed(1) + '%'"></span></span>
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctF().toFixed(1) + '%'"></span></span>
</div>
</div>
</article>
<article x-show="modo === 'cristales'">
@@ -378,7 +420,10 @@
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
@@ -391,7 +436,10 @@
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(p.costoDwarven)"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
@@ -430,38 +478,44 @@
<template x-if="presupuestoMaximizar && !presupuestoMezclar && planMaximizado().length > 0">
<div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
<table>
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="p in planMaximizado()" :key="p.id">
<tr :class="p.ganancia < 0 ? 'row-loss' : p.ganancia > 0 ? 'row-profit' : ''">
<td class="text-bold" x-text="tipoShort(p.tipo)"></td>
<td class="text-center"><span class="grade-badge" x-text="p.grado"></span></td>
<td class="text-mono text-right" x-text="p.crafteos"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(p.costoDwarven)"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(p.ganancia)"></td>
</tr>
</thead>
<tbody>
<template x-for="p in planMaximizado()" :key="p.id">
<tr :class="p.ganancia < 0 ? 'row-loss' : p.ganancia > 0 ? 'row-profit' : ''">
<td class="text-bold" x-text="tipoShort(p.tipo)"></td>
<td class="text-center"><span class="grade-badge" x-text="p.grado"></span></td>
<td class="text-mono text-right" x-text="p.crafteos"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(p.ganancia)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</tbody>
</table>
</div>
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap">
<div>
@@ -484,40 +538,46 @@
<template x-if="presupuestoMezclar && planMezcla().length > 0">
<div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">%</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
<table>
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">%</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="p in planMezcla()" :key="p.id">
<tr :class="p.ganancia < 0 ? 'row-loss' : p.ganancia > 0 ? 'row-profit' : ''">
<td class="text-bold" x-text="tipoShort(p.tipo)"></td>
<td class="text-center"><span class="grade-badge" x-text="p.grado"></span></td>
<td class="text-mono text-right text-bold" x-text="p.pctAsignado + '%'"></td>
<td class="text-mono text-right" x-text="p.crafteos"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(p.costoDwarven)"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(p.ganancia)"></td>
</tr>
</thead>
<tbody>
<template x-for="p in planMezcla()" :key="p.id">
<tr :class="p.ganancia < 0 ? 'row-loss' : p.ganancia > 0 ? 'row-profit' : ''">
<td class="text-bold" x-text="tipoShort(p.tipo)"></td>
<td class="text-center"><span class="grade-badge" x-text="p.grado"></span></td>
<td class="text-mono text-right text-bold" x-text="p.pctAsignado + '%'"></td>
<td class="text-mono text-right" x-text="p.crafteos"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(p.ganancia)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</tbody>
</table>
</div>
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap">
<div>
@@ -600,7 +660,10 @@
<th class="cell-label text-right">Crafteos nec.</th>
<th class="cell-label text-right">Cristales nec.</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
@@ -618,7 +681,10 @@
<td class="text-mono text-right" x-text="objetivoCrafteos(f)"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoCristales(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoOre(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoCosto(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoCostoCristales(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoCostoOre(f))"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(objetivoCostoDwarven(f))"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(objetivoCosto(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoVenta(f))"></td>
<td class="text-mono text-right text-bold"
:class="objetivoGanancia(f) > 0 ? 'text-success' : objetivoGanancia(f) < 0 ? 'text-error' : ''"
@@ -661,6 +727,130 @@
</div>
</article>
<!-- ===== MODO PLAN POR CRISTALES (maximizar) ===== -->
<article x-show="modo === 'cristales-presupuesto'">
<header>
<h2>Plan por cristales</h2>
<p class="text-sm text-muted">Indicá cuántos cristales tenés de un grado. Te mostramos el plan greedy (max ROI/cristal) que maximiza la ganancia con esos cristales.</p>
</header>
<div class="flex-row gap-4 mb-4" style="flex-wrap:wrap;align-items:flex-end">
<div>
<p class="text-sm text-muted mb-2">Grado del cristal</p>
<div class="flex-row gap-1">
<button type="button"
@click="cristalesPresupuestoGrado = 'D'"
:class="cristalesPresupuestoGrado === 'D' ? 'primary' : 'outline secondary'"
style="font-size:0.75rem;padding:0.3rem 0.6rem"><span class="grade-badge">D</span></button>
<template x-for="g in ['C','B','A','S']" :key="g">
<button type="button"
@click="cristalesPresupuestoGrado = g"
:class="cristalesPresupuestoGrado === g ? 'primary' : 'outline secondary'"
style="font-size:0.75rem;padding:0.3rem 0.6rem"><span class="grade-badge" x-text="g"></span></button>
</template>
</div>
</div>
<label style="max-width:18rem;flex:1">
<span class="text-sm text-muted">Cantidad de cristales</span>
<input type="number" min="0" step="1"
x-model.number="cristalesPresupuestoCantidad"
placeholder="ej. 1000"/>
</label>
<div class="flex-row gap-1" style="align-items:center">
<span class="text-xs text-muted">Tipo:</span>
<button type="button"
@click="presupuestoFiltroTipo = []"
:class="presupuestoFiltroTipo.length === 0 ? 'primary' : 'outline secondary'"
style="font-size:0.7rem;padding:0.2rem 0.5rem">Todos</button>
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
<button type="button"
@click="toggleFiltroTipo(tipo)"
:class="presupuestoFiltroTipo.includes(tipo) ? 'primary' : 'outline secondary'"
style="font-size:0.7rem;padding:0.2rem 0.5rem"
x-text="tipoShort(tipo)"></button>
</template>
</div>
</div>
<template x-if="cristalesPresupuestoCantidad > 0 && planPorCristales().length > 0">
<div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales usados</th>
<th class="cell-label text-right">Ore nec.</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo total</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="p in planPorCristales()" :key="p.id">
<tr :class="p.ganancia < 0 ? 'row-loss' : p.ganancia > 0 ? 'row-profit' : ''">
<td class="text-bold" x-text="tipoShort(p.tipo)"></td>
<td class="text-center"><span class="grade-badge" x-text="p.grado"></span></td>
<td class="text-mono text-right" x-text="p.crafteos"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(p.costoDwarven)"></td>
<td class="text-mono text-right text-bold" x-text="fmtAdena(p.costo)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(p.ganancia)"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap">
<div>
<p class="text-xs text-muted mb-2">Cristales usados</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(planTotalesCristales().cristales)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Cristales sobrantes</p>
<p class="text-lg text-mono text-bold"
x-text="fmtAdena(Math.max(0, cristalesPresupuestoCantidad - planTotalesCristales().cristales))"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Costo total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(planTotalesCristales().costo)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Ganancia esperada</p>
<p class="text-lg text-mono text-bold"
:class="planTotalesCristales().ganancia > 0 ? 'text-success' : planTotalesCristales().ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(planTotalesCristales().ganancia)"></p>
</div>
</div>
</div>
</template>
<template x-if="cristalesPresupuestoCantidad > 0 && planPorCristales().length === 0">
<p class="text-sm text-muted text-center p-4">
Ninguna receta rentable para cristales grado <span class="grade-badge" x-text="cristalesPresupuestoGrado"></span> con los filtros actuales.
<button type="button" @click="limpiarFiltros()" class="link-action text-xs">Limpiar filtros</button>
</p>
</template>
<template x-if="cristalesPresupuestoCantidad <= 0">
<p class="text-sm text-muted text-center p-4">Ingresá una cantidad de cristales para ver el plan.</p>
</template>
</article>
<!-- ===== NG VS D-GRADE ===== -->
<article x-show="modo === 'objetivo' || modo === 'cristales'">
<header>
@@ -734,6 +924,8 @@
presupuestoFiltroGrado: [],
presupuestoMaximizar: false,
presupuestoMezclar: false,
cristalesPresupuestoGrado: 'D',
cristalesPresupuestoCantidad: 0,
presupuestoMix: { D: 20, C: 20, B: 20, A: 20, S: 20 },
presupuestoMixTipo: { 'Soulshot': 34, 'Spiritshot': 33, 'Blessed Spiritshot': 33 },
rankingFiltro: null,
@@ -765,6 +957,21 @@
return window.calc.sumarTotales(this.tablas().flatMap(t => t.calculos));
},
pctC() {
const t = this.totalesGlobal();
return t.costoTotal > 0 ? (t.costoCristales * 100.0) / t.costoTotal : 0;
},
pctO() {
const t = this.totalesGlobal();
return t.costoTotal > 0 ? (t.costoOre * 100.0) / t.costoTotal : 0;
},
pctF() {
const t = this.totalesGlobal();
return t.costoTotal > 0 ? (t.costoDwarven * 100.0) / t.costoTotal : 0;
},
canSave() {
return this.totalesGlobal().cristalesUsados > 0 && this.saveStatus !== 'saving';
},
@@ -894,14 +1101,31 @@
},
objetivoCosto(f) {
const s = this.store().state;
if (!s) return 0;
return this.objetivoCostoCristales(f) + this.objetivoCostoOre(f) + this.objetivoCostoDwarven(f);
},
objetivoCostoCristales(f) {
const s = this.store().state;
if (!s) return 0;
const cristales = this.objetivoCristales(f);
const ore = this.objetivoOre(f);
const precioCristal = s.insumos.cristales[f.grado] || 0;
return cristales * precioCristal;
},
objetivoCostoOre(f) {
const s = this.store().state;
if (!s) return 0;
const ore = this.objetivoOre(f);
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const fee = this.objetivoCrafteos(f) * (s.insumos.dwarvenFeePerCraft || 0);
return cristales * precioCristal + ore * precioOre + fee;
return ore * precioOre;
},
objetivoCostoDwarven(f) {
const s = this.store().state;
if (!s) return 0;
return this.objetivoCrafteos(f) * (s.insumos.dwarvenFeePerCraft || 0);
},
objetivoVenta(f) {
@@ -941,6 +1165,9 @@
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const orePorCraft = f.soulOreReq != null ? f.soulOreReq : f.spiritOreReq != null ? f.spiritOreReq : 0;
const costoPorBatch = f.cristalesReq * precioCristal + orePorCraft * precioOre + (s.insumos.dwarvenFeePerCraft || 0);
const costoCristalesPorBatch = f.cristalesReq * precioCristal;
const costoOrePorBatch = orePorCraft * precioOre;
const costoDwarvenPorBatch = (s.insumos.dwarvenFeePerCraft || 0);
const ventaPorBatch = f.shotsObtenidos * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const gananciaPorBatch = ventaPorBatch - costoPorBatch;
const roi = f.cristalesReq > 0 ? Math.round(gananciaPorBatch / f.cristalesReq) : 0;
@@ -954,6 +1181,9 @@
return {
id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia, roi,
costoCristales: crafteos * costoCristalesPorBatch,
costoOre: crafteos * costoOrePorBatch,
costoDwarven: crafteos * costoDwarvenPorBatch,
};
});
},
@@ -992,9 +1222,15 @@
const shots = crafteos * f.shotsObtenidos;
const venta = shots * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const ganancia = venta - costo;
const precioCristal = s.insumos.cristales[f.grado] || 0;
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const costoCristales = crafteos * f.cristalesReq * precioCristal;
const costoOre = crafteos * orePorCraft * precioOre;
const costoDwarven = crafteos * (s.insumos.dwarvenFeePerCraft || 0);
plan.push({
id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia,
costoCristales, costoOre, costoDwarven,
});
restante -= costo;
}
@@ -1047,9 +1283,13 @@
const shots = crafteos * f.shotsObtenidos;
const venta = crafteos * ventaPorBatch;
const ganancia = venta - costo;
const costoCristales = crafteos * f.cristalesReq * precioCristal;
const costoOre = crafteos * orePorCraft * precioOre;
const costoDwarven = crafteos * (s.insumos.dwarvenFeePerCraft || 0);
plan.push({
id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia,
costoCristales, costoOre, costoDwarven,
pctTipo: tipoPct, pctGrado: gradoPct, pctAsignado: Math.round(jointFraction * 1000) / 10,
});
}
@@ -1152,7 +1392,80 @@
costo: acc.costo + r.costo,
venta: acc.venta + r.venta,
ganancia: acc.ganancia + r.ganancia,
}), { crafteos: 0, shots: 0, cristales: 0, ore: 0, costo: 0, venta: 0, ganancia: 0 });
costoCristales: acc.costoCristales + (r.costoCristales || 0),
costoOre: acc.costoOre + (r.costoOre || 0),
costoDwarven: acc.costoDwarven + (r.costoDwarven || 0),
}), { crafteos: 0, shots: 0, cristales: 0, ore: 0, costo: 0, venta: 0, ganancia: 0,
costoCristales: 0, costoOre: 0, costoDwarven: 0 });
},
planPorCristales() {
const s = this.store().state;
if (!s || this.cristalesPresupuestoCantidad <= 0) return [];
const grado = this.cristalesPresupuestoGrado;
const formulas = [...s.formulas]
.filter(f => f.grado === grado && f.cristalesReq > 0 && f.shotsObtenidos > 0)
.filter(f => this.presupuestoFiltroTipo.length === 0 || this.presupuestoFiltroTipo.includes(f.tipo));
if (formulas.length === 0) return [];
const ranked = formulas
.map(f => {
const precioCristal = s.insumos.cristales[f.grado] || 0;
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const orePorCraft = f.soulOreReq != null ? f.soulOreReq : f.spiritOreReq != null ? f.spiritOreReq : 0;
const costoPorBatch = f.cristalesReq * precioCristal + orePorCraft * precioOre + (s.insumos.dwarvenFeePerCraft || 0);
const ventaPorBatch = f.shotsObtenidos * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const gananciaPorBatch = ventaPorBatch - costoPorBatch;
const roi = f.cristalesReq > 0 ? Math.round(gananciaPorBatch / f.cristalesReq) : 0;
return { f, costoPorBatch, gananciaPorBatch, roi };
})
.filter(x => x.gananciaPorBatch > 0)
.sort((a, b) => b.roi - a.roi);
if (ranked.length === 0) return [];
let restantes = this.cristalesPresupuestoCantidad;
const plan = [];
for (const { f, costoPorBatch } of ranked) {
if (restantes <= 0) break;
const crafteos = Math.floor(restantes / f.cristalesReq);
if (crafteos <= 0) continue;
const cristales = crafteos * f.cristalesReq;
const orePorCraft = f.soulOreReq != null ? f.soulOreReq : f.spiritOreReq != null ? f.spiritOreReq : 0;
const ore = crafteos * orePorCraft;
const shots = crafteos * f.shotsObtenidos;
const precioCristal = s.insumos.cristales[f.grado] || 0;
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const costoCristales = cristales * precioCristal;
const costoOre = ore * precioOre;
const costoDwarven = crafteos * (s.insumos.dwarvenFeePerCraft || 0);
const costo = crafteos * costoPorBatch;
const venta = shots * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const ganancia = venta - costo;
plan.push({
id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia,
costoCristales, costoOre, costoDwarven,
});
restantes -= cristales;
}
return plan;
},
planTotalesCristales() {
const p = this.planPorCristales();
return p.reduce((acc, r) => ({
cristales: acc.cristales + r.cristales,
crafteos: acc.crafteos + r.crafteos,
ore: acc.ore + r.ore,
shots: acc.shots + r.shots,
costo: acc.costo + r.costo,
venta: acc.venta + r.venta,
ganancia: acc.ganancia + r.ganancia,
costoCristales: acc.costoCristales + r.costoCristales,
costoOre: acc.costoOre + r.costoOre,
costoDwarven: acc.costoDwarven + r.costoDwarven,
}), { cristales: 0, crafteos: 0, ore: 0, shots: 0, costo: 0, venta: 0, ganancia: 0,
costoCristales: 0, costoOre: 0, costoDwarven: 0 });
},
toggleFiltroTipo(tipo) {
@@ -1221,6 +1534,9 @@
oreNecesario: c.oreNecesario,
crafteosPosibles: c.crafteosPosibles,
shotsObtenidos: c.shotsObtenidos,
costoCristales: c.costoCristales,
costoOre: c.costoOre,
costoDwarven: c.costoDwarven,
costoTotal: c.costoTotal,
valorVenta: c.valorVenta,
ganancia: c.ganancia,
@@ -1238,6 +1554,9 @@
totalShots: tg.shotsObtenidos,
totalCristalesUsed: tg.cristalesUsados,
totalOreUsed: tg.oreNecesario,
totalCostCristales: tg.costoCristales,
totalCostOre: tg.costoOre,
totalCostDwarven: tg.costoDwarven,
items,
snapshot: { insumos: s.insumos, formulas: s.formulas },
});
@@ -73,6 +73,43 @@
</div>
</div>
<div class="grid-4 mb-6" style="gap:0.5rem">
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Cristales</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(stats.totalCostCristales)"></p>
<p class="text-xs text-muted" x-text="fmtPct(stats.costCristalesPct)"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Ore</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(stats.totalCostOre)"></p>
<p class="text-xs text-muted" x-text="fmtPct(stats.costOrePct)"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Fee</p>
<p class="text-md text-mono text-bold text-muted" x-text="fmtAdena(stats.totalCostDwarven)"></p>
<p class="text-xs text-muted" x-text="fmtPct(stats.costDwarvenPct)"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Total invertido</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(stats.totalCost)"></p>
<p class="text-xs text-muted">costo acumulado</p>
</div>
</div>
<div class="cost-split-section mb-6" x-show="stats.totalCost > 0">
<p class="text-xs uppercase tracking-wide text-muted mb-2">Distribución global del costo</p>
<div class="cost-split-bar" style="width:100%;max-width:32rem;height:1.25rem">
<div class="cost-split-segment-cristales" :style="'width:' + stats.costCristalesPct + '%'" :title="'Cristales: ' + fmtAdena(stats.totalCostCristales) + ' (' + stats.costCristalesPct.toFixed(1) + '%)'"></div>
<div class="cost-split-segment-ore" :style="'width:' + stats.costOrePct + '%'" :title="'Ore: ' + fmtAdena(stats.totalCostOre) + ' (' + stats.costOrePct.toFixed(1) + '%)'"></div>
<div class="cost-split-segment-fee" :style="'width:' + stats.costDwarvenPct + '%'" :title="'Fee: ' + fmtAdena(stats.totalCostDwarven) + ' (' + stats.costDwarvenPct.toFixed(1) + '%)'"></div>
</div>
<div class="flex-row gap-3 mt-2" style="font-size:0.75rem;flex-wrap:wrap">
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-cristales"></span> Cristales <span x-text="stats.costCristalesPct.toFixed(1) + '%'"></span></span>
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="stats.costOrePct.toFixed(1) + '%'"></span></span>
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="stats.costDwarvenPct.toFixed(1) + '%'"></span></span>
</div>
</div>
<div class="sparkline-container" x-show="runs.length > 0">
<p class="text-xs uppercase tracking-wide text-muted mb-2">Tendencia reciente</p>
<div class="sparkline">
@@ -284,6 +321,37 @@
</div>
</div>
<div class="grid-4 mb-6" style="gap:0.5rem">
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Cristales</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(costCristalesTotal())"></p>
<p class="text-xs text-muted" x-text="fmtPct(costCristalesPct())"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Ore</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(costOreTotal())"></p>
<p class="text-xs text-muted" x-text="fmtPct(costOrePct())"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">C. Fee</p>
<p class="text-md text-mono text-bold text-muted" x-text="fmtAdena(costDwarvenTotal())"></p>
<p class="text-xs text-muted" x-text="fmtPct(costDwarvenPct())"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(modalDetails.totalCost)"></p>
<p class="text-xs text-muted">esta corrida</p>
</div>
</div>
<div class="cost-split-section mb-6" x-show="modalDetails.totalCost > 0">
<div class="cost-split-bar" style="width:100%;max-width:32rem;height:1rem">
<div class="cost-split-segment-cristales" :style="'width:' + costCristalesPct() + '%'"></div>
<div class="cost-split-segment-ore" :style="'width:' + costOrePct() + '%'"></div>
<div class="cost-split-segment-fee" :style="'width:' + costDwarvenPct() + '%'"></div>
</div>
</div>
<template x-if="modalDetails.snapshot">
<div class="snapshot-section">
<h3 class="text-sm text-bold mb-3">
@@ -352,6 +420,9 @@
<th class="cell-label text-right">Ore</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right" title="Costo en cristales">C. Cristales</th>
<th class="cell-label text-right" title="Costo en ore">C. Ore</th>
<th class="cell-label text-right" title="Fee Dwarven">C. Fee</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
@@ -368,6 +439,9 @@
<td class="text-mono text-right" x-text="item.oreNecesario"></td>
<td class="text-mono text-right" x-text="item.crafteosPosibles"></td>
<td class="text-mono text-right" x-text="item.shotsObtenidos"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.costoCristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.costoOre)"></td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(item.costoDwarven)"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.valorVenta)"></td>
<td class="text-mono text-right text-bold"
@@ -588,6 +662,36 @@
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
},
costCristalesTotal() {
if (!this.modalDetails?.items) return 0;
return this.modalDetails.items.reduce((s, i) => s + (i.costoCristales || 0), 0);
},
costOreTotal() {
if (!this.modalDetails?.items) return 0;
return this.modalDetails.items.reduce((s, i) => s + (i.costoOre || 0), 0);
},
costDwarvenTotal() {
if (!this.modalDetails?.items) return 0;
return this.modalDetails.items.reduce((s, i) => s + (i.costoDwarven || 0), 0);
},
costCristalesPct() {
const total = this.modalDetails?.totalCost || 0;
return total > 0 ? Math.round((this.costCristalesTotal() * 1000) / total) / 10 : 0;
},
costOrePct() {
const total = this.modalDetails?.totalCost || 0;
return total > 0 ? Math.round((this.costOreTotal() * 1000) / total) / 10 : 0;
},
costDwarvenPct() {
const total = this.modalDetails?.totalCost || 0;
return total > 0 ? Math.round((this.costDwarvenTotal() * 1000) / total) / 10 : 0;
},
snapshotCristalesUsed() {
const items = this.modalDetails?.items || [];
return new Set(items.map(i => i.grado));
@@ -676,7 +780,7 @@
},
exportCSV() {
const headers = ['Fecha', 'Label', 'Costo', 'Venta', 'Ganancia', 'Margen%', 'Costo/shot', 'Shots', 'Cristales', 'Ore', 'Items'];
const headers = ['Fecha', 'Label', 'Costo', 'Venta', 'Ganancia', 'Margen%', 'Costo/shot', 'Shots', 'Cristales', 'Ore', 'C. Cristales', 'C. Ore', 'C. Fee', 'Items'];
const rows = this.runs.map(r => [
this.fmtDateTime(r.createdAt),
r.label || '',
@@ -688,6 +792,9 @@
r.totalShots,
r.totalCristalesUsed,
r.totalOreUsed,
r.costCristales,
r.costOre,
r.costDwarven,
r.itemCount,
]);
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');