CI / Build Native (push) Successful in 6m52s
- Full visual overhaul: amber phosphor CRT aesthetic (gold on near-black) - JetBrains Mono font family applied site-wide - Terminal window style: LED amber dot on every panel header - Header with 'user@shot-crafter:~$' title bar - Login/register as terminal window cards - Responsive: grid-5/grid-8 collapse on mobile, tables scroll horizontally - Tabs with horizontal scroll on mobile - Article headers wrap cleanly on small screens - Custom scrollbars, selection color, focus states in amber theme - New favicon in amber style - term-section.html reusable partial created
1672 lines
87 KiB
HTML
1672 lines
87 KiB
HTML
<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Calculadora — Calculadora de Craft de Shots</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap">
|
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
|
</head>
|
|
<body x-data="appShell()">
|
|
|
|
{#include partials/header.html /}
|
|
{#include partials/tab-bar.html /}
|
|
|
|
<main class="container" x-data="calculadoraSection()">
|
|
|
|
<article style="margin-bottom:1rem">
|
|
<header style="margin-bottom:0.5rem">
|
|
<h2 style="margin:0;font-size:0.95rem">Precio cristales</h2>
|
|
<p class="text-xs text-muted" style="margin:0">Precio unitario en adena (cambia el profit en tiempo real)</p>
|
|
</header>
|
|
<div class="grid-5">
|
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
|
<label>
|
|
<span class="text-sm text-muted" x-text="'Cristal ' + grado"></span>
|
|
<input type="number" min="0" step="1"
|
|
x-model.number="store().state.insumos.cristales[grado]"
|
|
@input="store().scheduleSave()"/>
|
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.cristales[grado])"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
</article>
|
|
|
|
<div class="flex-row gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
|
<div class="bg-warning" style="flex:1;min-width:200px">
|
|
<p class="text-sm" style="margin:0">
|
|
<strong>Cristales:</strong> Ingresá cristales disp. y mirá profit en tiempo real.
|
|
|
|
|
<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">
|
|
<button type="button"
|
|
@click="modo = 'cristales'"
|
|
:class="modo === 'cristales' ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.75rem;padding:0.3rem 0.6rem">Cristales</button>
|
|
<button type="button"
|
|
@click="modo = 'presupuesto'"
|
|
:class="modo === 'presupuesto' ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.75rem;padding:0.3rem 0.6rem">Presupuesto</button>
|
|
<button type="button"
|
|
@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>
|
|
|
|
<article x-show="modo === 'cristales'">
|
|
<div class="flex-row">
|
|
<label class="flex-1">
|
|
<span class="text-sm text-muted mb-2">Label (opcional)</span>
|
|
<input type="text"
|
|
x-model="runLabel"
|
|
maxlength="100"
|
|
placeholder="p.ej. Sesion lunes, crafteo nocturno..."/>
|
|
</label>
|
|
<div class="flex-row" style="align-items:flex-end">
|
|
<button type="button"
|
|
@click="saveRun()"
|
|
:disabled="!canSave()"
|
|
:title="totalesGlobal().cristalesUsados === 0 ? 'Ingresa cristales disponibles para poder guardar' : 'Guardar esta produccion en el historial'"
|
|
class="primary">
|
|
<span x-show="saveStatus !== 'saving'">Guardar produccion</span>
|
|
<span x-show="saveStatus === 'saving'">Guardando…</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div x-show="saveStatus === 'saved'" class="success-msg mt-2">Produccion guardada. La podes ver en la pestana Historial.</div>
|
|
<div x-show="saveStatus === 'error'" class="error-msg mt-2" x-text="saveError || 'Error al guardar'"></div>
|
|
</article>
|
|
|
|
<div x-show="modo === 'cristales'">
|
|
<template x-for="t in tablas()" :key="t.tipo">
|
|
<article>
|
|
<header>
|
|
<h2 x-text="t.tipo"></h2>
|
|
<p class="text-sm text-muted">Crafteo de <span x-text="t.tipo"></span>. Ingresa solo los cristales disponibles por grado.</p>
|
|
</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" 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)">
|
|
<td class="text-center">
|
|
<span class="grade-badge" x-text="f.grado"></span>
|
|
</td>
|
|
<td>
|
|
<input type="number" min="0" step="1"
|
|
x-model.number="store().state.disponibles[f.tipo][f.grado]"
|
|
@input="store().scheduleSave()"/>
|
|
</td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.cristalesUsados)"></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)?.shotsObtenidos)"></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)"
|
|
x-text="fmtAdena(calculo(f.tipo, f.grado)?.ganancia)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td class="cell-label text-center">Σ</td>
|
|
<td class="cell-label text-right text-muted">—</td>
|
|
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.cristalesUsados)"></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.shotsObtenidos)"></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>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
<template x-if="hasWarning(t)">
|
|
<p class="text-sm" style="color:#92400e;background:#fef3c7;border:1px solid #fcd34d;padding:0.5rem 0.75rem;border-radius:4px;margin-top:0.5rem">
|
|
⚠️ Una o mas filas tienen cristales requeridos en 0. Completa la receta en la pestana Formulas.
|
|
</p>
|
|
</template>
|
|
</article>
|
|
</template>
|
|
</div>
|
|
|
|
<article x-show="modo === 'cristales'">
|
|
<header><h2>Totales globales</h2></header>
|
|
<div class="grid-8 text-center">
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Cristales usados</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().cristalesUsados)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ore necesaria</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().oreNecesario)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Crafteos</p>
|
|
<p class="text-lg text-mono text-bold" x-text="totalesGlobal().crafteosPosibles"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Shots obtenidos</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>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ganancia</p>
|
|
<p class="text-lg text-mono text-bold"
|
|
:class="gananciaClass(totalesGlobal().ganancia)"
|
|
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'">
|
|
<header class="flex-row justify-between" style="align-items:center">
|
|
<div>
|
|
<h2>Ranking de rentabilidad</h2>
|
|
<p class="text-sm text-muted">Todas las recetas ordenadas por mejor ROI. Filtra por tipo para comparar.</p>
|
|
</div>
|
|
<div class="flex-row gap-1">
|
|
<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>
|
|
</header>
|
|
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="cell-label text-center">#</th>
|
|
<th class="cell-label text-left">Tipo</th>
|
|
<th class="cell-label text-center">Grado</th>
|
|
<th class="cell-label text-right sortable" @click="rankingOrdenarPor('ganancia')">
|
|
Ganancia <span class="sort-icon" x-text="rankingSortIcon('ganancia')"></span>
|
|
</th>
|
|
<th class="cell-label text-right sortable" @click="rankingOrdenarPor('roi')">
|
|
ROI/cristal <span class="sort-icon" x-text="rankingSortIcon('roi')"></span>
|
|
</th>
|
|
<th class="cell-label text-right sortable" @click="rankingOrdenarPor('margenPct')">
|
|
Margen% <span class="sort-icon" x-text="rankingSortIcon('margenPct')"></span>
|
|
</th>
|
|
<th class="cell-label text-right sortable" @click="rankingOrdenarPor('shotsObtenidos')">
|
|
Shots <span class="sort-icon" x-text="rankingSortIcon('shotsObtenidos')"></span>
|
|
</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-center">vs Mercado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="(r, idx) in rankingFiltrado()" :key="r.id">
|
|
<tr :class="r.ganancia < 0 ? 'row-loss' : r.ganancia > 0 ? 'row-profit' : ''">
|
|
<td class="text-center text-muted text-sm" x-text="idx + 1"></td>
|
|
<td class="text-bold" x-text="tipoShort(r.tipo)"></td>
|
|
<td class="text-center">
|
|
<span class="grade-badge" x-text="r.grado"></span>
|
|
</td>
|
|
<td class="text-mono text-right text-bold"
|
|
:class="r.ganancia > 0 ? 'text-success' : r.ganancia < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(r.ganancia)"></td>
|
|
<td class="text-mono text-right"
|
|
:class="r.roi > 0 ? 'text-success' : r.roi < 0 ? 'text-error' : ''"
|
|
x-text="r.roi > 0 ? '+' + fmtAdena(r.roi) : r.roi < 0 ? fmtAdena(r.roi) : '0'"></td>
|
|
<td class="text-mono text-right"
|
|
:class="r.margenPct > 0 ? 'text-success' : r.margenPct < 0 ? 'text-error' : ''"
|
|
x-text="fmtPct(r.margenPct)"></td>
|
|
<td class="text-mono text-right" x-text="r.shotsObtenidos"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(r.costoCristales)"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(r.costoOre)"></td>
|
|
<td class="text-mono text-right text-muted" x-text="fmtAdena(r.costoDwarven)"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(r.costo)"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(r.venta)"></td>
|
|
<td class="text-center">
|
|
<template x-if="r.precioMercado > 0">
|
|
<span class="badge"
|
|
:class="r.costoPorShot < r.precioMercado ? 'badge-craft' : 'badge-buy'"
|
|
:title="'Costo/shot: ' + fmtAdena(r.costoPorShot) + ' vs Mercado: ' + fmtAdena(r.precioMercado)">
|
|
<span x-text="r.costoPorShot < r.precioMercado ? 'Craft' : 'Buy'"></span>
|
|
</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p x-show="rankingFiltrado().length === 0" class="text-sm text-muted text-center p-4">
|
|
No hay recetas visibles con el filtro actual.
|
|
</p>
|
|
</article>
|
|
|
|
<!-- ===== MODO PRESUPUESTO ===== -->
|
|
<article x-show="modo === 'presupuesto'">
|
|
<header class="flex-row justify-between" style="align-items:flex-start;flex-wrap:wrap;gap:0.5rem">
|
|
<div>
|
|
<h2>Plan por presupuesto</h2>
|
|
<p class="text-sm text-muted">Ingresá tu presupuesto en adena. Por defecto ves qué podés craftear por grado. Activá 'Maximizar margen' para usar el algoritmo greedy y obtener el máximo profit.</p>
|
|
</div>
|
|
<div class="flex-row gap-2" style="flex-wrap:wrap">
|
|
<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 class="flex-row gap-1" style="align-items:center">
|
|
<span class="text-xs text-muted">Cristal:</span>
|
|
<button type="button"
|
|
@click="presupuestoFiltroGrado = []"
|
|
:class="presupuestoFiltroGrado.length === 0 ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem">Todos</button>
|
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
|
<button type="button"
|
|
@click="toggleFiltroGrado(grado)"
|
|
:class="presupuestoFiltroGrado.includes(grado) ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem"
|
|
x-text="grado"></button>
|
|
</template>
|
|
</div>
|
|
<div class="flex-row gap-1" style="align-items:center">
|
|
<button type="button"
|
|
@click="presupuestoMaximizar = !presupuestoMaximizar; presupuestoMezclar = false"
|
|
:class="presupuestoMaximizar ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem">Maximizar margen</button>
|
|
<button type="button"
|
|
@click="presupuestoMezclar = !presupuestoMezclar; presupuestoMaximizar = false"
|
|
:class="presupuestoMezclar ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem">Mezclar</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div style="max-width:28rem;margin-bottom:1.5rem">
|
|
<label>
|
|
<span class="text-sm text-muted">Presupuesto disponible (adena)</span>
|
|
<input type="number" min="0" step="1000"
|
|
x-model.number="presupuestoAdena"
|
|
placeholder="ej. 10000000"/>
|
|
</label>
|
|
</div>
|
|
|
|
<template x-if="presupuestoMezclar">
|
|
<div style="margin-bottom:1.5rem">
|
|
<div class="grid-5" style="margin-bottom:0.5rem">
|
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
|
<label>
|
|
<span class="text-sm text-muted" x-text="'Grado ' + grado"></span>
|
|
<input type="range" min="0" max="100" step="5"
|
|
:value="presupuestoMix[grado]"
|
|
@input="onMixChange(grado, $event.target.value)"/>
|
|
<span class="text-xs text-mono text-bold" x-text="presupuestoMix[grado] + '%'"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
<div class="grid-3" style="margin-top:0.5rem">
|
|
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
|
|
<label>
|
|
<span class="text-sm text-muted" x-text="tipo"></span>
|
|
<input type="range" min="0" max="100" step="5"
|
|
:value="presupuestoMixTipo[tipo]"
|
|
@input="onMixTipoChange(tipo, $event.target.value)"/>
|
|
<span class="text-xs text-mono text-bold" x-text="presupuestoMixTipo[tipo] + '%'"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
<div class="flex-row gap-2" style="font-size:0.75rem">
|
|
<span class="text-muted">Total: <span x-text="Object.values(presupuestoMix).reduce((a,b) => a+b, 0) + '%'"></span></span>
|
|
<button type="button" @click="resetMix()" class="link-action">Reset (20% c/u)</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!presupuestoMaximizar && !presupuestoMezclar && planPorPresupuesto().length > 0">
|
|
<div>
|
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
|
<div style="margin-bottom:1.5rem">
|
|
<p class="text-sm text-bold mb-2">
|
|
<span class="grade-badge" x-text="grado"></span>
|
|
</p>
|
|
<template x-if="planPorPresupuesto().filter(r => r.grado === grado).length > 0">
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="cell-label text-left">Tipo</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 planPorPresupuesto().filter(r => r.grado === grado)" :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-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>
|
|
</template>
|
|
<template x-if="planPorPresupuesto().filter(r => r.grado === grado).length === 0">
|
|
<p class="text-xs text-muted text-center p-3" style="background:var(--pico-card-sectioning-background-color);border-radius:4px">
|
|
Sin recetas para este grado.
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<div class="flex-row gap-4 mt-4" style="flex-wrap:wrap">
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Presupuesto usado</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(planTotalesPresupuesto().costo)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ganancia esperada</p>
|
|
<p class="text-lg text-mono text-bold"
|
|
:class="planTotalesPresupuesto().ganancia > 0 ? 'text-success' : planTotalesPresupuesto().ganancia < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(planTotalesPresupuesto().ganancia)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Adena sobrante</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(Math.max(0, presupuestoAdena - planTotalesPresupuesto().costo))"></p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4" x-show="planTotalesPresupuesto().costo > 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:' + pctCOf(planTotalesPresupuesto()) + '%'" :title="'Cristales: ' + fmtAdena(planTotalesPresupuesto().costoCristales) + ' (' + pctCOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-ore" :style="'width:' + pctOOf(planTotalesPresupuesto()) + '%'" :title="'Ore: ' + fmtAdena(planTotalesPresupuesto().costoOre) + ' (' + pctOOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-fee" :style="'width:' + pctFOf(planTotalesPresupuesto()) + '%'" :title="'Fee: ' + fmtAdena(planTotalesPresupuesto().costoDwarven) + ' (' + pctFOf(planTotalesPresupuesto()).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="pctCOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctOOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctFOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<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" 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>
|
|
</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">Presupuesto usado</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(planTotalesPresupuesto().costo)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ganancia esperada</p>
|
|
<p class="text-lg text-mono text-bold"
|
|
:class="planTotalesPresupuesto().ganancia > 0 ? 'text-success' : planTotalesPresupuesto().ganancia < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(planTotalesPresupuesto().ganancia)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Adena sobrante</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(Math.max(0, presupuestoAdena - planTotalesPresupuesto().costo))"></p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4" x-show="planTotalesPresupuesto().costo > 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:' + pctCOf(planTotalesPresupuesto()) + '%'" :title="'Cristales: ' + fmtAdena(planTotalesPresupuesto().costoCristales) + ' (' + pctCOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-ore" :style="'width:' + pctOOf(planTotalesPresupuesto()) + '%'" :title="'Ore: ' + fmtAdena(planTotalesPresupuesto().costoOre) + ' (' + pctOOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-fee" :style="'width:' + pctFOf(planTotalesPresupuesto()) + '%'" :title="'Fee: ' + fmtAdena(planTotalesPresupuesto().costoDwarven) + ' (' + pctFOf(planTotalesPresupuesto()).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="pctCOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctOOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctFOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<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" 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>
|
|
</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">Presupuesto usado</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(planTotalesPresupuesto().costo)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ganancia esperada</p>
|
|
<p class="text-lg text-mono text-bold"
|
|
:class="planTotalesPresupuesto().ganancia > 0 ? 'text-success' : planTotalesPresupuesto().ganancia < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(planTotalesPresupuesto().ganancia)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Adena sobrante</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(Math.max(0, presupuestoAdena - planTotalesPresupuesto().costo))"></p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4" x-show="planTotalesPresupuesto().costo > 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:' + pctCOf(planTotalesPresupuesto()) + '%'" :title="'Cristales: ' + fmtAdena(planTotalesPresupuesto().costoCristales) + ' (' + pctCOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-ore" :style="'width:' + pctOOf(planTotalesPresupuesto()) + '%'" :title="'Ore: ' + fmtAdena(planTotalesPresupuesto().costoOre) + ' (' + pctOOf(planTotalesPresupuesto()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-fee" :style="'width:' + pctFOf(planTotalesPresupuesto()) + '%'" :title="'Fee: ' + fmtAdena(planTotalesPresupuesto().costoDwarven) + ' (' + pctFOf(planTotalesPresupuesto()).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="pctCOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctOOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctFOf(planTotalesPresupuesto()).toFixed(1) + '%'"></span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="presupuestoAdena > 0 && planActual().length === 0">
|
|
<p class="text-sm text-muted text-center p-4">
|
|
Ninguna receta rentable con los filtros actuales.
|
|
<button type="button" @click="limpiarFiltros()" class="link-action text-xs">Limpiar filtros</button>
|
|
</p>
|
|
</template>
|
|
<template x-if="presupuestoAdena <= 0">
|
|
<p class="text-sm text-muted text-center p-4">Ingresá un presupuesto para ver el plan.</p>
|
|
</template>
|
|
</article>
|
|
|
|
<!-- ===== MODO OBJETIVO ===== -->
|
|
<article x-show="modo === 'objetivo'">
|
|
<header class="flex-row justify-between" style="align-items:flex-start;flex-wrap:wrap;gap:0.5rem">
|
|
<div>
|
|
<h2>Plan por objetivo de shots</h2>
|
|
<p class="text-sm text-muted">Indicá cuántos shots querés por tipo y grado. Se calcula el costo total y los insumos necesarios.</p>
|
|
</div>
|
|
<div class="flex-row gap-2" style="flex-wrap:wrap">
|
|
<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 class="flex-row gap-1" style="align-items:center">
|
|
<span class="text-xs text-muted">Cristal:</span>
|
|
<button type="button"
|
|
@click="presupuestoFiltroGrado = []"
|
|
:class="presupuestoFiltroGrado.length === 0 ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem">Todos</button>
|
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
|
<button type="button"
|
|
@click="toggleFiltroGrado(grado)"
|
|
:class="presupuestoFiltroGrado.includes(grado) ? 'primary' : 'outline secondary'"
|
|
style="font-size:0.7rem;padding:0.2rem 0.5rem"
|
|
x-text="grado"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<template x-for="t in tiposFiltrados()" :key="t">
|
|
<div style="margin-bottom:1.5rem">
|
|
<p class="text-sm text-bold mb-2" x-text="tipoShort(t) + ' — ' + t"></p>
|
|
<template x-if="formulasPorTipoFiltradas(t).length > 0">
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="cell-label text-center">Grado</th>
|
|
<th class="cell-label text-right">Shots deseados</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">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="f in formulasPorTipoFiltradas(t)" :key="f.id">
|
|
<tr :class="objetivoGanancia(f) < 0 ? 'row-loss' : objetivoGanancia(f) > 0 ? 'row-profit' : ''">
|
|
<td class="text-center"><span class="grade-badge" x-text="f.grado"></span></td>
|
|
<td>
|
|
<input type="number" min="0" step="1"
|
|
x-model.number="objetivoShots[f.id]"
|
|
placeholder="0"
|
|
style="width:6rem;text-align:right"/>
|
|
</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(objetivoOre(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' : ''"
|
|
x-text="fmtAdena(objetivoGanancia(f))"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
<template x-if="formulasPorTipoFiltradas(t).length === 0">
|
|
<p class="text-xs text-muted text-center p-3" style="background:var(--pico-card-sectioning-background-color);border-radius:4px">
|
|
No hay recetas que coincidan con los filtros.
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<div style="border-top:1px solid var(--pico-muted-border-color);padding-top:1rem">
|
|
<div class="flex-row gap-4" style="flex-wrap:wrap">
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Total costo</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(objetivoTotales().costo)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Total venta</p>
|
|
<p class="text-lg text-mono text-bold" x-text="fmtAdena(objetivoTotales().venta)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Ganancia neta</p>
|
|
<p class="text-lg text-mono text-bold"
|
|
:class="objetivoTotales().ganancia > 0 ? 'text-success' : objetivoTotales().ganancia < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(objetivoTotales().ganancia)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted mb-2">Total shots</p>
|
|
<p class="text-lg text-mono text-bold" x-text="objetivoTotales().shots"></p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4" x-show="objetivoTotales().costo > 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:' + pctCOf(objetivoTotales()) + '%'" :title="'Cristales: ' + fmtAdena(objetivoTotales().costoCristales) + ' (' + pctCOf(objetivoTotales()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-ore" :style="'width:' + pctOOf(objetivoTotales()) + '%'" :title="'Ore: ' + fmtAdena(objetivoTotales().costoOre) + ' (' + pctOOf(objetivoTotales()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-fee" :style="'width:' + pctFOf(objetivoTotales()) + '%'" :title="'Fee: ' + fmtAdena(objetivoTotales().costoDwarven) + ' (' + pctFOf(objetivoTotales()).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="pctCOf(objetivoTotales()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctOOf(objetivoTotales()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctFOf(objetivoTotales()).toFixed(1) + '%'"></span></span>
|
|
</div>
|
|
</div>
|
|
</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 class="mt-4" x-show="planTotalesCristales().costo > 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:' + pctCOf(planTotalesCristales()) + '%'" :title="'Cristales: ' + fmtAdena(planTotalesCristales().costoCristales) + ' (' + pctCOf(planTotalesCristales()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-ore" :style="'width:' + pctOOf(planTotalesCristales()) + '%'" :title="'Ore: ' + fmtAdena(planTotalesCristales().costoOre) + ' (' + pctOOf(planTotalesCristales()).toFixed(1) + '%)'"></div>
|
|
<div class="cost-split-segment-fee" :style="'width:' + pctFOf(planTotalesCristales()) + '%'" :title="'Fee: ' + fmtAdena(planTotalesCristales().costoDwarven) + ' (' + pctFOf(planTotalesCristales()).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="pctCOf(planTotalesCristales()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-ore"></span> Ore <span x-text="pctOOf(planTotalesCristales()).toFixed(1) + '%'"></span></span>
|
|
<span class="text-mono"><span class="cost-legend-dot cost-split-segment-fee"></span> Fee <span x-text="pctFOf(planTotalesCristales()).toFixed(1) + '%'"></span></span>
|
|
</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>
|
|
<h2>NG vs D-grade — Comparativa de compra directa</h2>
|
|
<p class="text-sm text-muted">Compara el precio NG (compra directa en NPC) vs craftear D-grade. Útil para saber si vale la pena craftear o directamente comprar NG.</p>
|
|
</header>
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="cell-label text-left">Tipo</th>
|
|
<th class="cell-label text-right">NG (precio NPC)</th>
|
|
<th class="cell-label text-right">D-grade craft/shot</th>
|
|
<th class="cell-label text-right">D-grade venta/shot</th>
|
|
<th class="cell-label text-right">Ganancia D</th>
|
|
<th class="cell-label text-center">Recomendación</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
|
|
<tr>
|
|
<td class="text-bold" x-text="tipoShort(tipo)"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(ngVentaPorShot(tipo))"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(costoPorShotDgrade(tipo))"></td>
|
|
<td class="text-mono text-right" x-text="fmtAdena(ventaPorShotDgrade(tipo))"></td>
|
|
<td class="text-mono text-right text-bold"
|
|
:class="gananciaDgrade(tipo) > 0 ? 'text-success' : gananciaDgrade(tipo) < 0 ? 'text-error' : ''"
|
|
x-text="fmtAdena(gananciaDgrade(tipo))"></td>
|
|
<td class="text-center">
|
|
<span class="badge" :class="costoPorShotDgrade(tipo) < ngVentaPorShot(tipo) ? 'badge-craft' : 'badge-buy'"
|
|
:title="'Craft D: ' + fmtAdena(costoPorShotDgrade(tipo)) + '/shot vs NG: ' + fmtAdena(ngVentaPorShot(tipo)) + '/shot'">
|
|
<span x-text="costoPorShotDgrade(tipo) < ngVentaPorShot(tipo) ? 'Craft D' : 'Buy NG'"></span>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-xs text-muted mt-2">
|
|
* D-grade craft/shot: costo total de craftear D-grade dividido por shots obtenidos por batch.
|
|
Configurá el precio NG en la pestaña Insumos.
|
|
</p>
|
|
</article>
|
|
|
|
</main>
|
|
|
|
<footer class="container">
|
|
<p class="text-center text-xs text-muted">
|
|
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
|
|
</p>
|
|
</footer>
|
|
|
|
<script src="/static/js/defaults.js?v=5"></script>
|
|
<script src="/static/js/api.js?v=5"></script>
|
|
<script src="/static/js/calc.js?v=5"></script>
|
|
<script src="/static/js/format.js?v=5"></script>
|
|
<script src="/static/js/app.js?v=5"></script>
|
|
<script defer src="/static/js/alpine.min.js?v=5"></script>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('calculadoraSection', () => ({
|
|
store() { return Alpine.store('app'); },
|
|
runLabel: '',
|
|
saveStatus: 'idle',
|
|
saveError: null,
|
|
modo: 'cristales',
|
|
presupuestoAdena: 0,
|
|
presupuestoFiltroTipo: [],
|
|
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,
|
|
rankingOrden: 'roi',
|
|
rankingOrdenDir: 'desc',
|
|
|
|
calculo(tipo, grado) {
|
|
const s = this.store().state;
|
|
if (!s) return null;
|
|
const f = s.formulas.find(x => x.tipo === tipo && x.grado === grado);
|
|
if (!f) return null;
|
|
return window.calc.calcularFila(f, s.disponibles[tipo]?.[grado] || 0, s.insumos);
|
|
},
|
|
|
|
tablas() {
|
|
const s = this.store().state;
|
|
if (!s) return [];
|
|
return ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'].map(tipo => {
|
|
const formulasTipo = s.formulas.filter(f => f.tipo === tipo);
|
|
const calculos = formulasTipo.map(f =>
|
|
window.calc.calcularFila(f, s.disponibles[tipo]?.[f.grado] || 0, s.insumos)
|
|
);
|
|
const totales = window.calc.sumarTotales(calculos);
|
|
return { tipo, formulas: formulasTipo, calculos, totales };
|
|
});
|
|
},
|
|
|
|
totalesGlobal() {
|
|
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;
|
|
},
|
|
|
|
pctCOf(t) {
|
|
const total = t.costoTotal != null ? t.costoTotal : t.costo;
|
|
return total > 0 ? (t.costoCristales * 100.0) / total : 0;
|
|
},
|
|
|
|
pctOOf(t) {
|
|
const total = t.costoTotal != null ? t.costoTotal : t.costo;
|
|
return total > 0 ? (t.costoOre * 100.0) / total : 0;
|
|
},
|
|
|
|
pctFOf(t) {
|
|
const total = t.costoTotal != null ? t.costoTotal : t.costo;
|
|
return total > 0 ? (t.costoDwarven * 100.0) / total : 0;
|
|
},
|
|
|
|
canSave() {
|
|
return this.totalesGlobal().cristalesUsados > 0 && this.saveStatus !== 'saving';
|
|
},
|
|
|
|
rowClass(tipo, grado) {
|
|
const c = this.calculo(tipo, grado);
|
|
return c?.warning ? 'bg-warning' : '';
|
|
},
|
|
|
|
hasWarning(tabla) {
|
|
return tabla.formulas.some(f => {
|
|
const c = this.calculo(f.tipo, f.grado);
|
|
return c?.warning || f.cristalesReq === 0;
|
|
});
|
|
},
|
|
|
|
gananciaClass(v) {
|
|
if (v > 0) return 'text-success';
|
|
if (v < 0) return 'text-error';
|
|
return '';
|
|
},
|
|
|
|
tipoShort(tipo) {
|
|
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
|
|
},
|
|
|
|
rankingFiltrado() {
|
|
const s = this.store().state;
|
|
if (!s) return [];
|
|
const todos = s.formulas.map(f => {
|
|
const c = this.calculo(f.tipo, f.grado);
|
|
const roi = c.cristalesUsados > 0 ? Math.round(c.ganancia / c.cristalesUsados) : 0;
|
|
const margenPct = c.costoTotal > 0 ? Math.round((c.ganancia / c.costoTotal) * 1000) / 10 : 0;
|
|
const precioMercado = s.insumos.precioMercado?.[f.tipo]?.[f.grado] || 0;
|
|
const costoPorShot = c.shotsObtenidos > 0 ? Math.round(c.costoTotal / c.shotsObtenidos) : 0;
|
|
return {
|
|
id: f.id,
|
|
tipo: f.tipo,
|
|
grado: f.grado,
|
|
ganancia: c.ganancia,
|
|
roi,
|
|
margenPct,
|
|
shotsObtenidos: c.shotsObtenidos,
|
|
costo: c.costoTotal,
|
|
costoCristales: c.costoCristales,
|
|
costoOre: c.costoOre,
|
|
costoDwarven: c.costoDwarven,
|
|
venta: c.valorVenta,
|
|
precioMercado,
|
|
costoPorShot,
|
|
};
|
|
});
|
|
const filtrados = this.rankingFiltro
|
|
? todos.filter(r => r.tipo === this.rankingFiltro)
|
|
: todos;
|
|
const field = this.rankingOrden;
|
|
const dir = this.rankingOrdenDir === 'asc' ? 1 : -1;
|
|
return [...filtrados].sort((a, b) => {
|
|
const va = a[field], vb = b[field];
|
|
if (va == null) return 1;
|
|
if (vb == null) return -1;
|
|
if (typeof va === 'string') return dir * va.localeCompare(vb);
|
|
if (va < vb) return -1 * dir;
|
|
if (va > vb) return 1 * dir;
|
|
return 0;
|
|
});
|
|
},
|
|
|
|
rankingOrdenarPor(field) {
|
|
if (this.rankingOrden === field) {
|
|
this.rankingOrdenDir = this.rankingOrdenDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.rankingOrden = field;
|
|
this.rankingOrdenDir = 'desc';
|
|
}
|
|
},
|
|
|
|
rankingSortIcon(field) {
|
|
if (this.rankingOrden !== field) return '↕';
|
|
return this.rankingOrdenDir === 'asc' ? '↑' : '↓';
|
|
},
|
|
|
|
fmtAdena(n) {
|
|
if (n == null) return '—';
|
|
return Math.round(n).toLocaleString('es-CL');
|
|
},
|
|
|
|
fmtPct(pct) {
|
|
if (pct == null) return '—';
|
|
const sign = pct > 0 ? '+' : '';
|
|
return sign + pct.toFixed(1).replace('.', ',') + '%';
|
|
},
|
|
|
|
formulasPorTipo(tipo) {
|
|
const s = this.store().state;
|
|
if (!s) return [];
|
|
return s.formulas.filter(f => f.tipo === tipo);
|
|
},
|
|
|
|
tiposFiltrados() {
|
|
return ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'];
|
|
},
|
|
|
|
formulasPorTipoFiltradas(tipo) {
|
|
const s = this.store().state;
|
|
if (!s) return [];
|
|
return s.formulas.filter(f =>
|
|
f.tipo === tipo &&
|
|
(this.presupuestoFiltroTipo.length === 0 || this.presupuestoFiltroTipo.includes(f.tipo)) &&
|
|
(this.presupuestoFiltroGrado.length === 0 || this.presupuestoFiltroGrado.includes(f.grado))
|
|
);
|
|
},
|
|
|
|
objetivoShots: {},
|
|
|
|
objetivoCrafteos(f) {
|
|
const deseado = this.objetivoShots[f.id] || 0;
|
|
if (deseado <= 0 || f.shotsObtenidos <= 0) return 0;
|
|
return Math.ceil(deseado / f.shotsObtenidos);
|
|
},
|
|
|
|
objetivoCristales(f) {
|
|
return this.objetivoCrafteos(f) * f.cristalesReq;
|
|
},
|
|
|
|
objetivoOre(f) {
|
|
const crafteos = this.objetivoCrafteos(f);
|
|
const orePorCraft = f.soulOreReq != null ? f.soulOreReq : f.spiritOreReq != null ? f.spiritOreReq : 0;
|
|
return crafteos * orePorCraft;
|
|
},
|
|
|
|
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 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;
|
|
return ore * precioOre;
|
|
},
|
|
|
|
objetivoCostoDwarven(f) {
|
|
const s = this.store().state;
|
|
if (!s) return 0;
|
|
return this.objetivoCrafteos(f) * (s.insumos.dwarvenFeePerCraft || 0);
|
|
},
|
|
|
|
objetivoVenta(f) {
|
|
const s = this.store().state;
|
|
if (!s) return 0;
|
|
const shots = (this.objetivoShots[f.id] || 0);
|
|
return shots * (s.insumos.venta[f.tipo]?.[f.grado] || 0);
|
|
},
|
|
|
|
objetivoGanancia(f) {
|
|
return this.objetivoVenta(f) - this.objetivoCosto(f);
|
|
},
|
|
|
|
objetivoTotales() {
|
|
const s = this.store().state;
|
|
if (!s) return { costo: 0, venta: 0, ganancia: 0, shots: 0, costoCristales: 0, costoOre: 0, costoDwarven: 0 };
|
|
let costo = 0, venta = 0, shots = 0;
|
|
let costoCristales = 0, costoOre = 0, costoDwarven = 0;
|
|
for (const f of s.formulas) {
|
|
const s2 = this.objetivoShots[f.id] || 0;
|
|
if (s2 <= 0) continue;
|
|
shots += s2;
|
|
costo += this.objetivoCosto(f);
|
|
venta += this.objetivoVenta(f);
|
|
costoCristales += this.objetivoCostoCristales(f);
|
|
costoOre += this.objetivoCostoOre(f);
|
|
costoDwarven += this.objetivoCostoDwarven(f);
|
|
}
|
|
return { costo, venta, ganancia: venta - costo, shots, costoCristales, costoOre, costoDwarven };
|
|
},
|
|
|
|
planPorPresupuesto() {
|
|
const s = this.store().state;
|
|
if (!s || this.presupuestoAdena <= 0) return [];
|
|
const formulas = [...s.formulas]
|
|
.filter(f => f.cristalesReq > 0 && f.shotsObtenidos > 0)
|
|
.filter(f => this.presupuestoFiltroTipo.length === 0 || this.presupuestoFiltroTipo.includes(f.tipo))
|
|
.filter(f => this.presupuestoFiltroGrado.length === 0 || this.presupuestoFiltroGrado.includes(f.grado));
|
|
return 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 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;
|
|
const crafteos = Math.floor(this.presupuestoAdena / costoPorBatch);
|
|
const costo = crafteos * costoPorBatch;
|
|
const cristales = crafteos * f.cristalesReq;
|
|
const ore = crafteos * orePorCraft;
|
|
const shots = crafteos * f.shotsObtenidos;
|
|
const venta = crafteos * ventaPorBatch;
|
|
const ganancia = venta - costo;
|
|
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,
|
|
};
|
|
});
|
|
},
|
|
|
|
planMaximizado() {
|
|
const s = this.store().state;
|
|
if (!s || this.presupuestoAdena <= 0) return [];
|
|
const formulas = [...s.formulas]
|
|
.filter(f => f.cristalesReq > 0 && f.shotsObtenidos > 0)
|
|
.filter(f => this.presupuestoFiltroTipo.length === 0 || this.presupuestoFiltroTipo.includes(f.tipo))
|
|
.filter(f => this.presupuestoFiltroGrado.length === 0 || this.presupuestoFiltroGrado.includes(f.grado));
|
|
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);
|
|
|
|
let restante = this.presupuestoAdena;
|
|
const plan = [];
|
|
for (const { f, costoPorBatch, gananciaPorBatch } of ranked) {
|
|
if (restante <= 0) break;
|
|
const crafteos = Math.floor(restante / costoPorBatch);
|
|
if (crafteos <= 0) continue;
|
|
const costo = crafteos * costoPorBatch;
|
|
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 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;
|
|
}
|
|
return plan;
|
|
},
|
|
|
|
planActual() {
|
|
if (this.presupuestoMezclar) return this.planMezcla();
|
|
if (this.presupuestoMaximizar) return this.planMaximizado();
|
|
return this.planPorPresupuesto();
|
|
},
|
|
|
|
planMezcla() {
|
|
const s = this.store().state;
|
|
if (!s || this.presupuestoAdena <= 0) return [];
|
|
const totalGradoPct = Object.values(this.presupuestoMix).reduce((a, b) => a + b, 0);
|
|
const totalTipoPct = Object.values(this.presupuestoMixTipo).reduce((a, b) => a + b, 0);
|
|
if (totalGradoPct === 0 || totalTipoPct === 0) return [];
|
|
const formulas = [...s.formulas]
|
|
.filter(f => f.cristalesReq > 0 && f.shotsObtenidos > 0)
|
|
.filter(f => this.presupuestoFiltroTipo.length === 0 || this.presupuestoFiltroTipo.includes(f.tipo))
|
|
.filter(f => this.presupuestoFiltroGrado.length === 0 || this.presupuestoFiltroGrado.includes(f.grado));
|
|
const formulasByTipoGrado = {};
|
|
formulas.forEach(f => {
|
|
formulasByTipoGrado[f.tipo + '|' + f.grado] = f;
|
|
});
|
|
const plan = [];
|
|
for (const tipo of ['Soulshot', 'Spiritshot', 'Blessed Spiritshot']) {
|
|
const tipoPct = this.presupuestoMixTipo[tipo] || 0;
|
|
if (tipoPct === 0) continue;
|
|
for (const grado of ['D', 'C', 'B', 'A', 'S']) {
|
|
const gradoPct = this.presupuestoMix[grado] || 0;
|
|
if (gradoPct === 0) continue;
|
|
const jointFraction = (tipoPct / 100) * (gradoPct / 100);
|
|
const budget = this.presupuestoAdena * jointFraction;
|
|
const f = formulasByTipoGrado[tipo + '|' + grado];
|
|
if (!f) continue;
|
|
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;
|
|
if (gananciaPorBatch <= 0) continue;
|
|
const crafteos = Math.floor(budget / costoPorBatch);
|
|
if (crafteos <= 0) continue;
|
|
const costo = crafteos * costoPorBatch;
|
|
const cristales = crafteos * f.cristalesReq;
|
|
const ore = crafteos * orePorCraft;
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
return plan;
|
|
},
|
|
|
|
onMixChange(grado, newValue) {
|
|
const snap = (v) => Math.round(v / 5) * 5;
|
|
const oldValue = this.presupuestoMix[grado] || 0;
|
|
const target = snap(Number(newValue));
|
|
const delta = target - oldValue;
|
|
if (delta === 0) return;
|
|
const others = ['D', 'C', 'B', 'A', 'S'].filter(g => g !== grado);
|
|
const otherTotal = others.reduce((sum, g) => sum + (this.presupuestoMix[g] || 0), 0);
|
|
const newOtherTotal = Math.max(0, Math.min(100, otherTotal - delta));
|
|
if (otherTotal === 0) {
|
|
const total = 100 - target;
|
|
const perOther = Math.floor(total / others.length / 5) * 5;
|
|
let remaining = total;
|
|
others.forEach((g, i) => {
|
|
if (i === others.length - 1) {
|
|
this.presupuestoMix[g] = remaining;
|
|
} else {
|
|
this.presupuestoMix[g] = perOther;
|
|
remaining -= perOther;
|
|
}
|
|
});
|
|
this.presupuestoMix[grado] = target;
|
|
return;
|
|
}
|
|
let remaining = newOtherTotal;
|
|
others.forEach((g, i) => {
|
|
const ratio = (this.presupuestoMix[g] || 0) / otherTotal;
|
|
let newVal;
|
|
if (i === others.length - 1) {
|
|
newVal = remaining;
|
|
} else {
|
|
newVal = snap(ratio * newOtherTotal);
|
|
newVal = Math.min(remaining, newVal);
|
|
remaining -= newVal;
|
|
}
|
|
this.presupuestoMix[g] = newVal;
|
|
});
|
|
this.presupuestoMix[grado] = target;
|
|
},
|
|
|
|
onMixTipoChange(tipo, newValue) {
|
|
const snap = (v) => Math.round(v / 5) * 5;
|
|
const oldValue = this.presupuestoMixTipo[tipo] || 0;
|
|
const target = snap(Number(newValue));
|
|
const delta = target - oldValue;
|
|
if (delta === 0) return;
|
|
const others = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'].filter(g => g !== tipo);
|
|
const otherTotal = others.reduce((sum, g) => sum + (this.presupuestoMixTipo[g] || 0), 0);
|
|
const newOtherTotal = Math.max(0, Math.min(100, otherTotal - delta));
|
|
if (otherTotal === 0) {
|
|
const total = 100 - target;
|
|
const perOther = Math.floor(total / others.length / 5) * 5;
|
|
let remaining = total;
|
|
others.forEach((g, i) => {
|
|
if (i === others.length - 1) {
|
|
this.presupuestoMixTipo[g] = remaining;
|
|
} else {
|
|
this.presupuestoMixTipo[g] = perOther;
|
|
remaining -= perOther;
|
|
}
|
|
});
|
|
this.presupuestoMixTipo[tipo] = target;
|
|
return;
|
|
}
|
|
let remaining = newOtherTotal;
|
|
others.forEach((g, i) => {
|
|
const ratio = (this.presupuestoMixTipo[g] || 0) / otherTotal;
|
|
let newVal;
|
|
if (i === others.length - 1) {
|
|
newVal = remaining;
|
|
} else {
|
|
newVal = snap(ratio * newOtherTotal);
|
|
newVal = Math.min(remaining, newVal);
|
|
remaining -= newVal;
|
|
}
|
|
this.presupuestoMixTipo[g] = newVal;
|
|
});
|
|
this.presupuestoMixTipo[tipo] = target;
|
|
},
|
|
|
|
resetMix() {
|
|
this.presupuestoMix = { D: 20, C: 20, B: 20, A: 20, S: 20 };
|
|
this.presupuestoMixTipo = { 'Soulshot': 34, 'Spiritshot': 33, 'Blessed Spiritshot': 33 };
|
|
},
|
|
|
|
planTotalesPresupuesto() {
|
|
const p = this.planActual();
|
|
return p.reduce((acc, r) => ({
|
|
crafteos: acc.crafteos + r.crafteos,
|
|
shots: acc.shots + r.shots,
|
|
cristales: acc.cristales + r.cristales,
|
|
ore: acc.ore + r.ore,
|
|
costo: acc.costo + r.costo,
|
|
venta: acc.venta + r.venta,
|
|
ganancia: acc.ganancia + r.ganancia,
|
|
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) {
|
|
this.presupuestoFiltroTipo = this.presupuestoFiltroTipo.includes(tipo)
|
|
? this.presupuestoFiltroTipo.filter(t => t !== tipo)
|
|
: [...this.presupuestoFiltroTipo, tipo];
|
|
},
|
|
|
|
toggleFiltroGrado(grado) {
|
|
this.presupuestoFiltroGrado = this.presupuestoFiltroGrado.includes(grado)
|
|
? this.presupuestoFiltroGrado.filter(g => g !== grado)
|
|
: [...this.presupuestoFiltroGrado, grado];
|
|
},
|
|
|
|
limpiarFiltros() {
|
|
this.presupuestoFiltroTipo = [];
|
|
this.presupuestoFiltroGrado = [];
|
|
},
|
|
|
|
formulaDgrade(tipo) {
|
|
const s = this.store().state;
|
|
if (!s) return null;
|
|
return s.formulas.find(f => f.tipo === tipo && f.grado === 'D');
|
|
},
|
|
|
|
ngVentaPorShot(tipo) {
|
|
const s = this.store().state;
|
|
if (!s) return 0;
|
|
return s.insumos.ngVenta?.[tipo] || 0;
|
|
},
|
|
|
|
costoPorShotDgrade(tipo) {
|
|
const f = this.formulaDgrade(tipo);
|
|
if (!f || f.shotsObtenidos <= 0) return 0;
|
|
const s = this.store().state;
|
|
if (!s) return 0;
|
|
const precioCristal = s.insumos.cristales['D'] || 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);
|
|
return Math.round(costoPorBatch / f.shotsObtenidos);
|
|
},
|
|
|
|
ventaPorShotDgrade(tipo) {
|
|
const s = this.store().state;
|
|
if (!s) return 0;
|
|
return s.insumos.venta?.[tipo]?.['D'] || 0;
|
|
},
|
|
|
|
gananciaDgrade(tipo) {
|
|
return this.ventaPorShotDgrade(tipo) - this.costoPorShotDgrade(tipo);
|
|
},
|
|
|
|
async saveRun() {
|
|
if (!this.canSave()) return;
|
|
const tablas = this.tablas();
|
|
const tg = this.totalesGlobal();
|
|
const s = this.store().state;
|
|
const items = tablas.flatMap(t =>
|
|
t.formulas.map((f, i) => {
|
|
const c = t.calculos[i];
|
|
return {
|
|
tipo: t.tipo, grado: f.grado,
|
|
cristalesDisponibles: c.cristalesDisponibles,
|
|
cristalesUsados: c.cristalesUsados,
|
|
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,
|
|
};
|
|
}).filter(item => item.cristalesDisponibles > 0)
|
|
);
|
|
this.saveStatus = 'saving';
|
|
this.saveError = null;
|
|
try {
|
|
await window.api.saveHistoryRun({
|
|
label: this.runLabel.trim() || null,
|
|
totalCost: tg.costoTotal,
|
|
totalSale: tg.valorVenta,
|
|
totalProfit: tg.ganancia,
|
|
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 },
|
|
});
|
|
this.saveStatus = 'saved';
|
|
this.runLabel = '';
|
|
setTimeout(() => { this.saveStatus = 'idle'; }, 3000);
|
|
} catch (err) {
|
|
this.saveStatus = 'error';
|
|
this.saveError = err.message || 'Error al guardar';
|
|
}
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|