feat(personajes): inventory capacity for shots per character
CI / Build Native (push) Successful in 6m57s
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:
@@ -31,6 +31,12 @@ public class Character extends PanacheEntityBase {
|
|||||||
@Column(name = "level", nullable = false)
|
@Column(name = "level", nullable = false)
|
||||||
public int level;
|
public int level;
|
||||||
|
|
||||||
|
@Column(name = "inventory_slots", nullable = false)
|
||||||
|
public int inventorySlots = 80;
|
||||||
|
|
||||||
|
@Column(name = "stack_limit", nullable = false)
|
||||||
|
public int stackLimit = 9999;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
public Instant createdAt;
|
public Instant createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ public record CharacterDto(
|
|||||||
String name,
|
String name,
|
||||||
String characterClass,
|
String characterClass,
|
||||||
int level,
|
int level,
|
||||||
|
int inventorySlots,
|
||||||
|
int stackLimit,
|
||||||
Instant createdAt) {
|
Instant createdAt) {
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,9 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
|
|||||||
public record CharacterIn(
|
public record CharacterIn(
|
||||||
String name,
|
String name,
|
||||||
String characterClass,
|
String characterClass,
|
||||||
int level) {
|
int level,
|
||||||
|
Integer inventorySlots,
|
||||||
|
Integer stackLimit) {
|
||||||
|
|
||||||
public CharacterIn {
|
public CharacterIn {
|
||||||
if (name == null || name.isBlank()) {
|
if (name == null || name.isBlank()) {
|
||||||
@@ -25,4 +27,12 @@ public record CharacterIn(
|
|||||||
throw new IllegalArgumentException("level must be greater than 0");
|
throw new IllegalArgumentException("level must be greater than 0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int effectiveInventorySlots() {
|
||||||
|
return inventorySlots != null && inventorySlots > 0 ? inventorySlots : 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int effectiveStackLimit() {
|
||||||
|
return stackLimit != null && stackLimit > 0 ? stackLimit : 9999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,9 @@ public class CharacterResource {
|
|||||||
CharacterIn validated = new CharacterIn(
|
CharacterIn validated = new CharacterIn(
|
||||||
input.name(),
|
input.name(),
|
||||||
input.characterClass(),
|
input.characterClass(),
|
||||||
input.level());
|
input.level(),
|
||||||
|
input.inventorySlots(),
|
||||||
|
input.stackLimit());
|
||||||
CharacterDto created = characterService.create(userId.get(), validated);
|
CharacterDto created = characterService.create(userId.get(), validated);
|
||||||
return Response.status(201).entity(created).build();
|
return Response.status(201).entity(created).build();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -84,6 +86,30 @@ public class CharacterResource {
|
|||||||
.orElse(Response.status(404).build());
|
.orElse(Response.status(404).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/{id}/inventory")
|
||||||
|
public Response updateInventory(@Context HttpHeaders headers,
|
||||||
|
@PathParam("id") String idStr,
|
||||||
|
UpdateInventoryRequest body) {
|
||||||
|
Optional<UUID> userId = extractUserId(headers);
|
||||||
|
if (userId.isEmpty()) return Response.status(401).build();
|
||||||
|
|
||||||
|
UUID id;
|
||||||
|
try {
|
||||||
|
id = UUID.fromString(idStr);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body == null || body.inventorySlots() <= 0 || body.stackLimit() <= 0) {
|
||||||
|
return Response.status(400).entity("{\"error\":\"slots y stackLimit deben ser mayores a 0\"}").build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<CharacterDto> updated = characterService.updateInventory(userId.get(), id, body.inventorySlots(), body.stackLimit());
|
||||||
|
return updated.map(c -> Response.ok(c).build())
|
||||||
|
.orElse(Response.status(404).build());
|
||||||
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
|
||||||
@@ -116,4 +142,6 @@ public class CharacterResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UpdateLevelRequest(int level) {}
|
public record UpdateLevelRequest(int level) {}
|
||||||
|
|
||||||
|
public record UpdateInventoryRequest(int inventorySlots, int stackLimit) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public class CharacterService {
|
|||||||
entity.name = input.name();
|
entity.name = input.name();
|
||||||
entity.characterClass = input.characterClass();
|
entity.characterClass = input.characterClass();
|
||||||
entity.level = input.level();
|
entity.level = input.level();
|
||||||
|
entity.inventorySlots = input.effectiveInventorySlots();
|
||||||
|
entity.stackLimit = input.effectiveStackLimit();
|
||||||
entity.createdAt = Instant.now();
|
entity.createdAt = Instant.now();
|
||||||
entity.persist();
|
entity.persist();
|
||||||
return toDto(entity);
|
return toDto(entity);
|
||||||
@@ -46,6 +48,15 @@ public class CharacterService {
|
|||||||
return Optional.of(toDto(entity));
|
return Optional.of(toDto(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<CharacterDto> updateInventory(UUID userId, UUID id, int inventorySlots, int stackLimit) {
|
||||||
|
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||||
|
if (entity == null) return Optional.empty();
|
||||||
|
if (inventorySlots > 0) entity.inventorySlots = inventorySlots;
|
||||||
|
if (stackLimit > 0) entity.stackLimit = stackLimit;
|
||||||
|
return Optional.of(toDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(UUID userId, UUID id) {
|
public void delete(UUID userId, UUID id) {
|
||||||
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||||
@@ -67,6 +78,8 @@ public class CharacterService {
|
|||||||
entity.name,
|
entity.name,
|
||||||
entity.characterClass,
|
entity.characterClass,
|
||||||
entity.level,
|
entity.level,
|
||||||
|
entity.inventorySlots,
|
||||||
|
entity.stackLimit,
|
||||||
entity.createdAt);
|
entity.createdAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +143,13 @@ window.api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateCharacterInventory(id, inventorySlots, stackLimit) {
|
||||||
|
return this.request(`/api/characters/${id}/inventory`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ inventorySlots, stackLimit }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
deleteCharacter(id) {
|
deleteCharacter(id) {
|
||||||
return this.request(`/api/characters/${id}`, { method: 'DELETE' });
|
return this.request(`/api/characters/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE characters ADD COLUMN inventory_slots INT NOT NULL DEFAULT 80;
|
||||||
|
ALTER TABLE characters ADD COLUMN stack_limit INT NOT NULL DEFAULT 9999;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<h2>Agregar personaje</h2>
|
<h2>Agregar personaje</h2>
|
||||||
</header>
|
</header>
|
||||||
<form @submit.prevent="submitCharacter()">
|
<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>
|
<label>
|
||||||
<span class="text-sm text-muted">Nombre</span>
|
<span class="text-sm text-muted">Nombre</span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
@@ -46,6 +46,20 @@
|
|||||||
required
|
required
|
||||||
placeholder="85"/>
|
placeholder="85"/>
|
||||||
</label>
|
</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>
|
||||||
<div style="margin-top: 0.75rem;">
|
<div style="margin-top: 0.75rem;">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -80,6 +94,9 @@
|
|||||||
<th>Nombre</th>
|
<th>Nombre</th>
|
||||||
<th>Clase</th>
|
<th>Clase</th>
|
||||||
<th>Nivel</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>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -99,6 +116,24 @@
|
|||||||
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem;"/>
|
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem;"/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="secondary outline"
|
class="secondary outline"
|
||||||
@@ -114,6 +149,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
</main>
|
||||||
|
|
||||||
<footer class="container">
|
<footer class="container">
|
||||||
@@ -139,12 +208,19 @@
|
|||||||
name: '',
|
name: '',
|
||||||
characterClass: '',
|
characterClass: '',
|
||||||
level: null,
|
level: null,
|
||||||
|
inventorySlots: 80,
|
||||||
|
stackLimit: 9999,
|
||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadCharacters();
|
await this.loadCharacters();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fmtAdena(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return Math.round(n).toLocaleString('es-CL');
|
||||||
|
},
|
||||||
|
|
||||||
async loadCharacters() {
|
async loadCharacters() {
|
||||||
try {
|
try {
|
||||||
this.characters = await window.api.listCharacters();
|
this.characters = await window.api.listCharacters();
|
||||||
@@ -157,7 +233,7 @@
|
|||||||
|
|
||||||
async submitCharacter() {
|
async submitCharacter() {
|
||||||
if (!this.form.name.trim() || !this.form.characterClass.trim() || !this.form.level) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@@ -167,10 +243,14 @@
|
|||||||
name: this.form.name.trim(),
|
name: this.form.name.trim(),
|
||||||
characterClass: this.form.characterClass.trim(),
|
characterClass: this.form.characterClass.trim(),
|
||||||
level: this.form.level,
|
level: this.form.level,
|
||||||
|
inventorySlots: this.form.inventorySlots || null,
|
||||||
|
stackLimit: this.form.stackLimit || null,
|
||||||
});
|
});
|
||||||
this.form.name = '';
|
this.form.name = '';
|
||||||
this.form.characterClass = '';
|
this.form.characterClass = '';
|
||||||
this.form.level = null;
|
this.form.level = null;
|
||||||
|
this.form.inventorySlots = 80;
|
||||||
|
this.form.stackLimit = 9999;
|
||||||
await this.loadCharacters();
|
await this.loadCharacters();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message || 'Error al guardar';
|
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) {
|
async updateLevel(c) {
|
||||||
if (c.level <= 0 || c.level > 100) {
|
if (c.level <= 0 || c.level > 100) {
|
||||||
c.level = Math.min(100, Math.max(1, c.level));
|
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) {
|
async deleteCharacter(id, name) {
|
||||||
if (!confirm('Eliminar el personaje "' + name + '"?')) return;
|
if (!confirm('Eliminar el personaje "' + name + '"?')) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user