You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 06:56:19 +00:00
Merge pull request #160 from simoleo89/feat/furnidata-source-diagnostics
Resolve furnidata from configured source
This commit is contained in:
@@ -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;
|
||||
@@ -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,20 +89,10 @@ 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);
|
||||
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;
|
||||
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
|
||||
if (source.ok()) return source.path();
|
||||
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message());
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+289
-13
@@ -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 "{}";
|
||||
return getItemJson(itemId, null);
|
||||
}
|
||||
|
||||
if (source.directory) {
|
||||
return findItemInSplitDir(source.path, itemId);
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
if (!Files.exists(source.path)) return "{}";
|
||||
String byId = index.byId.get(itemId);
|
||||
if (byId != null) {
|
||||
return new LookupResult(byId, diagnostic(source, itemId, classname, "matched_id"));
|
||||
}
|
||||
|
||||
String content = readJson5(source.path);
|
||||
return findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+81
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user