feat(calculadora): cost split breakdown + plan por cristales mode
CI / Build Native (push) Has been cancelled
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:
@@ -6,7 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -75,7 +74,9 @@ public class HistoryService {
|
||||
|
||||
if (runs.isEmpty()) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -85,6 +86,9 @@ public class HistoryService {
|
||||
long totalShots = 0;
|
||||
long totalCristalesUsed = 0;
|
||||
long totalOreUsed = 0;
|
||||
long totalCostCristales = 0;
|
||||
long totalCostOre = 0;
|
||||
long totalCostDwarven = 0;
|
||||
int totalItems = 0;
|
||||
ProductionRun best = runs.get(0);
|
||||
ProductionRun worst = runs.get(0);
|
||||
@@ -103,6 +107,9 @@ public class HistoryService {
|
||||
|
||||
for (RunItem item : items) {
|
||||
tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum);
|
||||
totalCostCristales += item.costoCristales();
|
||||
totalCostOre += item.costoOre();
|
||||
totalCostDwarven += item.costoDwarven();
|
||||
}
|
||||
|
||||
if (r.totalProfit > best.totalProfit) best = r;
|
||||
@@ -125,6 +132,10 @@ public class HistoryService {
|
||||
long avgCostPerShot = totalShots > 0 ? totalCost / 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> worstItems = parseItems(worst.itemsJson);
|
||||
|
||||
@@ -136,6 +147,12 @@ public class HistoryService {
|
||||
totalShots,
|
||||
totalCristalesUsed,
|
||||
totalOreUsed,
|
||||
totalCostCristales,
|
||||
totalCostOre,
|
||||
totalCostDwarven,
|
||||
round1(pctC),
|
||||
round1(pctO),
|
||||
round1(pctD),
|
||||
totalProfit / n,
|
||||
totalCost / n,
|
||||
totalSale / n,
|
||||
@@ -150,6 +167,10 @@ public class HistoryService {
|
||||
tiposBreakdown);
|
||||
}
|
||||
|
||||
private static double round1(double v) {
|
||||
return Math.round(v * 10.0) / 10.0;
|
||||
}
|
||||
|
||||
private List<RunItem> parseItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,12 @@ public record HistoryStats(
|
||||
long totalShots,
|
||||
long totalCristalesUsed,
|
||||
long totalOreUsed,
|
||||
long totalCostCristales,
|
||||
long totalCostOre,
|
||||
long totalCostDwarven,
|
||||
double costCristalesPct,
|
||||
double costOrePct,
|
||||
double costDwarvenPct,
|
||||
long avgProfit,
|
||||
long avgCost,
|
||||
long avgSale,
|
||||
|
||||
@@ -13,6 +13,9 @@ public record RunIn(
|
||||
long totalShots,
|
||||
long totalCristalesUsed,
|
||||
long totalOreUsed,
|
||||
long totalCostCristales,
|
||||
long totalCostOre,
|
||||
long totalCostDwarven,
|
||||
List<RunItem> items,
|
||||
RunSnapshot snapshot) {
|
||||
}
|
||||
@@ -11,6 +11,9 @@ public record RunItem(
|
||||
int oreNecesario,
|
||||
int crafteosPosibles,
|
||||
int shotsObtenidos,
|
||||
long costoCristales,
|
||||
long costoOre,
|
||||
long costoDwarven,
|
||||
long costoTotal,
|
||||
long valorVenta,
|
||||
long ganancia) {
|
||||
|
||||
@@ -17,6 +17,9 @@ public record RunSummary(
|
||||
long totalShots,
|
||||
long totalCristalesUsed,
|
||||
long totalOreUsed,
|
||||
long costCristales,
|
||||
long costOre,
|
||||
long costDwarven,
|
||||
double profitMarginPct,
|
||||
long costPerShot,
|
||||
long profitPerShot,
|
||||
@@ -25,6 +28,10 @@ public record RunSummary(
|
||||
boolean wasProfitable) {
|
||||
|
||||
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()
|
||||
.map(i -> i.tipo())
|
||||
.distinct()
|
||||
@@ -45,6 +52,9 @@ public record RunSummary(
|
||||
r.totalShots,
|
||||
r.totalCristalesUsed,
|
||||
r.totalOreUsed,
|
||||
itemCosts.cristales(),
|
||||
itemCosts.ore(),
|
||||
itemCosts.dwarven(),
|
||||
Math.round(marginPct * 10.0) / 10.0,
|
||||
cps,
|
||||
pps,
|
||||
@@ -52,4 +62,16 @@ public record RunSummary(
|
||||
tipos,
|
||||
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-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,
|
||||
crafteosPosibles: 0,
|
||||
shotsObtenidos: 0,
|
||||
costoCristales: 0,
|
||||
costoOre: 0,
|
||||
costoDwarven: 0,
|
||||
costoTotal: 0,
|
||||
valorVenta: 0,
|
||||
ganancia: 0,
|
||||
@@ -33,7 +36,9 @@ window.calc = {
|
||||
const precioVenta = insumos.venta[formula.tipo]?.[formula.grado] || 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 ganancia = valorVenta - costoTotal;
|
||||
|
||||
@@ -46,6 +51,9 @@ window.calc = {
|
||||
oreLabel,
|
||||
crafteosPosibles,
|
||||
shotsObtenidos,
|
||||
costoCristales,
|
||||
costoOre,
|
||||
costoDwarven: dwarvenFee,
|
||||
costoTotal,
|
||||
valorVenta,
|
||||
ganancia,
|
||||
@@ -59,12 +67,17 @@ window.calc = {
|
||||
oreNecesario: acc.oreNecesario + c.oreNecesario,
|
||||
crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles,
|
||||
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,
|
||||
valorVenta: acc.valorVenta + c.valorVenta,
|
||||
ganancia: acc.ganancia + c.ganancia,
|
||||
}), {
|
||||
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>Objetivo:</strong> Quiero X shots, cuánto cuesta.
|
||||
|
|
||||
<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');
|
||||
|
||||
Reference in New Issue
Block a user