feat(personajes): inventory capacity for shots per character
CI / Build Native (push) Successful in 6m57s

Add 2 new fields per character:
- inventorySlots (default 80) — slots disponibles en el inventario
- stackLimit (default 9999) — límite por stack (config del server)

Capacidad por personaje = slots × stack limit (total shots que puede
cargar). Útil para responder 'tengo N shots, ¿me alcanzan los chars?'.

Backend:
- V4__character_inventory.sql migration adds the 2 columns with defaults
- Character entity + CharacterDto + CharacterIn records include them
- CharacterService.updateInventory() + CharacterResource PUT
  /api/characters/{id}/inventory endpoint for inline edits
- Validation: slots and stackLimit must be > 0

Frontend:
- Agregar personaje form: 2 nuevos inputs (Slots default 80, Stack default 9999)
- Mis personajes table: columnas Slots, Stack y Capacidad (shots),
  editables inline con @blur y @keydown.enter (mismo patrón que level)
- Nueva sección '💼 Capacidad de inventario de shots' con:
  - Total de la cuenta (verde, grande)
  - Sub-info: N personajes · M slots · stack máx X
  - Cards por personaje (slots × stack)
- Alpine helpers nuevos: capacityOf(), totalCapacity(), totalSlots(),
  maxStack(), updateInventory()

Verificado en navegador:
- Math correcta: 100×9999 + 100×99999 = 10.999.800 shots ✓
- Edición inline persiste via PUT /inventory
- Migration V4 aplicada limpiamente en el boot (4 migrations total)
This commit is contained in:
2026-08-23 11:15:49 -04:00
parent c109a59d3d
commit c11f2e5aac
8 changed files with 184 additions and 7 deletions
@@ -20,7 +20,7 @@
<h2>Agregar personaje</h2>
</header>
<form @submit.prevent="submitCharacter()">
<div class="grid" style="grid-template-columns: 1fr 1fr 100px;">
<div class="grid" style="grid-template-columns: 1fr 1fr 80px 80px 100px; gap:0.5rem">
<label>
<span class="text-sm text-muted">Nombre</span>
<input type="text"
@@ -46,6 +46,20 @@
required
placeholder="85"/>
</label>
<label>
<span class="text-sm text-muted" title="Slots de inventario del personaje">Slots</span>
<input type="number"
x-model.number="form.inventorySlots"
min="0"
placeholder="80"/>
</label>
<label>
<span class="text-sm text-muted" title="Stack limit por slot (config del server)">Stack</span>
<input type="number"
x-model.number="form.stackLimit"
min="0"
placeholder="9999"/>
</label>
</div>
<div style="margin-top: 0.75rem;">
<button type="submit"
@@ -80,6 +94,9 @@
<th>Nombre</th>
<th>Clase</th>
<th>Nivel</th>
<th class="text-right" title="Slots de inventario">Slots</th>
<th class="text-right" title="Stack limit por slot">Stack</th>
<th class="text-right" title="Capacidad total = slots × stack">Capacidad (shots)</th>
<th>Acciones</th>
</tr>
</thead>
@@ -99,6 +116,24 @@
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem;"/>
</div>
</td>
<td class="text-right">
<input type="number"
min="0"
x-model.number="c.inventorySlots"
@keydown.enter="updateInventory(c)"
@blur="updateInventory(c)"
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem; text-align: right;"/>
</td>
<td class="text-right">
<input type="number"
min="0"
x-model.number="c.stackLimit"
@keydown.enter="updateInventory(c)"
@blur="updateInventory(c)"
style="width: 80px; padding: 0.25rem 0.5rem; font-size: 0.875rem; text-align: right;"/>
</td>
<td class="text-right text-mono text-bold"
x-text="fmtAdena(capacityOf(c))"></td>
<td>
<button type="button"
class="secondary outline"
@@ -114,6 +149,40 @@
</div>
</article>
<article x-show="characters.length > 0">
<header>
<h2>💼 Capacidad de inventario de shots</h2>
<p class="text-sm text-muted">Total de shots que pueden cargar todos tus personajes en el inventario. Slots × Stack limit por personaje.</p>
</header>
<div class="flex-row gap-4 mb-4" style="flex-wrap:wrap;align-items:center">
<div>
<p class="text-xs text-muted mb-2">Capacidad total de la cuenta</p>
<p class="text-lg text-mono text-bold text-success"
x-text="fmtAdena(totalCapacity()) + ' shots'"></p>
<p class="text-xs text-muted"
x-text="characters.length + ' personaje' + (characters.length === 1 ? '' : 's') + ' · ' + totalSlots() + ' slots · stack máx ' + fmtAdena(maxStack())"></p>
</div>
</div>
<div class="grid-3">
<template x-for="c in characters" :key="c.id">
<div class="metric-card">
<p class="text-sm text-bold mb-2" x-text="c.name"></p>
<p class="text-xs text-muted mb-2"
x-text="c.characterClass + ' · Lv ' + c.level"></p>
<div class="flex-row gap-2" style="font-size:0.75rem">
<span class="text-mono"><span x-text="c.inventorySlots"></span> slots</span>
<span class="text-mono">× <span x-text="c.stackLimit"></span></span>
</div>
<p class="text-lg text-mono text-bold mt-2"
x-text="fmtAdena(capacityOf(c))"></p>
<p class="text-xs text-muted">shots</p>
</div>
</template>
</div>
</article>
</main>
<footer class="container">
@@ -139,12 +208,19 @@
name: '',
characterClass: '',
level: null,
inventorySlots: 80,
stackLimit: 9999,
},
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();
@@ -157,7 +233,7 @@
async submitCharacter() {
if (!this.form.name.trim() || !this.form.characterClass.trim() || !this.form.level) {
this.error = 'Completá todos los campos';
this.error = 'Completá nombre, clase y nivel';
return;
}
this.error = null;
@@ -167,10 +243,14 @@
name: this.form.name.trim(),
characterClass: this.form.characterClass.trim(),
level: this.form.level,
inventorySlots: this.form.inventorySlots || null,
stackLimit: this.form.stackLimit || null,
});
this.form.name = '';
this.form.characterClass = '';
this.form.level = null;
this.form.inventorySlots = 80;
this.form.stackLimit = 9999;
await this.loadCharacters();
} catch (e) {
this.error = e.message || 'Error al guardar';
@@ -179,6 +259,23 @@
}
},
capacityOf(c) {
return (c.inventorySlots || 0) * (c.stackLimit || 0);
},
totalCapacity() {
return this.characters.reduce((sum, c) => sum + this.capacityOf(c), 0);
},
totalSlots() {
return this.characters.reduce((sum, c) => sum + (c.inventorySlots || 0), 0);
},
maxStack() {
if (!this.characters.length) return 0;
return this.characters.reduce((m, c) => Math.max(m, c.stackLimit || 0), 0);
},
async updateLevel(c) {
if (c.level <= 0 || c.level > 100) {
c.level = Math.min(100, Math.max(1, c.level));
@@ -190,6 +287,18 @@
}
},
async updateInventory(c) {
const slots = Math.max(0, c.inventorySlots || 0);
const stack = Math.max(0, c.stackLimit || 0);
c.inventorySlots = slots;
c.stackLimit = stack;
try {
await window.api.updateCharacterInventory(c.id, slots, stack);
} catch (e) {
this.error = e.message || 'Error al actualizar inventario';
}
},
async deleteCharacter(id, name) {
if (!confirm('Eliminar el personaje "' + name + '"?')) return;
try {