diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java index 3862d8c..cc54516 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java @@ -110,11 +110,13 @@ public final class CharacterCommand extends AbstractCommandCollection { } try { String name = context.get(nameArg); + CharacterService.CharacterCreationResult result; 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) { context.sendMessage(Message.raw( "Personnage créé : " + result.profile().getDisplayName())); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java index db7e6e3..49be335 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java @@ -109,6 +109,15 @@ public final class CharacterRepository { 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 private List findByQuery(@Nonnull String whereClause, @Nonnull String... params) throws SQLException { diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java index 2e3241f..8d85d1d 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java @@ -87,6 +87,25 @@ public final class CharacterService { if (name == null) { 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()) { return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount()); } @@ -105,9 +124,54 @@ public final class CharacterService { } @Nonnull - public String nextDefaultName(@Nonnull UUID accountUuid) throws SQLException { - int count = characters.countByAccount(accountUuid); - return "Perso " + (count + 1); + public String defaultCharacterName( + @Nonnull UUID accountUuid, + @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 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( @@ -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 private static String normalizeName(@Nonnull String rawName) { String name = rawName.trim(); @@ -269,4 +345,18 @@ public final class CharacterService { 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."); + } + } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java index 4361cbe..2f91027 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java @@ -29,9 +29,6 @@ import java.util.logging.Logger; /** * Mandatory character selector shown on join before gameplay begins. - *

- * 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 { @@ -44,6 +41,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage ref, + @Nonnull Store 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 ref, @Nonnull Store 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 ref, @Nonnull Store store) { UICommandBuilder ui = new UICommandBuilder(); UIEventBuilder events = new UIEventBuilder(); bindActions(events); renderCharacterList(ui, events, loadCharacters()); + applyConfirmPanel(ui, events); sendUpdate(ui, events, false); } @@ -142,6 +205,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage= 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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", diff --git a/Webapp/public/css/style.css b/Webapp/public/css/style.css index 41c94c7..1d1b68e 100644 --- a/Webapp/public/css/style.css +++ b/Webapp/public/css/style.css @@ -494,6 +494,201 @@ code { 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) { .layout { grid-template-columns: 1fr; @@ -517,4 +712,8 @@ code { display: block; overflow-x: auto; } + + .inventory-grid.cols-9 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } diff --git a/Webapp/public/icons/unknown.svg b/Webapp/public/icons/unknown.svg new file mode 100644 index 0000000..57e5264 --- /dev/null +++ b/Webapp/public/icons/unknown.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Webapp/server.js b/Webapp/server.js index c96b2e0..2c668a8 100644 --- a/Webapp/server.js +++ b/Webapp/server.js @@ -8,12 +8,28 @@ const auth = require("./src/auth"); const labels = require("./src/labels"); const fmt = require("./src/format"); +const inventory = require("./src/inventory"); +const itemIcons = require("./src/itemIcons"); + const app = express(); const PORT = process.env.PORT || 3000; app.set("view engine", "ejs"); app.set("views", path.join(__dirname, "views")); 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( @@ -38,6 +54,8 @@ app.use((req, res, next) => { ]; res.locals.fmt = fmt; res.locals.auth = auth; + res.locals.db = db; + res.locals.itemIcons = itemIcons; res.locals.dbStatus = db.getDbStatus(); res.locals.sessionUser = req.session.playerUuid ? { @@ -48,6 +66,67 @@ app.use((req, res, 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) { if (!player) return null; @@ -120,7 +199,7 @@ app.get("/players/:uuid", (req, res) => { title: player.display_name, player, group, - comingSoon: true, + comingSoon: false, isOwner: req.session.playerUuid === player.uuid, }); }); @@ -146,12 +225,14 @@ app.get("/profile", (req, res) => { } const group = db.getGroupForPlayer(player.uuid); + const characters = loadAccountCharacters(req.session.playerUuid); return res.render("profile/account", { page: "profile", title: "Mon profil", player, group, + characters, }); } @@ -256,4 +337,16 @@ app.listen(PORT, () => { console.warn(`Base SQLite introuvable : ${status.path}`); 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}`, + ); + } }); diff --git a/Webapp/src/db.js b/Webapp/src/db.js index e5d340d..24376ac 100644 --- a/Webapp/src/db.js +++ b/Webapp/src/db.js @@ -54,6 +54,69 @@ function hasCharactersTable() { 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() { const connection = getDb(); if (!connection) { @@ -286,6 +349,11 @@ module.exports = { getPlayerByUuid, getPlayerAuthByDisplayName, hasPasswordColumn, + hasCharactersTable, + hasInventoryTable, + getCharacterInventory, + getActiveCharacterId, + getCharactersByAccountUuid, getGroupForPlayer, getReferenceLabel, }; diff --git a/Webapp/src/inventory.js b/Webapp/src/inventory.js new file mode 100644 index 0000000..7bfb18f --- /dev/null +++ b/Webapp/src/inventory.js @@ -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, +}; diff --git a/Webapp/src/itemIcons.js b/Webapp/src/itemIcons.js new file mode 100644 index 0000000..d4b9e86 --- /dev/null +++ b/Webapp/src/itemIcons.js @@ -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} */ +const iconPathIndex = new Map(); +/** @type {Map} */ +const iconCache = new Map(); +/** @type {Set} */ +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, +}; diff --git a/Webapp/views/partials/characters-inventories.ejs b/Webapp/views/partials/characters-inventories.ejs new file mode 100644 index 0000000..4532f2e --- /dev/null +++ b/Webapp/views/partials/characters-inventories.ejs @@ -0,0 +1,30 @@ +<% if (characters.length === 0) { %> +

+

Aucun personnage trouvé pour ce compte.

+
+<% } else { %> + <% characters.forEach(function(character) { %> +
+
+
+

<%= character.display_name %>

+

+ Niveau <%= character.level %> + · <%= character.classLabel %> + · <%= character.raceLabel %> + · <%= fmt.formatMoney(character.money) %> money +

+
+ <% if (character.isActive) { %> + Personnage actif + <% } %> +
+ + <%- include('inventory-panel', { + playerInventory: character.playerInventory, + panelTitle: null, + embedded: true + }) %> +
+ <% }); %> +<% } %> diff --git a/Webapp/views/partials/inventory-panel.ejs b/Webapp/views/partials/inventory-panel.ejs new file mode 100644 index 0000000..f9fd3f4 --- /dev/null +++ b/Webapp/views/partials/inventory-panel.ejs @@ -0,0 +1,99 @@ +
+ <% if (typeof embedded === 'undefined' || !embedded) { %> +
+

<%= typeof panelTitle !== 'undefined' && panelTitle ? panelTitle : 'Inventaire' %>

+ <% if (!playerInventory.isEmpty) { %> + <%= playerInventory.totalItems %> objet(s) + <% } %> +
+ <% } else if (!playerInventory.isEmpty) { %> +
+ <%= playerInventory.totalItems %> objet(s) +
+ <% } %> + + <% if (!db.hasInventoryTable()) { %> +

Table inventaire absente. Redémarrez le serveur Hytale pour appliquer la migration personnages.

+ <% } else if (playerInventory.isEmpty) { %> +

Inventaire vide. Les objets sont enregistrés à la déconnexion du personnage en jeu.

+ <% } else { %> +
+ <% playerInventory.sections.forEach(function(section) { %> +
+
+

<%= section.label %>

+ <%= section.used %> / <%= section.capacity %> +
+
+ <% section.cells.forEach(function(item) { %> + <% if (item) { %> +
+ <%= item.name %> + <%= item.name %> + <% if (item.quantity > 1) { %> + ×<%= item.quantity %> + <% } %> + <% if (item.durability) { %> + <%= item.durability %> + <% } %> +
+ <% } else { %> + + <% } %> + <% }); %> +
+
+ <% }); %> +
+ +
+ Liste détaillée + + + + + + + + + + + + <% playerInventory.sections.forEach(function(section) { %> + <% section.cells.forEach(function(item, index) { %> + <% if (item) { %> + + + + + + + + <% } %> + <% }); %> + <% }); %> + +
SectionEmplacementObjetQtéDurabilité
<%= section.label %><%= index + 1 %> +
+ +
+ <%= item.name %> + <%= item.itemId %> +
+
+
<%= item.quantity %><%= item.durability || '—' %>
+
+ <% } %> +
diff --git a/Webapp/views/players/profile.ejs b/Webapp/views/players/profile.ejs index a586323..0328f83 100644 --- a/Webapp/views/players/profile.ejs +++ b/Webapp/views/players/profile.ejs @@ -2,7 +2,7 @@ <%- 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 }) %> diff --git a/Webapp/views/profile/account.ejs b/Webapp/views/profile/account.ejs index 704fc8b..da5c39e 100644 --- a/Webapp/views/profile/account.ejs +++ b/Webapp/views/profile/account.ejs @@ -11,9 +11,14 @@ <%- include('../partials/player-card', { player, group, comingSoon: false }) %> -
-

À venir

-

Inventaire, historique de combat et gestion avancée du compte seront ajoutés prochainement.

+
+
+

Mes personnages

+ <%= characters.length %> +
+

Inventaire sauvegardé à la déconnexion de chaque personnage en jeu.

+<%- include('../partials/characters-inventories', { characters }) %> + <%- include('../partials/page-end') %>