+1
-2
@@ -1,5 +1,4 @@
|
||||
PORT=3000
|
||||
# Chemin vers la base SQLite du plugin MMORPG (relatif à Webapp/ ou absolu)
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
- [Express](https://expressjs.com/) — serveur HTTP
|
||||
|
||||
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "disklexar-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -58,6 +59,15 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
@@ -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>
|
||||
@@ -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 }) %>
|
||||
|
||||
<p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p>
|
||||
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
|
||||
<%- include('../partials/player-card', { player, group, comingSoon: false }) %>
|
||||
|
||||
<section class="panel coming-soon">
|
||||
<h2>À venir</h2>
|
||||
<p>Inventaire, historique de combat et gestion avancée du compte seront ajoutés prochainement.</p>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<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>
|
||||
|
||||
<%- include('../partials/characters-inventories', { characters }) %>
|
||||
|
||||
<%- include('../partials/page-end') %>
|
||||
|
||||
Reference in New Issue
Block a user