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
@@ -6,7 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -75,7 +74,9 @@ public class HistoryService {
if (runs.isEmpty()) { if (runs.isEmpty()) {
return new HistoryStats( return new HistoryStats(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0.0, 0.0, 0.0,
0, 0, 0, 0.0, 0, 0,
null, null, 0, 0, 0, Map.of()); null, null, 0, 0, 0, Map.of());
} }
@@ -85,6 +86,9 @@ public class HistoryService {
long totalShots = 0; long totalShots = 0;
long totalCristalesUsed = 0; long totalCristalesUsed = 0;
long totalOreUsed = 0; long totalOreUsed = 0;
long totalCostCristales = 0;
long totalCostOre = 0;
long totalCostDwarven = 0;
int totalItems = 0; int totalItems = 0;
ProductionRun best = runs.get(0); ProductionRun best = runs.get(0);
ProductionRun worst = runs.get(0); ProductionRun worst = runs.get(0);
@@ -103,6 +107,9 @@ public class HistoryService {
for (RunItem item : items) { for (RunItem item : items) {
tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum); tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum);
totalCostCristales += item.costoCristales();
totalCostOre += item.costoOre();
totalCostDwarven += item.costoDwarven();
} }
if (r.totalProfit > best.totalProfit) best = r; if (r.totalProfit > best.totalProfit) best = r;
@@ -125,6 +132,10 @@ public class HistoryService {
long avgCostPerShot = totalShots > 0 ? totalCost / totalShots : 0; long avgCostPerShot = totalShots > 0 ? totalCost / totalShots : 0;
long avgProfitPerShot = totalShots > 0 ? totalProfit / totalShots : 0; long avgProfitPerShot = totalShots > 0 ? totalProfit / totalShots : 0;
double pctC = totalCost > 0 ? (double) totalCostCristales * 100.0 / totalCost : 0.0;
double pctO = totalCost > 0 ? (double) totalCostOre * 100.0 / totalCost : 0.0;
double pctD = totalCost > 0 ? (double) totalCostDwarven * 100.0 / totalCost : 0.0;
List<RunItem> bestItems = parseItems(best.itemsJson); List<RunItem> bestItems = parseItems(best.itemsJson);
List<RunItem> worstItems = parseItems(worst.itemsJson); List<RunItem> worstItems = parseItems(worst.itemsJson);
@@ -136,6 +147,12 @@ public class HistoryService {
totalShots, totalShots,
totalCristalesUsed, totalCristalesUsed,
totalOreUsed, totalOreUsed,
totalCostCristales,
totalCostOre,
totalCostDwarven,
round1(pctC),
round1(pctO),
round1(pctD),
totalProfit / n, totalProfit / n,
totalCost / n, totalCost / n,
totalSale / n, totalSale / n,
@@ -150,6 +167,10 @@ public class HistoryService {
tiposBreakdown); tiposBreakdown);
} }
private static double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
private List<RunItem> parseItems(String json) { private List<RunItem> parseItems(String json) {
if (json == null || json.isBlank()) return List.of(); if (json == null || json.isBlank()) return List.of();
try { try {
@@ -14,6 +14,12 @@ public record HistoryStats(
long totalShots, long totalShots,
long totalCristalesUsed, long totalCristalesUsed,
long totalOreUsed, long totalOreUsed,
long totalCostCristales,
long totalCostOre,
long totalCostDwarven,
double costCristalesPct,
double costOrePct,
double costDwarvenPct,
long avgProfit, long avgProfit,
long avgCost, long avgCost,
long avgSale, long avgSale,
@@ -13,6 +13,9 @@ public record RunIn(
long totalShots, long totalShots,
long totalCristalesUsed, long totalCristalesUsed,
long totalOreUsed, long totalOreUsed,
long totalCostCristales,
long totalCostOre,
long totalCostDwarven,
List<RunItem> items, List<RunItem> items,
RunSnapshot snapshot) { RunSnapshot snapshot) {
} }
@@ -11,6 +11,9 @@ public record RunItem(
int oreNecesario, int oreNecesario,
int crafteosPosibles, int crafteosPosibles,
int shotsObtenidos, int shotsObtenidos,
long costoCristales,
long costoOre,
long costoDwarven,
long costoTotal, long costoTotal,
long valorVenta, long valorVenta,
long ganancia) { long ganancia) {
@@ -17,6 +17,9 @@ public record RunSummary(
long totalShots, long totalShots,
long totalCristalesUsed, long totalCristalesUsed,
long totalOreUsed, long totalOreUsed,
long costCristales,
long costOre,
long costDwarven,
double profitMarginPct, double profitMarginPct,
long costPerShot, long costPerShot,
long profitPerShot, long profitPerShot,
@@ -25,6 +28,10 @@ public record RunSummary(
boolean wasProfitable) { boolean wasProfitable) {
public static RunSummary from(ProductionRun r, List<RunItem> items) { public static RunSummary from(ProductionRun r, List<RunItem> items) {
return from(r, items, sumItems(items));
}
public static RunSummary from(ProductionRun r, List<RunItem> items, ItemCostSplit itemCosts) {
List<String> tipos = items.stream() List<String> tipos = items.stream()
.map(i -> i.tipo()) .map(i -> i.tipo())
.distinct() .distinct()
@@ -45,6 +52,9 @@ public record RunSummary(
r.totalShots, r.totalShots,
r.totalCristalesUsed, r.totalCristalesUsed,
r.totalOreUsed, r.totalOreUsed,
itemCosts.cristales(),
itemCosts.ore(),
itemCosts.dwarven(),
Math.round(marginPct * 10.0) / 10.0, Math.round(marginPct * 10.0) / 10.0,
cps, cps,
pps, pps,
@@ -52,4 +62,16 @@ public record RunSummary(
tipos, tipos,
r.totalProfit > 0); r.totalProfit > 0);
} }
private static ItemCostSplit sumItems(List<RunItem> items) {
long c = 0, o = 0, d = 0;
for (RunItem i : items) {
c += i.costoCristales();
o += i.costoOre();
d += i.costoDwarven();
}
return new ItemCostSplit(c, o, d);
}
public record ItemCostSplit(long cristales, long ore, long dwarven) {}
} }
@@ -691,3 +691,46 @@ header {
.card-expired { opacity: 0.7; } .card-expired { opacity: 0.7; }
.card-urgent { border-left: 3px solid #dc2626; } .card-urgent { border-left: 3px solid #dc2626; }
/* ============================================
COST SPLIT BAR — distribución de costo (cristales / ore / fee)
============================================ */
.cost-split-bar {
display: flex;
height: 0.75rem;
background: var(--pico-muted-border-color);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--pico-muted-border-color);
}
.cost-split-segment-cristales {
background: #6366f1;
height: 100%;
transition: width 0.2s ease-out;
}
.cost-split-segment-ore {
background: #f59e0b;
height: 100%;
transition: width 0.2s ease-out;
}
.cost-split-segment-fee {
background: #94a3b8;
height: 100%;
transition: width 0.2s ease-out;
}
.cost-legend-dot {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 2px;
vertical-align: middle;
margin-right: 0.25rem;
}
.cost-split-section {
/* container for the cost split bar + legend */
}
@@ -14,6 +14,9 @@ window.calc = {
oreLabel, oreLabel,
crafteosPosibles: 0, crafteosPosibles: 0,
shotsObtenidos: 0, shotsObtenidos: 0,
costoCristales: 0,
costoOre: 0,
costoDwarven: 0,
costoTotal: 0, costoTotal: 0,
valorVenta: 0, valorVenta: 0,
ganancia: 0, ganancia: 0,
@@ -33,7 +36,9 @@ window.calc = {
const precioVenta = insumos.venta[formula.tipo]?.[formula.grado] || 0; const precioVenta = insumos.venta[formula.tipo]?.[formula.grado] || 0;
const dwarvenFee = crafteosPosibles * (insumos.dwarvenFeePerCraft || 0); const dwarvenFee = crafteosPosibles * (insumos.dwarvenFeePerCraft || 0);
const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre + dwarvenFee; const costoCristales = cristalesUsados * precioCristal;
const costoOre = oreNecesario * precioOre;
const costoTotal = costoCristales + costoOre + dwarvenFee;
const valorVenta = shotsObtenidos * precioVenta; const valorVenta = shotsObtenidos * precioVenta;
const ganancia = valorVenta - costoTotal; const ganancia = valorVenta - costoTotal;
@@ -46,6 +51,9 @@ window.calc = {
oreLabel, oreLabel,
crafteosPosibles, crafteosPosibles,
shotsObtenidos, shotsObtenidos,
costoCristales,
costoOre,
costoDwarven: dwarvenFee,
costoTotal, costoTotal,
valorVenta, valorVenta,
ganancia, ganancia,
@@ -59,12 +67,17 @@ window.calc = {
oreNecesario: acc.oreNecesario + c.oreNecesario, oreNecesario: acc.oreNecesario + c.oreNecesario,
crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles, crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles,
shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos, shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos,
costoCristales: acc.costoCristales + c.costoCristales,
costoOre: acc.costoOre + c.costoOre,
costoDwarven: acc.costoDwarven + c.costoDwarven,
costoTotal: acc.costoTotal + c.costoTotal, costoTotal: acc.costoTotal + c.costoTotal,
valorVenta: acc.valorVenta + c.valorVenta, valorVenta: acc.valorVenta + c.valorVenta,
ganancia: acc.ganancia + c.ganancia, ganancia: acc.ganancia + c.ganancia,
}), { }), {
cristalesUsados: 0, oreNecesario: 0, crafteosPosibles: 0, cristalesUsados: 0, oreNecesario: 0, crafteosPosibles: 0,
shotsObtenidos: 0, costoTotal: 0, valorVenta: 0, ganancia: 0, shotsObtenidos: 0,
costoCristales: 0, costoOre: 0, costoDwarven: 0,
costoTotal: 0, valorVenta: 0, ganancia: 0,
}); });
}, },
}; };
@@ -41,6 +41,8 @@
<strong>Presupuesto:</strong> Dado un presupuesto, qué craftear. <strong>Presupuesto:</strong> Dado un presupuesto, qué craftear.
&nbsp;|&nbsp; &nbsp;|&nbsp;
<strong>Objetivo:</strong> Quiero X shots, cuánto cuesta. <strong>Objetivo:</strong> Quiero X shots, cuánto cuesta.
&nbsp;|&nbsp;
<strong>Plan cristales:</strong> Tengo N cristales, dame el mejor plan.
</p> </p>
</div> </div>
<div class="flex-row gap-1"> <div class="flex-row gap-1">
@@ -56,6 +58,10 @@
@click="modo = 'objetivo'" @click="modo = 'objetivo'"
:class="modo === 'objetivo' ? 'primary' : 'outline secondary'" :class="modo === 'objetivo' ? 'primary' : 'outline secondary'"
style="font-size:0.75rem;padding:0.3rem 0.6rem">Objetivo</button> 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>
</div> </div>
@@ -92,7 +98,7 @@
</header> </header>
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="cell-label text-center">Grado</th> <th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cristales disp.</th> <th class="cell-label text-right">Cristales disp.</th>
@@ -100,7 +106,10 @@
<th class="cell-label text-right" x-text="t.tipo === 'Soulshot' ? 'Soul Ore nec.' : 'Spirit Ore nec.'"></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">Crafteos</th>
<th class="cell-label text-right">Shots</th> <th class="cell-label text-right">Shots</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 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">Venta</th>
<th class="cell-label text-right">Ganancia</th> <th class="cell-label text-right">Ganancia</th>
</tr> </tr>
@@ -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)?.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)?.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)?.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" x-text="fmtAdena(calculo(f.tipo, f.grado)?.valorVenta)"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
:class="gananciaClass(calculo(f.tipo, f.grado)?.ganancia)" :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.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.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.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" x-text="fmtAdena(t.totales.valorVenta)"></td>
<td class="cell-label text-right text-mono" :class="gananciaClass(t.totales.ganancia)" <td class="cell-label text-right text-mono" :class="gananciaClass(t.totales.ganancia)"
x-text="fmtAdena(t.totales.ganancia)"></td> x-text="fmtAdena(t.totales.ganancia)"></td>
@@ -153,7 +168,7 @@
</template> </template>
</div> </div>
<article x-show="modo === 'cristales'"> <article x-show="modo === 'cristales'">
<header><h2>Totales globales</h2></header> <header><h2>Totales globales</h2></header>
<div class="grid-8 text-center"> <div class="grid-8 text-center">
<div> <div>
@@ -170,12 +185,26 @@
</div> </div>
<div> <div>
<p class="text-xs text-muted mb-2">Shots obtenidos</p> <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>
<div> <div>
<p class="text-xs text-muted mb-2">Costo total</p> <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> <p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().costoTotal)"></p>
</div> </div>
</div>
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap;align-items:center">
<div> <div>
<p class="text-xs text-muted mb-2">Valor venta</p> <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> <p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().valorVenta)"></p>
@@ -187,6 +216,19 @@
x-text="fmtAdena(totalesGlobal().ganancia)"></p> x-text="fmtAdena(totalesGlobal().ganancia)"></p>
</div> </div>
</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>
<article x-show="modo === 'cristales'"> <article x-show="modo === 'cristales'">
@@ -378,7 +420,10 @@
<th class="cell-label text-right">Shots</th> <th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th> <th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore 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">Venta</th>
<th class="cell-label text-right">Ganancia</th> <th class="cell-label text-right">Ganancia</th>
</tr> </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.shots)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.cristales)"></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.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" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''" :class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
@@ -430,7 +478,7 @@
<template x-if="presupuestoMaximizar && !presupuestoMezclar && planMaximizado().length > 0"> <template x-if="presupuestoMaximizar && !presupuestoMezclar && planMaximizado().length > 0">
<div> <div>
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="cell-label text-left">Tipo</th> <th class="cell-label text-left">Tipo</th>
@@ -439,7 +487,10 @@
<th class="cell-label text-right">Shots</th> <th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th> <th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore 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">Venta</th>
<th class="cell-label text-right">Ganancia</th> <th class="cell-label text-right">Ganancia</th>
</tr> </tr>
@@ -453,7 +504,10 @@
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></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.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></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" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''" :class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
@@ -484,7 +538,7 @@
<template x-if="presupuestoMezclar && planMezcla().length > 0"> <template x-if="presupuestoMezclar && planMezcla().length > 0">
<div> <div>
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="cell-label text-left">Tipo</th> <th class="cell-label text-left">Tipo</th>
@@ -494,7 +548,10 @@
<th class="cell-label text-right">Shots</th> <th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Cristales</th> <th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore 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">Venta</th>
<th class="cell-label text-right">Ganancia</th> <th class="cell-label text-right">Ganancia</th>
</tr> </tr>
@@ -509,7 +566,10 @@
<td class="text-mono text-right" x-text="fmtAdena(p.shots)"></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.cristales)"></td>
<td class="text-mono text-right" x-text="fmtAdena(p.ore)"></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" x-text="fmtAdena(p.venta)"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
:class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''" :class="p.ganancia > 0 ? 'text-success' : p.ganancia < 0 ? 'text-error' : ''"
@@ -600,7 +660,10 @@
<th class="cell-label text-right">Crafteos nec.</th> <th class="cell-label text-right">Crafteos nec.</th>
<th class="cell-label text-right">Cristales 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">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">Venta</th>
<th class="cell-label text-right">Ganancia</th> <th class="cell-label text-right">Ganancia</th>
</tr> </tr>
@@ -618,7 +681,10 @@
<td class="text-mono text-right" x-text="objetivoCrafteos(f)"></td> <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(objetivoCristales(f))"></td>
<td class="text-mono text-right" x-text="fmtAdena(objetivoOre(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" x-text="fmtAdena(objetivoVenta(f))"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
:class="objetivoGanancia(f) > 0 ? 'text-success' : objetivoGanancia(f) < 0 ? 'text-error' : ''" :class="objetivoGanancia(f) > 0 ? 'text-success' : objetivoGanancia(f) < 0 ? 'text-error' : ''"
@@ -661,6 +727,130 @@
</div> </div>
</article> </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 ===== --> <!-- ===== NG VS D-GRADE ===== -->
<article x-show="modo === 'objetivo' || modo === 'cristales'"> <article x-show="modo === 'objetivo' || modo === 'cristales'">
<header> <header>
@@ -734,6 +924,8 @@
presupuestoFiltroGrado: [], presupuestoFiltroGrado: [],
presupuestoMaximizar: false, presupuestoMaximizar: false,
presupuestoMezclar: false, presupuestoMezclar: false,
cristalesPresupuestoGrado: 'D',
cristalesPresupuestoCantidad: 0,
presupuestoMix: { D: 20, C: 20, B: 20, A: 20, S: 20 }, presupuestoMix: { D: 20, C: 20, B: 20, A: 20, S: 20 },
presupuestoMixTipo: { 'Soulshot': 34, 'Spiritshot': 33, 'Blessed Spiritshot': 33 }, presupuestoMixTipo: { 'Soulshot': 34, 'Spiritshot': 33, 'Blessed Spiritshot': 33 },
rankingFiltro: null, rankingFiltro: null,
@@ -765,6 +957,21 @@
return window.calc.sumarTotales(this.tablas().flatMap(t => t.calculos)); 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() { canSave() {
return this.totalesGlobal().cristalesUsados > 0 && this.saveStatus !== 'saving'; return this.totalesGlobal().cristalesUsados > 0 && this.saveStatus !== 'saving';
}, },
@@ -894,14 +1101,31 @@
}, },
objetivoCosto(f) { 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; const s = this.store().state;
if (!s) return 0; if (!s) return 0;
const cristales = this.objetivoCristales(f); const cristales = this.objetivoCristales(f);
const ore = this.objetivoOre(f);
const precioCristal = s.insumos.cristales[f.grado] || 0; 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 precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre;
const fee = this.objetivoCrafteos(f) * (s.insumos.dwarvenFeePerCraft || 0); return ore * precioOre;
return cristales * precioCristal + ore * precioOre + fee; },
objetivoCostoDwarven(f) {
const s = this.store().state;
if (!s) return 0;
return this.objetivoCrafteos(f) * (s.insumos.dwarvenFeePerCraft || 0);
}, },
objetivoVenta(f) { objetivoVenta(f) {
@@ -941,6 +1165,9 @@
const precioOre = f.soulOreReq != null ? s.insumos.soulOre : s.insumos.spiritOre; 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 orePorCraft = f.soulOreReq != null ? f.soulOreReq : f.spiritOreReq != null ? f.spiritOreReq : 0;
const costoPorBatch = f.cristalesReq * precioCristal + orePorCraft * precioOre + (s.insumos.dwarvenFeePerCraft || 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 ventaPorBatch = f.shotsObtenidos * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const gananciaPorBatch = ventaPorBatch - costoPorBatch; const gananciaPorBatch = ventaPorBatch - costoPorBatch;
const roi = f.cristalesReq > 0 ? Math.round(gananciaPorBatch / f.cristalesReq) : 0; const roi = f.cristalesReq > 0 ? Math.round(gananciaPorBatch / f.cristalesReq) : 0;
@@ -954,6 +1181,9 @@
return { return {
id: f.id, tipo: f.tipo, grado: f.grado, id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia, roi, 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 shots = crafteos * f.shotsObtenidos;
const venta = shots * (s.insumos.venta[f.tipo]?.[f.grado] || 0); const venta = shots * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
const ganancia = venta - costo; 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({ plan.push({
id: f.id, tipo: f.tipo, grado: f.grado, id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia, crafteos, cristales, ore, shots, costo, venta, ganancia,
costoCristales, costoOre, costoDwarven,
}); });
restante -= costo; restante -= costo;
} }
@@ -1047,9 +1283,13 @@
const shots = crafteos * f.shotsObtenidos; const shots = crafteos * f.shotsObtenidos;
const venta = crafteos * ventaPorBatch; const venta = crafteos * ventaPorBatch;
const ganancia = venta - costo; const ganancia = venta - costo;
const costoCristales = crafteos * f.cristalesReq * precioCristal;
const costoOre = crafteos * orePorCraft * precioOre;
const costoDwarven = crafteos * (s.insumos.dwarvenFeePerCraft || 0);
plan.push({ plan.push({
id: f.id, tipo: f.tipo, grado: f.grado, id: f.id, tipo: f.tipo, grado: f.grado,
crafteos, cristales, ore, shots, costo, venta, ganancia, crafteos, cristales, ore, shots, costo, venta, ganancia,
costoCristales, costoOre, costoDwarven,
pctTipo: tipoPct, pctGrado: gradoPct, pctAsignado: Math.round(jointFraction * 1000) / 10, pctTipo: tipoPct, pctGrado: gradoPct, pctAsignado: Math.round(jointFraction * 1000) / 10,
}); });
} }
@@ -1152,7 +1392,80 @@
costo: acc.costo + r.costo, costo: acc.costo + r.costo,
venta: acc.venta + r.venta, venta: acc.venta + r.venta,
ganancia: acc.ganancia + r.ganancia, 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) { toggleFiltroTipo(tipo) {
@@ -1221,6 +1534,9 @@
oreNecesario: c.oreNecesario, oreNecesario: c.oreNecesario,
crafteosPosibles: c.crafteosPosibles, crafteosPosibles: c.crafteosPosibles,
shotsObtenidos: c.shotsObtenidos, shotsObtenidos: c.shotsObtenidos,
costoCristales: c.costoCristales,
costoOre: c.costoOre,
costoDwarven: c.costoDwarven,
costoTotal: c.costoTotal, costoTotal: c.costoTotal,
valorVenta: c.valorVenta, valorVenta: c.valorVenta,
ganancia: c.ganancia, ganancia: c.ganancia,
@@ -1238,6 +1554,9 @@
totalShots: tg.shotsObtenidos, totalShots: tg.shotsObtenidos,
totalCristalesUsed: tg.cristalesUsados, totalCristalesUsed: tg.cristalesUsados,
totalOreUsed: tg.oreNecesario, totalOreUsed: tg.oreNecesario,
totalCostCristales: tg.costoCristales,
totalCostOre: tg.costoOre,
totalCostDwarven: tg.costoDwarven,
items, items,
snapshot: { insumos: s.insumos, formulas: s.formulas }, snapshot: { insumos: s.insumos, formulas: s.formulas },
}); });
@@ -73,6 +73,43 @@
</div> </div>
</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"> <div class="sparkline-container" x-show="runs.length > 0">
<p class="text-xs uppercase tracking-wide text-muted mb-2">Tendencia reciente</p> <p class="text-xs uppercase tracking-wide text-muted mb-2">Tendencia reciente</p>
<div class="sparkline"> <div class="sparkline">
@@ -284,6 +321,37 @@
</div> </div>
</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"> <template x-if="modalDetails.snapshot">
<div class="snapshot-section"> <div class="snapshot-section">
<h3 class="text-sm text-bold mb-3"> <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">Ore</th>
<th class="cell-label text-right">Crafteos</th> <th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</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">Costo</th>
<th class="cell-label text-right">Venta</th> <th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</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.oreNecesario"></td>
<td class="text-mono text-right" x-text="item.crafteosPosibles"></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="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.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.valorVenta)"></td> <td class="text-mono text-right" x-text="fmtAdena(item.valorVenta)"></td>
<td class="text-mono text-right text-bold" <td class="text-mono text-right text-bold"
@@ -588,6 +662,36 @@
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0); 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() { snapshotCristalesUsed() {
const items = this.modalDetails?.items || []; const items = this.modalDetails?.items || [];
return new Set(items.map(i => i.grado)); return new Set(items.map(i => i.grado));
@@ -676,7 +780,7 @@
}, },
exportCSV() { 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 => [ const rows = this.runs.map(r => [
this.fmtDateTime(r.createdAt), this.fmtDateTime(r.createdAt),
r.label || '', r.label || '',
@@ -688,6 +792,9 @@
r.totalShots, r.totalShots,
r.totalCristalesUsed, r.totalCristalesUsed,
r.totalOreUsed, r.totalOreUsed,
r.costCristales,
r.costOre,
r.costDwarven,
r.itemCount, r.itemCount,
]); ]);
const csv = [headers, ...rows].map(row => row.join(',')).join('\n'); const csv = [headers, ...rows].map(row => row.join(',')).join('\n');