Merge pull request #161 from duckietm/main

Sync Main to DEV
This commit is contained in:
DuckieTM
2026-06-08 07:31:07 +02:00
committed by GitHub
9 changed files with 607 additions and 35 deletions
@@ -0,0 +1,42 @@
-- 021_furnidata_config_cleanup.sql
-- Reverts the emulator_settings rows inserted by 021_furnidata_config.sql.
--
-- Safe default:
-- This script ends with ROLLBACK. Run it once to preview the exact rows, then
-- change the final ROLLBACK to COMMIT only if the preview is correct.
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS cleanup_furnidata_settings;
CREATE TEMPORARY TABLE cleanup_furnidata_settings (
`key` VARCHAR(255) NOT NULL PRIMARY KEY
);
INSERT INTO cleanup_furnidata_settings (`key`) VALUES
('items.furnidata.names.enabled'),
('items.furnidata.path'),
('items.furnidata.max.bytes'),
('items.furnidata.watch.enabled'),
('items.furnidata.watch.debounce.ms'),
('items.furnidata.watch.min.interval.ms'),
('items.furnidata.delta.cap'),
('furni.editor.import.url'),
('furni.editor.import.cache.ms');
-- Preview rows that will be removed.
SELECT es.`key`, es.`value`
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`
ORDER BY es.`key`;
DELETE es
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Preview remaining matching rows inside the transaction.
SELECT COUNT(*) AS remaining_furnidata_settings
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Safe default. Change to COMMIT after reviewing the preview.
ROLLBACK;
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.2.34</version>
<version>4.2.39</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -109,13 +109,13 @@ public class FurnidataReader {
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject o = el.getAsJsonObject();
if (!o.has("id") || !o.has("classname")) continue;
if (!o.has("id") || o.get("id").isJsonNull() || !o.has("classname") || o.get("classname").isJsonNull()) continue;
out.add(new FurnidataEntry(
o.get("id").getAsInt(),
o.get("classname").getAsString(),
type,
o.has("name") ? o.get("name").getAsString() : "",
o.has("description") ? o.get("description").getAsString() : ""
(o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "",
(o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : ""
));
}
}
@@ -0,0 +1,172 @@
package com.eu.habbo.habbohotel.items;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class FurnidataSourceResolver {
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataSourceResolver.class);
public enum Status {
RESOLVED,
SOURCE_MISSING,
CONFIG_MISSING,
UNRESOLVED_PLACEHOLDER,
ERROR
}
public record Source(Path path, boolean directory, Status status, String message) {
public boolean ok() {
return this.status == Status.RESOLVED && this.path != null && Files.exists(this.path);
}
}
private FurnidataSourceResolver() {
}
public static Source resolve() {
try {
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
if (!override.isEmpty()) {
Path p = Paths.get(override);
if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path");
return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path does not exist");
}
String rendererConfigPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", "");
String assetBasePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!rendererConfigPath.isEmpty()) {
Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath.isEmpty() ? null : Paths.get(assetBasePath));
if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer;
}
Source fallback = resolveFromAssetBase(assetBasePath);
if (fallback != null) return fallback;
return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found");
} catch (Exception e) {
LOGGER.warn("FurnidataSourceResolver failed", e);
return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "Resolver error");
}
}
public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) {
try {
if (rendererConfig == null || !Files.exists(rendererConfig)) {
return new Source(rendererConfig, false, Status.SOURCE_MISSING, "renderer-config path does not exist");
}
String raw = Files.readString(rendererConfig, StandardCharsets.UTF_8);
JsonObject rendererObj = JsonParser.parseString(FurnidataReader.stripJson5(raw)).getAsJsonObject();
String furniUrl = expandRendererUrl(rendererObj, "furnidata.url");
if (furniUrl.isBlank()) return new Source(null, false, Status.CONFIG_MISSING, "furnidata.url is missing");
if (hasUnresolvedPathPlaceholder(furniUrl)) return new Source(null, false, Status.UNRESOLVED_PLACEHOLDER, furniUrl);
Source source = toLocalSource(assetBase, furniUrl);
if (source == null) return new Source(null, false, Status.CONFIG_MISSING, "furni.editor.asset.base.path is missing");
if (!Files.exists(source.path())) return new Source(source.path(), source.directory(), Status.SOURCE_MISSING, "Resolved source does not exist");
return source;
} catch (Exception e) {
return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "renderer-config parse failed");
}
}
private static Source resolveFromAssetBase(String assetBasePath) {
if (assetBasePath == null || assetBasePath.isEmpty()) return null;
Path dir = Paths.get(assetBasePath);
Path split = dir.resolve("furnidata");
if (Files.isDirectory(split)) return new Source(split, true, Status.RESOLVED, "asset base split furnidata");
Path legacy = dir.resolve("FurnitureData.json");
if (Files.exists(legacy)) return new Source(legacy, false, Status.RESOLVED, "asset base FurnitureData.json");
return new Source(dir, true, Status.SOURCE_MISSING, "No furnidata or FurnitureData.json under asset base");
}
public static String expandRendererUrl(JsonObject rendererObj, String key) {
if (rendererObj == null || !rendererObj.has(key)) return "";
String value = rendererObj.get(key).getAsString();
for (int i = 0; i < 10; i++) {
int start = value.indexOf("${");
if (start < 0) break;
int end = value.indexOf('}', start + 2);
if (end < 0) break;
String placeholder = value.substring(start + 2, end);
if (!rendererObj.has(placeholder)) break;
value = value.substring(0, start) + rendererObj.get(placeholder).getAsString() + value.substring(end + 1);
}
return value;
}
public static Source toLocalSource(Path assetBase, String furniUrl) {
if (furniUrl == null || furniUrl.isBlank()) return null;
String cleanUrl = stripQueryAndFragment(furniUrl);
boolean splitMode = cleanUrl.endsWith("/");
if (!cleanUrl.startsWith("http")) {
Path local = Paths.get(cleanUrl);
return new Source(local, splitMode || Files.isDirectory(local), Status.RESOLVED, "local furnidata.url");
}
if (assetBase == null) return null;
String urlPath;
try {
urlPath = URI.create(cleanUrl).getPath();
} catch (Exception e) {
int scheme = cleanUrl.indexOf("://");
int pathStart = scheme >= 0 ? cleanUrl.indexOf('/', scheme + 3) : -1;
urlPath = pathStart >= 0 ? cleanUrl.substring(pathStart) : cleanUrl;
}
String normalized = urlPath.replace('\\', '/');
String baseName = assetBase.getFileName() != null ? assetBase.getFileName().toString() : "";
String marker = "/" + baseName + "/";
int markerIndex = baseName.isEmpty() ? -1 : normalized.indexOf(marker);
Path candidate;
if (markerIndex >= 0) {
candidate = assetBase.resolve(normalized.substring(markerIndex + marker.length()));
} else if (splitMode) {
String trimmed = normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized;
candidate = assetBase.resolve(trimmed.substring(trimmed.lastIndexOf('/') + 1));
} else {
candidate = assetBase.resolve(normalized.substring(normalized.lastIndexOf('/') + 1));
}
return new Source(candidate, splitMode || Files.isDirectory(candidate), Status.RESOLVED, "renderer-config furnidata.url");
}
private static boolean hasUnresolvedPathPlaceholder(String value) {
if (value == null) return false;
return stripQueryAndFragment(value).contains("${");
}
private static String stripQueryAndFragment(String value) {
String out = value;
int q = out.indexOf('?');
if (q >= 0) out = out.substring(0, q);
int h = out.indexOf('#');
if (h >= 0) out = out.substring(0, h);
return out;
}
}
@@ -5,7 +5,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -44,7 +43,7 @@ public class FurnitureTextProvider {
try {
this.source = resolveSource();
if (this.source == null) {
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved names fall back to public_name");
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved - names fall back to public_name");
return;
}
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
@@ -90,21 +89,11 @@ public class FurnitureTextProvider {
}
private static Path resolveSource() {
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
if (!override.isEmpty()) {
Path p = Paths.get(override);
if (Files.exists(p)) return p;
LOGGER.warn("FurnitureTextProvider: items.furnidata.path '{}' does not exist", override);
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
if (source.ok()) return source.path();
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message());
return null;
}
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
Path dir = Paths.get(basePath);
Path split = dir.resolve("furnidata");
if (Files.isDirectory(split)) return split;
Path legacy = dir.resolve("FurnitureData.json");
return Files.exists(legacy) ? legacy : null;
}
/**
* Build a fresh sanitized index, swap it in atomically, and return the
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.incoming.furnieditor;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.items.FurnidataSourceResolver;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
@@ -9,12 +10,15 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Manages reading and writing of FurnitureData entries.
@@ -43,24 +47,172 @@ public class FurniDataManager {
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
private static volatile CachedIndex cachedIndex = null;
public record LookupResult(String itemJson, String diagnosticJson) {
}
/**
* Get the JSON string for a specific item.
* Returns "{}" if not found or on error.
*/
public static String getItemJson(int itemId) {
try {
ResolvedSource source = resolveSource();
if (source == null) return "{}";
if (source.directory) {
return findItemInSplitDir(source.path, itemId);
return getItemJson(itemId, null);
}
if (!Files.exists(source.path)) return "{}";
/**
* Get the JSON string for a specific item.
* Prefer the DB classname because items_base.id can diverge from the
* furnidata id after imports/reconciliations. Falls back to id lookup.
* Returns "{}" if not found or on error.
*/
public static String getItemJson(int itemId, String classname) {
return getItemLookup(itemId, classname).itemJson();
}
String content = readJson5(source.path);
return findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId);
public static LookupResult getItemLookup(int itemId, String classname) {
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
if (source == null || !source.ok()) {
return new LookupResult("{}", diagnostic(source, itemId, classname, "source_missing"));
}
try {
CachedIndex index = indexFor(source);
String key = baseClassname(classname);
String byClassname = key != null ? index.byClassname.get(key) : null;
if (byClassname != null) {
return new LookupResult(byClassname, diagnostic(source, itemId, classname, "matched_classname"));
}
String byId = index.byId.get(itemId);
if (byId != null) {
return new LookupResult(byId, diagnostic(source, itemId, classname, "matched_id"));
}
String reason = index.empty ? "manifest_empty" : "not_found";
return new LookupResult("{}", diagnostic(source, itemId, classname, reason));
} catch (Exception e) {
LOGGER.warn("Failed to read FurnitureData for item " + itemId, e);
FurnidataSourceResolver.Source errorSource = new FurnidataSourceResolver.Source(source.path(), source.directory(), FurnidataSourceResolver.Status.ERROR, e.getMessage());
return new LookupResult("{}", diagnostic(errorSource, itemId, classname, "error"));
}
}
private static CachedIndex indexFor(FurnidataSourceResolver.Source source) {
long signature = sourceSignature(source.path());
String sourceKey = source.path().toAbsolutePath().normalize().toString();
CachedIndex current = cachedIndex;
if (current != null && current.sourceKey.equals(sourceKey) && current.signature == signature) return current;
CachedIndex next = buildIndex(source, sourceKey, signature);
cachedIndex = next;
return next;
}
private static CachedIndex buildIndex(FurnidataSourceResolver.Source source, String sourceKey, long signature) {
Map<Integer, String> byId = new HashMap<>();
Map<String, String> byClassname = new HashMap<>();
if (source.directory()) {
indexSplitDir(source.path(), byId, byClassname);
} else {
try {
String content = readJson5(source.path());
indexRoot(JsonParser.parseString(content).getAsJsonObject(), byId, byClassname);
} catch (Exception e) {
LOGGER.warn("Failed to parse furnidata source {}", source.path(), e);
}
}
return new CachedIndex(sourceKey, signature, Map.copyOf(byId), Map.copyOf(byClassname), byId.isEmpty() && byClassname.isEmpty());
}
private static void indexSplitDir(Path baseDir, Map<Integer, String> byId, Map<String, String> byClassname) {
if (!Files.isDirectory(baseDir)) return;
for (String tier : readTiersManifest(baseDir)) {
Path tierDir = baseDir.resolve(tier);
if (!Files.isDirectory(tierDir)) continue;
for (String fileName : readFilesManifest(tierDir)) {
Path file = tierDir.resolve(fileName);
if (!Files.exists(file)) continue;
try {
String content = readJson5(file);
indexRoot(JsonParser.parseString(content).getAsJsonObject(), byId, byClassname);
} catch (Exception e) {
LOGGER.warn("Failed to parse split gamedata file " + file, e);
}
}
}
}
private static void indexRoot(JsonObject root, Map<Integer, String> byId, Map<String, String> byClassname) {
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
for (JsonElement el : sectionObj.getAsJsonArray("furnitype")) {
JsonObject obj = el.getAsJsonObject();
String json = obj.toString();
if (obj.has("id")) byId.put(obj.get("id").getAsInt(), json);
if (obj.has("classname")) {
String key = baseClassname(obj.get("classname").getAsString());
if (key != null) byClassname.put(key, json);
}
}
}
}
private static long sourceSignature(Path source) {
try {
if (source == null || !Files.exists(source)) return -1L;
if (!Files.isDirectory(source)) return Files.getLastModifiedTime(source).toMillis() ^ Files.size(source);
final long[] signature = { 17L };
try (var stream = Files.walk(source)) {
stream.filter(Files::isRegularFile).forEach(path -> {
try {
signature[0] = (signature[0] * 31L) ^ Files.getLastModifiedTime(path).toMillis() ^ Files.size(path);
} catch (Exception ignored) {
}
});
}
return signature[0];
} catch (Exception e) {
return System.nanoTime();
}
}
private static String diagnostic(FurnidataSourceResolver.Source source, int itemId, String classname, String reason) {
JsonObject obj = new JsonObject();
obj.addProperty("reason", reason);
obj.addProperty("itemId", itemId);
obj.addProperty("classname", classname != null ? classname : "");
obj.addProperty("sourcePath", source != null && source.path() != null ? source.path().toString() : "");
obj.addProperty("sourceDirectory", source != null && source.directory());
obj.addProperty("sourceStatus", source != null ? source.status().name() : "CONFIG_MISSING");
obj.addProperty("message", source != null && source.message() != null ? source.message() : "");
return obj.toString();
}
private record CachedIndex(String sourceKey, long signature, Map<Integer, String> byId, Map<String, String> byClassname, boolean empty) {
}
static String findItemJson(Path source, boolean directory, int itemId, String classname) {
try {
if (directory) {
return findItemInSplitDir(source, itemId, classname);
}
if (!Files.exists(source)) return "{}";
String content = readJson5(source);
String found = findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId, classname);
return found != null ? found : "{}";
} catch (Exception e) {
LOGGER.warn("Failed to read FurnitureData for item " + itemId, e);
}
@@ -69,6 +221,13 @@ public class FurniDataManager {
}
private static String findItemInRoot(JsonObject root, int itemId) {
return findItemInRoot(root, itemId, null);
}
private static String findItemInRoot(JsonObject root, int itemId, String classname) {
String byClassname = findItemInRootByClassname(root, classname);
if (byClassname != null) return byClassname;
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
@@ -85,11 +244,43 @@ public class FurniDataManager {
return null;
}
private static String findItemInRootByClassname(JsonObject root, String classname) {
String wanted = baseClassname(classname);
if (wanted == null) return null;
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject obj = el.getAsJsonObject();
if (!obj.has("classname")) continue;
String actual = baseClassname(obj.get("classname").getAsString());
if (wanted.equals(actual)) return obj.toString();
}
}
return null;
}
private static String baseClassname(String classname) {
if (classname == null) return null;
int star = classname.indexOf('*');
String base = star >= 0 ? classname.substring(0, star) : classname;
base = base.trim().toLowerCase(java.util.Locale.ROOT);
return base.isEmpty() ? null : base;
}
/**
* Walk the split directory layout looking for an item by id.
* Later tiers (custom, then seasonal) override earlier ones.
*/
private static String findItemInSplitDir(Path baseDir, int itemId) {
private static String findItemInSplitDir(Path baseDir, int itemId, String classname) {
if (!Files.isDirectory(baseDir)) return "{}";
List<String> tiers = readTiersManifest(baseDir);
@@ -107,7 +298,7 @@ public class FurniDataManager {
try {
String content = readJson5(file);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
String match = findItemInRoot(obj, itemId);
String match = findItemInRoot(obj, itemId, classname);
if (match != null) found = match;
} catch (Exception e) {
LOGGER.warn("Failed to parse split gamedata file " + file, e);
@@ -239,7 +430,7 @@ public class FurniDataManager {
* Represents the resolved location of the furnidata source: either a single
* file or a directory in split-layout mode.
*/
private static class ResolvedSource {
static class ResolvedSource {
final Path path;
final boolean directory;
@@ -270,9 +461,9 @@ public class FurniDataManager {
if (!rendererObj.has("furnidata.url")) return null;
String furniUrl = rendererObj.get("furnidata.url").getAsString();
String furniUrl = expandRendererUrl(rendererObj, "furnidata.url");
if (furniUrl.contains("${")) {
if (hasUnresolvedPathPlaceholder(furniUrl)) {
Path fallback = fallbackToBasePath();
return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null;
}
@@ -296,6 +487,9 @@ public class FurniDataManager {
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
ResolvedSource mapped = toLocalSource(Paths.get(basePath), furniUrl);
if (mapped != null) return mapped;
if (splitMode) {
// Derive the directory name from the URL: take the last non-empty
// segment before the trailing slash. e.g. https://x/y/furnidata/ -> "furnidata"
@@ -326,4 +520,86 @@ public class FurniDataManager {
if (Files.exists(legacy)) return legacy;
return null;
}
static String expandRendererUrl(JsonObject rendererObj, String key) {
if (rendererObj == null || !rendererObj.has(key)) return "";
String value = rendererObj.get(key).getAsString();
for (int i = 0; i < 10; i++) {
int start = value.indexOf("${");
if (start < 0) break;
int end = value.indexOf('}', start + 2);
if (end < 0) break;
String placeholder = value.substring(start + 2, end);
if (!rendererObj.has(placeholder)) break;
String replacement = rendererObj.get(placeholder).getAsString();
value = value.substring(0, start) + replacement + value.substring(end + 1);
}
return value;
}
private static boolean hasUnresolvedPathPlaceholder(String value) {
if (value == null) return false;
String pathOnly = stripQueryAndFragment(value);
return pathOnly.contains("${");
}
static ResolvedSource toLocalSource(Path assetBase, String furniUrl) {
if (furniUrl == null || furniUrl.isBlank()) return null;
String cleanUrl = stripQueryAndFragment(furniUrl);
boolean splitMode = cleanUrl.endsWith("/");
if (!cleanUrl.startsWith("http")) {
Path local = Paths.get(cleanUrl);
return new ResolvedSource(local, splitMode || Files.isDirectory(local));
}
if (assetBase == null) return null;
String urlPath;
try {
urlPath = URI.create(cleanUrl).getPath();
} catch (Exception e) {
int scheme = cleanUrl.indexOf("://");
int pathStart = scheme >= 0 ? cleanUrl.indexOf('/', scheme + 3) : -1;
urlPath = pathStart >= 0 ? cleanUrl.substring(pathStart) : cleanUrl;
}
String normalizedUrlPath = urlPath.replace('\\', '/');
String baseName = assetBase.getFileName() != null ? assetBase.getFileName().toString() : "";
String marker = "/" + baseName + "/";
Path candidate;
int markerIndex = baseName.isEmpty() ? -1 : normalizedUrlPath.indexOf(marker);
if (markerIndex >= 0) {
String relative = normalizedUrlPath.substring(markerIndex + marker.length());
candidate = assetBase.resolve(relative);
} else if (splitMode) {
String trimmed = normalizedUrlPath.endsWith("/")
? normalizedUrlPath.substring(0, normalizedUrlPath.length() - 1)
: normalizedUrlPath;
String dirName = trimmed.substring(trimmed.lastIndexOf('/') + 1);
candidate = assetBase.resolve(dirName);
} else {
String filename = normalizedUrlPath.substring(normalizedUrlPath.lastIndexOf('/') + 1);
candidate = assetBase.resolve(filename);
}
return new ResolvedSource(candidate, splitMode || Files.isDirectory(candidate));
}
private static String stripQueryAndFragment(String value) {
String out = value;
int q = out.indexOf('?');
if (q >= 0) out = out.substring(0, q);
int h = out.indexOf('#');
if (h >= 0) out = out.substring(0, h);
return out;
}
}
@@ -41,6 +41,7 @@ public class FurniEditorDetailEvent extends MessageHandler {
int usageCount = 0;
List<Map<String, Object>> catalogItems = new ArrayList<>();
String furniDataJson = "{}";
String furniDataDiagnosticJson = "{}";
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
// Load full item data
@@ -86,11 +87,15 @@ public class FurniEditorDetailEvent extends MessageHandler {
// Try to read furnidata.json entry
try {
furniDataJson = FurniDataManager.getItemJson(itemId);
Object classname = item.get("item_name");
FurniDataManager.LookupResult lookup = FurniDataManager.getItemLookup(itemId, classname != null ? classname.toString() : null);
furniDataJson = lookup.itemJson();
furniDataDiagnosticJson = lookup.diagnosticJson();
} catch (Exception e) {
furniDataJson = "{}";
furniDataDiagnosticJson = "{}";
}
client.sendResponse(new FurniEditorDetailComposer(item, usageCount, catalogItems, furniDataJson));
client.sendResponse(new FurniEditorDetailComposer(item, usageCount, catalogItems, furniDataJson, furniDataDiagnosticJson));
}
}
@@ -12,12 +12,18 @@ public class FurniEditorDetailComposer extends MessageComposer {
private final int usageCount;
private final List<Map<String, Object>> catalogItems;
private final String furniDataJson;
private final String furniDataDiagnosticJson;
public FurniEditorDetailComposer(Map<String, Object> item, int usageCount, List<Map<String, Object>> catalogItems, String furniDataJson) {
this(item, usageCount, catalogItems, furniDataJson, "{}");
}
public FurniEditorDetailComposer(Map<String, Object> item, int usageCount, List<Map<String, Object>> catalogItems, String furniDataJson, String furniDataDiagnosticJson) {
this.item = item;
this.usageCount = usageCount;
this.catalogItems = catalogItems;
this.furniDataJson = furniDataJson;
this.furniDataDiagnosticJson = furniDataDiagnosticJson;
}
@Override
@@ -71,6 +77,7 @@ public class FurniEditorDetailComposer extends MessageComposer {
// furnidata JSON string
this.response.appendString(this.furniDataJson != null ? this.furniDataJson : "{}");
this.response.appendString(this.furniDataDiagnosticJson != null ? this.furniDataDiagnosticJson : "{}");
return this.response;
}
@@ -0,0 +1,81 @@
package com.eu.habbo.messages.incoming.furnieditor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.eu.habbo.habbohotel.items.FurnidataSourceResolver;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class FurniDataManagerTest {
@Test
void findsItemByClassnameBeforeDbId(@TempDir Path dir) throws Exception {
Path file = dir.resolve("FurnitureData.json");
Files.writeString(file, """
{
"roomitemtypes": { "furnitype": [
{ "id": 9999, "classname": "throne", "name": "Throne", "description": "Royal seat" }
]},
"wallitemtypes": { "furnitype": [] }
}
""");
String json = FurniDataManager.findItemJson(file, false, 230, "throne");
assertNotEquals("{}", json);
assertTrue(json.contains("\"classname\":\"throne\""));
assertTrue(json.contains("\"id\":9999"));
}
@Test
void fallsBackToItemIdWhenClassnameIsMissing(@TempDir Path dir) throws Exception {
Path file = dir.resolve("FurnitureData.json");
Files.writeString(file, """
{
"roomitemtypes": { "furnitype": [
{ "id": 230, "classname": "db_only_match", "name": "DB ID Match", "description": "" }
]},
"wallitemtypes": { "furnitype": [] }
}
""");
String json = FurniDataManager.findItemJson(file, false, 230, "missing_classname");
assertNotEquals("{}", json);
assertTrue(json.contains("\"classname\":\"db_only_match\""));
}
@Test
void expandsRendererConfigPlaceholders() {
JsonObject config = JsonParser.parseString("""
{
"gamedata.url": "http://localhost:5173/nitro-assets/gamedata",
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=${timestamp}"
}
""").getAsJsonObject();
String url = FurnidataSourceResolver.expandRendererUrl(config, "furnidata.url");
assertEquals("http://localhost:5173/nitro-assets/gamedata/FurnitureData.json?t=${timestamp}", url);
}
@Test
void mapsRendererUrlRelativeToAssetBase(@TempDir Path dir) {
Path assetBase = dir.resolve("nitro-assets");
FurnidataSourceResolver.Source source = FurnidataSourceResolver.toLocalSource(
assetBase,
"http://localhost:5173/nitro-assets/gamedata/FurnitureData.json?t=123"
);
assertNotNull(source);
assertEquals(assetBase.resolve("gamedata").resolve("FurnitureData.json"), source.path());
assertFalse(source.directory());
}
}