Files
shot-crafter-calculator/src/main/resources/templates/PageResource/personajes.html
T
darroyo e8e5ab41c7
CI / Build Native (push) Successful in 6m51s
feat(personajes): capacity by weight (L2 dwarf model)
Reemplaza el modelo slots × stack por el modelo real de L2 para
vendedores enanos: capacidad basada en el límite de peso del personaje
con multiplicador por el pasivo 'Weight Limit Increase' del enano.

Modelo:
- maxWeight = baseWeight × (1 + skillLevel)
  - skillLevel 0 → ×1 (sin pasivo)
  - skillLevel 1 → ×2 (+100%, lv1)
  - skillLevel 2 → ×3 (+200%, lv2)
  - skillLevel 3 → ×4 (+300%, lv3)
- capacity(tipo, grado) = floor(maxWeight / shotWeight[tipo][grado])

Backend (V5 migration):
- DROP COLUMN inventory_slots, stack_limit
- ADD COLUMN base_weight DEFAULT 115230
- ADD COLUMN skill_level DEFAULT 0
- Character/CharacterDto/CharacterIn con los nuevos campos
- PUT /api/characters/{id}/weight endpoint (reemplaza /inventory)
- Validación: baseWeight > 0, skillLevel 0..3

Frontend:
- Form: Base weight (default 115230) + Skill level select (0..3)
- Tabla: Peso base + Skill enano (select editable) + Peso máx calculado
- Sección '💼 Capacidad de inventario de shots (por peso)':
  - Por personaje: header con nombre, lvl, base, multiplicador, badge skill
  - Tabla 3×5 con las 15 capacidades (3 tipos × 5 grados)
  - Sub-info: Peso máx = base × (1+skill)
  - Total 15 tipos por personaje
- Shot weights constants en Alpine (SHOT_WEIGHTS, TIPOS, GRADOS)
- Métodos: maxWeightOf(c), capacityOf(c, tipo, grado), totalCapacityOf(c),
  updateWeight(c), tipoShort(tipo)

Pesos de shots (hardcoded):
- SS: D=3, C=3, B=2, A=2, S=2
- SPS: D=5, C=3, B=3, A=2, S=2
- BSS: D=5, C=3, B=3, A=2, S=2

Verificado en navegador:
- Migration V5 aplicada limpiamente (5 migrations total)
- Personajes existentes preservados con default baseWeight=115230 skillLevel=0
- Math correcta: skillLevel=3, baseWeight=115230 → maxWeight=460.920 →
  SS D=153.640, BSS S=230.460
- Edición inline de skill en la tabla persiste via PUT /weight
- Visualización 3×5 por personaje con badge dinámico
2026-08-23 12:34:47 -04:00

357 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="es" data-theme="light">
<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>Personajes — Calculadora de Craft de Shots</title>
<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="personajesSection()">
<article>
<header>
<h2>Agregar personaje</h2>
</header>
<form @submit.prevent="submitCharacter()">
<div class="grid" style="grid-template-columns: 1fr 1fr 80px 130px 130px; gap:0.5rem">
<label>
<span class="text-sm text-muted">Nombre</span>
<input type="text"
x-model="form.name"
maxlength="50"
required
placeholder="p.ej. DarkMage"/>
</label>
<label>
<span class="text-sm text-muted">Clase</span>
<input type="text"
x-model="form.characterClass"
maxlength="30"
required
placeholder="p.ej. DC, HE, SWS, EE"/>
</label>
<label>
<span class="text-sm text-muted">Nivel</span>
<input type="number"
x-model.number="form.level"
min="1"
max="100"
required
placeholder="85"/>
</label>
<label>
<span class="text-sm text-muted" title="Peso base que puede cargar el personaje">Peso base</span>
<input type="number"
x-model.number="form.baseWeight"
min="1"
step="1"
placeholder="115230"/>
</label>
<label>
<span class="text-sm text-muted" title="Pasivo del enano: 0=sin skill, 1=+100%, 2=+200%, 3=+300%">Skill enano</span>
<select x-model.number="form.skillLevel">
<option value="0">Sin pasivo</option>
<option value="1">+100% (lv1)</option>
<option value="2">+200% (lv2)</option>
<option value="3">+300% (lv3)</option>
</select>
</label>
</div>
<div style="margin-top: 0.75rem;">
<button type="submit"
class="primary"
:disabled="submitting">
<span x-show="!submitting">Agregar</span>
<span x-show="submitting">Guardando…</span>
</button>
</div>
<p x-show="error" class="text-error text-sm" x-text="error"></p>
</form>
</article>
<article>
<header>
<h2 style="margin: 0;">Mis personajes</h2>
<p class="text-sm text-muted" x-text="characters.length + ' personaje' + (characters.length === 1 ? '' : 's')"></p>
</header>
<div x-show="loading && characters.length === 0" class="text-center p-4">
<p class="text-sm text-muted">Cargando…</p>
</div>
<div x-show="characters.length === 0 && !loading" class="text-center p-4">
<p class="text-sm text-muted">No hay personajes. Agregá uno arriba.</p>
</div>
<div x-show="characters.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Clase</th>
<th>Nivel</th>
<th class="text-right" title="Peso base del personaje">Peso base</th>
<th class="text-right" title="Nivel del pasivo del enano (0-3)">Skill enano</th>
<th class="text-right" title="Peso máximo = base × (1 + skill)">Peso máx</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<template x-for="c in characters" :key="c.id">
<tr>
<td x-text="c.name"></td>
<td x-text="c.characterClass"></td>
<td>
<div class="flex-row gap-1" style="align-items: center;">
<input type="number"
min="1"
max="100"
x-model.number="c.level"
@keydown.enter="updateLevel(c)"
@blur="updateLevel(c)"
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem;"/>
</div>
</td>
<td class="text-right">
<input type="number"
min="1"
step="1"
x-model.number="c.baseWeight"
@keydown.enter="updateWeight(c)"
@blur="updateWeight(c)"
style="width: 100px; padding: 0.25rem 0.5rem; font-size: 0.875rem; text-align: right;"/>
</td>
<td class="text-right">
<select x-model.number="c.skillLevel"
@change="updateWeight(c)"
style="width: 130px; padding: 0.25rem 0.5rem; font-size: 0.875rem;">
<option value="0">0 — sin pasivo</option>
<option value="1">1 — +100%</option>
<option value="2">2 — +200%</option>
<option value="3">3 — +300%</option>
</select>
</td>
<td class="text-right text-mono text-bold"
x-text="fmtAdena(maxWeightOf(c))"></td>
<td>
<button type="button"
class="secondary outline"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
@click="deleteCharacter(c.id, c.name)">
Eliminar
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</article>
<article x-show="characters.length > 0">
<header>
<h2>💼 Capacidad de inventario de shots (por peso)</h2>
<p class="text-sm text-muted">Cada card muestra cuántos shots de cada tipo×grado caben en el inventario según el pasivo del personaje. Peso máx = baseWeight × (1 + skillLevel).</p>
</header>
<div class="flex-row gap-2" style="flex-wrap:wrap">
<template x-for="c in characters" :key="c.id">
<article style="flex:1; min-width:320px; max-width:560px">
<header style="flex-wrap:wrap;gap:0.5rem;align-items:center">
<div>
<p class="text-sm text-bold" x-text="c.name" style="margin:0"></p>
<p class="text-xs text-muted" style="margin:0"
x-text="c.characterClass + ' · Lv ' + c.level + ' · base ' + fmtAdena(c.baseWeight) + ' · ×' + (c.skillLevel + 1)"></p>
</div>
<span style="margin-left:auto"
:class="c.skillLevel > 0 ? 'badge-success' : 'badge-muted'"
x-text="c.skillLevel === 0 ? 'Sin pasivo' : 'Skill +' + (c.skillLevel * 100) + '%'"></span>
</header>
<p class="text-xs text-muted mt-2">Peso máx: <span class="text-bold text-mono" x-text="fmtAdena(maxWeightOf(c))"></span></p>
<div class="table-wrapper" style="margin-top:0.5rem">
<table style="font-size:0.7rem; margin:0">
<thead>
<tr>
<th class="cell-label text-left"></th>
<template x-for="g in GRADOS" :key="g">
<th class="cell-label text-right" x-text="g"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="t in TIPOS" :key="t">
<tr>
<td class="text-bold text-mono" x-text="tipoShort(t)"></td>
<template x-for="g in GRADOS" :key="t + '-' + g">
<td class="text-right text-mono" x-text="fmtAdena(capacityOf(c, t, g))"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-xs text-muted mt-2">
Total 15 tipos: <span class="text-mono text-bold" x-text="fmtAdena(totalCapacityOf(c))"></span> shots
</p>
</article>
</template>
</div>
</article>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Hacé clic en el nivel para editarlo inline · Enter o perder foco guarda
</p>
</footer>
<script src="/static/js/defaults.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('personajesSection', () => ({
characters: [],
loading: true,
submitting: false,
error: null,
SHOT_WEIGHTS: {
Soulshot: { D: 3, C: 3, B: 2, A: 2, S: 2 },
Spiritshot: { D: 5, C: 3, B: 3, A: 2, S: 2 },
'Blessed Spiritshot': { D: 5, C: 3, B: 3, A: 2, S: 2 },
},
TIPOS: ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'],
GRADOS: ['D', 'C', 'B', 'A', 'S'],
form: {
name: '',
characterClass: '',
level: null,
baseWeight: 115230,
skillLevel: 0,
},
async init() {
await this.loadCharacters();
},
fmtAdena(n) {
if (n == null) return '—';
return Math.round(n).toLocaleString('es-CL');
},
async loadCharacters() {
try {
this.characters = await window.api.listCharacters();
} catch (e) {
this.error = e.message || 'Error cargando personajes';
} finally {
this.loading = false;
}
},
async submitCharacter() {
if (!this.form.name.trim() || !this.form.characterClass.trim() || !this.form.level) {
this.error = 'Completá nombre, clase y nivel';
return;
}
this.error = null;
this.submitting = true;
try {
await window.api.createCharacter({
name: this.form.name.trim(),
characterClass: this.form.characterClass.trim(),
level: this.form.level,
baseWeight: this.form.baseWeight || null,
skillLevel: this.form.skillLevel,
});
this.form.name = '';
this.form.characterClass = '';
this.form.level = null;
this.form.baseWeight = 115230;
this.form.skillLevel = 0;
await this.loadCharacters();
} catch (e) {
this.error = e.message || 'Error al guardar';
} finally {
this.submitting = false;
}
},
tipoShort(tipo) {
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
},
maxWeightOf(c) {
return (c.baseWeight || 115230) * (1 + (c.skillLevel || 0));
},
capacityOf(c, tipo, grado) {
const maxW = this.maxWeightOf(c);
const w = this.SHOT_WEIGHTS[tipo]?.[grado] || 0;
return w > 0 ? Math.floor(maxW / w) : 0;
},
totalCapacityOf(c) {
let total = 0;
for (const t of this.TIPOS) {
for (const g of this.GRADOS) {
total += this.capacityOf(c, t, g);
}
}
return total;
},
async updateLevel(c) {
if (c.level <= 0 || c.level > 100) {
c.level = Math.min(100, Math.max(1, c.level));
}
try {
await window.api.updateCharacterLevel(c.id, c.level);
} catch (e) {
this.error = e.message || 'Error al actualizar nivel';
}
},
async updateWeight(c) {
const bw = Math.max(1, c.baseWeight || 115230);
const sk = Math.max(0, Math.min(3, c.skillLevel || 0));
c.baseWeight = bw;
c.skillLevel = sk;
try {
await window.api.updateCharacterWeight(c.id, bw, sk);
} catch (e) {
this.error = e.message || 'Error al actualizar peso/skill';
}
},
async deleteCharacter(id, name) {
if (!confirm('Eliminar el personaje "' + name + '"?')) return;
try {
await window.api.deleteCharacter(id);
await this.loadCharacters();
} catch (e) {
alert(e.message || 'Error al eliminar personaje');
}
},
}));
});
</script>
</body>
</html>