Files
shot-crafter-calculator/src/main/resources/templates/PageResource/calculadora.html
T
darroyo c9622200a7
CI / Build Native (push) Successful in 6m52s
refactor(ui): redesign with amber CRT terminal theme and mobile responsive
- 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
2026-08-23 23:33:18 -04:00

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.
&nbsp;|&nbsp;
<strong>Presupuesto:</strong> Dado un presupuesto, qué craftear.
&nbsp;|&nbsp;
<strong>Objetivo:</strong> Quiero X shots, cuánto cuesta.
&nbsp;|&nbsp;
<strong>Plan cristales:</strong> Tengo N cristales, dame el mejor plan.
</p>
</div>
<div class="flex-row gap-1">
<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>