selector and web
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-07 00:35:18 +02:00
parent 211779785d
commit 836499d10d
18 changed files with 1189 additions and 25 deletions
@@ -110,11 +110,13 @@ public final class CharacterCommand extends AbstractCommandCollection {
} }
try { try {
String name = context.get(nameArg); String name = context.get(nameArg);
CharacterService.CharacterCreationResult result;
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
name = characters.nextDefaultName(playerRef.getUuid()); result = characters.createDefaultCharacter(
playerRef.getUuid(), playerRef.getUsername());
} else {
result = characters.createCharacter(playerRef.getUuid(), name);
} }
CharacterService.CharacterCreationResult result =
characters.createCharacter(playerRef.getUuid(), name);
if (result.ok() && result.profile() != null) { if (result.ok() && result.profile() != null) {
context.sendMessage(Message.raw( context.sendMessage(Message.raw(
"Personnage créé : " + result.profile().getDisplayName())); "Personnage créé : " + result.profile().getDisplayName()));
@@ -109,6 +109,15 @@ public final class CharacterRepository {
return profile; return profile;
} }
public boolean delete(@Nonnull UUID accountUuid, @Nonnull UUID characterId) throws SQLException {
String sql = "DELETE FROM characters WHERE id = ? AND account_uuid = ?";
try (PreparedStatement statement = connection().prepareStatement(sql)) {
statement.setString(1, characterId.toString());
statement.setString(2, accountUuid.toString());
return statement.executeUpdate() > 0;
}
}
@Nonnull @Nonnull
private List<PlayerProfile> findByQuery(@Nonnull String whereClause, @Nonnull String... params) private List<PlayerProfile> findByQuery(@Nonnull String whereClause, @Nonnull String... params)
throws SQLException { throws SQLException {
@@ -87,6 +87,25 @@ public final class CharacterService {
if (name == null) { if (name == null) {
return CharacterCreationResult.invalidName(); return CharacterCreationResult.invalidName();
} }
return createCharacterInternal(accountUuid, name);
}
/**
* Creates a character named after the player's account username.
* Uses {@code "pseudo 2"}, {@code "pseudo 3"}, … when the base name is already taken.
*/
@Nonnull
public CharacterCreationResult createDefaultCharacter(
@Nonnull UUID accountUuid,
@Nonnull String playerUsername) throws SQLException {
String name = defaultCharacterName(accountUuid, playerUsername);
return createCharacterInternal(accountUuid, name);
}
@Nonnull
private CharacterCreationResult createCharacterInternal(
@Nonnull UUID accountUuid,
@Nonnull String name) throws SQLException {
if (characters.countByAccount(accountUuid) >= config.getMaxCharactersPerAccount()) { if (characters.countByAccount(accountUuid) >= config.getMaxCharactersPerAccount()) {
return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount()); return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount());
} }
@@ -105,9 +124,54 @@ public final class CharacterService {
} }
@Nonnull @Nonnull
public String nextDefaultName(@Nonnull UUID accountUuid) throws SQLException { public String defaultCharacterName(
int count = characters.countByAccount(accountUuid); @Nonnull UUID accountUuid,
return "Perso " + (count + 1); @Nonnull String playerUsername) throws SQLException {
String base = sanitizeCharacterName(playerUsername);
if (!characters.nameExists(accountUuid, base)) {
return base;
}
for (int suffix = 2; suffix <= config.getMaxCharactersPerAccount() + 1; suffix++) {
String suffixText = " " + suffix;
int maxBaseLength = MAX_NAME_LENGTH - suffixText.length();
String truncatedBase = base.length() > maxBaseLength
? base.substring(0, Math.max(1, maxBaseLength)).trim()
: base;
String candidate = truncatedBase + suffixText;
if (!characters.nameExists(accountUuid, candidate)) {
return candidate;
}
}
return base + " " + System.currentTimeMillis();
}
@Nonnull
public CharacterDeletionResult deleteCharacter(
@Nonnull UUID accountUuid,
@Nonnull UUID characterId) throws SQLException {
Optional<PlayerProfile> found = characters.findById(characterId);
if (found.isEmpty() || !found.get().getAccountUuid().equals(accountUuid)) {
return CharacterDeletionResult.notFound();
}
PlayerAccount account = accounts.findByUuid(accountUuid).orElse(null);
if (account != null && characterId.equals(account.getActiveCharacterId())) {
accounts.setActiveCharacter(accountUuid, null);
account.setActiveCharacterId(null);
account.touch();
accounts.save(account);
}
if (!characters.delete(accountUuid, characterId)) {
return CharacterDeletionResult.notFound();
}
logger.at(Level.INFO).log(
"Deleted character %s (%s) for account %s",
found.get().getDisplayName(),
characterId,
accountUuid);
return CharacterDeletionResult.success(found.get().getDisplayName());
} }
public void openCharacterSelector( public void openCharacterSelector(
@@ -224,6 +288,18 @@ public final class CharacterService {
} }
} }
@Nullable
private static String sanitizeCharacterName(@Nonnull String rawName) {
String name = rawName.trim();
if (name.isEmpty()) {
return "Joueur";
}
if (name.length() > MAX_NAME_LENGTH) {
name = name.substring(0, MAX_NAME_LENGTH);
}
return name;
}
@Nullable @Nullable
private static String normalizeName(@Nonnull String rawName) { private static String normalizeName(@Nonnull String rawName) {
String name = rawName.trim(); String name = rawName.trim();
@@ -269,4 +345,18 @@ public final class CharacterService {
false, null, "Limite de " + max + " personnages atteinte."); false, null, "Limite de " + max + " personnages atteinte.");
} }
} }
public record CharacterDeletionResult(
boolean ok,
@Nullable String deletedName,
@Nullable String errorMessage) {
static CharacterDeletionResult success(@Nonnull String deletedName) {
return new CharacterDeletionResult(true, deletedName, null);
}
static CharacterDeletionResult notFound() {
return new CharacterDeletionResult(false, null, "Personnage introuvable.");
}
}
} }
@@ -29,9 +29,6 @@ import java.util.logging.Logger;
/** /**
* Mandatory character selector shown on join before gameplay begins. * Mandatory character selector shown on join before gameplay begins.
* <p>
* Character rows are predefined in {@code MmorpgCharacterSelect.ui} — {@code appendInline}
* only supports simple widgets (e.g. {@code Label}), not nested {@code Group}/{@code TextButton}.
*/ */
public final class CharacterSelectPage extends InteractiveCustomUIPage<CharacterSelectPage.UiEvent> { public final class CharacterSelectPage extends InteractiveCustomUIPage<CharacterSelectPage.UiEvent> {
@@ -44,6 +41,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
private final CharacterService characters; private final CharacterService characters;
private final int maxCharacters; private final int maxCharacters;
@Nullable
private UUID pendingDeleteId;
@Nullable
private String pendingDeleteName;
public CharacterSelectPage( public CharacterSelectPage(
@Nonnull PlayerRef playerRef, @Nonnull PlayerRef playerRef,
@Nonnull UUID accountUuid, @Nonnull UUID accountUuid,
@@ -67,6 +69,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername())); ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername()));
bindActions(events); bindActions(events);
renderCharacterList(ui, events, loadCharacters()); renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
} }
@Override @Override
@@ -80,17 +83,36 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
} }
if ("refresh".equals(event.action())) { if ("refresh".equals(event.action())) {
clearPendingDelete();
refresh(ref, store); refresh(ref, store);
return; return;
} }
if ("create".equals(event.action())) { if ("create".equals(event.action())) {
clearPendingDelete();
handleCreate(ref, store); handleCreate(ref, store);
return; return;
} }
if ("select".equals(event.action()) && event.characterId() != null) { if ("select".equals(event.action()) && event.characterId() != null) {
clearPendingDelete();
handleSelect(ref, store, event.characterId()); handleSelect(ref, store, event.characterId());
return;
}
if ("delete".equals(event.action()) && event.characterId() != null) {
handleDeleteRequest(ref, store, event.characterId());
return;
}
if ("confirm_delete".equals(event.action())) {
handleConfirmDelete(ref, store);
return;
}
if ("cancel_delete".equals(event.action())) {
clearPendingDelete();
refresh(ref, store);
} }
} }
@@ -101,16 +123,16 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
setStatus(ref, store, "Limite de " + maxCharacters + " personnages atteinte."); setStatus(ref, store, "Limite de " + maxCharacters + " personnages atteinte.");
return; return;
} }
String name = characters.nextDefaultName(accountUuid); CharacterService.CharacterCreationResult result =
CharacterService.CharacterCreationResult result = characters.createCharacter(accountUuid, name); characters.createDefaultCharacter(accountUuid, playerRef.getUsername());
if (!result.ok()) { if (!result.ok()) {
setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Creation impossible."); setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Création impossible.");
return; return;
} }
refresh(ref, store); refresh(ref, store);
} catch (SQLException e) { } catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character creation failed", e); LOGGER.log(Level.WARNING, "Character creation failed", e);
setStatus(ref, store, "Erreur lors de la creation du personnage."); setStatus(ref, store, "Erreur lors de la création du personnage.");
} }
} }
@@ -126,11 +148,52 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
} }
} }
private void handleDeleteRequest(
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID characterId) {
try {
var found = characters.findCharacter(accountUuid, characterId.toString());
if (found.isEmpty()) {
setStatus(ref, store, "Personnage introuvable.");
return;
}
pendingDeleteId = characterId;
pendingDeleteName = found.get().getDisplayName();
refresh(ref, store);
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character delete request failed", e);
setStatus(ref, store, "Erreur lors de la préparation de la suppression.");
}
}
private void handleConfirmDelete(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) {
if (pendingDeleteId == null) {
refresh(ref, store);
return;
}
try {
CharacterService.CharacterDeletionResult result =
characters.deleteCharacter(accountUuid, pendingDeleteId);
clearPendingDelete();
if (result.ok()) {
setStatus(ref, store, "Personnage supprimé : " + result.deletedName());
} else {
setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Suppression impossible.");
}
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character deletion failed", e);
clearPendingDelete();
setStatus(ref, store, "Erreur lors de la suppression du personnage.");
}
}
private void refresh(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) { private void refresh(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) {
UICommandBuilder ui = new UICommandBuilder(); UICommandBuilder ui = new UICommandBuilder();
UIEventBuilder events = new UIEventBuilder(); UIEventBuilder events = new UIEventBuilder();
bindActions(events); bindActions(events);
renderCharacterList(ui, events, loadCharacters()); renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
sendUpdate(ui, events, false); sendUpdate(ui, events, false);
} }
@@ -142,6 +205,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
UIEventBuilder events = new UIEventBuilder(); UIEventBuilder events = new UIEventBuilder();
bindActions(events); bindActions(events);
renderCharacterList(ui, events, loadCharacters()); renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
ui.set("#FooterHint.Text", escape(message)); ui.set("#FooterHint.Text", escape(message));
sendUpdate(ui, events, false); sendUpdate(ui, events, false);
} }
@@ -184,9 +248,37 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
EventData.of("Action", "select") EventData.of("Action", "select")
.append("CharacterId", profile.getCharacterId().toString()), .append("CharacterId", profile.getCharacterId().toString()),
false); false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#CharDelete" + slot,
EventData.of("Action", "delete")
.append("CharacterId", profile.getCharacterId().toString()),
false);
} }
} }
private void applyConfirmPanel(@Nonnull UICommandBuilder ui, @Nonnull UIEventBuilder events) {
if (pendingDeleteId == null || pendingDeleteName == null) {
ui.set("#ConfirmPanel.Visible", false);
return;
}
ui.set("#ConfirmPanel.Visible", true);
ui.set(
"#ConfirmMessage.Text",
escape("Supprimer « " + pendingDeleteName + " » ?\nInventaire et progression seront perdus."));
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#ConfirmDeleteButton",
EventData.of("Action", "confirm_delete"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#CancelDeleteButton",
EventData.of("Action", "cancel_delete"),
false);
}
private void bindActions(@Nonnull UIEventBuilder events) { private void bindActions(@Nonnull UIEventBuilder events) {
events.addEventBinding( events.addEventBinding(
CustomUIEventBindingType.Activating, CustomUIEventBindingType.Activating,
@@ -200,6 +292,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
false); false);
} }
private void clearPendingDelete() {
pendingDeleteId = null;
pendingDeleteName = null;
}
@Nonnull @Nonnull
private static String formatSummary(@Nonnull PlayerProfile profile) { private static String formatSummary(@Nonnull PlayerProfile profile) {
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
@@ -3,6 +3,7 @@
@MmorpgAccent = #f5c842; @MmorpgAccent = #f5c842;
@MmorpgText = #e8e8ec; @MmorpgText = #e8e8ec;
@MmorpgMuted = #878e9c; @MmorpgMuted = #878e9c;
@MmorpgDanger = #c9302c;
Group { Group {
LayoutMode: Center; LayoutMode: Center;
@@ -49,9 +50,14 @@ Group {
FlexWeight: 1; FlexWeight: 1;
} }
TextButton #CharDelete0 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect0 { TextButton #CharSelect0 {
Text: "Jouer"; Text: "Jouer";
Anchor: (Width: 100); Anchor: (Width: 88);
} }
} }
@@ -67,9 +73,14 @@ Group {
FlexWeight: 1; FlexWeight: 1;
} }
TextButton #CharDelete1 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect1 { TextButton #CharSelect1 {
Text: "Jouer"; Text: "Jouer";
Anchor: (Width: 100); Anchor: (Width: 88);
} }
} }
@@ -85,9 +96,14 @@ Group {
FlexWeight: 1; FlexWeight: 1;
} }
TextButton #CharDelete2 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect2 { TextButton #CharSelect2 {
Text: "Jouer"; Text: "Jouer";
Anchor: (Width: 100); Anchor: (Width: 88);
} }
} }
@@ -103,9 +119,14 @@ Group {
FlexWeight: 1; FlexWeight: 1;
} }
TextButton #CharDelete3 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect3 { TextButton #CharSelect3 {
Text: "Jouer"; Text: "Jouer";
Anchor: (Width: 100); Anchor: (Width: 88);
} }
} }
@@ -121,9 +142,43 @@ Group {
FlexWeight: 1; FlexWeight: 1;
} }
TextButton #CharDelete4 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect4 { TextButton #CharSelect4 {
Text: "Jouer"; Text: "Jouer";
Anchor: (Width: 100); Anchor: (Width: 88);
}
}
}
Group #ConfirmPanel {
LayoutMode: Top;
Visible: false;
Background: #2a1515(0.95);
Padding: (Full: 12);
Anchor: (Bottom: 8);
Label #ConfirmMessage {
Style: (FontSize: 14, TextColor: @MmorpgText, Wrap: true, HorizontalAlignment: Center);
Text: "Confirmer la suppression ?";
Anchor: (Bottom: 10);
}
Group #ConfirmActions {
LayoutMode: Left;
TextButton #ConfirmDeleteButton {
Text: "Confirmer la suppression";
FlexWeight: 1;
Anchor: (Right: 8);
}
TextButton #CancelDeleteButton {
Text: "Annuler";
FlexWeight: 1;
} }
} }
} }
+1 -2
View File
@@ -1,5 +1,4 @@
PORT=3000 PORT=3000
# Chemin vers la base SQLite du plugin MMORPG (relatif à Webapp/ ou absolu)
DB_PATH=../mmorpg.db DB_PATH=../mmorpg.db
# Secret pour les sessions de connexion (changez en production) HYTALE_ASSETS_ZIP=/home/bat/.var/app/com.hypixel.HytaleLauncher/data/Hytale/install/release/package/game/latest/Assets.zip
SESSION_SECRET=change-me-in-production SESSION_SECRET=change-me-in-production
+2
View File
@@ -52,6 +52,8 @@ Site accessible sur [http://localhost:3000](http://localhost:3000).
Pour modifier le mot de passe, réutilisez la commande en jeu. Pour modifier le mot de passe, réutilisez la commande en jeu.
Les icônes d'inventaire sont extraites de `Assets.zip` via la commande `unzip` (présente sur Arch : `pacman -S unzip`). Chemin auto via `Hytale/hytale.properties` ou `HYTALE_ASSETS_ZIP` dans `.env`.
## Stack ## Stack
- [Express](https://expressjs.com/) — serveur HTTP - [Express](https://expressjs.com/) — serveur HTTP
+10
View File
@@ -8,6 +8,7 @@
"name": "disklexar-web", "name": "disklexar-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -58,6 +59,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+199
View File
@@ -494,6 +494,201 @@ code {
color: #fecaca; color: #fecaca;
} }
.inventory-panel-embedded {
margin-bottom: 0;
padding: 0;
border: none;
box-shadow: none;
background: transparent;
}
.character-inventory-block {
margin-bottom: 1.5rem;
}
.character-inventory-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.character-inventory-head h2 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
.character-meta {
margin: 0;
}
.section-intro {
margin: 0;
}
.inventory-embedded-meta {
display: flex;
justify-content: flex-end;
margin-bottom: 0.75rem;
}
.inventory-panel {
margin-top: 0;
}
.inventory-sections {
display: grid;
grid-template-columns: minmax(88px, auto) minmax(88px, auto) 1fr;
grid-template-areas:
"hotbar hotbar hotbar"
"storage storage storage"
"armor utility .";
gap: 1.25rem;
align-items: start;
}
.inventory-section--hotbar {
grid-area: hotbar;
}
.inventory-section--storage {
grid-area: storage;
}
.inventory-section--armor {
grid-area: armor;
}
.inventory-section--utility {
grid-area: utility;
}
.inventory-section--hotbar .inventory-grid,
.inventory-section--storage .inventory-grid {
width: 100%;
max-width: none;
}
@media (max-width: 900px) {
.inventory-sections {
grid-template-columns: 1fr;
grid-template-areas:
"hotbar"
"storage"
"armor"
"utility";
}
.inventory-section--armor .inventory-grid,
.inventory-section--utility .inventory-grid {
max-width: 120px;
}
}
.inventory-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.65rem;
}
.inventory-section-head h3 {
margin: 0;
font-size: 0.95rem;
color: var(--accent);
}
.inventory-grid {
display: grid;
gap: 0.4rem;
}
.inventory-grid.cols-9 {
grid-template-columns: repeat(9, minmax(0, 1fr));
}
.inventory-grid.cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
max-width: 120px;
}
.inventory-slot {
aspect-ratio: 1;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
padding: 0.35rem;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 72px;
overflow: hidden;
}
.inventory-slot.empty {
opacity: 0.35;
}
.inventory-slot.filled {
border-color: rgba(94, 179, 255, 0.35);
background: rgba(94, 179, 255, 0.08);
align-items: center;
text-align: center;
}
.slot-icon {
width: 44px;
height: 44px;
object-fit: contain;
image-rendering: pixelated;
flex-shrink: 0;
}
.item-row {
display: flex;
align-items: center;
gap: 0.65rem;
}
.item-row-icon {
width: 36px;
height: 36px;
object-fit: contain;
image-rendering: pixelated;
flex-shrink: 0;
}
.slot-name {
font-size: 0.62rem;
line-height: 1.15;
font-weight: 600;
word-break: break-word;
margin-top: 0.15rem;
}
.slot-qty {
align-self: flex-end;
font-size: 0.72rem;
color: var(--accent);
font-weight: 700;
}
.slot-dur {
font-size: 0.62rem;
color: var(--text-muted);
}
.inventory-list {
margin-top: 1.25rem;
}
.inventory-list summary {
cursor: pointer;
color: var(--accent);
margin-bottom: 0.75rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -517,4 +712,8 @@ code {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
} }
.inventory-grid.cols-9 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Objet inconnu">
<rect width="64" height="64" rx="8" fill="#1c2430"/>
<rect x="8" y="8" width="48" height="48" rx="6" fill="#2a3544" stroke="#3a4554"/>
<path d="M22 42 L32 22 L42 42 Z" fill="none" stroke="#5eb3ff" stroke-width="3" stroke-linejoin="round"/>
<circle cx="32" cy="46" r="3" fill="#878e9c"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

+94 -1
View File
@@ -8,12 +8,28 @@ const auth = require("./src/auth");
const labels = require("./src/labels"); const labels = require("./src/labels");
const fmt = require("./src/format"); const fmt = require("./src/format");
const inventory = require("./src/inventory");
const itemIcons = require("./src/itemIcons");
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views")); app.set("views", path.join(__dirname, "views"));
app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(path.join(__dirname, "public")));
app.get("/icons/items/:itemId.png", (req, res) => {
const itemId = decodeURIComponent(req.params.itemId);
const data = itemIcons.getIconData(itemId);
if (!data) {
res.type("image/svg+xml");
return res.sendFile(path.join(__dirname, "public/icons/unknown.svg"));
}
res.set("Content-Type", "image/png");
res.set("Cache-Control", "public, max-age=86400");
res.send(data);
});
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
app.use( app.use(
@@ -38,6 +54,8 @@ app.use((req, res, next) => {
]; ];
res.locals.fmt = fmt; res.locals.fmt = fmt;
res.locals.auth = auth; res.locals.auth = auth;
res.locals.db = db;
res.locals.itemIcons = itemIcons;
res.locals.dbStatus = db.getDbStatus(); res.locals.dbStatus = db.getDbStatus();
res.locals.sessionUser = req.session.playerUuid res.locals.sessionUser = req.session.playerUuid
? { ? {
@@ -48,6 +66,67 @@ app.use((req, res, next) => {
next(); next();
}); });
function loadPlayerInventory(player) {
const characterId = inventory.resolveCharacterId(player);
const slots = db.getCharacterInventory(characterId);
return inventory.buildInventoryView(slots);
}
function enrichCharacter(character) {
if (!character) return null;
const classLabel = labels.labelClass(
character.class_id,
db.getReferenceLabel("classes", character.class_id),
);
const raceLabel = labels.labelRace(
character.race_id,
db.getReferenceLabel("races", character.race_id),
);
const jobs = labels.parseJsonArray(character.jobs).map(labels.labelJob);
const powers = labels.parseJsonArray(character.powers).map(labels.labelPower);
return {
...character,
display_name: character.name ?? character.display_name,
classLabel,
raceLabel,
jobs,
powers,
};
}
function loadAccountCharacters(accountUuid) {
if (db.hasCharactersTable()) {
const activeCharacterId = db.getActiveCharacterId(accountUuid);
return db.getCharactersByAccountUuid(accountUuid).map((character) => {
const enriched = enrichCharacter(character);
return {
...enriched,
character_id: character.id,
isActive: character.id === activeCharacterId,
playerInventory: inventory.buildInventoryView(
db.getCharacterInventory(character.id),
),
};
});
}
const player = enrichPlayer(db.getPlayerByUuid(accountUuid));
if (!player) {
return [];
}
return [
{
...player,
character_id: inventory.resolveCharacterId(player),
isActive: true,
playerInventory: loadPlayerInventory(player),
},
];
}
function enrichPlayer(player) { function enrichPlayer(player) {
if (!player) return null; if (!player) return null;
@@ -120,7 +199,7 @@ app.get("/players/:uuid", (req, res) => {
title: player.display_name, title: player.display_name,
player, player,
group, group,
comingSoon: true, comingSoon: false,
isOwner: req.session.playerUuid === player.uuid, isOwner: req.session.playerUuid === player.uuid,
}); });
}); });
@@ -146,12 +225,14 @@ app.get("/profile", (req, res) => {
} }
const group = db.getGroupForPlayer(player.uuid); const group = db.getGroupForPlayer(player.uuid);
const characters = loadAccountCharacters(req.session.playerUuid);
return res.render("profile/account", { return res.render("profile/account", {
page: "profile", page: "profile",
title: "Mon profil", title: "Mon profil",
player, player,
group, group,
characters,
}); });
} }
@@ -256,4 +337,16 @@ app.listen(PORT, () => {
console.warn(`Base SQLite introuvable : ${status.path}`); console.warn(`Base SQLite introuvable : ${status.path}`);
console.warn("Lancez le serveur Hytale pour créer mmorpg.db à la racine du monorepo."); console.warn("Lancez le serveur Hytale pour créer mmorpg.db à la racine du monorepo.");
} }
const icons = itemIcons.getStatus();
if (!icons.available) {
console.warn("Icônes Hytale indisponibles.");
if (icons.error) {
console.warn(`${icons.error}`);
}
console.warn(" → Définissez HYTALE_ASSETS_ZIP dans Webapp/.env si besoin.");
} else {
console.log(
`Icônes Hytale (${icons.backend}) : ${icons.indexedIcons} PNG indexés, ${icons.indexedItems} items — ${icons.assetsZip}`,
);
}
}); });
+68
View File
@@ -54,6 +54,69 @@ function hasCharactersTable() {
return Boolean(row); return Boolean(row);
} }
function hasInventoryTable() {
const connection = getDb();
if (!connection) {
return false;
}
const row = queryOne(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'character_inventory'",
);
return Boolean(row);
}
function getCharacterInventory(characterId) {
if (!characterId || !hasInventoryTable()) {
return [];
}
return queryAll(
`
SELECT section_id, slot_index, item_id, quantity, durability, max_durability, metadata
FROM character_inventory
WHERE character_id = ?
ORDER BY section_id, slot_index
`,
[characterId],
);
}
function getActiveCharacterId(accountUuid) {
const row = queryOne(
`SELECT active_character_id FROM player_profiles WHERE uuid = ?`,
[accountUuid],
);
return row?.active_character_id ?? null;
}
function getCharactersByAccountUuid(accountUuid) {
if (!hasCharactersTable()) {
return [];
}
return queryAll(
`
SELECT
id,
account_uuid,
name,
level,
experience,
class_id,
powers,
jobs,
guild_id,
group_id,
money,
race_id,
date_creation,
updated_at
FROM characters
WHERE account_uuid = ?
ORDER BY name COLLATE NOCASE ASC
`,
[accountUuid],
);
}
function getDashboardStats() { function getDashboardStats() {
const connection = getDb(); const connection = getDb();
if (!connection) { if (!connection) {
@@ -286,6 +349,11 @@ module.exports = {
getPlayerByUuid, getPlayerByUuid,
getPlayerAuthByDisplayName, getPlayerAuthByDisplayName,
hasPasswordColumn, hasPasswordColumn,
hasCharactersTable,
hasInventoryTable,
getCharacterInventory,
getActiveCharacterId,
getCharactersByAccountUuid,
getGroupForPlayer, getGroupForPlayer,
getReferenceLabel, getReferenceLabel,
}; };
+84
View File
@@ -0,0 +1,84 @@
const itemIcons = require("./itemIcons");
/** Hytale inventory section ids (InventoryComponent). */
const SECTIONS = [
{ id: -1, key: "hotbar", label: "Barre rapide", slots: 9, cols: 9 },
{ id: -2, key: "storage", label: "Sac", slots: 36, cols: 9 },
{ id: -3, key: "armor", label: "Armure", slots: 4, cols: 1 },
{ id: -5, key: "utility", label: "Utilitaire", slots: 4, cols: 1 },
];
function formatItemId(itemId) {
if (!itemId) {
return "Inconnu";
}
let trimmed = itemId;
if (trimmed.startsWith("Weapon_")) {
trimmed = trimmed.slice("Weapon_".length);
} else if (trimmed.startsWith("Tool_")) {
trimmed = trimmed.slice("Tool_".length);
} else if (trimmed.startsWith("Armor_")) {
trimmed = trimmed.slice("Armor_".length);
}
return trimmed.replaceAll("_", " ");
}
function enrichSlot(row) {
const durability =
row.durability != null && row.max_durability != null
? `${Math.round(row.durability)} / ${Math.round(row.max_durability)}`
: null;
return {
sectionId: row.section_id,
slotIndex: row.slot_index,
itemId: row.item_id,
name: formatItemId(row.item_id),
iconUrl: itemIcons.getIconUrl(row.item_id),
quantity: row.quantity,
durability,
};
}
function buildInventoryView(slots) {
const byKey = new Map();
for (const row of slots) {
const key = `${row.section_id}:${row.slot_index}`;
byKey.set(key, enrichSlot(row));
}
const sections = SECTIONS.map((section) => {
const cells = [];
for (let slot = 0; slot < section.slots; slot += 1) {
cells.push(byKey.get(`${section.id}:${slot}`) ?? null);
}
const used = cells.filter(Boolean).length;
return {
...section,
cells,
used,
capacity: section.slots,
};
});
const totalItems = slots.length;
return {
sections,
totalItems,
isEmpty: totalItems === 0,
};
}
function resolveCharacterId(player) {
if (!player) {
return null;
}
return player.character_id || player.uuid;
}
module.exports = {
buildInventoryView,
resolveCharacterId,
formatItemId,
};
+316
View File
@@ -0,0 +1,316 @@
const fs = require("fs");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
const PLUGIN_ITEMS_DIR = path.resolve(
__dirname,
"../../Hytale/plugins/mmorpg/src/main/resources/Server/Item/Items",
);
const HYTALE_PROPS = path.resolve(__dirname, "../../Hytale/hytale.properties");
const GRADLE_PROPS = path.resolve(__dirname, "../../Hytale/gradle.properties");
/** @type {Map<string, string[]>} */
const iconPathIndex = new Map();
/** @type {Map<string, Buffer>} */
const iconCache = new Map();
/** @type {Set<string>} */
let zipEntries = null;
let zipBackend = null;
let assetsZipPath = null;
let indexedIcons = 0;
let lastError = null;
function readProperty(filePath, key) {
if (!fs.existsSync(filePath)) {
return null;
}
const line = fs
.readFileSync(filePath, "utf8")
.split("\n")
.map((entry) => entry.trim())
.find((entry) => entry.startsWith(`${key}=`));
return line ? line.slice(key.length + 1).trim() : null;
}
function commandAvailable(command) {
const result = spawnSync("sh", ["-c", `command -v ${command}`], {
encoding: "utf8",
});
return result.status === 0;
}
function normalizeEntryName(name) {
return name.replace(/\\/g, "/").replace(/^\/+/, "");
}
function resolveIconPath(iconField, itemId) {
const candidates = [];
if (iconField) {
const icon = iconField.replace(/\\/g, "/").replace(/^\/+/, "");
candidates.push(
icon.startsWith("Common/") ? icon : `Common/${icon}`,
icon,
);
}
candidates.push(
`Common/Icons/ItemsGenerated/${itemId}.png`,
`Icons/ItemsGenerated/${itemId}.png`,
);
return [...new Set(candidates.map(normalizeEntryName))];
}
function findAssetsZipInHome(home) {
if (!home || !fs.existsSync(home)) {
return null;
}
try {
const output = execFileSync(
"find",
[home, "-name", "Assets.zip", "-size", "+1M"],
{ encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
);
return (
output
.split("\n")
.map((line) => line.trim())
.find(Boolean) ?? null
);
} catch {
return null;
}
}
function detectAssetsZip() {
if (process.env.HYTALE_ASSETS_ZIP) {
const configured = path.resolve(process.env.HYTALE_ASSETS_ZIP);
if (fs.existsSync(configured)) {
return configured;
}
lastError = `HYTALE_ASSETS_ZIP introuvable : ${configured}`;
return null;
}
const home =
process.env.HYTALE_HOME ||
readProperty(HYTALE_PROPS, "hytale.home_path") ||
readProperty(GRADLE_PROPS, "hytale.home_path");
if (!home) {
lastError = "hytale.home_path introuvable (Hytale/hytale.properties)";
return null;
}
const candidates = [
path.join(home, "install/release/package/game/latest/Assets.zip"),
path.join(home, "Assets.zip"),
findAssetsZipInHome(home),
].filter(Boolean);
const found = candidates.find(
(candidate) => fs.existsSync(candidate) && fs.statSync(candidate).size > 0,
);
if (!found) {
lastError = `Assets.zip introuvable sous ${home}`;
return null;
}
return found;
}
function listZipEntries(zipPath) {
const output = execFileSync("unzip", ["-Z1", zipPath], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
return output.split("\n").map(normalizeEntryName).filter(Boolean);
}
function readZipEntry(zipPath, entryPath) {
return execFileSync("unzip", ["-p", zipPath, normalizeEntryName(entryPath)], {
maxBuffer: 16 * 1024 * 1024,
});
}
function entryExists(entryPath) {
if (!zipEntries) {
return false;
}
const normalized = normalizeEntryName(entryPath);
return (
zipEntries.has(normalized) ||
zipEntries.has(`/${normalized}`) ||
zipEntries.has(normalized.toLowerCase())
);
}
function pickExistingEntry(candidates) {
for (const candidate of candidates) {
const normalized = normalizeEntryName(candidate);
if (zipEntries?.has(normalized)) {
return normalized;
}
if (zipEntries?.has(`/${normalized}`)) {
return `/${normalized}`;
}
}
return candidates[0] ?? null;
}
function registerIconPaths(itemId, paths) {
const existing = iconPathIndex.get(itemId) ?? [];
iconPathIndex.set(itemId, [...new Set([...existing, ...paths])]);
}
function indexItemJson(itemId, jsonText) {
try {
const parsed = JSON.parse(jsonText);
registerIconPaths(itemId, resolveIconPath(parsed.Icon, itemId));
} catch {
registerIconPaths(itemId, resolveIconPath(null, itemId));
}
}
function indexPluginItems() {
if (!fs.existsSync(PLUGIN_ITEMS_DIR)) {
return;
}
for (const fileName of fs.readdirSync(PLUGIN_ITEMS_DIR)) {
if (!fileName.endsWith(".json")) {
continue;
}
const itemId = fileName.slice(0, -".json".length);
const jsonText = fs.readFileSync(path.join(PLUGIN_ITEMS_DIR, fileName), "utf8");
indexItemJson(itemId, jsonText);
}
}
function indexIconPathsFromListing(entries) {
const iconPattern = /(?:^|\/)Icons\/ItemsGenerated\/(.+)\.png$/i;
for (const entryName of entries) {
const match = entryName.match(iconPattern);
if (!match) {
continue;
}
registerIconPaths(match[1], [entryName]);
indexedIcons += 1;
}
}
function loadItemDefinition(itemId) {
if (!assetsZipPath || iconPathIndex.has(itemId)) {
return;
}
const jsonCandidates = [
`Server/Item/Items/${itemId}.json`,
`server/Item/Items/${itemId}.json`,
];
for (const jsonPath of jsonCandidates) {
if (!entryExists(jsonPath)) {
continue;
}
try {
const jsonText = readZipEntry(assetsZipPath, jsonPath).toString("utf8");
indexItemJson(itemId, jsonText);
return;
} catch {
// Try next path.
}
}
registerIconPaths(itemId, resolveIconPath(null, itemId));
}
function initAssets() {
assetsZipPath = detectAssetsZip();
if (!assetsZipPath) {
return;
}
if (!commandAvailable("unzip")) {
lastError = "Commande unzip absente — installez le paquet unzip (Arch: pacman -S unzip)";
return;
}
try {
const entries = listZipEntries(assetsZipPath);
zipEntries = new Set(entries);
indexIconPathsFromListing(entries);
indexPluginItems();
zipBackend = "unzip-cli";
} catch (error) {
lastError = error.message;
zipBackend = null;
zipEntries = null;
}
}
function getCandidates(itemId) {
if (!iconPathIndex.has(itemId)) {
loadItemDefinition(itemId);
}
return iconPathIndex.get(itemId) ?? resolveIconPath(null, itemId);
}
function getIconData(itemId) {
if (!itemId || !isAvailable()) {
return null;
}
if (iconCache.has(itemId)) {
return iconCache.get(itemId);
}
const entryPath = pickExistingEntry(getCandidates(itemId));
if (!entryPath) {
return null;
}
try {
const data = readZipEntry(assetsZipPath, entryPath);
if (!data?.length) {
return null;
}
iconCache.set(itemId, data);
return data;
} catch {
return null;
}
}
function getIconUrl(itemId) {
if (!itemId || !isAvailable()) {
return "/icons/unknown.svg";
}
return `/icons/items/${encodeURIComponent(itemId)}.png`;
}
function isAvailable() {
return Boolean(assetsZipPath && zipBackend);
}
function getStatus() {
return {
available: isAvailable(),
backend: zipBackend,
assetsZip: assetsZipPath,
indexedIcons,
indexedItems: iconPathIndex.size,
error: lastError,
};
}
initAssets();
module.exports = {
getIconData,
getIconUrl,
isAvailable,
getStatus,
};
@@ -0,0 +1,30 @@
<% if (characters.length === 0) { %>
<section class="panel">
<p class="empty">Aucun personnage trouvé pour ce compte.</p>
</section>
<% } else { %>
<% characters.forEach(function(character) { %>
<section class="panel character-inventory-block">
<div class="character-inventory-head">
<div>
<h2><%= character.display_name %></h2>
<p class="muted character-meta">
Niveau <%= character.level %>
· <%= character.classLabel %>
· <%= character.raceLabel %>
· <%= fmt.formatMoney(character.money) %> money
</p>
</div>
<% if (character.isActive) { %>
<span class="tag tag-online">Personnage actif</span>
<% } %>
</div>
<%- include('inventory-panel', {
playerInventory: character.playerInventory,
panelTitle: null,
embedded: true
}) %>
</section>
<% }); %>
<% } %>
+99
View File
@@ -0,0 +1,99 @@
<section class="panel inventory-panel<%= typeof embedded !== 'undefined' && embedded ? ' inventory-panel-embedded' : '' %>">
<% if (typeof embedded === 'undefined' || !embedded) { %>
<div class="panel-header">
<h2><%= typeof panelTitle !== 'undefined' && panelTitle ? panelTitle : 'Inventaire' %></h2>
<% if (!playerInventory.isEmpty) { %>
<span class="badge"><%= playerInventory.totalItems %> objet(s)</span>
<% } %>
</div>
<% } else if (!playerInventory.isEmpty) { %>
<div class="inventory-embedded-meta">
<span class="badge"><%= playerInventory.totalItems %> objet(s)</span>
</div>
<% } %>
<% if (!db.hasInventoryTable()) { %>
<p class="empty">Table inventaire absente. Redémarrez le serveur Hytale pour appliquer la migration personnages.</p>
<% } else if (playerInventory.isEmpty) { %>
<p class="empty">Inventaire vide. Les objets sont enregistrés à la déconnexion du personnage en jeu.</p>
<% } else { %>
<div class="inventory-sections">
<% playerInventory.sections.forEach(function(section) { %>
<div class="inventory-section inventory-section--<%= section.key %>">
<div class="inventory-section-head">
<h3><%= section.label %></h3>
<span class="muted"><%= section.used %> / <%= section.capacity %></span>
</div>
<div class="inventory-grid cols-<%= section.cols %>">
<% section.cells.forEach(function(item) { %>
<% if (item) { %>
<div class="inventory-slot filled" title="<%= item.name %> · <%= item.itemId %>">
<img
class="slot-icon"
src="<%= item.iconUrl %>"
alt="<%= item.name %>"
loading="lazy"
onerror="this.onerror=null;this.src='/icons/unknown.svg';"
>
<span class="slot-name"><%= item.name %></span>
<% if (item.quantity > 1) { %>
<span class="slot-qty">×<%= item.quantity %></span>
<% } %>
<% if (item.durability) { %>
<span class="slot-dur"><%= item.durability %></span>
<% } %>
</div>
<% } else { %>
<div class="inventory-slot empty" aria-hidden="true"></div>
<% } %>
<% }); %>
</div>
</div>
<% }); %>
</div>
<details class="inventory-list">
<summary>Liste détaillée</summary>
<table class="table">
<thead>
<tr>
<th>Section</th>
<th>Emplacement</th>
<th>Objet</th>
<th>Qté</th>
<th>Durabilité</th>
</tr>
</thead>
<tbody>
<% playerInventory.sections.forEach(function(section) { %>
<% section.cells.forEach(function(item, index) { %>
<% if (item) { %>
<tr>
<td><%= section.label %></td>
<td><%= index + 1 %></td>
<td>
<div class="item-row">
<img
class="item-row-icon"
src="<%= item.iconUrl %>"
alt=""
loading="lazy"
onerror="this.onerror=null;this.src='/icons/unknown.svg';"
>
<div>
<span class="player-name"><%= item.name %></span>
<small class="muted"><%= item.itemId %></small>
</div>
</div>
</td>
<td><%= item.quantity %></td>
<td><%= item.durability || '—' %></td>
</tr>
<% } %>
<% }); %>
<% }); %>
</tbody>
</table>
</details>
<% } %>
</section>
+1 -1
View File
@@ -2,7 +2,7 @@
<%- include('../partials/page-header', { title: player.display_name, subtitle: 'Profil joueur' }) %> <%- include('../partials/page-header', { title: player.display_name, subtitle: 'Profil joueur' }) %>
<%- include('../partials/player-card', { player, group, comingSoon }) %> <%- include('../partials/player-card', { player, group, comingSoon: false }) %>
<p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p> <p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p>
+8 -3
View File
@@ -11,9 +11,14 @@
<%- include('../partials/player-card', { player, group, comingSoon: false }) %> <%- include('../partials/player-card', { player, group, comingSoon: false }) %>
<section class="panel coming-soon"> <section class="panel">
<h2>À venir</h2> <div class="panel-header">
<p>Inventaire, historique de combat et gestion avancée du compte seront ajoutés prochainement.</p> <h2>Mes personnages</h2>
<span class="badge"><%= characters.length %></span>
</div>
<p class="muted section-intro">Inventaire sauvegardé à la déconnexion de chaque personnage en jeu.</p>
</section> </section>
<%- include('../partials/characters-inventories', { characters }) %>
<%- include('../partials/page-end') %> <%- include('../partials/page-end') %>