CI / Build Native (push) Successful in 6m51s
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
357 lines
13 KiB
HTML
357 lines
13 KiB
HTML
<!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>
|